Teaching · Explainer
Difference-in-differences & intervention analysis.
This page walks through how to evaluate whether a policy or treatment actually changed an outcome, using a difference-in-differences-style framework applied to a controlled interrupted time series (ITS). The same framework pseudocode applies to most before/after policy evaluations — swap in your own treated and control series and intervention year.
The real-world question. In 2001, EPA revised the drinking water arsenic Maximum Contaminant Level (MCL) from 50 µg/L down to 10 µg/L, with compliance required starting in 2006. To meet the new standard, many utilities installed advanced removal technologies — Adsorptive Media (AM) being one of the most widely adopted — while others stayed on existing treatment, forming a natural control group. Every system faced the same regulatory pressure after 2006, so a simple before/after comparison of AM systems alone would conflate "the rule" with "the technology." The question this worked example answers: once the shared regulatory effect is netted out using the control group, did installing AM specifically improve arsenic removal and compliance, beyond what utilities achieved anyway? The dataset below (entry-point annual arsenic concentrations, 1998–2019) comes from the same EPA arsenic demonstration program behind the Arsenic Demonstration Sites project.
Example dataset · annual EP arsenic concentrations, 1998–2019, AM vs. control
Columns: year, technology (AM = Adsorptive Media; control = other/baseline treatment), sampling_point_type, annual_mean, annual_sd, annual_min, annual_max, n_systems, se_mean, ci_lower, ci_upper. EP = Entry Point, finished water as it enters the distribution system.
Step 1
Framework pseudocode for intervention analysis.
Before writing any code, define the comparison in plain terms. This pseudocode generalizes to any before/after policy or treatment evaluation that has a comparison (control) series available.
General framework (click to collapse)
- Define the groups. Identify the treated series (the group exposed to the intervention) and a control/reference series that was not exposed but experiences similar background trends.
- Define the intervention time. Pick the first period the intervention applies to — e.g., the first year a regulation took effect. Everything before is "pre"; that period onward is "post."
- Build the time variables.
time_index= elapsed time since the start of the series (captures the shared baseline trend).post= 1 from the intervention period onward, else 0 (captures a shared level shift).post_time= time elapsed since the intervention (0 before it) (captures a shared slope change).treated= 1 for the treated group, 0 for control.
- Interact treated with each time term.
treated:time_index,treated:post, andtreated:post_timeisolate what is different about the treated group specifically, net of whatever the control group also experienced. - Fit the model and pull out the two estimands that matter most: the treated-specific level change at the boundary (
treated:post) and the treated-specific slope change afterward (treated:post_time). - Check assumptions before trusting the estimate: pre-trend similarity (
treated:time_indexin the pre-period only), residual autocorrelation, and a placebo test with a fake boundary inside the pre-period. - Report with uncertainty. Pair every estimate with a confidence interval, and describe it as an association/controlled-ITS estimate unless the design genuinely supports a causal claim.
Step 2
The controlled segmented regression.
The control series carries its own baseline, trend, and common post-boundary shift. The treated interactions estimate how much the AM series departs from that shared pattern.
A single colon (treated:post) is R's formula interaction operator — it means "the combined effect of these two variables," not a namespace reference. That's different from a double colon (pkg::function), which calls a function from a specific package. Each treated:… term below estimates something AM-specific, on top of whatever treated, time_index, post, and post_time already explain on their own.
Step 3
Worked example in R.
This reads the downloadable CSV above directly, fits the controlled segmented model, runs pre-trend and placebo diagnostics, and prints a plain-language interpretation template filled in with the fitted numbers. Base R is enough to run it; ggplot2, sandwich, and lmtest are used when available for a nicer plot and robust standard errors.
# Controlled interrupted time-series analysis: AM vs. control, annual EP data
# Put arsenic_am_data_wqtc_demo.csv in the working directory (downloaded above).
# pacman package and p_load command makes it easy to install and load commands
if (!require(pacman)) install.packages("pacman")
p_load(dplyr, ggplot2, sandwich, lmtest)
dat <- read.csv("arsenic_am_data_wqtc_demo.csv", stringsAsFactors = FALSE)
names(dat)[1] <- "year" # guards against a UTF-8 BOM corrupting the first column name
# --- Validate before analysis ---
expected_years <- 1998:2019
stopifnot(setequal(unique(dat$technology), c("AM", "control")))
stopifnot(!anyDuplicated(dat[c("year", "technology")]))
stopifnot(all(vapply(split(dat$year, dat$technology),
function(x) identical(sort(x), expected_years), logical(1))))
# --- Build the intervention variables (2006 = first post-intervention year) ---
dat <- dat |>
mutate(
technology = factor(technology, levels = c("control", "AM")),
treated = as.integer(technology == "AM"),
time_index = year - 1998,
post = as.integer(year >= 2006),
post_time = pmax(year - 2006, 0),
pre_period = year < 2006
)
# --- Descriptive pre/post means (not causal, just context) ---
dat |>
group_by(technology, post) |>
summarise(annual_mean = mean(annual_mean), .groups = "drop") |>
mutate(period = ifelse(post == 0, "1998-2005 (pre)", "2006-2019 (post)")) |>
select(technology, period, annual_mean) |>
print()
# --- Plot the two series with the intervention boundary marked ---
ggplot(dat, aes(year, annual_mean, colour = technology)) +
geom_line(linewidth = 0.9) +
geom_point(size = 2) +
geom_vline(xintercept = 2006, linetype = "dashed", colour = "#b45309") +
labs(title = "Annual EP mean: AM vs. control",
subtitle = "Dashed line = 2006, first post-intervention year",
x = "Year", y = "annual_mean", colour = "Technology") +
theme_minimal(base_size = 12)
# --- Primary controlled segmented regression ---
model <- lm(
annual_mean ~ treated + time_index + post + post_time +
treated:time_index + treated:post + treated:post_time,
data = dat
)
summary(model)
cbind(estimate = coef(model), confint(model))
# --- Newey-West robust inference (lag = 2 as an annual-data starting point;
# sensitivity-test other lags, there are few years per series) ---
nw_vcov <- NeweyWest(model, lag = 2, prewhite = FALSE)
coeftest(model, vcov. = nw_vcov)
nw_se <- sqrt(diag(nw_vcov))
nw_ci <- data.frame(
term = names(coef(model)),
estimate = coef(model),
lower = coef(model) - 1.96 * nw_se,
upper = coef(model) + 1.96 * nw_se,
row.names = NULL
)
key <- filter(nw_ci, term %in% c("treated:post", "treated:post_time"))
print(key)
# --- AM-specific effect over time relative to the no-intervention counterfactual ---
data.frame(year = 2006:2019) |>
mutate(am_specific_effect = coef(model)["treated:post"] +
coef(model)["treated:post_time"] * (year - 2006)) |>
print()
# --- Pre-trend check (parallel trends) ---
pre_model <- lm(annual_mean ~ treated + time_index + treated:time_index,
data = filter(dat, pre_period))
coef(summary(pre_model))["treated:time_index", , drop = FALSE]
# --- Placebo test: move the boundary to 2004, using only real pre-period years ---
placebo <- dat |>
filter(pre_period) |>
mutate(
placebo_post = as.integer(year >= 2004),
placebo_post_time = pmax(year - 2004, 0)
)
placebo_model <- lm(
annual_mean ~ treated + time_index + placebo_post + placebo_post_time +
treated:time_index + treated:placebo_post + treated:placebo_post_time,
data = placebo
)
coef(summary(placebo_model))[c("treated:placebo_post", "treated:placebo_post_time"), , drop = FALSE]
# --- Residuals over time (watch for patterns a 2006-only story wouldn't predict) ---
plot(dat$year, residuals(model), pch = 16, xlab = "Year", ylab = "Residual",
main = "Residuals from controlled segmented model")
abline(h = 0, lty = 2)
# --- Plain-language summary, filled in from the fitted model at runtime ---
fmt <- function(x) formatC(x, digits = 2, format = "f")
level <- filter(key, term == "treated:post")
slope <- filter(key, term == "treated:post_time")
cat(sprintf(
"At the 2006 boundary, the estimated AM-specific differential level change was %s units (95%% CI %s, %s). The estimated additional annual slope change after 2006 was %s units/year (95%% CI %s, %s).\n",
fmt(level$estimate), fmt(level$lower), fmt(level$upper),
fmt(slope$estimate), fmt(slope$lower), fmt(slope$upper)
))
# --- Optional sensitivity analysis: inverse-variance weighting ---
# annual_mean is already an aggregated value. The primary model above does NOT
# weight by annual_sd / n_systems / se_mean. As a sensitivity check only:
#
# weighted_model <- lm(
# annual_mean ~ treated + time_index + post + post_time +
# treated:time_index + treated:post + treated:post_time,
# data = dat, weights = 1 / dat$se_mean^2
# )
# summary(weighted_model)
#
# Compare coefficients and uncertainty against the unweighted primary model
# rather than reporting the weighted version alone.
Interactive
Computed live from the data.
Nothing below is typed in — it's fetched from the same CSV you can download above and computed on the spot in JavaScript, right as the page loads: the before/after group means, and a full OLS regression with Newey-West (lag-2) robust standard errors, fit live in your browser with no R required.
| Group | Before (1998–2005) | After (2006–2019) | Difference |
|---|---|---|---|
| … | |||
Loading CSV…
| Term | Estimate | Robust SE | z | p-value |
|---|---|---|---|---|
| … | ||||
Fitting model…
Visualizing it
The AM impact, before and after.
A simplified two-period version of the same idea — no trend terms, just the pre/post means for each group — makes the logic easy to see at a glance. The dashed line is the counterfactual: where AM "would have" landed if it had simply followed the control group's own change. The gap between that line and AM's actual post-period point is the AM-specific impact.
| Group | Before (1998–2005) | After (2006–2019) | Difference |
|---|---|---|---|
| Treatment (AM) | 28.5 ±4.9 | 8.3 ±2.3 | −20.2 |
| Control (no AM) | 23.7 ±1.3 | 19.7 ±2.0 | −4.1 (β₂) |
| Difference | 4.8 (β₁) | −11.3 | −16.1 (β₃) |
± is the standard deviation across years within each period (8 pre-years, 14 post-years) — a descriptive spread, not a standard error. β₀ = control baseline · β₁ = AM vs. control gap already present pre-intervention · β₂ = common post-period shift (control) · β₃ = AM-specific impact (the simple-DiD counterpart to treated:post in the full model above). Numbers are real, unweighted group means from the dataset — see the live regression table above for the full estimate with a Newey-West standard error and p-value.
Step 4
Check pre-trends, residuals, and placebo timing.
Diagnostics matter even more with short annual series like this one (22 years per group) — a single influential year can make or break an estimate.
Pre-trend check
Fit the segmented model on pre-period data only (1998–2005) and inspect treated:time_index. If AM and control were already diverging before 2006, the "parallel trends" assumption behind the controlled comparison is weaker, and any post-2006 estimate is harder to attribute to the intervention alone.
Residuals & autocorrelation
Plot residuals against year and check the ACF. Annual environmental data is often autocorrelated, which understates conventional standard errors. Newey-West (lag 2, sensitivity-tested) gives more honest uncertainty, but it doesn't fix a flawed comparison — it only adjusts the error bars around it.
Placebo test
Re-run the same model with a fake intervention boundary (here, 2004) using only real pre-intervention years. A placebo interaction that looks about as large as the real one is a warning sign that something other than the actual 2006 intervention is driving the result.
Step 5
Reading the result.
For any year y at or after 2006, the model-implied AM-specific effect relative to the no-intervention counterfactual is approximately:
Report it as: "At the 2006 boundary, the estimated AM-specific differential level change was [estimate] units (95% CI [lower], [upper]). The estimated additional annual slope change after 2006 was [estimate] units per year (95% CI [lower], [upper])." Treat this as a controlled-ITS association rather than a definitive causal effect unless the intervention timing, control validity, and pre-trends genuinely support that stronger claim.
Step 6
Before you conclude.
A controlled ITS coefficient is only as credible as its timing, comparison series, data quality, and design assumptions.
treated:post reported with a confidence interval and clear units?treated:post_time reported and interpreted over time, not just at one point?annual_mean treated as aggregated, with any se_mean weighting clearly labeled as a sensitivity check?Quick Access