Augmented Synthetic Difference-in-Differences (ASDID)

ASDID is a causal inference estimator for panel data that combines the strengths of Difference-in-Differences (DID) and Synthetic Control (SC) methods, building on the Synthetic Difference-in-Differences (SDID) framework by Arkhangelsky et al. (2021).

When to use ASDID

  • You have panel data (units observed over time) with a treatment that starts at a known time

  • Parallel trends may not hold exactly (some units are better controls than others)

  • You have many control units (100s to millions) and want to select the best donors

  • You need power analysis or inference via placebo tests

Key differences from SDID

Feature

SDID

ASDID

Fixed effects

Intercept in solver

Two-way demeaning (explicit FE removal)

Regularization

eta * noise_level (hand-tuned)

MAD² of first-differences (data-driven, robust)

Time weighting

Equal weight per observation

Inverse-variance weighting (downweights noisy periods)

Scalability

~1000 units (Frank-Wolfe)

5M+ units (PGD + donor screening)

Covariates

Joint optimization

Partial-out via FWL theorem

Quick Start: Panel Estimator

The simplest way to use ASDID within the azcausal framework:

[1]:
from azcausal.data import CaliforniaProp99
from azcausal.estimators.panel.asdid import ASDID

# Load the California Proposition 99 dataset
panel = CaliforniaProp99().panel()

# Fit ASDID
result = ASDID().fit(panel)
print(result.summary())
╭──────────────────────────────────────────────────────────────────────────────╮
|                                    Panel                                     |
|  Time Periods: 31 (19/12)                                  total (pre/post)  |
|  Units: 39 (38/1)                                       total (contr/treat)  |
├──────────────────────────────────────────────────────────────────────────────┤
|                                     ATT                                      |
|  Effect: -14.60                                                              |
|  Observed: 60.35                                                             |
|  Counter Factual: 74.95                                                      |
├──────────────────────────────────────────────────────────────────────────────┤
|                                  Percentage                                  |
|  Effect: -19.48                                                              |
|  Observed: 80.52                                                             |
|  Counter Factual: 100.00                                                     |
├──────────────────────────────────────────────────────────────────────────────┤
|                                  Cumulative                                  |
|  Effect: -175.19                                                             |
|  Observed: 724.20                                                            |
|  Counter Factual: 899.39                                                     |
╰──────────────────────────────────────────────────────────────────────────────╯

Standalone Package

The standalone module has no dependencies on azcausal — only numpy and scipy. It can be copied into any project and used independently.

from azcausal.standalone.asdid import asdid, did, se_placebo, power_curve

Data Preparation

Convert a long-format DataFrame to the matrix inputs using df_to_matrix. It validates that treatment assignment is consistent (block treatment).

[2]:
from azcausal.standalone.asdid import df_to_matrix, asdid
from azcausal.data import CaliforniaProp99

# Get raw DataFrame
df = CaliforniaProp99().df()
print(df.head())

# Convert to matrix format
Y, n_pre, treat = df_to_matrix(df, time_col="Year", unit_col="State",
                               outcome_col="PacksPerCapita", treat_col="treated")
print(f"Y: {Y.shape}, n_pre: {n_pre}, n_treat: {treat.sum()}")

# Run ASDID directly
att, lambd, omega, donors = asdid(Y, n_pre, treat)
print(f"ATT: {att[0]:.2f}")
         State  Year  PacksPerCapita  treated
0      Alabama  1970       89.800003        0
1     Arkansas  1970      100.300003        0
2     Colorado  1970      124.800003        0
3  Connecticut  1970      120.000000        0
4     Delaware  1970      155.000000        0
Y: (31, 39), n_pre: 19, n_treat: 1
ATT: -14.60

Estimation

Both asdid() and did() return (att, lambd, omega, donors):

[3]:
import numpy as np
from azcausal.standalone.asdid import asdid, did

# Prepare data as numpy arrays
panel = CaliforniaProp99().panel()
Y = panel['PacksPerCapita'].values  # (n_time, n_units)
n_pre = 19  # 19 pre-treatment periods
treat = np.zeros(39, dtype=bool)
treat[2] = True  # California is the treated unit

# ASDID: learns optimal unit weights (omega) and time weights (lambda)
att, lambd, omega, donors = asdid(Y, n_pre, treat)
print(f'ASDID: ATT = {att[0]:.2f} packs per capita')

# DID: uniform weights (standard difference-in-differences)
att_d, lambd_d, omega_d, donors_d = did(Y, n_pre, treat)
print(f'DID:   ATT = {att_d[0]:.2f} packs per capita')
ASDID: ATT = -14.60 packs per capita
DID:   ATT = -27.35 packs per capita

Inference

Standard errors can be computed via:

  • Placebo (se_placebo): Permutation-based, recommended for n_treat=1. Correct coverage.

  • Jackknife (se_jackknife): Analytical leave-one-out, fast. Requires n_treat ≥ 2.

Inference functions take an estimator object as the first argument, decoupling estimation from inference:

[4]:
from azcausal.standalone.asdid import ASDID as ASDIDEstimator, se_placebo, summary

# Create estimator object (holds configuration)
est = ASDIDEstimator(max_donors=10000)

# Placebo SE: permutes treatment assignment to estimate null distribution
se = se_placebo(est, Y, n_pre, treat, n_placebo=200)

# Print formatted summary with confidence intervals
res = asdid(Y, n_pre, treat, return_dict=True)
summary(Y, n_pre, treat, res, se=se, conf=90, title='California Prop 99')
╭──────────────────────────────────────────────────────────────────────────────╮
│                              California Prop 99                              │
├══════════════════════════════════════════════════════════════════════════════┤
│                                    Panel                                     │
│  Time Periods: 31 (19/12) total (pre/post)                                   │
│  Units: 39 (38/1) total (contr/treat)                                        │
│  Donors: 38                                                                  │
├──────────────────────────────────────────────────────────────────────────────┤
│                                     ATT                                      │
│  Effect (±SE): -14.60 (±9.1203)                                              │
│  Confidence Interval (90%): [-29.60 , 0.40]                                  │
│  Observed: 60.35                                                             │
│  Counter Factual: 74.95                                                      │
├──────────────────────────────────────────────────────────────────────────────┤
│                                  Percentage                                  │
│  Effect (±SE): -19.48 (±12.17)                                               │
│  Confidence Interval (90%): [-39.49 , 0.54]                                  │
│  Observed: 80.52                                                             │
│  Counter Factual: 100.00                                                     │
├──────────────────────────────────────────────────────────────────────────────┤
│                                  Cumulative                                  │
│  Effect (±SE): -175.19 (±109.44)                                             │
│  Confidence Interval (90%): [-355.21 , 4.83]                                 │
│  Observed: 724.20                                                            │
│  Counter Factual: 899.39                                                     │
╰──────────────────────────────────────────────────────────────────────────────╯

Power Analysis

Simulate experiments to determine statistical power at different effect sizes. Uses the control panel to generate random treatment assignments and inject known ATTs.

[5]:
from azcausal.standalone.asdid import power_curve, plot_power_curve, mde, DID
import matplotlib.pyplot as plt

# Compare power: ASDID vs DID
att_values = [-25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25]

df_asdid = power_curve(est, Y, n_pre, n_treat=5, att_values=att_values, n_samples=200)
fig, _ = plot_power_curve(df_asdid, title='ASDID Power Curve — California Prop 99')
plt.show()

# Minimum detectable effect
print(f'ASDID MDE (90% conf, 90% power): {mde(se, conf=90, power=0.9):.1f} packs')
../_images/estimators_asdid_12_0.png
ASDID MDE (90% conf, 90% power): 26.7 packs

Visualization

Three-panel plot showing raw data, demeaned residuals, and the synthetic control fit:

[6]:
from azcausal.standalone.asdid import plot_asdid

res = asdid(Y, n_pre, treat, return_dict=True)
fig, _ = plot_asdid(Y, res)
plt.show()
../_images/estimators_asdid_14_0.png

Scalability

ASDID scales to millions of units via donor screening: an unconstrained ridge pre-screen identifies the top-K most relevant control units, then PGD optimizes weights on the reduced set.

[7]:
import time

# Generate a large panel: 500k units, 13 time periods
np.random.seed(42)
Y_big = np.random.randn(13, 500_000).astype(np.float32) * 0.5
Y_big[9:, -100:] += 3.0
treat_big = np.zeros(500_000, dtype=bool)
treat_big[-100:] = True

t0 = time.time()
att, lambd, omega, donors = asdid(Y_big, 9, treat_big, max_donors=1000)
elapsed = time.time() - t0

print(f'Panel: 500,000 units × 13 time periods')
print(f'ATT: {att[0]:.2f} (true: 3.0)')
print(f'Selected {len(donors)} donors from 499,900 controls')
print(f'Time: {elapsed:.2f}s')
Panel: 500,000 units × 13 time periods
ATT: 3.01 (true: 3.0)
Selected 1000 donors from 499,900 controls
Time: 0.09s

Covariate Adjustment

ASDID supports time-varying covariates via the Frisch-Waugh-Lovell theorem. Covariates are partialled out before weight estimation, removing confounding from observed factors that shift differently for treated and control units.

[8]:
# Example: covariates that confound the treatment effect
np.random.seed(42)
n_time, n_units, n_cov = 50, 30, 2
n_pre, n_treat = 35, 5
true_att, true_beta = 3.0, np.array([4.0, -2.5])

treat_ex = np.zeros(n_units, dtype=bool); treat_ex[-n_treat:] = True
X = np.random.randn(n_time, n_units, n_cov) * 0.5
X[n_pre:, -n_treat:, 0] += 1.5  # confounding covariate shift

Y_ex = np.random.randn(n_time, n_units) * 0.3
Y_ex += np.einsum('tnc,c->tn', X, true_beta)  # covariate effect
Y_ex[n_pre:, -n_treat:] += true_att

# Without covariates: biased
att_no_x, _, _, _ = asdid(Y_ex, n_pre, treat_ex)
# With covariates: corrected
att_with_x, _, _, _ = asdid(Y_ex, n_pre, treat_ex, X=X)

print(f'True ATT: {true_att}')
print(f'Without X: {att_no_x[0]:.2f} (biased by confounding)')
print(f'With X:    {att_with_x[0]:.2f} (corrected)')
True ATT: 3.0
Without X: 9.05 (biased by confounding)
With X:    3.10 (corrected)