Synthetic Control with Temporal Aggregation (SCTA)#

When to Use This Estimator#

The synthetic control (SC) method of Abadie and co-authors [ABADIE2010] builds a counterfactual for one treated unit as a weighted average of donor units that reproduces the treated unit’s pre-treatment outcome path. When the outcome is measured at high frequency – monthly births, daily sales, weekly visits – a practitioner faces a choice that quietly changes the answer: match the donors on the raw high-frequency series, or first average it into coarser intervals (say, yearly) and match on those?

Neither extreme is safe. Matching on the disaggregated series gives many pre-periods to balance, but each one is a noisy realisation of the latent factors, so the weights can overfit that noise – a bias Abadie and Vives-i-Bastida explicitly caution against. Matching on the aggregated series averages the noise away (variance falls by a factor of the block length), but it also throws away within-interval signal: if there is little long-run variation left to learn the donor loadings from, aggregation can inflate the bias instead of shrinking it.

SCTA [SunBenMichaelFellerTA] refuses the binary. It finds one set of donor weights that jointly balances both the disaggregated high-frequency outcomes and their temporal aggregates, trading the two off through a single knob \(\nu\). Aggregation reduces noise in the balancing objective only to the extent that long-run signal for the factors survives; the joint fit lets the data keep whichever view balances better while never fully abandoning the other.

Use SCTA when you have one treated unit, a high-frequency outcome, and a genuine question about whether to aggregate – typically because the disaggregated fit looks like it is chasing seasonal or idiosyncratic noise, or because the aggregated fit alone discards too much. It is the temporal sibling of Synthetic Control with Multiple Outcomes (SCMO): where SCMO adds an outcome dimension to pin the weights down, SCTA adds an aggregation-level dimension.

Notation and the Stacked Design#

For each unit \(i\) and each high-frequency period we observe an outcome, grouped into \(T_0\) pre-treatment intervals of \(K\) observations each (for example, \(K = 12\) months per year). Write \(\dot{Y}_{itk}\) for the outcome of unit \(i\) in interval \(t\), sub-period \(k\), demeaned by that unit’s pre-treatment average \(\bar{Y}_{i\cdot\cdot}\) – the intercept shift of Doudchenko and Imbens. The treated unit is \(i = 1\); the remaining units are donors. The estimator is the demeaned weighting form

\[\hat{Y}_{1Tk}(0) \;=\; \bar{Y}_{1\cdot\cdot} \;+\; \sum_{W_i = 0} \gamma_i\, \dot{Y}_{iTk},\]

so only the weights \(\gamma\) (on the simplex by default) must be chosen.

SCTA chooses them by stacking two balance targets into one matching design. The disaggregated target is every pre-period value \(\dot{Y}_{itk}\); the aggregated target is the block mean \(\bar{\dot{Y}}_{it} = \tfrac{1}{K}\sum_{k} \dot{Y}_{itk}\). For each unit the matching vector is the concatenation

\[\big[\, \underbrace{\bar{\dot{Y}}_{i1}, \ldots, \bar{\dot{Y}}_{i,T_0/K}}_{\text{aggregated blocks}} \;\big|\; \underbrace{\dot{Y}_{i11}, \ldots, \dot{Y}_{i T_0 K}}_{\text{disaggregated periods}} \,\big].\]

A fixed diagonal weight matrix \(\mathbf{V} = \operatorname{diag}(K\nu, \ldots, K\nu, 1, \ldots, 1)\) puts weight \(K\nu\) on each aggregated row and \(1\) on each disaggregated row, and the weights solve the \(\mathbf{V}\)-weighted simplex least-squares problem

\[\min_{\gamma \ge 0,\, \sum_i \gamma_i = 1} \; (\mathbf{a} - \mathbf{B}\gamma)^{\top}\, \mathbf{V}\, (\mathbf{a} - \mathbf{B}\gamma),\]

where \(\mathbf{a}\) and \(\mathbf{B}\) are the stacked treated vector and donor matrix. At \(\nu = 0\) the aggregated rows drop out and SCTA is the conventional disaggregated SC; larger \(\nu\) shifts balance onto the aggregates. mlsynth solves this at the true optimum with its active-set QP, so the weights do not depend on solver idiosyncrasies.

Assumptions#

  1. Single treated unit, block-structured pre-period. Exactly one unit is treated, and the pre-period contains at least one whole block of \(K\) high-frequency observations.

    Remark. The leading \(\lfloor T_0 / K \rfloor\) complete blocks are aggregated; every disaggregated period is retained, so a ragged tail (\(T_0\) not a multiple of \(K\)) is simply kept disaggregated. The Texas application below has six whole years plus three spare months and is handled without special-casing.

  2. Common latent factors. Outcomes under control follow a linear factor model with two-way fixed effects; donors and the treated unit share the time factors, and the factor variance-covariance is non-degenerate on the disaggregated series (\(\bar\xi^{\,\mathrm{dis}} > 0\)), the usual no-weak-identification condition.

    Remark. Aggregation tightens the bias bound only when long-run signal survives – formally when \(\sqrt{K}\,\bar\xi^{\,\mathrm{agg}} > \bar\xi^{\,\mathrm{dis}}\). If the aggregated factor variance is tiny (little long-run variation), aggregating can inflate the bias. The frontier diagnostic below is how you check which regime you are in.

  3. Intercept-shifted weights. The counterfactual allows a level difference between the treated unit and its synthetic control (the demeaning), so only the shape of the donor combination is matched.

    Remark. This is intrinsic to the estimator form, not a tuning choice; demean=True is the default and matches the paper. Setting it off reverts to a raw simplex combination and is offered only for diagnostics.

Choosing nu and the Imbalance Frontier#

The optimal \(\nu\) depends on unknown factor-model quantities and is infeasible to compute. Following the paper, SCTA defaults to the equal-weight heuristic \(\nu = 0.5\) and asks you to assess sensitivity rather than trust a single number. Passing a frontier grid traces the imbalance frontier: for each \(\nu\) it reports the disaggregated and aggregated pre-treatment RMSE, the two axes of Figure 1 in the paper. A good \(\nu\) is one where both imbalances are small; a frontier that collapses to one axis tells you aggregation is buying (or costing) you signal.

Inference and Diagnostics#

Each fit carries a conformal prediction interval and p-value for the average post-treatment effect (the CWZ moving-block construction of Chernozhukov-Wuethrich-Zhu, shared with Synthetic Control with Multiple Outcomes (SCMO)), controlled by conformal_alpha. The fit diagnostics expose the disaggregated pre-treatment RMSE; the frontier exposes both the disaggregated and aggregated RMSE across \(\nu\). Donor weights are returned for inspection of the comparison group.

Example#

import numpy as np
import pandas as pd
from mlsynth import SCTA

# A monthly panel: 8 units, 16 months, treatment on u0 from month 12.
rng = np.random.default_rng(0)
f = np.linspace(0, 3, 16) + 0.4 * np.sin(np.linspace(0, 6, 16))
rows = []
for u in range(8):
    base = rng.normal(100, 8) + rng.normal(1, 0.3) * f
    for t in range(16):
        y = base[t] + (8.0 if (u == 0 and t >= 12) else 0.0)
        rows.append((f"u{u}", t, float(y), int(u == 0 and t >= 12)))
df = pd.DataFrame(rows, columns=["unit", "time", "y", "treat"])

config = {
    "df": df, "outcome": "y", "treat": "treat",
    "unitid": "unit", "time": "time",
    "block_length": 4,            # K = 4 high-frequency periods per block
    "nu": 0.5,                    # equal weight on aggregated and disaggregated
    "frontier": [0.0, 0.5, 1.0, 2.0],
}
results = SCTA(config).fit()

print(results.effects.att)                 # average post-treatment effect
print(results.inference.p_value)           # conformal p-value
for point in results.frontier:             # the imbalance frontier
    print(point["nu"], point["rmse_dis"], point["rmse_agg"])

Ridge augmentation. Set augment="ridge" to add the bilevel ridge-augmented correction (the Augmented SCM of Ben-Michael, Feller and Rothstein) on top of the joint simplex fit, closing residual pre-treatment imbalance at the cost of leaving the simplex. ridge_lambda fixes the penalty; left unset it is chosen by cross-validation.

Verification#

SCTA reproduces the temporal-aggregation construction of the paper’s Texas SB8 study [BellStuartGemmill] and is cross-validated against the augsynth R reference. Because the joint fit’s base simplex is ill-conditioned on a large donor pool, the per-unit weight vector is solver-dependent: mlsynth reaches the true optimum of the \(\mathbf{V}\)-weighted objective, while augsynth’s interior-point solver lands a few percent short, so the estimates agree to solver tolerance rather than bit for bit (plain \(\nu = 0.5\): \({\approx}\,19{,}800\) vs \(18{,}918\); ridge: \({\approx}\,12{,}500\) vs \(12{,}982\), annualised). See the dedicated page SCTA — Temporal Aggregation for Synthetic Control (Sun et al. 2024).

Core API#

class mlsynth.estimators.scta.SCTA(config: SCTAConfig | dict)#

Bases: object

Synthetic Control with Temporal Aggregation estimator.

Parameters:

config (SCTAConfig or dict) – Validated configuration. Beyond the common fields (df, outcome, treat, unitid, time, display_graphs, save, colors), SCTA reads block_length (K), nu, augment, ridge_lambda, demean and frontier.

References

Sun, L., Ben-Michael, E., & Feller, A. (2024). Temporal Aggregation for the Synthetic Control Method. AEA Papers and Proceedings, 114: 614-617.

fit() SCTAResults#

Aggregate, fit the joint SC, and return standardized results.

Returns:

SCTAResults – Standardized results with the headline fit lifted into the shared sub-models and an optional imbalance frontier in frontier.

Configuration#

class mlsynth.utils.scta_helpers.config.SCTAConfig(*, 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>, block_length: int, nu: float = 0.5, augment: ~typing.Literal['ridge'] | None = None, ridge_lambda: float | None = None, demean: bool = True, conformal_alpha: ~typing.Annotated[float, ~annotated_types.Gt(gt=0.0), ~annotated_types.Lt(lt=1.0)] = 0.1, frontier: ~typing.List[float] | None = None)#

Bases: BaseEstimatorConfig

Configuration for Synthetic Control with Temporal Aggregation (SCTA).

SCTA (Sun, Ben-Michael & Feller 2024) fits a single-treated-unit synthetic control that jointly balances the disaggregated high-frequency pre-period outcomes and their temporal aggregates (block means of block_length consecutive periods). A single weight nu on a fixed diagonal V trades the two off: nu = 0 recovers the conventional disaggregated SC, larger nu shifts balance onto the aggregated series.

augment: Literal['ridge'] | None#
block_length: int#
conformal_alpha: float#
demean: bool#
frontier: List[float] | 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].

nu: float#
ridge_lambda: float | None#

Results and Inputs#

Frozen, NumPy-first containers for Synthetic Control with Temporal Aggregation.

SCTA (Sun, Ben-Michael & Feller 2024) balances the treated unit against donors on a stacked matching vector [aggregate block means | disaggregated pre outcomes], weighted by a fixed diagonal V parameterised by nu. Everything below is pure NumPy; the only DataFrame touchpoint is mlsynth.utils.scta_helpers.setup.prepare_scta_inputs().

class mlsynth.utils.scta_helpers.structures.SCTAFit(nu: float, weights: ~numpy.ndarray, counterfactual: ~numpy.ndarray, gap: ~numpy.ndarray, att: float, pre_rmse: float, rmse_dis: float, rmse_agg: float, donor_weights: ~typing.Dict[~typing.Any, float], att_se: float | None = None, ci: ~typing.Tuple[float, float] = (nan, nan), p_value: float | None = None, metadata: ~typing.Dict[str, ~typing.Any] = <factory>)#

Bases: object

A single SCTA fit at a given nu.

att: float#
att_se: float | None = None#
ci: Tuple[float, float] = (nan, nan)#
counterfactual: ndarray#
donor_weights: Dict[Any, float]#
gap: ndarray#
metadata: Dict[str, Any]#
nu: float#
p_value: float | None = None#
pre_rmse: float#
rmse_agg: float#
rmse_dis: float#
weights: ndarray#
class mlsynth.utils.scta_helpers.structures.SCTAInputs(treated_name: ~typing.Any, donor_names: ~typing.Sequence, y: ~numpy.ndarray, donor_matrix: ~numpy.ndarray, T0: int, block_length: int, time_labels: ~typing.Sequence, metadata: ~typing.Dict[str, ~typing.Any] = <factory>)#

Bases: object

Preprocessed, NumPy-only panel for the SCTA engine.

Parameters:
  • treated_name (Any) – Label of the treated unit.

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

  • y (np.ndarray) – Treated-unit outcome series, shape (T,).

  • donor_matrix (np.ndarray) – Donor outcomes, shape (T, J) (rows = periods).

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

  • block_length (int) – Aggregation block length K (high-frequency periods per aggregate).

  • time_labels (Sequence) – Length-T period labels.

  • metadata (dict) – Free-form provenance.

property T: int#
T0: int#
block_length: int#
donor_matrix: ndarray#
donor_names: Sequence#
metadata: Dict[str, Any]#
property n_blocks: int#
property n_donors: int#
time_labels: Sequence#
treated_name: Any#
y: ndarray#
class mlsynth.utils.scta_helpers.structures.SCTAResults(*, 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.scta_helpers.structures.SCTAInputs, fit: ~mlsynth.utils.scta_helpers.structures.SCTAFit, frontier: ~typing.List[~typing.Dict[str, float]] | None = None, metadata: ~typing.Dict[str, ~typing.Any] = <factory>)#

Bases: BaseEstimatorResults

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

An EffectResult: it lifts the headline fit into the standardized sub-models so the flat accessors resolve through the base contract, and keeps the imbalance-frontier diagnostic in frontier.

Parameters:
  • inputs (SCTAInputs) – Preprocessed panel.

  • fit (SCTAFit) – The headline fit (at the scalar nu).

  • frontier (list of dict, optional) – One {"nu", "rmse_dis", "rmse_agg", "att"} point per requested nu when an imbalance frontier was traced.

fit: SCTAFit#
frontier: List[Dict[str, float]] | None#
inputs: SCTAInputs#
metadata: Dict[str, Any]#
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].

Engine#

Long-DataFrame -> NumPy boundary for SCTA (the only pandas touchpoint).

Wraps the canonical mlsynth.utils.datautils.dataprep() contract (Ywide / y / donor_matrix / pre_periods) into the NumPy-only SCTAInputs consumed by the engine.

mlsynth.utils.scta_helpers.setup.prepare_scta_inputs(df: DataFrame, *, unitid: str, time: str, outcome: str, treat: str, block_length: int) SCTAInputs#

Ingest the panel via dataprep and assemble SCTAInputs.

Parameters:
  • df (pd.DataFrame) – Long balanced high-frequency panel (one row per unit-period).

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

  • block_length (int) – Aggregation block length K. At least one whole block must fit in the pre-period (T0 >= K); the leading floor(T0 / K) complete blocks are aggregated and every disaggregated pre-period is retained, so a ragged tail (T0 not a multiple of K) is kept disaggregated – matching the paper’s Texas construction (6 whole years + 3 spare months).

Returns:

SCTAInputs – Pure-NumPy container for the estimation engine.

Raises:

MlsynthConfigError – If the pre-period is shorter than one block (T0 < block_length).

SCTA estimation engine: temporal aggregation, fixed-V solve, frontier.

All functions are pure NumPy. The matching vector stacks the temporal aggregates (block means of K consecutive pre-periods) on top of the disaggregated pre-period outcomes, demeaned by each unit’s disaggregated pre-treatment mean (the Doudchenko-Imbens / fixed-effects intercept shift the paper adopts). A fixed diagonal V = diag(K*nu on aggregate rows, 1 on disaggregated rows) weights the joint fit; the simplex is solved at the true optimum (mlsynth’s active-set QP), optionally ridge-augmented.

mlsynth.utils.scta_helpers.pipeline.fit_one(inputs: SCTAInputs, nu: float, *, augment: str | None = None, ridge_lambda: float | None = None, demean: bool = True) SCTAFit#

Fit SCTA at a single nu and assemble an SCTAFit.

mlsynth.utils.scta_helpers.pipeline.run_scta(inputs: SCTAInputs, *, nu: float, augment: str | None = None, ridge_lambda: float | None = None, demean: bool = True, conformal_alpha: float = 0.1, frontier: List[float] | None = None) Tuple[SCTAFit, List[Dict[str, float]] | None]#

Headline fit at nu plus an optional imbalance-frontier sweep.

Attaches a CWZ conformal ATT / p-value / interval to the headline fit.