pyfector
pyfector: counterfactual estimators for panel data in Python.
A Python implementation of counterfactual estimators for panel data, following Liu, Wang and Xu (2024). See the R fect package for the R implementation.
Usage
import pyfector
result = pyfector.fect(
data=df,
Y="outcome", D="treat",
index=("unit", "year"),
method="ife",
r=(0, 5),
se=True,
seed=42,
)
result.summary()
result.plot()
API modules
pyfector.diagnostics provides pre-trend tests, holdout/refit placebo tests,
and post-treatment sensitivity diagnostics. pyfector.inference provides
bootstrap, jackknife, and permutation inference. pyfector.panel,
pyfector.estimators, and pyfector.cv describe panel construction,
estimation, and cross-validation.
1""" 2pyfector: counterfactual estimators for panel data in Python. 3 4A Python implementation of counterfactual estimators for panel data, 5following Liu, Wang and Xu (2024). See the 6[R fect package](https://github.com/xuyiqing/fect) for the R implementation. 7 8Usage 9----- 10 11```python 12 13 import pyfector 14 15 result = pyfector.fect( 16 data=df, 17 Y="outcome", D="treat", 18 index=("unit", "year"), 19 method="ife", 20 r=(0, 5), 21 se=True, 22 seed=42, 23 ) 24 result.summary() 25 result.plot() 26``` 27 28API modules 29----------- 30`pyfector.diagnostics` provides pre-trend tests, holdout/refit placebo tests, 31and post-treatment sensitivity diagnostics. `pyfector.inference` provides 32bootstrap, jackknife, and permutation inference. `pyfector.panel`, 33`pyfector.estimators`, and `pyfector.cv` describe panel construction, 34estimation, and cross-validation. 35 36""" 37 38__version__ = "0.2.3" 39 40from .fect import fect, FectResult 41from .backend import set_device, get_device 42from .diagnostics import ( 43 run_diagnostics, 44 Diagnostics, 45 DiagnosticResult, 46 TostResult, 47 PretrendFResult, 48 EquivFResult, 49 PlaceboResult, 50 CarryoverResult, 51 LooResult, 52) 53from .plotting import plot 54 55__all__ = [ 56 "fect", 57 "FectResult", 58 "set_device", 59 "get_device", 60 "run_diagnostics", 61 "Diagnostics", 62 "DiagnosticResult", 63 "TostResult", 64 "PretrendFResult", 65 "EquivFResult", 66 "PlaceboResult", 67 "CarryoverResult", 68 "LooResult", 69 "plot", 70]
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.
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, ...).
35def set_device(device: Literal["cpu", "gpu"]) -> None: 36 """Set the compute device globally.""" 37 global _DEVICE 38 if device == "gpu" and not _check_cupy(): 39 raise ImportError( 40 "CuPy is required for GPU support. Install it with: " 41 "pip install cupy-cuda12x (adjust for your CUDA version)" 42 ) 43 _DEVICE = device
Set the compute device globally.
Return the current device.
369def run_diagnostics( 370 result, 371 f_threshold: float = 0.5, 372 tost_threshold: float = 0.36, 373 placebo_period: tuple[int, int] | None = None, 374 carryover_period: tuple[int, int] | None = None, 375 loo: bool = False, 376 alpha: float = 0.05, 377 *, 378 _requested: list[str] | None = None, 379 _strict: bool | None = None, 380) -> Diagnostics: 381 """Run diagnostic tests on a FectResult. 382 383 Parameters 384 ---------- 385 result : FectResult 386 Must have inference results (``se=True``). 387 f_threshold : float 388 Noncentrality per treated unit for the equivalence F-test: 389 ``ncp = N * f_threshold``. The default coefficient is 0.5; in the 390 paper's notation this is ``kappa**2``. Small p-values support 391 equivalence. ``N`` is the largest ``count_on`` in the tested 392 pre-periods and also determines both F-tests' scale and degrees 393 of freedom. 394 tost_threshold : float 395 Equivalence bound for TOST. The literal ``0.36`` (default or 396 explicit) triggers Liu et al. (2024)'s scale-aware bound, 397 ``0.36 * sqrt(result.sigma2_fect)``. Any other positive float 398 is taken as an absolute outcome-scale bound. ``None`` is 399 invalid. 400 placebo_period : (start, end), optional 401 Relative pre-treatment window for the holdout/refit placebo test. 402 Observed cells with ``start <= time_on <= end`` and ``time_on < 0`` 403 are removed from the fitting mask, the model is refit using the 404 selected rank/lambda configuration, and the placebo ATT is 405 computed from those withheld cells. For example, ``(-3, 0)`` 406 withholds relative periods ``-3, -2, -1``. 407 Treated and reversal units must retain at least ``max(min_T0, 1)`` 408 untreated observations after holdout. Bootstrap draws use the 409 fit's cluster column when supplied. 410 carryover_period : (start, end), optional 411 Relative time window for carryover test. 412 loo : bool 413 If True, run leave-one-out post-period sensitivity. 414 alpha : float 415 Significance cutoff used for ``TostResult.all_pass``. 416 417 Returns 418 ------- 419 Diagnostics 420 Available tests and their resolved options. Unavailable configured 421 tests raise for an explicit list and warn in best-effort mode. 422 """ 423 from scipy import stats 424 425 diag = Diagnostics() 426 427 requested = set(_requested) if _requested is not None else _VALID_DIAG_NAMES.copy() 428 strict = _requested is not None if _strict is None else _strict 429 expected = requested.copy() 430 unavailable_reasons = {} 431 if _requested is None: 432 if placebo_period is None: 433 expected.discard("placebo") 434 if carryover_period is None: 435 expected.discard("carryover") 436 if not loo: 437 expected.discard("loo") 438 439 def report_unavailable(): 440 missing = sorted((expected - set(diag.tests)) | (expected & set(unavailable_reasons))) 441 if missing: 442 descriptions = [ 443 f"{name} ({unavailable_reasons[name]})" if name in unavailable_reasons else name 444 for name in missing 445 ] 446 message = f"Requested diagnostics unavailable: {', '.join(descriptions)}" 447 if strict: 448 raise ValueError(message) 449 warnings.warn(message, RuntimeWarning, stacklevel=3) 450 451 if result.inference is None: 452 report_unavailable() 453 return diag 454 455 inf = result.inference 456 time_on = result.time_on 457 att_on = result.att_on 458 459 if time_on is None or att_on is None: 460 report_unavailable() 461 return diag 462 463 pre_mask = time_on < 0 464 465 pre_idx = np.where(pre_mask)[0] 466 k = len(pre_idx) 467 att_pre = att_on[pre_idx] 468 469 if placebo_period is not None: 470 placebo_period = _validate_placebo_period(placebo_period) 471 472 needs_tost_threshold = bool({"tost", "placebo"} & requested) 473 threshold_abs = float("nan") 474 threshold_source: str | None = None 475 sigma2_fect_used: float | None = None 476 if needs_tost_threshold: 477 _validate_tost_threshold_value(tost_threshold) 478 try: 479 threshold_abs, threshold_source, sigma2_fect_used = _resolve_tost_threshold( 480 result, tost_threshold 481 ) 482 except ValueError as exc: 483 if strict or _strict is None: 484 raise 485 for name in requested & {"tost", "placebo"}: 486 unavailable_reasons[name] = str(exc) 487 488 # Pre-trend / equivalence F-tests share the bootstrap covariance pass. 489 if ( 490 k > 0 and ("pretrend_f" in requested or "equiv_f" in requested) 491 and inf.att_on_boot is not None 492 ): 493 boot_pre_all = inf.att_on_boot[pre_idx, :] 494 valid_boot = np.all(np.isfinite(boot_pre_all), axis=0) 495 boot_pre = boot_pre_all[:, valid_boot] 496 n_boot = boot_pre.shape[1] 497 if n_boot <= k: 498 boot_pre = None 499 500 else: 501 boot_pre = None 502 503 if boot_pre is not None: 504 counts = getattr(result, "count_on", None) 505 if counts is None: 506 reason = "observed treated-unit counts (count_on) are required" 507 n_treated = 0 508 else: 509 pre_counts = np.asarray(counts)[pre_idx] 510 valid_counts = np.all(np.isfinite(pre_counts)) and np.all(pre_counts >= 0) 511 n_treated = int(np.max(pre_counts)) if valid_counts else 0 512 reason = "insufficient observed treated units for the number of tested pre-periods" 513 if n_treated <= k: 514 for name in requested & {"pretrend_f", "equiv_f"}: 515 unavailable_reasons[name] = reason 516 boot_pre = None 517 518 if boot_pre is not None: 519 S = np.cov(boot_pre) 520 if k == 1: 521 S = np.asarray(S).reshape(1, 1) 522 523 try: 524 cond = np.linalg.cond(S) 525 S_inv = np.linalg.pinv(S) if cond > 1e12 else np.linalg.inv(S) 526 F_raw = float(att_pre @ S_inv @ att_pre) 527 scale = (n_treated - k) / ((n_treated - 1) * k) 528 F_stat = F_raw * scale 529 530 if np.isfinite(F_stat) and F_stat >= 0: 531 if "pretrend_f" in requested: 532 diag.pretrend_f = PretrendFResult( 533 f_stat=F_stat, 534 p_value=float(stats.f.sf(F_stat, k, n_treated - k)), 535 df1=k, 536 df2=n_treated - k, 537 ) 538 if "equiv_f" in requested: 539 ncp = n_treated * f_threshold 540 diag.equiv_f = EquivFResult( 541 p_value=float(stats.ncf.cdf(F_stat, k, n_treated - k, ncp)), 542 f_threshold=f_threshold, 543 n_treated=n_treated, 544 ncp=ncp, 545 ) 546 except np.linalg.LinAlgError: 547 pass 548 549 # Per-period TOST. 550 if k > 0 and "tost" in requested and "tost" not in unavailable_reasons and inf.att_on_se is not None: 551 se_pre = inf.att_on_se[pre_idx] 552 553 tost_pvals = np.full(k, np.nan) 554 for i in range(k): 555 if se_pre[i] > 0: 556 if inf.att_on_boot is not None: 557 n_boot_i = int(np.isfinite(inf.att_on_boot[pre_idx[i], :]).sum()) 558 else: 559 n_boot_i = 200 560 df = max(n_boot_i - 1, 1) 561 t_upper = (att_pre[i] - threshold_abs) / se_pre[i] 562 t_lower = (att_pre[i] + threshold_abs) / se_pre[i] 563 p_upper = float(stats.t.cdf(t_upper, df)) 564 p_lower = float(1 - stats.t.cdf(t_lower, df)) 565 tost_pvals[i] = max(p_upper, p_lower) 566 567 finite = tost_pvals[np.isfinite(tost_pvals)] 568 max_p = float(finite.max()) if finite.size else float("nan") 569 all_pass = bool(finite.size == k and (finite < alpha).all()) 570 if finite.size < k: 571 unavailable_reasons["tost"] = "missing usable standard errors for pre-treatment periods" 572 if finite.size: 573 diag.tost = TostResult( 574 pvals=tost_pvals, 575 periods=time_on[pre_idx].copy(), 576 threshold=threshold_abs, 577 threshold_source=threshold_source, 578 sigma2_fect=sigma2_fect_used, 579 max_pval=max_p, 580 all_pass=all_pass, 581 ) 582 583 # Holdout/refit placebo test. This follows R fect's placeboTest path: 584 # selected pre-treatment cells are removed from II before fitting. 585 if "placebo" in requested and "placebo" not in unavailable_reasons and placebo_period is not None: 586 try: 587 diag.placebo = _run_placebo_test( 588 result, 589 placebo_period=placebo_period, 590 threshold_abs=threshold_abs, 591 ) 592 except (ValueError, RuntimeError) as exc: 593 if strict or _strict is None: 594 raise 595 unavailable_reasons["placebo"] = str(exc) 596 597 # Carryover (estimate only; bootstrap p deferred). 598 if ( 599 "carryover" in requested 600 and carryover_period is not None 601 and hasattr(result, "att_off") 602 and result.att_off is not None 603 ): 604 c_start, c_end = carryover_period 605 time_off = getattr(result, "time_off", None) 606 if time_off is not None: 607 off_mask = (time_off >= c_start) & (time_off <= c_end) 608 if np.any(off_mask): 609 diag.carryover = CarryoverResult( 610 estimate=float(np.mean(result.att_off[off_mask])), 611 period=(int(c_start), int(c_end)), 612 ) 613 614 # Leave-one-out post-period. 615 if "loo" in requested and loo: 616 post_mask = time_on >= 0 617 post_idx = np.where(post_mask)[0] 618 if len(post_idx) > 1: 619 full_att = result.att_avg 620 loo_atts: list[float] = [] 621 loo_periods: list[float] = [] 622 for drop_i in post_idx: 623 remaining = np.delete(post_idx, np.where(post_idx == drop_i)) 624 if len(remaining) > 0: 625 loo_atts.append(float(np.mean(att_on[remaining]))) 626 loo_periods.append(float(time_on[drop_i])) 627 atts_arr = np.array(loo_atts) 628 diag.loo = LooResult( 629 atts=atts_arr, 630 periods=np.array(loo_periods), 631 max_change=float(np.max(np.abs(atts_arr - full_att))), 632 ) 633 634 diag.options = { 635 "tost_threshold": threshold_abs if needs_tost_threshold else None, 636 "tost_threshold_source": threshold_source, 637 "f_threshold": f_threshold, 638 "alpha": alpha, 639 "placebo_period": placebo_period, 640 "carryover_period": carryover_period, 641 "loo": loo, 642 "sigma2_fect": sigma2_fect_used if sigma2_fect_used is not None 643 else getattr(result, "sigma2_fect", None), 644 } 645 try: 646 from . import __version__ as _ver 647 diag.options["pyfector_version"] = _ver 648 except Exception: 649 pass 650 651 report_unavailable() 652 return diag
Run diagnostic tests on a FectResult.
Parameters
- result (FectResult):
Must have inference results (
se=True). - f_threshold (float):
Noncentrality per treated unit for the equivalence F-test:
ncp = N * f_threshold. The default coefficient is 0.5; in the paper's notation this iskappa**2. Small p-values support equivalence.Nis the largestcount_onin the tested pre-periods and also determines both F-tests' scale and degrees of freedom. - tost_threshold (float):
Equivalence bound for TOST. The literal
0.36(default or explicit) triggers Liu et al. (2024)'s scale-aware bound,0.36 * sqrt(result.sigma2_fect). Any other positive float is taken as an absolute outcome-scale bound.Noneis invalid. - placebo_period ((start, end), optional):
Relative pre-treatment window for the holdout/refit placebo test.
Observed cells with
start <= time_on <= endandtime_on < 0are removed from the fitting mask, the model is refit using the selected rank/lambda configuration, and the placebo ATT is computed from those withheld cells. For example,(-3, 0)withholds relative periods-3, -2, -1. Treated and reversal units must retain at leastmax(min_T0, 1)untreated observations after holdout. Bootstrap draws use the fit's cluster column when supplied. - carryover_period ((start, end), optional): Relative time window for carryover test.
- loo (bool): If True, run leave-one-out post-period sensitivity.
- alpha (float):
Significance cutoff used for
TostResult.all_pass.
Returns
- Diagnostics: Available tests and their resolved options. Unavailable configured tests raise for an explicit list and warn in best-effort mode.
172@dataclass 173class Diagnostics: 174 """Slim-safe registry of diagnostic test results. 175 176 ``tests`` is the future-proof extension point. Built-in tests expose 177 convenience properties for stable user code, but the container itself 178 does not need a new field every time pyfector adds a diagnostic. 179 """ 180 options: dict = field(default_factory=dict) 181 tests: dict[str, Any] = field(default_factory=dict) 182 183 @property 184 def available(self) -> tuple[str, ...]: 185 """Names of populated diagnostics, with built-ins first.""" 186 known = [name for name in _DIAGNOSTIC_SUMMARY_ORDER if name in self.tests] 187 extra = sorted(name for name in self.tests if name not in set(known)) 188 return tuple(known + extra) 189 190 def get(self, name: str, default: Any = None) -> Any: 191 """Return a diagnostic by name, or ``default`` if absent.""" 192 return self.tests.get(name, default) 193 194 def set_test(self, name: str, value: Any | None) -> None: 195 """Set or remove a diagnostic result by name.""" 196 if not name or not isinstance(name, str): 197 raise ValueError("diagnostic name must be a non-empty string") 198 if value is None: 199 self.tests.pop(name, None) 200 else: 201 self.tests[name] = value 202 203 def set(self, name: str, value: Any | None) -> None: 204 """Compatibility shortcut for :meth:`set_test`.""" 205 self.set_test(name, value) 206 207 def __contains__(self, name: str) -> bool: 208 return name in self.tests 209 210 def __getitem__(self, name: str) -> Any: 211 return self.tests[name] 212 213 def _typed(self, name: str, typ: type) -> Any | None: 214 value = self.tests.get(name) 215 return value if isinstance(value, typ) else None 216 217 @property 218 def tost(self) -> TostResult | None: 219 return self._typed("tost", TostResult) 220 221 @tost.setter 222 def tost(self, value: TostResult | None) -> None: 223 self.set_test("tost", value) 224 225 @property 226 def pretrend_f(self) -> PretrendFResult | None: 227 return self._typed("pretrend_f", PretrendFResult) 228 229 @pretrend_f.setter 230 def pretrend_f(self, value: PretrendFResult | None) -> None: 231 self.set_test("pretrend_f", value) 232 233 @property 234 def equiv_f(self) -> EquivFResult | None: 235 return self._typed("equiv_f", EquivFResult) 236 237 @equiv_f.setter 238 def equiv_f(self, value: EquivFResult | None) -> None: 239 self.set_test("equiv_f", value) 240 241 @property 242 def placebo(self) -> PlaceboResult | None: 243 return self._typed("placebo", PlaceboResult) 244 245 @placebo.setter 246 def placebo(self, value: PlaceboResult | None) -> None: 247 self.set_test("placebo", value) 248 249 @property 250 def carryover(self) -> CarryoverResult | None: 251 return self._typed("carryover", CarryoverResult) 252 253 @carryover.setter 254 def carryover(self, value: CarryoverResult | None) -> None: 255 self.set_test("carryover", value) 256 257 @property 258 def loo(self) -> LooResult | None: 259 return self._typed("loo", LooResult) 260 261 @loo.setter 262 def loo(self, value: LooResult | None) -> None: 263 self.set_test("loo", value) 264 265 def summary(self) -> str: 266 lines = ["Diagnostic Tests", "=" * 50] 267 for name in self.available: 268 sub = self.tests[name] 269 if sub is not None: 270 if hasattr(sub, "summary"): 271 lines.append(sub.summary()) 272 else: 273 lines.append(f"{name}: {sub!r}") 274 return "\n".join(lines)
Slim-safe registry of diagnostic test results.
tests is the future-proof extension point. Built-in tests expose
convenience properties for stable user code, but the container itself
does not need a new field every time pyfector adds a diagnostic.
183 @property 184 def available(self) -> tuple[str, ...]: 185 """Names of populated diagnostics, with built-ins first.""" 186 known = [name for name in _DIAGNOSTIC_SUMMARY_ORDER if name in self.tests] 187 extra = sorted(name for name in self.tests if name not in set(known)) 188 return tuple(known + extra)
Names of populated diagnostics, with built-ins first.
190 def get(self, name: str, default: Any = None) -> Any: 191 """Return a diagnostic by name, or ``default`` if absent.""" 192 return self.tests.get(name, default)
Return a diagnostic by name, or default if absent.
194 def set_test(self, name: str, value: Any | None) -> None: 195 """Set or remove a diagnostic result by name.""" 196 if not name or not isinstance(name, str): 197 raise ValueError("diagnostic name must be a non-empty string") 198 if value is None: 199 self.tests.pop(name, None) 200 else: 201 self.tests[name] = value
Set or remove a diagnostic result by name.
203 def set(self, name: str, value: Any | None) -> None: 204 """Compatibility shortcut for :meth:`set_test`.""" 205 self.set_test(name, value)
Compatibility shortcut for set_test().
54@dataclass(frozen=True) 55class TostResult: 56 """Per-period TOST equivalence test on pre-treatment coefficients.""" 57 pvals: np.ndarray 58 periods: np.ndarray 59 threshold: float 60 threshold_source: str 61 sigma2_fect: float | None 62 max_pval: float 63 all_pass: bool 64 65 def summary(self) -> str: 66 lines = [ 67 f"TOST per pre-period (threshold={self.threshold:.4f}, " 68 f"source={self.threshold_source}):" 69 ] 70 for i, t in enumerate(self.periods): 71 p = float(self.pvals[i]) 72 status = "PASS" if p < 0.05 else "fail" 73 lines.append(f" t={t:+.0f}: p={p:.4f} [{status}]") 74 lines.append( 75 f" max p={self.max_pval:.4f} all_pass={self.all_pass}" 76 ) 77 return "\n".join(lines)
Per-period TOST equivalence test on pre-treatment coefficients.
65 def summary(self) -> str: 66 lines = [ 67 f"TOST per pre-period (threshold={self.threshold:.4f}, " 68 f"source={self.threshold_source}):" 69 ] 70 for i, t in enumerate(self.periods): 71 p = float(self.pvals[i]) 72 status = "PASS" if p < 0.05 else "fail" 73 lines.append(f" t={t:+.0f}: p={p:.4f} [{status}]") 74 lines.append( 75 f" max p={self.max_pval:.4f} all_pass={self.all_pass}" 76 ) 77 return "\n".join(lines)
80@dataclass(frozen=True) 81class PretrendFResult: 82 """Joint F-test for all pre-treatment ATTs equal to zero.""" 83 f_stat: float 84 p_value: float 85 df1: int 86 df2: int 87 88 def summary(self) -> str: 89 return ( 90 f"Pre-trend F-test:\n" 91 f" F({self.df1},{self.df2}) = {self.f_stat:.4f}, " 92 f"p = {self.p_value:.4f}" 93 )
Joint F-test for all pre-treatment ATTs equal to zero.
96@dataclass(frozen=True) 97class EquivFResult: 98 """Equivalence F-test (non-central F) for pre-trend bound.""" 99 p_value: float 100 f_threshold: float 101 n_treated: int | None = None 102 ncp: float | None = None 103 104 def summary(self) -> str: 105 return ( 106 f"Equivalence F-test:\n" 107 f" p = {self.p_value:.4f} (f_threshold={self.f_threshold})" 108 )
Equivalence F-test (non-central F) for pre-trend bound.
111@dataclass(frozen=True) 112class PlaceboResult: 113 """Holdout/refit placebo ATT with bootstrap and TOST p-values. 114 115 ``n_obs`` counts withheld cells from eligible units. ``n_dropped_units`` 116 counts units excluded after holdout; ``n_boot`` and ``n_boot_failed`` 117 count valid and failed placebo bootstrap replications. 118 """ 119 estimate: float 120 se: float 121 p_value: float 122 equiv_p_value: float 123 period: tuple[int, int] 124 n_obs: int 125 n_boot: int 126 n_dropped_units: int = 0 127 n_boot_failed: int = 0 128 129 def summary(self) -> str: 130 return ( 131 f"Placebo test (holdout/refit, window {self.period}):\n" 132 f" ATT={self.estimate:.4f} (SE={self.se:.4f}) " 133 f"p={self.p_value:.4f} equiv_p={self.equiv_p_value:.4f}" 134 )
Holdout/refit placebo ATT with bootstrap and TOST p-values.
n_obs counts withheld cells from eligible units. n_dropped_units
counts units excluded after holdout; n_boot and n_boot_failed
count valid and failed placebo bootstrap replications.
137@dataclass(frozen=True) 138class CarryoverResult: 139 """Carryover-window mean ATT (post-reversal). SE/p deferred.""" 140 estimate: float 141 period: tuple[int, int] 142 143 def summary(self) -> str: 144 return ( 145 f"Carryover test (window {self.period}):\n" 146 f" ATT={self.estimate:.4f}" 147 )
Carryover-window mean ATT (post-reversal). SE/p deferred.
150@dataclass(frozen=True) 151class LooResult: 152 """Post-treatment sensitivity from dropping each post-treatment period. 153 154 This is not a leave-one-pre-period-out no-pretrend test. 155 """ 156 atts: np.ndarray 157 periods: np.ndarray 158 max_change: float 159 160 def summary(self) -> str: 161 return ( 162 f"Leave-one-out:\n" 163 f" Max ATT change = {self.max_change:.6f}" 164 )
Post-treatment sensitivity from dropping each post-treatment period.
This is not a leave-one-pre-period-out no-pretrend test.
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).