Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
- [DifferenceInDifferences](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - basic 2x2 DiD with robust/cluster-robust SEs, wild bootstrap, formula interface, and fixed effects
- [TwoWayFixedEffects](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - panel data DiD with unit and time fixed effects via within-transformation or dummies; event-study mode (`event_study=True`) for period-specific treatment effects
- [CallawaySantAnna](https://diff-diff.readthedocs.io/en/stable/api/staggered.html) - Callaway & Sant'Anna (2021) group-time ATT estimator for staggered adoption
- [ChainedDiD](https://diff-diff.readthedocs.io/en/stable/api/chained_did.html) - Bellégo, Benatia & Dortet-Bernadet (2025) chained IPW for overlapping incomplete panels; experimental, identity GMM and PT-post
- [ChaisemartinDHaultfoeuille](https://diff-diff.readthedocs.io/en/stable/api/chaisemartin_dhaultfoeuille.html) - de Chaisemartin & D'Haultfœuille (2020/2022) for **reversible (non-absorbing) treatments** with multi-horizon event study, normalized effects, cost-benefit delta, sup-t bands, and dynamic placebos. The most general option for treatments that switch on AND off (see also `LPDiD`/`TROP` `non_absorbing`). Alias `DCDH`.
- [SunAbraham](https://diff-diff.readthedocs.io/en/stable/api/staggered.html) - Sun & Abraham (2021) interaction-weighted estimator for heterogeneity-robust event studies
- [ImputationDiD](https://diff-diff.readthedocs.io/en/stable/api/imputation.html) - Borusyak, Jaravel & Spiess (2024) imputation estimator, most efficient under homogeneous effects
Expand Down
66 changes: 66 additions & 0 deletions benchmarks/R/benchmark_chained_did.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env Rscript
# External reference only; refuses unpinned environments and never installs packages.
# Usage: Rscript benchmarks/R/benchmark_chained_did.R input.csv output.json
# Input columns: id, t, g, y and optionally static x / cluster.

args <- commandArgs(trailingOnly = TRUE)
if (length(args) != 2L) stop("Expected input.csv and output.json")
if (getRversion() != "4.5.2") stop("Reference requires R 4.5.2")
versions <- c(cdid = "1.1.0.0", did = "2.5.1", DRDID = "1.3.0")
for (package in names(versions)) {
if (!requireNamespace(package, quietly = TRUE)) stop(paste("Missing reference package", package))
if (as.character(packageVersion(package)) != versions[[package]]) {
stop(paste("Unmatched reference version for", package))
}
}
if (!requireNamespace("jsonlite", quietly = TRUE)) stop("Missing jsonlite")
sha <- packageDescription("cdid")[["RemoteSha"]]
if (is.null(sha) || sha != "1039120668cceabd8111cad82d32ab3927c2b94f") {
stop("cdid must record the pinned GitHub RemoteSha")
}
data <- read.csv(args[[1L]], na.strings = c("", "NA"))
stopifnot(all(c("id", "t", "g", "y") %in% names(data)))
data <- data[order(data$id, data$t), ]
frame_columns <- intersect(c("id", "g", "x", "cluster"), names(data))
frame <- data[!duplicated(data$id), frame_columns, drop = FALSE]
fit <- cdid::att_gt(
yname = "y", tname = "t", idname = "id", gname = "g", data = data, frame = frame,
xformla = if ("x" %in% names(data)) ~ x else ~ 1,
control_group = "nevertreated", anticipation = 0, est_method = "ipw",
base_period = "universal", mode = "chained", selection = "none",
gmm_weighting = "identity", pt_assumption = "post",
bstrap = FALSE, cband = FALSE,
clustervars = if ("cluster" %in% names(data)) "cluster" else NULL
)
simple <- cdid::aggte(fit, type = "simple", bstrap = FALSE, cband = FALSE)
dynamic <- cdid::aggte(fit, type = "dynamic", bstrap = FALSE, cband = FALSE)
event_if <- dynamic$inf.function$dynamic.inf.func.e
stopifnot(is.matrix(event_if), nrow(event_if) == fit$n)
if ("cluster" %in% names(data)) {
positions <- match(rownames(event_if), as.character(frame$id))
stopifnot(!anyNA(positions))
event_if <- rowsum(event_if, frame$cluster[positions], reorder = FALSE)
}
event_vcov <- crossprod(event_if) / fit$n^2
finite_se <- is.finite(dynamic$se.egt)
stopifnot(all(abs(sqrt(diag(event_vcov))[finite_se] - dynamic$se.egt[finite_se]) < 1e-8))
calendar <- sort(unique(data$t))
groups <- sort(unique(data$g[data$g > 0]))
references <- calendar[match(groups, calendar) - 1L]
event_reference <- vapply(dynamic$egt, function(e) {
included <- (fit$t - fit$group) == e
any(included) && all(fit$t[included] == references[match(fit$group[included], groups)])
}, logical(1))
answer <- list(
metadata = list(R = as.character(getRversion()), packages = as.list(versions),
cdid_sha = sha, n = fit$n, covariance_scale = "estimator",
cluster_correction = "none"),
cells = data.frame(group = fit$group, time = fit$t, att = fit$att,
available = is.finite(fit$att)),
vcov = fit$V_analytical / fit$n,
overall = list(att = simple$overall.att, se = simple$overall.se),
events = list(event_time = dynamic$egt, att = dynamic$att.egt, se = dynamic$se.egt,
is_reference = event_reference, vcov = event_vcov)
)
jsonlite::write_json(answer, args[[2L]], auto_unbox = TRUE, pretty = TRUE,
na = "null", digits = 17)
45 changes: 45 additions & 0 deletions benchmarks/R/inspect_chained_did_weights.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env Rscript
# Inspect public estimator-callback inputs for the first pair; never estimates ATT.
# Usage: Rscript benchmarks/R/inspect_chained_did_weights.R input.csv output.json
args <- commandArgs(trailingOnly = TRUE)
stopifnot(length(args) == 2L, getRversion() == "4.5.2",
as.character(packageVersion("cdid")) == "1.1.0.0",
packageDescription("cdid")[["RemoteSha"]] == "1039120668cceabd8111cad82d32ab3927c2b94f")
data <- read.csv(args[[1L]], na.strings = c("", "NA"))
stopifnot(all(c("id", "t", "g", "y", "x") %in% names(data)),
identical(sort(unique(data$t)), 0:3),
identical(sort(unique(data$g)), c(0L, 2L)))
observed_counts <- tapply(is.finite(data$y), data$id, sum)
frame <- data[!duplicated(data$id), c("id", "g", "x")]
eligible <- frame[observed_counts[as.character(frame$id)] >= 2, ]
eligible_counts <- table(factor(eligible$g > 0, levels = c(FALSE, TRUE)))
pair <- data[data$t %in% 0:1 & is.finite(data$y), ]
pair_ids <- names(which(table(pair$id) == 2L))
pair_counts <- table(factor(frame$g[as.character(frame$id) %in% pair_ids] > 0,
levels = c(FALSE, TRUE)))
expected <- as.numeric(eligible_counts / pair_counts)
stopifnot(all(pair_counts > 0), all(is.finite(expected)))
inspected <- FALSE
probe <- function(y1, y0, D, covariates, i.weights, inffunc = TRUE, ...) {
counts <- table(factor(D, levels = 0:1))
stopifnot(identical(as.integer(counts), as.integer(pair_counts)),
length(i.weights) == length(D),
all(abs(i.weights - expected[D + 1L]) < 1e-12))
jsonlite::write_json(list(
status = "public_inputs_verified_no_estimate", pair = c(0L, 1L),
cohorts = c(0L, 2L), eligible_counts = as.integer(eligible_counts),
pair_counts = as.integer(counts), weights = expected,
max_abs_weight_error = max(abs(i.weights - expected[D + 1L]))
), args[[2L]], auto_unbox = TRUE, pretty = TRUE, digits = 17)
inspected <<- TRUE
stop("PUBLIC_INPUT_INSPECTION_ONLY", call. = FALSE)
}
tryCatch(cdid::att_gt(
yname = "y", tname = "t", idname = "id", gname = "g", data = data, frame = frame,
xformla = ~ x, est_method = probe, control_group = "nevertreated",
base_period = "universal", mode = "chained", selection = "none",
gmm_weighting = "identity", pt_assumption = "post", bstrap = FALSE, cband = FALSE
), error = function(e) {
if (!identical(conditionMessage(e), "PUBLIC_INPUT_INSPECTION_ONLY")) stop(e)
})
stopifnot(inspected)
Loading