---
title: "Synthetic DiD time weights across replication studies"
subtitle: "Default author implementation · exploratory community extension"
date: "2026-10-19"
format:
  html:
    embed-resources: true
    toc: true
    code-fold: true
    html-math-method: katex
execute:
  warning: false
  message: false
params:
  inputs: "../../analyses/sdid-time-weights-default/inputs"
  fit_archive: "../../analyses/sdid-time-weights-default/fits"
  r_library: "../../.Rlib"
---

```{r}
#| label: setup
#| include: false
.libPaths(c(params$r_library, .libPaths()))
library(synthdid)
library(ggplot2)
library(jsonlite)
library(knitr)
stopifnot(as.character(packageVersion("synthdid")) == "0.0.9")
upstream_commit <- "70c1ce3eac58e28c30b67435ca377bb48baa9b8a"
manifest <- read.csv(file.path(params$inputs, "manifest.csv"), stringsAsFactors=FALSE)
manifest$representative <- tolower(manifest$representative)=="true"
manifest$experimental <- tolower(manifest$experimental)=="true"
stopifnot(nrow(manifest)==396, length(unique(manifest$paper_id))==13)
dir.create(params$fit_archive, recursive=TRUE, showWarnings=FALSE)
short_names <- c(
  "aeraej-10"="Religion and conflict", "aeraej-104"="Collectivization",
  "aeraej-150"="Rebel on the Canal", "aeraej-34"="ARRA and voting",
  "aeraej-38"="Cap-and-trade", "aeraej-39"="Medical implants",
  "aeraej-390"="School construction", "study-01"="Curriculum and Ideology",
  "study-05"="Government audits (randomized)", "study-08"="Italian opera",
  "study-14"="Birth of a Nation", "study-16"="Hospitals / Gift of a Lifetime",
  "study-20"="Credit Markets")
```

## 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](../sdid-time-weights-2026-10-19/). 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](https://github.com/synth-inference/synthdid/tree/70c1ce3eac58e28c30b67435ca377bb48baa9b8a),
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.

```{r}
#| label: fit-defaults
#| results: hide
# 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

**`r nrow(fits)` of `r nrow(manifest)` default calls returned weights across
`r length(unique(fits$paper_id))` papers.** Two zero-noise cases failed (one
Curriculum and Ideology outcome and one Italian opera outcome). Of the returned
fits, `r sum(fits$lambda_iteration_cap)` reached the time-weight iteration limit
and `r sum(fits$omega_iteration_cap)` 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.

```{r}
#| label: explorer
#| echo: false
#| results: asis
payload <- lapply(seq_len(nrow(fits)),function(i){
  f <- fits[i,]; w <- weights[weights$fit_id==f$fit_id,]
  list(id=f$fit_id,paper_id=f$paper_id,paper=f$paper,spec=f$specification,
    target=f$source_target,outcome=f$outcome_label,cohort=f$adoption_source_period,
    T0=f$T0,T1=f$T1,N0=f$N0,N1=f$N1,representative=f$representative,
    lambda=w$lambda,period=w$source_period,cap=f$lambda_iteration_cap,
    omega_cap=f$omega_iteration_cap)
})
cat('<script type="application/json" id="weight-data">',
    gsub('</','<\\/',toJSON(payload,auto_unbox=TRUE,digits=16)), '</script>')
```

```{=html}
<style>
.sdid-controls {display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin:16px 0}
.sdid-controls label {font-size:14px;font-weight:600;display:block}
.sdid-controls label {min-width:0}
.sdid-controls select {display:block;width:100%;min-width:0;padding:8px;margin-top:5px;background:white;color:#17344c;border:1px solid #a7b6c1;border-radius:4px}
#sdid-chart {width:100%;height:auto;min-height:240px} #sdid-heat {width:100%;height:auto}
.sdid-note {font-size:14px;color:#44546a;line-height:1.5} #sdid-detail {font-variant-numeric:tabular-nums}
</style>
<div class="sdid-controls">
<label>Paper<select id="paper-select"></select></label>
<label>Specification<select id="spec-select"></select></label>
<label>Adoption cohort<select id="cohort-select"></select></label>
<label>Support cutoff<select id="threshold-select"><option value="0">Strictly positive</option><option value="1e-8">Above 10⁻⁸</option><option value="1e-6">Above 10⁻⁶</option><option value="1e-4">Above 10⁻⁴</option></select></label>
</div>
<p id="sdid-detail" aria-live="polite"></p>
<svg id="sdid-chart" viewBox="0 0 900 330" role="img" aria-label="Selected fit's pre-treatment time weights"></svg>
<p class="sdid-note" id="sdid-target"></p>
<h3>All cohorts and outcomes in this paper</h3>
<p class="sdid-note">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.</p>
<svg id="sdid-heat" role="img" aria-label="Within-paper heatmap of time weights"></svg>
<script>
(()=>{
const data=JSON.parse(document.getElementById('weight-data').textContent);
const paper=document.getElementById('paper-select'), spec=document.getElementById('spec-select'), cohort=document.getElementById('cohort-select'), cutoff=document.getElementById('threshold-select');
const ns='http://www.w3.org/2000/svg';
function node(tag,attrs,text){const x=document.createElementNS(ns,tag);Object.entries(attrs||{}).forEach(([k,v])=>x.setAttribute(k,v));if(text!==undefined)x.textContent=text;return x;}
function options(el,items){el.replaceChildren(...items.map(([v,t])=>{const o=document.createElement('option');o.value=v;o.textContent=t;return o;}));}
options(paper,[...new Map(data.map(d=>[d.paper_id,d.paper])).entries()]);
function specs(){const ds=data.filter(d=>d.paper_id===paper.value);options(spec,[...new Map(ds.map(d=>[d.spec,d.spec+' · '+d.target])).entries()]);const r=ds.find(d=>d.representative);if(r)spec.value=r.spec;cohorts(r?.id);}
function cohorts(preferred){const ds=data.filter(d=>d.paper_id===paper.value&&d.spec===spec.value);options(cohort,ds.map(d=>[d.id,String(d.cohort)+' · '+d.T0+' pre-periods']));if(preferred&&ds.some(d=>d.id===preferred))cohort.value=preferred;draw();}
function draw(){const d=data.find(x=>x.id===cohort.value), l=d.lambda, t=+cutoff.value;const svg=document.getElementById('sdid-chart');svg.replaceChildren();
const viewWidth=Math.max(320,svg.clientWidth);svg.setAttribute('viewBox',`0 0 ${viewWidth} 330`);
const left=48,top=24,width=viewWidth-63,height=238,max=Math.max(...l)*1.06,step=width/l.length;
for(let k=0;k<=4;k++){const val=max*k/4,y=top+height-height*k/4;svg.append(node('line',{x1:left,x2:left+width,y1:y,y2:y,stroke:'#dbe3e8'}));svg.append(node('text',{x:left-7,y:y+4,'text-anchor':'end','font-size':12,fill:'#475569'},val.toFixed(2)));}
l.forEach((w,i)=>{const bar=node('rect',{x:left+i*step+step*.08,y:top+height*(1-w/max),width:Math.max(1,step*.84),height:Math.max(w>0?1:0,height*w/max),fill:w>t?'#176b9b':'#cbd5e1'});bar.append(node('title',{},`Source period ${d.period[i]}; lag ${l.length-i}; weight ${w.toPrecision(8)}`));svg.append(bar);});
const tickIndices=[...new Set([0,Math.floor((l.length-1)/2),l.length-1])];tickIndices.forEach(i=>svg.append(node('text',{x:left+(i+.5)*step,y:top+height+23,'text-anchor':'middle','font-size':13},String(d.period[i]))));
svg.append(node('text',{x:left+width/2,y:317,'text-anchor':'middle','font-size':14},'Pre-treatment source period'));
const k=Math.ceil(.2*l.length),mass=l.slice(-k).reduce((a,b)=>a+b,0),eff=1/l.reduce((a,b)=>a+b*b,0);
document.getElementById('sdid-detail').textContent=`${l.filter(w=>w>t).length}/${d.T0} periods above cutoff · effective periods ${eff.toFixed(2)} · last ${k} pre-period(s): ${(mass*100).toFixed(1)}% of weight · ${d.N0} donors, ${d.N1} treated, ${d.T1} post-periods${d.cap?' · time-weight iteration limit reached':''}${d.omega_cap?' · unit-weight iteration limit reached':''}`;
document.getElementById('sdid-target').textContent=d.target+' — '+d.outcome;
const ds=data.filter(x=>x.paper_id===paper.value), heat=document.getElementById('sdid-heat'),rh=Math.max(3,Math.min(24,480/ds.length)),h=ds.length*rh+40;heat.setAttribute('viewBox',`0 0 ${viewWidth} ${h}`);heat.replaceChildren();
ds.forEach((f,j)=>{f.lambda.forEach((w,i)=>{const v=Math.sqrt(w),rgb=w>t?`rgb(${Math.round(230-207*v)},${Math.round(240-133*v)},${Math.round(247-92*v)})`:'#f1f3f5';const r=node('rect',{x:left+i*width/f.T0,y:10+j*rh,width:width/f.T0+.1,height:rh,fill:rgb});r.append(node('title',{},`${f.id}; source period ${f.period[i]}; lambda=${w.toPrecision(8)}`));r.addEventListener('click',()=>{spec.value=f.spec;cohorts(f.id);});heat.append(r);});if(f.id===d.id)heat.append(node('rect',{x:left,y:10+j*rh,width,height:rh,fill:'none',stroke:'#111827','stroke-width':1.2,'pointer-events':'none'}));});
heat.append(node('text',{x:left,y:h-4,'font-size':12},'Oldest pre-period'));heat.append(node('text',{x:left+width,y:h-4,'font-size':12,'text-anchor':'end'},'Last pre-period'));
}
paper.addEventListener('change',specs);spec.addEventListener('change',()=>cohorts());cohort.addEventListener('change',draw);cutoff.addEventListener('change',draw);window.addEventListener('resize',draw);specs();
})();
</script>
```

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

```{r}
#| label: representative-profiles
#| echo: false
#| fig-width: 12
#| fig-height: 13
representatives <- fits[fits$representative,]
wp <- merge(weights,representatives[,c("fit_id","T0","T1","N0")],by="fit_id")
wp$paper_label <- paste0(short_names[wp$paper_id],"\nT0=",wp$T0,"; N0=",wp$N0)
profile_plot <- ggplot(wp,aes(x=event_period,y=lambda))+
  geom_col(fill="#176b9b",width=.9)+
  facet_wrap(~paper_label,scales="free_x",ncol=3)+
  labs(x="Observed periods before adoption (last pre-period = -1)",y="Default SDID time weight")+
  theme_minimal(base_size=11)+theme(panel.grid.minor=element_blank())
print(profile_plot)
ggsave("representative-profiles.pdf",profile_plot,width=12,height=13)
ggsave("representative-profiles.png",profile_plot,width=12,height=13,dpi=150)
```

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

```{r}
#| label: paper-summary
#| echo: false
paper_summary <- data.frame(paper_id=representatives$paper_id,
  paper=unname(short_names[representatives$paper_id]),fit_id=representatives$fit_id,
  T0=representatives$T0,N0=representatives$N0,
  nonzero=representatives$exact_support,
  effective_periods=representatives$effective_pre_periods,
  mass_recent=representatives$mass_last_20pct,
  uniform_recent=representatives$uniform_mass_last_20pct,
  normalized_mean_position=representatives$normalized_mean_position)
write.csv(paper_summary,"paper-summary.csv",row.names=FALSE)
shown <- paper_summary[,c("paper","T0","N0","nonzero","effective_periods","mass_recent","uniform_recent")]
names(shown)<-c("Paper","Pre periods","Donors","Nonzero","Effective periods","Recent mass","Uniform benchmark")
kable(shown,digits=3)
```

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

```{r}
#| label: verification
#| echo: false
verification <- list(attempted=nrow(manifest),fits=nrow(fits),failures=length(failures),papers=length(unique(fits$paper_id)),
  weight_rows=nrow(weights),all_finite=all(is.finite(weights$lambda)),
  nonnegative=all(weights$lambda>=0),max_simplex_error=max(fits$simplex_error),
  max_att_identity_error=max(fits$att_identity_error),
  lambda_iteration_caps=sum(fits$lambda_iteration_cap),
  omega_iteration_caps=sum(fits$omega_iteration_cap),
  estimates_promoted_to_main_collection=FALSE,bootstrap_runs=0)
write_json(verification,"verification.json",auto_unbox=TRUE,pretty=TRUE)
kable(data.frame(Check=c("Completed default fits","Papers","Stored pre-period weights",
  "Maximum simplex error","Maximum weighted-DiD identity error",
  "Time-weight iteration caps","Unit-weight iteration caps"),
  Value=c(verification$fits,verification$papers,verification$weight_rows,
    format(verification$max_simplex_error,scientific=TRUE),
    format(verification$max_att_identity_error,scientific=TRUE),
    verification$lambda_iteration_caps,verification$omega_iteration_caps)))
```

Failed default calls are retained in the [failure log](failures.csv). 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

- [All time weights, including exact zeros (CSV)](time-weights.csv)
- [Fit dimensions, estimates and diagnostics (CSV)](fits.csv)
- [Support counts at four thresholds (CSV)](support-thresholds.csv)
- [Representative-paper summary (CSV)](paper-summary.csv)
- [Representative profiles (PDF)](representative-profiles.pdf)
- [Frozen fit roster (CSV)](manifest.csv)
- [Input provenance (JSON)](input-provenance.json)
- [Implementation and defaults (JSON)](implementation.json)
- [Validation totals (JSON)](verification.json)
- [Default-call failures (CSV)](failures.csv)
- [Executable Quarto document](analysis.qmd)

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.
