Adapted from Xu (2023) ‘Causal Inference with Time-Series Cross-Sectional Data: A Reflection’
DiD and parallel trends
If we want to identify the effect of \(D_{t}\) on \(Y_{t}\) (the “instantaneous” effect), via a standard selection-on-observables design, we would need to adjust for both \(X\) and \(U\)
But here, we’ve supposed that \(U\) is unobserved - can’t control for it directly.
We need more assumptions!
DiD works by making an additional functional form assumption about the latent factor.
We assume that we can decompose the latent factor into two additive components: \(U_{it} = \alpha_i + \delta_t\)
This gives us the classic two-way fixed effect formulation:
Later on when we talk about mediation, we’ll introduce other kinds of estimands for cumulative effects over time.
Sequential ignorability
Identification of causal effects under outcome-treatment feedback can be done under a sequential ignorability assumption (Robins, 1986; Blackwell and Glynn, 2018).
At each time period, treatment is as-if random conditional on observed covariate and outcome history
We’ll cover this more when we talk about mediation
Sequential ignorability
Adapted from Blackwell and Glynn (2018) ‘How to Make Causal Inferences with Time-Series Cross-Sectional Data under Selection on Observables’
Estimation of treatment effects
Under sequential ignorability, we can motivate estimation of the contemporaneous effect of treatment using an Autoregressive Distributed Lag (ADL) model framework.
Adjust for some number of lagged outcomes (autoregressive)
Adjust for some number of lagged treatments (distributed lag)
If we believe the parametric assumptions behind this model (e.g. constant effects), \(\tau_0\) identifies the average contemporaneous effect of treatment at time \(t\).
But does \(\tau_1\) identify the effect one period out?
No! We have post-treatment bias (Blackwell and Glynn, 2018).
This is the Table 2 fallacy all over again.
Sequential ignorability
Adapted from Blackwell and Glynn (2018) ‘How to Make Causal Inferences with Time-Series Cross-Sectional Data under Selection on Observables’
LDV vs. DiD
We’ll talk about how to estimate these long-term and cumulative effects under sequential ignorability when we get to mediation in a few weeks.
The relevant estimands and estimators come from this literature.
Future treatment and covariates are mediators of past treatment!
But let’s consider the simple setting with two time periods and two treatment groups
Everyone is under control at time \(t-1\), some units are treated at time \(t\)
Two possible ways to estimate the treatment effect.
Difference-in-differences - Regress \(Y_{it} - Y_{it-1}\) on \(D_{it}\)
Lagged-DV adjustment - Regress \(Y_{it}\) on \(D_{it}\) and \(Y_{it-1}\)
Controlling for LDV under strict exogeneity
What happens if we control for the lagged dependent variable under strict exogeneity?
What we actually want to do is adjust for the expected lagged outcome.
But the actual observed outcome contains random error
\(Y_{it-1}\) can be high because of the latent factor or the error
We are controlling for an imperfect proxy of the true confounder
This measurement error induces a bias due to reversion to the mean
If treated units tend to be higher in their outcomes than control (on average), then control units with similar observed outcomes are outliers
We’d expect the subsequent outcome to be closer to the mean if there’s independent noise at \(t\) versus \(t-1\)
Simulation: Controlling for LDV
To see the bias from controlling for lagged \(Y\), consider the following DGP:
\(N = 2000\) units, two periods (\(t{-}1\) and \(t\)), true treatment effect \(\tau = 0\)
Treatment correlated with confounder: \(D_i \sim \text{Bernoulli}(\text{logit}^{-1}(\alpha_i))\)
Outcome at \(t{-}1\): \(Y_{it-1} = \alpha_i + \varepsilon_{it-1}\), where \(\varepsilon_{it-1} \sim N(0, \sigma_\varepsilon^2)\)
Outcome at \(t\): \(Y_{it} = \alpha_i + \tau D_i + \varepsilon_{it}\), where \(\varepsilon_{it} \sim N(0, \sigma_\varepsilon^2)\)
Parallel trends holds by construction: \(\alpha_i\) is time-invariant
Vary \(\sigma_\varepsilon \in \{0.5, 1, 2, 4, 8\}\) to control how noisy the proxy \(Y_{it-1}\) is for \(\alpha_i\)
Compare three estimators: simple difference-in-means post-treatment, DiD, and LDV
Simulation: LDV bias from measurement error
Code
set.seed(53703)N <-2000n_sims <-500noise_levels <-c(0.5, 1, 2, 4, 8)sim_results <-map_dfr(noise_levels, function(sigma_e) {map_dfr(1:n_sims, function(s) {# Time-invariant confounder alpha_i <-rnorm(N, 0, 1)# Treatment correlated with confounder D_i <-rbinom(N, 1, plogis(alpha_i))# True treatment effect is ZERO beta <-0# Pre and post outcomes: parallel trends holds Y_pre <- alpha_i +rnorm(N, 0, sigma_e) Y_post <- alpha_i + beta * D_i +rnorm(N, 0, sigma_e)# Difference-in-means (biased by alpha_i) dim_est <-coef(lm(Y_post ~ D_i))["D_i"]# DiD (unbiased) did_est <-coef(lm(I(Y_post - Y_pre) ~ D_i))["D_i"]# LDV (biased -- errors in variables) ldv_est <-coef(lm(Y_post ~ D_i + Y_pre))["D_i"]tibble(sigma_e = sigma_e, sim = s,`Diff-in-means`= dim_est, DiD = did_est, LDV = ldv_est) })})sim_summary <- sim_results %>%pivot_longer(cols =c(`Diff-in-means`, DiD, LDV),names_to ="Estimator", values_to ="estimate") %>%group_by(sigma_e, Estimator) %>%summarize(mean_est =mean(estimate),ci_low =quantile(estimate, 0.025),ci_high =quantile(estimate, 0.975), .groups ="drop")sim_summary$Estimator <-factor(sim_summary$Estimator,levels =c("Diff-in-means", "LDV", "DiD"))ggplot(sim_summary, aes(x =factor(sigma_e), y = mean_est,color = Estimator, group = Estimator)) +geom_point(size =3, position =position_dodge(width =0.4)) +geom_errorbar(aes(ymin = ci_low, ymax = ci_high),width =0.3, position =position_dodge(width =0.4)) +geom_hline(yintercept =0, linetype ="dashed") +labs(x =expression(paste("Noise in pre-treatment outcome (", sigma[epsilon], ")")),y ="Estimate",title ="LDV bias under the DiD model (true effect = 0)",subtitle ="More noise in Y(pre) = worse proxy for confounder = more residual bias") +scale_color_manual(values =c("Diff-in-means"="gray60","LDV"="dodgerblue","DiD"="indianred")) +theme_bw() +theme(legend.position ="bottom")
Simulation: What if outcomes are autocorrelated?
In the previous simulation, \(\varepsilon_{it-1}\) and \(\varepsilon_{it}\) are independent — the only thing linking outcomes over time is \(\alpha_i\)
In practice, outcomes are often autocorrelated: a unit that is high at \(t{-}1\) (beyond \(\alpha_i\)) tends to stay high at \(t\)
Modified DGP: let \(\varepsilon_{it} = \rho \cdot \varepsilon_{it-1} + \nu_i\) where \(\nu_i \sim N(0, \sigma_\varepsilon^2)\)
When \(\rho > 0\), \(Y_{it-1}\) is a better proxy for \(Y_{it}\)’s non-treatment component
LDV should perform better because controlling for \(Y_{it-1}\) captures more of the variation
We fix \(\sigma_\varepsilon = 2\) and vary \(\rho \in \{0, 0.25, 0.5, 0.75, 0.95\}\)
where \(\varepsilon_{it}\) is serially uncorrelated and independent of \(\alpha_i\) and \(D_{it}\). Since \(D_{it-1} = 0\): \(Y_{it-1} = \alpha_i + \varepsilon_{it-1}\)
You mistakenly estimate the LDV regression (controlling for \(Y_{it-1}\) instead of \(\alpha_i\)):
\[Y_{it} = a + b \cdot Y_{it-1} + \tau^{\text{LDV}} D_{it} + u_{it}\]
Bracketing: Case 1 – FWL decomposition
By FWL, the LDV estimand is \(\hat{\tau}^{\text{LDV}} \xrightarrow{p} \frac{\text{Cov}(Y_{it}, \tilde{D}_{it})}{\text{Var}(\tilde{D}_{it})}\)
\(\tilde{D}_{it} = D_{it} - \hat{\gamma} Y_{it-1}\) is the residual from the auxiliary regression of \(D_{it}\) on \(Y_{it-1}\)
To evaluate this, substitute \(\alpha_i = Y_{it-1} - \varepsilon_{it-1}\) into the true model:
So: \(\hat{\tau}^{\text{LDV}} \xrightarrow{p} \tau + \frac{\hat{\gamma} \sigma^2_\varepsilon}{\text{Var}(\tilde{D}_{it})}\), with bias having the same sign as \(\hat{\gamma}\) (the sign of selection)
Bracketing: Case 1 – Why is LDV biased?
\(Y_{it-1} = \alpha_i + \varepsilon_{it-1}\) is a noisy proxy for the confounder \(\alpha_i\)
Controlling for a noisy proxy does not fully remove confounding
With positive selection (high outcome \(\leadsto\) more likely to be treated) (\(\hat{\gamma} > 0\))
LDV overestimates\(\tau\)
With negative selection (\(\hat{\gamma} < 0\))
LDV underestimates\(\tau\)
Bracketing: Case 2 – LDV is correct, DiD is misspecified
\(\mathbf{Z}_i\) are the observed covariates (with time-varying coefficients \(\mathbf{\theta}_t\)).
\(\boldsymbol{\lambda}_t\) are \(F\)-dimensional time-varying factors and \(\boldsymbol{\mu}_i\) are unit-specific loadings
This is a more general model of latent unobserved confounding than DiD
In DiD, there is a single common time trend across all factors \(\boldsymbol{\lambda}_t = \lambda\)
The latent factor model allows for some number of varying time-trends across units (elements of \(\boldsymbol{\lambda}_t\)) that apply to different units (weights dictated by \(\boldsymbol{\mu}_i\)).
SCM: Choosing the weights
The goal of the synthetic control method is to find weights such that the weighted average of donor units reproduces the covariates and the pre-treatment trajectory of the treated unit
# Extract path datayears <- dataprep.out$tag$time.plotY_treat <- dataprep.out$Y1plotY_synth <- dataprep.out$Y0plot %*% synth.out$solution.wplot_df <-data.frame(year = years,Treated =as.numeric(Y_treat),Synthetic =as.numeric(Y_synth)) %>%pivot_longer(cols =c(Treated, Synthetic), names_to ="Unit", values_to ="gdpcap")ggplot(plot_df, aes(x = year, y = gdpcap, color = Unit, linetype = Unit)) +geom_line(linewidth =1.2) +geom_vline(xintercept =1970, linetype ="dashed", color ="gray40") +annotate("text", x =1971, y =3, label ="Terrorism\nonset", hjust =0, size =3.5) +labs(x ="Year", y ="Real per capita GDP\n(1986 USD, thousands)",title ="Basque Country vs. Synthetic Basque Country",subtitle ="Abadie & Gardeazabal (2003) — Effect of ETA terrorism on GDP") +scale_color_manual(values =c("Treated"="black", "Synthetic"="firebrick")) +scale_linetype_manual(values =c("Treated"="solid", "Synthetic"="dashed")) +theme_bw() +theme(legend.position ="bottom")
Basque Country: Treatment Effect (Gap Plot)
Code
gap <-as.numeric(Y_treat) -as.numeric(Y_synth)gap_df <-data.frame(year = years, gap = gap)ggplot(gap_df, aes(x = year, y = gap)) +geom_line(linewidth =1.2) +geom_hline(yintercept =0, linetype ="dashed") +geom_vline(xintercept =1970, linetype ="dashed", color ="gray40") +annotate("text", x =1971, y =0.4, label ="Terrorism onset", hjust =0, size =3.5) +labs(x ="Year", y ="Gap in real per capita GDP\n(Treated − Synthetic)",title ="Estimated effect of terrorism on Basque Country GDP",subtitle ="Negative gap = terrorism reduced GDP relative to synthetic control") +theme_bw()
SCM: Inference
With a single treated unit, standard inference is not straightforward. Abadie et al. (2010) propose a permutation testing approach:
Apply SCM to each donor unit as if it were treated
If the treated unit’s gap is unusually large relative to these “placebo” gaps, the effect is unlikely due to chance
Test statistic: post/pre RMSPE ratio
Common practice to drop controls with egregiously worse pre-treatment fit compared to treated unit.
Recent work in statistics moves beyond this:
“Conformal inference” approach in Chernozhukov, Wüthrich, and Zhu (2019)
Basque Country: Permutation tests
Code
# Run placebo tests for each control regioncontrol_ids <-c(2:16, 18)placebo_gaps <-list()for (ctrl_id in control_ids) { donor_ids <-setdiff(c(2:16, 18), ctrl_id)tryCatch({ dp <-dataprep(foo = basque,predictors =c("school.illit", "school.prim", "school.med","school.high", "school.post.high", "invest"),predictors.op ="mean",time.predictors.prior =1964:1969,special.predictors =list(list("gdpcap", 1960:1969, "mean"),list("sec.agriculture", seq(1961, 1969, 2), "mean"),list("sec.energy", seq(1961, 1969, 2), "mean"),list("sec.industry", seq(1961, 1969, 2), "mean"),list("sec.construction", seq(1961, 1969, 2), "mean"),list("sec.services.venta", seq(1961, 1969, 2), "mean"),list("sec.services.nonventa", seq(1961, 1969, 2), "mean"),list("popdens", 1969, "mean") ),dependent ="gdpcap",unit.variable ="regionno",unit.names.variable ="regionname",time.variable ="year",treatment.identifier = ctrl_id,controls.identifier = donor_ids,time.optimize.ssr =1960:1969,time.plot =1955:1997 ) so <-synth(data.prep.obj = dp, method ="BFGS") y1 <-as.numeric(dp$Y1plot) y0 <-as.numeric(dp$Y0plot %*% so$solution.w) placebo_gaps[[as.character(ctrl_id)]] <-data.frame(year =1955:1997, gap = y1 - y0, unit = ctrl_id ) }, error =function(e) {# Skip units where optimization fails })}placebo_df <-do.call(rbind, placebo_gaps)# Add treated unittreated_gap_df <-data.frame(year = years, gap = gap, unit =17)
Basque Country: Permutation tests
Code
ggplot() +geom_line(data = placebo_df, aes(x = year, y = gap, group = unit),color ="gray70", alpha =0.6) +geom_line(data = treated_gap_df, aes(x = year, y = gap),color ="black", linewidth =1.2) +geom_vline(xintercept =1970, linetype ="dashed", color ="gray40") +geom_hline(yintercept =0, linetype ="dashed") +labs(x ="Year", y ="Gap in real per capita GDP",title ="Placebo tests: Basque Country (black) vs. donor regions (gray)",subtitle ="Each gray line = SCM applied to a donor region as if it were treated") +theme_bw()
Permutation Tests: Dropping Poor Fits
Code
# Compute pre-treatment RMSPE for each unittreated_pre_rmspe <-sqrt(mean((treated_gap_df %>%filter(year <1970))$gap^2))placebo_pre_rmspe <- placebo_df %>%filter(year <1970) %>%group_by(unit) %>%summarize(pre_rmspe =sqrt(mean(gap^2))) %>%ungroup()# Keep only units with pre-RMSPE <= 5x the treated unitkeep_units <- placebo_pre_rmspe %>%filter(pre_rmspe <=5* treated_pre_rmspe) %>%pull(unit)placebo_df_trimmed <- placebo_df %>%filter(unit %in% keep_units)n_dropped <-n_distinct(placebo_df$unit) -length(keep_units)ggplot() +geom_line(data = placebo_df_trimmed, aes(x = year, y = gap, group = unit),color ="gray70", alpha =0.6) +geom_line(data = treated_gap_df, aes(x = year, y = gap),color ="black", linewidth =1.2) +geom_vline(xintercept =1970, linetype ="dashed", color ="gray40") +geom_hline(yintercept =0, linetype ="dashed") +labs(x ="Year", y ="Gap in real per capita GDP",title =paste0("Placebo tests: Dropping donors with pre-RMSPE > 5x treated (", n_dropped, " dropped)"),subtitle ="Basque Country (black) vs. donor regions with adequate pre-treatment fit (gray)") +theme_bw()
library(augsynth)# Use multisynth for the multi-unit case# multisynth infers treatment timing from the binary indicatormsyn <-multisynth( RepVotesMajorPercent ~ treated,unit = county_state, time = year,data = dff)msyn_summ <-summary(msyn)# Extract average ATT for plottingavg_att <- msyn_summ$att %>%filter(Level =="Average")ggplot(avg_att, aes(x = Time, y = Estimate)) +geom_point(size =3) +geom_errorbar(aes(ymin = lower_bound, ymax = upper_bound), width =0.3) +geom_hline(yintercept =0, linetype ="dashed") +geom_vline(xintercept =-0.5, linetype ="dashed", color ="red") +labs(x ="Time relative to treatment (election cycles)",y ="Estimated ATT (pp)",title ="Multisynth: Effect of shale shock on Republican vote share",subtitle ="Average across treated coal counties (partially pooled SCM)") +theme_bw()
Placebo testing
We’ve fit the synthetic control model to minimize pre-treatment outcome discrepancies.
A perfect synthetic control will reproduce the trajectory exactly
But this doesn’t mean that our estimator is unbiased - especially if we have a short panel.
How do we assess whether the bias is small?
Perfect pre-treatment fit is no longer a valid placebo test!
We can use a placebo test approach where we backdate the treatment to an earlier period and check whether the synthetic control produces spurious “effects” in held-out pre-treatment periods
Same intuition as our discussion last week - we want our placebo tests to involve held-out comparisons.
Backdating falsification tests
Code
# In-time falsification: backdate treatment to 2000 and 1996# Fit SCM using only pre-placebo data, check for "effects" in held-out periodsplacebo_years <-c(1996, 2000, 2004)falsification_results <-list()for (placebo_yr in placebo_years) {# Create dataset: only pre-2008 data, with treatment backdated falsification_df <- dff %>%filter(year <2008) %>%mutate(placebo_treat =as.integer(treat ==1& year >= placebo_yr))tryCatch({ plac_fit <-multisynth( RepVotesMajorPercent ~ placebo_treat,unit = county_state, time = year,data = falsification_df, ) plac_summ <-summary(plac_fit) plac_att <- plac_summ$att %>%filter(Level =="Average") %>%mutate(placebo_year = placebo_yr) falsification_results[[as.character(placebo_yr)]] <- plac_att }, error =function(e) {cat("Error for placebo year", placebo_yr, ":", conditionMessage(e), "\n") })}all_results <-bind_rows(do.call(rbind, falsification_results)) %>%mutate(placebo_label =paste0("Placebo treatment = ", placebo_year))ggplot(all_results, aes(x = Time, y = Estimate,color =factor(placebo_year))) +geom_point(size =2.5) +geom_errorbar(aes(ymin = lower_bound, ymax = upper_bound), width =0.3) +geom_hline(yintercept =0, linetype ="dashed") +geom_vline(xintercept =-0.5, linetype ="dotted") +facet_wrap(~ placebo_label, scales ="free_x") +labs(x ="Time relative to (placebo) treatment",y ="Estimated ATT (pp)",title ="Placebo tests: backdated treatment vs. actual",subtitle ="Placebo treatments should show no effect in held-out pre-2008 periods") +scale_color_manual(values =c("1996"="steelblue", "2000"="darkorange","2004"="black")) +theme_bw() +theme(legend.position ="none")
Conclusion
Causal inference in time-series data is hard!
Even the estimands can get tricky - we’ll come back to this again when we talk about mediation
The core problem centers around how we deal with unobserved confounding and the role that past outcomes play in identification/estimation
Three scenarios:
Unobserved confounding but simple functional form (DiD)
Adjust via differencing - works in short panels
Observed confounding driven by past \(Y\)
Adjust by controlling for past \(Y\) - also fine in short panels
Unobserved confounding more complex than DiD
Adjust for past \(Y\) to indirectly control for the latent factor - need many pre-treatment periods!
Next week
The last of the “holy trinity” - regression discontinuity designs
What happens when we have arbitrary unobserved confounding…
…but treatment is assigned via a threshold
In this case, even if treatment-outcome are confounded, that confounding might be negligible in the window around the threshold!
Two ways to think about this
“As-if-random” assignment at the cut-off
Continuity in potential outcomes at the cut-off
Estimation by extrapolation to the threshold.
Works best when we have lots of units near the cut-off.