Dynamic Covariate Balancing DiD#

When treatments change over time and units dynamically select into treatment based on past outcomes, standard DiD methods break down. The Viviano and Bradic (2026) estimator handles this by combining potential local projections with sequential covariate balancing weights, producing valid inference without requiring propensity score estimation.

See also

Dynamic Covariate Balancing DiD for the theoretical foundations, identifying assumptions, and the dynamic balancing algorithm.

Empirical application#

This example follows the empirical analysis in Viviano and Bradic (2026), which revisits Acemoglu et al. (2019) on the effect of democracy on economic growth.

The dataset contains 141 countries observed across six five-year periods from 1989 to 2010. The outcome is log GDP per capita and the treatment is a binary democracy indicator. Unlike standard DiD settings where treatment is permanent, democracy can switch on and off across periods. About two-thirds of country-period observations are democratic, but many countries transition between democracy and autocracy multiple times. This switching creates dynamic selection into treatment, where a country’s current democratic status depends on its past economic performance and governance history, violating the staggered adoption assumption required by standard DiD.

The dataset includes 158 country-level covariates, 4 lagged outcome variables, and a geographic region indicator. Acemoglu et al. (2019) assume a dynamic selection model where treatment decisions depend on past outcomes and covariates, making this an ideal application for DCB. Viviano and Bradic (2026) show that standard local projections and the linear regression approach of Acemoglu et al. (2019) substantially underestimate the long-run effect of democracy, while IPW methods produce unstable estimates due to the compounding of propensity scores over multiple periods.

Loading the data#

from moderndid.core.data import load_acemoglu

df = load_acemoglu()
print(df.select("Y", "D", "Unit", "Time", "region", "V1", "V2").head(6))
shape: (6, 7)
┌──────────┬─────┬──────┬──────┬────────┬───────────┬───────────┐
│ Y        ┆ D   ┆ Unit ┆ Time ┆ region ┆ V1        ┆ V2        │
│ ---      ┆ --- ┆ ---  ┆ ---  ┆ ---    ┆ ---       ┆ ---       │
│ f64      ┆ i64 ┆ i64  ┆ i64  ┆ str    ┆ f64       ┆ f64       │
╞══════════╪═════╪══════╪══════╪════════╪═══════════╪═══════════╡
│ 7.330224 ┆ 1   ┆ 70   ┆ 0    ┆ SA     ┆ -0.398948 ┆ -0.064505 │
│ 7.374312 ┆ 1   ┆ 70   ┆ 1    ┆ SA     ┆ -0.398948 ┆ -0.064505 │
│ 7.427509 ┆ 1   ┆ 70   ┆ 2    ┆ SA     ┆ -0.398948 ┆ -0.064505 │
│ 7.497994 ┆ 1   ┆ 70   ┆ 3    ┆ SA     ┆ -0.398948 ┆ -0.064505 │
│ 7.526908 ┆ 1   ┆ 70   ┆ 4    ┆ SA     ┆ -0.398948 ┆ -0.064505 │
│ 7.557471 ┆ 1   ┆ 70   ┆ 5    ┆ SA     ┆ -0.398948 ┆ -0.064505 │
└──────────┴─────┴──────┴──────┴────────┴───────────┴───────────┘

The Y column is log GDP per capita. The D column is the democracy indicator, which can switch on and off across periods. The six time periods (0–5) correspond to five-year intervals from 1989 to 2010. The dataset also includes 158 country-level covariates (V1V158), 4 lagged outcome variables (lag1.Value1lag4.Value1), and a region variable encoding the World Bank geographic region.

Estimation#

We start by estimating the average treatment effect of being democratic for two consecutive periods versus not being democratic. The treatment histories ds1=[1, 1] and ds2=[0, 0] specify these two sequences, read left to right from the earliest to the most recent period.

from moderndid.diddynamic import dyn_balancing

result = dyn_balancing(
    data=df,
    yname="Y",
    tname="Time",
    idname="Unit",
    treatment_name="D",
    ds1=[1, 1],
    ds2=[0, 0],
    xformla="~ V1 + V2 + V3 + V4 + V5",
    fixed_effects=["region"],
)
print(result)
==============================================================================
 Dynamic Covariate Balancing Estimation
==============================================================================

 DCB estimation for the ATE:

┌────────┬────────────┬──────────┬────────────────────────┐
│    ATE │ Std. Error │ Pr(>|t|) │ [95% Conf. Interval]   │
├────────┼────────────┼──────────┼────────────────────────┤
│ 0.3011 │     0.2032 │   0.1383 │ [ -0.0971,   0.6993]   │
└────────┴────────────┴──────────┴────────────────────────┘

------------------------------------------------------------------------------
 Signif. codes: '*' confidence interval does not cover 0

------------------------------------------------------------------------------
 Potential Outcomes
------------------------------------------------------------------------------
 mu(ds1):  8.0044  (0.1397)
 mu(ds2):  7.7033  (0.1476)

------------------------------------------------------------------------------
 Data Info
------------------------------------------------------------------------------
 Treatment history ds1: [1, 1]
 Treatment history ds2: [0, 0]
 Outcome variable: Y
 Units: 137
 Observations: 274

------------------------------------------------------------------------------
 Estimation Details
------------------------------------------------------------------------------
 Balancing: DCB
 Coefficient estimation: lasso_plain

------------------------------------------------------------------------------
 Inference
------------------------------------------------------------------------------
 Significance level: 0.05
 Analytical standard errors
 Robust (chi-squared) critical values
==============================================================================
 Viviano and Bradic (2026)

The estimated ATE of 0.30 means that two consecutive periods of democracy increase log GDP per capita by about 0.30 (roughly 35% in levels) compared to two periods without democracy. The potential outcome estimates mu(ds1) and mu(ds2) show the average outcomes under each history, with standard errors in parentheses.

Inference uses robust chi-squared critical values by default, which provide valid coverage under weaker conditions than Gaussian quantiles by accounting for the estimation error of the balancing weights. With a p-value of 0.14, the effect is not statistically significant at the 5% level with this parsimonious specification. The history length analysis below examines how the effect varies with exposure duration.

The xformla argument specifies which covariates to include in the LASSO regression. Here we use a small subset (V1V5) of the 158 available covariates. The fixed_effects argument adds region dummies to the model without penalisation. Any unit not observed in all periods within the treatment history window is automatically dropped (here 4 units, leaving 137).

Interpreting the result object#

The result is a DynBalancingResult containing the ATE, variances, potential outcomes, and diagnostics for the balancing procedure.

print(f"ATE:     {result.att:.4f}")
print(f"SE:      {result.se:.4f}")
print(f"mu(ds1): {result.mu1:.4f}")
print(f"mu(ds2): {result.mu2:.4f}")
print(f"Robust quantile:   {result.robust_quantile:.4f}")
print(f"Gaussian quantile: {result.gaussian_quantile:.4f}")
print(f"Imbalance ds1: {result.imbalances['ds1']:.6f}")
print(f"Imbalance ds2: {result.imbalances['ds2']:.6f}")
ATE:     0.3011
SE:      0.2032
mu(ds1): 8.0044
mu(ds2): 7.7033
Robust quantile:   3.8415
Gaussian quantile: 1.9600
Imbalance ds1: 0.000012
Imbalance ds2: 0.000009

The imbalance measures report the maximum covariate imbalance (rescaled by standard deviation) after the DCB weights have been applied. Values close to zero confirm that the balancing procedure has successfully equalised the covariate distributions between treatment groups. This is a key advantage of DCB over IPW, which can leave substantial residual imbalance when propensity scores are difficult to estimate. The gammas dictionary contains the actual balancing weights per treatment history, and coefficients contains the LASSO-estimated regression coefficients used in the recursive local projections.

Treatment history length#

With long panels, using the full treatment history can thin the effective sample because only units matching the entire treatment path contribute non-zero weights. Setting histories_length traces out how the treatment effect evolves with exposure length. Here we compare the effect of being democratic for 1, 2, 3, 4, and 5 consecutive periods against the corresponding non-democratic histories.

history = dyn_balancing(
    data=df,
    yname="Y",
    tname="Time",
    idname="Unit",
    treatment_name="D",
    ds1=[1, 1, 1, 1, 1],
    ds2=[0, 0, 0, 0, 0],
    histories_length=[1, 2, 3, 4, 5],
    xformla="~ V1 + V2 + V3 + V4 + V5",
    fixed_effects=["region"],
)
print(history)
==============================================================================
 Dynamic Covariate Balancing History
==============================================================================

 Length         ATE          SE     mu(ds1)     mu(ds2)
 ------------------------------------------------------
      1      0.2743      0.2008      7.9699      7.6956
      2      0.3011      0.2032      8.0044      7.7033
      3      0.2385      0.2164      8.0130      7.7746
      4      0.2804      0.2205      8.0113      7.7309
      5      0.2451      0.2294      8.0095      7.7645

==============================================================================
 Viviano and Bradic (2026)

The effect ranges from 0.24 to 0.30 across history lengths, with the estimated mu(ds1) stable around 8.0 and mu(ds2) fluctuating between 7.70 and 7.77. Standard errors grow slightly with history length as the effective sample shrinks, since fewer countries maintain the same democratic status for longer stretches. This trade-off between identification of long-run effects and statistical precision is exactly what the history length diagnostic is designed to reveal.

With this parsimonious specification (five covariates and region fixed effects), the point estimates are positive and relatively stable across horizons. Viviano and Bradic (2026) show that richer specifications using the full set of 158 covariates with lagged outcomes produce larger and more precisely estimated effects, with the ATE growing substantially at longer horizons. Their analysis also demonstrates that standard local projections and the linear regression approach of Acemoglu et al. (2019) underestimate the long-run effect because they average over the distribution of future treatment assignments.

The result is a DynBalancingHistoryResult with a summary DataFrame and a list of individual DynBalancingResult objects, one per history length. Each individual result exposes the same attributes as the single-ATE result (att, se, gammas, coefficients, imbalances), allowing detailed diagnostics at each horizon.

Clustered standard errors#

In applications where countries within the same geographic region may be correlated, for example through trade linkages or shared institutions, the clustervars argument produces cluster-robust standard errors that are valid under arbitrary within-cluster dependence.

result_cl = dyn_balancing(
    data=df,
    yname="Y",
    tname="Time",
    idname="Unit",
    treatment_name="D",
    ds1=[1, 1],
    ds2=[0, 0],
    xformla="~ V1 + V2 + V3 + V4 + V5",
    fixed_effects=["region"],
    clustervars=["region"],
)
print(f"Unclustered SE: {result.se:.4f}")
print(f"Clustered SE:   {result_cl.se:.4f}")
Unclustered SE: 0.2032
Clustered SE:   0.1479

Here clustering by region produces a smaller standard error, which can happen when within-cluster residuals partially cancel, reducing the total variance. The default is cross-sectionally independent standard errors, matching the assumption in the theory. Which level to cluster at depends on the empirical context and should reflect the structure of dependence in the data.

Pooled regression#

By default, the estimator uses the outcome in the final period only. The pooled=True option pools observations across all periods into a single regression with time fixed effects. This can improve precision by increasing the effective sample size, at the cost of assuming that the treatment effect is stationary across periods.

result_pooled = dyn_balancing(
    data=df,
    yname="Y",
    tname="Time",
    idname="Unit",
    treatment_name="D",
    ds1=[1, 1],
    ds2=[0, 0],
    xformla="~ V1 + V2 + V3 + V4 + V5",
    fixed_effects=["region", "Time"],
    pooled=True,
)
print(f"Unpooled ATE: {result.att:.4f}  SE: {result.se:.4f}")
print(f"Pooled ATE:   {result_pooled.att:.4f}  SE: {result_pooled.se:.4f}")
Unpooled ATE: 0.3011  SE: 0.2032
Pooled ATE:   0.2895  SE: 0.2018

The pooled specification produces a similar point estimate (0.29 versus 0.30) with a slightly smaller standard error, consistent with the increased effective sample size from pooling across periods.

When pooled=True and the time variable is included in fixed_effects, the regression includes time dummies and standard errors are automatically clustered at the unit level to account for within-unit serial correlation, unless a larger clustering variable is specified.

Balancing strategies#

While DCB is the recommended default, the balancing argument supports several alternative weighting strategies: "ipw" for inverse probability weighting, "aipw" for augmented IPW, and "ipw_msm" for marginal structural model weights.

result_ipw = dyn_balancing(
    data=df,
    yname="Y",
    tname="Time",
    idname="Unit",
    treatment_name="D",
    ds1=[1, 1],
    ds2=[0, 0],
    xformla="~ V1 + V2 + V3 + V4 + V5",
    fixed_effects=["region"],
    balancing="ipw",
)

These alternatives require estimating the propensity score via logistic regression, which can fail when covariates are high-dimensional or when the propensity score is close to zero for some units. On the Acemoglu et al. dataset, the AIPW estimator encounters a singular Hessian in the propensity score model, illustrating exactly the instability that motivates DCB. DCB avoids this problem entirely by constructing balancing weights through a quadratic program that does not estimate or require the propensity score.

Publication-quality tables#

The result objects implement the maketables interface for combining multiple specifications into a single publication table.

from moderndid.core.maketables import etable

tab = etable(
    [result, result_cl, result_pooled],
    labels=["DCB", "DCB (clustered)", "DCB (pooled)"],
)
print(tab)

See Publication Tables with maketables for more details on the table system.

Next steps#

For details on additional parameters including impulse_response, final_periods, debias, and fast_adaptive, see the API reference.

For theoretical background on the dynamic covariate balancing methodology and the formal identification and inference results, see the Background section.