Synthetic DiD time weights across replication studies

Default author implementation · exploratory community extension

Published

October 19, 2026

Scope

This exploratory exercise attempts the 396 available balanced cohort–outcome blocks from 13 papers, preserving the samples and windows frozen in the feasibility inventory. Seven papers have a block with at least five pre-treatment periods. Government audits are randomized and shown as a separate design stratum. School construction and Religion and Conflict retain their pending design/specification labels. Computational fits do not resolve those design assessments.

The fits use the authors’ synthdid package, version 0.0.9 at the linked commit. The call below passes only the outcome matrix and block dimensions. Penalties, intercepts, sparsification and stopping rules are the package defaults. Each staggered-treatment cohort is a separate simultaneous-adoption problem, using the existing control pool and full existing post window. The outputs do not replace the original collection’s estimates.

Code
# Numerical estimation and diagnostics are contained in this document.
# Input matrices are prepared data, not estimates; every render fits anew.
fits <- vector("list", nrow(manifest))
weight_rows <- vector("list", nrow(manifest))
metric_rows <- vector("list", nrow(manifest))
failures <- list()
cached_file <- NULL
for (j in seq_len(nrow(manifest))) {
  m <- manifest[j, ]
  if (!identical(cached_file, m$matrix_file)) {
    source_Y <- as.matrix(read.csv(file.path(params$inputs, m$matrix_file), header=FALSE))
    units <- read.csv(file.path(params$inputs, m$units_file))
    periods <- read.csv(file.path(params$inputs, m$periods_file))
    cached_file <- m$matrix_file
  }
  ctrl <- which(units$first_treated_column_zero_based == -1)
  tr <- which(units$first_treated_column_zero_based == m$cohort_column-1)
  Y <- source_Y[c(ctrl,tr), , drop=FALSE]
  N0 <- length(ctrl); N1 <- length(tr); T0 <- m$T0; T1 <- m$T1
  stopifnot(N0==m$N0, N1==m$N1, ncol(Y)==T0+T1, all(is.finite(Y)), T0>=2, N0>=2)
  tic <- proc.time()[[3]]
  fit <- tryCatch(synthdid::synthdid_estimate(Y, N0, T0), error=function(e)e)
  elapsed <- proc.time()[[3]]-tic
  if (inherits(fit,"error")) {
    failures[[length(failures)+1]] <- data.frame(m,error=conditionMessage(fit),
      donor_noise_sd=sd(as.numeric(apply(Y[seq_len(N0),seq_len(T0),drop=FALSE],1,diff))))
    write.csv(do.call(rbind,failures),"failures.csv",row.names=FALSE)
    next
  }
  saveRDS(fit, file.path(params$fit_archive, paste0(m$fit_id,".rds")))
  ww <- attr(fit,"weights"); opts <- attr(fit,"opts")
  lambda <- as.numeric(ww$lambda); omega <- as.numeric(ww$omega)
  stopifnot(length(lambda)==T0, length(omega)==N0, all(is.finite(lambda)),
    min(lambda)>=0, abs(sum(lambda)-1)<1e-10, min(omega)>=0, abs(sum(omega)-1)<1e-10)
  # Independent four-cell weighted DiD identity, separate from package assembly.
  pre <- seq_len(T0); post <- T0+seq_len(T1)
  treated <- N0+seq_len(N1)
  control_pre <- as.numeric(crossprod(omega,Y[seq_len(N0),pre,drop=FALSE]))
  control_post <- sum(omega*rowMeans(Y[seq_len(N0),post,drop=FALSE]))
  direct_att <- mean(Y[treated,post,drop=FALSE])-control_post-
    sum(lambda*(colMeans(Y[treated,pre,drop=FALSE])-control_pre))
  att_check <- abs(direct_att-as.numeric(fit))
  stopifnot(att_check < 1e-9*(1+abs(as.numeric(fit))))
  # Frank-Wolfe dual gap for the time-weight objective. The package's default
  # stopping rule is based on objective improvement, not this certificate.
  A <- Y[seq_len(N0),pre,drop=FALSE]
  b <- rowMeans(Y[seq_len(N0),post,drop=FALSE])
  A <- sweep(A,2,colMeans(A)); b <- b-mean(b)
  grad <- as.numeric(2*crossprod(A,A%*%lambda-b)/N0 + 2*opts$zeta.lambda^2*lambda)
  fw_gap <- max(0,sum(lambda*grad)-min(grad))
  sigma <- sd(as.numeric(apply(Y[seq_len(N0),pre,drop=FALSE],1,diff)))
  lvals <- ww$lambda.vals[is.finite(ww$lambda.vals)]
  ovals <- ww$omega.vals[is.finite(ww$omega.vals)]
  k_recent <- ceiling(.2*T0)
  shortest90 <- T0
  for (lo in seq_len(T0)) {
    ix <- which(cumsum(lambda[lo:T0])>=.9-1e-14)
    if (length(ix)) shortest90 <- min(shortest90,ix[1])
  }
  for (threshold in c(0,1e-8,1e-6,1e-4)) {
    active <- lambda>threshold
    ix <- which(active)
    runs <- sum(active & !c(FALSE,head(active,-1)))
    metric_rows[[j]] <- rbind(metric_rows[[j]],data.frame(
      fit_id=m$fit_id, threshold=threshold, support=sum(active), support_fraction=mean(active),
      support_runs=runs, support_span=if(length(ix))max(ix)-min(ix)+1 else 0))
  }
  fits[[j]] <- data.frame(m,
    estimate=as.numeric(fit), seconds=elapsed,
    exact_support=sum(lambda>0), effective_pre_periods=1/sum(lambda^2),
    recent_periods=k_recent, mass_last_20pct=sum(tail(lambda,k_recent)),
    uniform_mass_last_20pct=k_recent/T0,
    weighted_lag=sum(lambda*(T0+1-pre)),
    normalized_mean_position=sum(lambda*(pre-1)/(T0-1)),
    shortest_90pct_periods=shortest90, shortest_90pct_fraction=shortest90/T0,
    lambda_iterations=length(lvals), omega_iterations=length(ovals),
    lambda_iteration_cap=length(lvals)>=opts$max.iter,
    omega_iteration_cap=length(ovals)>=opts$max.iter,
    lambda_final_improvement=if(length(lvals)>=2)tail(lvals,2)[1]-tail(lvals,1) else NA,
    default_min_decrease=opts$min.decrease, zeta_lambda=opts$zeta.lambda,
    zeta_omega=opts$zeta.omega, lambda_fw_gap=fw_gap,
    lambda_fw_gap_noise_scaled=if(sigma>0)fw_gap/sigma^2 else NA,
    simplex_error=max(abs(sum(lambda)-1),abs(sum(omega)-1)), att_identity_error=att_check)
  weight_rows[[j]] <- data.frame(fit_id=m$fit_id,paper_id=m$paper_id,
    specification=m$specification,cohort_column=m$cohort_column,
    adoption_source_period=m$adoption_source_period,
    pre_period_index=pre,source_period=periods$source_period[pre],
    event_period=pre-T0-1,normalized_position=(pre-1)/(T0-1),
    lambda=lambda,exact_nonzero=lambda>0)
  if(j%%10==0 || j==nrow(manifest)) {
    write_json(list(completed=j,total=nrow(manifest),last_fit=m$fit_id),"progress.json",auto_unbox=TRUE)
    write.csv(do.call(rbind,fits[seq_len(j)]),"fits.csv",row.names=FALSE)
    write.csv(do.call(rbind,weight_rows[seq_len(j)]),"time-weights.csv",row.names=FALSE)
  }
}
fits <- do.call(rbind,fits)
weights <- do.call(rbind,weight_rows)
metrics <- do.call(rbind,metric_rows)
if(!length(failures)) write.csv(data.frame(fit_id=character(),error=character()),"failures.csv",row.names=FALSE)
write.csv(fits,"fits.csv",row.names=FALSE)
write.csv(weights,"time-weights.csv",row.names=FALSE)
write.csv(metrics,"support-thresholds.csv",row.names=FALSE)
write_json(list(package="synthdid",version=as.character(packageVersion("synthdid")),
  commit=upstream_commit,call="synthdid::synthdid_estimate(Y, N0, T0)",
  archive_sha256="9bf048eb12e5972a2290726136acaacbf6f13471072428354eeeed0298607766",
  R=R.version.string,session=capture.output(sessionInfo()),
  defaults=lapply(formals(synthdid::synthdid_estimate),function(x)paste(deparse(x),collapse=" "))),
  "implementation.json",auto_unbox=TRUE,pretty=TRUE)

Inspect individual fits

394 of 396 default calls returned weights across 13 papers. Two zero-noise cases failed (one Curriculum and Ideology outcome and one Italian opera outcome). Of the returned fits, 16 reached the time-weight iteration limit and 195 reached the unit-weight limit. These are recorded default outputs, not a claim of certified convergence. The two limits are flagged separately: without covariates, the time-weight optimization is independent of unit-weight optimization.

Choose a paper, specification and cohort to inspect the full time-weight vector. The support cutoff changes highlighting and the support count; it does not renormalize or refit the weights. Calendar labels are preserved where available; some source inputs encode time as an index. Lag is measured in observed periods.

All cohorts and outcomes in this paper

One row per fit, ordered by specification and cohort. Columns run from the oldest pre-period (left) to the last pre-period (right), scaled to each fit's pre-period length. Gray cells are zero or below the selected cutoff; blue intensity increases with weight on a square-root scale. Hover for exact values; click a row to select that fit.

Representative profiles

These examples were selected before fitting: the first sorted available specification in each paper, then its median eligible cohort, preferring at least five pre-periods when available. The explorer and downloads contain every fit, including alternative outcomes and shorter histories.

Cross-example summaries

The summary below gives one preselected example per paper. Mass near adoption uses the last ceiling(0.2 × T0) periods; its uniform-weight benchmark can exceed 20% for short histories. Effective pre-periods equal 1/sum(lambda^2). Exact support depends on the default optimization and sparsification settings.

Paper Pre periods Donors Nonzero Effective periods Recent mass Uniform benchmark
Religion and conflict 2 634 2 1.732 0.697 0.500
Collectivization 3 1031 2 1.276 0.877 0.333
Rebel on the Canal 176 455 154 62.390 0.249 0.205
ARRA and voting 3 399 2 1.445 0.810 0.333
Cap-and-trade 4 81 3 2.411 0.563 0.250
Medical implants 5 2462 3 2.220 0.577 0.200
School construction 2 7872 2 1.048 0.976 0.500
Curriculum and Ideology 2 6 2 1.447 0.191 0.500
Government audits (randomized) 8 3523 2 2.000 0.000 0.250
Italian opera 56 4 2 1.951 0.000 0.214
Birth of a Nation 45 495 15 8.401 0.329 0.200
Hospitals / Gift of a Lifetime 10 52 5 3.458 0.437 0.200
Credit Markets 20 194 4 1.928 0.734 0.200

These profiles describe this available-input sample. Several studies contribute many outcomes and cohorts; the 396 attempted fits are not independent studies. Treatment timing, donor pools and post horizons vary across fits, so a difference between two weight profiles need not be caused by pre-period length alone. In the no-covariate default problem, time weights depend on donor outcomes; treated outcomes affect unit weights and the ATT.

Checks and interpretation

Check Value
Completed default fits 394
Papers 13
Stored pre-period weights 7947
Maximum simplex error 5.762057e-14
Maximum weighted-DiD identity error 2.942091e-14
Time-weight iteration caps 16
Unit-weight iteration caps 195

Failed default calls are retained in the failure log. No weights are invented for these cases. In particular, constant donor pre-period changes can make the default noise scale zero and produce an undefined optimizer update.

Default stopping is based on objective improvement. The exports also record a Frank–Wolfe dual gap for the time-weight objective; returning a finite estimate does not establish high-precision optimality. Fits that hit iteration limits remain visibly flagged. No penalty or convergence setting was adjusted to obtain these results, and no bootstrap inference was run.

The package first runs a preliminary optimization, zeros weights no larger than one quarter of the largest preliminary weight, and resumes optimization. The final support can therefore depend on that algorithmic step. The explorer offers small-weight cutoffs for inspection; it does not replace a future comparison with unsparsified or tighter-tolerance fits.

Downloads and reproducibility

The document reads prepared matrices, unit/adoption metadata and period labels from params$inputs; it contains the complete numerical fit, diagnostic and plotting code and does not load saved estimates. Supply these data files and install the pinned synthdid source (plus mvtnorm, ggplot2, jsonlite and knitr) to render in a clean analysis directory. The repository’s preparation script scripts/prepare_sdid_time_weight_inputs.py reproduces the frozen inputs from the existing source archive and verifies them against the inventory. Full fitted R objects, including unit weights, are retained locally in the private analysis archive. Microdata are not included in these downloads.