Skip to contents
library(causalverse)
knitr::opts_chunk$set(
  warning = FALSE,
  message = FALSE,cache = TRUE)

Theory and Background

Overview

Synthetic Difference-in-Differences (SynthDID) is a causal inference method introduced by Arkhangelsky et al. (2021) that combines the strengths of two well-established approaches: Synthetic Control (SC) and Difference-in-Differences (DID). The key insight is that by combining unit weights (from SC) with time weights (a new addition), SynthDID achieves double robustness properties and improved efficiency over either method alone.

Mathematical Formulation

Consider a balanced panel with NN units observed over TT time periods. Among these, NcoN_{\text{co}} units are controls (never treated) and NtrN_{\text{tr}} units receive treatment starting at period T0+1T_0 + 1. Let YitY_{it} denote the observed outcome for unit ii at time tt.

The SynthDID estimator solves the following optimization problem:

τ̂sdid=argminμ,α,β,τi=1Nt=1T(YitμαiβtWitτ)2ω̂iλ̂t \hat{\tau}^{\text{sdid}} = \arg\min_{\mu, \alpha, \beta, \tau} \sum_{i=1}^{N} \sum_{t=1}^{T} \left( Y_{it} - \mu - \alpha_i - \beta_t - W_{it} \tau \right)^2 \hat{\omega}_i \hat{\lambda}_t

where:

  • WitW_{it} is the treatment indicator (equals 1 if unit ii is treated at time tt, 0 otherwise),
  • ω̂i\hat{\omega}_i are unit weights that make the weighted average of control units approximate the treated units in the pre-treatment period,
  • λ̂t\hat{\lambda}_t are time weights that make the weighted average of pre-treatment periods approximate the post-treatment periods for control units,
  • αi\alpha_i and βt\beta_t are unit and time fixed effects.

In practice, the estimator can be written in a more compact “double-differencing” form:

τ̂sdid=(YtrpostYtrpre,λ̂)icoω̂i(YipostYipre,λ̂) \hat{\tau}^{\text{sdid}} = \left( \bar{Y}_{\text{tr}}^{\text{post}} - \bar{Y}_{\text{tr}}^{\text{pre},\hat{\lambda}} \right) - \sum_{i \in \text{co}} \hat{\omega}_i \left( Y_i^{\text{post}} - Y_i^{\text{pre},\hat{\lambda}} \right)

where Ytrpost\bar{Y}_{\text{tr}}^{\text{post}} is the average outcome of treated units in the post-treatment period, and the λ̂\hat{\lambda}-superscript denotes a time-weighted average over pre-treatment periods.

Unit Weights (ω̂\hat{\omega})

The unit weights ω̂\hat{\omega} are chosen to minimize the discrepancy between the weighted combination of control units and the treated units in the pre-treatment period. Specifically, they solve:

ω̂sdid=argminωΔN0t=1T0(ω0+i=1N0ωiYit1Ntri=N0+1NYit)2+ζ2T0ω22 \hat{\omega}^{\text{sdid}} = \arg\min_{\omega \in \Delta^{N_0}} \sum_{t=1}^{T_0} \left( \omega_0 + \sum_{i=1}^{N_0} \omega_i Y_{it} - \frac{1}{N_{\text{tr}}} \sum_{i=N_0+1}^{N} Y_{it} \right)^2 + \zeta^2 T_0 \|\omega\|_2^2

where ΔN0\Delta^{N_0} is the simplex (non-negative weights that sum to one), and ζ\zeta is a regularization parameter. The intercept ω0\omega_0 is a key difference from standard SC—it allows for a level shift, which connects SynthDID to DID.

Time Weights (λ̂\hat{\lambda})

Time weights are unique to SynthDID and not present in standard SC or DID. They solve:

λ̂sdid=argminλΔT0i=1N0(λ0+t=1T0λtYit1Tpostt=T0+1TYit)2+ξ2N0λ22 \hat{\lambda}^{\text{sdid}} = \arg\min_{\lambda \in \Delta^{T_0}} \sum_{i=1}^{N_0} \left( \lambda_0 + \sum_{t=1}^{T_0} \lambda_t Y_{it} - \frac{1}{T_{\text{post}}} \sum_{t=T_0+1}^{T} Y_{it} \right)^2 + \xi^2 N_0 \|\lambda\|_2^2

These time weights focus the comparison on pre-treatment periods that are most informative for predicting post-treatment outcomes among control units. Periods that are more similar to post-treatment periods receive higher weight.

Comparison with SC and DID

The SynthDID framework nests both SC and DID as special cases:

Method Unit Weights (ω\omega) Time Weights (λ\lambda) Intercept
DID Uniform (1/N01/N_0) Uniform (1/T01/T_0) Yes (implicit)
SC Optimized (no intercept) Zero (only uses levels) No
SynthDID Optimized (with intercept) Optimized Yes
  • DID assumes parallel trends and weights all control units and pre-treatment periods equally. It is valid when the parallel trends assumption holds but can be biased otherwise.
  • SC constructs a synthetic control by weighting donor units to match the treated unit’s pre-treatment trajectory in levels. It does not difference, so it relies on a good level match.
  • SynthDID combines both approaches. By using optimized unit weights (like SC) and optimized time weights plus an intercept (like DID), it achieves a form of “double robustness”: it remains consistent if either the SC-style level match or the DID-style parallel trends assumption approximately holds.

Assumptions

The formal assumptions underlying SynthDID include:

  1. No anticipation: Treatment has no effect before the treatment period. Units do not change behavior in anticipation of treatment.

  2. SUTVA (Stable Unit Treatment Value Assumption): The potential outcomes for any unit are unaffected by the treatment assignment of other units (no spillovers), and there is only one version of treatment.

  3. Approximate factor model: Outcomes follow an approximate factor model structure: Yit(0)=δt+ϕiμt+εit Y_{it}(0) = \delta_t + \phi_i' \mu_t + \varepsilon_{it} where δt\delta_t are time fixed effects, ϕi\phi_i are unit-specific factor loadings, μt\mu_t are common factors, and εit\varepsilon_{it} are idiosyncratic errors.

  4. Regularity conditions: The number of pre-treatment periods and control units must grow, and the error terms must satisfy certain mixing and moment conditions for asymptotic inference.

  5. Overlap in pre-treatment outcomes: The pre-treatment trajectory of treated units must lie approximately within the convex hull of control units’ trajectories (similar to SC).

A key advantage of SynthDID over pure DID is that it relaxes the strict parallel trends assumption. Instead, it requires only that the weighted combination of control units (after double-differencing) approximates the treated units’ counterfactual trajectory.

Synthetic DID

Block Adoption (Assignment)

This section contains the code from the synthdid package (Arkhangelsky et al. 2021), which can handle block adoption (i.e., when all units are treated at the same time).

library(synthdid)

# Estimate the effect of California Proposition 99 on cigarette consumption
data('california_prop99')

setup = synthdid::panel.matrices(synthdid::california_prop99)

tau.hat = synthdid::synthdid_estimate(setup$Y, setup$N0, setup$T0)

print(summary(tau.hat))
#> $estimate
#> [1] -15.60383
#> 
#> $se
#>      [,1]
#> [1,]   NA
#> 
#> $controls
#>                estimate 1
#> Nevada              0.124
#> New Hampshire       0.105
#> Connecticut         0.078
#> Delaware            0.070
#> Colorado            0.058
#> Illinois            0.053
#> Nebraska            0.048
#> Montana             0.045
#> Utah                0.042
#> New Mexico          0.041
#> Minnesota           0.039
#> Wisconsin           0.037
#> West Virginia       0.034
#> North Carolina      0.033
#> Idaho               0.031
#> Ohio                0.031
#> Maine               0.028
#> Iowa                0.026
#> 
#> $periods
#>      estimate 1
#> 1988      0.427
#> 1986      0.366
#> 1987      0.206
#> 
#> $dimensions
#>           N1           N0 N0.effective           T1           T0 T0.effective 
#>        1.000       38.000       16.388       12.000       19.000        2.783

# for only one treated unit, we can only use the placebo method
# se = sqrt(vcov(tau.hat, method='placebo'))
# sqrt(vcov(tau.hat, method='bootstrap'))
# sqrt(vcov(tau.hat, method='jackknife'))

# sprintf('point estimate: %1.2f', tau.hat)
# sprintf('95%% CI (%1.2f, %1.2f)', tau.hat - 1.96 * se, tau.hat + 1.96 * se)
synthdid::synthdid_plot(tau.hat) + ama_theme()

Using fixest dataset

If you want to use the base_stagg dataset, you have to choose just 1 period, because function synthdid_estimate cannot handle staggered adoption.

df_stagg <- fixest::base_stagg |>

  dplyr::filter(year_treated %in% c(10000, 8)) |>
  dplyr::transmute(
    id = as.factor(id),
    year = as.integer(year),
    y = as.double(y),
    treated = as.integer(if_else(time_to_treatment >= 0, 1, 0))
  )
head(df_stagg)
#>   id year          y treated
#> 1 84    1 -1.5837825       0
#> 2 81    1  1.7723144       0
#> 3 80    1 -0.7819864       0
#> 4 79    1 -4.7819034       0
#> 5 78    1 -8.6996118       0
#> 6 77    1  1.6714719       0

setup_stagg = df_stagg |>
  # specify column names
  synthdid::panel.matrices(
    unit = "id",
    time = "year",
    outcome = "y",
    treatment = "treated"
  )
head(fixest::base_did)
#>             y         x1 id period post treat
#> 1  2.87530627  0.5365377  1      1    0     1
#> 2  1.86065272 -3.0431894  1      2    0     1
#> 3  0.09416524  5.5768439  1      3    0     1
#> 4  3.78147485 -2.8300587  1      4    0     1
#> 5 -2.55819959 -5.0443544  1      5    0     1
#> 6  1.72873240 -0.6363849  1      6    1     1
df = fixest::base_did |> 
  mutate(
    id = as.factor(id),
    period = as.integer(period),
    y = as.double(y),
    post = as.integer(post)
  ) |> 
  # correct treatment 
  mutate(treatment = as.integer(if_else(treat == 0, 0, post))) 

setup <- df |>
  synthdid::panel.matrices(
    unit = "id",
    time = "period",
    outcome = "y",
    treatment = "treatment"
  )


tau.hat = synthdid::synthdid_estimate(setup$Y, setup$N0, setup$T0)
tau.hat
#> synthdid: 4.828 +- 1.025. Effective N0/N0 = 52.4/53~1.0. Effective T0/T0 = 4.6/5~0.9. N1,T1 = 55,5.
# sqrt(vcov(tau.hat, method='bootstrap'))
sqrt(vcov(tau.hat, method='jackknife'))
#>           [,1]
#> [1,] 0.5228316

# to use this SE method, must have more controls than treated units
# can try the base_stagg dataset from above 
# sqrt(vcov(tau.hat, method='placebo'))

# Comparing counterfactual estimate to real-world observation
synthdid::synthdid_plot(tau.hat
                        # , se.method = "placebo"
                        ) + ama_theme()


# Control unit contribution plot (black is the ATE, grey lines are CIs)
synthdid::synthdid_units_plot(tau.hat) +
  ama_theme(base_size = 8) +
  ggplot2::theme(axis.text.x = ggplot2::element_text(angle = 45, hjust = 1, size = 7))


# pre-treatment parallel trends
# synthdid::synthdid_plot(tau.hat, overlay = 1)
# synthdid::synthdid_plot(tau.hat, overlay = .8)
# synthdid::synthdid_placebo_plot(tau.hat)
# synthdid::synthdid_rmse_plot(tau.hat)

Incorporating Covariates (Residualization)

The standard SynthDID estimator operates on a single outcome variable and does not directly accept covariates. However, time-varying covariates can be incorporated through a residualization approach: first regress the outcome on covariates, then apply SynthDID to the residuals.

Why Use Covariates?

Covariates can improve SynthDID estimation in several ways:

  1. Improved pre-treatment fit: By removing variation explained by observables, the residualized outcome may be easier to match across treated and control units.
  2. Reduced bias: If covariates are correlated with both treatment assignment and outcomes, including them reduces omitted variable bias.
  3. Increased precision: Removing covariate-driven variation reduces noise, leading to tighter standard errors.

The Residualization Approach

To incorporate time-varying elements into the unit and time weights of the synthdid, consider using residuals obtained from regressing observed outcomes against time-varying variables. This can be expressed as:

Yitres=YitXitβ̂ Y_{it}^{res} = Y_{it} - X_{it} \hat{\beta}

where β̂\hat{\beta} is derived from the regression equation Y=βXitY = \beta X_{it}.

The steps are:

  1. Regress the outcome on covariates (pooling all units and time periods).
  2. Extract the residuals.
  3. Apply SynthDID to the residuals as the new outcome.

Single Covariate Example

setup2 <- df |>
  dplyr::mutate(y_res = residuals(lm(y ~ x1))) |>
  synthdid::panel.matrices(
    unit = "id",
    time = "period",
    outcome = "y_res",
    treatment = "treatment"
  )


tau.hat2 = synthdid::synthdid_estimate(setup2$Y, setup2$N0, setup2$T0)
tau.hat2
#> synthdid: 4.850 +- 0.923. Effective N0/N0 = 52.5/53~1.0. Effective T0/T0 = 4.6/5~0.9. N1,T1 = 55,5.

Multiple Covariates

When multiple covariates are available, simply include them in the regression:

# Example with multiple covariates
# base_did only has x1, so we demonstrate with x1 (extend similarly for x2, x3, etc.)
df_multi <- df |>
  dplyr::mutate(y_res = residuals(lm(y ~ x1)))

setup_multi <- df_multi |>
  synthdid::panel.matrices(
    unit = "id",
    time = "period",
    outcome = "y_res",
    treatment = "treatment"
  )
tau_multi = synthdid::synthdid_estimate(setup_multi$Y, setup_multi$N0, setup_multi$T0)
tau_multi
#> synthdid: 4.850 +- 0.923. Effective N0/N0 = 52.5/53~1.0. Effective T0/T0 = 4.6/5~0.9. N1,T1 = 55,5.

Important Considerations for Residualization

When using the residualization approach, keep the following in mind:

  1. Use pre-treatment data only for the regression (ideally). Running the regression on all data including post-treatment periods may introduce post-treatment bias if the treatment affects the covariates. A conservative approach is:
# Conservative approach: estimate beta from pre-treatment data only
beta_pre <- lm(y ~ x1, data = df |> dplyr::filter(post == 0))
df$y_res_conservative <- df$y - predict(beta_pre, newdata = df)
head(df[, c("id", "period", "y", "y_res_conservative")])
#>   id period           y y_res_conservative
#> 1  1      1  2.87530627           2.060713
#> 2  1      2  1.86065272           4.562316
#> 3  1      3  0.09416524          -5.671367
#> 4  1      4  3.78147485           6.273786
#> 5  1      5 -2.55819959           2.109147
#> 6  1      6  1.72873240           2.066265
  1. Do not include treatment-affected covariates: If a covariate is itself affected by the treatment (a “bad control” or mediator), including it will bias the treatment effect estimate.

  2. Unit and time fixed effects: If the covariates include unit-specific or time-specific means, consider whether these overlap with the fixed effects already implicit in SynthDID.

  3. Comparing with and without covariates: It is good practice to report results both with and without covariate adjustment. If results are similar, this provides additional confidence in robustness.

tau.sc   = synthdid::sc_estimate(setup$Y, setup$N0, setup$T0)
tau.did  = synthdid::did_estimate(setup$Y, setup$N0, setup$T0)
estimates = list(tau.did, tau.sc, tau.hat)
names(estimates) = c('Diff-in-Diff', 'Synthetic Control', 'Synthetic Diff-in-Diff')

print(unlist(estimates))
#>           Diff-in-Diff      Synthetic Control Synthetic Diff-in-Diff 
#>               4.993390               4.475815               4.827761

synthdid::synthdid_plot(estimates) +
  ama_theme()

Spaghetti plots

top.controls = synthdid::synthdid_controls(tau.hat)[1:10, , drop=FALSE]
synthdid::synthdid_plot(tau.hat, spaghetti.units=rownames(top.controls)) + ama_theme()

synthdid::synthdid_units_plot(estimates) +
  ama_theme() +
  ggplot2::theme(axis.text.x = ggplot2::element_text(angle = 45, hjust = 1, size = 7))

synthdid::synthdid_units_plot(tau.hat, units = rownames(top.controls)) +
  ama_theme() +
  ggplot2::theme(axis.text.x = ggplot2::element_text(angle = 45, hjust = 1, size = 8))

Diagnostics

Good practice requires inspecting the quality of the synthetic control fit before interpreting treatment effect estimates. This section demonstrates several diagnostic tools available in the synthdid package.

Inspecting Unit Weights

The synthdid_controls() function returns the unit weights assigned to each control unit. Examining these weights helps understand which control units contribute most to the synthetic control.

# Get all unit weights
unit_weights <- synthdid::synthdid_controls(tau.hat)

# Display top contributing control units
head(unit_weights, 10)
#>     estimate 1
#> 74  0.02410098
#> 89  0.02270290
#> 91  0.02225061
#> 57  0.02183605
#> 85  0.02128654
#> 70  0.02102706
#> 104 0.02095126
#> 62  0.02090213
#> 76  0.02082322
#> 64  0.02076289

# How many controls have non-negligible weight?
cat("Number of controls with weight > 0.01:",
    sum(unit_weights > 0.01), "\n")
#> Number of controls with weight > 0.01: 47
cat("Total weight on top 5 controls:",
    sum(sort(unit_weights, decreasing = TRUE)[1:5]), "\n")
#> Total weight on top 5 controls: 0.1121771

A well-behaved SynthDID estimate typically places non-trivial weight on a relatively small number of control units, producing a sparse and interpretable synthetic control.

Pre-Treatment Fit Assessment

Evaluating how well the synthetic control matches the treated unit(s) in the pre-treatment period is essential. A poor pre-treatment fit suggests the synthetic control may not provide a reliable counterfactual.

# Overlay plot: shows treated vs synthetic in pre-treatment
# overlay = 1 means full overlap in display
synthdid::synthdid_plot(tau.hat, overlay = 1) +
  ama_theme() +
  ggplot2::ggtitle("Pre-treatment Fit: Treated vs. Synthetic Control")

The overlay parameter controls the degree of visual overlap between treated and synthetic trajectories. A value of 1 maximizes overlap, making it easier to assess pre-treatment fit. Values between 0 and 1 provide partial overlay.

# Partial overlay for alternative visualization
synthdid::synthdid_plot(tau.hat, overlay = 0.8) +
  ama_theme() +
  ggplot2::ggtitle("Partial Overlay (0.8)")

Placebo and RMSE Diagnostics

The synthdid_placebo_plot() function performs placebo tests by iteratively treating each control unit as if it were treated. If the method is working well, the placebo effects should be centered around zero, and the true treatment effect should stand out.

synthdid::synthdid_placebo_plot(tau.hat) +
  ama_theme() +
  ggplot2::ggtitle("Placebo Test: Distribution of Placebo Effects")

The synthdid_rmse_plot() function displays the root mean squared error of the pre-treatment fit for the treated unit relative to placebo fits for control units.

synthdid::synthdid_rmse_plot(tau.hat) +
  ama_theme() +
  ggplot2::ggtitle("RMSE Diagnostic Plot")

If the treated unit’s pre-treatment RMSE is substantially larger than the placebo units’ RMSE values, this indicates a poor pre-treatment fit and the results should be interpreted with caution.

Comparing Diagnostics Across Estimators

It can also be informative to compare diagnostic plots across DID, SC, and SynthDID to understand how each method constructs its counterfactual.

# Recall: estimates list was created earlier
# estimates = list(tau.did, tau.sc, tau.hat)
synthdid::synthdid_plot(estimates, overlay = 1) +
  ama_theme() +
  ggplot2::ggtitle("Pre-treatment Fit Across Estimators")

Standard Errors and Inference

Inference for SynthDID requires special methods because the weights are estimated from the data. The synthdid package provides three approaches for computing standard errors, each with different properties and applicability.

Placebo Method

The placebo method computes standard errors by iteratively reassigning treatment to control units. For each placebo iteration, a control unit is treated as if it received the treatment, and the SynthDID estimator is recomputed on the remaining controls. The standard deviation of these placebo estimates provides the standard error.

When to use: The placebo method is preferred when there is only one treated unit (as in the California Proposition 99 example). It requires that the number of control units exceeds the number of treated units (N0>N1N_0 > N_1).

setup_ca = synthdid::panel.matrices(synthdid::california_prop99)
tau_ca = synthdid::synthdid_estimate(setup_ca$Y, setup_ca$N0, setup_ca$T0)

# Placebo SE (works with single treated unit)
se_placebo = sqrt(vcov(tau_ca, method = 'placebo'))
cat("Placebo SE:", round(se_placebo, 3), "\n")
#> Placebo SE: 10.988
cat("95% CI: (", round(tau_ca - 1.96 * se_placebo, 2), ",",
    round(tau_ca + 1.96 * se_placebo, 2), ")\n")
#> 95% CI: ( -37.14 , 5.93 )

The causalverse package provides synthdid_se_placebo(), which extends the placebo method to compute per-period standard errors, useful for examining how uncertainty varies across post-treatment periods.

# Per-period placebo SE using causalverse
se_per_placebo <- causalverse::synthdid_se_placebo(tau_ca, replications = 500)
print(se_per_placebo)
#>  [1]  0.0000000  0.0000000  0.0000000  0.0000000  0.0000000  0.0000000
#>  [7]  0.0000000  0.0000000  0.0000000  0.0000000  0.0000000  0.0000000
#> [13]  0.0000000  0.0000000  0.0000000  0.0000000  0.9753081  0.6982304
#> [19]  1.1515451  3.6942604  8.1181143  8.9459650  9.9971758 10.1986216
#> [25] 11.5297336 12.4457975 12.7908651 13.6441388 12.6979526 13.4358033
#> [31] 13.9389997  3.6942604  5.4551748  6.2090102  6.9327953  7.4077444
#> [37]  7.8063416  8.2300288  8.5699643  8.8712930  9.0372998  9.2210960
#> [43]  9.4440224

Jackknife Method

The jackknife method estimates variance by systematically leaving out one unit at a time and recomputing the estimate. This is the leave-one-out approach commonly used in statistics.

When to use: The jackknife method requires more than one treated unit. When there is only one treated unit, causalverse::synthdid_se_jacknife() automatically falls back to the placebo method.

# Jackknife SE (requires multiple treated units)
# Using base_did which has multiple treated units
se_jack = sqrt(vcov(tau.hat, method = 'jackknife'))
cat("Jackknife SE:", round(se_jack, 3), "\n")
#> Jackknife SE: 0.523

The causalverse package provides synthdid_se_jacknife(), which extends the standard jackknife to produce per-period standard errors:

# Per-period jackknife SE using causalverse
se_per_jack <- causalverse::synthdid_se_jacknife(tau.hat)
print(se_per_jack)
#>  [1] 0.1693245 0.1566397 0.2301585 0.1284699 0.1639381 0.8780495 0.9689758
#>  [8] 1.1750561 0.9525326 1.1468373 0.8780495 0.6799381 0.6388343 0.5293119
#> [15] 0.5228316

Bootstrap Method

The bootstrap method resamples control units with replacement and recomputes the estimate for each bootstrap sample.

When to use: Bootstrap can be used as an alternative to the jackknife when there are multiple treated units. It is generally more computationally intensive but can be more robust in finite samples.

# Bootstrap SE (requires multiple treated units)
se_boot = sqrt(vcov(tau.hat, method = 'bootstrap'))
cat("Bootstrap SE:", round(se_boot, 3), "\n")
#> Bootstrap SE: 0.568

Comparison of SE Methods

The table below summarizes the properties of each inference method:

Method Min. Treated Units Requirement Computational Cost Per-Period
Placebo 1 N0>N1N_0 > N_1 Moderate Yes (via causalverse)
Jackknife 2+ (fallback to placebo if 1) None Low Yes (via causalverse)
Bootstrap 2+ None High No (standard)

Constructing Confidence Intervals

Once standard errors are obtained, confidence intervals can be constructed in the usual way:

CI1α=τ̂±zα/2×SE(τ̂) \text{CI}_{1-\alpha} = \hat{\tau} \pm z_{\alpha/2} \times \text{SE}(\hat{\tau})

For a 95% confidence interval, z0.025=1.96z_{0.025} = 1.96.

cat("=== Comparison of SE Methods (base_did data) ===\n")
#> === Comparison of SE Methods (base_did data) ===
cat("Point estimate:", round(as.numeric(tau.hat), 3), "\n\n")
#> Point estimate: 4.828

cat("Jackknife SE:", round(se_jack, 3), "\n")
#> Jackknife SE: 0.523
cat("  95% CI: (", round(as.numeric(tau.hat) - 1.96 * se_jack, 2), ",",
    round(as.numeric(tau.hat) + 1.96 * se_jack, 2), ")\n\n")
#>   95% CI: ( 3.8 , 5.85 )

cat("Bootstrap SE:", round(se_boot, 3), "\n")
#> Bootstrap SE: 0.568
cat("  95% CI: (", round(as.numeric(tau.hat) - 1.96 * se_boot, 2), ",",
    round(as.numeric(tau.hat) + 1.96 * se_boot, 2), ")\n")
#>   95% CI: ( 3.71 , 5.94 )

Multiple Estimators with panel_estimate()

The causalverse::panel_estimate() function provides a unified interface for computing treatment effect estimates using multiple methods simultaneously. This is valuable for assessing the robustness of findings across different identifying assumptions.

Available Estimators

The package supports the following estimators:

Estimator Function Description
synthdid synthdid::synthdid_estimate() Synthetic Difference-in-Differences: combines unit weights, time weights, and an intercept.
did synthdid::did_estimate() Standard Difference-in-Differences: uniform weights on all control units and all pre-treatment periods.
sc synthdid::sc_estimate() Synthetic Control: optimized unit weights with no intercept and no time weights.
sc_ridge Custom wrapper SC with ridge-regularized unit weights. Regularization parameter scales as (N1×T1)1/4(N_1 \times T_1)^{1/4}.
difp Custom wrapper De-meaned SynthDID: uses uniform time weights (1/T01/T_0) with SynthDID-style unit weights.
difp_ridge Custom wrapper DIFP with ridge regularization on unit weights.

Running All Estimators

setup = synthdid::panel.matrices(synthdid::california_prop99)

# Run for specific estimators
results_selected = causalverse::panel_estimate(
  setup,
  selected_estimators = c("synthdid", "did", "sc")
)

results_selected
#> $synthdid
#> $synthdid$estimate
#> synthdid: -15.604 +- NA. Effective N0/N0 = 16.4/38~0.4. Effective T0/T0 = 2.8/19~0.1. N1,T1 = 1,12. 
#> 
#> $synthdid$std.error
#> [1] 10.05324
#> 
#> 
#> $did
#> $did$estimate
#> synthdid: -27.349 +- NA. Effective N0/N0 = 38.0/38~1.0. Effective T0/T0 = 19.0/19~1.0. N1,T1 = 1,12. 
#> 
#> $did$std.error
#> [1] 15.81479
#> 
#> 
#> $sc
#> $sc$estimate
#> synthdid: -19.620 +- NA. Effective N0/N0 = 3.8/38~0.1. Effective T0/T0 = Inf/19~Inf. N1,T1 = 1,12. 
#> 
#> $sc$std.error
#> [1] 11.16422
# to access more details in the estimate object
summary(results_selected$did$estimate)
#> $estimate
#> [1] -27.34911
#> 
#> $se
#>      [,1]
#> [1,]   NA
#> 
#> $controls
#>                estimate 1
#> Wyoming             0.026
#> Wisconsin           0.026
#> West Virginia       0.026
#> Virginia            0.026
#> Vermont             0.026
#> Utah                0.026
#> Texas               0.026
#> Tennessee           0.026
#> South Dakota        0.026
#> South Carolina      0.026
#> Rhode Island        0.026
#> Pennsylvania        0.026
#> Oklahoma            0.026
#> Ohio                0.026
#> North Dakota        0.026
#> North Carolina      0.026
#> New Mexico          0.026
#> New Hampshire       0.026
#> Nevada              0.026
#> Nebraska            0.026
#> Montana             0.026
#> Missouri            0.026
#> Mississippi         0.026
#> Minnesota           0.026
#> Maine               0.026
#> Louisiana           0.026
#> Kentucky            0.026
#> Kansas              0.026
#> Iowa                0.026
#> Indiana             0.026
#> Illinois            0.026
#> Idaho               0.026
#> Georgia             0.026
#> Delaware            0.026
#> Connecticut         0.026
#> 
#> $periods
#>      estimate 1
#> 1988      0.053
#> 1987      0.053
#> 1986      0.053
#> 1985      0.053
#> 1984      0.053
#> 1983      0.053
#> 1982      0.053
#> 1981      0.053
#> 1980      0.053
#> 1979      0.053
#> 1978      0.053
#> 1977      0.053
#> 1976      0.053
#> 1975      0.053
#> 1974      0.053
#> 1973      0.053
#> 1972      0.053
#> 1971      0.053
#> 
#> $dimensions
#>           N1           N0 N0.effective           T1           T0 T0.effective 
#>            1           38           38           12           19           19

process_panel_estimate(results_selected)
#>     Method Estimate    SE
#> 1 SYNTHDID   -15.60 10.05
#> 2      DID   -27.35 15.81
#> 3       SC   -19.62 11.16

Now run all six estimators (excluding mc which requires the MCPanel package):

# Run all available estimators
results_all = causalverse::panel_estimate(
  setup,
  selected_estimators = c("synthdid", "did", "sc", "sc_ridge", "difp", "difp_ridge")
)

# Format results into a clean table
results_table <- causalverse::process_panel_estimate(results_all)
results_table
#>       Method Estimate    SE
#> 1   SYNTHDID   -15.60 10.05
#> 2        DID   -27.35 15.81
#> 3         SC   -19.62 11.16
#> 4   SC_RIDGE   -21.72 11.30
#> 5       DIFP   -11.10 10.19
#> 6 DIFP_RIDGE   -16.12 10.17

Interpreting the Results

When comparing across estimators:

  • If all methods agree, this provides strong evidence for the treatment effect, as it is robust to different identifying assumptions.
  • If DID and SynthDID agree but SC differs, this suggests that level matching (SC) is difficult but the parallel trends assumption may hold.
  • If SC and SynthDID agree but DID differs, this suggests that parallel trends may be violated, but there is a good level match among weighted control units.
  • If methods disagree substantially, investigate the assumptions of each method carefully and rely on the one whose assumptions are most plausible in your context.

Visualizing Estimator Comparison

# Create estimates for plotting
tau.sc_ca   = synthdid::sc_estimate(setup$Y, setup$N0, setup$T0)
tau.did_ca  = synthdid::did_estimate(setup$Y, setup$N0, setup$T0)
tau.sdid_ca = synthdid::synthdid_estimate(setup$Y, setup$N0, setup$T0)

estimates_ca = list(tau.did_ca, tau.sc_ca, tau.sdid_ca)
names(estimates_ca) = c('DID', 'SC', 'SynthDID')

synthdid::synthdid_plot(estimates_ca) +
  ama_theme() +
  ggplot2::ggtitle("California Prop 99: Comparing Estimators")

Per-Period Treatment Effects

While the overall ATT provides a single summary of the treatment effect, researchers are often interested in how the effect evolves over time. The causalverse::synthdid_est_per() function decomposes the SynthDID estimate into per-period treatment effects.

Decomposing Effects by Period

The per-period estimator computes, for each post-treatment period t>T0t > T_0:

τ̂t=(Ytr,tŶsynth,t)intercept adjustment \hat{\tau}_t = \left( \bar{Y}_{\text{tr},t} - \hat{Y}_{\text{synth},t} \right) - \text{intercept adjustment}

where Ŷsynth,t\hat{Y}_{\text{synth},t} is the synthetic control prediction for period tt using the estimated unit and time weights.

# Using the base_did setup from earlier
sdid_est <- synthdid::synthdid_estimate(setup$Y, setup$N0, setup$T0)

# Compute per-period effects
per_period <- causalverse::synthdid_est_per(
  Y       = setup$Y,
  N0      = setup$N0,
  T0      = setup$T0,
  weights = attr(sdid_est, 'weights')
)

# View the structure
cat("Treatment effects by period:\n")
#> Treatment effects by period:
print(round(per_period$est, 3))
#>  [1]   0.000   0.000   0.000   0.000   0.000   0.000   0.000   0.000   0.000
#> [10]   0.000   0.000   0.000   0.000   0.000   0.000   0.000   0.409   0.039
#> [19]  -0.448  -4.845  -4.326  -8.654  -8.419 -12.545 -16.106 -18.906 -19.350
#> [28] -20.884 -22.782 -25.945 -24.485  -4.845  -4.585  -5.941  -6.561  -7.758
#> [37]  -9.149 -10.543 -11.644 -12.671 -13.682 -14.796 -15.604

cat("\nNumber of treated units:", per_period$Ntr, "\n")
#> 
#> Number of treated units: 1
cat("Number of control units:", per_period$Nco, "\n")
#> Number of control units: 38

The output includes:

  • est: A vector of treatment effects for each period (pre-treatment periods should be near zero), followed by cumulative ATEs for post-treatment periods.
  • y_obs: Observed (weighted) outcomes for treated units.
  • y_pred: Predicted counterfactual outcomes for treated units.
  • lambda.synth: The time weights used.
  • Ntr and Nco: Counts of treated and control units.

Visualizing Per-Period Effects

# Extract post-treatment effects
T0_ca <- setup$T0
T_total <- ncol(setup$Y)
T1_ca <- T_total - T0_ca

# The first T_total elements are per-period, remaining are cumulative ATEs
per_period_effects <- per_period$est[1:T_total]
post_effects <- per_period_effects[(T0_ca + 1):T_total]

# Create a plot of per-period effects
effect_df <- data.frame(
  Period = (T0_ca + 1):T_total,
  Effect = post_effects
)

ggplot2::ggplot(effect_df, ggplot2::aes(x = Period, y = Effect)) +
  ggplot2::geom_point(size = 3) +
  ggplot2::geom_line() +
  ggplot2::geom_hline(yintercept = 0, linetype = "dashed", color = "gray50") +
  ggplot2::geom_hline(
    yintercept = as.numeric(sdid_est),
    linetype = "dotted",
    color = "blue"
  ) +
  ggplot2::labs(
    x = "Time Period",
    y = "Treatment Effect",
    title = "Per-Period Treatment Effects (SynthDID)",
    subtitle = "Blue dotted line = overall ATT"
  ) +
  ama_theme()

Staggered Adoptions

This vignette adapts the block assignment method from Arkhangelsky et al. (2021) for staggered adoption scenarios. Following the approach suggested by Ben-Michael, Feller, and Rothstein (2022), separate assignment matrices are created for each treatment period to fit the block assignment context. The synthdid estimator is then applied to each sub-sample. Finally, the Average Treatment Effect on the Treated (ATT) is calculated as a weighted average of all ATTs for all treated periods, with weights based on the proportion of treated units in each time period for each sub-sample.

df <- fixest::base_stagg |>
   dplyr::mutate(treatvar = if_else(time_to_treatment >= 0, 1, 0)) |>
   dplyr::mutate(treatvar = as.integer(if_else(year_treated > (5 + 2), 0, treatvar)))

est <- causalverse::synthdid_est_ate(
  data               = df,
  adoption_cohorts   = 5:7,
  lags               = 2,
  leads              = 2,
  time_var           = "year",
  unit_id_var        = "id",
  treated_period_var = "year_treated",
  treat_stat_var     = "treatvar",
  outcome_var        = "y", 
)
#> Adoption Cohort: 5 
#> Treated units: 5 Control units: 65 
#> Adoption Cohort: 6 
#> Treated units: 5 Control units: 60 
#> Adoption Cohort: 7 
#> Treated units: 5 Control units: 55

data.frame(
    Period = names(est$TE_mean_w),
    ATE = est$TE_mean_w,
    SE = est$SE_mean_w
) |>
    causalverse::nice_tab()
#>    Period   ATE   SE
#> 1      -2 -0.05 0.22
#> 2      -1  0.05 0.22
#> 3       0 -5.07 0.80
#> 4       1 -4.68 0.51
#> 5       2 -3.70 0.79
#> 6 cumul.0 -5.07 0.80
#> 7 cumul.1 -4.87 0.55
#> 8 cumul.2 -4.48 0.53

causalverse::synthdid_plot_ate(est)

Compare to different estimators (e.g., DID and SC)

methods <- c("synthdid", "did", "sc", "sc_ridge", "difp", "difp_ridge")

estimates_methods <- lapply(methods, function(method) {
  causalverse::synthdid_est_ate(
    data               = df,
    adoption_cohorts   = 5:7,
    lags               = 2,
    leads              = 2,
    time_var           = "year",
    unit_id_var        = "id",
    treated_period_var = "year_treated",
    treat_stat_var     = "treatvar",
    outcome_var        = "y",
    method = method
  )
})
#> Adoption Cohort: 5 
#> Treated units: 5 Control units: 65 
#> Adoption Cohort: 6 
#> Treated units: 5 Control units: 60 
#> Adoption Cohort: 7 
#> Treated units: 5 Control units: 55 
#> Adoption Cohort: 5 
#> Treated units: 5 Control units: 65 
#> Adoption Cohort: 6 
#> Treated units: 5 Control units: 60 
#> Adoption Cohort: 7 
#> Treated units: 5 Control units: 55 
#> Adoption Cohort: 5 
#> Treated units: 5 Control units: 65 
#> Adoption Cohort: 6 
#> Treated units: 5 Control units: 60 
#> Adoption Cohort: 7 
#> Treated units: 5 Control units: 55 
#> Adoption Cohort: 5 
#> Treated units: 5 Control units: 65 
#> Adoption Cohort: 6 
#> Treated units: 5 Control units: 60 
#> Adoption Cohort: 7 
#> Treated units: 5 Control units: 55 
#> Adoption Cohort: 5 
#> Treated units: 5 Control units: 65 
#> Adoption Cohort: 6 
#> Treated units: 5 Control units: 60 
#> Adoption Cohort: 7 
#> Treated units: 5 Control units: 55 
#> Adoption Cohort: 5 
#> Treated units: 5 Control units: 65 
#> Adoption Cohort: 6 
#> Treated units: 5 Control units: 60 
#> Adoption Cohort: 7 
#> Treated units: 5 Control units: 55

plots <- lapply(seq_along(estimates_methods), function(i) {
  causalverse::synthdid_plot_ate(estimates_methods[[i]],
                                 title = methods[i],
                                 theme = causalverse::ama_theme(base_size = 6)) +
    theme(axis.text.x = element_text(angle = 45, hjust = 1, size = 8))
})

gridExtra::grid.arrange(grobs = plots, ncol = 2)

Subgroup Analysis (i.e., exploring treatment effects heterogeneity)


est_sub <- causalverse::synthdid_est_ate(
  data               = df,
  adoption_cohorts   = 5:7,
  lags               = 2,
  leads              = 2,
  time_var           = "year",
  unit_id_var        = "id",
  treated_period_var = "year_treated",
  treat_stat_var     = "treatvar",
  outcome_var        = "y",
  # a vector of subgroup id (from unit id)
  subgroup           =  c(
    # some are treated
    "11", "30", "49" ,
    # some are control within this period
    "20", "25", "21")
)
#> Adoption Cohort: 5 
#> Treated units: 3 Control units: 65 
#> Adoption Cohort: 6 
#> Treated units: 0 Control units: 60 
#> Adoption Cohort: 7 
#> Treated units: 0 Control units: 55

data.frame(
    Period = names(est_sub$TE_mean_w),
    ATE = est_sub$TE_mean_w,
    SE = est_sub$SE_mean_w
) |>
    causalverse::nice_tab()
#>    Period   ATE   SE
#> 1      -2  0.32 0.44
#> 2      -1 -0.32 0.44
#> 3       0 -4.29 1.68
#> 4       1 -4.00 1.52
#> 5       2 -3.44 2.90
#> 6 cumul.0 -4.29 1.68
#> 7 cumul.1 -4.14 1.52
#> 8 cumul.2 -3.91 1.82

causalverse::synthdid_plot_ate(est)

To incorporate time-varying variables, use the residuals obtained from regressing the outcome on these variables.

df_x <- fixest::base_stagg |>
   dplyr::mutate(treatvar = if_else(time_to_treatment >= 0, 1, 0)) |>
   dplyr::mutate(treatvar = as.integer(if_else(year_treated > (5 + 2), 0, treatvar))) |> 
  
  mutate(y_res = residuals(lm(y ~ x1)))


est_x <- causalverse::synthdid_est_ate(
  data               = df_x,
  adoption_cohorts   = 5:7,
  lags               = 2,
  leads              = 2,
  time_var           = "year",
  unit_id_var        = "id",
  treated_period_var = "year_treated",
  treat_stat_var     = "treatvar",
  outcome_var        = "y_res"
)
#> Adoption Cohort: 5 
#> Treated units: 5 Control units: 65 
#> Adoption Cohort: 6 
#> Treated units: 5 Control units: 60 
#> Adoption Cohort: 7 
#> Treated units: 5 Control units: 55

data.frame(
    Period = names(est_x$TE_mean_w),
    ATE    = est_x$TE_mean_w,
    SE     = est_x$SE_mean_w
) |>
    causalverse::nice_tab()
#>    Period   ATE   SE
#> 1      -2 -0.04 0.11
#> 2      -1  0.04 0.11
#> 3       0 -5.54 0.43
#> 4       1 -4.48 0.38
#> 5       2 -3.22 0.30
#> 6 cumul.0 -5.54 0.43
#> 7 cumul.1 -5.01 0.36
#> 8 cumul.2 -4.41 0.29

causalverse::synthdid_plot_ate(est_x)

Best Practices and Practical Guidance

When to Use SynthDID vs. SC vs. DID

Choosing the right estimator depends on the features of your data and the plausibility of the identifying assumptions:

Use DID when:

  • You have many treated and control units.
  • The parallel trends assumption is credible (e.g., pre-treatment trends are visually parallel).
  • Treatment is not concentrated in a single unit or a very small number of units.
  • You want a simple, well-understood baseline estimate.

Use SC when:

  • You have one or very few treated units (e.g., a single country, state, or firm).
  • The treated unit’s pre-treatment outcome trajectory can be closely matched by a weighted combination of control units in levels.
  • The number of pre-treatment periods is large relative to the number of control units.
  • Parallel trends is implausible, but level matching is achievable.

Use SynthDID when:

  • You want the benefits of both SC and DID without committing to one set of assumptions.
  • You have a moderate number of treated units (one or more).
  • You want double robustness: the estimator is consistent if either the SC-type or DID-type assumption holds.
  • You want data-driven time weights that focus on the most informative pre-treatment periods.

Pre-Treatment Fit Assessment

Before reporting SynthDID results, always assess the quality of the pre-treatment fit:

  1. Visual inspection: Use synthdid_plot() with overlay = 1 to compare treated and synthetic trajectories in the pre-treatment period. They should track each other closely.

  2. RMSE check: Use synthdid_rmse_plot() to compare the treated unit’s pre-treatment RMSE against placebo units. The treated unit’s RMSE should not be an outlier.

  3. Weight sparsity: Use synthdid_controls() to inspect unit weights. A very diffuse weight distribution (many controls with small weights) may indicate a poor match.

  4. Placebo tests: Use synthdid_placebo_plot() to check that the estimated effect is unusual compared to placebo effects. If many placebo effects are as large as the true effect, the result is not statistically compelling.

Reporting Guidelines

When reporting SynthDID results in academic papers or policy reports, include the following:

  1. Point estimate and standard error: Report the ATT with at least one SE method. State which SE method was used and why.

  2. Confidence interval: Report the 95% confidence interval.

  3. Pre-treatment fit plot: Show the treated vs. synthetic control trajectory, including the pre-treatment period.

  4. Unit weights: Report or visualize the control unit weights. Identify the top contributing control units.

  5. Robustness across estimators: Report results from DID, SC, and SynthDID side by side. Discuss any discrepancies.

  6. Sensitivity to specification: Consider varying the number of pre-treatment periods, the set of control units, or the covariate specification.

  7. Placebo tests: Report the results of placebo tests (in-time or in-space placebos).

  8. Staggered adoption: If treatment is staggered, clearly describe the cohort-by-cohort estimation strategy and how the overall ATT is aggregated.

Common Pitfalls

Avoid these common mistakes when applying SynthDID:

  • Too few pre-treatment periods: SynthDID requires sufficient pre-treatment data to estimate both unit and time weights reliably. As a rule of thumb, at least 10 pre-treatment periods are desirable.

  • Too few control units: With very few controls, the unit weights cannot achieve a good match and the placebo SE method will have low power.

  • Post-treatment covariates: Never residualize on variables that are themselves affected by the treatment.

  • Ignoring weight quality: Always inspect the unit and time weights. Extreme weights (e.g., all weight on a single control) suggest fragility.

  • Staggered adoption without adjustment: The base synthdid_estimate() function assumes block adoption. For staggered designs, use causalverse::synthdid_est_ate() which handles cohort-by-cohort estimation.

  • Over-interpreting per-period effects: Per-period effects are noisier than the overall ATT. Focus on the overall pattern rather than individual period estimates unless you have theoretical reasons to expect time-varying effects.

Comprehensive Visualization Suite for Synthetic DID

Treated vs. Synthetic Control Trajectory

The most important figure in any synthetic control paper is the trajectory plot showing how the treated unit and its synthetic counterpart evolved over time.

library(synthdid)

# Use the California Prop 99 data (canonical SC example)
data("california_prop99")
setup_ca <- synthdid::panel.matrices(california_prop99)

# Estimate SynthDID
est_sdid <- synthdid::synthdid_estimate(setup_ca$Y, setup_ca$N0, setup_ca$T0)
cat("SDID Estimate:", round(as.numeric(est_sdid), 3), "\n")
#> SDID Estimate: -15.604

# Built-in trajectory plot
plot(est_sdid, overlay = 1) +
  ggplot2::ggtitle("California Prop 99: Synthetic DID Trajectory")

Unit and Time Weights

Understanding which donors and pre-treatment periods receive the most weight is critical for assessing credibility.

sdid_weights <- attr(est_sdid, "weights")

# Unit weights
omega_df <- data.frame(
    unit   = rownames(setup_ca$Y)[seq_len(setup_ca$N0)],
    weight = sdid_weights$omega
)
omega_df <- omega_df[omega_df$weight > 0.005, ]
omega_df <- omega_df[order(omega_df$weight, decreasing = TRUE), ]
omega_df$unit <- factor(omega_df$unit, levels = rev(omega_df$unit))

p_omega <- ggplot(omega_df, aes(x = unit, y = weight)) +
    geom_col(fill = "#2166AC", alpha = 0.8) +
    coord_flip() +
    labs(title = "Donor Unit Weights", x = NULL, y = "Weight") +
    causalverse::ama_theme()

# Time weights
lambda_df <- data.frame(
    time   = seq_len(setup_ca$T0),
    weight = sdid_weights$lambda
)

p_lambda <- ggplot(lambda_df, aes(x = time, y = weight)) +
    geom_col(fill = "#D73027", alpha = 0.8) +
    labs(title = "Pre-Treatment Time Weights", x = "Pre-Treatment Period",
         y = "Weight") +
    causalverse::ama_theme()

gridExtra::grid.arrange(p_omega, p_lambda, ncol = 2)

Estimator Comparison

Compare SC, DID, and SDID side-by-side:

results_compare <- causalverse::panel_estimate(
    setup_ca,
    selected_estimators = c("sc", "did", "synthdid")
)
cat("=== Estimator Comparison ===\n")
#> === Estimator Comparison ===
print(causalverse::process_panel_estimate(results_compare))
#>     Method Estimate    SE
#> 1       SC   -19.62 11.12
#> 2      DID   -27.35 15.81
#> 3 SYNTHDID   -15.60 10.60

# Visual comparison
comp_df <- data.frame(
    method   = toupper(names(results_compare)),
    estimate = sapply(results_compare, function(x) as.numeric(x$estimate)),
    se       = sapply(results_compare, function(x) as.numeric(x$std.error))
)
comp_df$ci_lo <- comp_df$estimate - 1.96 * comp_df$se
comp_df$ci_hi <- comp_df$estimate + 1.96 * comp_df$se

ggplot(comp_df, aes(x = method, y = estimate)) +
    geom_hline(yintercept = 0, linetype = "dashed", color = "gray50") +
    geom_point(size = 4, color = "#2166AC") +
    geom_errorbar(aes(ymin = ci_lo, ymax = ci_hi),
                  width = 0.2, color = "#2166AC") +
    labs(
        title    = "Effect of Proposition 99: SC vs. DID vs. SDID",
        subtitle = "All estimates with 95% placebo CI",
        x        = "Estimator",
        y        = "ATT (cigarette packs per capita)"
    ) +
    causalverse::ama_theme()

RMSPE Ratio: Placebo Inference

The RMSPE (Root Mean Squared Prediction Error) ratio compares post-treatment fit to pre-treatment fit, providing a non-parametric p-value.

# Extract gaps
synth_wts   <- sdid_weights$omega
synth_Y     <- as.vector(t(synth_wts) %*% setup_ca$Y[seq_len(setup_ca$N0), ])
treated_Y   <- setup_ca$Y[nrow(setup_ca$Y), ]
time_labels <- as.numeric(colnames(setup_ca$Y))
treat_yr    <- time_labels[setup_ca$T0 + 1]

treated_gap  <- treated_Y - synth_Y
pre_mask     <- time_labels < treat_yr
post_mask    <- time_labels >= treat_yr

rmspe_pre    <- sqrt(mean(treated_gap[pre_mask]^2))
rmspe_post   <- sqrt(mean(treated_gap[post_mask]^2))
ratio_treat  <- rmspe_post / rmspe_pre

# Placebo units (subsample for speed)
n_placebo <- min(20, setup_ca$N0)
placebo_ratios <- vapply(seq_len(n_placebo), function(j) {
    g_pre  <- setup_ca$Y[j, pre_mask] -
              colMeans(setup_ca$Y[setdiff(seq_len(setup_ca$N0), j), pre_mask])
    g_post <- setup_ca$Y[j, post_mask] -
              colMeans(setup_ca$Y[setdiff(seq_len(setup_ca$N0), j), post_mask])
    pre_r  <- sqrt(mean(g_pre^2))
    if (pre_r < 1e-10) return(NA_real_)
    sqrt(mean(g_post^2)) / pre_r
}, numeric(1))
placebo_ratios <- na.omit(placebo_ratios)

p_val_rmspe <- mean(placebo_ratios >= ratio_treat)
cat("RMSPE ratio (treated):  ", round(ratio_treat, 3), "\n")
#> RMSPE ratio (treated):   1.671
cat("Placebo p-value:        ", round(p_val_rmspe, 3), "\n")
#> Placebo p-value:         0.15

ratio_df <- data.frame(
    ratio = c(as.numeric(placebo_ratios), ratio_treat),
    type  = c(rep("Placebo", length(placebo_ratios)), "Treated")
)
ggplot(ratio_df, aes(x = ratio, fill = type)) +
    geom_histogram(bins = 15, alpha = 0.7) +
    scale_fill_manual(values = c("Placebo" = "steelblue",
                                  "Treated" = "#D73027")) +
    geom_vline(xintercept = ratio_treat, color = "#D73027",
               linewidth = 1.5, linetype = "dashed") +
    labs(
        title    = "RMSPE Ratio: Treated vs. Placebo Units",
        subtitle = paste0("Permutation p-value = ", round(p_val_rmspe, 3)),
        x        = "Post/Pre RMSPE Ratio",
        y        = "Count",
        fill     = NULL
    ) +
    causalverse::ama_theme()

Staggered Adoption: ATE Across Cohorts

ate_results <- causalverse::synthdid_est_ate(
    data   = fixest::base_stagg,
    method = "synthdid"
)
causalverse::synthdid_plot_ate(ate_results)

Extended Visualization Suite

This section demonstrates the full visualization toolkit available in causalverse for synthetic DID analyses.

Gap Plot with In-Space Placebos

The causalverse::sc_gap_plot() function plots the treatment-synthetic gap over time and overlays donor unit gaps as in-space placebos, providing visual inference about whether the treated unit’s gap is unusual.

library(synthdid)

# California Prop 99 setup
data("california_prop99")
setup_ca <- synthdid::panel.matrices(california_prop99)
est_sdid  <- synthdid::synthdid_estimate(setup_ca$Y, setup_ca$N0, setup_ca$T0)
wts       <- attr(est_sdid, "weights")

# Treated unit trajectory and synthetic
Y        <- setup_ca$Y
N0       <- setup_ca$N0
T0       <- setup_ca$T0
N        <- nrow(Y)
T_total  <- ncol(Y)
time_lbl <- as.numeric(colnames(Y))
treat_yr <- time_lbl[T0 + 1]

synth_Y   <- as.vector(t(wts$omega) %*% Y[seq_len(N0), ])
treated_Y <- Y[N, ]

# Compute donor gaps (each control treated as if it were treated, with equal weights)
donor_gaps_list <- lapply(seq_len(N0), function(j) {
  other_ctrl <- setdiff(seq_len(N0), j)
  synth_j <- colMeans(Y[other_ctrl, , drop = FALSE])
  as.numeric(Y[j, ] - synth_j)
})

causalverse::sc_gap_plot(
  treated_outcome   = as.numeric(treated_Y),
  synthetic_outcome = synth_Y,
  time_periods      = time_lbl,
  treatment_period  = treat_yr,
  donor_gaps        = donor_gaps_list,
  placebo_alpha     = 0.20,
  placebo_rmspe_filter = 2,
  ci_level          = 0.90,
  title             = "California Prop 99: Gap Plot with In-Space Placebos",
  unit_label        = "California"
) + causalverse::ama_theme()

The red line shows California’s gap (treated minus synthetic). Gray lines show donor state gaps estimated with equal-weight synthetic controls. Units with pre-treatment RMSPE more than twice California’s are excluded following Abadie, Diamond and Hainmueller (2010).

Treatment Calendar: Staggered Adoption Timing


# Prepare base_stagg: add a proper binary treatment column
df_stagg_cal <- fixest::base_stagg |>
  dplyr::mutate(treatment = as.integer(time_to_treatment >= 0 &
                                         year_treated <= (5 + 2)))

# Treatment calendar heatmap
causalverse::treatment_calendar(
  data      = df_stagg_cal,
  unit_var  = "id",
  time_var  = "year",
  treat_var = "treatment",
  max_units = 40,
  title     = "Staggered Adoption Calendar: base_stagg"
) + causalverse::ama_theme(base_size = 10)

The calendar heatmap communicates treatment timing heterogeneity at a glance. Units are sorted by first treatment date (earliest adopters at top). Dark cells indicate treated periods.

Staggered Adoption Summary Table

causalverse::staggered_summary() returns a comprehensive summary of the staggered design, including cohort sizes and the calendar plot.

summ <- causalverse::staggered_summary(
  data      = df_stagg_cal,
  unit_var  = "id",
  time_var  = "year",
  treat_var = "treatment",
  plot      = TRUE
)

# Print cohort sizes table
cat("=== Cohort Sizes ===\n")
#> === Cohort Sizes ===
print(summ$cohort_sizes)
#>   first_treat_period n_units  share
#> 1                  2       5 0.0526
#> 2                  3       5 0.0526
#> 3                  4       5 0.0526
#> 4                  5       5 0.0526
#> 5                  6       5 0.0526
#> 6                  7       5 0.0526
cat("\nTotal units:", summ$n_units, "\n")
#> 
#> Total units: 95
cat("Periods:", summ$n_periods, "\n")
#> Periods: 10
cat("Never-treated units:", summ$never_treated_n, "\n")
#> Never-treated units: 65

# Cohort bar chart
summ$cohort_plot + causalverse::ama_theme()

Trajectory Plot: Observed vs. Synthetic with Shaded Treatment Window

The trajectory plot is the central figure in any synthetic control paper. Here we build a fully customized version using ggplot2 and the estimated weights from SynthDID.


# California Prop 99  -  full trajectory
Y_mat    <- setup_ca$Y
time_lbl <- as.numeric(colnames(Y_mat))
treat_yr <- time_lbl[setup_ca$T0 + 1]

synth_Y   <- as.vector(t(wts$omega) %*% Y_mat[seq_len(setup_ca$N0), ])
treated_Y <- as.numeric(Y_mat[nrow(Y_mat), ])

traj_df <- data.frame(
  year      = rep(time_lbl, 2),
  value     = c(treated_Y, synth_Y),
  series    = rep(c("California (Treated)", "Synthetic California"), each = length(time_lbl))
)

ggplot(traj_df, aes(x = year, y = value, color = series, linetype = series)) +
  annotate("rect",
    xmin = treat_yr, xmax = max(time_lbl) + 0.5,
    ymin = -Inf, ymax = Inf,
    fill = "#FEE0D2", alpha = 0.4
  ) +
  geom_vline(xintercept = treat_yr, linetype = "dashed", color = "gray40") +
  geom_line(linewidth = 1.1) +
  annotate("text",
    x = treat_yr + 0.3, y = max(treated_Y) * 0.97,
    label = "Treatment\nstarts", hjust = 0, size = 3.2, color = "gray30"
  ) +
  scale_color_manual(
    values = c("California (Treated)" = "#D73027",
               "Synthetic California" = "#2166AC")
  ) +
  scale_linetype_manual(
    values = c("California (Treated)" = "solid",
               "Synthetic California" = "dashed")
  ) +
  labs(
    title    = "California Prop 99: Observed vs. Synthetic Trajectory",
    subtitle = paste0("SDID estimate = ",
                      round(as.numeric(est_sdid), 1),
                      " cigarette packs per capita"),
    x        = "Year",
    y        = "Cigarette Packs Sold per Capita",
    color    = NULL,
    linetype = NULL
  ) +
  causalverse::ama_theme() +
  theme(legend.position = "bottom")

Unit Weight Bar Chart (Top Donors)

omega_df <- data.frame(
  unit   = rownames(setup_ca$Y)[seq_len(setup_ca$N0)],
  weight = as.numeric(wts$omega)
)
# Keep all with positive weight
omega_df <- omega_df[omega_df$weight > 1e-4, ]
omega_df <- omega_df[order(omega_df$weight, decreasing = TRUE), ]
# Top 15
omega_top <- head(omega_df, 15)
omega_top$unit <- factor(omega_top$unit, levels = rev(omega_top$unit))

ggplot(omega_top, aes(x = unit, y = weight)) +
  geom_col(fill = "#2166AC", alpha = 0.85) +
  geom_text(aes(label = sprintf("%.3f", weight)), hjust = -0.1, size = 3) +
  coord_flip(clip = "off") +
  scale_y_continuous(expand = expansion(mult = c(0, 0.2))) +
  labs(
    title    = "SynthDID Donor Unit Weights (Top 15)",
    subtitle = "Prop 99 California analysis  -  states with weight > 0",
    x        = NULL,
    y        = "Weight"
  ) +
  causalverse::ama_theme()

Time Weight Bar Chart

lambda_df <- data.frame(
  period = seq_len(setup_ca$T0),
  year   = time_lbl[seq_len(setup_ca$T0)],
  weight = as.numeric(wts$lambda)
)

ggplot(lambda_df, aes(x = year, y = weight)) +
  geom_col(fill = "#D73027", alpha = 0.80) +
  geom_hline(
    yintercept = 1 / setup_ca$T0,
    linetype = "dashed", color = "gray40"
  ) +
  annotate("text",
    x = min(lambda_df$year), y = 1 / setup_ca$T0 + 0.005,
    label = "Uniform weight (DID)",
    hjust = 0, size = 3, color = "gray40"
  ) +
  labs(
    title    = "SynthDID Pre-Treatment Time Weights",
    subtitle = "Higher weight on periods more similar to post-treatment period",
    x        = "Year (Pre-Treatment)",
    y        = "Weight"
  ) +
  causalverse::ama_theme()

The dashed line shows the uniform weight that DID would assign (1/T0). SynthDID concentrates weight on periods that are more predictive of the post-treatment period.

Estimator Comparison with Confidence Intervals

set.seed(42)

# Compute estimates and SEs for all three methods
tau_sc_ca   <- synthdid::sc_estimate(setup_ca$Y, setup_ca$N0, setup_ca$T0)
tau_did_ca  <- synthdid::did_estimate(setup_ca$Y, setup_ca$N0, setup_ca$T0)
tau_sdid_ca <- synthdid::synthdid_estimate(setup_ca$Y, setup_ca$N0, setup_ca$T0)

# Use placebo SE (valid here: single treated unit, many donors)
get_placebo_se <- function(tau_obj) {
  tryCatch(
    sqrt(vcov(tau_obj, method = "placebo")),
    error = function(e) NA_real_
  )
}

est_vals <- c(
  as.numeric(tau_sc_ca),
  as.numeric(tau_did_ca),
  as.numeric(tau_sdid_ca)
)
se_vals  <- vapply(
  list(tau_sc_ca, tau_did_ca, tau_sdid_ca),
  get_placebo_se,
  numeric(1)
)

comp_df <- data.frame(
  method   = c("Synthetic\nControl", "Difference-in-\nDifferences",
                "Synthetic\nDiff-in-Diff"),
  estimate = est_vals,
  se       = se_vals
)
comp_df$ci_lo <- comp_df$estimate - 1.96 * comp_df$se
comp_df$ci_hi <- comp_df$estimate + 1.96 * comp_df$se
comp_df$method <- factor(comp_df$method,
                         levels = comp_df$method)

ggplot(comp_df, aes(x = method, y = estimate)) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "gray50") +
  geom_errorbar(aes(ymin = ci_lo, ymax = ci_hi),
                width = 0.18, color = "#444444", linewidth = 0.8) +
  geom_point(size = 4, color = "#2166AC") +
  geom_text(aes(label = sprintf("%.1f", estimate)),
            vjust = -1.1, size = 3.3, color = "#2166AC") +
  labs(
    title    = "Proposition 99: SC vs. DID vs. SynthDID",
    subtitle = "Point estimates with 95% placebo confidence intervals",
    x        = NULL,
    y        = "ATT (packs per capita per year)"
  ) +
  causalverse::ama_theme()

Inference Methods Deep Dive

Overview of Inference Approaches

SynthDID standard errors cannot be computed from standard OLS formulas because the unit and time weights are estimated from the data. The synthdid package provides three resampling-based methods, each appropriate in different settings.

# Base DID data: multiple treated units  -  allows all three SE methods

df_did <- fixest::base_did |>
  dplyr::mutate(
    id        = as.factor(id),
    period    = as.integer(period),
    y         = as.double(y),
    treatment = as.integer(if_else(treat == 0, 0, post))
  )

setup_did <- df_did |>
  synthdid::panel.matrices(
    unit      = "id",
    time      = "period",
    outcome   = "y",
    treatment = "treatment"
  )

tau_did_base <- synthdid::synthdid_estimate(setup_did$Y, setup_did$N0, setup_did$T0)
cat("Point estimate:", round(as.numeric(tau_did_base), 4), "\n")
#> Point estimate: 4.8278
cat("N treated:", nrow(setup_did$Y) - setup_did$N0, "\n")
#> N treated: 55
cat("N control:", setup_did$N0, "\n")
#> N control: 53

Placebo (In-Space) Standard Errors

The placebo method iterates through control units, treating each one as if it were treated, and computes the SDID estimator on the remaining controls. The standard deviation of these placebo estimates provides the SE.

# Standard placebo SE requires N_controls > N_treated
# base_did: 40 control, 10 treated → eligible
se_placebo_base <- sqrt(vcov(tau_did_base, method = "placebo"))
cat("Placebo SE:", round(se_placebo_base, 4), "\n")

# Per-period placebo SE from causalverse
set.seed(2024)
se_per_period <- causalverse::synthdid_se_placebo(tau_did_base, replications = 200)
print(se_per_period)

Jackknife Standard Errors

The jackknife leaves one unit out at a time (treated or control, depending on implementation) and recomputes the estimate. The causalverse wrapper synthdid_se_jacknife() provides per-period jackknife SEs.

se_jack_base <- sqrt(vcov(tau_did_base, method = "jackknife"))
cat("Jackknife SE:", round(se_jack_base, 4), "\n")

# Per-period jackknife SE
se_jack_per <- causalverse::synthdid_se_jacknife(tau_did_base)
print(se_jack_per)

Bootstrap Standard Errors

Bootstrap resamples control units with replacement and recomputes estimates across B bootstrap draws. It is the most computationally intensive method but can be more robust in finite samples.

set.seed(123)
se_boot_base <- sqrt(vcov(tau_did_base, method = "bootstrap"))
cat("Bootstrap SE:", round(se_boot_base, 4), "\n")

Comparing All Three SE Methods

est_val <- as.numeric(tau_did_base)

se_table <- data.frame(
  Method        = c("Placebo", "Jackknife", "Bootstrap"),
  SE            = round(c(se_placebo_base, se_jack_base, se_boot_base), 4),
  CI_lower_95   = round(est_val - 1.96 *
                          c(se_placebo_base, se_jack_base, se_boot_base), 3),
  CI_upper_95   = round(est_val + 1.96 *
                          c(se_placebo_base, se_jack_base, se_boot_base), 3),
  Requirements  = c("N_ctrl > N_treated", "N_treated >= 2 (or fallback)",
                    "N_treated >= 2")
)

cat("=== SE Method Comparison ===\n")
cat("Point estimate:", round(est_val, 4), "\n\n")
print(se_table)

RMSPE Ratio Test: Permutation Inference

The RMSPE ratio test is a classic non-parametric inference approach from Abadie, Diamond, and Hainmueller (2010). It computes the ratio of post-treatment to pre-treatment prediction error (RMSPE) for both the treated unit and each control unit (treated as a placebo). A large ratio for the treated unit relative to the placebo distribution indicates a genuine treatment effect.

# Detailed RMSPE computation for California Prop 99
Y_ca    <- setup_ca$Y
T0_ca   <- setup_ca$T0
N0_ca   <- setup_ca$N0
times_ca <- as.numeric(colnames(Y_ca))
treat_yr_ca <- times_ca[T0_ca + 1]
pre_idx_ca  <- which(times_ca < treat_yr_ca)
post_idx_ca <- which(times_ca >= treat_yr_ca)

# Treated unit gap
synth_ca  <- as.vector(t(wts$omega) %*% Y_ca[seq_len(N0_ca), ])
gap_treat <- as.numeric(Y_ca[nrow(Y_ca), ]) - synth_ca

rmspe_pre_treat  <- sqrt(mean(gap_treat[pre_idx_ca]^2))
rmspe_post_treat <- sqrt(mean(gap_treat[post_idx_ca]^2))
ratio_treat      <- rmspe_post_treat / rmspe_pre_treat

cat("=== RMSPE Ratio Test ===\n")
#> === RMSPE Ratio Test ===
cat("Treated unit pre-treatment RMSPE:  ", round(rmspe_pre_treat, 4), "\n")
#> Treated unit pre-treatment RMSPE:   24.8952
cat("Treated unit post-treatment RMSPE: ", round(rmspe_post_treat, 4), "\n")
#> Treated unit post-treatment RMSPE:  41.6049
cat("RMSPE ratio (treated):             ", round(ratio_treat, 4), "\n\n")
#> RMSPE ratio (treated):              1.6712

# Placebo RMSPE ratios for all control units
placebo_ratios_all <- vapply(seq_len(N0_ca), function(j) {
  other <- setdiff(seq_len(N0_ca), j)
  synth_j <- colMeans(Y_ca[other, , drop = FALSE])
  gap_j   <- as.numeric(Y_ca[j, ]) - synth_j
  pre_r   <- sqrt(mean(gap_j[pre_idx_ca]^2))
  if (pre_r < 1e-10) return(NA_real_)
  post_r  <- sqrt(mean(gap_j[post_idx_ca]^2))
  post_r / pre_r
}, numeric(1))
placebo_ratios_all <- na.omit(placebo_ratios_all)

# Permutation p-value
p_val <- mean(placebo_ratios_all >= ratio_treat)
cat("Number of placebo units:           ", length(placebo_ratios_all), "\n")
#> Number of placebo units:            38
cat("Units with ratio >= treated unit:  ",
    sum(placebo_ratios_all >= ratio_treat), "\n")
#> Units with ratio >= treated unit:   6
cat("Permutation p-value:               ", round(p_val, 4), "\n")
#> Permutation p-value:                0.1579

# Histogram
ratio_df_all <- data.frame(
  ratio = c(as.numeric(placebo_ratios_all), ratio_treat),
  type  = c(rep("Placebo states", length(placebo_ratios_all)), "California")
)

ggplot(ratio_df_all, aes(x = ratio, fill = type)) +
  geom_histogram(bins = 20, alpha = 0.75, position = "identity") +
  geom_vline(xintercept = ratio_treat, color = "#D73027",
             linewidth = 1.5, linetype = "dashed") +
  scale_fill_manual(values = c("Placebo states" = "#4393C3",
                                "California"    = "#D73027")) +
  annotate("text",
    x = ratio_treat * 1.05, y = Inf, vjust = 2,
    label = paste0("California\nratio = ", round(ratio_treat, 1),
                   "\np = ", round(p_val, 3)),
    hjust = 0, size = 3.5, color = "#D73027"
  ) +
  labs(
    title    = "RMSPE Ratio: Permutation Inference for Prop 99",
    subtitle = paste0("California's post/pre RMSPE ratio vs. all donor states\n",
                      "Permutation p-value = ", round(p_val, 3)),
    x        = "Post/Pre RMSPE Ratio",
    y        = "Count",
    fill     = NULL
  ) +
  causalverse::ama_theme() +
  theme(legend.position = "top")

A permutation p-value below 0.05 indicates that the treated unit’s RMSPE ratio is unusual compared to what we would expect under the null hypothesis of no treatment effect.

Staggered Adoption: A Complete Workflow

This section provides a complete, step-by-step workflow for staggered adoption using causalverse::synthdid_est_ate().

Setting Up the Staggered Data


# base_stagg: staggered DiD dataset with units treated at years 5, 6, or 7
# Never-treated units have year_treated = 10000 (a large placeholder)
df_full <- fixest::base_stagg |>
  dplyr::mutate(
    treatvar = as.integer(
      time_to_treatment >= 0 & year_treated <= (5 + 2)
    )
  )

cat("Treatment cohort distribution:\n")
#> Treatment cohort distribution:
print(table(df_full$year_treated[!duplicated(df_full$id)]))
#> 
#>     2     3     4     5     6     7     8     9    10 10000 
#>     5     5     5     5     5     5     5     5     5    50

cat("\nPanel dimensions:\n")
#> 
#> Panel dimensions:
cat("Units:", length(unique(df_full$id)), "\n")
#> Units: 95
cat("Periods:", length(unique(df_full$year)), "\n")
#> Periods: 10

Cohort Structure Visualization

# Staggered summary for the treated cohorts only
summ_full <- causalverse::staggered_summary(
  data      = df_full,
  unit_var  = "id",
  time_var  = "year",
  treat_var = "treatvar",
  plot      = TRUE
)

# Show calendar plot
summ_full$calendar_plot + causalverse::ama_theme(base_size = 9)

summ_full$cohort_plot + causalverse::ama_theme()

Per-Cohort SDID Estimates

The synthdid_est_ate() function estimates a separate SynthDID model for each adoption cohort, then aggregates to an overall ATE.

set.seed(42)
est_staggered <- causalverse::synthdid_est_ate(
  data               = df_full,
  adoption_cohorts   = 5:7,
  lags               = 3,
  leads              = 3,
  time_var           = "year",
  unit_id_var        = "id",
  treated_period_var = "year_treated",
  treat_stat_var     = "treatvar",
  outcome_var        = "y"
)
#> Adoption Cohort: 5 
#> Treated units: 5 Control units: 60 
#> Adoption Cohort: 6 
#> Treated units: 5 Control units: 55 
#> Adoption Cohort: 7 
#> Treated units: 5 Control units: 50

# Per-period ATE table
ate_df <- data.frame(
  Period = names(est_staggered$TE_mean_w),
  ATE    = round(est_staggered$TE_mean_w, 4),
  SE     = round(est_staggered$SE_mean_w, 4)
)
ate_df$CI_lo <- round(ate_df$ATE - 1.96 * ate_df$SE, 3)
ate_df$CI_hi <- round(ate_df$ATE + 1.96 * ate_df$SE, 3)
print(ate_df)
#>          Period     ATE     SE  CI_lo  CI_hi
#> -3           -3 -0.0394 0.1527 -0.339  0.260
#> -2           -2 -0.0470 0.2042 -0.447  0.353
#> -1           -1  0.0864 0.1880 -0.282  0.455
#> 0             0 -4.7257 0.6792 -6.057 -3.394
#> 1             1 -4.3725 0.4828 -5.319 -3.426
#> 2             2 -3.4619 0.7168 -4.867 -2.057
#> 3             3 -1.5984 0.6707 -2.913 -0.284
#> cumul.0 cumul.0 -4.7257 0.6792 -6.057 -3.394
#> cumul.1 cumul.1 -4.5491 0.4488 -5.429 -3.669
#> cumul.2 cumul.2 -4.1867 0.4202 -5.010 -3.363
#> cumul.3 cumul.3 -3.5396 0.3989 -4.321 -2.758

# Plot
causalverse::synthdid_plot_ate(est_staggered) + causalverse::ama_theme()

Comparing Staggered SDID vs. DID vs. SC

methods_compare <- c("synthdid", "did", "sc")

results_staggered <- lapply(methods_compare, function(m) {
  causalverse::synthdid_est_ate(
    data               = df_full,
    adoption_cohorts   = 5:7,
    lags               = 3,
    leads              = 3,
    time_var           = "year",
    unit_id_var        = "id",
    treated_period_var = "year_treated",
    treat_stat_var     = "treatvar",
    outcome_var        = "y",
    method             = m
  )
})
#> Adoption Cohort: 5 
#> Treated units: 5 Control units: 60 
#> Adoption Cohort: 6 
#> Treated units: 5 Control units: 55 
#> Adoption Cohort: 7 
#> Treated units: 5 Control units: 50 
#> Adoption Cohort: 5 
#> Treated units: 5 Control units: 60 
#> Adoption Cohort: 6 
#> Treated units: 5 Control units: 55 
#> Adoption Cohort: 7 
#> Treated units: 5 Control units: 50 
#> Adoption Cohort: 5 
#> Treated units: 5 Control units: 60 
#> Adoption Cohort: 6 
#> Treated units: 5 Control units: 55 
#> Adoption Cohort: 7 
#> Treated units: 5 Control units: 50
names(results_staggered) <- methods_compare

# Aggregate overall ATEs for comparison
overall_ates <- lapply(names(results_staggered), function(nm) {
  res <- results_staggered[[nm]]
  te  <- res$TE_mean_w
  se  <- res$SE_mean_w
  # Weighted average across periods (equal weighting for display)
  data.frame(
    Method   = nm,
    Mean_ATE = round(mean(te, na.rm = TRUE), 4),
    Mean_SE  = round(mean(se, na.rm = TRUE), 4)
  )
})

cat("=== Staggered ATE Comparison Across Estimators ===\n")
#> === Staggered ATE Comparison Across Estimators ===
print(do.call(rbind, overall_ates))
#>     Method Mean_ATE Mean_SE
#> 1 synthdid  -2.8327  0.4583
#> 2      did  -2.7699  0.4960
#> 3       sc  -3.1116  0.5626

Covariates in SynthDID: Residualization and Balance

Why Residualize?

The standard SynthDID estimator uses only the outcome variable to compute weights. When time-varying covariates are available, residualizing the outcome on those covariates can improve pre-treatment fit by removing variation that the covariates explain, leaving the weights to focus on unexplained dynamics.

Step-by-Step Residualization


df_cov <- fixest::base_did |>
  dplyr::mutate(
    id        = as.factor(id),
    period    = as.integer(period),
    y         = as.double(y),
    treatment = as.integer(if_else(treat == 0, 0, post))
  )

# Step 1: Conservative residualization using pre-treatment data only
beta_pre <- lm(y ~ x1, data = df_cov |> dplyr::filter(post == 0))
df_cov$y_res <- df_cov$y - predict(beta_pre, newdata = df_cov)

# Step 2: Build panel matrices for raw outcome and residualized outcome
setup_raw <- df_cov |>
  synthdid::panel.matrices(
    unit = "id", time = "period",
    outcome = "y", treatment = "treatment"
  )

setup_res <- df_cov |>
  synthdid::panel.matrices(
    unit = "id", time = "period",
    outcome = "y_res", treatment = "treatment"
  )

# Step 3: Estimate both
tau_raw <- synthdid::synthdid_estimate(setup_raw$Y, setup_raw$N0, setup_raw$T0)
tau_res <- synthdid::synthdid_estimate(setup_res$Y, setup_res$N0, setup_res$T0)

cat("=== Covariate Adjustment Comparison ===\n")
#> === Covariate Adjustment Comparison ===
cat("SynthDID (raw outcome):        ", round(as.numeric(tau_raw), 4), "\n")
#> SynthDID (raw outcome):         4.8278
cat("SynthDID (residualized on x1): ", round(as.numeric(tau_res), 4), "\n")
#> SynthDID (residualized on x1):  4.8501

Pre-Treatment Fit Assessment

A key diagnostic is whether the residualized outcome achieves better pre-treatment fit:

compute_prefit_rmspe <- function(setup) {
  tau_obj  <- synthdid::synthdid_estimate(setup$Y, setup$N0, setup$T0)
  wts_obj  <- attr(tau_obj, "weights")
  synth_oc <- as.vector(t(wts_obj$omega) %*% setup$Y[seq_len(setup$N0), ])
  treat_oc <- as.numeric(setup$Y[nrow(setup$Y), ])
  gap_oc   <- treat_oc - synth_oc
  sqrt(mean(gap_oc[seq_len(setup$T0)]^2))
}

rmspe_raw <- compute_prefit_rmspe(setup_raw)
rmspe_res <- compute_prefit_rmspe(setup_res)

cat("Pre-treatment RMSPE (raw):          ", round(rmspe_raw, 5), "\n")
#> Pre-treatment RMSPE (raw):           4.28485
cat("Pre-treatment RMSPE (residualized): ", round(rmspe_res, 5), "\n")
#> Pre-treatment RMSPE (residualized):  4.95701
cat("Improvement:                        ",
    round(100 * (rmspe_raw - rmspe_res) / rmspe_raw, 1), "%\n")
#> Improvement:                         -15.7 %

Covariate Balance Before and After Weighting

After computing SynthDID weights, we can assess whether the weighted control group achieves better covariate balance relative to the treated group.

# Extract unit weights
omega_raw <- attr(tau_raw, "weights")$omega

# Unit IDs for control vs. treated
unit_ids  <- rownames(setup_raw$Y)
ctrl_ids  <- unit_ids[seq_len(setup_raw$N0)]
treat_ids <- unit_ids[(setup_raw$N0 + 1):nrow(setup_raw$Y)]

# Pre-treatment x1 means for each unit
df_pre <- df_cov |> dplyr::filter(post == 0)
ctrl_means <- tapply(
  df_pre$x1[df_pre$id %in% ctrl_ids],
  df_pre$id[df_pre$id %in% ctrl_ids],
  mean, na.rm = TRUE
)
treat_mean_x1 <- mean(df_pre$x1[df_pre$id %in% treat_ids], na.rm = TRUE)

# Unweighted control mean
unweighted_ctrl_mean <- mean(ctrl_means, na.rm = TRUE)

# Weighted control mean using SynthDID omega
ctrl_means_aligned <- ctrl_means[names(omega_raw)]
ctrl_means_aligned[is.na(ctrl_means_aligned)] <- 0
weighted_ctrl_mean <- sum(omega_raw * ctrl_means_aligned)

cat("=== Covariate Balance (x1) ===\n")
#> === Covariate Balance (x1) ===
cat("Treated mean:               ", round(treat_mean_x1, 4), "\n")
#> Treated mean:                0.1712
cat("Unweighted control mean:    ", round(unweighted_ctrl_mean, 4), "\n")
#> Unweighted control mean:     0.0458
cat("SynthDID-weighted ctrl mean:", round(weighted_ctrl_mean, 4), "\n")
#> SynthDID-weighted ctrl mean: 0
cat("\nBias (unweighted): ",
    round(abs(treat_mean_x1 - unweighted_ctrl_mean), 4), "\n")
#> 
#> Bias (unweighted):  0.1254
cat("Bias (weighted):   ",
    round(abs(treat_mean_x1 - weighted_ctrl_mean), 4), "\n")
#> Bias (weighted):    0.1712

Multiple Treated Units

When multiple units are treated, SynthDID aggregates the per-unit estimates into an overall ATT. This section demonstrates setup and estimation with more than one treated unit.

Setup with Multiple Treated Units

# base_did has multiple treated and control units
# Let's also examine the structure
df_multi_t <- fixest::base_did |>
  dplyr::mutate(
    id        = as.factor(id),
    period    = as.integer(period),
    y         = as.double(y),
    treatment = as.integer(if_else(treat == 0, 0, post))
  )

# Panel structure
cat("Units:", length(unique(df_multi_t$id)), "\n")
#> Units: 108
cat("Periods:", length(unique(df_multi_t$period)), "\n")
#> Periods: 10
cat("Treated units:", sum(unique(df_multi_t[, c("id", "treat")])$treat), "\n")
#> Treated units: 55
cat("Control units:",
    sum(unique(df_multi_t[, c("id", "treat")])$treat == 0), "\n")
#> Control units: 53

setup_multi <- df_multi_t |>
  synthdid::panel.matrices(
    unit = "id", time = "period",
    outcome = "y", treatment = "treatment"
  )

cat("\nPanel matrix dimensions:", nrow(setup_multi$Y), "units x",
    ncol(setup_multi$Y), "periods\n")
#> 
#> Panel matrix dimensions: 108 units x 10 periods
cat("N0 (control):", setup_multi$N0, "\n")
#> N0 (control): 53
cat("N_treated:", nrow(setup_multi$Y) - setup_multi$N0, "\n")
#> N_treated: 55

Aggregate and Per-Unit Estimates

tau_multi_t <- synthdid::synthdid_estimate(
  setup_multi$Y, setup_multi$N0, setup_multi$T0
)
cat("Aggregate ATT (SynthDID):", round(as.numeric(tau_multi_t), 4), "\n")

# With multiple treated units, all three SE methods are available
se_jack_multi <- sqrt(vcov(tau_multi_t, method = "jackknife"))
se_boot_multi <- sqrt(vcov(tau_multi_t, method = "bootstrap"))
se_plac_multi <- sqrt(vcov(tau_multi_t, method = "placebo"))

cat("\n=== Standard Errors (multiple treated units) ===\n")
cat("Jackknife SE: ", round(se_jack_multi, 4), "\n")
cat("Bootstrap SE: ", round(se_boot_multi, 4), "\n")
cat("Placebo SE:   ", round(se_plac_multi, 4), "\n")

# Jackknife CI
est_m <- as.numeric(tau_multi_t)
cat("\n95% CI (Jackknife): (",
    round(est_m - 1.96 * se_jack_multi, 3), ",",
    round(est_m + 1.96 * se_jack_multi, 3), ")\n")

Using panel_estimate() for Multiple Estimators

results_multi <- causalverse::panel_estimate(
  setup_multi,
  selected_estimators = c("synthdid", "did", "sc", "sc_ridge", "difp")
)
cat("=== Multi-Unit Estimator Comparison ===\n")
print(causalverse::process_panel_estimate(results_multi))

Per-Unit Standard Errors

With multiple treated units, per-unit treatment effects and placebo SEs can help identify heterogeneous effects:

# Per-period effects using causalverse
per_period_multi <- causalverse::synthdid_est_per(
  Y       = setup_multi$Y,
  N0      = setup_multi$N0,
  T0      = setup_multi$T0,
  weights = attr(tau_multi_t, "weights")
)
cat("Per-period estimates (post-treatment):\n")
T0_m <- setup_multi$T0
T_m  <- ncol(setup_multi$Y)
print(round(per_period_multi$est[(T0_m + 1):T_m], 4))

Sensitivity Analysis: Leave-One-Out Donors

Sensitivity analysis for synthetic control methods typically involves dropping one donor unit at a time and examining whether estimates remain stable.

Leave-One-Out Estimation

set.seed(99)

Y_loo    <- setup_ca$Y
N0_loo   <- setup_ca$N0
T0_loo   <- setup_ca$T0

# Donors with non-negligible weight (focus LOO on important donors)
omega_full <- attr(est_sdid, "weights")$omega
important_donors <- which(omega_full > 0.01)
cat("Donors with weight > 0.01:", length(important_donors), "\n")
#> Donors with weight > 0.01: 21
cat("Donor names:", rownames(Y_loo)[important_donors], "\n\n")
#> Donor names: Colorado Connecticut Delaware Idaho Illinois Indiana Iowa Kansas Maine Minnesota Montana Nebraska Nevada New Hampshire New Mexico North Carolina Ohio Pennsylvania Utah West Virginia Wisconsin

# Run LOO: exclude one important donor at a time
loo_results <- lapply(important_donors, function(j) {
  keep_ctrl <- setdiff(seq_len(N0_loo), j)
  Y_loo_j   <- Y_loo[c(keep_ctrl, nrow(Y_loo)), ]
  tryCatch({
    tau_j <- synthdid::synthdid_estimate(
      Y_loo_j,
      N0 = length(keep_ctrl),
      T0 = T0_loo
    )
    list(
      dropped = rownames(Y_loo)[j],
      est     = as.numeric(tau_j),
      ok      = TRUE
    )
  }, error = function(e) {
    list(dropped = rownames(Y_loo)[j], est = NA_real_, ok = FALSE)
  })
})

loo_df <- data.frame(
  Dropped  = sapply(loo_results, `[[`, "dropped"),
  Estimate = round(sapply(loo_results, `[[`, "est"), 3),
  stringsAsFactors = FALSE
)
loo_df$Dropped <- factor(loo_df$Dropped,
                         levels = loo_df$Dropped[order(loo_df$Estimate)])
full_est_val <- round(as.numeric(est_sdid), 3)
loo_df$Delta <- loo_df$Estimate - full_est_val

cat("=== Leave-One-Out Sensitivity ===\n")
#> === Leave-One-Out Sensitivity ===
cat("Full estimate:", full_est_val, "\n\n")
#> Full estimate: -15.604
print(loo_df)
#>           Dropped Estimate  Delta
#> 1        Colorado  -15.566  0.038
#> 2     Connecticut  -16.509 -0.905
#> 3        Delaware  -15.161  0.443
#> 4           Idaho  -15.315  0.289
#> 5        Illinois  -16.262 -0.658
#> 6         Indiana  -15.529  0.075
#> 7            Iowa  -15.451  0.153
#> 8          Kansas  -15.677 -0.073
#> 9           Maine  -15.586  0.018
#> 10      Minnesota  -15.727 -0.123
#> 11        Montana  -14.983  0.621
#> 12       Nebraska  -15.353  0.251
#> 13         Nevada  -17.058 -1.454
#> 14  New Hampshire  -16.510 -0.906
#> 15     New Mexico  -15.610 -0.006
#> 16 North Carolina  -15.687 -0.083
#> 17           Ohio  -15.630 -0.026
#> 18   Pennsylvania  -15.633 -0.029
#> 19           Utah  -15.297  0.307
#> 20  West Virginia  -15.223  0.381
#> 21      Wisconsin  -15.532  0.072

Visualizing LOO Sensitivity

ggplot(loo_df, aes(x = Dropped, y = Estimate)) +
  geom_hline(
    yintercept = full_est_val,
    linetype = "dashed", color = "gray40", linewidth = 0.8
  ) +
  geom_hline(yintercept = 0, color = "gray70") +
  geom_point(aes(color = Delta > 0), size = 4) +
  geom_text(
    aes(label = sprintf("%.1f", Estimate)),
    vjust = -1.0, size = 3.2
  ) +
  scale_color_manual(
    values = c("TRUE" = "#D73027", "FALSE" = "#4393C3"),
    guide  = "none"
  ) +
  labs(
    title    = "Leave-One-Out Sensitivity Analysis",
    subtitle = paste0("Dashed line = full estimate (", full_est_val,
                      "). Each point drops one key donor."),
    x        = "Dropped Donor State",
    y        = "SynthDID Estimate"
  ) +
  causalverse::ama_theme()

A robust result shows that removing any single donor does not substantially change the estimate. Large swings indicate that the estimate is heavily reliant on a specific donor unit.

LOO Gap Plot for Key Donors

# Compare gap plots: full model vs. dropping top donor
top_donor_idx <- which.max(omega_full)
top_donor_name <- rownames(Y_loo)[top_donor_idx]
cat("Top donor by weight:", top_donor_name,
    "(weight =", round(omega_full[top_donor_idx], 3), ")\n")
#> Top donor by weight: Nevada (weight = 0.124 )

# Full-model gap
synth_full <- as.vector(t(omega_full) %*% Y_loo[seq_len(N0_loo), ])
gap_full   <- as.numeric(Y_loo[nrow(Y_loo), ]) - synth_full

# LOO gap (dropping top donor)
keep_idx     <- setdiff(seq_len(N0_loo), top_donor_idx)
Y_loo_top    <- Y_loo[c(keep_idx, nrow(Y_loo)), ]
tau_loo_top  <- synthdid::synthdid_estimate(Y_loo_top, length(keep_idx), T0_loo)
wts_loo_top  <- attr(tau_loo_top, "weights")$omega
synth_loo    <- as.vector(t(wts_loo_top) %*% Y_loo_top[seq_len(length(keep_idx)), ])
gap_loo      <- as.numeric(Y_loo_top[nrow(Y_loo_top), ]) - synth_loo

time_ca_lbl <- as.numeric(colnames(Y_loo))
treat_yr_ca <- time_ca_lbl[T0_loo + 1]

gap_comp_df <- data.frame(
  year = rep(time_ca_lbl, 2),
  gap  = c(gap_full, gap_loo),
  spec = rep(c(paste0("Full model\n(est=", round(full_est_val, 1), ")"),
               paste0("Drop ", top_donor_name,
                      "\n(est=", round(as.numeric(tau_loo_top), 1), ")")),
             each = length(time_ca_lbl))
)

ggplot(gap_comp_df, aes(x = year, y = gap, color = spec, linetype = spec)) +
  geom_hline(yintercept = 0, color = "gray60") +
  geom_vline(xintercept = treat_yr_ca, linetype = "dashed", color = "gray30") +
  geom_line(linewidth = 1.0) +
  scale_color_manual(
    values = c(
      setNames("#2166AC", paste0("Full model\n(est=", round(full_est_val, 1), ")")),
      setNames("#D73027", paste0("Drop ", top_donor_name,
                                 "\n(est=", round(as.numeric(tau_loo_top), 1), ")"))
    )
  ) +
  labs(
    title    = "Sensitivity to Top Donor: Gap Plot Comparison",
    x        = "Year",
    y        = "Gap (Treated - Synthetic)",
    color    = NULL,
    linetype = NULL
  ) +
  causalverse::ama_theme() +
  theme(legend.position = "bottom")

Publication Pipeline: End-to-End Example

This section demonstrates a complete publication-ready workflow using the California Proposition 99 data.

Step 1: Data Description and Balance

# Summarize treated vs. control units (using pre-treatment cigarette sales)
Y_pub    <- setup_ca$Y
N0_pub   <- setup_ca$N0
N_pub    <- nrow(Y_pub)

# Pre-treatment average (outcome proxy as "baseline")
pre_avg <- rowMeans(Y_pub[, seq_len(T0_loo)])
unit_df <- data.frame(
  state       = rownames(Y_pub),
  group       = c(rep("Control", N0_pub),
                  rep("Treated", N_pub - N0_pub)),
  pre_avg_cig = pre_avg
)

cat("=== Pre-Treatment Outcome Balance ===\n")
#> === Pre-Treatment Outcome Balance ===
cat("Control states (N =", N0_pub, "):\n")
#> Control states (N = 38 ):
cat("  Mean cigarette sales:", round(mean(unit_df$pre_avg_cig[unit_df$group == "Control"]), 1), "\n")
#>   Mean cigarette sales: 130.6
cat("  SD:", round(sd(unit_df$pre_avg_cig[unit_df$group == "Control"]), 1), "\n\n")
#>   SD: 30.6
cat("Treated state (California):\n")
#> Treated state (California):
cat("  Mean cigarette sales:", round(mean(unit_df$pre_avg_cig[unit_df$group == "Treated"]), 1), "\n")
#>   Mean cigarette sales: 116.2

Step 2: Run All Estimators

results_pub <- causalverse::panel_estimate(
  setup_ca,
  selected_estimators = c("synthdid", "did", "sc", "sc_ridge", "difp", "difp_ridge")
)

cat("=== All Estimator Results ===\n")
#> === All Estimator Results ===
tbl_pub <- causalverse::process_panel_estimate(results_pub)
print(tbl_pub)
#>       Method Estimate    SE
#> 1   SYNTHDID   -15.60 10.05
#> 2        DID   -27.35 15.81
#> 3         SC   -19.62 11.16
#> 4   SC_RIDGE   -21.72 11.30
#> 5       DIFP   -11.10 10.19
#> 6 DIFP_RIDGE   -16.12 10.17

Step 3: Extract and Tidy the SynthDID Estimate

# Tidy the main SDID estimate using causalverse::tidy_causal()
tidy_sdid <- causalverse::tidy_causal(
  model      = est_sdid,
  conf_level = 0.95
)
print(tidy_sdid)
#>          term  estimate std_error    t_stat   p_value  ci_lower ci_upper n_obs
#> 1 SDID Effect -15.60383  9.523185 -1.638509 0.1013155 -34.26893 3.061271    NA
#>         model_class
#> 1 synthdid_estimate

Step 4: Publication Table via causal_table()

# Build three OLS models for comparison as a publication table example
# (In practice, replace these with your causal estimates)
# Use the raw california_prop99 data for an OLS comparison
ca_df <- synthdid::california_prop99
ca_df$post <- as.integer(ca_df$Year >= 1989)
ca_df$calif <- as.integer(ca_df$state == "California")
ca_df$did_term <- ca_df$post * ca_df$calif

m1 <- lm(PacksPerCapita ~ calif + post, data = ca_df)
m2 <- lm(PacksPerCapita ~ calif + post + did_term, data = ca_df)
m3 <- lm(PacksPerCapita ~ calif * post + RetailPrice, data = ca_df)

causal_tbl <- causalverse::causal_table(
  models    = list(
    "(1) Groups"  = m1,
    "(2) DID"     = m2,
    "(3) Controls"= m3
  ),
  coef_map  = c(
    calif     = "California",
    post      = "Post-1989",
    did_term  = "California × Post (DID)",
    "calif:post" = "California × Post (DID)",
    RetailPrice  = "Retail Price"
  ),
  title     = "Effect of Proposition 99 on Cigarette Sales",
  notes     = "OLS regressions. Robust SEs. * p<0.1, ** p<0.05, *** p<0.01.",
  output_format = "dataframe"
)
print(causal_tbl)

Step 5: Final Reporting Summary

# Final reporting summary
est_final  <- as.numeric(est_sdid)
se_final   <- se_placebo_base  # placebo SE for single treated unit
ci_lo      <- est_final - 1.96 * se_final
ci_hi      <- est_final + 1.96 * se_final
p_value    <- 2 * pnorm(-abs(est_final / se_final))

cat("=== Publication Summary: California Prop 99 SynthDID ===\n\n")
cat("Estimator:            Synthetic Difference-in-Differences\n")
cat("Data:                 california_prop99 (39 states, 31 years)\n")
cat("Treatment:            California Proposition 99 (1989)\n")
cat("Outcome:              Cigarette packs sold per capita per year\n\n")
cat("Point estimate:      ", round(est_final, 2), "\n")
cat("Standard error:      ", round(se_final, 2), "(placebo method)\n")
cat("95% CI:              (", round(ci_lo, 2), ",", round(ci_hi, 2), ")\n")
cat("p-value:             ", format.pval(p_value, digits = 3), "\n\n")
cat("Interpretation: Proposition 99 reduced cigarette sales by approximately\n")
cat(abs(round(est_final, 1)), "packs per capita per year in California\n")
cat("relative to the synthetic counterfactual.\n")

References

Arkhangelsky, Dmitry, Susan Athey, David A Hirshberg, Guido W Imbens, and Stefan Wager. 2021. “Synthetic Difference-in-Differences.” American Economic Review 111 (12): 4088–118.
Ben-Michael, Eli, Avi Feller, and Jesse Rothstein. 2022. “Synthetic Controls with Staggered Adoption.” Journal of the Royal Statistical Society Series B: Statistical Methodology 84 (2): 351–81.