Executive Summary
How much of the movement in cigarette sales packs pc was incremental, against 45 holdout markets.
The short answer
California's 1989 Prop-99 tobacco program is associated with a reduction of about 11.18 packs per capita in cigarette sales relative to 45 control states, a 13.1% decline from the counterfactual level. However, this estimate is not a clean causal claim: the treated and control markets were already diverging before the intervention opened, pulling apart by 0.67 packs per capita per period (p = 0.0162). That pre-existing trend accounts for 15% of the estimated effect, so the true incremental lift is materially uncertain.
The detail
The difference-in-differences estimate is −11.18 packs per capita per market-period (95% CI −14.33 to −8.02, p < 0.001), representing a −13.1% relative lift against a counterfactual level of 85.28. The parallel-trends check, which is the integrity gate for the design, fails: across 9 pre-intervention periods, the treated market was already moving −0.67 packs per capita per period away from the controls (95% CI −1.21 to −0.13, p = 0.0162). All 8 pre-intervention event-study estimates exclude zero, confirming that divergence predates the policy. Projected forward, the pre-trend alone would account for −1.68 packs per capita, or 15% of the headline effect. Standard errors are clustered on 46 markets.
What this can't tell you
Because the arms were already on different trajectories, the −11.18 figure conflates the intervention's effect with pre-existing drift. The design would be strengthened by more pre-intervention periods to measure the trend precisely, control markets selected for their pre-intervention tracking of California rather than by convenience, or a synthetic control weighting the 45 available controls to match California's pre-period path.
Analysis Overview
Difference-in-differences on 46 markets in 'state code' — 1 treated against 45 held back.
The short answer
The analysis compares cigarette sales in 1 treated market (California) to 45 control markets before and after 1989, using the controls as the counterfactual for what would have happened without the intervention. Two-way fixed effects absorb persistent differences in market size and shocks common to all states; unit-clustered standard errors account for market-to-market variation.
The detail
Cigarette sales packs per capita is regressed on the treated-by-after indicator in a two-way fixed-effects specification with fixed effects for all 46 markets and all 13 periods (9 pre, 4 post). The 45 control markets absorb whatever else moved cigarette sales in that window—inflation, national health campaigns, competitor behavior—providing a stronger counterfactual than a single series' own pre-period trend. Standard errors are clustered on the 46 markets (CR1), so the confidence interval reflects market-level variation rather than treating each market-period as independent. The method assumes no other shock hit only California during the post window; a regional promotion, supply disruption, or local weather event landing on one arm alone would be counted as lift.
What this can't tell you
The design cannot separate the intervention from pre-existing divergence between the arms. See the parallel-trends card for the severity and remedies.
Data Preparation
How the raw rows became a treated-versus-control panel.
The short answer
All 598 rows loaded with no missing data. Each of the 46 markets appeared once per period, so no aggregation was needed. California (label '1') was coded as treated and the remaining 45 markets as controls; post-intervention periods (label '1' in 'post prop99') opened at 1989. The panel is balanced: 46 markets × 13 periods = 598 observations.
The detail
No rows were dropped or aggregated. The 'california' field identified the treated group (1 market, label '1') and controls (45 markets, label '0'). The 'post prop99' field identified the post-intervention window (label '1') starting at 1989, with 9 pre-periods and 4 post-periods. All 598 observations are present; the panel is balanced. The 'year' field contains 13 distinct period labels, ordered and placed on an evenly spaced synthetic timeline for charting; the date ticks are period order placeholders, not real calendar dates.
What this can't tell you
The 'year' field does not hold actual calendar dates, so the timing of the intervention relative to real-world events cannot be verified from this export. Consider a transaction-level or monthly export to pin the intervention window precisely if external events are suspected.
Treated vs Control Over Time
Mean cigarette sales packs pc per period for each arm.
The short answer
Before 1989, the treated market (California) and the 45 control markets both declined in cigarette sales, but California was already pulling away at −0.67 packs per capita per period. After the intervention, the gap widened sharply: California fell from 90.1 packs per capita in the last pre-period to 67.5 in the final period, while controls fell from 112.48 to 100.42 over the same span.
The detail
Mean cigarette sales packs per capita for the treated arm over 13 periods: 120.2 → 118.6 → 115.4 → 110.8 → 104.8 → 102.8 → 99.7 → 97.5 → 90.1 → 82.4 → 77.8 → 68.7 → 67.5. Control arm: 134.89 → 135.10 → 133.32 → 128.45 → 122.72 → 121.29 → 119.02 → 115.92 → 112.48 → 108.22 → 103.77 → 101.28 → 100.42. The pre-period gap was narrowing—the treated market was already moving faster downward—at −0.67 packs per capita per period. After 1989 (period 10 onward), the treated arm dropped sharply while the control trajectory flattened, widening the gap further.
What this can't tell you
The visual separation after 1989 cannot be attributed wholly to the intervention because the arms were already diverging before it. See the parallel-trends card for the magnitude of pre-trend bias.
Effect by Period (Event Study)
Per-period treated-minus-control effect on cigarette sales packs pc, against the last pre-intervention period.
The short answer
All 8 pre-intervention estimates exclude zero, with effects ranging from 3.06 to 7.69 packs per capita. This is direct evidence the arms were not on parallel paths before 1989. After the intervention, estimates step sharply negative: −10.19 and −10.53 in the final two periods, but the transition is gradual rather than immediate, consistent with a trend that began before the policy.
The detail
Pre-intervention estimates (periods 1–8, relative to period 9 baseline): 7.69 (CI 3.94–11.45), 5.89 (2.03–9.74), 4.47 (0.73–8.20), 4.74 (1.43–8.04), 4.46 (1.90–7.03), 3.89 (1.97–5.81), 3.06 (1.60–4.53), 3.96 (2.35–5.57). Period 9 is fixed at zero by construction. Post-intervention estimates: −3.44 (CI −4.43 to −2.45), −3.59 (−5.96 to −1.22), −10.19 (−13.03 to −7.36), −10.53 (−13.79 to −7.28). Standard errors are unit-clustered. All pre-estimates miss zero; post-estimates step away from zero and widen in magnitude, but the drift began before 1989.
What this can't tell you
The gradual pre-period divergence means the post-period effect cannot be cleanly separated from a continuation of the existing trend. More pre-intervention periods would sharpen this distinction.
The Difference-in-Differences 2x2
Cell means before and after, for each arm, and their differences.
| Group | Before | After | Change |
|---|---|---|---|
| Treated (1 market) | 106.7 | 74.1 | -32.56 |
| Control (45 markets) | 124.8 | 103.4 | -21.38 |
| Difference (treated minus control) | -18.14 | -29.32 | -11.18 |
The short answer
California's treated markets fell 32.56 packs per capita from before to after, while control markets fell 21.38 over the same window. The difference—11.18 packs per capita—is what difference-in-differences attributes to the intervention. However, this raw number overstates the incremental lift because the two arms were not on parallel paths before the intervention started.
The detail
The four cell means are: treated before 106.66, treated after 74.10; control before 124.80, control after 103.42. The treated change is −32.56 packs per capita; the control change is −21.38. The difference between those changes is −11.18 packs per capita (95% CI: −14.33 to −8.02), which is the fixed-effects regression estimate on the balanced panel. This arithmetic is the foundation of the causal claim, but it assumes the control arm's change represents what would have happened to the treated arm absent the intervention. That assumption holds only if the arms were tracking each other before the policy switched on—a condition the parallel-trends check rejects.
What this can't tell you
The 2×2 table alone cannot detect whether the arms were already diverging before the intervention. That check belongs to the parallel-trends diagnostic, which fails here and undermines the causal reading of the 11.18 figure.
Parallel Trends Check
Were the arms already diverging before the intervention?
| Check | Value | Interpretation |
|---|---|---|
| Differential pre-intervention trend per period (treated minus control), in cigarette sales packs pc | -0.67 | Fitted on the 9 pre-intervention periods only: how fast the treated markets were pulling away from the controls BEFORE anything was switched on. Difference-in-differences assumes this is zero. |
| 95% interval on that differential trend | -1.21 to -0.13 | Standard errors are unit-clustered (CR1). An interval comfortably containing zero is what a clean design looks like. |
| p-value of the differential pre-trend | 0.0162 | Below 0.05 the arms were already diverging and the headline estimate cannot be read as causal. |
| Pre-intervention periods available | 9 | More pre-intervention periods make this check sharper; 9 are available here. |
| Pre-intervention event-study estimates whose 95% interval excludes zero | 8 of 8 | Each pre-intervention period is also estimated separately against the last pre-period; estimates that miss zero are direct evidence against parallel trends. 8 of them miss zero here. |
| Bias in the estimate if that pre-trend simply continued | -1.68 | The pre-trend projected across the post window is -1.68, or 15% of the estimated effect — that is how much of the headline -11.18 could be pre-existing drift rather than the intervention. |
| Verdict | VIOLATED | VIOLATED — the arms were already diverging by -0.67 per period before the intervention (p = 0.0162), so the difference-in-differences estimate is not a credible causal number |
The short answer
The parallel-trends assumption fails: over 9 pre-intervention periods, California was already moving −0.67 packs per capita per period away from the controls (p = 0.0162). This pre-existing divergence is counted as lift by difference-in-differences, inflating the causal estimate. Projected forward, the pre-trend alone accounts for −1.68 packs per capita, or 15% of the −11.18 headline effect.
The detail
Differential pre-intervention trend: −0.67 packs per capita per period (95% CI −1.21 to −0.13, p = 0.0162). All 8 pre-intervention event-study estimates exclude zero, confirming divergence. The 95% interval on the trend does not contain zero, failing the integrity check. If that trend had continued into the post-period (4 periods), it would account for −1.68 packs per capita, or 15% of the estimated −11.18. The design assumes the arms would have moved in parallel absent the intervention; this assumption is violated.
What this can't tell you
The headline −11.18 cannot be read as the true causal effect of Prop-99. To firm up the answer: collect more pre-intervention periods to measure the trend precisely rather than extrapolate it; select control markets that tracked California before the change; or build a synthetic control weighting the 45 available controls to match California's pre-period trajectory. A longer pre-period would be the most direct fix.
Incrementality Test — How Much Lift Was Actually Incremental?
Geo-lift / holdout incrementality measurement by difference-in-differences on a panel of units (markets, stores, regions). Some units were treated (advertising switched on, a change rolled out); the rest were deliberately held back. The untreated units supply the counterfactual: what the treated units would have done anyway.
Why This Method?
A single series' own past can only tell you what it was trending toward. Untreated control units also absorb whatever else happened that period — a demand swing, a competitor, a holiday — because it hit both arms. That is why difference-in-differences with real holdouts is a far stronger causal claim than a before/after comparison of one series against its own pre-period trend.
What This Analysis Covers
- The difference-in-differences estimate from a two-way fixed-effects
regression, with unit-clustered standard errors and a 95% interval
- The parallel-trends check: were the arms already diverging before the
intervention? If so, the estimate is not credible and the report says so
- An event study: per-period effect relative to the last pre-period
- The 2x2 of cell means the whole method rests on
Standard Library
Platform standard-library module (LAT-1441): runs on ANY panel via the semantic mapping {unit, period, metric, treated, post}. All narrative is derived from the user's own column names and computed values.
suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))Core Analysis Pipeline
Step 1: Every mapped column must be present
need <- c("unit", "period", "metric", "treated", "post")
human <- c(unit = unit_name, period = period_name, metric = metric_name,
treated = treated_name, post = post_name)
missing_keys <- setdiff(need, names(df))
if (length(missing_keys) > 0) {
stop(sprintf(
"Incrementality testing needs a market column('%s'), a period column ('%s'), a numeric outcome ('%s'), a treatment-group flag ('%s') and a before/after flag ('%s'). %s could not be found in the data.",
unit_name, period_name, metric_name, treated_name, post_name,
paste0("'", paste(human[missing_keys], collapse = "', '"), "'")))
}Step 2: Coerce the outcome (95% rule) — refuse if it is not numeric
v <- df$metric
if (!is.numeric(v)) {
conv <- suppressWarnings(as.numeric(as.character(v)))
n_orig <- sum(!is.na(v) & nzchar(trimws(as.character(v))))
if (n_orig > 0 && sum(!is.na(conv)) >= 0.95 * n_orig) {
v <- conv
} else {
stop(sprintf(
"'%s' is not numeric — measuring incremental lift needs a numeric outcome (sales, conversions, revenue).",
metric_name))
}
}
v <- as.numeric(v)Step 3: Build the timeline. Dates are used when the period column
parses as dates; otherwise the distinct period labels are ordered (numerically when they are numbers) and laid out on an evenly spaced synthetic timeline purely so the charts have a time axis. Which of the two happened is stated in the report.
per_raw <- trimws(as.character(df$period))
per_raw[is.na(df$period) | !nzchar(per_raw)] <- NA_character_
pd <- parse_dates_robust(per_raw)
n_nonblank <- sum(!is.na(per_raw))
if (n_nonblank == 0) {
stop(sprintf("'%s' is empty — there are no periods to compare before and after the intervention.",
period_name))
}
period_is_date <- sum(!is.na(pd)) >= 0.95 * n_nonblankStep 4: Binarize the two flags, then keep complete rows only
unit_chr <- trimws(as.character(df$unit))
unit_chr[is.na(df$unit) | !nzchar(unit_chr)] <- NA_character_
tb_treated <- function(clean, levs) {
list(level = levs[length(levs)],
reason = sprintf(
"neither label is a standard yes/no word, so the alphabetically later label '%s' was taken as the treated group",
levs[length(levs)]))
}
tb_post <- function(clean, levs) {
ord <- if (period_is_date) as.numeric(pd) else
as.numeric(factor(per_raw, levels = sort(unique(per_raw))))
mean_pos <- tapply(ord, clean, function(z) mean(z, na.rm = TRUE))
mean_pos <- mean_pos[is.finite(mean_pos)]
if (length(mean_pos) < 2) {
return(list(level = levs[length(levs)],
reason = sprintf("the periods could not be ordered, so the alphabetically later label '%s' was taken as the after-intervention side",
levs[length(levs)])))
}
lv <- names(mean_pos)[which.max(mean_pos)]
list(level = lv,
reason = sprintf("neither label is a standard before/after word, and '%s' falls later on the timeline on average", lv))
}
tr <- binarize_flag(
df$treated, treated_name, "^(1|true|yes|y|t|treated|treatment|test|on|exposed)$",
tb_treated,
paste0("'", treated_name,
"' holds only one value ('%s') — a holdout incrementality test needs BOTH treated markets and untreated control markets. Without controls there is no counterfactual, and the honest answer is that incremental lift cannot be measured from this data."))
po <- binarize_flag(
df$post, post_name, "^(1|true|yes|y|t|post|after|during|on)$",
tb_post,
paste0("'", post_name,
"' holds only one value ('%s') — the analysis needs periods from BOTH before and after the intervention started."))
treated01 <- ifelse(tr$clean == tr$on_level, 1L, 0L)
post01 <- ifelse(po$clean == po$on_level, 1L, 0L)
keep <- !is.na(unit_chr) & !is.na(per_raw) & !is.na(v) &
!is.na(treated01) & !is.na(post01)
n_dropped_rows <- initial_rows - sum(keep)
w <- data.frame(
unit = unit_chr[keep],
period_label = per_raw[keep],
period_date = if (period_is_date) pd[keep] else as.Date(NA),
metric = v[keep],
treated01 = treated01[keep],
post01 = post01[keep],
stringsAsFactors = FALSE)
if (nrow(w) == 0) {
stop(sprintf(
"No rows survived cleaning — every row is missing at least one of '%s', '%s', '%s', '%s' or '%s'.",
unit_name, period_name, metric_name, treated_name, post_name))
}Step 5: Order the periods and lay out the timeline
if (period_is_date) {
ord_levels <- sort(unique(w$period_date))
w$tidx <- match(w$period_date, ord_levels)
axis_dates <- ord_levels
period_axis_note <- sprintf(
"'%s' parsed as dates, so the charts use the real calendar timeline",
period_name)
} else {
labs <- unique(w$period_label)
num <- suppressWarnings(as.numeric(labs))
ord_levels <- if (all(!is.na(num))) labs[order(num)] else sort(labs)
w$tidx <- match(w$period_label, ord_levels)
axis_dates <- as.Date("2000-01-01") + (seq_along(ord_levels) - 1L)
period_axis_note <- sprintf(
"'%s' does not hold dates, so its %d distinct labels were ordered and placed on an evenly spaced synthetic timeline; the chart's date ticks are placeholders for period order, not real calendar dates",
period_name, length(ord_levels))
}
axis_iso <- format(axis_dates, "%Y-%m-%d")
period_label_of <- ord_levels
n_periods <- length(ord_levels)Step 6: Integrity of the design — the treatment flag must be a
property of the unit, and the before/after flag a property of the period
bad_units <- names(which(tapply(w$treated01, w$unit,
function(z) length(unique(z))) > 1))
if (length(bad_units) > 0) {
stop(sprintf(
"%d %s in '%s' (%s) %s marked as treated in some rows and untreated in others. Difference-in-differences needs '%s' to be constant within each %s — one arm per market for the whole window.",
length(bad_units), pl(length(bad_units), "market"), unit_name,
paste(head(bad_units, 3), collapse = ", "),
pl(length(bad_units), "is", "are"), treated_name, unit_name))
}
bad_periods <- names(which(tapply(w$post01, w$tidx,
function(z) length(unique(z))) > 1))
if (length(bad_periods) > 0) {
bad_lab <- period_label_of[as.integer(bad_periods)]
stop(sprintf(
"%d %s in '%s' (%s) %s marked both before and after the intervention. '%s' must be constant within each period — every period falls entirely on one side of the start date.",
length(bad_lab), pl(length(bad_lab), "period"), period_name,
paste(head(bad_lab, 3), collapse = ", "),
pl(length(bad_lab), "is", "are"), post_name))
}Step 7: Collapse duplicate unit-period rows by SUM (rows are read as
amounts that add up within one market-period), then count the design
n_before_agg <- nrow(w)
w <- aggregate(metric ~ unit + tidx + treated01 + post01, data = w, FUN = sum)
n_dup_rows <- n_before_agg - nrow(w)
w$period_label <- period_label_of[w$tidx]
w$period_iso <- axis_iso[w$tidx]
w <- w[order(w$unit, w$tidx), , drop = FALSE]
rownames(w) <- NULL
units_all <- unique(w$unit)
n_units <- length(units_all)
unit_arm <- tapply(w$treated01, w$unit, max)
n_treated_units <- sum(unit_arm == 1)
n_control_units <- sum(unit_arm == 0)
pre_idx <- sort(unique(w$tidx[w$post01 == 0]))
post_idx <- sort(unique(w$tidx[w$post01 == 1]))
n_pre <- length(pre_idx); n_post <- length(post_idx)Step 8: Refuse rather than degrade
if (n_control_units < 1) {
stop(sprintf(
"Every market in '%s' is in the treatment group ('%s' = '%s') — a holdout incrementality test needs untreated control markets to build the counterfactual.",
unit_name, treated_name, tr$on_level))
}
if (n_treated_units < 1) {
stop(sprintf(
"No market in '%s' is marked as treated ('%s' = '%s') — there is nothing to measure the lift of.",
unit_name, treated_name, tr$on_level))
}
if (n_pre < 2) {
stop(sprintf(
"Only %d pre-intervention %s in '%s' — at least 2 are needed, because with a single pre-period there is no way to check whether the arms were already diverging.",
n_pre, pl(n_pre, "period"), period_name))
}
if (n_post < 1) {
stop(sprintf(
"No period in '%s' is marked as after the intervention ('%s' = '%s') — there is no post window to measure.",
period_name, post_name, po$on_level))
}
if (n_units < 4) {
stop(sprintf(
"Only %d %s in '%s' (%d treated, %d control) — at least 4 are needed for a difference-in-differences estimate to carry any usable uncertainty. With fewer, the honest answer is that the lift cannot be separated from market-to-market noise.",
n_units, pl(n_units, "market"), unit_name, n_treated_units,
n_control_units))
}
n_obs <- nrow(w)
balanced <- n_obs == n_units * n_periodsStep 9: Two-way fixed-effects DiD. The interaction of treated and
post IS the difference-in-differences estimate; market and period fixed effects absorb the two main effects, so the interaction is fitted directly to keep the design matrix full rank.
w$did <- w$treated01 * w$post01
fit_did <- stats::lm(metric ~ did + factor(unit) + factor(period_label),
data = w)
if (is.na(coef(fit_did)[["did"]])) {
stop(sprintf(
"The treated-by-after term could not be estimated: it is perfectly explained by the market and period effects. Check that treated markets in '%s' are observed in BOTH the before and after windows of '%s'.",
unit_name, post_name))
}
n_clusters <- n_units
few_clusters <- n_clusters < 10
V_did <- cluster_vcov(fit_did, w$unit)
cluster_ok <- !is.null(V_did) && !few_clusters
inf_did <- coef_inference(fit_did, "did", V_did, n_clusters, cluster_ok)
se_basis <- inf_did$basisStep 10: Relative lift is the absolute effect over the
counterfactual level (observed treated-post mean minus the effect).
mean_treated_post <- mean(w$metric[w$treated01 == 1 & w$post01 == 1])
cf_level <- mean_treated_post - inf_did$est
rel_ok <- is.finite(cf_level) && abs(cf_level) > 1e-9
rel_scale <- if (rel_ok) 100 / abs(cf_level) else NA_real_
rel_est <- if (rel_ok) inf_did$est * rel_scale else NA_real_
rel_lo <- if (rel_ok) inf_did$lo * rel_scale else NA_real_
rel_hi <- if (rel_ok) inf_did$hi * rel_scale else NA_real_Step 11: THE integrity gate — parallel trends. Fit the pre-period
only and ask whether the treated arm was already moving differently.
pre_w <- w[w$post01 == 0, , drop = FALSE]
pre_w$treat_t <- pre_w$treated01 * pre_w$tidx
fit_pt <- tryCatch(
stats::lm(metric ~ treat_t + factor(unit) + factor(period_label),
data = pre_w),
error = function(e) NULL)
if (!is.null(fit_pt) && !is.na(coef(fit_pt)[["treat_t"]])) {
V_pt <- cluster_vcov(fit_pt, pre_w$unit)
inf_pt <- coef_inference(fit_pt, "treat_t", V_pt, n_clusters, cluster_ok)
} else {
inf_pt <- list(est = NA_real_, se = NA_real_, df = NA_real_, p = NA_real_,
lo = NA_real_, hi = NA_real_, basis = "not estimable")
}
pt_ok <- is.finite(inf_pt$est) && is.finite(inf_pt$p)If the measured pre-trend simply carried on, this is the bias it would inject into the DiD estimate over the post window.
mean_post_gap <- mean(post_idx) - max(pre_idx)
pt_bias <- if (pt_ok) inf_pt$est * mean_post_gap else NA_real_
pt_bias_share <- if (pt_ok && abs(inf_did$est) > 1e-9)
100 * abs(pt_bias) / abs(inf_did$est) else NA_real_
pt_verdict <- if (!pt_ok) "unassessable" else if (inf_pt$p < 0.05) "violated" else
if (inf_pt$p < 0.10) "borderline" else "supported"Step 12: Event study — per-period effect against the last
pre-intervention period, which is normalised to zero by construction.
ref_idx <- max(pre_idx)
es_terms <- setdiff(seq_len(n_periods), ref_idx)
es_params <- n_units + 2 * n_periods
es_method <- "regression"
es_rows <- NULL
if (es_params <= 500 && length(es_terms) > 0) {
es_names <- paste0("es", es_terms)
for (i in seq_along(es_terms)) {
w[[es_names[i]]] <- as.integer(w$treated01 == 1 & w$tidx == es_terms[i])
}
fml <- stats::as.formula(paste0(
"metric ~ ", paste(es_names, collapse = " + "),
" + factor(unit) + factor(period_label)"))
fit_es <- tryCatch(stats::lm(fml, data = w), error = function(e) NULL)
if (!is.null(fit_es)) {
V_es <- cluster_vcov(fit_es, w$unit)
es_rows <- do.call(rbind, lapply(seq_along(es_terms), function(i) {
ii <- coef_inference(fit_es, es_names[i], V_es, n_clusters, cluster_ok)
data.frame(tidx = es_terms[i], effect = ii$est,
ci_low = ii$lo, ci_high = ii$hi,
stringsAsFactors = FALSE)
}))
}
}
if (is.null(es_rows)) {How many PRE-period estimates are significantly non-zero — the visual half of the parallel-trends evidence.
pre_es <- es_rows[es_rows$tidx %in% setdiff(pre_idx, ref_idx), , drop = FALSE]
pt_n_bad_pre <- if (nrow(pre_es) == 0) 0L else
as.integer(sum(pre_es$ci_low > 0 | pre_es$ci_high < 0, na.rm = TRUE))
pt_n_pre_est <- nrow(pre_es)
event_study_df <- data.frame(
period = axis_iso[es_rows$tidx],
effect = round(es_rows$effect, 4),
ci_high = round(es_rows$ci_high, 4),
ci_low = round(es_rows$ci_low, 4),
stringsAsFactors = FALSE)Step 13: The picture people actually want — mean outcome per period
for each arm.
arm_means_df <- do.call(rbind, lapply(seq_len(n_periods), function(k) {
do.call(rbind, lapply(c(1L, 0L), function(a) {
sel <- w$tidx == k & w$treated01 == a
if (!any(sel)) return(NULL)
data.frame(period = axis_iso[k],
mean_metric = round(mean(w$metric[sel]), 4),
arm = if (a == 1L) "Treated" else "Control",
stringsAsFactors = FALSE)
}))
}))
if (nrow(arm_means_df) > 2000) {
keep_k <- sort(unique(round(seq(1, n_periods, length.out = 1000))))
arm_means_df <- arm_means_df[arm_means_df$period %in% axis_iso[keep_k], ,
drop = FALSE]
}
rownames(arm_means_df) <- NULLStep 14: The 2x2 the whole method rests on
cell <- function(a, p) {
sel <- w$treated01 == a & w$post01 == p
if (!any(sel)) NA_real_ else mean(w$metric[sel])
}
t_pre <- cell(1, 0); t_post <- cell(1, 1)
c_pre <- cell(0, 0); c_post <- cell(0, 1)
raw_did <- (t_post - t_pre) - (c_post - c_pre)
did_cells_df <- data.frame(
group = c(sprintf("Treated(%d %s)", n_treated_units,
pl(n_treated_units, "market")),
sprintf("Control(%d %s)", n_control_units,
pl(n_control_units, "market")),
"Difference(treated minus control)"),
before = round(c(t_pre, c_pre, t_pre - c_pre), 4),
after = round(c(t_post, c_post, t_post - c_post), 4),
change = round(c(t_post - t_pre, c_post - c_pre, raw_did), 4),
stringsAsFactors = FALSE)Step 15: Round ONCE — the same numbers feed tables and prose
did_r <- round(inf_did$est, 2)
did_lo <- round(inf_did$lo, 2); did_hi <- round(inf_did$hi, 2)
did_p <- inf_did$p
rel_r <- if (is.na(rel_est)) NA_real_ else round(rel_est, 1)
rel_lo_r <- if (is.na(rel_lo)) NA_real_ else round(rel_lo, 1)
rel_hi_r <- if (is.na(rel_hi)) NA_real_ else round(rel_hi, 1)
raw_did_r <- round(raw_did, 2)
pt_slope <- if (pt_ok) round(inf_pt$est, 3) else NA_real_
pt_lo <- if (pt_ok) round(inf_pt$lo, 3) else NA_real_
pt_hi <- if (pt_ok) round(inf_pt$hi, 3) else NA_real_
pt_bias_r <- if (pt_ok) round(pt_bias, 2) else NA_real_
pt_bias_share_r <- if (is.na(pt_bias_share)) NA_real_ else round(pt_bias_share, 1)
cf_level_r <- round(cf_level, 2)
did_significant <- is.finite(did_lo) && is.finite(did_hi) &&
(did_lo > 0 || did_hi < 0)
direction_word <- if (is.finite(did_r) && did_r >= 0) "above" else "below"Step 16: The parallel-trends diagnostic table
pt_verdict_sentence <- switch(
pt_verdict,
violated = sprintf(
"VIOLATED — the arms were already diverging by %s per period before the intervention(%s), so the difference-in-differences estimate is not a credible causal number",
fmt_signed(pt_slope, 3), fmt_p_phrase(inf_pt$p)),
borderline = sprintf(
"BORDERLINE — the pre-period divergence of %s per period is not conclusive either way(%s); treat the estimate with caution",
fmt_signed(pt_slope, 3), fmt_p_phrase(inf_pt$p)),
supported = sprintf(
"SUPPORTED — no detectable pre-intervention divergence(%s per period, %s). This is a failure to detect a violation, not proof that trends were parallel",
fmt_signed(pt_slope, 3), fmt_p_phrase(inf_pt$p)),
"UNASSESSABLE — the pre-period trend term could not be estimated from this panel")
parallel_trends_df <- data.frame(
check = c(
sprintf("Differential pre-intervention trend per period(treated minus control), in %s", metric_name),
"95% interval on that differential trend",
"p-value of the differential pre-trend",
"Pre-intervention periods available",
"Pre-intervention event-study estimates whose 95% interval excludes zero",
"Bias in the estimate if that pre-trend simply continued",
"Verdict"),
value = c(
if (pt_ok) fmt_signed(pt_slope, 3) else "not estimable",
if (pt_ok) paste0(fmt_n(pt_lo, 3), " to ", fmt_n(pt_hi, 3)) else "not estimable",
if (pt_ok) fmt_p(inf_pt$p) else "not estimable",
as.character(n_pre),
sprintf("%d of %d", pt_n_bad_pre, pt_n_pre_est),
if (pt_ok) fmt_signed(pt_bias_r) else "not estimable",
toupper(pt_verdict)),
interpretation = c(
sprintf("Fitted on the %d pre-intervention %s only: how fast the treated markets were pulling away from the controls BEFORE anything was switched on. Difference-in-differences assumes this is zero.",
n_pre, pl(n_pre, "period")),
sprintf("Standard errors are %s. An interval comfortably containing zero is what a clean design looks like.",
inf_pt$basis),
"Below 0.05 the arms were already diverging and the headline estimate cannot be read as causal.",
sprintf("More pre-intervention periods make this check sharper; %d %s available here.",
n_pre, pl(n_pre, "is", "are")),
sprintf("Each pre-intervention period is also estimated separately against the last pre-period; estimates that miss zero are direct evidence against parallel trends. %s",
if (pt_n_bad_pre > 0)
sprintf("%d of them %s zero here.", pt_n_bad_pre,
pl(pt_n_bad_pre, "misses", "miss"))
else "None of them miss zero here."),
if (pt_ok) sprintf(
"The pre-trend projected across the post window is %s%s — that is how much of the headline %s could be pre-existing drift rather than the intervention.",
fmt_signed(pt_bias_r),
if (!is.na(pt_bias_share_r)) sprintf(", or %s%% of the estimated effect",
fmt_n(pt_bias_share_r, 1)) else "",
fmt_signed(did_r))
else "The projection could not be computed because the pre-trend term is not estimable.",
pt_verdict_sentence),
stringsAsFactors = FALSE)Step 17: Metrics + the one-paragraph computed answer
metrics <- list(
`Markets` = n_units,
`Treated Markets` = n_treated_units,
`Control Markets` = n_control_units,
`Pre Periods` = n_pre,
`Post Periods` = n_post,
`Incremental Lift` = did_r,
`Lift CI Low` = did_lo,
`Lift CI High` = did_hi,
`Relative Lift(%)` = rel_r,
`Parallel Trends` = toupper(pt_verdict)
)
lift_phrase <- paste0(
fmt_signed(did_r), " in ", metric_name, " per market-period(95% interval ",
fmt_n(did_lo), " to ", fmt_n(did_hi), ")",
if (!is.na(rel_r)) paste0(", a relative lift of ", fmt_signed(rel_r, 1),
"% against a counterfactual level of ",
fmt_n(cf_level_r)) else "")
answer <- if (pt_verdict == "violated") {
paste0(
"The parallel-trends assumption FAILS on this panel: treated and control markets in '",
unit_name, "' were already diverging by ", fmt_signed(pt_slope, 3),
" per period before the intervention(", fmt_p_phrase(inf_pt$p),
"), so the difference-in-differences estimate of ", lift_phrase,
" should NOT be read as incremental lift — an unknown part of it is pre-existing drift(projected bias ",
fmt_signed(pt_bias_r),
"). Fix the design before trusting a number: add more pre-intervention periods, choose control markets that tracked the treated ones before the change, or use a synthetic-control weighting of the controls.")
} else {
paste0(
"Difference-in-differences across ", n_units, " markets(",
n_treated_units, " treated, ", n_control_units, " held back) over ",
n_pre, " pre and ", n_post, " post ", pl(n_post, "period"),
": the treated markets ran ", lift_phrase, " ", direction_word,
" what the control markets say they would have done anyway. The interval ",
if (did_significant) "excludes" else "includes",
" zero, so the lift is ",
if (did_significant) "distinguishable from" else "not distinguishable from",
" noise. Parallel trends: ", toupper(pt_verdict), " (",
fmt_p_phrase(inf_pt$p), " on the pre-period differential trend). ",
"This assumes nothing else hit only the treated markets at the same time.")
}
json_output <- list(
answer = answer,
cards = lapply(
c("tldr", "overview", "preprocessing", "arm_trends", "event_study",
"did_table", "parallel_trends"),
function(cid) list(id = cid, metrics = metrics)
)
)
list(
initial_rows = initial_rows, final_rows = n_obs,
rows_removed = max(0, initial_rows - n_obs),
n_dropped_rows = n_dropped_rows, n_dup_rows = n_dup_rows,
unit_name = unit_name, period_name = period_name,
metric_name = metric_name, treated_name = treated_name,
post_name = post_name,
treated_on = tr$on_level, treated_off = tr$off_level,
treated_reason = tr$reason,
post_on = po$on_level, post_off = po$off_level, post_reason = po$reason,
period_is_date = period_is_date, period_axis_note = period_axis_note,
n_units = n_units, n_treated_units = n_treated_units,
n_control_units = n_control_units, n_periods = n_periods,
n_pre = n_pre, n_post = n_post, n_obs = n_obs, balanced = balanced,
first_post_label = period_label_of[min(post_idx)],
ref_period_label = period_label_of[ref_idx],
did_r = did_r, did_lo = did_lo, did_hi = did_hi, did_p = did_p,
did_significant = did_significant, direction_word = direction_word,
rel_r = rel_r, rel_lo = rel_lo_r, rel_hi = rel_hi_r, rel_ok = rel_ok,
cf_level_r = cf_level_r, raw_did_r = raw_did_r,
se_basis = se_basis, cluster_ok = cluster_ok, n_clusters = n_clusters,
few_clusters = few_clusters,
pt_slope = pt_slope, pt_lo = pt_lo, pt_hi = pt_hi, pt_p = inf_pt$p,
pt_verdict = pt_verdict, pt_verdict_sentence = pt_verdict_sentence,
pt_bias_r = pt_bias_r, pt_bias_share_r = pt_bias_share_r,
pt_n_bad_pre = pt_n_bad_pre, pt_n_pre_est = pt_n_pre_est,
es_method = es_method,
arm_means_df = arm_means_df, event_study_df = event_study_df,
did_cells_df = did_cells_df, parallel_trends_df = parallel_trends_df,
metrics = metrics, json_output = json_output
)
}