Forward Difference-in-Differences (FDID)

Contents

Forward Difference-in-Differences (FDID)#

When to Use This Estimator#

Difference-in-differences (DiD) is the workhorse of quasi-experimental causal inference, but it rests on a parallel-trends assumption: the treated unit’s untreated outcome would have moved in lockstep with the average of the controls. With a large, heterogeneous pool of candidate controls that assumption is rarely credible for the pool as a whole – most of the controls are simply the wrong comparison. The usual escape hatches each have a catch:

  • Plain DiD uses every control with equal weight. One badly mismatched control contaminates the average, and its bias does not shrink as the panel grows.

  • Synthetic control (SC, [ABADIE2010]) weights the controls on the simplex, but is justified only as the pre-period grows without bound, and has no inference theory when the data are non-stationary of unknown form – exactly the regime of most marketing and macro panels.

  • The panel-data approach of Hsiao, Ching and Wan ([HCW]) and its forward-selected variant ([fsPDA]) fit an unrestricted regression on the controls. When the number of donors \(N_0\) exceeds the number of pre-treatment periods \(T_0\) – common in store/geo studies – they overfit in-sample and predict poorly out-of-sample.

Forward DiD (Li [Li2024]) targets precisely this regime: many candidate controls, a short-to-moderate pre-period, and a need for valid inference under non-stationarity. It keeps DiD’s transparency – an equal-weighted comparison group plus a single intercept – but chooses which controls enter the comparison by a greedy forward search on pre-treatment fit. Because only one parameter is ever estimated (the DiD intercept \(b_0\)), no matter how many controls are selected, overfitting is impossible and the textbook DiD standard error applies. Its advantages, in Li’s own summary:

  1. It is a flexible drop-in for DiD, usable even when DiD’s all-controls parallel trend is too restrictive.

  2. It accommodates any number of controls, including \(N_0 > T_0\).

  3. There are no overfitting concerns – one parameter after selection.

  4. It is computationally cheap: a greedy \(O(N_0^2)\) search, not the \(2^{N_0}\) subsets of the optimal procedure.

  5. It has inference theory valid for stationary and non-stationary data, which SC and HCW lack.

Forward Selection vs. Matching and Weighting#

Every synthetic-control-family estimator answers the same question – what comparison reproduces the treated unit’s untreated path? – but each makes a different structural bet about the comparison. Forward DiD’s bet is distinctive:

A subset of the controls shares the treated unit’s trend; find that subset and average it with equal weights.

It does not try to weight all controls cleverly (SC), nor regress on all of them (HCW), nor trust all of them (DiD). It selects. The selection is greedy: add, one at a time, the control that most improves pre-treatment fit, trace the fit as the subset grows, and keep the subset that fits best.

DiD

Synthetic control

Forward DiD

Comparison

all controls, equal weight

all controls, simplex weights

a selected subset, equal weight

Free parameters

1 (intercept)

\(N\) weights

1 (intercept) – after selection

Overfitting risk

none

controlled by the simplex

none – one parameter

Key assumption

all controls are parallel

treated is in the convex hull

some subset is parallel

Inference under non-stationarity

standard

none available

standard (Prop. 2.1)

The equal weights are the crux. Because the selected controls enter through a single average – not \(|\mathcal{D}|\) separate coefficients – adding more controls cannot increase the model’s degrees of freedom. This is an implicit regularization that buys both the overfitting immunity and the clean, DiD-style inference theory. Forward DiD is therefore best read as DiD with a principled, data-driven choice of comparison group, not as a new weighting scheme.

Notation#

Index the units by \(j\). Let \(j = 1\) be the single treated unit and \(\mathcal{N} \coloneqq \{1, \ldots, N\}\) all units; the donor pool is \(\mathcal{N}_0 \coloneqq \mathcal{N} \setminus \{1\}\), with \(|\mathcal{N}_0| = N_0\). A selected subset \(\mathcal{D} \subseteq \mathcal{N}_0\) is the comparison group; write its equal-weighted average outcome as

\[\bar{y}_{\mathcal{D}, t} = \frac{1}{|\mathcal{D}|} \sum_{j \in \mathcal{D}} y_{jt}.\]

Time runs over \(t \in \mathcal{T} \coloneqq \{1, \ldots, T\}\); the intervention takes effect after \(T_0\), giving a pre-period \(\mathcal{T}_1 \coloneqq \{t \in \mathcal{T} : t \le T_0\}\) (so \(|\mathcal{T}_1| = T_0\)) and a post-period \(\mathcal{T}_2 \coloneqq \{t \in \mathcal{T} : t > T_0\}\) (so \(|\mathcal{T}_2| = T - T_0\)). Potential outcomes are \(y_{jt}^N\) (without the intervention) and \(y_{jt}^I\) (under it); for the treated unit we observe \(y_{1t} = y_{1t}^N\) on \(\mathcal{T}_1\) and \(y_{1t} = y_{1t}^I\) on \(\mathcal{T}_2\). The estimand is the average treatment effect on the treated,

\[\mathrm{ATT} = \frac{1}{|\mathcal{T}_2|} \sum_{t \in \mathcal{T}_2} \bigl(y_{1t}^I - y_{1t}^N\bigr).\]

Notation bridge

Li [Li2024] writes the treated outcome \(y_{tr,t}\), the selected control set \(\mathcal{N}_{co}\) of size \(N_{co}\), its average \(\bar{y}_{\mathcal{N}_{co}, t}\), the intercept \(\alpha\), and uses \(T_1\) / \(T_2\) for the pre/post sample sizes (treatment at \(T_1 + 1\)). In the mlsynth canon these become the treated unit \(j = 1\) (hence \(y_{1t}\)), the comparison group \(\mathcal{D} \subseteq \mathcal{N}_0\) with average \(\bar{y}_{\mathcal{D}, t}\), the level-shift intercept \(b_0\) (the canon reserves \(\alpha\) for the significance level), and the single split point \(T_0\).

Mathematical Formulation#

The DiD model#

For a fixed comparison group \(\mathcal{D}\), Forward DiD posits that the treated unit’s untreated outcome equals the group average plus a constant level shift:

\[y_{1t}^N = b_0 + \bar{y}_{\mathcal{D}, t} + v_t, \qquad t = 1, \ldots, T,\]

with \(b_0\) an unknown intercept and \(v_t\) a zero-mean, weakly dependent error. Both \(y_{1t}^N\) and \(\bar{y}_{\mathcal{D}, t}\) may be non-stationary (trending) provided their difference is stationary – this is the Forward DiD parallel-trends condition. The intercept is estimated by least squares on the pre-period,

\[\widehat{b}_0 = \frac{1}{T_0} \sum_{t \in \mathcal{T}_1} \bigl(y_{1t} - \bar{y}_{\mathcal{D}, t}\bigr),\]

so the in-sample fit and out-of-sample counterfactual are

\[\widehat{y}_{1t} = \widehat{b}_0 + \bar{y}_{\mathcal{D}, t}, \qquad t = 1, \ldots, T,\]

and the per-period effect is \(\tau_t = y_{1t} - \widehat{y}_{1t}\), whose post-period average is the ATT,

\[\widehat{\tau} = \frac{1}{|\mathcal{T}_2|} \sum_{t \in \mathcal{T}_2} \bigl(y_{1t} - \widehat{y}_{1t}\bigr).\]

Because the model has a single parameter, the pre-treatment fit quality is summarized by an \(R^2\) (identical to the adjusted \(R^2\), since there is only one regressor coefficient):

\[R^2_{\mathcal{D}} = 1 - \frac{\sum_{t \in \mathcal{T}_1} \widehat{v}_t^2} {\sum_{t \in \mathcal{T}_1} (y_{1t} - \bar{y}_1)^2}, \qquad \widehat{v}_t = y_{1t} - \bar{y}_{\mathcal{D}, t} - \widehat{b}_0,\]

where \(\bar{y}_1\) is the treated unit’s pre-period mean.

Note

\(\tau_t\) and \(\widehat{\tau}\) are exactly the gap and att the result object returns (computed in mlsynth.utils.effectutils), and the pre/post fit are rmse_pre / rmse_post (mlsynth.utils.fitutils) – the math here names the quantities mlsynth.FDID.fit() reports.

The forward-selection algorithm#

Maximizing \(R^2_{\mathcal{D}}\) is equivalent to minimizing the pre-period residual variance \(T_0^{-1} \sum_{t \in \mathcal{T}_1} \widehat{v}_t^2\). Forward DiD searches over comparison groups greedily:

  1. Step 1. For each single donor \(i \in \mathcal{N}_0\), form the one-unit comparison group and compute its pre-period \(R^2\). Keep the best single donor, \(\widehat{c}_1\).

  2. Step 2. Add to \(\{\widehat{c}_1\}\) each of the remaining \(N_0 - 1\) donors in turn; keep the two-unit group with the highest \(R^2\).

  3. Step 3. Continue, adding one donor at a time, until all \(N_0\) donors are in. This yields \(N_0\) nested groups (sizes \(1, 2, \ldots, N_0\)); select the one, \(\widehat{\mathcal{D}} = \mathcal{N}_{co}\), with the largest \(R^2\).

The greedy search evaluates \(1 + 2 + \cdots + N_0 = N_0(N_0+1)/2\) sub-models, not the \(2^{N_0}\) of the exhaustive procedure (for \(N_0 = 60\), that is 1,830 versus \(1.15 \times 10^{18}\)). The final group \(\widehat{\mathcal{D}}\) is then plugged into the DiD formulas above for the ATT, its standard error, and the \(R^2\).

How mlsynth computes this: incremental means and a batched \(R^2\)#

Read literally, each step of the algorithm re-forms a subset average from scratch and re-fits a DiD regression for every remaining candidate – an \(O(N)\) rebuild times an \(O(N)\) candidate loop times the per-candidate work, repeated \(N\) times. mlsynth’s forward_did_select() instead collapses each step into a handful of vectorized NumPy operations through three observations.

  1. The comparison average is updated incrementally, never rebuilt. Let \(\mathbf{m}^{(k)} \in \mathbb{R}^{T}\) be the running average over the \(k\) already-selected controls. Adding control \(c\) gives the \((k+1)\)-average by a single rank-one update,

\[\mathbf{m}^{(k+1)} = \mathbf{m}^{(k)} + \frac{\mathbf{y}_c - \mathbf{m}^{(k)}}{k + 1},\]

which is \(O(T)\), not \(O(kT)\). This is _update_synthetic_control() (current_mean + (control - current_mean) / (k + 1)).

  1. All candidate averages for a step are built in one matrix. At step \(k\), let \(\mathbf{Y}_{\mathcal{R}} \in \mathbb{R}^{T_0 \times |\mathcal{R}|}\) stack the pre-period columns of the remaining candidates \(\mathcal{R}\). Every candidate \((k+1)\)-average – one per column – is formed simultaneously by broadcasting the running pre-period mean \(\mathbf{m}^{(k)}_{\mathcal{T}_1}\):

\[\mathbf{M} = \frac{k\,\mathbf{m}^{(k)}_{\mathcal{T}_1}\mathbf{1}^\top + \mathbf{Y}_{\mathcal{R}}}{k + 1} \in \mathbb{R}^{T_0 \times |\mathcal{R}|}.\]

In code this is the one line new_means = (current_mean_pre[:, None] * k + candidates) / (k + 1) inside _select_best_donor().

  1. The intercept \(b_0\) drops out, so scoring is pure inner products. This is the step that removes the per-candidate regression entirely. Profiling out \(b_0\) from the DiD loss is exactly centering: the fitted residual for candidate column \(\ell\) is \(\widehat{v}_t = (y_{1t} - \bar y_1) - (M_{t\ell} - \bar M_\ell)\). Writing \(\widetilde{\mathbf{y}} = \mathbf{y}_{1,\mathcal{T}_1} - \bar y_1\) (precomputed once, with its norm \(\|\widetilde{\mathbf{y}}\|_2^2 = \mathrm{ss}_{\text{tot}}\)), the residual sum of squares for all candidates is

\[\mathrm{SSR}_\ell = \mathrm{ss}_{\text{tot}} + \underbrace{\| \mathbf{M}_\ell - \bar M_\ell \|_2^2}_{\text{column SS}} - 2\,\underbrace{\widetilde{\mathbf{y}}^\top (\mathbf{M}_\ell - \bar M_\ell)}_{\text{one matrix--vector product}}, \qquad R^2_\ell = 1 - \frac{\mathrm{SSR}_\ell}{\mathrm{ss}_{\text{tot}}}.\]

The cross term for the whole candidate set is the single matvec \(\widetilde{\mathbf{y}}^\top(\mathbf{M} - \bar{\mathbf{M}})\); the column sums of squares are one reduction. This is _r2_batch() – no candidate is ever regressed, and \(b_0\) is never explicitly solved during the search (it is recovered only once, for the winning group, in did_from_mean()).

Taken together, a forward step costs \(O(T_0 |\mathcal{R}|)\) and the entire search is \(O(T_0 N_0^2)\), with the inner loop expressed as a broadcast, a matrix–vector product, a column reduction, and an argmax – no Python-level loop over candidates and no per-candidate solve. This is what lets the implementation run the selection over \(\sim\)1,500 controls, and what makes the \(M = 5{,}000\) replication Monte Carlo in Verification tractable.

Assumptions#

Assumption 1 (Forward DiD parallel trends). There exists a subset \(\mathcal{D} \subseteq \mathcal{N}_0\) and a constant \(b_0\) such that \(y_{1t}^N = b_0 + \bar{y}_{\mathcal{D}, t} + v_t\) for all \(t\), where \(v_t\) is a weakly dependent process with zero mean and finite variance.

Remark. This says the gap between the treated unit and the selected comparison group is stable across the pre- and post-periods up to a mean-zero shock. It is strictly weaker than DiD’s requirement that all controls be parallel: it asks only that some equal-weighted subset be parallel. Both \(y_{1t}^N\) and \(\bar{y}_{\mathcal{D}, t}\) may trend arbitrarily (even non-linearly), so long as their difference is trendless – which is what makes the method valid under non-stationarity.

Assumption 2 (no anticipation / no interference). Controls are untreated throughout, and the treated unit’s outcome equals its untreated potential outcome in the pre-period.

Remark. Standard in the DiD/SC literature. It is what lets the pre-period identify the comparison group: if controls were themselves affected by the intervention (spillover), their pre/post relationship to the treated unit would shift and selection would be biased.

Assumption 3 (regularity for inference). The partial sums of \(v_t\) obey a central limit theorem; errors are weakly dependent with finite long-run variance.

Remark. This is what delivers the asymptotic normality in Proposition 2.1 below, and it holds for the broad class of stationary, weakly-dependent error processes – it does not require \(v_t\) to be i.i.d. or the levels \(y_{1t}^N\) to be stationary.

When not to use Forward DiD

Assumption 1 fails when no subset of controls can track the treated unit – above all when the treated unit lies outside the range of the controls (e.g. its outcome trends upward more steeply than every control’s). Equal weights cannot extrapolate beyond the controls, so no selection rescues it. In that regime Li points to methods that let the treated unit sit outside the control hull: the augmented DiD ([ADID]), factor-model / interactive-fixed-effect estimators, or SC with an intercept.

Diagnostic: a side-by-side panel where Forward PTA holds vs. fails#

The pretreatment \(R^2\) returned by FDID is the natural empirical check on Assumption 1. The script below draws two panels with the same underlying common factor and the same true ATT of zero, differing only in the treated unit’s factor loading:

  • Panel A (treated_loading = 1). The treated unit shares the controls’ single-factor loading. Assumption 1 holds for any subset of the donors.

  • Panel B (treated_loading = 3). The treated unit trends three times faster than any control. No subset of the equal-weighted donors can extrapolate the steeper trend – Assumption 1 fails.

import numpy as np
import pandas as pd

from mlsynth import FDID


def make_panel(*, treated_loading, n_controls=40, T1=24, T2=12, seed=0):
    """Synthetic panel with one common smoothly-trending factor.

    The treated unit's loading on the factor is ``treated_loading``; every
    control loads with coefficient 1. True ATT = 0 by construction.
    """
    rng = np.random.default_rng(seed)
    T = T1 + T2
    f = np.cumsum(rng.standard_normal(T)) / np.sqrt(T)
    eps_tr = 0.10 * rng.standard_normal(T)
    eps_co = 0.10 * rng.standard_normal((n_controls, T))
    y_tr = 1.0 + treated_loading * f + eps_tr
    y_co = 1.0 + 1.0 * f[None, :] + eps_co
    rows = []
    for t in range(T):
        rows.append({"unit": "treated", "time": t, "y": float(y_tr[t]),
                     "treat": int(t >= T1)})
        for i in range(n_controls):
            rows.append({"unit": f"c{i}", "time": t, "y": float(y_co[i, t]),
                         "treat": 0})
    return pd.DataFrame(rows)


for label, loading in [("Forward PTA holds (loading=1)", 1.0),
                        ("Forward PTA fails (loading=3)", 3.0)]:
    df = make_panel(treated_loading=loading, seed=0)
    res = FDID({"df": df, "outcome": "y", "treat": "treat",
                 "unitid": "unit", "time": "time",
                 "display_graphs": False}).fit()
    print(f"{label:35s}  FDID ATT = {res.fdid.att:+.3f}  "
           f"R^2 = {res.fdid.r_squared:.3f}  "
           f"selected {len(res.fdid.selected_names)} donors")

prints:

Forward PTA holds (loading=1)        FDID ATT = -0.009  R^2 = 0.975  selected 4 donors
Forward PTA fails (loading=3)        FDID ATT = -0.802  R^2 = 0.588  selected 2 donors

Two lessons jump out:

  1. The \(R^2\) is the warning signal. When Forward PTA holds, FDID hits \(R^2 \approx 0.98\) and recovers the true zero ATT to within noise. When it fails, the in-sample fit drops to \(R^2 \approx 0.59\) – a much weaker fit on a panel of the same dimensions. Compare the two against the same threshold you would apply in a forecast exercise (Li’s empirical applications report \(R^2\) of 0.76-0.91 on Atlanta / San Diego / San Jose). When the pre-fit is weak, distrust the post-fit ATT.

  2. The bias is large and one-sided. When Forward PTA fails because the treated unit trends faster than any subset of controls, FDID’s equal-weighted comparison group flattens the post-period counterfactual and the ATT is biased toward zero from above (here: \(-0.80\) against a true 0). A clean placebo on the pre-period will also be off: the in-sample residuals are systematically wrong when the controls cannot extrapolate the treated unit’s trend.

If your application reports \(R^2\) materially below the threshold you would consider acceptable for a forecast (say, < 0.7), treat the ATT estimate as a lower bound on the magnitude of misspecification, not an estimate of the causal effect, and switch to one of the methods Li flags for the out-of-hull case: Forward Difference-in-Differences (FDID) with a different comparison construction is unlikely to recover it – try the augmented DiD, a factor-model / interactive-fixed-effects estimator, or synthetic control with an intercept.

Inference#

Because Forward DiD estimates a single parameter, its inference is the textbook DiD inference. Let \(\widehat{\sigma}^2_{\mathcal{D}} = T_0^{-1} \sum_{t \in \mathcal{T}_1} \widehat{v}_t^2\) be the pre-period residual variance on the selected group. Li’s Proposition 2.1 establishes

\[\left| \Pr\!\left( \frac{\sqrt{|\mathcal{T}_2|}\,(\widehat{\tau} - \mathrm{ATT})} {\widehat{\sigma}_{\mathcal{D}}} \le a \right) - \Phi(a) \right| \to 0, \qquad a \in \mathbb{R},\]

as \(T_0, |\mathcal{T}_2| \to \infty\), where \(\Phi\) is the standard-normal CDF. mlsynth reports the finite-sample standard error that also carries the estimation error in \(\widehat{b}_0\):

\[\mathrm{SE}(\widehat{\tau}) = \widehat{\sigma}_{\mathcal{D}} \sqrt{\frac{1}{T_0} + \frac{1}{|\mathcal{T}_2|}},\]

since \(\widehat{\tau} - \mathrm{ATT} = -T_0^{-1} \sum_{\mathcal{T}_1} v_t + |\mathcal{T}_2|^{-1} \sum_{\mathcal{T}_2} v_t\) contributes one \(1/T_0\) and one \(1/|\mathcal{T}_2|\) variance term. This collapses to Proposition 2.1’s \(\widehat{\sigma}_{\mathcal{D}} / \sqrt{|\mathcal{T}_2|}\) when \(T_0 \gg |\mathcal{T}_2|\). The 95% Wald interval and two-sided p-value follow in the usual way.

Serially correlated residuals#

That formula prices the two averages with \(\widehat{\sigma}^2\), the residual’s variance at a single date. The variance of an average over a window is not the variance at a date: it is the long-run variance, which sums the autocovariances \(\gamma_k = \operatorname{Cov}(v_t, v_{t+k})\). The two agree exactly when every \(\gamma_k\) with \(k \ge 1\) is zero, which the online appendix’s iid assumptions impose. Assumption 2.1 in the main text asks only for weak dependence, and under that weaker condition the estimator is still consistent while the interval is not. FDID — the standard error under serial correlation (Li 2023) measures the cost: coverage of the nominal 95% interval falls to 0.53 when the residual is an AR(1) with coefficient 0.9.

Setting inference="hac" reports instead

\[\mathrm{SE}_{\text{HAC}}(\widehat{\tau})^2 = \sum_{T \in \{T_0,\, |\mathcal{T}_2|\}} \frac{1}{T} \Bigl[\widehat\gamma_0 + 2\sum_{k=1}^{\min(L,\,T-1)} \bigl(1 - \tfrac{k}{T}\bigr)\widehat\gamma_k\Bigr],\]

with the autocovariances estimated on the pre-period residuals – the only stretch long enough to estimate them, and the stretch \(\widehat\sigma^2\) already uses. The weight \(1 - k/T\) is not a kernel choice: it is the exact coefficient lag \(k\) carries in the variance of a length-\(T\) mean, so truncation at \(L\) is the only approximation. Each block’s sum is floored at \(\widehat\gamma_0\), since a truncated alternating sequence can fall below the iid value.

The truncation defaults to \(L = \min(|\mathcal{T}_2| - 1,\, T_0 / 10)\), and lrvar_lag overrides it. The first term is exhaustive: lag \(k\) enters the post block with weight \(1 - k/|\mathcal{T}_2|\), which is zero at \(k = |\mathcal{T}_2|\). The second is the usual one-tenth-of-sample cap on the pre-period sample supplying the estimates.

Consistency of the selection#

Li also shows the greedy search recovers a valid comparison group. Under Assumption 1 and the appendix’s regularity conditions, with \(N_0\) fixed, the empirical forward selection selects (one of) the same subset(s) the infeasible procedure based on true error variances would select, with probability approaching one as \(T_0 \to \infty\) (Proposition 2.2; Proposition D.1 handles ties). Proposition D.2 extends this to the case where \(N_0\) grows with \(T_0\) under a latent group structure. Intuitively, by the law of large numbers each step’s empirical \(R^2\) converges to its population value, so the greedy path tracks the population-optimal path.

Example#

The block below fits Forward DiD on the Hsiao, Ching and Wan ([HCW]) panel of quarterly real-GDP growth for Hong Kong and 24 comparison economies – the canonical setting for the forward-selected panel-data approach ([fsPDA]) that Forward DiD descends from – with Hong Kong’s economic integration with mainland China as the intervention (44 pre-treatment quarters, 17 post).

import pandas as pd
from mlsynth import FDID

url = "https://raw.githubusercontent.com/jgreathouse9/mlsynth/main/basedata/HongKong.csv"
df = pd.read_csv(url)

res = FDID({"df": df, "outcome": "GDP", "treat": "Integration",
            "unitid": "Country", "time": "Time", "display_graphs": True}).fit()

# Forward DiD fit and the all-controls DiD benchmark, side by side.
print(res.fdid.att, res.fdid.r_squared, res.fdid.selected_names)
print(res.did.att, res.did.r_squared)

Forward DiD keeps a small, regionally sensible subset of Hong Kong’s trading partners instead of averaging all 24 economies, so it tracks Hong Kong’s pre-integration path far more closely (a higher pre-period \(R^2\)) and estimates the post-integration GDP-growth effect more precisely than the all-controls DiD. The exact selected group and the cell-by-cell match to Li’s released output are in FDID — Forward Difference-in-Differences (Li 2024).

res is an FDIDResults: res.fdid and res.did are the two FDIDMethodFit objects, the convenience accessors (res.att, res.att_se, res.counterfactual, res.donor_weights) forward to the Forward DiD fit, and res.att_by_method() / res.ci_by_method() return both side by side.

Verification#

Forward DiD is validated on three fronts. Path A – mlsynth reproduces the author’s public Hong Kong GDP companion replication cell by cell (FDID ATT \(0.0254\), \(53.84\%\), pre-period \(R^2 = 0.843\), 9 of 24 controls). Path B – the paper’s own Monte Carlo (Li 2024, Web Appendix E) reproduces cell by cell (e.g. cell \((48, 24)\) yields \(\mathrm{PMSE} = 0.084\) against the paper’s \(0.082\)), confirming Forward DiD pays only a small efficiency cost when ordinary DiD is valid and wins decisively when half the controls are mismatched. See the dedicated replication page, FDID — Forward Difference-in-Differences (Li 2024), for the full design, code, and cell-by-cell tables.

Path C – the paper’s results about the donor selection instead of the estimate. Propositions 2.2 and D.1 say the forward search converges on the subset the same search would pick if it knew the population variances; against that benchmark, computed in closed form, mlsynth’s exact recovery rises from 0.00 to 0.77 as \(T_1\) grows from 25 to 1600, and Lemma B.1’s uniform deviations stay inside their \(\sqrt{\log N / T_1}\) band throughout. See FDID — forward-selection consistency (Li 2023), which also reports where the convergence is slow and why. A companion case measures the other side of the same proposition: its variance formula uses the residual’s marginal variance where a block mean needs the long-run variance, so under a serially correlated residual the reported interval narrows well below nominal while the point estimate stays consistent. See FDID — the standard error under serial correlation (Li 2023).

Core API#

Forward Difference-in-Differences (FDID) estimator.

Implements the forward-selection difference-in-differences method of Li (2023), Frontiers: A Simple Forward Difference-in-Differences Method, Marketing Science. FDID greedily grows the control group one donor at a time, keeping the subset that maximises pre-treatment fit, and reports both the forward-selected estimate (FDID) and the textbook all-donor difference-in-differences benchmark (DID), each with Li (2023) analytical standard errors. Set inference="hac" for a standard error robust to a serially correlated parallel-trends residual, which Li’s formula does not price in.

The estimator is a thin orchestration layer over mlsynth.utils.fdid_helpers: it validates configuration, prepares the panel, runs forward selection, assembles a typed FDIDResults, and optionally plots the counterfactuals.

class mlsynth.estimators.fdid.FDID(config: FDIDConfig | dict)#

Bases: object

Forward Difference-in-Differences (FDID) estimator.

Parameters:

config (FDIDConfig or dict) – Validated configuration (or a compatible dictionary). See mlsynth.utils.fdid_helpers.config.FDIDConfig for the available fields (df, outcome, treat, unitid, time, display_graphs, save, counterfactual_color, treated_color, verbose, inference, lrvar_lag).

References

Li, K. T. (2023). Frontiers: A Simple Forward Difference-in-Differences Method. Marketing Science, 43(2), 267-279. https://doi.org/10.1287/mksc.2022.0212

Examples

>>> import pandas as pd
>>> from mlsynth import FDID
>>> url = "https://raw.githubusercontent.com/jgreathouse9/mlsynth/refs/heads/main/basedata/basque_data.csv"
>>> data = pd.read_csv(url)
>>> config = {
...     "df": data,
...     "outcome": data.columns[2],
...     "treat": data.columns[-1],
...     "unitid": data.columns[0],
...     "time": data.columns[1],
...     "display_graphs": False,
... }
>>> results = FDID(config).fit()
>>> round(results.att, 3)
fit() FDIDResults#

Run forward selection and return the typed FDID results.

Returns:

FDIDResults – Container exposing the forward-selected fdid fit (primary) and the all-donor did benchmark, plus convenience aliases (att, att_se, counterfactual, gap, donor_weights).

Raises:
  • MlsynthDataError – If panel balancing or data preparation fails.

  • MlsynthEstimationError – If there are too few pre-periods or forward selection fails.

Configuration#

class mlsynth.utils.fdid_helpers.config.FDIDConfig(*, df: ~pandas.DataFrame, outcome: str, treat: str, unitid: str, time: str, display_graphs: bool = True, save: bool | str = False, counterfactual_color: ~typing.List[str] = <factory>, treated_color: str = 'black', plot: ~mlsynth.config_models.PlotConfig = <factory>, verbose: bool = True, inference: ~typing.Literal['analytic', 'hac'] = 'analytic', lrvar_lag: ~typing.Annotated[int | None, ~annotated_types.Ge(ge=0)] = None)#

Configuration for the Forward Difference-in-Differences (FDID) estimator. Inherits all common configuration parameters from BaseEstimatorConfig.

Additional Parameters#

verbosebool, default=True

Whether to save intermediary Forward Selection results.

inference{“analytic”, “hac”}, default=”analytic”

Which standard error to report. "analytic" is Li (2023) Proposition 2.1, exact when the parallel-trends residual is serially uncorrelated. "hac" estimates the residual’s autocovariances on the pre-period and prices them into the variance of the pre- and post-period block means, which restores coverage when the residual is dependent and costs nothing when it is not. See mlsynth.utils.fdid_helpers.inference.

lrvar_lagint, optional

Truncation lag for inference="hac". Defaults to hac_lag(), which is min(T1 - 1, T0 // 10).

inference: Literal['analytic', 'hac']#
lrvar_lag: int | None#
model_config: ClassVar[ConfigDict] = {'arbitrary_types_allowed': True, 'extra': 'forbid'}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

verbose: bool#

Result Containers#

FDID.fit() returns an FDIDResults, whose fdid and did fields each hold an FDIDMethodFit (counterfactual, gap, ATT, analytical standard error, 95% CI, p-value, pre-period RMSE and \(R^2\), selected donor names and equal weights, and – for Forward DiD – the \(R^2\) selection path). The prepared panel is exposed as an FDIDInputs.

Frozen dataclasses for the Forward Difference-in-Differences estimator.

FDID (Li 2023, Frontiers: A Simple Forward Difference-in-Differences Method, Marketing Science) builds the control group for a single treated unit by forward selection: it greedily adds the donor that most improves pre-treatment fit (R^2 between the treated unit and the running donor average), tracks the R^2 path, and keeps the subset that maximises it. The synthetic control is the simple average of the selected donors, with a difference-in-differences intercept.

Two estimates are always returned side by side:

  • FDID – the forward-selected difference-in-differences (best donor subset).

  • DID – the textbook two-way difference-in-differences using all donors (the average of every control unit). This is the natural benchmark the forward search improves upon.

Both carry Li (2023) analytical standard errors, or the serial-correlation-robust alternative when the config asks for it. The three layers below (inputs, per-method fit, top-level results) mirror the CLUSTERSC / PROXIMAL container design used elsewhere in mlsynth.

class mlsynth.utils.fdid_helpers.structures.FDIDInputs(y: ~numpy.ndarray, donor_matrix: ~numpy.ndarray, pre_periods: int, post_periods: int, T: int, donor_names: ~typing.Sequence, time_labels: ~numpy.ndarray, treated_unit_name: ~typing.Any, verbose: bool = True, prepped: ~typing.Dict[str, ~typing.Any] = <factory>)#

Bases: object

Preprocessed panel data for the FDID pipeline.

Parameters:
  • y (np.ndarray) – Treated-unit outcome over all T periods, shape (T,).

  • donor_matrix (np.ndarray) – Donor outcomes, shape (T, n_donors).

  • pre_periods (int) – Number of pre-treatment periods T0.

  • post_periods (int) – Number of post-treatment periods T1 = T - T0.

  • T (int) – Total number of periods.

  • donor_names (Sequence) – Length-n_donors donor labels (column order of donor_matrix).

  • time_labels (np.ndarray) – Length-T time labels.

  • treated_unit_name (Any) – Identifier of the treated unit.

  • verbose (bool) – Whether the forward-selection path is recorded step by step.

  • prepped (dict) – The raw mlsynth.utils.datautils.dataprep() dictionary, kept so the plotter can reuse the prepared panel.

T: int#
donor_matrix: ndarray#
donor_names: Sequence#
property n_donors: int#

Number of donor units.

post_periods: int#
pre_periods: int#
prepped: Dict[str, Any]#
time_labels: ndarray#
treated_unit_name: Any#
verbose: bool = True#
y: ndarray#
class mlsynth.utils.fdid_helpers.structures.FDIDMethodFit(name: str, counterfactual: ~numpy.ndarray, gap: ~numpy.ndarray, att: float, att_se: float, att_percent: float, satt: float, pre_rmse: float, r_squared: float, intercept: float, p_value: float, ci: ~typing.Tuple[float, float], selected_indices: ~typing.List[int], selected_names: ~typing.List[~typing.Any], donor_weights: ~typing.Dict[~typing.Any, float], r2_path: ~numpy.ndarray | None = None, intermediary: list | None = None, inference_method: str = 'analytic', lrvar_lag: int | None = None, metadata: ~typing.Dict[str, ~typing.Any] = <factory>)#

Bases: object

Single FDID/DID fit output.

Parameters:
  • name (str) – Method identifier ("FDID" or "DID").

  • counterfactual (np.ndarray) – Estimated counterfactual outcome path, shape (T,).

  • gap (np.ndarray) – Observed treated minus counterfactual, shape (T,).

  • att (float) – Mean post-treatment treatment effect.

  • att_se (float) – Li (2023) analytical standard error of the ATT.

  • att_percent (float) – ATT as a percentage of the post-period counterfactual mean.

  • satt (float) – Standardised ATT (att / se * sqrt(T1)).

  • pre_rmse (float) – Root-mean-squared pre-treatment fit error.

  • r_squared (float) – Pre-treatment R^2 of the difference-in-differences fit.

  • intercept (float) – Difference-in-differences intercept (treated minus donor pre-period mean).

  • p_value (float) – Two-sided p-value for the ATT.

  • ci (tuple of float) – (lower, upper) 95% confidence interval for the ATT.

  • selected_indices (list of int) – Column indices of the donors retained (all donors for DID).

  • selected_names (list) – Donor labels corresponding to selected_indices.

  • donor_weights (dict) – Mapping {donor_name: weight} (equal weights over the selected donors).

  • r2_path (np.ndarray or None) – R^2 after each forward-selection step (FDID only; None for DID).

  • intermediary (list or None) – Per-step diagnostics when verbose (FDID only).

  • inference_method (str) – Which standard error att_se is – "analytic" (Li 2023, Proposition 2.1) or "hac".

  • lrvar_lag (int or None) – Truncation lag used by the HAC standard error; None under the analytic formula.

  • metadata (dict) – Free-form per-method diagnostics.

att: float#
att_percent: float#
att_se: float#
ci: Tuple[float, float]#
counterfactual: ndarray#
donor_weights: Dict[Any, float]#
gap: ndarray#
inference_method: str = 'analytic'#
intercept: float#
intermediary: list | None = None#
lrvar_lag: int | None = None#
metadata: Dict[str, Any]#
name: str#
p_value: float#
pre_rmse: float#
r2_path: ndarray | None = None#
r_squared: float#
satt: float#
selected_indices: List[int]#
selected_names: List[Any]#
class mlsynth.utils.fdid_helpers.structures.FDIDResults(*, effects: ~mlsynth.config_models.EffectsResults | None = None, fit_diagnostics: ~mlsynth.config_models.FitDiagnosticsResults | None = None, time_series: ~mlsynth.config_models.TimeSeriesResults | None = None, weights: ~mlsynth.config_models.WeightsResults | None = None, inference: ~mlsynth.config_models.InferenceResults | None = None, method_details: ~mlsynth.config_models.MethodDetailsResults | None = None, sub_method_results: ~typing.Dict[str, ~typing.Any] | None = None, additional_outputs: ~typing.Dict[str, ~typing.Any] | None = None, raw_results: ~typing.Dict[str, ~typing.Any] | None = None, execution_summary: ~typing.Dict[str, ~typing.Any] | None = None, plot_config: ~mlsynth.config_models.PlotConfig | None = None, inputs: ~mlsynth.utils.fdid_helpers.structures.FDIDInputs, fdid: ~mlsynth.utils.fdid_helpers.structures.FDIDMethodFit, did: ~mlsynth.utils.fdid_helpers.structures.FDIDMethodFit, selected_variant: str = 'FDID', metadata: ~typing.Dict[str, ~typing.Any] = <factory>)#

Bases: BaseEstimatorResults

Top-level container returned by mlsynth.FDID.fit().

An EffectResult (the observational report): in addition to the FDID-specific fields below, it exposes the standardized sub-models (effects, time_series, weights, inference, fit_diagnostics, method_details) – derived from the selected variant – and the flat accessors att/att_ci/ counterfactual/gap/donor_weights/pre_rmse.

Parameters:
  • inputs (FDIDInputs) – Preprocessed panel.

  • fdid (FDIDMethodFit) – Forward-selected difference-in-differences fit (primary).

  • did (FDIDMethodFit) – Textbook difference-in-differences using all donors.

  • selected_variant (str) – Which fit is exposed via the convenience aliases att, att_se, counterfactual, gap, donor_weights"FDID" or "DID". Defaults to "FDID".

  • metadata (dict) – Free-form pipeline diagnostics.

property att: float#

ATT of the primary variant.

att_by_method() Dict[str, float]#

{method: ATT} for both fits.

property att_se: float#

ATT standard error of the primary variant.

ci_by_method() Dict[str, Tuple[float, float]]#

{method: (lower, upper)} confidence intervals for both fits.

property counterfactual: ndarray#

Counterfactual of the primary variant.

did: FDIDMethodFit#
property donor_weights: Dict[Any, float]#

Donor weights of the primary variant.

fdid: FDIDMethodFit#
property gap: ndarray#

Gap of the primary variant.

inputs: FDIDInputs#
metadata: Dict[str, Any]#
property methods: Dict[str, FDIDMethodFit]#

{method_name: fit} for both fits, FDID first.

model_config: ClassVar[ConfigDict] = {'arbitrary_types_allowed': True, 'extra': 'forbid', 'frozen': True, 'json_encoders': {<class 'numpy.ndarray'>: <function BaseEstimatorResults.Config.<lambda>>}}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

property pre_rmse: float#

Pre-treatment RMSE of the primary variant.

se_by_method() Dict[str, float]#

{method: ATT standard error} for both fits.

selected_variant: str#

Helper Modules#

Data preparation – balances the panel, pivots it, validates the pre-period count, and packs everything into the typed FDIDInputs.

Data preparation for the Forward Difference-in-Differences estimator.

mlsynth.utils.fdid_helpers.setup.prepare_fdid_inputs(df: DataFrame, outcome: str, treat: str, unitid: str, time: str, verbose: bool = True) FDIDInputs#

Balance the panel, pivot it, and package it into FDIDInputs.

Parameters:
  • df (pd.DataFrame) – Long panel with outcome, treatment, unit, and time columns.

  • outcome, treat, unitid, time (str) – Column names identifying the outcome, treatment indicator, unit, and time period.

  • verbose (bool, default True) – Whether the forward-selection path should be recorded step by step.

Returns:

FDIDInputs – Preprocessed panel ready for forward selection.

Raises:
  • MlsynthDataError – If panel balancing or data preparation fails (e.g. no donor units).

  • MlsynthEstimationError – If fewer than two pre-treatment periods are available.

The forward-selection core and the difference-in-differences arithmetic. The public entry points are forward_did_select (the vectorized greedy search) and did_from_mean (the DiD fit for a fixed comparison group); the private helpers documented below are the incremental-mean and batched \(R^2\) primitives described in How mlsynth computes this.

Forward-selection and difference-in-differences estimation for FDID.

This module holds the heavy numerical core of the Forward Difference-in-Differences estimator of Li (2023):

  • forward_did_select() – the vectorised forward-selection loop that greedily adds the donor most improving pre-treatment R^2, tracks the R^2 path, and returns the optimal donor subset alongside the textbook all-donor difference-in-differences benchmark.

  • did_from_mean() – the difference-in-differences estimate for a given donor average (ATT, fit, analytical inference, and vectors).

Both previously lived in the shared selector_helpers grab-bag and the legacy estutils module; they are FDID-specific and now live with the rest of the FDID pipeline.

mlsynth.utils.fdid_helpers.estimation._choose_optimal_subset(selected: List[int], R2_path: ndarray) Tuple[List[int], ndarray]#

Keep the donor prefix up to (and including) the R^2-maximising step.

mlsynth.utils.fdid_helpers.estimation._compute_fdid_result(treated_outcome: ndarray, control_outcomes: ndarray, optimal_idxs: List[int], pre_periods: int, R2_path: ndarray, donor_names: List[Any], inference: str = 'analytic', lrvar_lag: int | None = None) Dict[str, Any]#

Difference-in-differences result for the selected donor subset.

mlsynth.utils.fdid_helpers.estimation._record_verbose_step(intermediary_results: list, it: int, best_idx: int, best_r2: float, r2_cand: ndarray, selected: List[int], donor_names: List[Any], current_mean_pre: ndarray, k: int) None#

Append one forward-selection step to the verbose diagnostics log.

mlsynth.utils.fdid_helpers.estimation.did_from_mean(treated: ndarray, mean_ctrl: ndarray, pre_periods: int, inference: str = 'analytic', lrvar_lag: int | None = None) Dict[str, Any]#

Difference-in-differences estimate from a pre-computed donor average.

Parameters:
  • treated (np.ndarray) – Treated-unit outcome vector, shape (T,).

  • mean_ctrl (np.ndarray) – Average outcome of the selected donor pool, shape (T,).

  • pre_periods (int) – Number of pre-treatment periods T0.

  • inference ({“analytic”, “hac”}, default “analytic”) – Standard error to report; see did_inference().

  • lrvar_lag (int, optional) – Truncation lag for inference="hac".

Returns:

dict – Structured result with Effects, Fit, Inference, and Vectors blocks.

mlsynth.utils.fdid_helpers.estimation.forward_did_select(treated_outcome: ndarray, control_outcomes: ndarray, pre_periods: int, donor_names: List[Any], verbose: bool = False, inference: str = 'analytic', lrvar_lag: int | None = None) Dict[str, Any]#

Run Li (2023) forward-selected difference-in-differences.

Sequentially adds the control unit that most improves pre-treatment fit (R^2) with the treated unit, tracks the path of R^2 values, and returns both the textbook all-donor DID and the optimal FDID estimate.

Parameters:
  • treated_outcome (np.ndarray) – Treated-unit outcome vector, shape (T,).

  • control_outcomes (np.ndarray) – Outcome matrix for all potential control units, shape (T, N).

  • pre_periods (int) – Number of pre-treatment periods T0.

  • donor_names (list) – Donor labels; length must equal N.

  • verbose (bool, default False) – If True, attach per-step diagnostics under "intermediary".

  • inference ({“analytic”, “hac”}, default “analytic”) – Standard error to report. Inference is downstream of selection: the forward search maximises pre-treatment R^2 and never consults a standard error, so this changes the reported interval and nothing else.

  • lrvar_lag (int, optional) – Truncation lag for inference="hac".

Returns:

dict{"DID": <all-donor result>, "FDID": <forward-selected result>}.

References

Li, K. T. (2023). Frontiers: A Simple Forward Difference-in-Differences Method. Marketing Science, 43(2), 267-279. https://doi.org/10.1287/mksc.2022.0212

The Li (2023) analytical standard error, confidence interval, and p-value.

Inference for Forward Difference-in-Differences (Li 2023).

Li (2023) derives a closed-form variance for the difference-in-differences ATT estimator. Writing the pre-treatment residuals of the treated unit against its difference-in-differences fit as e_t, the post-period average treatment effect has asymptotic variance

Var(ATT) = (omega_1 + omega_2) / T1,

where omega_2 = mean(e_t^2) is the pre-period residual variance and omega_1 = (T1 / T0) * omega_2 inflates it for the post-period sample size T1. The standard error is the square root of this quantity.

Serial correlation#

That formula studentises by the residual’s marginal variance. The estimator’s sampling error is a difference of two block means, \(\bar e_{\text{post}} - \bar e_{\text{pre}}\), and a block mean’s variance is governed by the residual’s autocovariances. The two agree exactly when the residual is serially uncorrelated, which Online Appendix A’s Assumptions 2(ii) and 3(i) impose. Assumption 2.1 in the main text asks only for weak dependence, and the appendix remarks that the iid assumptions “can be easily relaxed” to it. The estimator survives that relaxation; the standard error does not, since nothing in omega_1 + omega_2 estimates an autocovariance. benchmarks/cases/fdid_serial_correlation_mc measures the cost: coverage of the nominal 95% interval falls from 0.94 to 0.53 as an AR(1) residual’s coefficient goes from 0 to 0.9, with the point estimate consistent throughout.

method="hac" prices the autocovariances in. It estimates them on the pre-period residuals – the only stretch long enough to estimate them, and the stretch Li already uses for omega_2 – and puts them through the exact finite-sample variance of a block mean,

\[\operatorname{Var}(\bar e_T) = \frac{1}{T}\Bigl[\gamma_0 + 2\sum_{k=1}^{\min(L,\,T-1)}\bigl(1 - \tfrac{k}{T}\bigr)\gamma_k \Bigr],\]

summed over the pre and post blocks. The Bartlett weight \(1 - k/T\) here is not a kernel choice: it is the exact coefficient lag \(k\) carries in the variance of a length-T mean, so at \(L = T - 1\) the expression is the variance itself and not an approximation to it. Truncation at L is the only approximation, and the sum is floored at \(\gamma_0\), since truncating an alternating autocovariance sequence can drive the sum below the iid value or negative.

The default truncation is min(T2 - 1, T1 // 10). The first term is exhaustive, not conservative: lag k enters the post block with weight 1 - k/T2, which is zero at k = T2. The second is the usual one-tenth-of-sample HAC cap, on the pre-period sample that supplies the estimates. Both bind in practice, and dropping either loses coverage: at \(T_1 = 100, T_2 = 100\) the uncapped T2 - 1 gives 0.53 against 0.75 for the capped rule, and at \(T_1 = 400, T_2 = 40\) a Newey-West-style T1-only rule gives 0.81 against 0.88.

References

Li, K. T. (2023). Frontiers: A Simple Forward Difference-in-Differences Method. Marketing Science, 43(2), 267-279. https://doi.org/10.1287/mksc.2022.0212

mlsynth.utils.fdid_helpers.inference.INFERENCE_METHODS = ('analytic', 'hac')#

Inference methods accepted by did_inference().

mlsynth.utils.fdid_helpers.inference.block_mean_variance(autocovariances: ndarray, block_length: int) float#

Variance of the mean of a length-block_length stretch.

Parameters:
  • autocovariances (np.ndarray) – gamma_0, ..., gamma_L. Lags beyond L are treated as zero.

  • block_length (int) – Number of periods averaged, T.

Returns:

float\(\bigl[\gamma_0 + 2\sum_{k\ge1}(1 - k/T)\gamma_k\bigr] / T\), floored at \(\gamma_0 / T\).

Raises:

ValueError – If block_length is not positive or the array is empty.

mlsynth.utils.fdid_helpers.inference.did_inference(att: float, pre_residuals: ndarray, pre_periods: int, post_periods: int, method: str = 'analytic', lrvar_lag: int | None = None) Tuple[float, Tuple[float, float], float, float]#

Compute the FDID standard error, 95% CI, p-value, and SATT.

Parameters:
  • att (float) – Estimated average treatment effect on the treated.

  • pre_residuals (np.ndarray) – Pre-treatment residuals of the treated unit against its difference-in-differences fit, shape (T0,).

  • pre_periods (int) – Number of pre-treatment periods T0.

  • post_periods (int) – Number of post-treatment periods T1.

  • method ({“analytic”, “hac”}, default “analytic”) – "analytic" is Li (2023) Proposition 2.1, exact under a serially uncorrelated residual. "hac" estimates the residual’s autocovariances on the pre-period and prices them into the variance of both block means; see the module docstring.

  • lrvar_lag (int, optional) – Truncation lag for method="hac". Defaults to hac_lag(). Ignored by the analytic path.

Returns:

  • se (float) – Standard error of the ATT (nan if undefined).

  • ci (tuple of float) – (lower, upper) 95% confidence interval.

  • p_value (float) – Two-sided p-value for the ATT.

  • satt (float) – Standardised ATT (att / se * sqrt(T1)).

Raises:

ValueError – If method is not one of INFERENCE_METHODS, or lrvar_lag is negative.

mlsynth.utils.fdid_helpers.inference.hac_lag(pre_periods: int, post_periods: int) int#

Default truncation lag, min(T2 - 1, T1 // 10).

Parameters:
  • pre_periods (int) – Pre-treatment period count T0, the sample the autocovariances are estimated on.

  • post_periods (int) – Post-treatment period count T1.

Returns:

int – A non-negative lag. See the module docstring for why both terms bind.

mlsynth.utils.fdid_helpers.inference.residual_autocovariances(residuals: ndarray, max_lag: int) ndarray#

Sample autocovariances of the pre-period residuals, lags 0..max_lag.

Parameters:
  • residuals (np.ndarray) – Pre-treatment residual series, shape (n,).

  • max_lag (int) – Highest lag to return. Clamped to n - 1, past which no lagged product exists.

Returns:

np.ndarray – Length min(max_lag, n - 1) + 1. Entry k is sum_t (e_t - ebar)(e_{t-k} - ebar) / n, the standard divisor-n estimator, which keeps the implied spectral density non-negative.

Raises:

ValueError – If max_lag is negative or residuals is empty.

Assembly of the raw selection output into the typed result containers.

Assemble typed FDID results from the raw estimation dictionaries.

mlsynth.utils.fdid_helpers.results_assembly.assemble_fdid_results(selector_output: Dict[str, Dict[str, Any]], inputs: FDIDInputs) FDIDResults#

Build the typed FDIDResults container.

Parameters:
Returns:

FDIDResults – Container exposing the FDID (primary) and DID fits.

The observed-versus-counterfactual overlay plot for the FDID and DID fits.

Plotting wrapper for the Forward Difference-in-Differences estimator.

mlsynth.utils.fdid_helpers.plotter.plot_fdid(results: FDIDResults, *, time: str, unitid: str, outcome: str, treat: str, treated_color: str, counterfactual_color: str | List[str], save: bool | dict) None#

Plot observed vs FDID and DID counterfactuals.

Plotting failures are downgraded to warnings so a rendering problem never masks a successful estimation.

The Web Appendix E Monte Carlo DGPs (DGP1-DGP4), packaged as simulate_fdid_sample() so the replication in Verification runs as a one-liner.

Web Appendix E Monte Carlo DGPs for the Forward DiD method.

Implements the four data-generating processes from Li, Shi & Huang (2023) Web Appendix E. Each draw produces one treated unit and N controls over T1 + T2 periods, generated from three common factors:

\[\begin{split}f_{1t} &= 0.8 f_{1,t-1} + v_{1t}, \\ f_{2t} &= -0.6 f_{2,t-1} + v_{2t} + 0.8 v_{2,t-1}, \\ f_{3t} &= v_{3t} + 0.9 v_{3,t-1} + 0.4 v_{3,t-2},\end{split}\]

with \(v_{kt} \sim \mathcal{N}(0, 1)\) and outcomes

\[\begin{split}y_{tr,t} &= a_0 + c_0 \mathbf{1}' f_t + \varepsilon_{tr,t}, \\ y_{it} &= 1 + c_1 \mathbf{1}' f_t + \varepsilon_{it} \quad i \le N/2, \\ y_{it} &= 1 + c_2 \mathbf{1}' f_t + \varepsilon_{it} \quad i > N/2,\end{split}\]

where \(\varepsilon_{it} \sim \mathcal{N}(0, 1)\). The four DGPs vary \((a_0, c_0, c_1, c_2)\):

DGP  (a_0, c_0, c_1, c_2)
1    (1, 1, 1, 1) — all controls match (DiD is applicable)
2    (1, 1, 1, 2) — half the controls have mismatched loadings
3    (2, 1, 1, 1) — treated has a different intercept
4    (2, 1, 1, 2) — intercept and half-mismatched loadings

True ATT is zero in every DGP (matching the paper’s PMSE convention; the PMSE is invariant to a constant treatment effect).

Note

The appendix prints f_2t = -0.6 f_{1,t-1} + ... for the lag term, but the Monte Carlo numbers in Li’s Table 5 match the alternative reading -0.6 f_{2,t-1} (ARMA(1,1) on \(f_2\) itself). The latter is used here — it reproduces the paper’s DID PMSE values closely (within ~3%) while the literal reading reproduces only the FDID column.

class mlsynth.utils.fdid_helpers.simulation.FDIDSample(df: DataFrame, Y_treated: ndarray, Y_controls: ndarray, T1: int, T2: int, dgp: int)#

One draw from a Web Appendix E DGP.

df#

Long panel with columns unit / time / y / treat ready to feed to mlsynth.FDID.

Type:

pd.DataFrame

Y_treated#

Treated-unit outcome path, shape (T,).

Type:

np.ndarray

Y_controls#

Control outcomes, shape (N, T). Rows 0..N//2-1 carry loading c_1; rows N//2..N-1 carry loading c_2.

Type:

np.ndarray

T1, T2

Pre- / post-treatment period counts.

Type:

int

dgp#

Which of the four DGPs was drawn.

Type:

int

T1: int#
T2: int#
Y_controls: ndarray#
Y_treated: ndarray#
df: DataFrame#
dgp: int#
mlsynth.utils.fdid_helpers.simulation.simulate_fdid_sample(dgp: int, N: int = 60, T1: int = 24, T2: int = 12, rng: Generator | None = None) FDIDSample#

Draw one sample from FDID Web Appendix E DGP dgp (1-4).

Parameters:
  • dgp (int) – Which DGP to draw (1, 2, 3, or 4).

  • N (int, default 60) – Number of control units (the paper uses N = 60).

  • T1, T2 (int) – Pre- and post-treatment period counts.

  • rng (np.random.Generator, optional) – NumPy RNG. Defaults to np.random.default_rng().

Returns:

FDIDSample

mlsynth.utils.fdid_helpers.simulation.simulate_fdid_serial_sample(rho: float, N: int = 20, T1: int = 400, T2: int = 10, rng: Generator | None = None) FDIDSample#

Draw a panel whose parallel-trends residual is AR(1) at the optimum.

Not one of the paper’s four DGPs. It is Web Appendix E’s DGP 2 – treated loading \(c_0 = 1\), a matched control half at \(c_1 = 1\) and a mismatched half at \(c_2 = 2\) – with the treated unit’s idiosyncratic shock replaced by an AR(1),

\[y_{tr,t} = 1 + \mathbf{1}'f_t + u_t, \qquad u_t = \rho u_{t-1} + e_t,\]

with \(e_t\) scaled so \(\operatorname{Var}(u_t) = 1\) at every \(\rho\). Only the dependence changes with \(\rho\), never the size of the shock, so a measured effect cannot be the error growing.

The point is what happens at the subset the forward search selects. There the mean loading equals \(c_0\), the factor term cancels, and the residual is \(u_t\) plus the donor average’s independent noise – serially correlated, which the appendix’s Assumptions 2(ii) and 3(i) rule out and Assumption 2.1 in the main text permits. The four Web Appendix E DGPs cannot exhibit this: their factors are serially correlated but that term vanishes at the optimum, leaving an iid residual exactly where the standard error needs testing.

Parameters:
  • rho (float) – AR(1) coefficient of the treated shock, in (-1, 1). Zero recovers DGP 2’s error structure.

  • N (int, default 20) – Number of control units; the first N // 2 are the matched half.

  • T1, T2 (int) – Pre- and post-treatment period counts.

  • rng (np.random.Generator, optional)

Returns:

FDIDSample – Carrying dgp = 2, the design it modifies. True ATT is zero.