pyfector.fect
Public entry point for pyfector.
This module exposes fect(), the single function most users call,
and the FectResult dataclass that packages its output.
fect() orchestrates the full pipeline:
pyfector.panel.prepare_panel()converts the input DataFrame to dense(T, N)matrices with treatment timing and unit classification.pyfector.panel.initial_fit()computes the starting fitY0and initial covariate coefficients on the control subsample.- If the user passes a range for
r(IFE) or leaveslam=None(MC),pyfector.cvchooses the hyperparameter by cross-validation. pyfector.estimatorsiterates the EM loop for the chosen method (fe,ife,mc, orcfe).- Treatment effects are computed from
eff = Y - Y_ct; the overall ATT and dynamic ATT by relative event time are derived in_compute_effects()vianumpy.bincount(). Raw missing outcomes remain excluded from effect averages; model imputations are counterfactual predictions, not substitutes for unobserved treated outcomes. - When
se=Truethe requested inference routine (bootstraporjackknife) frompyfector.inferenceis called with a closure that re-runs the EM loop on resampled units.
Example
import pyfector
result = pyfector.fect(
data=df,
Y="outcome", D="treat",
index=("unit", "year"),
X=["gdp", "pop"],
method="ife",
r=(0, 5), # CV over 0..5 factors
se=True,
nboots=200,
device="cpu", # or "gpu"
n_jobs=4,
seed=42,
)
result.summary()
result.plot(kind="gap")
1""" 2Public entry point for pyfector. 3 4This module exposes :func:`fect`, the single function most users call, 5and the :class:`FectResult` dataclass that packages its output. 6 7:func:`fect` orchestrates the full pipeline: 8 91. :func:`pyfector.panel.prepare_panel` converts the input DataFrame to 10 dense ``(T, N)`` matrices with treatment timing and unit 11 classification. 122. :func:`pyfector.panel.initial_fit` computes the starting fit ``Y0`` 13 and initial covariate coefficients on the control subsample. 143. If the user passes a range for ``r`` (IFE) or leaves ``lam=None`` 15 (MC), :mod:`pyfector.cv` chooses the hyperparameter by 16 cross-validation. 174. :mod:`pyfector.estimators` iterates the EM loop for the chosen 18 method (``fe``, ``ife``, ``mc``, or ``cfe``). 195. Treatment effects are computed from ``eff = Y - Y_ct``; the overall 20 ATT and dynamic ATT by relative event time are derived in 21 :func:`_compute_effects` via :func:`numpy.bincount`. Raw missing 22 outcomes remain excluded from effect averages; model imputations are 23 counterfactual predictions, not substitutes for unobserved treated 24 outcomes. 256. When ``se=True`` the requested inference routine 26 (``bootstrap`` or ``jackknife``) from :mod:`pyfector.inference` is 27 called with a closure that re-runs the EM loop on resampled units. 28 29Example 30------- 31```python 32 33 import pyfector 34 35 result = pyfector.fect( 36 data=df, 37 Y="outcome", D="treat", 38 index=("unit", "year"), 39 X=["gdp", "pop"], 40 method="ife", 41 r=(0, 5), # CV over 0..5 factors 42 se=True, 43 nboots=200, 44 device="cpu", # or "gpu" 45 n_jobs=4, 46 seed=42, 47 ) 48 result.summary() 49 result.plot(kind="gap") 50``` 51""" 52 53from __future__ import annotations 54 55from dataclasses import dataclass, field 56from typing import Any, Literal 57import os 58 59import numpy as np 60 61from .backend import set_device, get_backend, to_numpy, to_device, make_rng 62from .panel import PanelData, prepare_panel, initial_fit 63from .estimators import estimate_ife, estimate_mc, estimate_cfe, EstimationResult 64from .cv import cv_ife, cv_mc, CVResult 65from .inference import bootstrap, jackknife, InferenceResult 66from .diagnostics import ( 67 Diagnostics, 68 run_diagnostics as _run_diagnostics, 69 validate_diagnostics_request, 70) 71 72 73@dataclass 74class FectResult: 75 """Container for all fect estimation results.""" 76 # Method info 77 method: str 78 """Estimator used for the point fit.""" 79 r_cv: int | None = None 80 """Selected IFE rank, or the fixed rank supplied by the caller.""" 81 lambda_cv: float | None = None 82 """Selected or supplied MC penalty.""" 83 84 # Point estimates 85 att_avg: float = 0.0 86 """ATT averaged over observed treated outcome cells.""" 87 att_avg_unit: float = 0.0 88 """Mean of unit-specific ATTs over units with observed treated outcomes.""" 89 90 # Dynamic effects 91 att_on: np.ndarray | None = None 92 """Mean counterfactual gap at each relative event time, including pre-treatment periods.""" 93 time_on: np.ndarray | None = None 94 """Relative event-time grid; treatment onset is zero.""" 95 count_on: np.ndarray | None = None 96 """Number of observed ever-treated units contributing at each event time.""" 97 98 # Exit effects (treatment reversal) 99 att_off: np.ndarray | None = None 100 """Post-reversal event-study effects when populated.""" 101 time_off: np.ndarray | None = None 102 """Post-reversal event-time grid when populated.""" 103 104 # Coefficients 105 beta: np.ndarray | None = None 106 """Estimated covariate coefficients.""" 107 covariate_names: list[str] = field(default_factory=list) 108 """Column names corresponding to beta.""" 109 110 # Fixed effects 111 mu: float = 0.0 112 """Estimated intercept.""" 113 alpha: np.ndarray | None = None # unit FE 114 """Estimated unit fixed effects.""" 115 xi: np.ndarray | None = None # time FE 116 """Estimated time fixed effects.""" 117 factors: np.ndarray | None = None 118 """Estimated latent time factors.""" 119 loadings: np.ndarray | None = None 120 """Estimated unit loadings on the latent factors.""" 121 122 # Counterfactual and effects matrices 123 Y_ct: np.ndarray | None = None # T×N counterfactual 124 """Fitted untreated counterfactual outcomes, shaped (T, N).""" 125 eff: np.ndarray | None = None # T×N treatment effects 126 """Outcome minus fitted counterfactual, shaped (T, N).""" 127 residuals: np.ndarray | None = None 128 """Masked residuals on the untreated fitting sample.""" 129 130 # Model fit 131 sigma2: float = 0.0 132 """Chosen estimator residual variance.""" 133 sigma2_fect: float = 0.0 # additive-FE baseline residual variance 134 """Additive fixed-effect baseline residual variance used for the TOST bound.""" 135 IC: float = 0.0 136 """Estimator information criterion.""" 137 PC: float = 0.0 138 """Estimator panel criterion.""" 139 rmse: float = 0.0 140 """Reserved field; defaults to 0.0.""" 141 niter: int = 0 142 """Number of EM iterations.""" 143 converged: bool = False 144 """Whether the estimator reached its convergence tolerance.""" 145 146 # Inference 147 inference: InferenceResult | None = None 148 """Bootstrap or jackknife inference results; see pyfector.inference.InferenceResult.""" 149 150 # Diagnostics (populated when fect(..., diagnostics="full" | list)) 151 diagnostics: Diagnostics | None = None 152 """Requested diagnostic tests and their resolved options.""" 153 154 # CV 155 cv_result: CVResult | None = None 156 """Cross-validation candidates and scores; see pyfector.cv.CVResult.""" 157 158 # Panel metadata 159 panel: PanelData | None = None 160 """Filtered panel matrices and unit metadata; see pyfector.panel.PanelData.""" 161 fit_options: dict[str, Any] = field(default_factory=dict) 162 """Fit settings used by diagnostic refits.""" 163 164 # Reproducibility 165 seed: int | None = None 166 """Seed used for cross-validation and bootstrap draws.""" 167 168 def summary(self) -> str: 169 """Print summary table of results.""" 170 lines = [] 171 lines.append(f"pyfector estimation results") 172 lines.append(f"{'='*60}") 173 lines.append(f"Method: {self.method}") 174 if self.r_cv is not None: 175 lines.append(f"Number of factors (CV): {self.r_cv}") 176 if self.lambda_cv is not None: 177 lines.append(f"Lambda (CV): {self.lambda_cv:.6f}") 178 lines.append(f"Converged: {self.converged} (iter={self.niter})") 179 lines.append(f"Sigma^2: {self.sigma2:.6f}") 180 lines.append(f"Sigma^2_fect (FE baseline): {self.sigma2_fect:.6f}") 181 lines.append(f"") 182 lines.append(f"ATT (average): {self.att_avg:.6f}") 183 if self.inference is not None: 184 inf = self.inference 185 lines.append(f" SE: {inf.att_avg_se:.6f}") 186 lines.append(f" CI: [{inf.att_avg_ci[0]:.6f}, {inf.att_avg_ci[1]:.6f}]") 187 lines.append(f" p-val: {inf.att_avg_pval:.4f}") 188 189 if self.beta is not None and len(self.beta) > 0: 190 lines.append(f"") 191 lines.append(f"Coefficients:") 192 for i, name in enumerate(self.covariate_names): 193 lines.append(f" {name}: {self.beta[i]:.6f}") 194 195 if self.att_on is not None and self.time_on is not None: 196 lines.append(f"") 197 lines.append(f"Dynamic effects (ATT by relative time):") 198 lines.append(f" {'Time':>6s} {'ATT':>10s} {'Count':>6s}", ) 199 for i, t in enumerate(self.time_on): 200 count = self.count_on[i] if self.count_on is not None else "" 201 att = self.att_on[i] 202 if self.inference is not None: 203 se = self.inference.att_on_se[i] 204 lines.append(f" {t:>6.0f} {att:>10.4f} ({se:.4f}) {count}") 205 else: 206 lines.append(f" {t:>6.0f} {att:>10.4f} {count}") 207 208 lines.append(f"{'='*60}") 209 if self.panel is not None: 210 lines.append(f"N={self.panel.N}, T={self.panel.T}") 211 if self.seed is not None: 212 lines.append(f"Seed: {self.seed}") 213 if self.diagnostics is not None: 214 lines.append("") 215 lines.append(self.diagnostics.summary()) 216 return "\n".join(lines) 217 218 def __repr__(self): 219 return self.summary() 220 221 def plot(self, kind="gap", **kwargs): 222 """Plot results. Shortcut for ``pyfector.plot(self, kind, ...)``.""" 223 from .plotting import plot as _plot 224 return _plot(self, kind=kind, **kwargs) 225 226 def diagnose(self, **kwargs): 227 """Run diagnostic tests. Shortcut for ``pyfector.run_diagnostics(self, ...)``.""" 228 from .diagnostics import run_diagnostics 229 return run_diagnostics(self, **kwargs) 230 231 232def fect( 233 data, 234 Y: str, 235 D: str, 236 index: tuple[str, str], 237 X: list[str] | None = None, 238 W: str | None = None, 239 group: str | None = None, 240 method: Literal["fe", "ife", "mc", "cfe", "both"] = "ife", 241 force: Literal["none", "unit", "time", "two-way"] = "two-way", 242 r: int | tuple[int, int] = 0, 243 lam: float | None = None, 244 nlambda: int = 10, 245 lambda_candidates: list[float] | np.ndarray | None = None, 246 CV: bool = True, 247 k: int = 10, 248 cv_prop: float = 0.1, 249 cv_nobs: int = 3, 250 cv_treat: bool = True, 251 cv_donut: int = 0, 252 criterion: str = "mspe", 253 cv_rule: Literal["min", "onepct"] = "min", 254 se: bool = False, 255 vartype: Literal["bootstrap", "jackknife"] = "bootstrap", 256 nboots: int = 200, 257 alpha: float = 0.05, 258 tol: float = 1e-7, 259 max_iter: int = 5000, 260 min_T0: int = 1, 261 min_T0_strict: bool = False, 262 max_missing: float = 1.0, 263 normalize: bool = False, 264 # CFE-specific 265 Z: list[str] | None = None, 266 Q: list[str] | None = None, 267 # Performance 268 device: Literal["cpu", "gpu"] = "cpu", 269 n_jobs: int | None = -1, 270 seed: int | None = None, 271 # Diagnostics (optional; run at fit time and attached to result) 272 diagnostics: Literal["none", "full"] | list[str] = "none", 273 diagnostics_options: dict | None = None, 274 *, 275 cluster: str | None = None, 276) -> FectResult: 277 """Estimate counterfactual treatment effects for panel data. 278 279 This is the main Python entry point for the counterfactual estimator 280 workflow. Where the paper and the historical R package differ, 281 pyfector defaults to the paper's statistical definition and exposes 282 R-package-style behavior through explicit options. 283 284 Missing outcome policy 285 ---------------------- 286 pyfector distinguishes raw missing outcomes from counterfactual 287 missingness caused by treatment. Observed untreated cells 288 (``D == 0`` and non-missing ``Y``) fit the response surface. Observed 289 treated cells (``D == 1`` and non-missing ``Y``) contribute to ATT as 290 ``Y - Y_ct``. If a treated outcome is missing in the input data, the 291 model can still produce a counterfactual ``Y_ct`` for that cell, but 292 the cell is not counted in ``att_avg`` or ``att_on`` because the 293 treated potential outcome was not observed. 294 295 By default, ``min_T0`` is enforced only for treated and reversal 296 units. Sparse controls are retained if they have at least one 297 observed outcome, because they may still inform the low-rank response 298 surface. Set ``min_T0_strict=True`` to require controls to satisfy 299 ``min_T0`` too, matching the more conservative R fect sparse-panel 300 behavior. 301 302 Parameters 303 ---------- 304 data : polars.DataFrame, pandas.DataFrame 305 Long-format panel data. 306 Y, D : str 307 Column names for outcome and binary treatment indicator. 308 index : (str, str) 309 Column names for (unit_id, time_period). 310 X : list of str, optional 311 Time-varying covariates. 312 W : str, optional 313 Observation weight column. 314 group : str, optional 315 Reserved for grouped estimation. Currently raises 316 ``NotImplementedError`` when supplied. 317 method : {"fe", "ife", "mc", "cfe", "both"} 318 Estimation method. 319 force : {"none", "unit", "time", "two-way"} 320 Fixed effects specification. 321 r : int or (int, int) 322 Number of factors. If tuple, CV selects from range. 323 lam : float, optional 324 Nuclear norm penalty for MC. If None with CV=True, auto-selected. 325 nlambda : int 326 Number of automatically generated lambda candidates for MC CV. 327 lambda_candidates : array-like, optional 328 Explicit non-negative lambda candidates for MC CV. When supplied, 329 ``nlambda`` is ignored. 330 CV : bool 331 If True, cross-validate over ``r`` for IFE when ``r`` is a tuple, 332 or over ``lam`` for MC when ``lam`` is None. 333 k : int 334 Number of CV folds. 335 cv_prop : float 336 Fraction of eligible observed control cells masked per CV fold. 337 cv_nobs : int 338 Number of consecutive within-unit observations to mask as a block. 339 cv_treat : bool 340 If True, restrict CV masks to pre-treatment cells of ever-treated 341 units. If False, use all observed control cells. 342 cv_donut : int 343 Exclude this many periods around treatment onset from CV evaluation. 344 criterion : {"mspe", "gmspe", "mad"} 345 Cross-validation loss. 346 cv_rule : {"min", "onepct"} 347 CV selection rule. ``"min"`` chooses the strict minimum-score 348 candidate and is the paper-faithful default. ``"onepct"`` chooses 349 the simplest candidate within 1% of the best score (lower ``r`` for 350 IFE, higher ``lam`` for MC). 351 se : bool 352 Compute standard errors via bootstrap/jackknife. 353 vartype : {"bootstrap", "jackknife"} 354 Inference method when ``se=True``. 355 cluster : str, optional 356 Column of cluster labels, constant within each unit. Bootstrap 357 inference resamples whole clusters in both the main fit and the 358 placebo refit. Requires at least two retained clusters. 359 nboots : int 360 Number of bootstrap replications, at least 2. Ignored for jackknife. 361 alpha : float 362 Significance level for confidence intervals and tests. 363 tol : float 364 EM convergence tolerance for final point estimation. 365 max_iter : int 366 Maximum EM iterations. 367 min_T0 : int 368 Minimum untreated/pre-treatment observed periods. By default this is 369 enforced only for treated and treatment-reversal units. 370 min_T0_strict : bool 371 If True, enforce ``min_T0`` on all units, including controls. This 372 matches R fect's conservative handling of sparse control rows. 373 max_missing : float 374 Maximum missing-outcome fraction per unit, in ``[0, 1]``. Units with 375 no observed outcomes are always dropped, regardless of this threshold, 376 because they provide neither fitting information nor observed treated 377 effects. 378 normalize : bool 379 If True, estimate on an outcome standardized by its observed standard 380 deviation, then transform effects back to the original scale. 381 Z, Q : list of str, optional 382 Reserved CFE interaction arguments. Currently raise 383 ``NotImplementedError`` when supplied. 384 device : {"cpu", "gpu"} 385 Compute device. 386 n_jobs : int, optional 387 Parallel workers for CV and bootstrap. ``-1`` or ``None`` uses 388 all available CPUs. 389 seed : int, optional 390 Random seed for full reproducibility. 391 392 Returns 393 ------- 394 FectResult 395 Treatment effects, fitted counterfactuals, and requested inference 396 and diagnostics. 397 """ 398 # Set device 399 set_device(device) 400 xp = get_backend() 401 n_jobs = _resolve_n_jobs(n_jobs) 402 if device == "gpu": 403 n_jobs = 1 404 405 if group is not None: 406 raise NotImplementedError("The `group` argument is not implemented yet.") 407 if Z is not None or Q is not None: 408 raise NotImplementedError("The `Z` and `Q` CFE interaction arguments are not implemented yet.") 409 if criterion not in {"mspe", "gmspe", "mad"}: 410 raise ValueError("criterion must be 'mspe', 'gmspe', or 'mad'") 411 if cv_rule not in {"min", "onepct"}: 412 raise ValueError("cv_rule must be 'min' or 'onepct'") 413 if min_T0 < 0: 414 raise ValueError("min_T0 must be non-negative") 415 if cluster is not None and vartype != "bootstrap": 416 raise ValueError("cluster is supported only with vartype='bootstrap'") 417 if se and vartype == "bootstrap" and ( 418 isinstance(nboots, (bool, np.bool_)) 419 or not isinstance(nboots, (int, np.integer)) or nboots < 2 420 ): 421 raise ValueError("nboots must be an integer of at least 2") 422 if not 0.0 <= max_missing <= 1.0: 423 raise ValueError("max_missing must be between 0 and 1") 424 425 # Validate diagnostics request before doing any expensive estimation 426 # so users don't wait for a 30-min MC fit only to find their config 427 # is wrong. 428 requested_diag = validate_diagnostics_request( 429 diagnostics, diagnostics_options, se, 430 ) 431 432 # Map force string to int 433 force_map = {"none": 0, "unit": 1, "time": 2, "two-way": 3} 434 force_int = force_map[force] 435 436 # Prepare panel data 437 panel = prepare_panel( 438 data, Y=Y, D=D, index=index, X=X, W=W, 439 group=group, min_T0=min_T0, min_T0_strict=min_T0_strict, 440 max_missing=max_missing, 441 cluster=cluster, 442 ) 443 444 if not np.any((panel.D > 0) & (panel.I > 0)): 445 raise ValueError( 446 "No observed treated units remain after filtering. " 447 "Check treatment, min_T0, and missing-outcome filters." 448 ) 449 if se and panel.cluster is not None and len(np.unique(panel.cluster)) < 2: 450 raise ValueError("cluster bootstrap requires at least two clusters after filtering") 451 452 # Move to device 453 Y_mat = to_device(panel.Y) 454 D_mat = to_device(panel.D) 455 I_mat = to_device(panel.I) 456 II_mat = to_device(panel.II) 457 X_mat = to_device(panel.X) if panel.X is not None else None 458 W_mat = to_device(panel.W) if panel.W is not None else None 459 460 # Normalize 461 norm_factor = 1.0 462 if normalize: 463 sd_y = float(xp.std(Y_mat[I_mat > 0])) 464 if sd_y > 0: 465 Y_mat = Y_mat / sd_y 466 norm_factor = sd_y 467 468 # Initial fit 469 Y0, beta0 = initial_fit(Y_mat, X_mat, II_mat, force_int) 470 471 # Determine r and lambda 472 r_cv = None 473 lambda_cv = None 474 cv_result = None 475 476 if method == "ife": 477 if isinstance(r, tuple) and CV: 478 cv_result = cv_ife( 479 Y_mat, Y0, X_mat, I_mat, II_mat, D_mat, W_mat, beta0, 480 force=force_int, r_range=r, k=k, cv_prop=cv_prop, 481 cv_nobs=cv_nobs, cv_treat=cv_treat, cv_donut=cv_donut, 482 criterion=criterion, cv_rule=cv_rule, 483 tol=tol, max_iter=max_iter, 484 n_jobs=n_jobs, seed=seed, 485 ) 486 r_cv = cv_result.best_r 487 else: 488 r_cv = r if isinstance(r, int) else r[0] 489 490 elif method == "mc": 491 if lam is None and CV: 492 cv_result = cv_mc( 493 Y_mat, Y0, X_mat, I_mat, II_mat, D_mat, W_mat, beta0, 494 force=force_int, lambda_candidates=lambda_candidates, 495 nlambda=nlambda, k=k, cv_prop=cv_prop, 496 cv_nobs=cv_nobs, cv_treat=cv_treat, cv_donut=cv_donut, 497 criterion=criterion, cv_rule=cv_rule, 498 tol=tol, max_iter=max_iter, 499 n_jobs=n_jobs, seed=seed, 500 ) 501 lambda_cv = cv_result.best_lambda 502 else: 503 lambda_cv = lam if lam is not None else 0.0 504 505 elif method == "fe": 506 r_cv = 0 507 508 elif method == "cfe": 509 r_cv = r if isinstance(r, int) else r[0] 510 511 # Point estimation 512 if method in ("fe", "ife"): 513 est = estimate_ife( 514 Y_mat, Y0, X_mat, II_mat, W_mat, beta0, 515 r=r_cv, force=force_int, tol=tol, max_iter=max_iter, 516 ) 517 elif method == "mc": 518 est = estimate_mc( 519 Y_mat, Y0, X_mat, II_mat, W_mat, beta0, 520 lam=lambda_cv, force=force_int, tol=tol, max_iter=max_iter, 521 ) 522 elif method == "cfe": 523 est = estimate_cfe( 524 Y_mat, Y0, X_mat, II_mat, W_mat, beta0, 525 r=r_cv, force=force_int, tol=tol, max_iter=max_iter, 526 ) 527 elif method == "both": 528 # Run both IFE and MC, return IFE results with MC comparison 529 if isinstance(r, tuple) and CV: 530 cv_result = cv_ife( 531 Y_mat, Y0, X_mat, I_mat, II_mat, D_mat, W_mat, beta0, 532 force=force_int, r_range=r, k=k, cv_prop=cv_prop, 533 cv_nobs=cv_nobs, cv_treat=cv_treat, cv_donut=cv_donut, 534 criterion=criterion, cv_rule=cv_rule, 535 tol=tol, max_iter=max_iter, 536 n_jobs=n_jobs, seed=seed, 537 ) 538 r_cv = cv_result.best_r 539 else: 540 r_cv = r if isinstance(r, int) else r[0] 541 est = estimate_ife( 542 Y_mat, Y0, X_mat, II_mat, W_mat, beta0, 543 r=r_cv, force=force_int, tol=tol, max_iter=max_iter, 544 ) 545 else: 546 raise ValueError(f"Unknown method: {method}") 547 548 # Compute effects 549 eff = Y_mat - est.fit 550 Y_ct = est.fit 551 552 # Additive-FE baseline residual variance (Liu et al. 2024 sigma2.fect). 553 # For method="fe" the main estimator IS the additive-FE pass, so reuse 554 # est.sigma2. For ife/mc/cfe/both, run an extra r=0 IFE pass on the 555 # same panel with the user's requested FE structure. 556 if method == "fe": 557 sigma2_fect_value = float(est.sigma2) 558 else: 559 est_fect = estimate_ife( 560 Y_mat, Y0, X_mat, II_mat, W_mat, beta0, 561 r=0, force=force_int, tol=tol, max_iter=max_iter, 562 ) 563 sigma2_fect_value = float(est_fect.sigma2) 564 565 # Denormalize 566 if normalize and norm_factor != 1.0: 567 eff = eff * norm_factor 568 Y_ct = Y_ct * norm_factor 569 Y_mat = Y_mat * norm_factor 570 if est.beta is not None: 571 est = est._replace(beta=est.beta * norm_factor) 572 sigma2_fect_value *= norm_factor ** 2 573 574 # ATT computation 575 T_on = to_device(panel.T_on) 576 att_avg, att_on, time_on, count_on, att_avg_unit = _compute_effects( 577 to_numpy(eff), to_numpy(D_mat), to_numpy(panel.T_on), to_numpy(I_mat), 578 ) 579 580 # Build result 581 result = FectResult( 582 method=method, 583 r_cv=r_cv, 584 lambda_cv=lambda_cv, 585 att_avg=att_avg, 586 att_avg_unit=att_avg_unit, 587 att_on=att_on, 588 time_on=time_on, 589 count_on=count_on, 590 beta=to_numpy(est.beta) if est.beta is not None else None, 591 covariate_names=panel.covariate_names, 592 mu=est.mu, 593 alpha=to_numpy(est.alpha) if est.alpha is not None else None, 594 xi=to_numpy(est.xi) if est.xi is not None else None, 595 factors=to_numpy(est.factors) if est.factors is not None else None, 596 loadings=to_numpy(est.loadings) if est.loadings is not None else None, 597 Y_ct=to_numpy(Y_ct), 598 eff=to_numpy(eff), 599 residuals=to_numpy(est.residuals), 600 sigma2=est.sigma2, 601 sigma2_fect=sigma2_fect_value, 602 IC=est.IC, 603 PC=est.PC, 604 niter=est.niter, 605 converged=est.converged, 606 cv_result=cv_result, 607 panel=panel, 608 fit_options={ 609 "force": force, 610 "force_int": force_int, 611 "tol": tol, 612 "max_iter": max_iter, 613 "normalize": normalize, 614 "norm_factor": norm_factor, 615 "vartype": vartype, 616 "nboots": nboots, 617 "n_jobs": n_jobs, 618 "min_T0": min_T0, 619 "min_T0_strict": min_T0_strict, 620 "cluster": cluster, 621 }, 622 seed=seed, 623 ) 624 625 # Inference 626 if se: 627 result.inference = _run_inference( 628 result, panel, Y_mat, X_mat, W_mat, beta0, Y0, 629 method=method, r_cv=r_cv, lambda_cv=lambda_cv, 630 force_int=force_int, tol=tol, max_iter=max_iter, 631 vartype=vartype, nboots=nboots, alpha=alpha, 632 n_jobs=n_jobs, seed=seed, normalize=normalize, 633 norm_factor=norm_factor, 634 ) 635 636 # Run requested diagnostics at fit time. requested_diag is None when 637 # diagnostics="none". Validation already enforced se=True and 638 # required-config presence. 639 if requested_diag is not None: 640 opts = dict(diagnostics_options or {}) 641 if "loo" in requested_diag: 642 opts["loo"] = True 643 else: 644 opts.setdefault("loo", False) 645 result.diagnostics = _run_diagnostics( 646 result, _requested=requested_diag, 647 _strict=isinstance(diagnostics, list), **opts, 648 ) 649 650 return result 651 652 653def _compute_effects(eff, D, T_on, I): 654 """Compute overall ATT, per-unit ATT, and dynamic ATT by event time. 655 656 ATT averages are defined only over observed outcome cells. A missing 657 raw treated outcome has no observed ``Y(1)``, so it is excluded even 658 though the estimator may have produced a counterfactual ``Y_ct`` for 659 that matrix position. 660 661 Dynamic ATT grouping is done with :func:`numpy.bincount` so the cost 662 is O(n_observed_ever_treated_cells) regardless of the number of 663 distinct relative-time values. Per-unit ATT uses a column-wise 664 masked mean via ``np.add.reduce`` rather than a Python loop. 665 """ 666 treated = (D > 0) & (I > 0) 667 n_treated = int(np.sum(treated)) 668 669 att_avg = float(np.sum(eff[treated]) / max(n_treated, 1)) 670 671 # Per-unit ATT (post-treatment only) — sum then divide by count, 672 # keeping only units that have at least one treated observation. 673 col_counts = treated.sum(axis=0) # (N,) 674 col_sums = (eff * treated).sum(axis=0) # (N,) 675 has_any = col_counts > 0 676 if np.any(has_any): 677 unit_atts = col_sums[has_any] / col_counts[has_any] 678 att_avg_unit = float(unit_atts.mean()) 679 else: 680 att_avg_unit = 0.0 681 682 # Dynamic ATT by relative event time. Include pre-treatment periods 683 # for ever-treated units (counterfactual gaps before onset). 684 ever_treated = np.any(D > 0, axis=0) # (N,) 685 all_periods = (I > 0) & ever_treated[np.newaxis, :] # (T, N) 686 687 T_on_flat = T_on[all_periods] 688 eff_flat = eff[all_periods] 689 valid = ~np.isnan(T_on_flat) 690 T_on_flat = T_on_flat[valid].astype(np.int64) 691 eff_flat = eff_flat[valid] 692 693 if T_on_flat.size == 0: 694 return att_avg, np.array([]), np.array([]), np.array([], dtype=np.int64), att_avg_unit 695 696 # Bincount over the shifted integer indices. 697 offset = int(T_on_flat.min()) 698 idx = T_on_flat - offset 699 minlength = int(T_on_flat.max() - offset + 1) 700 sums = np.bincount(idx, weights=eff_flat, minlength=minlength) 701 counts = np.bincount(idx, minlength=minlength) 702 keep = counts > 0 703 time_on = (offset + np.arange(minlength))[keep].astype(np.float64) 704 att_on = (sums[keep] / counts[keep]).astype(np.float64) 705 count_on = counts[keep].astype(np.int64) 706 707 return att_avg, att_on, time_on, count_on, att_avg_unit 708 709 710def _run_inference( 711 result, panel, Y_mat, X_mat, W_mat, beta0, Y0, 712 method, r_cv, lambda_cv, force_int, tol, max_iter, 713 vartype, nboots, alpha, n_jobs, seed, normalize, norm_factor, 714): 715 """Run bootstrap or jackknife inference. 716 717 Bootstrap replicates use a relaxed convergence tolerance 718 (``max(tol, 1e-3)``) because bootstrap SEs converge to 3–4 719 significant digits regardless of inner precision. The full-sample 720 fit is used as the warm-start initialiser for each replicate, 721 which cuts EM iterations by ~30-60 %. 722 """ 723 xp = get_backend() 724 725 # Relaxed tolerance: bootstrap SEs don't benefit from tight inner tol. 726 boot_tol = max(tol, 1e-3) 727 728 # Pre-move panel data to device once (avoids CPU→GPU copy per bootstrap rep) 729 II_dev = to_device(panel.II) 730 D_dev = to_device(panel.D) 731 I_dev = to_device(panel.I) 732 T_on_np = panel.T_on # keep on CPU for ATT computation 733 734 # Warm-start: use full-sample fitted values as initial imputation 735 # for each replicate instead of recomputing initial_fit from scratch. 736 Y0_full = to_device(result.Y_ct) 737 738 def _estimate_fn(unit_idx): 739 """Re-estimate on a subset of units.""" 740 Y_sub = Y_mat[:, unit_idx] 741 II_sub = II_dev[:, unit_idx] 742 D_sub = D_dev[:, unit_idx] 743 I_sub = I_dev[:, unit_idx] 744 X_sub = X_mat[:, unit_idx, :] if X_mat is not None else None 745 W_sub = W_mat[:, unit_idx] if W_mat is not None else None 746 T_on_sub = T_on_np[:, unit_idx] 747 beta0_sub = beta0 748 749 # Warm-start from full-sample fit (columns for resampled units). 750 # This is much closer to the replicate's solution than a cold 751 # initial_fit, cutting EM iterations significantly. 752 Y0_sub = Y0_full[:, unit_idx].copy() 753 754 if method in ("fe", "ife"): 755 est = estimate_ife( 756 Y_sub, Y0_sub, X_sub, II_sub, W_sub, beta0_sub, 757 r=r_cv, force=force_int, tol=boot_tol, max_iter=max_iter, 758 ) 759 elif method == "mc": 760 est = estimate_mc( 761 Y_sub, Y0_sub, X_sub, II_sub, W_sub, beta0_sub, 762 lam=lambda_cv, force=force_int, tol=boot_tol, max_iter=max_iter, 763 ) 764 else: 765 est = estimate_ife( 766 Y_sub, Y0_sub, X_sub, II_sub, W_sub, beta0_sub, 767 r=r_cv or 0, force=force_int, tol=boot_tol, max_iter=max_iter, 768 ) 769 770 eff = to_numpy(Y_sub - est.fit) 771 if normalize and norm_factor != 1.0: 772 eff = eff * norm_factor 773 774 return eff, to_numpy(D_sub), T_on_sub, to_numpy(I_sub) 775 776 if vartype == "bootstrap": 777 return bootstrap( 778 _estimate_fn, to_numpy(Y_mat), to_numpy(panel.D), 779 to_numpy(panel.I), panel.T_on, panel.unit_type, 780 nboots=nboots, alpha=alpha, n_jobs=n_jobs, seed=seed, 781 cluster=panel.cluster, 782 point_estimate=(result.eff, panel.D, panel.T_on, panel.I), 783 ) 784 else: 785 return jackknife( 786 _estimate_fn, to_numpy(Y_mat), to_numpy(panel.D), 787 to_numpy(panel.I), panel.T_on, panel.unit_type, 788 alpha=alpha, n_jobs=n_jobs, 789 ) 790 791 792def _resolve_n_jobs(n_jobs: int | None) -> int: 793 """Normalize public n_jobs values before passing them to joblib.""" 794 if n_jobs is None or n_jobs == -1: 795 return os.cpu_count() or 1 796 if n_jobs == 0 or n_jobs < -1: 797 raise ValueError("n_jobs must be a positive integer, -1, or None") 798 return int(n_jobs)
74@dataclass 75class FectResult: 76 """Container for all fect estimation results.""" 77 # Method info 78 method: str 79 """Estimator used for the point fit.""" 80 r_cv: int | None = None 81 """Selected IFE rank, or the fixed rank supplied by the caller.""" 82 lambda_cv: float | None = None 83 """Selected or supplied MC penalty.""" 84 85 # Point estimates 86 att_avg: float = 0.0 87 """ATT averaged over observed treated outcome cells.""" 88 att_avg_unit: float = 0.0 89 """Mean of unit-specific ATTs over units with observed treated outcomes.""" 90 91 # Dynamic effects 92 att_on: np.ndarray | None = None 93 """Mean counterfactual gap at each relative event time, including pre-treatment periods.""" 94 time_on: np.ndarray | None = None 95 """Relative event-time grid; treatment onset is zero.""" 96 count_on: np.ndarray | None = None 97 """Number of observed ever-treated units contributing at each event time.""" 98 99 # Exit effects (treatment reversal) 100 att_off: np.ndarray | None = None 101 """Post-reversal event-study effects when populated.""" 102 time_off: np.ndarray | None = None 103 """Post-reversal event-time grid when populated.""" 104 105 # Coefficients 106 beta: np.ndarray | None = None 107 """Estimated covariate coefficients.""" 108 covariate_names: list[str] = field(default_factory=list) 109 """Column names corresponding to beta.""" 110 111 # Fixed effects 112 mu: float = 0.0 113 """Estimated intercept.""" 114 alpha: np.ndarray | None = None # unit FE 115 """Estimated unit fixed effects.""" 116 xi: np.ndarray | None = None # time FE 117 """Estimated time fixed effects.""" 118 factors: np.ndarray | None = None 119 """Estimated latent time factors.""" 120 loadings: np.ndarray | None = None 121 """Estimated unit loadings on the latent factors.""" 122 123 # Counterfactual and effects matrices 124 Y_ct: np.ndarray | None = None # T×N counterfactual 125 """Fitted untreated counterfactual outcomes, shaped (T, N).""" 126 eff: np.ndarray | None = None # T×N treatment effects 127 """Outcome minus fitted counterfactual, shaped (T, N).""" 128 residuals: np.ndarray | None = None 129 """Masked residuals on the untreated fitting sample.""" 130 131 # Model fit 132 sigma2: float = 0.0 133 """Chosen estimator residual variance.""" 134 sigma2_fect: float = 0.0 # additive-FE baseline residual variance 135 """Additive fixed-effect baseline residual variance used for the TOST bound.""" 136 IC: float = 0.0 137 """Estimator information criterion.""" 138 PC: float = 0.0 139 """Estimator panel criterion.""" 140 rmse: float = 0.0 141 """Reserved field; defaults to 0.0.""" 142 niter: int = 0 143 """Number of EM iterations.""" 144 converged: bool = False 145 """Whether the estimator reached its convergence tolerance.""" 146 147 # Inference 148 inference: InferenceResult | None = None 149 """Bootstrap or jackknife inference results; see pyfector.inference.InferenceResult.""" 150 151 # Diagnostics (populated when fect(..., diagnostics="full" | list)) 152 diagnostics: Diagnostics | None = None 153 """Requested diagnostic tests and their resolved options.""" 154 155 # CV 156 cv_result: CVResult | None = None 157 """Cross-validation candidates and scores; see pyfector.cv.CVResult.""" 158 159 # Panel metadata 160 panel: PanelData | None = None 161 """Filtered panel matrices and unit metadata; see pyfector.panel.PanelData.""" 162 fit_options: dict[str, Any] = field(default_factory=dict) 163 """Fit settings used by diagnostic refits.""" 164 165 # Reproducibility 166 seed: int | None = None 167 """Seed used for cross-validation and bootstrap draws.""" 168 169 def summary(self) -> str: 170 """Print summary table of results.""" 171 lines = [] 172 lines.append(f"pyfector estimation results") 173 lines.append(f"{'='*60}") 174 lines.append(f"Method: {self.method}") 175 if self.r_cv is not None: 176 lines.append(f"Number of factors (CV): {self.r_cv}") 177 if self.lambda_cv is not None: 178 lines.append(f"Lambda (CV): {self.lambda_cv:.6f}") 179 lines.append(f"Converged: {self.converged} (iter={self.niter})") 180 lines.append(f"Sigma^2: {self.sigma2:.6f}") 181 lines.append(f"Sigma^2_fect (FE baseline): {self.sigma2_fect:.6f}") 182 lines.append(f"") 183 lines.append(f"ATT (average): {self.att_avg:.6f}") 184 if self.inference is not None: 185 inf = self.inference 186 lines.append(f" SE: {inf.att_avg_se:.6f}") 187 lines.append(f" CI: [{inf.att_avg_ci[0]:.6f}, {inf.att_avg_ci[1]:.6f}]") 188 lines.append(f" p-val: {inf.att_avg_pval:.4f}") 189 190 if self.beta is not None and len(self.beta) > 0: 191 lines.append(f"") 192 lines.append(f"Coefficients:") 193 for i, name in enumerate(self.covariate_names): 194 lines.append(f" {name}: {self.beta[i]:.6f}") 195 196 if self.att_on is not None and self.time_on is not None: 197 lines.append(f"") 198 lines.append(f"Dynamic effects (ATT by relative time):") 199 lines.append(f" {'Time':>6s} {'ATT':>10s} {'Count':>6s}", ) 200 for i, t in enumerate(self.time_on): 201 count = self.count_on[i] if self.count_on is not None else "" 202 att = self.att_on[i] 203 if self.inference is not None: 204 se = self.inference.att_on_se[i] 205 lines.append(f" {t:>6.0f} {att:>10.4f} ({se:.4f}) {count}") 206 else: 207 lines.append(f" {t:>6.0f} {att:>10.4f} {count}") 208 209 lines.append(f"{'='*60}") 210 if self.panel is not None: 211 lines.append(f"N={self.panel.N}, T={self.panel.T}") 212 if self.seed is not None: 213 lines.append(f"Seed: {self.seed}") 214 if self.diagnostics is not None: 215 lines.append("") 216 lines.append(self.diagnostics.summary()) 217 return "\n".join(lines) 218 219 def __repr__(self): 220 return self.summary() 221 222 def plot(self, kind="gap", **kwargs): 223 """Plot results. Shortcut for ``pyfector.plot(self, kind, ...)``.""" 224 from .plotting import plot as _plot 225 return _plot(self, kind=kind, **kwargs) 226 227 def diagnose(self, **kwargs): 228 """Run diagnostic tests. Shortcut for ``pyfector.run_diagnostics(self, ...)``.""" 229 from .diagnostics import run_diagnostics 230 return run_diagnostics(self, **kwargs)
Container for all fect estimation results.
Mean counterfactual gap at each relative event time, including pre-treatment periods.
Number of observed ever-treated units contributing at each event time.
Bootstrap or jackknife inference results; see pyfector.inference.InferenceResult.
Cross-validation candidates and scores; see pyfector.cv.CVResult.
Filtered panel matrices and unit metadata; see pyfector.panel.PanelData.
169 def summary(self) -> str: 170 """Print summary table of results.""" 171 lines = [] 172 lines.append(f"pyfector estimation results") 173 lines.append(f"{'='*60}") 174 lines.append(f"Method: {self.method}") 175 if self.r_cv is not None: 176 lines.append(f"Number of factors (CV): {self.r_cv}") 177 if self.lambda_cv is not None: 178 lines.append(f"Lambda (CV): {self.lambda_cv:.6f}") 179 lines.append(f"Converged: {self.converged} (iter={self.niter})") 180 lines.append(f"Sigma^2: {self.sigma2:.6f}") 181 lines.append(f"Sigma^2_fect (FE baseline): {self.sigma2_fect:.6f}") 182 lines.append(f"") 183 lines.append(f"ATT (average): {self.att_avg:.6f}") 184 if self.inference is not None: 185 inf = self.inference 186 lines.append(f" SE: {inf.att_avg_se:.6f}") 187 lines.append(f" CI: [{inf.att_avg_ci[0]:.6f}, {inf.att_avg_ci[1]:.6f}]") 188 lines.append(f" p-val: {inf.att_avg_pval:.4f}") 189 190 if self.beta is not None and len(self.beta) > 0: 191 lines.append(f"") 192 lines.append(f"Coefficients:") 193 for i, name in enumerate(self.covariate_names): 194 lines.append(f" {name}: {self.beta[i]:.6f}") 195 196 if self.att_on is not None and self.time_on is not None: 197 lines.append(f"") 198 lines.append(f"Dynamic effects (ATT by relative time):") 199 lines.append(f" {'Time':>6s} {'ATT':>10s} {'Count':>6s}", ) 200 for i, t in enumerate(self.time_on): 201 count = self.count_on[i] if self.count_on is not None else "" 202 att = self.att_on[i] 203 if self.inference is not None: 204 se = self.inference.att_on_se[i] 205 lines.append(f" {t:>6.0f} {att:>10.4f} ({se:.4f}) {count}") 206 else: 207 lines.append(f" {t:>6.0f} {att:>10.4f} {count}") 208 209 lines.append(f"{'='*60}") 210 if self.panel is not None: 211 lines.append(f"N={self.panel.N}, T={self.panel.T}") 212 if self.seed is not None: 213 lines.append(f"Seed: {self.seed}") 214 if self.diagnostics is not None: 215 lines.append("") 216 lines.append(self.diagnostics.summary()) 217 return "\n".join(lines)
Print summary table of results.
222 def plot(self, kind="gap", **kwargs): 223 """Plot results. Shortcut for ``pyfector.plot(self, kind, ...)``.""" 224 from .plotting import plot as _plot 225 return _plot(self, kind=kind, **kwargs)
Plot results. Shortcut for pyfector.plot(self, kind, ...).
227 def diagnose(self, **kwargs): 228 """Run diagnostic tests. Shortcut for ``pyfector.run_diagnostics(self, ...)``.""" 229 from .diagnostics import run_diagnostics 230 return run_diagnostics(self, **kwargs)
Run diagnostic tests. Shortcut for pyfector.run_diagnostics(self, ...).
233def fect( 234 data, 235 Y: str, 236 D: str, 237 index: tuple[str, str], 238 X: list[str] | None = None, 239 W: str | None = None, 240 group: str | None = None, 241 method: Literal["fe", "ife", "mc", "cfe", "both"] = "ife", 242 force: Literal["none", "unit", "time", "two-way"] = "two-way", 243 r: int | tuple[int, int] = 0, 244 lam: float | None = None, 245 nlambda: int = 10, 246 lambda_candidates: list[float] | np.ndarray | None = None, 247 CV: bool = True, 248 k: int = 10, 249 cv_prop: float = 0.1, 250 cv_nobs: int = 3, 251 cv_treat: bool = True, 252 cv_donut: int = 0, 253 criterion: str = "mspe", 254 cv_rule: Literal["min", "onepct"] = "min", 255 se: bool = False, 256 vartype: Literal["bootstrap", "jackknife"] = "bootstrap", 257 nboots: int = 200, 258 alpha: float = 0.05, 259 tol: float = 1e-7, 260 max_iter: int = 5000, 261 min_T0: int = 1, 262 min_T0_strict: bool = False, 263 max_missing: float = 1.0, 264 normalize: bool = False, 265 # CFE-specific 266 Z: list[str] | None = None, 267 Q: list[str] | None = None, 268 # Performance 269 device: Literal["cpu", "gpu"] = "cpu", 270 n_jobs: int | None = -1, 271 seed: int | None = None, 272 # Diagnostics (optional; run at fit time and attached to result) 273 diagnostics: Literal["none", "full"] | list[str] = "none", 274 diagnostics_options: dict | None = None, 275 *, 276 cluster: str | None = None, 277) -> FectResult: 278 """Estimate counterfactual treatment effects for panel data. 279 280 This is the main Python entry point for the counterfactual estimator 281 workflow. Where the paper and the historical R package differ, 282 pyfector defaults to the paper's statistical definition and exposes 283 R-package-style behavior through explicit options. 284 285 Missing outcome policy 286 ---------------------- 287 pyfector distinguishes raw missing outcomes from counterfactual 288 missingness caused by treatment. Observed untreated cells 289 (``D == 0`` and non-missing ``Y``) fit the response surface. Observed 290 treated cells (``D == 1`` and non-missing ``Y``) contribute to ATT as 291 ``Y - Y_ct``. If a treated outcome is missing in the input data, the 292 model can still produce a counterfactual ``Y_ct`` for that cell, but 293 the cell is not counted in ``att_avg`` or ``att_on`` because the 294 treated potential outcome was not observed. 295 296 By default, ``min_T0`` is enforced only for treated and reversal 297 units. Sparse controls are retained if they have at least one 298 observed outcome, because they may still inform the low-rank response 299 surface. Set ``min_T0_strict=True`` to require controls to satisfy 300 ``min_T0`` too, matching the more conservative R fect sparse-panel 301 behavior. 302 303 Parameters 304 ---------- 305 data : polars.DataFrame, pandas.DataFrame 306 Long-format panel data. 307 Y, D : str 308 Column names for outcome and binary treatment indicator. 309 index : (str, str) 310 Column names for (unit_id, time_period). 311 X : list of str, optional 312 Time-varying covariates. 313 W : str, optional 314 Observation weight column. 315 group : str, optional 316 Reserved for grouped estimation. Currently raises 317 ``NotImplementedError`` when supplied. 318 method : {"fe", "ife", "mc", "cfe", "both"} 319 Estimation method. 320 force : {"none", "unit", "time", "two-way"} 321 Fixed effects specification. 322 r : int or (int, int) 323 Number of factors. If tuple, CV selects from range. 324 lam : float, optional 325 Nuclear norm penalty for MC. If None with CV=True, auto-selected. 326 nlambda : int 327 Number of automatically generated lambda candidates for MC CV. 328 lambda_candidates : array-like, optional 329 Explicit non-negative lambda candidates for MC CV. When supplied, 330 ``nlambda`` is ignored. 331 CV : bool 332 If True, cross-validate over ``r`` for IFE when ``r`` is a tuple, 333 or over ``lam`` for MC when ``lam`` is None. 334 k : int 335 Number of CV folds. 336 cv_prop : float 337 Fraction of eligible observed control cells masked per CV fold. 338 cv_nobs : int 339 Number of consecutive within-unit observations to mask as a block. 340 cv_treat : bool 341 If True, restrict CV masks to pre-treatment cells of ever-treated 342 units. If False, use all observed control cells. 343 cv_donut : int 344 Exclude this many periods around treatment onset from CV evaluation. 345 criterion : {"mspe", "gmspe", "mad"} 346 Cross-validation loss. 347 cv_rule : {"min", "onepct"} 348 CV selection rule. ``"min"`` chooses the strict minimum-score 349 candidate and is the paper-faithful default. ``"onepct"`` chooses 350 the simplest candidate within 1% of the best score (lower ``r`` for 351 IFE, higher ``lam`` for MC). 352 se : bool 353 Compute standard errors via bootstrap/jackknife. 354 vartype : {"bootstrap", "jackknife"} 355 Inference method when ``se=True``. 356 cluster : str, optional 357 Column of cluster labels, constant within each unit. Bootstrap 358 inference resamples whole clusters in both the main fit and the 359 placebo refit. Requires at least two retained clusters. 360 nboots : int 361 Number of bootstrap replications, at least 2. Ignored for jackknife. 362 alpha : float 363 Significance level for confidence intervals and tests. 364 tol : float 365 EM convergence tolerance for final point estimation. 366 max_iter : int 367 Maximum EM iterations. 368 min_T0 : int 369 Minimum untreated/pre-treatment observed periods. By default this is 370 enforced only for treated and treatment-reversal units. 371 min_T0_strict : bool 372 If True, enforce ``min_T0`` on all units, including controls. This 373 matches R fect's conservative handling of sparse control rows. 374 max_missing : float 375 Maximum missing-outcome fraction per unit, in ``[0, 1]``. Units with 376 no observed outcomes are always dropped, regardless of this threshold, 377 because they provide neither fitting information nor observed treated 378 effects. 379 normalize : bool 380 If True, estimate on an outcome standardized by its observed standard 381 deviation, then transform effects back to the original scale. 382 Z, Q : list of str, optional 383 Reserved CFE interaction arguments. Currently raise 384 ``NotImplementedError`` when supplied. 385 device : {"cpu", "gpu"} 386 Compute device. 387 n_jobs : int, optional 388 Parallel workers for CV and bootstrap. ``-1`` or ``None`` uses 389 all available CPUs. 390 seed : int, optional 391 Random seed for full reproducibility. 392 393 Returns 394 ------- 395 FectResult 396 Treatment effects, fitted counterfactuals, and requested inference 397 and diagnostics. 398 """ 399 # Set device 400 set_device(device) 401 xp = get_backend() 402 n_jobs = _resolve_n_jobs(n_jobs) 403 if device == "gpu": 404 n_jobs = 1 405 406 if group is not None: 407 raise NotImplementedError("The `group` argument is not implemented yet.") 408 if Z is not None or Q is not None: 409 raise NotImplementedError("The `Z` and `Q` CFE interaction arguments are not implemented yet.") 410 if criterion not in {"mspe", "gmspe", "mad"}: 411 raise ValueError("criterion must be 'mspe', 'gmspe', or 'mad'") 412 if cv_rule not in {"min", "onepct"}: 413 raise ValueError("cv_rule must be 'min' or 'onepct'") 414 if min_T0 < 0: 415 raise ValueError("min_T0 must be non-negative") 416 if cluster is not None and vartype != "bootstrap": 417 raise ValueError("cluster is supported only with vartype='bootstrap'") 418 if se and vartype == "bootstrap" and ( 419 isinstance(nboots, (bool, np.bool_)) 420 or not isinstance(nboots, (int, np.integer)) or nboots < 2 421 ): 422 raise ValueError("nboots must be an integer of at least 2") 423 if not 0.0 <= max_missing <= 1.0: 424 raise ValueError("max_missing must be between 0 and 1") 425 426 # Validate diagnostics request before doing any expensive estimation 427 # so users don't wait for a 30-min MC fit only to find their config 428 # is wrong. 429 requested_diag = validate_diagnostics_request( 430 diagnostics, diagnostics_options, se, 431 ) 432 433 # Map force string to int 434 force_map = {"none": 0, "unit": 1, "time": 2, "two-way": 3} 435 force_int = force_map[force] 436 437 # Prepare panel data 438 panel = prepare_panel( 439 data, Y=Y, D=D, index=index, X=X, W=W, 440 group=group, min_T0=min_T0, min_T0_strict=min_T0_strict, 441 max_missing=max_missing, 442 cluster=cluster, 443 ) 444 445 if not np.any((panel.D > 0) & (panel.I > 0)): 446 raise ValueError( 447 "No observed treated units remain after filtering. " 448 "Check treatment, min_T0, and missing-outcome filters." 449 ) 450 if se and panel.cluster is not None and len(np.unique(panel.cluster)) < 2: 451 raise ValueError("cluster bootstrap requires at least two clusters after filtering") 452 453 # Move to device 454 Y_mat = to_device(panel.Y) 455 D_mat = to_device(panel.D) 456 I_mat = to_device(panel.I) 457 II_mat = to_device(panel.II) 458 X_mat = to_device(panel.X) if panel.X is not None else None 459 W_mat = to_device(panel.W) if panel.W is not None else None 460 461 # Normalize 462 norm_factor = 1.0 463 if normalize: 464 sd_y = float(xp.std(Y_mat[I_mat > 0])) 465 if sd_y > 0: 466 Y_mat = Y_mat / sd_y 467 norm_factor = sd_y 468 469 # Initial fit 470 Y0, beta0 = initial_fit(Y_mat, X_mat, II_mat, force_int) 471 472 # Determine r and lambda 473 r_cv = None 474 lambda_cv = None 475 cv_result = None 476 477 if method == "ife": 478 if isinstance(r, tuple) and CV: 479 cv_result = cv_ife( 480 Y_mat, Y0, X_mat, I_mat, II_mat, D_mat, W_mat, beta0, 481 force=force_int, r_range=r, k=k, cv_prop=cv_prop, 482 cv_nobs=cv_nobs, cv_treat=cv_treat, cv_donut=cv_donut, 483 criterion=criterion, cv_rule=cv_rule, 484 tol=tol, max_iter=max_iter, 485 n_jobs=n_jobs, seed=seed, 486 ) 487 r_cv = cv_result.best_r 488 else: 489 r_cv = r if isinstance(r, int) else r[0] 490 491 elif method == "mc": 492 if lam is None and CV: 493 cv_result = cv_mc( 494 Y_mat, Y0, X_mat, I_mat, II_mat, D_mat, W_mat, beta0, 495 force=force_int, lambda_candidates=lambda_candidates, 496 nlambda=nlambda, k=k, cv_prop=cv_prop, 497 cv_nobs=cv_nobs, cv_treat=cv_treat, cv_donut=cv_donut, 498 criterion=criterion, cv_rule=cv_rule, 499 tol=tol, max_iter=max_iter, 500 n_jobs=n_jobs, seed=seed, 501 ) 502 lambda_cv = cv_result.best_lambda 503 else: 504 lambda_cv = lam if lam is not None else 0.0 505 506 elif method == "fe": 507 r_cv = 0 508 509 elif method == "cfe": 510 r_cv = r if isinstance(r, int) else r[0] 511 512 # Point estimation 513 if method in ("fe", "ife"): 514 est = estimate_ife( 515 Y_mat, Y0, X_mat, II_mat, W_mat, beta0, 516 r=r_cv, force=force_int, tol=tol, max_iter=max_iter, 517 ) 518 elif method == "mc": 519 est = estimate_mc( 520 Y_mat, Y0, X_mat, II_mat, W_mat, beta0, 521 lam=lambda_cv, force=force_int, tol=tol, max_iter=max_iter, 522 ) 523 elif method == "cfe": 524 est = estimate_cfe( 525 Y_mat, Y0, X_mat, II_mat, W_mat, beta0, 526 r=r_cv, force=force_int, tol=tol, max_iter=max_iter, 527 ) 528 elif method == "both": 529 # Run both IFE and MC, return IFE results with MC comparison 530 if isinstance(r, tuple) and CV: 531 cv_result = cv_ife( 532 Y_mat, Y0, X_mat, I_mat, II_mat, D_mat, W_mat, beta0, 533 force=force_int, r_range=r, k=k, cv_prop=cv_prop, 534 cv_nobs=cv_nobs, cv_treat=cv_treat, cv_donut=cv_donut, 535 criterion=criterion, cv_rule=cv_rule, 536 tol=tol, max_iter=max_iter, 537 n_jobs=n_jobs, seed=seed, 538 ) 539 r_cv = cv_result.best_r 540 else: 541 r_cv = r if isinstance(r, int) else r[0] 542 est = estimate_ife( 543 Y_mat, Y0, X_mat, II_mat, W_mat, beta0, 544 r=r_cv, force=force_int, tol=tol, max_iter=max_iter, 545 ) 546 else: 547 raise ValueError(f"Unknown method: {method}") 548 549 # Compute effects 550 eff = Y_mat - est.fit 551 Y_ct = est.fit 552 553 # Additive-FE baseline residual variance (Liu et al. 2024 sigma2.fect). 554 # For method="fe" the main estimator IS the additive-FE pass, so reuse 555 # est.sigma2. For ife/mc/cfe/both, run an extra r=0 IFE pass on the 556 # same panel with the user's requested FE structure. 557 if method == "fe": 558 sigma2_fect_value = float(est.sigma2) 559 else: 560 est_fect = estimate_ife( 561 Y_mat, Y0, X_mat, II_mat, W_mat, beta0, 562 r=0, force=force_int, tol=tol, max_iter=max_iter, 563 ) 564 sigma2_fect_value = float(est_fect.sigma2) 565 566 # Denormalize 567 if normalize and norm_factor != 1.0: 568 eff = eff * norm_factor 569 Y_ct = Y_ct * norm_factor 570 Y_mat = Y_mat * norm_factor 571 if est.beta is not None: 572 est = est._replace(beta=est.beta * norm_factor) 573 sigma2_fect_value *= norm_factor ** 2 574 575 # ATT computation 576 T_on = to_device(panel.T_on) 577 att_avg, att_on, time_on, count_on, att_avg_unit = _compute_effects( 578 to_numpy(eff), to_numpy(D_mat), to_numpy(panel.T_on), to_numpy(I_mat), 579 ) 580 581 # Build result 582 result = FectResult( 583 method=method, 584 r_cv=r_cv, 585 lambda_cv=lambda_cv, 586 att_avg=att_avg, 587 att_avg_unit=att_avg_unit, 588 att_on=att_on, 589 time_on=time_on, 590 count_on=count_on, 591 beta=to_numpy(est.beta) if est.beta is not None else None, 592 covariate_names=panel.covariate_names, 593 mu=est.mu, 594 alpha=to_numpy(est.alpha) if est.alpha is not None else None, 595 xi=to_numpy(est.xi) if est.xi is not None else None, 596 factors=to_numpy(est.factors) if est.factors is not None else None, 597 loadings=to_numpy(est.loadings) if est.loadings is not None else None, 598 Y_ct=to_numpy(Y_ct), 599 eff=to_numpy(eff), 600 residuals=to_numpy(est.residuals), 601 sigma2=est.sigma2, 602 sigma2_fect=sigma2_fect_value, 603 IC=est.IC, 604 PC=est.PC, 605 niter=est.niter, 606 converged=est.converged, 607 cv_result=cv_result, 608 panel=panel, 609 fit_options={ 610 "force": force, 611 "force_int": force_int, 612 "tol": tol, 613 "max_iter": max_iter, 614 "normalize": normalize, 615 "norm_factor": norm_factor, 616 "vartype": vartype, 617 "nboots": nboots, 618 "n_jobs": n_jobs, 619 "min_T0": min_T0, 620 "min_T0_strict": min_T0_strict, 621 "cluster": cluster, 622 }, 623 seed=seed, 624 ) 625 626 # Inference 627 if se: 628 result.inference = _run_inference( 629 result, panel, Y_mat, X_mat, W_mat, beta0, Y0, 630 method=method, r_cv=r_cv, lambda_cv=lambda_cv, 631 force_int=force_int, tol=tol, max_iter=max_iter, 632 vartype=vartype, nboots=nboots, alpha=alpha, 633 n_jobs=n_jobs, seed=seed, normalize=normalize, 634 norm_factor=norm_factor, 635 ) 636 637 # Run requested diagnostics at fit time. requested_diag is None when 638 # diagnostics="none". Validation already enforced se=True and 639 # required-config presence. 640 if requested_diag is not None: 641 opts = dict(diagnostics_options or {}) 642 if "loo" in requested_diag: 643 opts["loo"] = True 644 else: 645 opts.setdefault("loo", False) 646 result.diagnostics = _run_diagnostics( 647 result, _requested=requested_diag, 648 _strict=isinstance(diagnostics, list), **opts, 649 ) 650 651 return result
Estimate counterfactual treatment effects for panel data.
This is the main Python entry point for the counterfactual estimator workflow. Where the paper and the historical R package differ, pyfector defaults to the paper's statistical definition and exposes R-package-style behavior through explicit options.
Missing outcome policy
pyfector distinguishes raw missing outcomes from counterfactual
missingness caused by treatment. Observed untreated cells
(D == 0 and non-missing Y) fit the response surface. Observed
treated cells (D == 1 and non-missing Y) contribute to ATT as
Y - Y_ct. If a treated outcome is missing in the input data, the
model can still produce a counterfactual Y_ct for that cell, but
the cell is not counted in att_avg or att_on because the
treated potential outcome was not observed.
By default, min_T0 is enforced only for treated and reversal
units. Sparse controls are retained if they have at least one
observed outcome, because they may still inform the low-rank response
surface. Set min_T0_strict=True to require controls to satisfy
min_T0 too, matching the more conservative R fect sparse-panel
behavior.
Parameters
- data (polars.DataFrame, pandas.DataFrame): Long-format panel data.
- Y, D (str): Column names for outcome and binary treatment indicator.
- index ((str, str)): Column names for (unit_id, time_period).
- X (list of str, optional): Time-varying covariates.
- W (str, optional): Observation weight column.
- group (str, optional):
Reserved for grouped estimation. Currently raises
NotImplementedErrorwhen supplied. - method ({"fe", "ife", "mc", "cfe", "both"}): Estimation method.
- force ({"none", "unit", "time", "two-way"}): Fixed effects specification.
- r (int or (int, int)): Number of factors. If tuple, CV selects from range.
- lam (float, optional): Nuclear norm penalty for MC. If None with CV=True, auto-selected.
- nlambda (int): Number of automatically generated lambda candidates for MC CV.
- lambda_candidates (array-like, optional):
Explicit non-negative lambda candidates for MC CV. When supplied,
nlambdais ignored. - CV (bool):
If True, cross-validate over
rfor IFE whenris a tuple, or overlamfor MC whenlamis None. - k (int): Number of CV folds.
- cv_prop (float): Fraction of eligible observed control cells masked per CV fold.
- cv_nobs (int): Number of consecutive within-unit observations to mask as a block.
- cv_treat (bool): If True, restrict CV masks to pre-treatment cells of ever-treated units. If False, use all observed control cells.
- cv_donut (int): Exclude this many periods around treatment onset from CV evaluation.
- criterion ({"mspe", "gmspe", "mad"}): Cross-validation loss.
- cv_rule ({"min", "onepct"}):
CV selection rule.
"min"chooses the strict minimum-score candidate and is the paper-faithful default."onepct"chooses the simplest candidate within 1% of the best score (lowerrfor IFE, higherlamfor MC). - se (bool): Compute standard errors via bootstrap/jackknife.
- vartype ({"bootstrap", "jackknife"}):
Inference method when
se=True. - cluster (str, optional): Column of cluster labels, constant within each unit. Bootstrap inference resamples whole clusters in both the main fit and the placebo refit. Requires at least two retained clusters.
- nboots (int): Number of bootstrap replications, at least 2. Ignored for jackknife.
- alpha (float): Significance level for confidence intervals and tests.
- tol (float): EM convergence tolerance for final point estimation.
- max_iter (int): Maximum EM iterations.
- min_T0 (int): Minimum untreated/pre-treatment observed periods. By default this is enforced only for treated and treatment-reversal units.
- min_T0_strict (bool):
If True, enforce
min_T0on all units, including controls. This matches R fect's conservative handling of sparse control rows. - max_missing (float):
Maximum missing-outcome fraction per unit, in
[0, 1]. Units with no observed outcomes are always dropped, regardless of this threshold, because they provide neither fitting information nor observed treated effects. - normalize (bool): If True, estimate on an outcome standardized by its observed standard deviation, then transform effects back to the original scale.
- Z, Q (list of str, optional):
Reserved CFE interaction arguments. Currently raise
NotImplementedErrorwhen supplied. - device ({"cpu", "gpu"}): Compute device.
- n_jobs (int, optional):
Parallel workers for CV and bootstrap.
-1orNoneuses all available CPUs. - seed (int, optional): Random seed for full reproducibility.
Returns
- FectResult: Treatment effects, fitted counterfactuals, and requested inference and diagnostics.