# Define reusable helpers for the Teaching Diagram section.
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch
nodes = {
"X": {"xy": (0.10, 0.54), "label": "Observed\ncontrols X", "color": "#dbeafe"},
"D": {"xy": (0.34, 0.74), "label": "Treatment\nD", "color": "#dcfce7"},
"S": {"xy": (0.34, 0.30), "label": "Outcome\nobserved S", "color": "#fef3c7"},
"Y": {"xy": (0.64, 0.58), "label": "Outcome\nY", "color": "#fee2e2"},
"Z": {"xy": (0.62, 0.22), "label": "Selection\nencouragement Z", "color": "#e0f2fe"},
"U": {"xy": (0.60, 0.88), "label": "Hidden response\nfactor U", "color": "#f3f4f6"},
"O": {"xy": (0.90, 0.52), "label": "Observed data\nS x Y", "color": "#ede9fe"},
}
fig, ax = plt.subplots(figsize=(12, 6))
ax.set_axis_off()
box_w, box_h = 0.15, 0.11
arrow_gap = 0.018
def anchor(node, side):
"""
Idea: Return the plotting anchor point for a named node in the diagram.
Parameters
----------
node : object
Graph node whose position, label, or incident edges are being processed.
side : object
Endpoint side used to place an arrow or edge on the correct part of a node.
Returns
-------
tuple[float, float] or np.ndarray
Coordinate of the requested side of a plotted node.
"""
x, y = nodes[node]["xy"]
offsets = {
"left": (-box_w / 2, 0),
"right": (box_w / 2, 0),
"top": (0, box_h / 2),
"bottom": (0, -box_h / 2),
"upper_right": (box_w / 2, box_h * 0.25),
"lower_right": (box_w / 2, -box_h * 0.25),
"upper_left": (-box_w / 2, box_h * 0.25),
"lower_left": (-box_w / 2, -box_h * 0.25),
}
dx, dy = offsets[side]
return (x + dx, y + dy)
def shorten_segment(start, end, gap=arrow_gap):
"""
Move arrow endpoints inward so arrowheads do not sit on top of boxes.
Parameters
----------
start : tuple[float, float]
Starting coordinate of the arrow or edge segment.
end : tuple[float, float]
Ending coordinate of the arrow or edge segment.
gap : float
Extra spacing between an edge endpoint and a node boundary.
Returns
-------
tuple
Coordinate pair or coordinate pairs used to place arrows cleanly in the diagram.
"""
start = np.asarray(start, dtype=float)
end = np.asarray(end, dtype=float)
delta = end - start
length = np.hypot(delta[0], delta[1])
if length == 0:
return tuple(start), tuple(end)
unit = delta / length
return tuple(start + gap * unit), tuple(end - gap * unit)
def shorten_polyline(points, gap=arrow_gap):
"""
Shorten only the first and final endpoints of a routed arrow.
Parameters
----------
points : list[tuple[float, float]]
Polyline points used to route an arrow.
gap : float
Extra spacing between an edge endpoint and a node boundary.
Returns
-------
list[tuple[float, float]]
Polyline coordinates shortened so line endpoints do not overlap node boxes.
"""
pts = [tuple(point) for point in points]
if len(pts) < 2:
return pts
pts[0], _ = shorten_segment(pts[0], pts[1], gap=gap)
_, pts[-1] = shorten_segment(pts[-2], pts[-1], gap=gap)
return pts
def draw_arrow(start, end, color, style="solid", rad=0.0, linewidth=1.7, gap=arrow_gap):
"""
Idea: Draw a routed arrow between diagram nodes while keeping the arrowhead visible.
Parameters
----------
start : tuple[float, float]
Starting coordinate for a plotted element.
end : tuple[float, float]
Ending coordinate for a plotted element.
color : str
Plot color used to identify this element.
style : object
Plotting style that determines how the curve, marker, or annotation is drawn.
rad : float
Curvature parameter for the plotted arrow.
linewidth : object
Line width used to make the plotted edge or reference line readable.
gap : float
Spacing used to keep plotted elements from overlapping.
Returns
-------
None
Adds an arrow annotation directly to the supplied Matplotlib axes.
"""
start, end = shorten_segment(start, end, gap=gap)
arrow = FancyArrowPatch(
start,
end,
arrowstyle="-|>",
mutation_scale=18,
linewidth=linewidth,
color=color,
linestyle=style,
shrinkA=0,
shrinkB=0,
connectionstyle=f"arc3,rad={rad}",
zorder=5,
)
ax.add_patch(arrow)
def draw_routed_arrow(points, color, style="solid", linewidth=1.7, gap=arrow_gap):
"""
Idea: Draw an arrow along a routed path so it avoids overlapping nearby nodes.
Parameters
----------
points : object
Coordinates or values used to draw the plotted object.
color : str
Plot color used to identify this element.
style : object
Plotting style that determines how the curve, marker, or annotation is drawn.
linewidth : object
Line width used to make the plotted edge or reference line readable.
gap : float
Spacing used to keep plotted elements from overlapping.
Returns
-------
None
Adds a routed arrow annotation directly to the supplied Matplotlib axes.
"""
pts = shorten_polyline(points, gap=gap)
for start, end in zip(pts[:-2], pts[1:-1]):
ax.plot([start[0], end[0]], [start[1], end[1]], color=color, linestyle=style, linewidth=linewidth, zorder=5)
draw_arrow(pts[-2], pts[-1], color=color, style=style, linewidth=linewidth, gap=0.0)
# Main observed paths.
draw_arrow(anchor("X", "upper_right"), anchor("D", "left"), color="#334155")
draw_arrow(anchor("X", "lower_right"), anchor("S", "left"), color="#334155")
draw_routed_arrow([anchor("X", "right"), (0.38, 0.56), anchor("Y", "left")], color="#334155")
draw_arrow(anchor("D", "right"), anchor("Y", "upper_left"), color="#15803d")
draw_arrow(anchor("D", "bottom"), anchor("S", "top"), color="#b45309")
draw_arrow(anchor("Z", "left"), anchor("S", "lower_right"), color="#0369a1")
draw_arrow(anchor("Y", "right"), anchor("O", "upper_left"), color="#334155")
draw_arrow(anchor("S", "right"), anchor("O", "lower_left"), color="#b45309")
# Dashed paths are separated so the nonignorable-selection risk is visible without crossing boxes.
draw_arrow(anchor("U", "lower_right"), anchor("Y", "top"), color="#6b7280", style="dashed", linewidth=1.5)
draw_routed_arrow([anchor("U", "lower_left"), (0.48, 0.66), anchor("S", "upper_right")], color="#6b7280", style="dashed", linewidth=1.5)
for spec in nodes.values():
x, y = spec["xy"]
rect = FancyBboxPatch(
(x - box_w / 2, y - box_h / 2),
box_w,
box_h,
boxstyle="round,pad=0.018",
facecolor=spec["color"],
edgecolor="#334155",
linewidth=1.2,
zorder=3,
)
ax.add_patch(rect)
ax.text(x, y, spec["label"], ha="center", va="center", fontsize=11, fontweight="bold", zorder=4)
ax.text(
0.50,
0.08,
"SSM estimates a treatment effect when Y is observed only for selected rows. The selection assumptions are part of the design.",
ha="center",
va="center",
fontsize=10,
color="#475569",
)
ax.set_title("Sample Selection Model Teaching Design", pad=18)
plt.tight_layout()
fig.savefig(FIGURE_DIR / f"{NOTEBOOK_PREFIX}_ssm_design_dag.png", dpi=160, bbox_inches="tight")
plt.show()