Executive Summary
Did the change move employment? New Jersey (min wage raised) vs Pennsylvania (control), before vs after.
The wage increase did not produce a statistically significant employment effect. The estimate is 2.75 jobs per store (95% CI −0.65 to 6.15, p = 0.113), with the confidence interval spanning zero. Relative to the counterfactual level of 18.15, this represents 15.15% of expected employment. The parallel-trends assumption—critical for the causal claim—could not be tested with only one pre-period observation per group. This remains an observational comparison without experimental controls.
Analysis Overview
Difference-in-differences on employment: New Jersey (min wage raised) (treated) vs Pennsylvania (control) (control), before vs after.
This analysis isolates the wage change's effect by subtracting Pennsylvania's natural employment movement from New Jersey's. Pennsylvania declined by 2.28 jobs per store while New Jersey rose by 0.47, yielding a difference-in-differences estimate of 2.75. This method removes seasonality, trends, and common shocks that a simple before-and-after would wrongly credit to the wage increase. The causal claim rests entirely on the parallel-trends assumption: that without the wage change, New Jersey would have moved like Pennsylvania.
Data Preparation
How the groups and periods were identified, and what was dropped.
All 768 rows were retained; no observations were dropped. Pennsylvania was identified as the control group by name, making New Jersey the treated group. Because no treatment start date was provided, the analysis split the period at the midpoint between 1992-02-19 and 1992-11-19—an assumption that should be refined if the exact implementation date is known. Observations were aggregated to one pre and one post value per store (384 units total), protecting the significance test against repeated measurements of the same location.
The 2x2: Group Means Before and After
Mean employment per group, before and after the change.
New Jersey employment moved from 20.43 before the wage increase to 20.90 after, a gain of 0.47 jobs per store. Pennsylvania employment fell from 23.38 to 21.10, a loss of 2.28 jobs per store. The difference between these two movements—0.47 minus (−2.28)—is the 2.75 estimate. The level gap between states (New Jersey lower than Pennsylvania both before and after) is expected and is eliminated by the difference-in-differences method. The story lies in the divergence of the trends, not in the level difference.
Treated vs Control Over Time
Mean employment per period for New Jersey (min wage raised) and Pennsylvania (control).
With only one pre-period and one post-period observation per group, the parallel-trends assumption cannot be tested. The data show New Jersey at 20.43 before and 20.90 after; Pennsylvania at 23.38 before and 21.10 after. To verify that the two groups moved in parallel absent the wage change would require multiple pre-period observations showing similar trajectories. A timeline with finer period granularity would allow direct inspection of whether the groups tracked together before the change and diverged after it.
The Difference-in-Differences Estimate
Cell means, per-group changes, and the estimate with its confidence interval.
| Measure | Estimate | Detail |
|---|---|---|
| New Jersey (min wage raised) — pre mean | 20.43 | Mean employment for New Jersey (min wage raised) before the change |
| New Jersey (min wage raised) — post mean | 20.9 | Mean employment for New Jersey (min wage raised) after the change |
| New Jersey (min wage raised) — change | 0.467 | How much New Jersey (min wage raised) moved (0.47) |
| Pennsylvania (control) — pre mean | 23.38 | Mean employment for Pennsylvania (control) before the change |
| Pennsylvania (control) — post mean | 21.1 | Mean employment for Pennsylvania (control) after the change |
| Pennsylvania (control) — change | -2.283 | How much Pennsylvania (control) moved anyway (-2.28) |
| Difference-in-differences | 2.75 | 95% CI -0.65 to 6.15; p = 0.113 |
| Effect vs counterfactual | 15.15 | Relative to the expected level of 18.15 absent the change (15.15%) |
New Jersey's pre mean was 20.431 and post mean was 20.897, a change of 0.467. Pennsylvania's pre mean was 23.38 and post mean was 21.097, a change of −2.283. The difference between these changes—the difference-in-differences estimate—is 2.75 (95% CI −0.65 to 6.15, p = 0.113). The confidence interval includes zero, indicating the effect is not statistically significant at conventional levels. Relative to the counterfactual expected level of 18.15 absent the change, the estimate represents 15.15%.
Method & Assumptions
The design, the identification choices made, and the assumptions the causal read rests on.
| Item | Detail |
|---|---|
| Design | Observational difference-in-differences: the treated group's before-to-after change minus the control group's. Not a randomized experiment. |
| Treated group | 'Pennsylvania (control)' was identified as the control group from its name; 'New Jersey (min wage raised)' is therefore treated. |
| Period split | No treatment start date was provided, so periods were split at the midpoint of the observed date range (1992-02-19 to 1992-11-19) — an assumption; provide a treatment_start parameter for a precise split. |
| Model | OLS regression of employment on group, period, and their interaction — the interaction term is the difference-in-differences estimate. |
| Inference | Observations were first averaged to one pre and one post value per store id (384 units), which guards the significance test against repeated measurements of the same unit. |
| Parallel trends | With only one pre period per group in 'period', the parallel-trends assumption cannot be tested here — the causal reading rests on it untested. |
| Key assumptions | No other shock hit only one group at the same time as the change; group composition is stable; absent the change, the treated group would have moved like the control group. |
Difference-in-differences is observational: it claims causality only through untested assumptions. The estimate is the interaction term from ordinary least squares regression of employment on group, period, and their interaction. Pennsylvania was identified as control by name; New Jersey is treated. The period split at the midpoint of 1992-02-19 to 1992-11-19 because no treatment date was provided—a refinable assumption. Observations were averaged to one pre and one post value per store (384 units) to protect inference against repeated measurements. Parallel trends cannot be tested with a single pre-period per group, so the causal reading rests on an untestable assumption. The analysis assumes no other shock hit only one group when the wage change occurred and that group composition remained stable.
Core Analysis Pipeline
compute_shared <- function(df, params, col_map = list()) {
# === SHARED EXPORTS ===
# initial_rows/final_rows/rows_removed $ row accounting
# n_dropped $ rows dropped for missing outcome/group/period
# outcome_h/group_h/period_h/unit_h $ humanized user names for prose
# trt_lvl/ctl_lvl/trt_rule $ which group value is treated + how decided
# period_rule $ how periods were split into pre and post
# has_time/date_iso $ whether a time axis exists + ISO strings (or NULL)
# n_pre_periods/n_post_periods $ distinct periods each side of the split
# m_t_pre/m_t_post/m_c_pre/m_c_post $ the 2x2 cell means (analysis frame)
# chg_t/chg_c $ per-group pre-to-post change
# did/did_se/did_p/ci_low/ci_high $ the DiD estimate (OLS interaction)
# cf/pct/pct_txt $ counterfactual level + effect as % of it
# collapse_note/n_units $ unit-collapse behavior (if unit mapped)
# pt_testable/pt_slope/pt_p/pt_short/pt_verdict $ parallel-trends check
# cell_means_df $ period_label, mean_outcome, group_label (4 rows)
# trends_df $ period_date (ISO), mean_outcome, group_label
# did_table_df $ measure, estimate, detail
# methods_df $ item, detail
# metrics / json_output
# === /SHARED EXPORTS ===Step 1: Resolve mapped columns (humanized for all prose)
initial_rows <- nrow(df)
outcome_h <- humanize_semantic("outcome", col_map)
group_h <- humanize_semantic("group", col_map)
period_h <- humanize_semantic("period", col_map)
unit_h <- humanize_semantic("unit", col_map)
for (req in c("outcome", "group", "period")) {
if (!(req %in% names(df))) {
stop(sprintf("Difference-in-differences needs '%s' (%s) mapped.",
humanize_semantic(req, col_map),
c(outcome = "the numeric outcome",
group = "the treated/control indicator",
period = "the pre/post indicator or date")[[req]]))
}
}
has_unit <- "unit" %in% names(df)Step 2: Coerce the outcome to numeric (95% rule)
y_raw <- df$outcome
if (!is.numeric(y_raw)) {
ch <- as.character(y_raw)
non_blank <- !is.na(ch) & trimws(ch) != ""
conv <- suppressWarnings(as.numeric(ch))
if (sum(non_blank) == 0 ||
sum(!is.na(conv[non_blank])) < 0.95 * sum(non_blank)) {
stop(sprintf("The outcome column '%s' does not look numeric — fewer than 95%% of its values parse as numbers. Pick a numeric column.",
outcome_h))
}
y_raw <- conv
}Step 3: Keep rows complete on outcome, group, and period (reported)
g_raw <- trimws(as.character(df$group))
p_raw <- trimws(as.character(df$period))
keep <- !is.na(y_raw) & !is.na(g_raw) & g_raw != "" & !is.na(p_raw) & p_raw != ""
n_dropped <- sum(!keep)
y <- y_raw[keep]; g <- g_raw[keep]; p <- p_raw[keep]
u <- if (has_unit) trimws(as.character(df$unit))[keep] else NULL
final_rows <- length(y)
if (final_rows < 12) {
stop(sprintf("Only %d usable rows remained after dropping rows missing %s, %s, or %s — at least 12 are needed for a difference-in-differences analysis.",
final_rows, outcome_h, group_h, period_h))
}Step 4: Identify the treated and control groups
g_lvls <- sort(unique(g))
if (length(g_lvls) < 2) {
stop(sprintf("The group column '%s' has only one distinct value ('%s') — difference-in-differences needs both a treated and a control group.",
group_h, g_lvls[1]))
}
if (length(g_lvls) > 2) {
stop(sprintf("The group column '%s' has %d distinct values (%s) — difference-in-differences needs exactly two: a treated group and a control group.",
group_h, length(g_lvls),
paste(head(g_lvls, 5), collapse = ", ")))
}
low_g <- tolower(g_lvls)
trt_subs <- c("treat", "test", "interven", "pilot", "exposed", "variant", "trial")
ctl_subs <- c("control", "comparison", "holdout", "placebo", "untreated", "baseline", "standard")
hits_trt <- sapply(low_g, function(s)
any(sapply(trt_subs, function(k) grepl(k, s, fixed = TRUE))) || s %in% c("1", "yes", "true"))
hits_ctl <- sapply(low_g, function(s)
any(sapply(ctl_subs, function(k) grepl(k, s, fixed = TRUE))) || s %in% c("0", "no", "false"))
if (sum(hits_trt & !hits_ctl) == 1) {
trt_lvl <- g_lvls[hits_trt & !hits_ctl]
trt_rule <- sprintf("'%s' was identified as the treated group from its name; '%s' is the control.",
trt_lvl, setdiff(g_lvls, trt_lvl))
} else if (sum(hits_ctl & !hits_trt) == 1) {
ctl_named <- g_lvls[hits_ctl & !hits_trt]
trt_lvl <- setdiff(g_lvls, ctl_named)
trt_rule <- sprintf("'%s' was identified as the control group from its name; '%s' is therefore treated.",
ctl_named, trt_lvl)
} else {
n_by_lvl <- table(g)
trt_lvl <- names(n_by_lvl)[which.min(n_by_lvl)]
trt_rule <- sprintf("Neither value of '%s' names a treated or control group, so the smaller group ('%s', %d rows) was ASSUMED to be treated — if that is wrong, the estimate's sign flips.",
group_h, trt_lvl, min(n_by_lvl))
}
ctl_lvl <- setdiff(g_lvls, trt_lvl)Step 5: Split periods into pre and post (labels, dates, or numbers)
parse_dates_vec <- function(ch) {
ok <- function(dd) sum(!is.na(dd)) >= 0.95 * length(ch)
d <- suppressWarnings(as.Date(ch, format = "%Y-%m-%d"))
if (!ok(d)) d <- suppressWarnings(as.Date(lubridate::mdy(ch, quiet = TRUE)))
if (!ok(d)) d <- suppressWarnings(as.Date(lubridate::dmy(ch, quiet = TRUE)))
if (!ok(d)) d <- suppressWarnings(as.Date(lubridate::ymd(ch, quiet = TRUE)))
if (ok(d) && length(unique(stats::na.omit(d))) >= 2) d else NULL
}
parse_date_one <- function(s) {
s <- trimws(as.character(s))
d <- suppressWarnings(as.Date(s, format = "%Y-%m-%d"))
if (is.na(d)) d <- suppressWarnings(as.Date(lubridate::mdy(s, quiet = TRUE)))
if (is.na(d)) d <- suppressWarnings(as.Date(lubridate::dmy(s, quiet = TRUE)))
if (is.na(d)) d <- suppressWarnings(as.Date(lubridate::ymd(s, quiet = TRUE)))
if (is.na(d)) {
ny <- suppressWarnings(as.numeric(s))
if (!is.na(ny) && ny == round(ny) && ny >= 1900 && ny <= 2100)
d <- as.Date(sprintf("%04d-01-01", ny))
}
d
}
tv <- NULL # numeric time value per row (NULL when labels)
date_iso <- NULL # ISO date string per row (NULL when no real dates)
period_kind <- "label"
dts <- parse_dates_vec(p)
if (!is.null(dts)) {
period_kind <- "date"
tv <- as.numeric(dts)
date_iso <- format(dts, "%Y-%m-%d")
} else {
pn <- suppressWarnings(as.numeric(p))
if (sum(!is.na(pn)) >= 0.95 * length(p) &&
length(unique(stats::na.omit(pn))) >= 2) {
pn_ok <- stats::na.omit(pn)
if (all(pn_ok == round(pn_ok)) && all(pn_ok >= 1900 & pn_ok <= 2100) &&
length(unique(pn_ok)) > 2) {
# Integer years -> treat as annual dates
dts <- as.Date(ifelse(is.na(pn), NA, sprintf("%04d-01-01", pn)),
format = "%Y-%m-%d")
period_kind <- "date"
tv <- as.numeric(dts)
date_iso <- format(dts, "%Y-%m-%d")
} else if (length(unique(pn_ok)) == 2) {
period_kind <- "binary_num"
tv <- pn
} else {
period_kind <- "numeric"
tv <- pn
}
}
}
if (period_kind != "label" && any(is.na(tv))) {
keep2 <- !is.na(tv)
n_dropped <- n_dropped + sum(!keep2)
y <- y[keep2]; g <- g[keep2]; p <- p[keep2]; tv <- tv[keep2]
if (!is.null(date_iso)) date_iso <- date_iso[keep2]
if (!is.null(u)) u <- u[keep2]
final_rows <- length(y)
}
rows_removed <- initial_rows - final_rows
ts_param <- params$treatment_start %||% params$treatment_date %||% NULL
if (period_kind == "date") {
if (!is.null(ts_param)) {
tsd <- parse_date_one(ts_param)
if (is.na(tsd)) {
stop(sprintf("The treatment_start parameter('%s') could not be read as a date.",
as.character(ts_param)))
}
is_post <- tv >= as.numeric(tsd)
period_rule <- sprintf("Periods were split at the provided treatment start(%s): dates on or after it count as post.",
format(tsd, "%Y-%m-%d"))
} else {
cut_v <- min(tv) + (max(tv) - min(tv)) / 2
is_post <- tv > cut_v
period_rule <- sprintf("No treatment start date was provided, so periods were split at the midpoint of the observed date range(%s to %s) — an assumption; provide a treatment_start parameter for a precise split.",
format(as.Date(min(tv), origin = "1970-01-01"), "%Y-%m-%d"),
format(as.Date(max(tv), origin = "1970-01-01"), "%Y-%m-%d"))
}
} else if (period_kind == "binary_num") {
v_hi <- max(tv)
is_post <- tv == v_hi
period_rule <- sprintf("'%s' has two numeric values: %s was read as the pre period and %s as the post period (smaller value first).",
period_h, r2(min(tv)), r2(v_hi))
} else if (period_kind == "numeric") {
if (!is.null(ts_param) && !is.na(suppressWarnings(as.numeric(ts_param)))) {
cut_v <- as.numeric(ts_param)
is_post <- tv >= cut_v
period_rule <- sprintf("Numeric periods were split at the provided treatment start(%s): values on or after it count as post.", r2(cut_v))
} else {
cut_v <- min(tv) + (max(tv) - min(tv)) / 2
is_post <- tv > cut_v
period_rule <- sprintf("No treatment start was provided, so the numeric periods were split at the midpoint of their range(%s) — an assumption; provide a treatment_start parameter for a precise split.", r2(cut_v))
}
} else {
p_lvls <- unique(p)
if (length(p_lvls) == 1) {
stop(sprintf("The period column '%s' has only one distinct value ('%s') — difference-in-differences needs data from both before and after the change.",
period_h, p_lvls[1]))
}
if (length(p_lvls) > 2) {
stop(sprintf("The period column '%s' has %d distinct non-date values — use a two-level before/after indicator or a date column.",
period_h, length(p_lvls)))
}
low_p <- tolower(p_lvls)
pre_subs <- c("pre", "before", "baseline", "prior", "begin", "start", "old", "early")
post_subs <- c("post", "after", "follow", "end", "late", "new")
is_pre_kw <- sapply(low_p, function(s)
any(sapply(pre_subs, function(k) grepl(k, s, fixed = TRUE))) || s %in% c("0", "no", "false"))
is_post_kw <- sapply(low_p, function(s)
any(sapply(post_subs, function(k) grepl(k, s, fixed = TRUE))) || s %in% c("1", "yes", "true"))
pre_lvl <- NULL
if (sum(is_pre_kw & !is_post_kw) == 1) {
pre_lvl <- p_lvls[is_pre_kw & !is_post_kw]
} else if (sum(is_post_kw & !is_pre_kw) == 1) {
pre_lvl <- setdiff(p_lvls, p_lvls[is_post_kw & !is_pre_kw])
}
if (is.null(pre_lvl) || length(pre_lvl) != 1) {
stop(sprintf("Could not tell which value of '%s' is the before period ('%s' vs '%s') — use labels like pre/post or before/after, or a date column.",
period_h, p_lvls[1], p_lvls[2]))
}
is_post <- p != pre_lvl
period_rule <- sprintf("'%s' was read as the pre period and '%s' as the post period from their labels.",
pre_lvl, setdiff(p_lvls, pre_lvl))
}
if (!any(is_post)) {
stop(sprintf("All %d usable rows of '%s' fall before the change — there is no post-period data to compare.",
final_rows, period_h))
}
if (all(is_post)) {
stop(sprintf("All %d usable rows of '%s' fall after the change — difference-in-differences needs pre-change (baseline) data for both groups.",
final_rows, period_h))
}
trt01 <- as.integer(g == trt_lvl)
post01 <- as.integer(is_post)
has_time <- !is.null(tv)
n_pre_periods <- if (has_time) length(unique(tv[post01 == 0])) else 1L
n_post_periods <- if (has_time) length(unique(tv[post01 == 1])) else 1LStep 6: Analysis frame — optionally collapse to unit x period means
af <- data.frame(y = y, trt = trt01, post = post01)
collapse_note <- ""
n_units <- NA_integer_
if (has_unit) {
agg <- stats::aggregate(list(y = af$y),
by = list(unit = u, trt = af$trt, post = af$post),
FUN = mean)
af <- data.frame(y = agg$y, trt = agg$trt, post = agg$post)
n_units <- length(unique(u))
collapse_note <- sprintf("Observations were first averaged to one pre and one post value per %s(%d units), which guards the significance test against repeated measurements of the same unit.",
unit_h, n_units)
}
cell_n <- table(factor(af$trt, levels = 0:1), factor(af$post, levels = 0:1))
for (ti in c("1", "0")) {
for (pi in c("0", "1")) {
if (cell_n[ti, pi] < 2) {
lvl_name <- if (ti == "1") trt_lvl else ctl_lvl
stop(sprintf("Group '%s' has only %d observation(s) in the %s period — at least 2 observations are needed in each of the four %s-by-%s cells.",
lvl_name, cell_n[ti, pi],
if (pi == "1") "post" else "pre",
group_h, period_h))
}
}
}
if (nrow(af) < 8) {
stop(sprintf("Only %d observations remained in the analysis frame — at least 8 are needed to estimate the difference-in-differences model.",
nrow(af)))
}Step 7: The 2x2 cell means and the OLS interaction estimate
cm <- tapply(af$y, list(factor(af$trt, levels = 0:1),
factor(af$post, levels = 0:1)), mean)
m_c_pre <- cm["0", "0"]; m_c_post <- cm["0", "1"]
m_t_pre <- cm["1", "0"]; m_t_post <- cm["1", "1"]
chg_t <- m_t_post - m_t_pre
chg_c <- m_c_post - m_c_pre
fit <- stats::lm(y ~ trt * post, data = af)
sm <- summary(fit)$coefficients
if (!("trt:post" %in% rownames(sm)) || is.na(coef(fit)["trt:post"])) {
stop("The difference-in-differences model could not be estimated — the group and period columns may be confounded(e.g. one group observed only pre, the other only post).")
}
did <- unname(coef(fit)["trt:post"])
did_se <- sm["trt:post", "Std. Error"]
did_p <- sm["trt:post", 4]
ci <- stats::confint(fit)["trt:post", ]
ci_low <- unname(ci[1]); ci_high <- unname(ci[2])Step 8: Effect size in outcome units and as % of the counterfactual
cf <- m_t_pre + chg_c
pct <- if (abs(cf) > 1e-8) 100 * did / abs(cf) else NA_real_
pct_txt <- if (is.na(pct)) "not defined(counterfactual level near zero)" else paste0(r2(pct), "%")Step 9: Parallel-trends check (needs a time axis + >= 2 pre periods)
pt_testable <- FALSE
pt_slope <- NA_real_; pt_p <- NA_real_
if (has_time) {
pre_i <- post01 == 0
if (length(unique(tv[pre_i])) >= 2 && length(unique(g[pre_i])) == 2) {
t_idx <- match(tv, sort(unique(tv)))
dpre <- data.frame(y = y[pre_i], t = t_idx[pre_i], trt = trt01[pre_i])
pfit <- tryCatch(stats::lm(y ~ t * trt, data = dpre), error = function(e) NULL)
if (!is.null(pfit)) {
pco <- summary(pfit)$coefficients
if ("t:trt" %in% rownames(pco) && !is.na(pco["t:trt", 1])) {
pt_slope <- pco["t:trt", 1]
pt_p <- pco["t:trt", 4]
pt_testable <- TRUE
}
}
}
}
if (!pt_testable) {
pt_short <- "not testable"
pt_verdict <- sprintf("With only one pre period per group in '%s', the parallel-trends assumption cannot be tested here — the causal reading rests on it untested.",
period_h)
} else if (pt_p >= 0.05) {
pt_short <- "consistent"
pt_verdict <- sprintf("Across the %d pre-change periods the two groups' trends do not detectably diverge (slope difference %s per period, %s) — consistent with, though not proof of, parallel trends.",
n_pre_periods, r3(pt_slope), fmt_pp(pt_p))
} else {
pt_short <- "divergent"
pt_verdict <- sprintf("The two groups were already trending apart before the change(slope difference %s per period, %s) — the difference-in-differences estimate is likely biased, and any causal conclusion should be treated with strong caution.",
r3(pt_slope), fmt_pp(pt_p))
}