Natchaya "Jade" Luangphairin
Browse all explainers →

Teaching · Explainer

Difference-in-differences & intervention analysis.

Controlled ITS Difference-in-Differences AM vs. Control · Annual EP

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)
  1. 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.
  2. 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."
  3. 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.
  4. Interact treated with each time term. treated:time_index, treated:post, and treated:post_time isolate what is different about the treated group specifically, net of whatever the control group also experienced.
  5. 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).
  6. Check assumptions before trusting the estimate: pre-trend similarity (treated:time_index in the pre-period only), residual autocorrelation, and a placebo test with a fake boundary inside the pre-period.
  7. 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.

annual_mean ~ treated + time_index + post + post_time + treated:time_index + treated:post + treated:post_time

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.

treated:post — the AM-specific differential level change at the 2006 boundary, relative to control.
treated:post_time — the AM-specific differential annual slope change after 2006, relative to control.
treated:time_index — a pre-period trend check. A large or statistically meaningful value weakens a parallel-trends interpretation.
post alone — the common post-period shift shared by the control group. This is not the AM effect on its 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.

R · controlled-its-arsenic.R

# 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.

COMPUTING FROM ARSENIC_AM_DATA_WQTC_DEMO.CSV…
Group Before (1998–2005) After (2006–2019) Difference
…

Loading CSV…

FULL REGRESSION · OLS WITH NEWEY-WEST (LAG 2) ROBUST SEs
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.

ARSENIC (µg/L) · PRE/POST MEANS, 1998–2019
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 (β₃)
0 5 10 15 20 25 30 Arsenic (µg/L) Pre-Intervention Post-Intervention AM intervention boundary: 2006 Counterfactual (business as usual) 23.7 19.7 β₀ β₀+β₂ 28.5 8.3 β₀+β₁ β₀+β₁+β₂+β₃ AM Impact −16.1 µg/L (β₃, simple DiD)
Treatment (AM) Control (no AM) Counterfactual trendline

± 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:

treated:post + treated:post_time × (y − 2006)

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.

Intervention definition — is 2006 defensibly the first post-intervention year?
Control validity — is the control series unaffected by the intervention, yet exposed to similar background forces?
Pre-trends — does the pre-period-only check support a plausible parallel-trends comparison?
Level change — is treated:post reported with a confidence interval and clear units?
Slope change — is treated:post_time reported and interpreted over time, not just at one point?
Uncertainty — were conventional and Newey-West results, residuals, and lag sensitivity considered?
Aggregation & weighting — is annual_mean treated as aggregated, with any se_mean weighting clearly labeled as a sensitivity check?
Placebo result — does the fake 2004 boundary avoid producing an effect as large as the real one?
Limitations — have concurrent events, control limitations, timing uncertainty, few annual observations, and the association-vs-causation distinction been stated plainly?
Browse all explainers →

Quick Access

Quick access to the main sections.