from matplotlib.patches import FancyArrowPatch, FancyBboxPatch
nodes = {
"X": {"xy": (0.10, 0.52), "label": "Pre-treatment\ncontrols X", "color": "#dbeafe"},
"D": {"xy": (0.35, 0.76), "label": "Treated\ngroup D", "color": "#dcfce7"},
"T": {"xy": (0.35, 0.28), "label": "Post\nperiod T", "color": "#fef3c7"},
"A": {"xy": (0.62, 0.52), "label": "Actual exposure\nD x T", "color": "#fde68a"},
"Y": {"xy": (0.88, 0.52), "label": "Outcome\nY", "color": "#fee2e2"},
"R": {"xy": (0.62, 0.86), "label": "Untreated trend\nrisk", "color": "#f3f4f6"},
}
fig, ax = plt.subplots(figsize=(12, 6))
ax.set_axis_off()
box_w, box_h = 0.15, 0.11
def anchor(node, side):
"""
Return a border point on a node box so arrows do not start from hidden centers.
Parameters
----------
node : str
Graph node name whose position or edge information is needed.
side : object
Side of a plotted node where an arrow or label should attach.
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_left": (-box_w / 2, box_h * 0.25),
"upper_right": (box_w / 2, box_h * 0.25),
"lower_left": (-box_w / 2, -box_h * 0.25),
"lower_right": (box_w / 2, -box_h * 0.25),
}
dx, dy = offsets[side]
return (x + dx, y + dy)
def draw_arrow(start, end, color, style="solid", rad=0.0, linewidth=1.7):
"""
Draw one visible arrow between already-chosen border points.
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.
color : str
Color used for the plotted element.
style : object
Line or marker style used to distinguish plotted elements.
rad : float
Curvature parameter for the plotted arrow.
linewidth : object
Line width used for the plotted curve, edge, or reference line.
Returns
-------
None
Adds an arrow annotation directly to the supplied Matplotlib axes.
"""
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):
"""
Draw a routed arrow where early segments are lines and only the final segment has an arrowhead.
Parameters
----------
points : list[tuple[float, float]]
Polyline points used to route an arrow.
color : str
Color used for the plotted element.
style : object
Line or marker style used to distinguish plotted elements.
linewidth : object
Line width used for the plotted curve, edge, or reference line.
Returns
-------
None
Adds a routed arrow annotation directly to the supplied Matplotlib axes.
"""
for start, end in zip(points[:-2], points[1:-1]):
ax.plot(
[start[0], end[0]],
[start[1], end[1]],
color=color,
linestyle=style,
linewidth=linewidth,
zorder=5,
)
draw_arrow(points[-2], points[-1], color=color, style=style, linewidth=linewidth)
# Direct arrows that can be drawn clearly between adjacent boxes.
draw_arrow(anchor("X", "upper_right"), anchor("D", "left"), color="#334155", rad=0.08)
draw_arrow(anchor("D", "lower_right"), anchor("A", "upper_left"), color="#15803d")
draw_arrow(anchor("T", "upper_right"), anchor("A", "lower_left"), color="#b45309")
draw_arrow(anchor("A", "right"), anchor("Y", "left"), color="#b45309")
draw_arrow(anchor("R", "lower_right"), anchor("Y", "upper_left"), color="#6b7280", style="dashed", rad=-0.08, linewidth=1.5)
# Routed arrows avoid passing behind the D, A, and Y boxes.
draw_routed_arrow([anchor("X", "top"), (0.24, 0.91), anchor("R", "left")], color="#6b7280", style="dashed", linewidth=1.5)
draw_routed_arrow([anchor("X", "lower_right"), (0.50, 0.41), anchor("Y", "lower_left")], color="#334155")
draw_routed_arrow([anchor("T", "lower_right"), (0.62, 0.21), anchor("Y", "bottom")], color="#334155")
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,
"DID compares before-after changes. DoubleML helps adjust for X, but the trend assumption remains a design assumption.",
ha="center",
va="center",
fontsize=10,
color="#475569",
)
ax.set_title("Difference-In-Differences Teaching Design", pad=18)
plt.tight_layout()
fig.savefig(FIGURE_DIR / f"{NOTEBOOK_PREFIX}_did_design_dag.png", dpi=160, bbox_inches="tight")
plt.show()