pyfector.plotting
Plotting functions for pyfector results.
Produces matplotlib/plotly figures equivalent to R fect's plot types:
- gap: dynamic treatment effects with CI bands
- status: treatment status heatmap
- factors: latent factor time series
- counterfactual: actual vs counterfactual for selected units
- equiv: equivalence test visualization
1""" 2Plotting functions for pyfector results. 3 4Produces matplotlib/plotly figures equivalent to R fect's plot types: 5- gap: dynamic treatment effects with CI bands 6- status: treatment status heatmap 7- factors: latent factor time series 8- counterfactual: actual vs counterfactual for selected units 9- equiv: equivalence test visualization 10""" 11 12from __future__ import annotations 13 14from typing import Literal 15 16import numpy as np 17 18 19def plot( 20 result, 21 kind: Literal["gap", "status", "factors", "counterfactual", "equiv", "calendar"] = "gap", 22 units: list | None = None, 23 show_ci: bool = True, 24 title: str | None = None, 25 figsize: tuple[float, float] = (10, 6), 26 ax=None, 27 **kwargs, 28): 29 """Plot fect results. 30 31 Parameters 32 ---------- 33 result : FectResult 34 Output from ``pyfector.fect()``. 35 kind : str 36 Plot type. 37 units : list, optional 38 Unit IDs for counterfactual plot. 39 show_ci : bool 40 Show confidence intervals (requires ``se=True`` in estimation). 41 """ 42 import matplotlib.pyplot as plt 43 44 if ax is None: 45 fig, ax = plt.subplots(figsize=figsize) 46 else: 47 fig = ax.get_figure() 48 49 if kind == "gap": 50 _plot_gap(result, ax, show_ci, **kwargs) 51 elif kind == "status": 52 _plot_status(result, ax, **kwargs) 53 elif kind == "factors": 54 _plot_factors(result, ax, **kwargs) 55 elif kind == "counterfactual": 56 _plot_counterfactual(result, ax, units, **kwargs) 57 elif kind == "equiv": 58 _plot_equiv(result, ax, **kwargs) 59 elif kind == "calendar": 60 _plot_calendar(result, ax, show_ci, **kwargs) 61 else: 62 raise ValueError(f"Unknown plot kind: {kind}") 63 64 if title: 65 ax.set_title(title) 66 67 fig.tight_layout() 68 return fig 69 70 71def _plot_gap(result, ax, show_ci, count_bar: bool = True, **kwargs): 72 """Dynamic treatment effects (gap) plot.""" 73 time_on = result.time_on 74 att_on = result.att_on 75 76 if time_on is None or att_on is None: 77 ax.text(0.5, 0.5, "No dynamic effects available", ha="center", va="center", 78 transform=ax.transAxes) 79 return 80 81 # Main line 82 ax.plot(time_on, att_on, "o-", color="#2166ac", markersize=4, linewidth=1.5, 83 label="ATT", zorder=3) 84 85 # CI bands 86 if show_ci and result.inference is not None: 87 inf = result.inference 88 ax.fill_between( 89 time_on, inf.att_on_ci_lower, inf.att_on_ci_upper, 90 alpha=0.2, color="#2166ac", label="95% CI", 91 ) 92 93 # Zero line 94 ax.axhline(0, color="gray", linewidth=0.8, linestyle="--", zorder=1) 95 96 # Treatment onset line 97 ax.axvline(0, color="#b2182b", linewidth=1, linestyle=":", alpha=0.7, 98 label="Treatment onset", zorder=2) 99 100 # Count bars at bottom 101 if count_bar and result.count_on is not None: 102 ax2 = ax.twinx() 103 ax2.bar(time_on, result.count_on, alpha=0.15, color="gray", width=0.8) 104 ax2.set_ylabel("Count", color="gray", fontsize=9) 105 ax2.tick_params(axis="y", labelcolor="gray", labelsize=8) 106 max_count = max(result.count_on) if len(result.count_on) > 0 else 1 107 ax2.set_ylim(0, max_count * 4) 108 109 ax.set_xlabel("Relative time to treatment") 110 ax.set_ylabel("Treatment effect") 111 ax.legend(loc="best", fontsize=9) 112 113 114def _plot_status(result, ax, **kwargs): 115 """Treatment status heatmap.""" 116 import matplotlib.colors as mcolors 117 118 panel = result.panel 119 if panel is None: 120 return 121 122 status = panel.obs_status 123 124 # Custom colormap: 1=treated(red), 2=control(blue), 3=missing(gray), 4=removed(white) 125 cmap = mcolors.ListedColormap(["#b2182b", "#2166ac", "#d9d9d9", "#ffffff"]) 126 bounds = [0.5, 1.5, 2.5, 3.5, 4.5] 127 norm = mcolors.BoundaryNorm(bounds, cmap.N) 128 129 # Sort units: controls first, then treated by treatment timing 130 order = np.argsort(panel.unit_type) 131 132 im = ax.imshow( 133 status[:, order].T, aspect="auto", cmap=cmap, norm=norm, 134 interpolation="nearest", 135 ) 136 137 ax.set_xlabel("Time period") 138 ax.set_ylabel("Unit") 139 140 # Legend 141 from matplotlib.patches import Patch 142 legend_elements = [ 143 Patch(facecolor="#b2182b", label="Treated"), 144 Patch(facecolor="#2166ac", label="Control"), 145 Patch(facecolor="#d9d9d9", label="Missing"), 146 ] 147 ax.legend(handles=legend_elements, loc="upper right", fontsize=8) 148 149 150def _plot_factors(result, ax, **kwargs): 151 """Plot estimated latent factors over time.""" 152 if result.factors is None: 153 ax.text(0.5, 0.5, "No factors estimated", ha="center", va="center", 154 transform=ax.transAxes) 155 return 156 157 T, r = result.factors.shape 158 time_ids = result.panel.time_ids if result.panel is not None else np.arange(T) 159 160 for k in range(r): 161 ax.plot(time_ids, result.factors[:, k], label=f"Factor {k+1}", linewidth=1.2) 162 163 ax.set_xlabel("Time") 164 ax.set_ylabel("Factor value") 165 ax.legend(fontsize=9) 166 167 168def _plot_counterfactual(result, ax, units=None, **kwargs): 169 """Actual vs counterfactual outcomes for selected units.""" 170 panel = result.panel 171 if panel is None or result.Y_ct is None: 172 return 173 174 if units is None: 175 # Pick first 3 treated units 176 treated_idx = np.where(panel.unit_type == 2)[0][:3] 177 else: 178 treated_idx = [np.where(panel.unit_ids == u)[0][0] for u in units 179 if u in panel.unit_ids] 180 181 time_ids = panel.time_ids 182 colors = ["#2166ac", "#b2182b", "#4daf4a", "#984ea3", "#ff7f00"] 183 184 for i, j in enumerate(treated_idx): 185 obs_mask = panel.I[:, j] > 0 186 color = colors[i % len(colors)] 187 uid = panel.unit_ids[j] 188 189 ax.plot(time_ids[obs_mask], panel.Y[obs_mask, j], "o-", 190 color=color, markersize=3, linewidth=1, 191 label=f"Unit {uid} (actual)") 192 ax.plot(time_ids, result.Y_ct[:, j], "--", 193 color=color, linewidth=1, alpha=0.7, 194 label=f"Unit {uid} (counterfactual)") 195 196 ax.set_xlabel("Time") 197 ax.set_ylabel("Outcome") 198 ax.legend(fontsize=8) 199 200 201def _plot_equiv(result, ax, threshold: float = 0.36, **kwargs): 202 """Equivalence test visualization.""" 203 if result.att_on is None or result.inference is None: 204 ax.text(0.5, 0.5, "Inference required", ha="center", va="center", 205 transform=ax.transAxes) 206 return 207 208 inf = result.inference 209 # Only pre-treatment periods 210 pre_mask = result.time_on < 0 211 if not np.any(pre_mask): 212 return 213 214 t_pre = result.time_on[pre_mask] 215 att_pre = result.att_on[pre_mask] 216 ci_lo = inf.att_on_ci_lower[pre_mask] 217 ci_hi = inf.att_on_ci_upper[pre_mask] 218 219 ax.errorbar(t_pre, att_pre, yerr=[att_pre - ci_lo, ci_hi - att_pre], 220 fmt="o", color="#2166ac", capsize=3, markersize=4) 221 222 # Equivalence bounds 223 ax.axhspan(-threshold, threshold, alpha=0.1, color="green", 224 label=f"Equiv. bounds (+-{threshold})") 225 ax.axhline(0, color="gray", linewidth=0.8, linestyle="--") 226 227 ax.set_xlabel("Relative time (pre-treatment)") 228 ax.set_ylabel("ATT") 229 ax.legend(fontsize=9) 230 231 232def _plot_calendar(result, ax, show_ci, **kwargs): 233 """Calendar-time ATT plot.""" 234 if result.eff is None or result.panel is None: 235 return 236 237 panel = result.panel 238 D = panel.D 239 eff = result.eff 240 241 # ATT by calendar time 242 time_ids = panel.time_ids 243 cal_att = [] 244 for t in range(panel.T): 245 treated_t = D[t, :] > 0 246 if np.any(treated_t): 247 cal_att.append(float(np.mean(eff[t, treated_t]))) 248 else: 249 cal_att.append(np.nan) 250 251 cal_att = np.array(cal_att) 252 valid = ~np.isnan(cal_att) 253 254 ax.plot(time_ids[valid], cal_att[valid], "o-", color="#2166ac", 255 markersize=3, linewidth=1) 256 ax.axhline(0, color="gray", linewidth=0.8, linestyle="--") 257 ax.set_xlabel("Calendar time") 258 ax.set_ylabel("ATT")
def
plot( result, kind: Literal['gap', 'status', 'factors', 'counterfactual', 'equiv', 'calendar'] = 'gap', units: list | None = None, show_ci: bool = True, title: str | None = None, figsize: tuple[float, float] = (10, 6), ax=None, **kwargs):
20def plot( 21 result, 22 kind: Literal["gap", "status", "factors", "counterfactual", "equiv", "calendar"] = "gap", 23 units: list | None = None, 24 show_ci: bool = True, 25 title: str | None = None, 26 figsize: tuple[float, float] = (10, 6), 27 ax=None, 28 **kwargs, 29): 30 """Plot fect results. 31 32 Parameters 33 ---------- 34 result : FectResult 35 Output from ``pyfector.fect()``. 36 kind : str 37 Plot type. 38 units : list, optional 39 Unit IDs for counterfactual plot. 40 show_ci : bool 41 Show confidence intervals (requires ``se=True`` in estimation). 42 """ 43 import matplotlib.pyplot as plt 44 45 if ax is None: 46 fig, ax = plt.subplots(figsize=figsize) 47 else: 48 fig = ax.get_figure() 49 50 if kind == "gap": 51 _plot_gap(result, ax, show_ci, **kwargs) 52 elif kind == "status": 53 _plot_status(result, ax, **kwargs) 54 elif kind == "factors": 55 _plot_factors(result, ax, **kwargs) 56 elif kind == "counterfactual": 57 _plot_counterfactual(result, ax, units, **kwargs) 58 elif kind == "equiv": 59 _plot_equiv(result, ax, **kwargs) 60 elif kind == "calendar": 61 _plot_calendar(result, ax, show_ci, **kwargs) 62 else: 63 raise ValueError(f"Unknown plot kind: {kind}") 64 65 if title: 66 ax.set_title(title) 67 68 fig.tight_layout() 69 return fig
Plot fect results.
Parameters
- result (FectResult):
Output from
pyfector.fect. - kind (str): Plot type.
- units (list, optional): Unit IDs for counterfactual plot.
- show_ci (bool):
Show confidence intervals (requires
se=Truein estimation).