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:

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

Container for all fect estimation results.

FectResult( method: str, r_cv: int | None = None, lambda_cv: float | None = None, att_avg: float = 0.0, att_avg_unit: float = 0.0, att_on: numpy.ndarray | None = None, time_on: numpy.ndarray | None = None, count_on: numpy.ndarray | None = None, att_off: numpy.ndarray | None = None, time_off: numpy.ndarray | None = None, beta: numpy.ndarray | None = None, covariate_names: list[str] = <factory>, mu: float = 0.0, alpha: numpy.ndarray | None = None, xi: numpy.ndarray | None = None, factors: numpy.ndarray | None = None, loadings: numpy.ndarray | None = None, Y_ct: numpy.ndarray | None = None, eff: numpy.ndarray | None = None, residuals: numpy.ndarray | None = None, sigma2: float = 0.0, sigma2_fect: float = 0.0, IC: float = 0.0, PC: float = 0.0, rmse: float = 0.0, niter: int = 0, converged: bool = False, inference: pyfector.inference.InferenceResult | None = None, diagnostics: pyfector.Diagnostics | None = None, cv_result: pyfector.cv.CVResult | None = None, panel: pyfector.panel.PanelData | None = None, fit_options: dict[str, typing.Any] = <factory>, seed: int | None = None)
method: str
r_cv: int | None = None
lambda_cv: float | None = None
att_avg: float = 0.0
att_avg_unit: float = 0.0
att_on: numpy.ndarray | None = None
time_on: numpy.ndarray | None = None
count_on: numpy.ndarray | None = None
att_off: numpy.ndarray | None = None
time_off: numpy.ndarray | None = None
beta: numpy.ndarray | None = None
covariate_names: list[str]
mu: float = 0.0
alpha: numpy.ndarray | None = None
xi: numpy.ndarray | None = None
factors: numpy.ndarray | None = None
loadings: numpy.ndarray | None = None
Y_ct: numpy.ndarray | None = None
eff: numpy.ndarray | None = None
residuals: numpy.ndarray | None = None
sigma2: float = 0.0
sigma2_fect: float = 0.0
IC: float = 0.0
PC: float = 0.0
rmse: float = 0.0
niter: int = 0
converged: bool = False
inference: pyfector.inference.InferenceResult | None = None
diagnostics: pyfector.Diagnostics | None = None
cv_result: pyfector.cv.CVResult | None = None
panel: pyfector.panel.PanelData | None = None
fit_options: dict[str, typing.Any]
seed: int | None = None
def summary(self) -> str:
135    def summary(self) -> str:
136        """Print summary table of results."""
137        lines = []
138        lines.append(f"pyfector estimation results")
139        lines.append(f"{'='*60}")
140        lines.append(f"Method: {self.method}")
141        if self.r_cv is not None:
142            lines.append(f"Number of factors (CV): {self.r_cv}")
143        if self.lambda_cv is not None:
144            lines.append(f"Lambda (CV): {self.lambda_cv:.6f}")
145        lines.append(f"Converged: {self.converged} (iter={self.niter})")
146        lines.append(f"Sigma^2: {self.sigma2:.6f}")
147        lines.append(f"Sigma^2_fect (FE baseline): {self.sigma2_fect:.6f}")
148        lines.append(f"")
149        lines.append(f"ATT (average): {self.att_avg:.6f}")
150        if self.inference is not None:
151            inf = self.inference
152            lines.append(f"  SE:     {inf.att_avg_se:.6f}")
153            lines.append(f"  CI:     [{inf.att_avg_ci[0]:.6f}, {inf.att_avg_ci[1]:.6f}]")
154            lines.append(f"  p-val:  {inf.att_avg_pval:.4f}")
155
156        if self.beta is not None and len(self.beta) > 0:
157            lines.append(f"")
158            lines.append(f"Coefficients:")
159            for i, name in enumerate(self.covariate_names):
160                lines.append(f"  {name}: {self.beta[i]:.6f}")
161
162        if self.att_on is not None and self.time_on is not None:
163            lines.append(f"")
164            lines.append(f"Dynamic effects (ATT by relative time):")
165            lines.append(f"  {'Time':>6s}  {'ATT':>10s}  {'Count':>6s}", )
166            for i, t in enumerate(self.time_on):
167                count = self.count_on[i] if self.count_on is not None else ""
168                att = self.att_on[i]
169                if self.inference is not None:
170                    se = self.inference.att_on_se[i]
171                    lines.append(f"  {t:>6.0f}  {att:>10.4f}  ({se:.4f})  {count}")
172                else:
173                    lines.append(f"  {t:>6.0f}  {att:>10.4f}  {count}")
174
175        lines.append(f"{'='*60}")
176        if self.panel is not None:
177            lines.append(f"N={self.panel.N}, T={self.panel.T}")
178        if self.seed is not None:
179            lines.append(f"Seed: {self.seed}")
180        if self.diagnostics is not None:
181            lines.append("")
182            lines.append(self.diagnostics.summary())
183        return "\n".join(lines)

Print summary table of results.

def plot(self, kind='gap', **kwargs):
188    def plot(self, kind="gap", **kwargs):
189        """Plot results. Shortcut for ``pyfector.plot(self, kind, ...)``."""
190        from .plotting import plot as _plot
191        return _plot(self, kind=kind, **kwargs)

Plot results. Shortcut for pyfector.plot(self, kind, ...).

def diagnose(self, **kwargs):
193    def diagnose(self, **kwargs):
194        """Run diagnostic tests. Shortcut for ``pyfector.run_diagnostics(self, ...)``."""
195        from .diagnostics import run_diagnostics
196        return run_diagnostics(self, **kwargs)

Run diagnostic tests. Shortcut for pyfector.run_diagnostics(self, ...).

def fect( data, Y: str, D: str, index: tuple[str, str], X: list[str] | None = None, W: str | None = None, group: str | None = None, method: Literal['fe', 'ife', 'mc', 'cfe', 'both'] = 'ife', force: Literal['none', 'unit', 'time', 'two-way'] = 'two-way', r: int | tuple[int, int] = 0, lam: float | None = None, nlambda: int = 10, lambda_candidates: list[float] | numpy.ndarray | None = None, CV: bool = True, k: int = 10, cv_prop: float = 0.1, cv_nobs: int = 3, cv_treat: bool = True, cv_donut: int = 0, criterion: str = 'mspe', cv_rule: Literal['min', 'onepct'] = 'min', se: bool = False, vartype: Literal['bootstrap', 'jackknife'] = 'bootstrap', nboots: int = 200, alpha: float = 0.05, tol: float = 1e-07, max_iter: int = 5000, min_T0: int = 1, min_T0_strict: bool = False, max_missing: float = 1.0, normalize: bool = False, Z: list[str] | None = None, Q: list[str] | None = None, device: Literal['cpu', 'gpu'] = 'cpu', n_jobs: int | None = -1, seed: int | None = None, diagnostics: Union[Literal['none', 'full'], list[str]] = 'none', diagnostics_options: dict | None = None) -> FectResult:
199def fect(
200    data,
201    Y: str,
202    D: str,
203    index: tuple[str, str],
204    X: list[str] | None = None,
205    W: str | None = None,
206    group: str | None = None,
207    method: Literal["fe", "ife", "mc", "cfe", "both"] = "ife",
208    force: Literal["none", "unit", "time", "two-way"] = "two-way",
209    r: int | tuple[int, int] = 0,
210    lam: float | None = None,
211    nlambda: int = 10,
212    lambda_candidates: list[float] | np.ndarray | None = None,
213    CV: bool = True,
214    k: int = 10,
215    cv_prop: float = 0.1,
216    cv_nobs: int = 3,
217    cv_treat: bool = True,
218    cv_donut: int = 0,
219    criterion: str = "mspe",
220    cv_rule: Literal["min", "onepct"] = "min",
221    se: bool = False,
222    vartype: Literal["bootstrap", "jackknife"] = "bootstrap",
223    nboots: int = 200,
224    alpha: float = 0.05,
225    tol: float = 1e-7,
226    max_iter: int = 5000,
227    min_T0: int = 1,
228    min_T0_strict: bool = False,
229    max_missing: float = 1.0,
230    normalize: bool = False,
231    # CFE-specific
232    Z: list[str] | None = None,
233    Q: list[str] | None = None,
234    # Performance
235    device: Literal["cpu", "gpu"] = "cpu",
236    n_jobs: int | None = -1,
237    seed: int | None = None,
238    # Diagnostics (optional; run at fit time and attached to result)
239    diagnostics: Literal["none", "full"] | list[str] = "none",
240    diagnostics_options: dict | None = None,
241) -> FectResult:
242    """Estimate counterfactual treatment effects for panel data.
243
244    This is the main Python entry point for the counterfactual estimator
245    workflow.  Where the paper and the historical R package differ,
246    pyfector defaults to the paper's statistical definition and exposes
247    R-package-style behavior through explicit options.
248
249    Missing outcome policy
250    ----------------------
251    pyfector distinguishes raw missing outcomes from counterfactual
252    missingness caused by treatment.  Observed untreated cells
253    (``D == 0`` and non-missing ``Y``) fit the response surface.  Observed
254    treated cells (``D == 1`` and non-missing ``Y``) contribute to ATT as
255    ``Y - Y_ct``.  If a treated outcome is missing in the input data, the
256    model can still produce a counterfactual ``Y_ct`` for that cell, but
257    the cell is not counted in ``att_avg`` or ``att_on`` because the
258    treated potential outcome was not observed.
259
260    By default, ``min_T0`` is enforced only for treated and reversal
261    units.  Sparse controls are retained if they have at least one
262    observed outcome, because they may still inform the low-rank response
263    surface.  Set ``min_T0_strict=True`` to require controls to satisfy
264    ``min_T0`` too, matching the more conservative R fect sparse-panel
265    behavior.
266
267    Parameters
268    ----------
269    data : polars.DataFrame, pandas.DataFrame
270        Long-format panel data.
271    Y, D : str
272        Column names for outcome and binary treatment indicator.
273    index : (str, str)
274        Column names for (unit_id, time_period).
275    X : list of str, optional
276        Time-varying covariates.
277    W : str, optional
278        Observation weight column.
279    group : str, optional
280        Reserved for grouped estimation. Currently raises
281        ``NotImplementedError`` when supplied.
282    method : {"fe", "ife", "mc", "cfe", "both"}
283        Estimation method.
284    force : {"none", "unit", "time", "two-way"}
285        Fixed effects specification.
286    r : int or (int, int)
287        Number of factors.  If tuple, CV selects from range.
288    lam : float, optional
289        Nuclear norm penalty for MC.  If None with CV=True, auto-selected.
290    nlambda : int
291        Number of automatically generated lambda candidates for MC CV.
292    lambda_candidates : array-like, optional
293        Explicit non-negative lambda candidates for MC CV. When supplied,
294        ``nlambda`` is ignored.
295    CV : bool
296        If True, cross-validate over ``r`` for IFE when ``r`` is a tuple,
297        or over ``lam`` for MC when ``lam`` is None.
298    k : int
299        Number of CV folds.
300    cv_prop : float
301        Fraction of eligible observed control cells masked per CV fold.
302    cv_nobs : int
303        Number of consecutive within-unit observations to mask as a block.
304    cv_treat : bool
305        If True, restrict CV masks to pre-treatment cells of ever-treated
306        units. If False, use all observed control cells.
307    cv_donut : int
308        Exclude this many periods around treatment onset from CV evaluation.
309    criterion : {"mspe", "gmspe", "mad"}
310        Cross-validation loss.
311    cv_rule : {"min", "onepct"}
312        CV selection rule. ``"min"`` chooses the strict minimum-score
313        candidate and is the paper-faithful default. ``"onepct"`` chooses
314        the simplest candidate within 1% of the best score (lower ``r`` for
315        IFE, higher ``lam`` for MC).
316    se : bool
317        Compute standard errors via bootstrap/jackknife.
318    vartype : {"bootstrap", "jackknife"}
319        Inference method when ``se=True``.
320    nboots : int
321        Number of bootstrap replications. Ignored for jackknife.
322    alpha : float
323        Significance level for confidence intervals and tests.
324    tol : float
325        EM convergence tolerance for final point estimation.
326    max_iter : int
327        Maximum EM iterations.
328    min_T0 : int
329        Minimum untreated/pre-treatment observed periods. By default this is
330        enforced only for treated and treatment-reversal units.
331    min_T0_strict : bool
332        If True, enforce ``min_T0`` on all units, including controls. This
333        matches R fect's conservative handling of sparse control rows.
334    max_missing : float
335        Maximum missing-outcome fraction per unit, in ``[0, 1]``. Units with
336        no observed outcomes are always dropped, regardless of this threshold,
337        because they provide neither fitting information nor observed treated
338        effects.
339    normalize : bool
340        If True, estimate on an outcome standardized by its observed standard
341        deviation, then transform effects back to the original scale.
342    Z, Q : list of str, optional
343        Reserved CFE interaction arguments. Currently raise
344        ``NotImplementedError`` when supplied.
345    device : {"cpu", "gpu"}
346        Compute device.
347    n_jobs : int, optional
348        Parallel workers for CV and bootstrap. ``-1`` or ``None`` uses
349        all available CPUs.
350    seed : int, optional
351        Random seed for full reproducibility.
352    """
353    # Set device
354    set_device(device)
355    xp = get_backend()
356    n_jobs = _resolve_n_jobs(n_jobs)
357    if device == "gpu":
358        n_jobs = 1
359
360    if group is not None:
361        raise NotImplementedError("The `group` argument is not implemented yet.")
362    if Z is not None or Q is not None:
363        raise NotImplementedError("The `Z` and `Q` CFE interaction arguments are not implemented yet.")
364    if criterion not in {"mspe", "gmspe", "mad"}:
365        raise ValueError("criterion must be 'mspe', 'gmspe', or 'mad'")
366    if cv_rule not in {"min", "onepct"}:
367        raise ValueError("cv_rule must be 'min' or 'onepct'")
368    if min_T0 < 0:
369        raise ValueError("min_T0 must be non-negative")
370    if not 0.0 <= max_missing <= 1.0:
371        raise ValueError("max_missing must be between 0 and 1")
372
373    # Validate diagnostics request before doing any expensive estimation
374    # so users don't wait for a 30-min MC fit only to find their config
375    # is wrong.
376    requested_diag = validate_diagnostics_request(
377        diagnostics, diagnostics_options, se,
378    )
379
380    # Map force string to int
381    force_map = {"none": 0, "unit": 1, "time": 2, "two-way": 3}
382    force_int = force_map[force]
383
384    # Prepare panel data
385    panel = prepare_panel(
386        data, Y=Y, D=D, index=index, X=X, W=W,
387        group=group, min_T0=min_T0, min_T0_strict=min_T0_strict,
388        max_missing=max_missing,
389    )
390
391    # Move to device
392    Y_mat = to_device(panel.Y)
393    D_mat = to_device(panel.D)
394    I_mat = to_device(panel.I)
395    II_mat = to_device(panel.II)
396    X_mat = to_device(panel.X) if panel.X is not None else None
397    W_mat = to_device(panel.W) if panel.W is not None else None
398
399    # Normalize
400    norm_factor = 1.0
401    if normalize:
402        sd_y = float(xp.std(Y_mat[I_mat > 0]))
403        if sd_y > 0:
404            Y_mat = Y_mat / sd_y
405            norm_factor = sd_y
406
407    # Initial fit
408    Y0, beta0 = initial_fit(Y_mat, X_mat, II_mat, force_int)
409
410    # Determine r and lambda
411    r_cv = None
412    lambda_cv = None
413    cv_result = None
414
415    if method == "ife":
416        if isinstance(r, tuple) and CV:
417            cv_result = cv_ife(
418                Y_mat, Y0, X_mat, I_mat, II_mat, D_mat, W_mat, beta0,
419                force=force_int, r_range=r, k=k, cv_prop=cv_prop,
420                cv_nobs=cv_nobs, cv_treat=cv_treat, cv_donut=cv_donut,
421                criterion=criterion, cv_rule=cv_rule,
422                tol=tol, max_iter=max_iter,
423                n_jobs=n_jobs, seed=seed,
424            )
425            r_cv = cv_result.best_r
426        else:
427            r_cv = r if isinstance(r, int) else r[0]
428
429    elif method == "mc":
430        if lam is None and CV:
431            cv_result = cv_mc(
432                Y_mat, Y0, X_mat, I_mat, II_mat, D_mat, W_mat, beta0,
433                force=force_int, lambda_candidates=lambda_candidates,
434                nlambda=nlambda, k=k, cv_prop=cv_prop,
435                cv_nobs=cv_nobs, cv_treat=cv_treat, cv_donut=cv_donut,
436                criterion=criterion, cv_rule=cv_rule,
437                tol=tol, max_iter=max_iter,
438                n_jobs=n_jobs, seed=seed,
439            )
440            lambda_cv = cv_result.best_lambda
441        else:
442            lambda_cv = lam if lam is not None else 0.0
443
444    elif method == "fe":
445        r_cv = 0
446
447    elif method == "cfe":
448        r_cv = r if isinstance(r, int) else r[0]
449
450    # Point estimation
451    if method in ("fe", "ife"):
452        est = estimate_ife(
453            Y_mat, Y0, X_mat, II_mat, W_mat, beta0,
454            r=r_cv, force=force_int, tol=tol, max_iter=max_iter,
455        )
456    elif method == "mc":
457        est = estimate_mc(
458            Y_mat, Y0, X_mat, II_mat, W_mat, beta0,
459            lam=lambda_cv, force=force_int, tol=tol, max_iter=max_iter,
460        )
461    elif method == "cfe":
462        est = estimate_cfe(
463            Y_mat, Y0, X_mat, II_mat, W_mat, beta0,
464            r=r_cv, force=force_int, tol=tol, max_iter=max_iter,
465        )
466    elif method == "both":
467        # Run both IFE and MC, return IFE results with MC comparison
468        if isinstance(r, tuple) and CV:
469            cv_result = cv_ife(
470                Y_mat, Y0, X_mat, I_mat, II_mat, D_mat, W_mat, beta0,
471                force=force_int, r_range=r, k=k, cv_prop=cv_prop,
472                cv_nobs=cv_nobs, cv_treat=cv_treat, cv_donut=cv_donut,
473                criterion=criterion, cv_rule=cv_rule,
474                tol=tol, max_iter=max_iter,
475                n_jobs=n_jobs, seed=seed,
476            )
477            r_cv = cv_result.best_r
478        else:
479            r_cv = r if isinstance(r, int) else r[0]
480        est = estimate_ife(
481            Y_mat, Y0, X_mat, II_mat, W_mat, beta0,
482            r=r_cv, force=force_int, tol=tol, max_iter=max_iter,
483        )
484    else:
485        raise ValueError(f"Unknown method: {method}")
486
487    # Compute effects
488    eff = Y_mat - est.fit
489    Y_ct = est.fit
490
491    # Additive-FE baseline residual variance (Liu et al. 2024 sigma2.fect).
492    # For method="fe" the main estimator IS the additive-FE pass, so reuse
493    # est.sigma2. For ife/mc/cfe/both, run an extra r=0 IFE pass on the
494    # same panel with the user's requested FE structure.
495    if method == "fe":
496        sigma2_fect_value = float(est.sigma2)
497    else:
498        est_fect = estimate_ife(
499            Y_mat, Y0, X_mat, II_mat, W_mat, beta0,
500            r=0, force=force_int, tol=tol, max_iter=max_iter,
501        )
502        sigma2_fect_value = float(est_fect.sigma2)
503
504    # Denormalize
505    if normalize and norm_factor != 1.0:
506        eff = eff * norm_factor
507        Y_ct = Y_ct * norm_factor
508        Y_mat = Y_mat * norm_factor
509        if est.beta is not None:
510            est = est._replace(beta=est.beta * norm_factor)
511        sigma2_fect_value *= norm_factor ** 2
512
513    # ATT computation
514    T_on = to_device(panel.T_on)
515    att_avg, att_on, time_on, count_on, att_avg_unit = _compute_effects(
516        to_numpy(eff), to_numpy(D_mat), to_numpy(panel.T_on), to_numpy(I_mat),
517    )
518
519    # Build result
520    result = FectResult(
521        method=method,
522        r_cv=r_cv,
523        lambda_cv=lambda_cv,
524        att_avg=att_avg,
525        att_avg_unit=att_avg_unit,
526        att_on=att_on,
527        time_on=time_on,
528        count_on=count_on,
529        beta=to_numpy(est.beta) if est.beta is not None else None,
530        covariate_names=panel.covariate_names,
531        mu=est.mu,
532        alpha=to_numpy(est.alpha) if est.alpha is not None else None,
533        xi=to_numpy(est.xi) if est.xi is not None else None,
534        factors=to_numpy(est.factors) if est.factors is not None else None,
535        loadings=to_numpy(est.loadings) if est.loadings is not None else None,
536        Y_ct=to_numpy(Y_ct),
537        eff=to_numpy(eff),
538        residuals=to_numpy(est.residuals),
539        sigma2=est.sigma2,
540        sigma2_fect=sigma2_fect_value,
541        IC=est.IC,
542        PC=est.PC,
543        niter=est.niter,
544        converged=est.converged,
545        cv_result=cv_result,
546        panel=panel,
547        fit_options={
548            "force": force,
549            "force_int": force_int,
550            "tol": tol,
551            "max_iter": max_iter,
552            "normalize": normalize,
553            "norm_factor": norm_factor,
554            "vartype": vartype,
555            "nboots": nboots,
556            "n_jobs": n_jobs,
557        },
558        seed=seed,
559    )
560
561    # Inference
562    if se:
563        result.inference = _run_inference(
564            result, panel, Y_mat, X_mat, W_mat, beta0, Y0,
565            method=method, r_cv=r_cv, lambda_cv=lambda_cv,
566            force_int=force_int, tol=tol, max_iter=max_iter,
567            vartype=vartype, nboots=nboots, alpha=alpha,
568            n_jobs=n_jobs, seed=seed, normalize=normalize,
569            norm_factor=norm_factor,
570        )
571
572    # Run requested diagnostics at fit time. requested_diag is None when
573    # diagnostics="none". Validation already enforced se=True and
574    # required-config presence.
575    if requested_diag is not None:
576        opts = dict(diagnostics_options or {})
577        if "loo" in requested_diag:
578            opts["loo"] = True
579        else:
580            opts.setdefault("loo", False)
581        result.diagnostics = _run_diagnostics(
582            result, _requested=requested_diag, **opts,
583        )
584
585    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 NotImplementedError when 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, nlambda is ignored. CV : bool If True, cross-validate over r for IFE when r is a tuple, or over lam for MC when lam is 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 (lower r for IFE, higher lam for MC). se : bool Compute standard errors via bootstrap/jackknife. vartype : {"bootstrap", "jackknife"} Inference method when se=True. nboots : int Number of bootstrap replications. 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_T0 on 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 NotImplementedError when supplied. device : {"cpu", "gpu"} Compute device. n_jobs : int, optional Parallel workers for CV and bootstrap. -1 or None uses all available CPUs. seed : int, optional Random seed for full reproducibility.