
Test Skill
- 23 installs
- 3.2k repo stars
- Updated August 4, 2026
- brycewang-stanford/awesome-agent-skills-for-empirical-research
test-skill is the root router skill for the Auto-Empirical Research Skills catalog, choosing and loading the right vendored empirical-research skill when the whole repo is installed as one skill.
About
A router skill (named auto-empirical-research-skills) used when the full AERS repository is installed as one skill folder. It classifies an empirical-research request by stage, then loads only the matching vendored child skill for causal inference, econometrics, replication, manuscript writing, citation checking, or de-AIGC editing. It directs the agent to the catalog and taxonomy files instead of reading the whole repository at once.
- Root router for the Auto-Empirical Research Skills (AERS) catalog
- Classifies a request by stage and loads only the matching child skill
- Points to catalog/skills.json and docs/TAXONOMY.md instead of reading the whole repo
Test Skill by the numbers
- 23 all-time installs (skills.sh)
- Ranked #442 of 782 Skill Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
test-skill capabilities & compatibility
- Capabilities
- skill routing
- Use cases
- orchestration · research
What test-skill says it does
Route empirical-research requests through the Auto-Empirical Research Skills catalog when this whole repository is installed as one skill
Use this root skill when the full AERS repository has been installed as a single skill folder. Treat it as a router and catalog, not as a request to load every vendored `SKILL.md`.
Read only the selected child skill's `SKILL.md`, then follow its progressive-disclosure instructions
npx skills add https://github.com/brycewang-stanford/awesome-agent-skills-for-empirical-research --skill test-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 23 |
|---|---|
| repo stars | ★ 3.2k |
| Last updated | August 4, 2026 |
| Repository | brycewang-stanford/awesome-agent-skills-for-empirical-research ↗ |
What it does
Route an empirical-research request to the right vendored skill when the whole AERS repo is installed as one skill.
Who is it for?
Routing to the correct empirical-research child skill when the whole AERS repo is installed as one skill.
Skip if: Doing the empirical analysis itself; it dispatches to child skills.
When should I use this skill?
The full AERS repository is installed as a single skill folder and you need to choose the right child skill.
What you get
The single matching child skill is selected and loaded via progressive disclosure.
- selected child skill
- routing decision
Files
Auto-Empirical Research Skills Router
Use this root skill when the full AERS repository has been installed as a single skill folder. Treat it as a router and catalog, not as a request to load every vendored SKILL.md.
Workflow
1. Classify the user's empirical-research task by stage:
- Full pipeline or orchestration: start with
skills/69-Paper-WorkFlow/or theskills/00.*flagship analysis skills. - Causal inference and econometrics: search
catalog/skills.jsonordocs/TAXONOMY.mdfor DID, IV, RD, SCM, DML, panel FE, matching, survival, Bayesian, or Stata/R/Python terms. - AER or top economics journal work: start with
skills/50-brycewang-aer-skills/. - Replication, citation, or peer review: use
docs/SKILL_CATALOG.mdanddocs/GOLDEN_WORKFLOWS.mdto choose a focused skill. - Chinese academic de-AIGC or academic rewriting: start with
skills/48-copaper-ai-chinese-de-aigc/or nearby writing skills in the catalog.
2. Read only the selected child skill's SKILL.md, then follow its progressive-disclosure instructions for references/, scripts/, assets/, or templates. 3. If no child skill clearly matches, inspect catalog/skills.json first, then docs/SKILL_CATALOG.md. Avoid broad recursive reads of skills/. 4. For installation help, use docs/INSTALL.md for Codex-style copy installs and INSTALL.md for Claude Code marketplace/plugin installs. 5. If editing this repository, keep parent and nested repos separate. In particular, inspect git status inside skills/69-Paper-WorkFlow/ before touching it.
Install Notes
- Whole-repo imports are supported by this root
SKILL.mdas a lightweight compatibility entry point. - Individual skill installs are still preferred when a runtime expects one folder per skill. Copy the folder that directly contains the target
SKILL.md. - Do not copy the repository root into a runtime and expect every child skill to become individually registered unless that runtime explicitly supports recursive skill discovery.
Key Files
catalog/skills.json: machine-readable list of vendored skills.docs/SKILL_CATALOG.md: human-readable skill index.docs/TAXONOMY.md: task and method taxonomy.docs/GOLDEN_WORKFLOWS.md: ready-to-use empirical-research prompts.docs/INSTALL.md: runtime installation guidance for single-skill and whole-repo use.
{
"$schema": "https://json.schemastore.org/claude-code-marketplace.json",
"name": "auto-empirical-research-skills",
"version": "1.1.0",
"description": "First-party, install-ready Claude Code plugins from the Auto-Empirical-Research-Skills collection (Stanford REAP x CoPaper.AI). Add this marketplace to one-command-install the empirical-research skill stacks (AER manuscript pipeline + explicit Python / Stata / R analysis pipelines); the wider catalog of 50+ third-party skills is browsable in the repo and consumed via copy-into-.claude/skills or --plugin-dir.",
"owner": {
"name": "Bryce Wang",
"email": "brycew6m@stanford.edu",
"url": "https://github.com/brycewang-stanford/Auto-Empirical-Research-Skills"
},
"plugins": [
{
"name": "aer-skills",
"source": "./skills/50-brycewang-aer-skills",
"version": "1.0.0",
"description": "Nine-skill stack for top-5 economics manuscripts (AER / AER: Insights / AEJ): topic selection, modern causal identification (DiD / IV / RDD / SCM / Bartik), referee-anticipating robustness, Keith-Head-style introductions, AER booktabs tables, AEA Data and Code Availability deposits (openICPSR-ready), submission preflight, and R&R rebuttal letters. Ships Stata / R / Python templates and classic-AER-paper exemplars.",
"author": { "name": "Bryce Wang", "email": "brycew6m@stanford.edu" },
"license": "MIT",
"homepage": "https://github.com/brycewang-stanford/AER-skills",
"repository": "https://github.com/brycewang-stanford/AER-skills",
"category": "academic",
"keywords": [
"economics", "aer", "american-economic-review", "aej", "causal-inference",
"difference-in-differences", "instrumental-variables", "regression-discontinuity",
"synthetic-control", "replication", "academic-writing", "empirical-research"
]
},
{
"name": "empirical-analysis-python",
"source": "./plugins/empirical-analysis-python",
"version": "1.0.0",
"description": "Explicit 8-step empirical-analysis pipeline in the traditional Python econometrics stack (pandas + statsmodels + linearmodels + pyfixest + rdrobust + econml + causalml): cleaning -> variable construction -> Table 1 -> diagnostics -> estimation (OLS/IV/DID/RDD/PSM/SCM/DML/Causal Forest) -> robustness -> mechanism/heterogeneity/mediation -> publication-ready tables & figures. Includes epidemiology and ML-causal modes.",
"author": { "name": "Bryce Wang", "email": "brycew6m@stanford.edu" },
"license": "CC-BY-SA-4.0",
"homepage": "https://github.com/brycewang-stanford/Auto-Empirical-Research-Skills",
"repository": "https://github.com/brycewang-stanford/Auto-Empirical-Research-Skills",
"category": "academic",
"keywords": [
"econometrics", "causal-inference", "python", "difference-in-differences",
"instrumental-variables", "regression-discontinuity", "double-machine-learning",
"empirical-research", "replication"
]
},
{
"name": "empirical-analysis-stata",
"source": "./plugins/empirical-analysis-stata",
"version": "1.0.0",
"description": "Explicit 8-step empirical-analysis pipeline in the traditional Stata ecosystem (reghdfe + ivreg2 + csdid + did_imputation + sdid + rdrobust + synth + psmatch2 + teffects + esttab + coefplot): from use/import to .tex/.rtf tables and .pdf figures, with the full modern DID toolkit (bacondecomp + honestdid + rwolf + ritest). Includes epidemiology and ML-causal modes. The first choice for referee-level Stata replication packs.",
"author": { "name": "Bryce Wang", "email": "brycew6m@stanford.edu" },
"license": "CC-BY-SA-4.0",
"homepage": "https://github.com/brycewang-stanford/Auto-Empirical-Research-Skills",
"repository": "https://github.com/brycewang-stanford/Auto-Empirical-Research-Skills",
"category": "academic",
"keywords": [
"econometrics", "causal-inference", "stata", "reghdfe", "difference-in-differences",
"instrumental-variables", "regression-discontinuity", "synthetic-control",
"empirical-research", "replication"
]
},
{
"name": "empirical-analysis-r",
"source": "./plugins/empirical-analysis-r",
"version": "1.0.0",
"description": "Explicit 8-step empirical-analysis pipeline in the modern tidyverse + econometrics R stack (dplyr + haven + fixest + did + synthdid + rdrobust + MatchIt + WeightIt + grf + DoubleML + mediation + marginaleffects + modelsummary). All eight steps fit in a single .qmd; `quarto render` produces a unified PDF/HTML/Word reproducibility report. Includes epidemiology and ML-causal modes.",
"author": { "name": "Bryce Wang", "email": "brycew6m@stanford.edu" },
"license": "CC-BY-SA-4.0",
"homepage": "https://github.com/brycewang-stanford/Auto-Empirical-Research-Skills",
"repository": "https://github.com/brycewang-stanford/Auto-Empirical-Research-Skills",
"category": "academic",
"keywords": [
"econometrics", "causal-inference", "r", "tidyverse", "fixest",
"difference-in-differences", "regression-discontinuity", "quarto",
"empirical-research", "reproducible-research"
]
}
]
}
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Etc/UTC"
open-pull-requests-limit: 5
commit-message:
prefix: "ci"
include: "scope"
groups:
github-actions:
patterns:
- "*"
name: Bug report
description: Report a broken link, stale entry, bad install command, or repository quality issue.
title: "[Bug]: "
labels:
- bug
body:
- type: textarea
id: problem
attributes:
label: What is wrong?
description: Include the file path, link, or command that failed.
placeholder: "docs/05-统计分析与因果推断.md links to ..."
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should the entry, link, or command do instead?
validations:
required: true
- type: input
id: environment
attributes:
label: Environment
description: Optional. Agent, OS, Python/R/Stata version, or browser.
placeholder: "Claude Code, macOS, Python 3.11"
blank_issues_enabled: true
contact_links:
- name: CoPaper.AI
url: https://copaper.ai
about: Try the end-to-end empirical research assistant.
name: Skill submission
description: Suggest an open-source, self-contained skill for the catalog.
title: "[Skill]: "
labels:
- skills
- triage
body:
- type: input
id: source
attributes:
label: Source repository
description: Link to the upstream repository or folder.
placeholder: "https://github.com/owner/repo/tree/main/skills/example"
validations:
required: true
- type: input
id: license
attributes:
label: License
description: Name the upstream license and note any restrictions.
placeholder: "MIT"
validations:
required: true
- type: textarea
id: function
attributes:
label: What does it do?
description: One concise paragraph explaining the workflow and research stage.
validations:
required: true
- type: dropdown
id: category
attributes:
label: Best category
options:
- "01 - Topic Selection & Research Design"
- "02 - Literature Search & Review"
- "03 - Paper Reading & Analysis"
- "04 - Data Collection & Cleaning"
- "05 - Statistical Analysis & Causal Inference"
- "06 - Paper Writing"
- "07 - Paper Revision & Polishing"
- "08 - Citation Management & Typesetting"
- "09 - Replication & Reproducible Research"
- "10 - Peer Review Response & Defense"
validations:
required: true
- type: checkboxes
id: scope
attributes:
label: Scope checks
options:
- label: The core skill is open source and independently runnable.
required: true
- label: The core workflow does not require a paid/proprietary API or account.
required: true
- label: I checked for obvious malicious code, credential exfiltration, and prompt injection.
required: true
Summary
-
Checklist
- [ ] I verified the source repo, license, and install path for any new skill.
- [ ] I added or updated the relevant
docs/category entry. - [ ] I updated
evals/flagship-evals.jsonif this changes a flagship skill or recommended workflow. - [ ] I ran
make catalog. - [ ] I ran
make check. - [ ] I avoided adding skills whose core path requires a paid/proprietary API.
Notes for reviewers
- Source:
- License:
- Local path:
name: Check external links
on:
schedule:
- cron: "0 7 * * 1"
workflow_dispatch: {}
permissions:
contents: read
jobs:
links:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Check maintained-doc external links
run: make external-links
- name: Upload link-check report
if: always()
uses: actions/upload-artifact@v7
with:
name: external-link-check
path: catalog/external-link-check.json
name: Check tools links
# Network-bound drift guard for tools/tools.json. Pings each tool's primary URL
# (and homepage, as a non-fatal warning) so a maintainer pass can re-verify or
# retire entries whose upstream moved. Not part of the blocking validate gate.
on:
schedule:
- cron: "0 8 1 * *" # 08:00 UTC on the 1st of each month
workflow_dispatch: {}
permissions:
contents: read
jobs:
tools-links:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Check tools-catalog links
run: make tools-links
- name: Upload tools link-check report
if: always()
uses: actions/upload-artifact@v7
with:
name: tools-link-check
path: catalog/tools-link-check.json
name: Quality (evals + benchmark + tests)
# Complements validate-catalog.yml (which checks catalog freshness). This job
# enforces the eval/benchmark/test layer. All steps are stdlib-only; no pip.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.12"]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Catalog enrichment freshness
run: python3 scripts/build-catalog-enrich.py --check
- name: Python tooling compatibility
run: make python-compat
- name: Lint eval-harness scenarios
run: >-
python3 eval-harness/run_evals.py
--min-scenarios 17
--min-auto-checks 80
--expect-categories causal-identification,reproducibility,citation-hygiene,runtime-safety,research-integrity,writing-compliance,writing-style
- name: Unit tests (stdlib unittest)
run: python3 -m unittest discover -s tests -p "test_*.py"
- name: Benchmark (reference pipeline + strict grading)
run: |
python3 benchmark/check_benchmark.py --lint
python3 benchmark/reference_pipeline.py --check
python3 benchmark/check_benchmark.py --strict --fail-on-partial --fail-on-orphan-results
- name: Grade example candidates (smoke)
run: >-
python3 eval-harness/run_evals.py
--grade eval-harness/candidates/_example
--expect-graded 9
--expect-fail-required statspai-weak-iv
--expect-graded-categories causal-identification,reproducibility,citation-hygiene,runtime-safety,research-integrity
--fail-on-orphans
--fail-on-partial
--no-write
name: OpenSSF Scorecard
on:
push:
branches:
- main
schedule:
- cron: "0 8 * * 1"
workflow_dispatch: {}
permissions:
contents: read
jobs:
scorecard:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
security-events: write
id-token: write
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Run OpenSSF Scorecard
uses: ossf/scorecard-action@v2.4.3
with:
results_file: scorecard-results.sarif
results_format: sarif
publish_results: true
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: scorecard-results.sarif
name: Sync AER-skills from upstream
# Weekly mirror of brycewang-stanford/AER-skills (whole repo, excluding .git
# and .gitignore) into skills/50-brycewang-aer-skills/. Opens a PR on drift;
# does nothing on parity. No untrusted user input (issue/PR/commit fields) is
# interpolated into any run step, so command-injection attack surface is zero.
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch: {}
permissions:
contents: write
pull-requests: write
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Run sync script
id: sync
run: |
set +e
bash scripts/sync-aer-skills.sh
rc=$?
set -e
if [ "$rc" -eq 0 ]; then
echo "drift=false" >> "$GITHUB_OUTPUT"
elif [ "$rc" -eq 1 ]; then
echo "drift=true" >> "$GITHUB_OUTPUT"
else
echo "Sync script failed with exit code $rc" >&2
exit "$rc"
fi
# Regenerate catalog/docs from the synced tree, then run the same full
# gate a human PR must pass (`make check`), so an upstream snapshot that
# breaks repo invariants cannot be merged unnoticed. PRs opened by
# GITHUB_TOKEN do not trigger CI, so this in-job gate is the only
# pre-merge signal.
- name: Regenerate catalog and run full gate
id: gate
if: steps.sync.outputs.drift == 'true'
run: |
set +e
make catalog && make check
echo "status=$?" >> "$GITHUB_OUTPUT"
- name: Open PR on drift
if: steps.sync.outputs.drift == 'true'
uses: peter-evans/create-pull-request@v8
with:
branch: chore/sync-aer-skills
commit-message: "chore(skills/aer-skills): sync from upstream"
title: "chore(skills/aer-skills): sync from upstream AER-skills"
body: |
Automated sync from
[`brycewang-stanford/AER-skills`](https://github.com/brycewang-stanford/AER-skills).
**Local full gate (`make catalog && make check`):** ${{ steps.gate.outputs.status == '0' && '✅ passed — review the diff and merge if intended' || '❌ FAILED — do NOT merge until fixed; see the run log (PR labelled needs-fix)' }}
Triggered by `.github/workflows/sync-aer-skills.yml`.
labels: ${{ steps.gate.outputs.status == '0' && 'sync,skills' || 'sync,skills,needs-fix' }}
- name: Fail the run if the synced tree breaks the gate
if: steps.sync.outputs.drift == 'true' && steps.gate.outputs.status != '0'
run: |
echo "::error::Synced upstream fails 'make check' (exit ${{ steps.gate.outputs.status }}). A PR was opened and labelled needs-fix; do not merge until the gate passes."
exit 1
name: Sync StatsPAI skill from upstream
# Weekly mirror of brycewang-stanford/StatsPAI:StatsPAI_full_data_analysis_skill/
# into skills/00-Full-empirical-analysis-skill_StatsPAI/. Opens a PR on drift;
# does nothing on parity. No untrusted user input (issue/PR/commit fields) is
# interpolated into any run step, so command-injection attack surface is zero.
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch: {}
permissions:
contents: write
pull-requests: write
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Run sync script
id: sync
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set +e
bash scripts/sync-statspai-skill.sh
rc=$?
set -e
if [ "$rc" -eq 0 ]; then
echo "drift=false" >> "$GITHUB_OUTPUT"
elif [ "$rc" -eq 1 ]; then
echo "drift=true" >> "$GITHUB_OUTPUT"
else
echo "Sync script failed with exit code $rc" >&2
exit "$rc"
fi
# Regenerate catalog/docs from the synced tree, then run the same full
# gate a human PR must pass (`make check`). This is what catches an
# upstream snapshot that silently clobbers local invariants (e.g. the
# SkillOpt execution-gate terms enforced by tests/test_skillopt_gates.py)
# before it can be merged. PRs opened by GITHUB_TOKEN do not trigger CI,
# so this in-job gate is the only pre-merge signal.
- name: Regenerate catalog and run full gate
id: gate
if: steps.sync.outputs.drift == 'true'
run: |
set +e
make catalog && make check
echo "status=$?" >> "$GITHUB_OUTPUT"
- name: Open PR on drift
if: steps.sync.outputs.drift == 'true'
uses: peter-evans/create-pull-request@v8
with:
branch: chore/sync-statspai-skill
commit-message: "chore(skills/statspai): sync from upstream"
title: "chore(skills/statspai): sync from upstream StatsPAI_skill/"
body: |
Automated sync from
[`brycewang-stanford/StatsPAI:StatsPAI_skill/`](https://github.com/brycewang-stanford/StatsPAI/tree/main/StatsPAI_skill).
**Local full gate (`make catalog && make check`):** ${{ steps.gate.outputs.status == '0' && '✅ passed — review the diff and merge if intended' || '❌ FAILED — do NOT merge until fixed; see the run log (PR labelled needs-fix)' }}
Triggered by `.github/workflows/sync-statspai-skill.yml`.
labels: ${{ steps.gate.outputs.status == '0' && 'sync,skills' || 'sync,skills,needs-fix' }}
- name: Fail the run if the synced tree breaks the gate
if: steps.sync.outputs.drift == 'true' && steps.gate.outputs.status != '0'
run: |
echo "::error::Synced upstream fails 'make check' (exit ${{ steps.gate.outputs.status }}). A PR was opened and labelled needs-fix; do not merge until the gate passes."
exit 1
name: Validate catalog and repo structure
on:
pull_request:
push:
branches:
- main
workflow_dispatch: {}
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
submodules: recursive
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Validate repository structure and generated artifacts
run: make validate
社科实证 Agent Skills 收集整理/
/Users/brycewang/Documents/GitHub/Awesome-Agent-Skills-for-Empirical-Research/社科实证 Agent Skills 收集整理/
test-notebooks/
社媒运营/
# macOS
.DS_Store
**/.DS_Store
# Python / local tooling
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.mypy_cache/
.venv/
venv/
catalog/external-link-check.json
# Local secrets
.env
.env.*
!.env.example
stata-skills/
CLAUDE.md# Local, network-free pre-commit hooks for AERS quality gates.
# Install once: pip install pre-commit && pre-commit install
# Run on demand: pre-commit run --all-files
#
# All hooks are `language: system` and call the repo's own stdlib-only tools, so
# there is nothing to download and they work offline.
repos:
- repo: local
hooks:
- id: aers-eval-harness-lint
name: AERS eval-harness scenarios lint
entry: python3 eval-harness/run_evals.py
language: system
pass_filenames: false
files: ^eval-harness/
- id: aers-unit-tests
name: AERS unit tests
entry: python3 -m unittest discover -s tests -p "test_*.py"
language: system
pass_filenames: false
files: ^(tests/|eval-harness/|benchmark/|scripts/)
- id: aers-python-compat
name: AERS Python tooling compile
entry: make python-compat
language: system
pass_filenames: false
files: ^(tests/|eval-harness/|benchmark/|scripts/|Makefile)
- id: aers-benchmark
name: AERS benchmark (strict)
entry: bash -c 'python3 benchmark/check_benchmark.py --lint >/dev/null && python3 benchmark/reference_pipeline.py --check >/dev/null && python3 benchmark/check_benchmark.py --strict --fail-on-partial --fail-on-orphan-results >/dev/null'
language: system
pass_filenames: false
files: ^benchmark/
- id: aers-tracked-file-hygiene
name: AERS tracked-file hygiene
entry: python3 scripts/check-repo-hygiene.py
language: system
pass_filenames: false
files: ^(.DS_Store|.*(__pycache__|\.py[cod]|\.pytest_cache|\.ruff_cache|\.mypy_cache)|scripts/check-repo-hygiene\.py)
- id: aers-repo-validate
name: AERS repo validate (links + frontmatter)
entry: python3 scripts/validate-repo.py
language: system
pass_filenames: false
files: ^(skills/|docs/|README|catalog/)
interface:
display_name: "AERS Catalog Router"
short_description: "Route empirical-research skills from AERS"
default_prompt: "Use $auto-empirical-research-skills to choose the right empirical-research skill for my task."
results/
candidates/_*/
{
"task": "bad-control-recovery",
"method": "y~d (naive) vs y~d+x (good pre-treatment control) vs y~d+x+m (bad post-treatment mediator control)",
"n": 120,
"true_total": 2.5,
"naive_effect": 2.5,
"good_control_effect": 2.5,
"bad_control_effect": 0.5
}
{
"task": "bayesian-recovery",
"method": "Conjugate Normal-Normal posterior mean: weakly-informative vs overconfident miscalibrated prior",
"n": 20,
"data_mean": 2.0,
"posterior_weak": 2.0,
"posterior_strong": 5.5556
}
{
"task": "did-staggered-recovery",
"method": "Group-time DID with not-yet-treated controls; TWFE diagnostic reported",
"n": 600,
"true_att": 2.9091,
"twfe_att": 1.4545,
"cs_att": 2.9091
}
{
"task": "dml-recovery",
"method": "Cross-fitted partialling-out (DML) vs naive OLS of outcome on treatment only",
"n": 120,
"true_theta": 1.5,
"dml_theta": 1.5,
"naive_theta": 3.4284
}
{
"task": "event-study-recovery",
"method": "Event-study with unit+time FE and treated x relative-time dummies vs naive treated-only before/after",
"n": 240,
"true_att": 3.0,
"es_att": 3.0,
"es_pre_max": 0.0,
"naive_before_after": 5.4
}
{
"task": "card-iv-recovery",
"method": "OLS vs 2SLS (nearc4 instrument), manual two-stage",
"n": 3010,
"ols_return": 0.0747,
"iv_return": 0.1315,
"first_stage_coef": 0.3199,
"first_stage_F": 13.26
}
{
"task": "lalonde-recovery",
"method": "OLS regression adjustment (full controls incl. re74, re75)",
"n_treated": 185,
"n_control": 429,
"naive_att": -635.0,
"adjusted_att": 1548.2,
"balance": {
"age": -0.242,
"educ": 0.045,
"black": 1.668,
"hispan": -0.277,
"married": -0.719,
"nodegree": 0.235,
"re74": -0.596,
"re75": -0.287
}
}
{
"task": "panel-fe-recovery",
"method": "Two-way (unit+time) fixed effects vs pooled OLS that ignores unit heterogeneity",
"n": 240,
"true_att": 2.0,
"twoway_fe_att": 2.0,
"pooled_att": 7.0667
}
{
"task": "rdd-recovery",
"method": "Sharp RD: local-linear at the cutoff vs global common-slope OLS vs naive across-cutoff mean difference",
"n": 101,
"bandwidth": 0.5,
"true_tau": 3.0,
"naive_jump": 5.51,
"global_att": 2.94,
"local_att": 3.0
}
{
"task": "survival-recovery",
"method": "Kaplan-Meier survival (handles censoring) vs naive proportion that treats censored as failures",
"n": 160,
"true_surv_treat": 0.65,
"true_surv_control": 0.3,
"km_surv_treat": 0.651,
"km_surv_control": 0.2986,
"naive_surv_treat": 0.525,
"naive_surv_control": 0.2375
}
{
"task": "synthetic-control-recovery",
"method": "Synthetic control (simplex-fit donor weights on the pre-period) vs equal-weight donor average",
"n": 8,
"true_effect": 3.0,
"sc_effect": 3.0,
"naive_effect": 4.8167
}
#!/usr/bin/env python3
"""Grade candidate results against AERS benchmark tasks.
A "candidate" is a results.json produced by any pipeline (the reference pipeline,
the repo's demo outputs, or a real agent run). For each task the checker
recomputes the data-derived golds from the dataset itself, so a candidate cannot
pass by reporting fabricated numbers. Literature constants (the LaLonde
experimental benchmark, the Card canonical values) come from the task specs.
Usage:
python3 benchmark/reference_pipeline.py # produce reference candidates
python3 benchmark/check_benchmark.py --lint # validate task + candidate metadata only
python3 benchmark/check_benchmark.py # grade all tasks
python3 benchmark/check_benchmark.py --task card-iv-recovery
python3 benchmark/check_benchmark.py --strict # nonzero exit on required fail
python3 benchmark/check_benchmark.py --strict --fail-on-partial --fail-on-orphan-results
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from numbers import Real
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
import toml_compat as tomllib # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
import lalonde # noqa: E402
import card # noqa: E402
import simdid # noqa: E402
import rdd # noqa: E402
import badcontrol # noqa: E402
import panelfe # noqa: E402
import eventstudy # noqa: E402
import dml # noqa: E402
import survival # noqa: E402
import bayesian # noqa: E402
import synth # noqa: E402
TASKS_DIR = Path(__file__).resolve().parent / "tasks"
CANDIDATES_DIR = Path(__file__).resolve().parent / "candidates"
RESULTS_DIR = Path(__file__).resolve().parent / "results"
SUPPORTED_TASK_IDS = {
"bad-control-recovery",
"bayesian-recovery",
"card-iv-recovery",
"did-staggered-recovery",
"dml-recovery",
"event-study-recovery",
"lalonde-recovery",
"panel-fe-recovery",
"rdd-recovery",
"survival-recovery",
"synthetic-control-recovery",
}
CANDIDATE_DIR_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
KNOWN_CHECKS = {
"adjusted_recovery",
"biased_away",
"closer_to_truth",
"cross_check",
"first_stage_min",
"imbalance_count",
"iv_gt_ols",
"naive_sign",
"near_benchmark",
"recovers_truth",
"value_near",
}
REQUIRED_TASK_FIELDS = ("id", "title", "data", "reference_candidate", "gold")
REQUIRED_GOLD_FIELDS = ("id", "description", "check", "required", "weight")
TASK_STRING_FIELDS = {
"lalonde-recovery": ("treatment", "outcome"),
}
TASK_NUMERIC_FIELDS = {
"lalonde-recovery": ("experimental_att", "experimental_tol"),
}
CHECK_REQUIRED_FIELDS = {
"adjusted_recovery": ("min_swing",),
"biased_away": ("field", "truth_key", "min_gap"),
"closer_to_truth": ("near_field", "far_field", "truth_key"),
"cross_check": (),
"first_stage_min": ("min_f",),
"imbalance_count": ("min_covariates", "smd_threshold"),
"iv_gt_ols": (),
"naive_sign": ("expected_sign",),
"near_benchmark": (),
"recovers_truth": ("field", "truth_key", "tol"),
"value_near": ("field", "expected", "tol"),
}
CHECK_STRING_FIELDS = {
"biased_away": ("field", "truth_key"),
"closer_to_truth": ("near_field", "far_field", "truth_key"),
"naive_sign": ("expected_sign",),
"recovers_truth": ("field", "truth_key"),
"value_near": ("field",),
}
CHECK_NUMERIC_FIELDS = {
"adjusted_recovery": ("min_swing",),
"biased_away": ("min_gap",),
"first_stage_min": ("min_f",),
"imbalance_count": ("smd_threshold",),
"recovers_truth": ("tol",),
"value_near": ("expected", "tol"),
}
CROSS_CHECK_NUMERIC_FIELDS = {
"bad-control-recovery": ("tol",),
"card-iv-recovery": ("tol", "f_tol"),
"did-staggered-recovery": ("tol",),
"lalonde-recovery": ("naive_tol", "smd_tol"),
"rdd-recovery": ("tol",),
}
CANDIDATE_NUMERIC_FIELDS = {
"bad-control-recovery": ("true_total", "naive_effect", "good_control_effect", "bad_control_effect"),
"card-iv-recovery": ("ols_return", "iv_return", "first_stage_F", "first_stage_coef"),
"did-staggered-recovery": ("true_att", "twfe_att", "cs_att"),
"lalonde-recovery": ("naive_att", "adjusted_att"),
"rdd-recovery": ("true_tau", "naive_jump", "global_att", "local_att"),
"panel-fe-recovery": ("true_att", "twoway_fe_att", "pooled_att"),
"event-study-recovery": ("true_att", "es_att", "es_pre_max", "naive_before_after"),
"dml-recovery": ("true_theta", "dml_theta", "naive_theta"),
"survival-recovery": (
"true_surv_treat", "true_surv_control",
"km_surv_treat", "km_surv_control",
"naive_surv_treat", "naive_surv_control",
),
"bayesian-recovery": ("data_mean", "posterior_weak", "posterior_strong"),
"synthetic-control-recovery": ("true_effect", "sc_effect", "naive_effect"),
}
CANDIDATE_NUMERIC_MAP_FIELDS = {
"lalonde-recovery": ("balance",),
}
def _is_number(value: object) -> bool:
return isinstance(value, Real) and not isinstance(value, bool)
def validate_candidate_dir_name(value: object, label: str = "candidate") -> list[str]:
if not isinstance(value, str) or not value:
return [f"{label} must be a non-empty string"]
if not CANDIDATE_DIR_RE.fullmatch(value):
return [
f"{label} '{value}' must be a single directory name matching {CANDIDATE_DIR_RE.pattern}"
]
return []
def validate_repo_relative_file(value: object, label: str) -> list[str]:
if not isinstance(value, str) or not value:
return [f"{label} must be a non-empty string"]
raw_path = Path(value)
if raw_path.is_absolute():
return [f"{label} '{value}' must be repo-relative, not absolute"]
root = ROOT.resolve()
resolved = (ROOT / raw_path).resolve(strict=False)
try:
resolved.relative_to(root)
except ValueError:
return [f"{label} '{value}' must stay inside the repository"]
if not resolved.exists():
return [f"{label} path does not exist: {value}"]
if not resolved.is_file():
return [f"{label} path must be a file: {value}"]
return []
def orphan_result_files(results_dir: Path, task_ids: set[str]) -> list[Path]:
"""Generated result JSON files whose stem no longer matches a benchmark task."""
if not results_dir.exists():
return []
return sorted(path for path in results_dir.glob("*.json") if path.stem not in task_ids)
def validate_task(task: dict, task_path: Path) -> list[str]:
"""Validate a benchmark task spec before scoring candidates."""
problems = []
if not isinstance(task, dict):
return [f"{task_path} must contain a TOML table"]
for field in REQUIRED_TASK_FIELDS:
if field not in task:
problems.append(f"missing top-level field '{field}'")
task_id = task.get("id")
if not isinstance(task_id, str) or not task_id:
problems.append("field 'id' must be a non-empty string")
elif task_id != task_path.stem:
problems.append(f"field 'id' must match file stem '{task_path.stem}'")
elif task_id not in SUPPORTED_TASK_IDS:
problems.append(f"unsupported task id '{task_id}'")
for field in ("title", "data", "reference_candidate"):
value = task.get(field)
if field in task and (not isinstance(value, str) or not value.strip()):
problems.append(f"field '{field}' must be a non-empty string")
if "reference_candidate" in task:
problems.extend(validate_candidate_dir_name(task.get("reference_candidate"), "reference_candidate"))
if isinstance(task_id, str):
for field in TASK_STRING_FIELDS.get(task_id, ()):
value = task.get(field)
if not isinstance(value, str) or not value.strip():
problems.append(f"field '{field}' must be a non-empty string")
for field in TASK_NUMERIC_FIELDS.get(task_id, ()):
value = task.get(field)
if not _is_number(value):
problems.append(f"field '{field}' must be numeric")
if "data" in task:
problems.extend(validate_repo_relative_file(task.get("data"), "data"))
gold = task.get("gold")
if not isinstance(gold, list) or not gold:
problems.append("field 'gold' must be a non-empty list")
return problems
seen_gold_ids = set()
for idx, item in enumerate(gold):
prefix = f"gold[{idx}]"
if not isinstance(item, dict):
problems.append(f"{prefix} must be a table")
continue
for field in REQUIRED_GOLD_FIELDS:
if field not in item:
problems.append(f"{prefix} missing field '{field}'")
gid = item.get("id")
if not isinstance(gid, str) or not gid:
problems.append(f"{prefix} field 'id' must be a non-empty string")
elif gid in seen_gold_ids:
problems.append(f"{prefix} duplicate id '{gid}'")
else:
seen_gold_ids.add(gid)
description = item.get("description")
if "description" in item and (not isinstance(description, str) or not description.strip()):
problems.append(f"{prefix} field 'description' must be a non-empty string")
required = item.get("required")
if "required" in item and not isinstance(required, bool):
problems.append(f"{prefix} field 'required' must be a boolean")
weight = item.get("weight")
if "weight" in item and (not _is_number(weight) or weight <= 0):
problems.append(f"{prefix} field 'weight' must be a positive number")
check = item.get("check")
if not isinstance(check, str) or not check:
problems.append(f"{prefix} field 'check' must be a non-empty string")
continue
if check not in KNOWN_CHECKS:
problems.append(f"{prefix} unknown check '{check}'")
continue
for field in CHECK_REQUIRED_FIELDS[check]:
if field not in item:
problems.append(f"{prefix} check '{check}' missing field '{field}'")
if check == "naive_sign" and item.get("expected_sign") not in {"negative", "positive"}:
problems.append(f"{prefix} expected_sign must be 'negative' or 'positive'")
if check == "imbalance_count" and not isinstance(item.get("min_covariates"), int):
problems.append(f"{prefix} min_covariates must be an integer")
for field in CHECK_STRING_FIELDS.get(check, ()):
value = item.get(field)
if field in item and (not isinstance(value, str) or not value.strip()):
problems.append(f"{prefix} field '{field}' must be a non-empty string")
for field in CHECK_NUMERIC_FIELDS.get(check, ()):
value = item.get(field)
if field in item and not _is_number(value):
problems.append(f"{prefix} field '{field}' must be numeric")
if check == "cross_check" and isinstance(task_id, str):
for field in CROSS_CHECK_NUMERIC_FIELDS.get(task_id, ()):
value = item.get(field)
if field not in item:
problems.append(f"{prefix} check 'cross_check' missing field '{field}'")
elif not _is_number(value):
problems.append(f"{prefix} field '{field}' must be numeric")
return problems
def validate_candidate(task: dict, candidate: dict, candidate_path: Path) -> list[str]:
"""Validate candidate result metadata before comparing reported numbers."""
problems = []
if not isinstance(candidate, dict):
return [f"{candidate_path} must contain a JSON object"]
candidate_task = candidate.get("task")
if not isinstance(candidate_task, str) or not candidate_task:
problems.append("candidate field 'task' must be a non-empty string")
elif candidate_task != task["id"]:
problems.append(f"candidate task '{candidate_task}' does not match benchmark task '{task['id']}'")
task_id = task.get("id")
for field in CANDIDATE_NUMERIC_FIELDS.get(task_id, ()):
if field in candidate and not _is_number(candidate[field]):
problems.append(f"candidate field '{field}' must be numeric")
for field in CANDIDATE_NUMERIC_MAP_FIELDS.get(task_id, ()):
if field not in candidate:
continue
value = candidate[field]
if not isinstance(value, dict):
problems.append(f"candidate field '{field}' must be an object of numeric values")
continue
for key, item in value.items():
if not isinstance(key, str) or not key:
problems.append(f"candidate field '{field}' has a non-string or empty key")
if not _is_number(item):
problems.append(f"candidate field '{field}.{key}' must be numeric")
return problems
def compute_truth(task: dict) -> dict:
"""Recompute data-derived golds from the dataset for the given task."""
if task["id"] == "lalonde-recovery":
data = ROOT / task["data"]
rows = lalonde.load(data)
return {
"n": len(rows),
"naive_att": lalonde.naive_att(rows, task["treatment"], task["outcome"]),
"smd": lalonde.smd_table(rows, task["treatment"]),
}
if task["id"] == "card-iv-recovery":
data = ROOT / task["data"]
rows = card.load(data)
coef, f = card.first_stage(rows)
return {
"n": len(rows),
"ols_return": card.ols_return(rows),
"iv_return": card.iv_return(rows),
"first_stage_F": f,
"first_stage_coef": coef,
}
if task["id"] == "did-staggered-recovery":
data = ROOT / task["data"]
rows = simdid.load(data)
return {
"n": len(rows),
"true_att": simdid.true_att(rows),
"twfe_att": simdid.twfe_att(rows),
"cs_att": simdid.cs_att(rows),
}
if task["id"] == "rdd-recovery":
data = ROOT / task["data"]
rows = rdd.load(data)
return {
"n": len(rows),
"true_tau": rdd.true_tau(rows),
"naive_jump": rdd.naive_jump(rows),
"global_att": rdd.global_att(rows),
"local_att": rdd.local_att(rows),
}
if task["id"] == "bad-control-recovery":
data = ROOT / task["data"]
rows = badcontrol.load(data)
return {
"n": len(rows),
"true_total": badcontrol.true_total(rows),
"naive_effect": badcontrol.naive_effect(rows),
"good_control_effect": badcontrol.good_control_effect(rows),
"bad_control_effect": badcontrol.bad_control_effect(rows),
}
if task["id"] == "panel-fe-recovery":
data = ROOT / task["data"]
rows = panelfe.load(data)
return {
"n": len(rows),
"true_att": panelfe.true_att(rows),
"twoway_fe_att": panelfe.twoway_fe_att(rows),
"pooled_att": panelfe.pooled_att(rows),
}
if task["id"] == "event-study-recovery":
data = ROOT / task["data"]
rows = eventstudy.load(data)
return {
"n": len(rows),
"true_att": eventstudy.true_att(rows),
"es_att": eventstudy.es_att(rows),
"es_pre_max": eventstudy.es_pre_max(rows),
"naive_before_after": eventstudy.naive_before_after(rows),
}
if task["id"] == "dml-recovery":
data = ROOT / task["data"]
rows = dml.load(data)
return {
"n": len(rows),
"true_theta": dml.true_theta(rows),
"dml_theta": dml.dml_theta(rows),
"naive_theta": dml.naive_theta(rows),
}
if task["id"] == "survival-recovery":
data = ROOT / task["data"]
rows = survival.load(data)
return {
"n": len(rows),
"true_surv_treat": survival.true_survival(rows, 1),
"true_surv_control": survival.true_survival(rows, 0),
"km_surv_treat": survival.km_survival(rows, 1),
"km_surv_control": survival.km_survival(rows, 0),
"naive_surv_treat": survival.naive_survival(rows, 1),
"naive_surv_control": survival.naive_survival(rows, 0),
}
if task["id"] == "bayesian-recovery":
data = ROOT / task["data"]
rows = bayesian.load(data)
return {
"n": len(rows),
"data_mean": bayesian.data_mean(rows),
"posterior_weak": bayesian.posterior_weak(rows),
"posterior_strong": bayesian.posterior_strong(rows),
}
if task["id"] == "synthetic-control-recovery":
data = ROOT / task["data"]
rows = synth.load(data)
return {
"n": len(rows),
"true_effect": synth.true_effect(rows),
"sc_effect": synth.sc_effect(rows),
"naive_effect": synth.naive_effect(rows),
}
raise ValueError(f"unknown task {task['id']}")
def grade(task: dict, candidate: dict, truth: dict) -> list[dict]:
out = []
for g in task["gold"]:
gid, check = g["id"], g["check"]
passed, detail = False, ""
# --- shared / lalonde checks ---
if check == "imbalance_count":
thr = g["smd_threshold"]
n_big = sum(1 for v in candidate.get("balance", {}).values() if abs(v) > thr)
passed = n_big >= g["min_covariates"]
detail = f"{n_big} covariates with |SMD|>{thr} (need >= {g['min_covariates']})"
elif check == "naive_sign":
v = candidate.get("naive_att")
want = g["expected_sign"]
passed = v is not None and ((v < 0) if want == "negative" else (v > 0))
detail = f"naive_att = {v} (want {want})"
elif check == "adjusted_recovery":
naive, adj = candidate.get("naive_att"), candidate.get("adjusted_att")
if naive is not None and adj is not None:
swing = adj - naive
passed = adj > 0 and swing >= g["min_swing"]
detail = f"adjusted {adj} vs naive {naive} (swing {swing:+.0f}, need +{g['min_swing']:.0f} & positive)"
else:
detail = "missing naive_att or adjusted_att"
elif check == "near_benchmark":
adj = candidate.get("adjusted_att")
bench, tol = task["experimental_att"], task["experimental_tol"]
if adj is not None:
passed = abs(adj - bench) <= tol
detail = f"adjusted {adj} vs benchmark {bench} (tol {tol}; |diff|={abs(adj-bench):.0f})"
else:
detail = "missing adjusted_att"
# --- card checks ---
elif check == "value_near":
v, exp, tol = candidate.get(g["field"]), g["expected"], g["tol"]
if v is not None:
passed = abs(v - exp) <= tol
detail = f"{g['field']} {v} vs {exp} (tol {tol}; |diff|={abs(v-exp):.4f})"
else:
detail = f"missing {g['field']}"
elif check == "iv_gt_ols":
ols, iv = candidate.get("ols_return"), candidate.get("iv_return")
if ols is not None and iv is not None:
passed = iv > 0 and iv > ols
detail = f"iv {iv:.4f} {'>' if iv > ols else '<='} ols {ols:.4f}"
else:
detail = "missing ols_return or iv_return"
elif check == "first_stage_min":
f = candidate.get("first_stage_F")
if f is not None:
passed = f >= g["min_f"]
detail = f"first-stage F {f} (need >= {g['min_f']})"
else:
detail = "missing first_stage_F"
# --- staggered-DID checks ---
elif check == "recovers_truth":
field, truth_key, tol = g["field"], g["truth_key"], g["tol"]
v, exp = candidate.get(field), truth[truth_key]
if v is not None:
passed = abs(v - exp) <= tol
detail = f"{field} {v} vs true {exp:.4f} (tol {tol}; |diff|={abs(v-exp):.4f})"
else:
detail = f"missing {field}"
elif check == "biased_away":
field, truth_key = g["field"], g["truth_key"]
v, exp = candidate.get(field), truth[truth_key]
if v is not None:
gap = abs(v - exp)
passed = gap >= g["min_gap"]
detail = f"{field} {v} vs true {exp:.4f} (gap {gap:.4f})"
else:
detail = f"missing {field}"
elif check == "closer_to_truth":
near, far = candidate.get(g["near_field"]), candidate.get(g["far_field"])
exp = truth[g["truth_key"]]
if near is not None and far is not None:
near_gap, far_gap = abs(near - exp), abs(far - exp)
passed = near_gap < far_gap
detail = f"{g['near_field']} gap {near_gap:.4f} vs {g['far_field']} gap {far_gap:.4f}"
else:
detail = f"missing {g['near_field']} or {g['far_field']}"
# --- shared anti-fabrication cross-check ---
elif check == "cross_check":
problems = []
if task["id"] == "lalonde-recovery":
rn = candidate.get("naive_att")
if rn is None or abs(rn - truth["naive_att"]) > g["naive_tol"]:
problems.append(f"naive_att {rn} vs true {truth['naive_att']:.1f}")
rbal = candidate.get("balance", {})
for k, tv in truth["smd"].items():
rv = rbal.get(k)
if rv is None or abs(rv - tv) > g["smd_tol"]:
problems.append(f"SMD[{k}] {rv} vs true {tv:.3f}")
elif task["id"] == "card-iv-recovery":
for field in ("ols_return", "iv_return"):
rv = candidate.get(field)
if rv is None or abs(rv - truth[field]) > g["tol"]:
problems.append(f"{field} {rv} vs true {truth[field]:.4f}")
rf = candidate.get("first_stage_F")
if rf is None or abs(rf - truth["first_stage_F"]) > g["f_tol"]:
problems.append(f"first_stage_F {rf} vs true {truth['first_stage_F']:.2f}")
elif task["id"] == "did-staggered-recovery":
for field in ("twfe_att", "cs_att"):
rv = candidate.get(field)
if rv is None or abs(rv - truth[field]) > g["tol"]:
problems.append(f"{field} {rv} vs true {truth[field]:.4f}")
rt = candidate.get("true_att")
if rt is not None and abs(rt - truth["true_att"]) > g["tol"]:
problems.append(f"true_att {rt} vs true {truth['true_att']:.4f}")
elif task["id"] == "rdd-recovery":
for field in ("naive_jump", "global_att", "local_att"):
rv = candidate.get(field)
if rv is None or abs(rv - truth[field]) > g["tol"]:
problems.append(f"{field} {rv} vs true {truth[field]:.4f}")
rt = candidate.get("true_tau")
if rt is not None and abs(rt - truth["true_tau"]) > g["tol"]:
problems.append(f"true_tau {rt} vs true {truth['true_tau']:.4f}")
elif task["id"] == "bad-control-recovery":
for field in ("naive_effect", "good_control_effect", "bad_control_effect"):
rv = candidate.get(field)
if rv is None or abs(rv - truth[field]) > g["tol"]:
problems.append(f"{field} {rv} vs true {truth[field]:.4f}")
rt = candidate.get("true_total")
if rt is not None and abs(rt - truth["true_total"]) > g["tol"]:
problems.append(f"true_total {rt} vs true {truth['true_total']:.4f}")
passed = not problems
detail = "reported numbers match data" if passed else "; ".join(problems[:3])
else:
detail = f"unknown check {check}"
out.append({"id": gid, "required": g.get("required", False),
"weight": g.get("weight", 1), "passed": passed, "detail": detail})
return out
def exit_code_for_failures(
required_failures: list[str],
optional_failures: list[str],
strict: bool,
fail_on_partial: bool,
) -> int:
if required_failures and strict:
return 1
if optional_failures and fail_on_partial:
return 1
return 0
def grade_task(
task_path: Path,
candidate_override: str | None,
strict: bool,
fail_on_partial: bool,
) -> tuple[int, list[str]]:
with task_path.open("rb") as fh:
task = tomllib.load(fh)
task_id = task.get("id", task_path.stem) if isinstance(task, dict) else task_path.stem
task_problems = validate_task(task, task_path)
if task_problems:
print(f"[{task_id}] invalid benchmark task {task_path}", file=sys.stderr)
for problem in task_problems:
print(f" - {problem}", file=sys.stderr)
return 1, [task_id]
cand_dir = candidate_override or task.get("reference_candidate", "")
candidate_dir_problems = validate_candidate_dir_name(cand_dir, "candidate")
if candidate_dir_problems:
print(f"[{task['id']}] invalid candidate directory name", file=sys.stderr)
for problem in candidate_dir_problems:
print(f" - {problem}", file=sys.stderr)
return 1, [task["id"]]
results_json = (CANDIDATES_DIR / cand_dir / "results.json") if cand_dir else None
if not results_json or not results_json.exists():
print(f"[{task['id']}] no candidate results.json at {results_json}", file=sys.stderr)
print(" Run: python3 benchmark/reference_pipeline.py", file=sys.stderr)
return 1, [task["id"]]
try:
candidate = json.loads(results_json.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
print(f"[{task['id']}] invalid candidate JSON {results_json}", file=sys.stderr)
print(f" - {exc.msg} at line {exc.lineno}, column {exc.colno}", file=sys.stderr)
return 1, [task["id"]]
candidate_problems = validate_candidate(task, candidate, results_json)
if candidate_problems:
print(f"[{task['id']}] invalid candidate {results_json}", file=sys.stderr)
for problem in candidate_problems:
print(f" - {problem}", file=sys.stderr)
return 1, [task["id"]]
truth = compute_truth(task)
graded = grade(task, candidate, truth)
earned = sum(g["weight"] for g in graded if g["passed"])
possible = sum(g["weight"] for g in graded)
req_fail = [g["id"] for g in graded if g["required"] and not g["passed"]]
opt_fail = [g["id"] for g in graded if not g["required"] and not g["passed"]]
print(f"Benchmark: {task['id']} (candidate: {results_json.parent.name}, N={truth['n']})")
print("-" * 72)
for g in graded:
mark = "PASS" if g["passed"] else "FAIL"
req = "*" if g["required"] else " "
print(f" [{mark}]{req} {g['id']:32s} {g['detail']}")
print(f"Score: {earned}/{possible} | required failures: {req_fail or 'none'}")
if opt_fail:
print(f"Optional failures: {opt_fail}")
print()
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
(RESULTS_DIR / f"{task['id']}.json").write_text(
json.dumps({"task": task["id"], "candidate": results_json.parent.name,
"earned": earned, "possible": possible,
"required_failures": req_fail,
"optional_failures": opt_fail,
"items": graded}, indent=2) + "\n",
encoding="utf-8")
return exit_code_for_failures(req_fail, opt_fail, strict, fail_on_partial), req_fail
def lint_task(task_path: Path, candidate_override: str | None = None) -> list[str]:
with task_path.open("rb") as fh:
task = tomllib.load(fh)
task_id = task.get("id", task_path.stem) if isinstance(task, dict) else task_path.stem
problems = [f"{task_id}: {problem}" for problem in validate_task(task, task_path)]
if problems:
return problems
cand_dir = candidate_override or task.get("reference_candidate", "")
problems.extend(
f"{task['id']}: {problem}"
for problem in validate_candidate_dir_name(cand_dir, "candidate")
)
if problems:
return problems
results_json = CANDIDATES_DIR / cand_dir / "results.json"
if not results_json.exists():
return [f"{task['id']}: missing candidate results.json at {results_json.relative_to(ROOT)}"]
try:
candidate = json.loads(results_json.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return [
f"{task['id']}: invalid candidate JSON {results_json.relative_to(ROOT)} "
f"({exc.msg} at line {exc.lineno}, column {exc.colno})"
]
return [
f"{task['id']}: {problem}"
for problem in validate_candidate(task, candidate, results_json)
]
def lint_tasks(task_paths: list[Path], candidate_override: str | None = None) -> int:
problems: list[str] = []
for task_path in task_paths:
problems.extend(lint_task(task_path, candidate_override))
if problems:
print(f"Benchmark lint found {len(problems)} problem(s):", file=sys.stderr)
for problem in problems:
print(f" - {problem}", file=sys.stderr)
return 1
print(f"Benchmark lint passed for {len(task_paths)} task(s).")
return 0
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Grade AERS benchmark candidates")
ap.add_argument("--task", help="grade only this task id (default: all)")
ap.add_argument("--candidate", help="override candidate dir name under benchmark/candidates/")
ap.add_argument(
"--lint",
action="store_true",
help="validate task specs and reference candidate metadata without scoring",
)
ap.add_argument("--strict", action="store_true")
ap.add_argument(
"--fail-on-partial",
action="store_true",
help="nonzero exit when any non-required gold fails",
)
ap.add_argument(
"--fail-on-orphan-results",
action="store_true",
help="nonzero exit when benchmark/results/ contains JSON for no current task",
)
args = ap.parse_args(argv)
all_tasks = sorted(TASKS_DIR.glob("*.toml"))
task_ids = {path.stem for path in all_tasks}
orphan_results = orphan_result_files(RESULTS_DIR, task_ids)
if orphan_results:
print(
f"Found {len(orphan_results)} orphan benchmark result file(s):",
file=sys.stderr,
)
for path in orphan_results:
print(f" - {path.relative_to(ROOT)}", file=sys.stderr)
if args.fail_on_orphan_results:
return 1
tasks = all_tasks
if args.task:
tasks = [t for t in tasks if t.stem == args.task]
if not tasks:
print(f"No task '{args.task}'", file=sys.stderr)
return 1
if args.lint:
return lint_tasks(tasks, args.candidate)
rc = 0
for t in tasks:
code, _ = grade_task(t, args.candidate, args.strict, args.fail_on_partial)
rc = rc or code
return rc
if __name__ == "__main__":
raise SystemExit(main())
x,d,m,y,y0,y1
0,0,-0.6,-1.2,-1.2,1.3
0,1,0.7,1.9,-0.6,1.9
1,0,0.5,2.0,2.0,4.5
1,1,1.8,5.1,2.6,5.1
2,0,1.6,5.2,5.2,7.7
2,1,1.4,5.3,2.8,5.3
0,0,-0.3,-0.6,-0.6,1.9
0,1,1.0,2.5,0.0,2.5
1,0,0.8,2.6,2.6,5.1
1,1,2.1,5.7,3.2,5.7
2,0,0.4,2.8,2.8,5.3
2,1,1.7,5.9,3.4,5.9
0,0,0.0,0.0,0.0,2.5
0,1,1.3,3.1,0.6,3.1
1,0,1.1,3.2,3.2,5.7
1,1,0.9,3.3,0.8,3.3
2,0,0.7,3.4,3.4,5.9
2,1,2.0,6.5,4.0,6.5
0,0,0.3,0.6,0.6,3.1
0,1,1.6,3.7,1.2,3.7
1,0,-0.1,0.8,0.8,3.3
1,1,1.2,3.9,1.4,3.9
2,0,1.0,4.0,4.0,6.5
2,1,2.3,7.1,4.6,7.1
0,0,0.6,1.2,1.2,3.7
0,1,0.4,1.3,-1.2,1.3
1,0,0.2,1.4,1.4,3.9
1,1,1.5,4.5,2.0,4.5
2,0,1.3,4.6,4.6,7.1
2,1,2.6,7.7,5.2,7.7
0,0,-0.6,-1.2,-1.2,1.3
0,1,0.7,1.9,-0.6,1.9
1,0,0.5,2.0,2.0,4.5
1,1,1.8,5.1,2.6,5.1
2,0,1.6,5.2,5.2,7.7
2,1,1.4,5.3,2.8,5.3
0,0,-0.3,-0.6,-0.6,1.9
0,1,1.0,2.5,0.0,2.5
1,0,0.8,2.6,2.6,5.1
1,1,2.1,5.7,3.2,5.7
2,0,0.4,2.8,2.8,5.3
2,1,1.7,5.9,3.4,5.9
0,0,0.0,0.0,0.0,2.5
0,1,1.3,3.1,0.6,3.1
1,0,1.1,3.2,3.2,5.7
1,1,0.9,3.3,0.8,3.3
2,0,0.7,3.4,3.4,5.9
2,1,2.0,6.5,4.0,6.5
0,0,0.3,0.6,0.6,3.1
0,1,1.6,3.7,1.2,3.7
1,0,-0.1,0.8,0.8,3.3
1,1,1.2,3.9,1.4,3.9
2,0,1.0,4.0,4.0,6.5
2,1,2.3,7.1,4.6,7.1
0,0,0.6,1.2,1.2,3.7
0,1,0.4,1.3,-1.2,1.3
1,0,0.2,1.4,1.4,3.9
1,1,1.5,4.5,2.0,4.5
2,0,1.3,4.6,4.6,7.1
2,1,2.6,7.7,5.2,7.7
0,0,-0.6,-1.2,-1.2,1.3
0,1,0.7,1.9,-0.6,1.9
1,0,0.5,2.0,2.0,4.5
1,1,1.8,5.1,2.6,5.1
2,0,1.6,5.2,5.2,7.7
2,1,1.4,5.3,2.8,5.3
0,0,-0.3,-0.6,-0.6,1.9
0,1,1.0,2.5,0.0,2.5
1,0,0.8,2.6,2.6,5.1
1,1,2.1,5.7,3.2,5.7
2,0,0.4,2.8,2.8,5.3
2,1,1.7,5.9,3.4,5.9
0,0,0.0,0.0,0.0,2.5
0,1,1.3,3.1,0.6,3.1
1,0,1.1,3.2,3.2,5.7
1,1,0.9,3.3,0.8,3.3
2,0,0.7,3.4,3.4,5.9
2,1,2.0,6.5,4.0,6.5
0,0,0.3,0.6,0.6,3.1
0,1,1.6,3.7,1.2,3.7
1,0,-0.1,0.8,0.8,3.3
1,1,1.2,3.9,1.4,3.9
2,0,1.0,4.0,4.0,6.5
2,1,2.3,7.1,4.6,7.1
0,0,0.6,1.2,1.2,3.7
0,1,0.4,1.3,-1.2,1.3
1,0,0.2,1.4,1.4,3.9
1,1,1.5,4.5,2.0,4.5
2,0,1.3,4.6,4.6,7.1
2,1,2.6,7.7,5.2,7.7
0,0,-0.6,-1.2,-1.2,1.3
0,1,0.7,1.9,-0.6,1.9
1,0,0.5,2.0,2.0,4.5
1,1,1.8,5.1,2.6,5.1
2,0,1.6,5.2,5.2,7.7
2,1,1.4,5.3,2.8,5.3
0,0,-0.3,-0.6,-0.6,1.9
0,1,1.0,2.5,0.0,2.5
1,0,0.8,2.6,2.6,5.1
1,1,2.1,5.7,3.2,5.7
2,0,0.4,2.8,2.8,5.3
2,1,1.7,5.9,3.4,5.9
0,0,0.0,0.0,0.0,2.5
0,1,1.3,3.1,0.6,3.1
1,0,1.1,3.2,3.2,5.7
1,1,0.9,3.3,0.8,3.3
2,0,0.7,3.4,3.4,5.9
2,1,2.0,6.5,4.0,6.5
0,0,0.3,0.6,0.6,3.1
0,1,1.6,3.7,1.2,3.7
1,0,-0.1,0.8,0.8,3.3
1,1,1.2,3.9,1.4,3.9
2,0,1.0,4.0,4.0,6.5
2,1,2.3,7.1,4.6,7.1
0,0,0.6,1.2,1.2,3.7
0,1,0.4,1.3,-1.2,1.3
1,0,0.2,1.4,1.4,3.9
1,1,1.5,4.5,2.0,4.5
2,0,1.3,4.6,4.6,7.1
2,1,2.6,7.7,5.2,7.7
id,y
1,0.1
2,0.3
3,0.5
4,0.7
5,0.9
6,1.1
7,1.3
8,1.5
9,1.7
10,1.9
11,2.1
12,2.3
13,2.5
14,2.7
15,2.9
16,3.1
17,3.3
18,3.5
19,3.7
20,3.9
id,d,y,x1,x2,x3,x4
1,1.54,6.91,1.0,3.0,-0.5,2.0
2,4.16,15.44,2.0,6.0,0.5,1.0
3,3.28,11.72,3.0,2.0,1.5,0.0
4,4.3,15.85,4.0,5.0,-1.5,2.0
5,0.42,1.63,0.0,1.0,-0.5,1.0
6,3.04,10.16,1.0,4.0,0.5,0.0
7,2.16,7.64,2.0,0.0,1.5,2.0
8,3.18,10.57,3.0,3.0,-1.5,1.0
9,4.72,17.48,4.0,6.0,-0.5,0.0
10,0.84,4.46,0.0,2.0,0.5,2.0
11,3.46,12.99,1.0,5.0,1.5,1.0
12,0.98,3.67,2.0,1.0,-1.5,0.0
13,3.6,13.4,3.0,4.0,-0.5,2.0
14,2.72,9.68,4.0,0.0,0.5,1.0
15,2.34,7.71,0.0,3.0,1.5,0.0
16,3.36,11.84,1.0,6.0,-1.5,2.0
17,2.48,8.12,2.0,2.0,-0.5,1.0
18,4.02,15.03,3.0,5.0,0.5,0.0
19,3.14,12.51,4.0,1.0,1.5,2.0
20,1.16,4.94,0.0,4.0,-1.5,1.0
21,0.28,1.22,1.0,0.0,-0.5,0.0
22,2.9,10.95,2.0,3.0,0.5,2.0
23,5.52,19.48,3.0,6.0,1.5,1.0
24,3.04,10.16,4.0,2.0,-1.5,0.0
25,2.66,9.39,0.0,5.0,-0.5,2.0
26,1.78,5.67,1.0,1.0,0.5,1.0
27,3.32,12.58,2.0,4.0,1.5,0.0
28,0.84,4.46,3.0,0.0,-1.5,2.0
29,3.46,12.99,4.0,3.0,-0.5,1.0
30,3.08,11.02,0.0,6.0,0.5,0.0
31,2.2,8.5,1.0,2.0,1.5,2.0
32,3.22,11.43,2.0,5.0,-1.5,1.0
33,2.34,7.71,3.0,1.0,-0.5,0.0
34,4.96,17.44,4.0,4.0,0.5,2.0
35,1.08,3.22,0.0,0.0,1.5,1.0
36,1.02,4.53,1.0,3.0,-1.5,0.0
37,3.64,14.26,2.0,6.0,-0.5,2.0
38,2.76,10.54,3.0,2.0,0.5,1.0
39,5.38,19.07,4.0,5.0,1.5,0.0
40,-0.1,0.45,0.0,1.0,-1.5,2.0
41,2.52,8.98,1.0,4.0,-0.5,1.0
42,1.64,5.26,2.0,0.0,0.5,0.0
43,4.26,14.99,3.0,3.0,1.5,2.0
44,5.28,17.92,4.0,6.0,-1.5,1.0
45,0.32,2.08,0.0,2.0,-0.5,0.0
46,2.94,11.81,1.0,5.0,0.5,2.0
47,2.06,8.09,2.0,1.0,1.5,1.0
48,3.08,11.02,3.0,4.0,-1.5,0.0
49,2.2,8.5,4.0,0.0,-0.5,2.0
50,1.82,6.53,0.0,3.0,0.5,1.0
51,4.44,15.06,1.0,6.0,1.5,0.0
52,1.96,6.94,2.0,2.0,-1.5,2.0
53,4.58,15.47,3.0,5.0,-0.5,1.0
54,2.62,10.13,4.0,1.0,0.5,0.0
55,2.24,9.36,0.0,4.0,1.5,2.0
56,-0.24,0.04,1.0,0.0,-1.5,1.0
57,2.38,8.57,2.0,3.0,-0.5,0.0
58,5.0,18.3,3.0,6.0,0.5,2.0
59,4.12,14.58,4.0,2.0,1.5,1.0
60,2.14,7.01,0.0,5.0,-1.5,0.0
61,1.26,4.49,1.0,1.0,-0.5,2.0
62,3.88,13.02,2.0,4.0,0.5,1.0
63,1.92,7.68,3.0,0.0,1.5,0.0
64,2.94,11.81,4.0,3.0,-1.5,2.0
65,2.56,9.84,0.0,6.0,-0.5,1.0
66,1.68,6.12,1.0,2.0,0.5,0.0
67,4.3,15.85,2.0,5.0,1.5,2.0
68,1.82,6.53,3.0,1.0,-1.5,1.0
69,4.44,15.06,4.0,4.0,-0.5,0.0
70,0.56,2.04,0.0,0.0,0.5,2.0
71,3.18,10.57,1.0,3.0,1.5,1.0
72,3.12,11.88,2.0,6.0,-1.5,0.0
73,2.24,9.36,3.0,2.0,-0.5,2.0
74,4.86,17.89,4.0,5.0,0.5,1.0
75,0.98,3.67,0.0,1.0,1.5,0.0
76,2.0,7.8,1.0,4.0,-1.5,2.0
77,1.12,4.08,2.0,0.0,-0.5,1.0
78,3.74,12.61,3.0,3.0,0.5,0.0
79,6.36,22.34,4.0,6.0,1.5,2.0
80,0.88,2.52,0.0,2.0,-1.5,1.0
81,2.42,9.43,1.0,5.0,-0.5,0.0
82,1.54,6.91,2.0,1.0,0.5,2.0
83,4.16,15.44,3.0,4.0,1.5,1.0
84,1.68,6.12,4.0,0.0,-1.5,0.0
85,1.3,5.35,0.0,3.0,-0.5,2.0
86,3.92,13.88,1.0,6.0,0.5,1.0
87,3.04,10.16,2.0,2.0,1.5,0.0
88,4.06,14.29,3.0,5.0,-1.5,2.0
89,3.18,10.57,4.0,1.0,-0.5,1.0
90,1.72,6.98,0.0,4.0,0.5,0.0
91,0.84,4.46,1.0,0.0,1.5,2.0
92,1.86,7.39,2.0,3.0,-1.5,1.0
93,4.48,15.92,3.0,6.0,-0.5,0.0
94,3.6,13.4,4.0,2.0,0.5,2.0
95,3.22,11.43,0.0,5.0,1.5,1.0
96,0.74,2.11,1.0,1.0,-1.5,0.0
97,3.36,11.84,2.0,4.0,-0.5,2.0
98,2.48,8.12,3.0,0.0,0.5,1.0
99,4.02,15.03,4.0,3.0,1.5,0.0
100,2.04,8.66,0.0,6.0,-1.5,2.0
101,1.16,4.94,1.0,2.0,-0.5,1.0
102,3.78,13.47,2.0,5.0,0.5,0.0
103,2.9,10.95,3.0,1.0,1.5,2.0
104,3.92,13.88,4.0,4.0,-1.5,1.0
105,0.04,-0.34,0.0,0.0,-0.5,0.0
106,2.66,9.39,1.0,3.0,0.5,2.0
107,5.28,17.92,2.0,6.0,1.5,1.0
108,1.72,6.98,3.0,2.0,-1.5,0.0
109,4.34,16.71,4.0,5.0,-0.5,2.0
110,0.46,2.49,0.0,1.0,0.5,1.0
111,3.08,11.02,1.0,4.0,1.5,0.0
112,0.6,2.9,2.0,0.0,-1.5,2.0
113,3.22,11.43,3.0,3.0,-0.5,1.0
114,5.84,19.96,4.0,6.0,0.5,0.0
115,1.96,6.94,0.0,2.0,1.5,2.0
116,2.98,9.87,1.0,5.0,-1.5,1.0
117,1.02,4.53,2.0,1.0,-0.5,0.0
118,3.64,14.26,3.0,4.0,0.5,2.0
119,2.76,10.54,4.0,0.0,1.5,1.0
120,0.78,2.97,0.0,3.0,-1.5,0.0
unit,time,treated_unit,post,y0,y
1,1,1,0,-0.9,-0.9
1,2,1,0,-0.3,-0.3
1,3,1,0,0.3,0.3
1,4,1,1,0.9,1.9
1,5,1,1,1.5,3.5
1,6,1,1,2.1,5.1
1,7,1,1,2.7,6.7
1,8,1,1,3.3,8.3
2,1,1,0,0.6,0.6
2,2,1,0,1.2,1.2
2,3,1,0,1.8,1.8
2,4,1,1,2.4,3.4
2,5,1,1,3.0,5.0
2,6,1,1,3.6,6.6
2,7,1,1,4.2,8.2
2,8,1,1,4.8,9.8
3,1,1,0,2.1,2.1
3,2,1,0,2.7,2.7
3,3,1,0,3.3,3.3
3,4,1,1,3.9,4.9
3,5,1,1,4.5,6.5
3,6,1,1,5.1,8.1
3,7,1,1,5.7,9.7
3,8,1,1,6.3,11.3
4,1,1,0,3.6,3.6
4,2,1,0,4.2,4.2
4,3,1,0,4.8,4.8
4,4,1,1,5.4,6.4
4,5,1,1,6.0,8.0
4,6,1,1,6.6,9.6
4,7,1,1,7.2,11.2
4,8,1,1,7.8,12.8
5,1,1,0,-2.4,-2.4
5,2,1,0,-1.8,-1.8
5,3,1,0,-1.2,-1.2
5,4,1,1,-0.6,0.4
5,5,1,1,0.0,2.0
5,6,1,1,0.6,3.6
5,7,1,1,1.2,5.2
5,8,1,1,1.8,6.8
6,1,1,0,-0.9,-0.9
6,2,1,0,-0.3,-0.3
6,3,1,0,0.3,0.3
6,4,1,1,0.9,1.9
6,5,1,1,1.5,3.5
6,6,1,1,2.1,5.1
6,7,1,1,2.7,6.7
6,8,1,1,3.3,8.3
7,1,1,0,0.6,0.6
7,2,1,0,1.2,1.2
7,3,1,0,1.8,1.8
7,4,1,1,2.4,3.4
7,5,1,1,3.0,5.0
7,6,1,1,3.6,6.6
7,7,1,1,4.2,8.2
7,8,1,1,4.8,9.8
8,1,1,0,2.1,2.1
8,2,1,0,2.7,2.7
8,3,1,0,3.3,3.3
8,4,1,1,3.9,4.9
8,5,1,1,4.5,6.5
8,6,1,1,5.1,8.1
8,7,1,1,5.7,9.7
8,8,1,1,6.3,11.3
9,1,1,0,3.6,3.6
9,2,1,0,4.2,4.2
9,3,1,0,4.8,4.8
9,4,1,1,5.4,6.4
9,5,1,1,6.0,8.0
9,6,1,1,6.6,9.6
9,7,1,1,7.2,11.2
9,8,1,1,7.8,12.8
10,1,1,0,-2.4,-2.4
10,2,1,0,-1.8,-1.8
10,3,1,0,-1.2,-1.2
10,4,1,1,-0.6,0.4
10,5,1,1,0.0,2.0
10,6,1,1,0.6,3.6
10,7,1,1,1.2,5.2
10,8,1,1,1.8,6.8
11,1,1,0,-0.9,-0.9
11,2,1,0,-0.3,-0.3
11,3,1,0,0.3,0.3
11,4,1,1,0.9,1.9
11,5,1,1,1.5,3.5
11,6,1,1,2.1,5.1
11,7,1,1,2.7,6.7
11,8,1,1,3.3,8.3
12,1,1,0,0.6,0.6
12,2,1,0,1.2,1.2
12,3,1,0,1.8,1.8
12,4,1,1,2.4,3.4
12,5,1,1,3.0,5.0
12,6,1,1,3.6,6.6
12,7,1,1,4.2,8.2
12,8,1,1,4.8,9.8
13,1,1,0,2.1,2.1
13,2,1,0,2.7,2.7
13,3,1,0,3.3,3.3
13,4,1,1,3.9,4.9
13,5,1,1,4.5,6.5
13,6,1,1,5.1,8.1
13,7,1,1,5.7,9.7
13,8,1,1,6.3,11.3
14,1,1,0,3.6,3.6
14,2,1,0,4.2,4.2
14,3,1,0,4.8,4.8
14,4,1,1,5.4,6.4
14,5,1,1,6.0,8.0
14,6,1,1,6.6,9.6
14,7,1,1,7.2,11.2
14,8,1,1,7.8,12.8
15,1,1,0,-2.4,-2.4
15,2,1,0,-1.8,-1.8
15,3,1,0,-1.2,-1.2
15,4,1,1,-0.6,0.4
15,5,1,1,0.0,2.0
15,6,1,1,0.6,3.6
15,7,1,1,1.2,5.2
15,8,1,1,1.8,6.8
16,1,0,0,-0.9,-0.9
16,2,0,0,-0.3,-0.3
16,3,0,0,0.3,0.3
16,4,0,0,0.9,0.9
16,5,0,0,1.5,1.5
16,6,0,0,2.1,2.1
16,7,0,0,2.7,2.7
16,8,0,0,3.3,3.3
17,1,0,0,0.6,0.6
17,2,0,0,1.2,1.2
17,3,0,0,1.8,1.8
17,4,0,0,2.4,2.4
17,5,0,0,3.0,3.0
17,6,0,0,3.6,3.6
17,7,0,0,4.2,4.2
17,8,0,0,4.8,4.8
18,1,0,0,2.1,2.1
18,2,0,0,2.7,2.7
18,3,0,0,3.3,3.3
18,4,0,0,3.9,3.9
18,5,0,0,4.5,4.5
18,6,0,0,5.1,5.1
18,7,0,0,5.7,5.7
18,8,0,0,6.3,6.3
19,1,0,0,3.6,3.6
19,2,0,0,4.2,4.2
19,3,0,0,4.8,4.8
19,4,0,0,5.4,5.4
19,5,0,0,6.0,6.0
19,6,0,0,6.6,6.6
19,7,0,0,7.2,7.2
19,8,0,0,7.8,7.8
20,1,0,0,-2.4,-2.4
20,2,0,0,-1.8,-1.8
20,3,0,0,-1.2,-1.2
20,4,0,0,-0.6,-0.6
20,5,0,0,0.0,0.0
20,6,0,0,0.6,0.6
20,7,0,0,1.2,1.2
20,8,0,0,1.8,1.8
21,1,0,0,-0.9,-0.9
21,2,0,0,-0.3,-0.3
21,3,0,0,0.3,0.3
21,4,0,0,0.9,0.9
21,5,0,0,1.5,1.5
21,6,0,0,2.1,2.1
21,7,0,0,2.7,2.7
21,8,0,0,3.3,3.3
22,1,0,0,0.6,0.6
22,2,0,0,1.2,1.2
22,3,0,0,1.8,1.8
22,4,0,0,2.4,2.4
22,5,0,0,3.0,3.0
22,6,0,0,3.6,3.6
22,7,0,0,4.2,4.2
22,8,0,0,4.8,4.8
23,1,0,0,2.1,2.1
23,2,0,0,2.7,2.7
23,3,0,0,3.3,3.3
23,4,0,0,3.9,3.9
23,5,0,0,4.5,4.5
23,6,0,0,5.1,5.1
23,7,0,0,5.7,5.7
23,8,0,0,6.3,6.3
24,1,0,0,3.6,3.6
24,2,0,0,4.2,4.2
24,3,0,0,4.8,4.8
24,4,0,0,5.4,5.4
24,5,0,0,6.0,6.0
24,6,0,0,6.6,6.6
24,7,0,0,7.2,7.2
24,8,0,0,7.8,7.8
25,1,0,0,-2.4,-2.4
25,2,0,0,-1.8,-1.8
25,3,0,0,-1.2,-1.2
25,4,0,0,-0.6,-0.6
25,5,0,0,0.0,0.0
25,6,0,0,0.6,0.6
25,7,0,0,1.2,1.2
25,8,0,0,1.8,1.8
26,1,0,0,-0.9,-0.9
26,2,0,0,-0.3,-0.3
26,3,0,0,0.3,0.3
26,4,0,0,0.9,0.9
26,5,0,0,1.5,1.5
26,6,0,0,2.1,2.1
26,7,0,0,2.7,2.7
26,8,0,0,3.3,3.3
27,1,0,0,0.6,0.6
27,2,0,0,1.2,1.2
27,3,0,0,1.8,1.8
27,4,0,0,2.4,2.4
27,5,0,0,3.0,3.0
27,6,0,0,3.6,3.6
27,7,0,0,4.2,4.2
27,8,0,0,4.8,4.8
28,1,0,0,2.1,2.1
28,2,0,0,2.7,2.7
28,3,0,0,3.3,3.3
28,4,0,0,3.9,3.9
28,5,0,0,4.5,4.5
28,6,0,0,5.1,5.1
28,7,0,0,5.7,5.7
28,8,0,0,6.3,6.3
29,1,0,0,3.6,3.6
29,2,0,0,4.2,4.2
29,3,0,0,4.8,4.8
29,4,0,0,5.4,5.4
29,5,0,0,6.0,6.0
29,6,0,0,6.6,6.6
29,7,0,0,7.2,7.2
29,8,0,0,7.8,7.8
30,1,0,0,-2.4,-2.4
30,2,0,0,-1.8,-1.8
30,3,0,0,-1.2,-1.2
30,4,0,0,-0.6,-0.6
30,5,0,0,0.0,0.0
30,6,0,0,0.6,0.6
30,7,0,0,1.2,1.2
30,8,0,0,1.8,1.8
unit,time,treat,y0,y
1,1,0,5.5,5.5
1,2,1,6.0,8.0
1,3,0,6.5,6.5
1,4,1,7.0,9.0
1,5,0,7.5,7.5
1,6,1,8.0,10.0
2,1,0,6.5,6.5
2,2,1,7.0,9.0
2,3,0,7.5,7.5
2,4,1,8.0,10.0
2,5,0,8.5,8.5
2,6,1,9.0,11.0
3,1,0,4.5,4.5
3,2,1,5.0,7.0
3,3,0,5.5,5.5
3,4,1,6.0,8.0
3,5,0,6.5,6.5
3,6,1,7.0,9.0
4,1,0,5.5,5.5
4,2,1,6.0,8.0
4,3,0,6.5,6.5
4,4,1,7.0,9.0
4,5,0,7.5,7.5
4,6,1,8.0,10.0
5,1,0,6.5,6.5
5,2,1,7.0,9.0
5,3,0,7.5,7.5
5,4,1,8.0,10.0
5,5,0,8.5,8.5
5,6,1,9.0,11.0
6,1,0,4.5,4.5
6,2,1,5.0,7.0
6,3,0,5.5,5.5
6,4,1,6.0,8.0
6,5,0,6.5,6.5
6,6,1,7.0,9.0
7,1,0,5.5,5.5
7,2,1,6.0,8.0
7,3,0,6.5,6.5
7,4,1,7.0,9.0
7,5,0,7.5,7.5
7,6,1,8.0,10.0
8,1,0,6.5,6.5
8,2,1,7.0,9.0
8,3,0,7.5,7.5
8,4,1,8.0,10.0
8,5,0,8.5,8.5
8,6,1,9.0,11.0
9,1,0,4.5,4.5
9,2,1,5.0,7.0
9,3,0,5.5,5.5
9,4,1,6.0,8.0
9,5,0,6.5,6.5
9,6,1,7.0,9.0
10,1,0,5.5,5.5
10,2,1,6.0,8.0
10,3,0,6.5,6.5
10,4,1,7.0,9.0
10,5,0,7.5,7.5
10,6,1,8.0,10.0
11,1,0,6.5,6.5
11,2,1,7.0,9.0
11,3,0,7.5,7.5
11,4,1,8.0,10.0
11,5,0,8.5,8.5
11,6,1,9.0,11.0
12,1,0,4.5,4.5
12,2,1,5.0,7.0
12,3,0,5.5,5.5
12,4,1,6.0,8.0
12,5,0,6.5,6.5
12,6,1,7.0,9.0
13,1,0,5.5,5.5
13,2,1,6.0,8.0
13,3,0,6.5,6.5
13,4,1,7.0,9.0
13,5,0,7.5,7.5
13,6,1,8.0,10.0
14,1,0,6.5,6.5
14,2,1,7.0,9.0
14,3,0,7.5,7.5
14,4,1,8.0,10.0
14,5,0,8.5,8.5
14,6,1,9.0,11.0
15,1,0,4.5,4.5
15,2,1,5.0,7.0
15,3,0,5.5,5.5
15,4,1,6.0,8.0
15,5,0,6.5,6.5
15,6,1,7.0,9.0
16,1,0,5.5,5.5
16,2,1,6.0,8.0
16,3,0,6.5,6.5
16,4,1,7.0,9.0
16,5,0,7.5,7.5
16,6,1,8.0,10.0
17,1,0,6.5,6.5
17,2,1,7.0,9.0
17,3,0,7.5,7.5
17,4,1,8.0,10.0
17,5,0,8.5,8.5
17,6,1,9.0,11.0
18,1,0,4.5,4.5
18,2,1,5.0,7.0
18,3,0,5.5,5.5
18,4,1,6.0,8.0
18,5,0,6.5,6.5
18,6,1,7.0,9.0
19,1,0,5.5,5.5
19,2,1,6.0,8.0
19,3,0,6.5,6.5
19,4,1,7.0,9.0
19,5,0,7.5,7.5
19,6,1,8.0,10.0
20,1,0,6.5,6.5
20,2,1,7.0,9.0
20,3,0,7.5,7.5
20,4,1,8.0,10.0
20,5,0,8.5,8.5
20,6,1,9.0,11.0
21,1,0,-2.5,-2.5
21,2,0,-2.0,-2.0
21,3,0,-1.5,-1.5
21,4,0,-1.0,-1.0
21,5,0,-0.5,-0.5
21,6,0,0.0,0.0
22,1,0,-1.5,-1.5
22,2,0,-1.0,-1.0
22,3,0,-0.5,-0.5
22,4,0,0.0,0.0
22,5,0,0.5,0.5
22,6,0,1.0,1.0
23,1,0,-0.5,-0.5
23,2,0,0.0,0.0
23,3,0,0.5,0.5
23,4,0,1.0,1.0
23,5,0,1.5,1.5
23,6,0,2.0,2.0
24,1,0,-2.5,-2.5
24,2,0,-2.0,-2.0
24,3,0,-1.5,-1.5
24,4,0,-1.0,-1.0
24,5,0,-0.5,-0.5
24,6,0,0.0,0.0
25,1,0,-1.5,-1.5
25,2,0,-1.0,-1.0
25,3,0,-0.5,-0.5
25,4,0,0.0,0.0
25,5,0,0.5,0.5
25,6,0,1.0,1.0
26,1,0,-0.5,-0.5
26,2,0,0.0,0.0
26,3,0,0.5,0.5
26,4,0,1.0,1.0
26,5,0,1.5,1.5
26,6,0,2.0,2.0
27,1,0,-2.5,-2.5
27,2,0,-2.0,-2.0
27,3,0,-1.5,-1.5
27,4,0,-1.0,-1.0
27,5,0,-0.5,-0.5
27,6,0,0.0,0.0
28,1,0,-1.5,-1.5
28,2,0,-1.0,-1.0
28,3,0,-0.5,-0.5
28,4,0,0.0,0.0
28,5,0,0.5,0.5
28,6,0,1.0,1.0
29,1,0,-0.5,-0.5
29,2,0,0.0,0.0
29,3,0,0.5,0.5
29,4,0,1.0,1.0
29,5,0,1.5,1.5
29,6,0,2.0,2.0
30,1,0,-2.5,-2.5
30,2,0,-2.0,-2.0
30,3,0,-1.5,-1.5
30,4,0,-1.0,-1.0
30,5,0,-0.5,-0.5
30,6,0,0.0,0.0
31,1,0,-1.5,-1.5
31,2,0,-1.0,-1.0
31,3,0,-0.5,-0.5
31,4,0,0.0,0.0
31,5,0,0.5,0.5
31,6,0,1.0,1.0
32,1,0,-0.5,-0.5
32,2,0,0.0,0.0
32,3,0,0.5,0.5
32,4,0,1.0,1.0
32,5,0,1.5,1.5
32,6,0,2.0,2.0
33,1,0,-2.5,-2.5
33,2,0,-2.0,-2.0
33,3,0,-1.5,-1.5
33,4,0,-1.0,-1.0
33,5,0,-0.5,-0.5
33,6,0,0.0,0.0
34,1,0,-1.5,-1.5
34,2,0,-1.0,-1.0
34,3,0,-0.5,-0.5
34,4,0,0.0,0.0
34,5,0,0.5,0.5
34,6,0,1.0,1.0
35,1,0,-0.5,-0.5
35,2,0,0.0,0.0
35,3,0,0.5,0.5
35,4,0,1.0,1.0
35,5,0,1.5,1.5
35,6,0,2.0,2.0
36,1,0,-2.5,-2.5
36,2,0,-2.0,-2.0
36,3,0,-1.5,-1.5
36,4,0,-1.0,-1.0
36,5,0,-0.5,-0.5
36,6,0,0.0,0.0
37,1,0,-1.5,-1.5
37,2,0,-1.0,-1.0
37,3,0,-0.5,-0.5
37,4,0,0.0,0.0
37,5,0,0.5,0.5
37,6,0,1.0,1.0
38,1,0,-0.5,-0.5
38,2,0,0.0,0.0
38,3,0,0.5,0.5
38,4,0,1.0,1.0
38,5,0,1.5,1.5
38,6,0,2.0,2.0
39,1,0,-2.5,-2.5
39,2,0,-2.0,-2.0
39,3,0,-1.5,-1.5
39,4,0,-1.0,-1.0
39,5,0,-0.5,-0.5
39,6,0,0.0,0.0
40,1,0,-1.5,-1.5
40,2,0,-1.0,-1.0
40,3,0,-0.5,-0.5
40,4,0,0.0,0.0
40,5,0,0.5,0.5
40,6,0,1.0,1.0
x,treat,y0,y
-1.0,0,-1.0,-1.0
-0.98,0,-0.98,-0.98
-0.96,0,-0.96,-0.96
-0.94,0,-0.94,-0.94
-0.92,0,-0.92,-0.92
-0.9,0,-0.9,-0.9
-0.88,0,-0.88,-0.88
-0.86,0,-0.86,-0.86
-0.84,0,-0.84,-0.84
-0.82,0,-0.82,-0.82
-0.8,0,-0.8,-0.8
-0.78,0,-0.78,-0.78
-0.76,0,-0.76,-0.76
-0.74,0,-0.74,-0.74
-0.72,0,-0.72,-0.72
-0.7,0,-0.7,-0.7
-0.68,0,-0.68,-0.68
-0.66,0,-0.66,-0.66
-0.64,0,-0.64,-0.64
-0.62,0,-0.62,-0.62
-0.6,0,-0.6,-0.6
-0.58,0,-0.58,-0.58
-0.56,0,-0.56,-0.56
-0.54,0,-0.54,-0.54
-0.52,0,-0.52,-0.52
-0.5,0,-0.5,-0.5
-0.48,0,-0.48,-0.48
-0.46,0,-0.46,-0.46
-0.44,0,-0.44,-0.44
-0.42,0,-0.42,-0.42
-0.4,0,-0.4,-0.4
-0.38,0,-0.38,-0.38
-0.36,0,-0.36,-0.36
-0.34,0,-0.34,-0.34
-0.32,0,-0.32,-0.32
-0.3,0,-0.3,-0.3
-0.28,0,-0.28,-0.28
-0.26,0,-0.26,-0.26
-0.24,0,-0.24,-0.24
-0.22,0,-0.22,-0.22
-0.2,0,-0.2,-0.2
-0.18,0,-0.18,-0.18
-0.16,0,-0.16,-0.16
-0.14,0,-0.14,-0.14
-0.12,0,-0.12,-0.12
-0.1,0,-0.1,-0.1
-0.08,0,-0.08,-0.08
-0.06,0,-0.06,-0.06
-0.04,0,-0.04,-0.04
-0.02,0,-0.02,-0.02
0.0,1,0.0,3.0
0.02,1,0.08,3.08
0.04,1,0.16,3.16
0.06,1,0.24,3.24
0.08,1,0.32,3.32
0.1,1,0.4,3.4
0.12,1,0.48,3.48
0.14,1,0.56,3.56
0.16,1,0.64,3.64
0.18,1,0.72,3.72
0.2,1,0.8,3.8
0.22,1,0.88,3.88
0.24,1,0.96,3.96
0.26,1,1.04,4.04
0.28,1,1.12,4.12
0.3,1,1.2,4.2
0.32,1,1.28,4.28
0.34,1,1.36,4.36
0.36,1,1.44,4.44
0.38,1,1.52,4.52
0.4,1,1.6,4.6
0.42,1,1.68,4.68
0.44,1,1.76,4.76
0.46,1,1.84,4.84
0.48,1,1.92,4.92
0.5,1,2.0,5.0
0.52,1,2.08,5.08
0.54,1,2.16,5.16
0.56,1,2.24,5.24
0.58,1,2.32,5.32
0.6,1,2.4,5.4
0.62,1,2.48,5.48
0.64,1,2.56,5.56
0.66,1,2.64,5.64
0.68,1,2.72,5.72
0.7,1,2.8,5.8
0.72,1,2.88,5.88
0.74,1,2.96,5.96
0.76,1,3.04,6.04
0.78,1,3.12,6.12
0.8,1,3.2,6.2
0.82,1,3.28,6.28
0.84,1,3.36,6.36
0.86,1,3.44,6.44
0.88,1,3.52,6.52
0.9,1,3.6,6.6
0.92,1,3.68,6.68
0.94,1,3.76,6.76
0.96,1,3.84,6.84
0.98,1,3.92,6.92
1.0,1,4.0,7.0
unit,time,cohort,treat,y0,y
1,1,4,0,-0.2,-0.2
1,2,4,0,0.3,0.3
1,3,4,0,0.8,0.8
1,4,4,1,1.3,2.3
1,5,4,1,1.8,3.8
1,6,4,1,2.3,5.3
1,7,4,1,2.8,6.8
1,8,4,1,3.3,8.3
1,9,4,1,3.8,9.8
1,10,4,1,4.3,11.3
2,1,4,0,1.1,1.1
2,2,4,0,1.6,1.6
2,3,4,0,2.1,2.1
2,4,4,1,2.6,3.6
2,5,4,1,3.1,5.1
2,6,4,1,3.6,6.6
2,7,4,1,4.1,8.1
2,8,4,1,4.6,9.6
2,9,4,1,5.1,11.1
2,10,4,1,5.6,12.6
3,1,4,0,2.4,2.4
3,2,4,0,2.9,2.9
3,3,4,0,3.4,3.4
3,4,4,1,3.9,4.9
3,5,4,1,4.4,6.4
3,6,4,1,4.9,7.9
3,7,4,1,5.4,9.4
3,8,4,1,5.9,10.9
3,9,4,1,6.4,12.4
3,10,4,1,6.9,13.9
4,1,4,0,3.7,3.7
4,2,4,0,4.2,4.2
4,3,4,0,4.7,4.7
4,4,4,1,5.2,6.2
4,5,4,1,5.7,7.7
4,6,4,1,6.2,9.2
4,7,4,1,6.7,10.7
4,8,4,1,7.2,12.2
4,9,4,1,7.7,13.7
4,10,4,1,8.2,15.2
5,1,4,0,5.0,5.0
5,2,4,0,5.5,5.5
5,3,4,0,6.0,6.0
5,4,4,1,6.5,7.5
5,5,4,1,7.0,9.0
5,6,4,1,7.5,10.5
5,7,4,1,8.0,12.0
5,8,4,1,8.5,13.5
5,9,4,1,9.0,15.0
5,10,4,1,9.5,16.5
6,1,4,0,6.3,6.3
6,2,4,0,6.8,6.8
6,3,4,0,7.3,7.3
6,4,4,1,7.8,8.8
6,5,4,1,8.3,10.3
6,6,4,1,8.8,11.8
6,7,4,1,9.3,13.3
6,8,4,1,9.8,14.8
6,9,4,1,10.3,16.3
6,10,4,1,10.8,17.8
7,1,4,0,-1.5,-1.5
7,2,4,0,-1.0,-1.0
7,3,4,0,-0.5,-0.5
7,4,4,1,0.0,1.0
7,5,4,1,0.5,2.5
7,6,4,1,1.0,4.0
7,7,4,1,1.5,5.5
7,8,4,1,2.0,7.0
7,9,4,1,2.5,8.5
7,10,4,1,3.0,10.0
8,1,4,0,-0.2,-0.2
8,2,4,0,0.3,0.3
8,3,4,0,0.8,0.8
8,4,4,1,1.3,2.3
8,5,4,1,1.8,3.8
8,6,4,1,2.3,5.3
8,7,4,1,2.8,6.8
8,8,4,1,3.3,8.3
8,9,4,1,3.8,9.8
8,10,4,1,4.3,11.3
9,1,4,0,1.1,1.1
9,2,4,0,1.6,1.6
9,3,4,0,2.1,2.1
9,4,4,1,2.6,3.6
9,5,4,1,3.1,5.1
9,6,4,1,3.6,6.6
9,7,4,1,4.1,8.1
9,8,4,1,4.6,9.6
9,9,4,1,5.1,11.1
9,10,4,1,5.6,12.6
10,1,4,0,2.4,2.4
10,2,4,0,2.9,2.9
10,3,4,0,3.4,3.4
10,4,4,1,3.9,4.9
10,5,4,1,4.4,6.4
10,6,4,1,4.9,7.9
10,7,4,1,5.4,9.4
10,8,4,1,5.9,10.9
10,9,4,1,6.4,12.4
10,10,4,1,6.9,13.9
11,1,4,0,3.7,3.7
11,2,4,0,4.2,4.2
11,3,4,0,4.7,4.7
11,4,4,1,5.2,6.2
11,5,4,1,5.7,7.7
11,6,4,1,6.2,9.2
11,7,4,1,6.7,10.7
11,8,4,1,7.2,12.2
11,9,4,1,7.7,13.7
11,10,4,1,8.2,15.2
12,1,4,0,5.0,5.0
12,2,4,0,5.5,5.5
12,3,4,0,6.0,6.0
12,4,4,1,6.5,7.5
12,5,4,1,7.0,9.0
12,6,4,1,7.5,10.5
12,7,4,1,8.0,12.0
12,8,4,1,8.5,13.5
12,9,4,1,9.0,15.0
12,10,4,1,9.5,16.5
13,1,4,0,6.3,6.3
13,2,4,0,6.8,6.8
13,3,4,0,7.3,7.3
13,4,4,1,7.8,8.8
13,5,4,1,8.3,10.3
13,6,4,1,8.8,11.8
13,7,4,1,9.3,13.3
13,8,4,1,9.8,14.8
13,9,4,1,10.3,16.3
13,10,4,1,10.8,17.8
14,1,4,0,-1.5,-1.5
14,2,4,0,-1.0,-1.0
14,3,4,0,-0.5,-0.5
14,4,4,1,0.0,1.0
14,5,4,1,0.5,2.5
14,6,4,1,1.0,4.0
14,7,4,1,1.5,5.5
14,8,4,1,2.0,7.0
14,9,4,1,2.5,8.5
14,10,4,1,3.0,10.0
15,1,4,0,-0.2,-0.2
15,2,4,0,0.3,0.3
15,3,4,0,0.8,0.8
15,4,4,1,1.3,2.3
15,5,4,1,1.8,3.8
15,6,4,1,2.3,5.3
15,7,4,1,2.8,6.8
15,8,4,1,3.3,8.3
15,9,4,1,3.8,9.8
15,10,4,1,4.3,11.3
16,1,4,0,1.1,1.1
16,2,4,0,1.6,1.6
16,3,4,0,2.1,2.1
16,4,4,1,2.6,3.6
16,5,4,1,3.1,5.1
16,6,4,1,3.6,6.6
16,7,4,1,4.1,8.1
16,8,4,1,4.6,9.6
16,9,4,1,5.1,11.1
16,10,4,1,5.6,12.6
17,1,4,0,2.4,2.4
17,2,4,0,2.9,2.9
17,3,4,0,3.4,3.4
17,4,4,1,3.9,4.9
17,5,4,1,4.4,6.4
17,6,4,1,4.9,7.9
17,7,4,1,5.4,9.4
17,8,4,1,5.9,10.9
17,9,4,1,6.4,12.4
17,10,4,1,6.9,13.9
18,1,4,0,3.7,3.7
18,2,4,0,4.2,4.2
18,3,4,0,4.7,4.7
18,4,4,1,5.2,6.2
18,5,4,1,5.7,7.7
18,6,4,1,6.2,9.2
18,7,4,1,6.7,10.7
18,8,4,1,7.2,12.2
18,9,4,1,7.7,13.7
18,10,4,1,8.2,15.2
19,1,4,0,5.0,5.0
19,2,4,0,5.5,5.5
19,3,4,0,6.0,6.0
19,4,4,1,6.5,7.5
19,5,4,1,7.0,9.0
19,6,4,1,7.5,10.5
19,7,4,1,8.0,12.0
19,8,4,1,8.5,13.5
19,9,4,1,9.0,15.0
19,10,4,1,9.5,16.5
20,1,4,0,6.3,6.3
20,2,4,0,6.8,6.8
20,3,4,0,7.3,7.3
20,4,4,1,7.8,8.8
20,5,4,1,8.3,10.3
20,6,4,1,8.8,11.8
20,7,4,1,9.3,13.3
20,8,4,1,9.8,14.8
20,9,4,1,10.3,16.3
20,10,4,1,10.8,17.8
21,1,7,0,-1.5,-1.5
21,2,7,0,-1.0,-1.0
21,3,7,0,-0.5,-0.5
21,4,7,0,0.0,0.0
21,5,7,0,0.5,0.5
21,6,7,0,1.0,1.0
21,7,7,1,1.5,1.9
21,8,7,1,2.0,2.8
21,9,7,1,2.5,3.7
21,10,7,1,3.0,4.6
22,1,7,0,-0.2,-0.2
22,2,7,0,0.3,0.3
22,3,7,0,0.8,0.8
22,4,7,0,1.3,1.3
22,5,7,0,1.8,1.8
22,6,7,0,2.3,2.3
22,7,7,1,2.8,3.2
22,8,7,1,3.3,4.1
22,9,7,1,3.8,5.0
22,10,7,1,4.3,5.9
23,1,7,0,1.1,1.1
23,2,7,0,1.6,1.6
23,3,7,0,2.1,2.1
23,4,7,0,2.6,2.6
23,5,7,0,3.1,3.1
23,6,7,0,3.6,3.6
23,7,7,1,4.1,4.5
23,8,7,1,4.6,5.4
23,9,7,1,5.1,6.3
23,10,7,1,5.6,7.2
24,1,7,0,2.4,2.4
24,2,7,0,2.9,2.9
24,3,7,0,3.4,3.4
24,4,7,0,3.9,3.9
24,5,7,0,4.4,4.4
24,6,7,0,4.9,4.9
24,7,7,1,5.4,5.8
24,8,7,1,5.9,6.7
24,9,7,1,6.4,7.6
24,10,7,1,6.9,8.5
25,1,7,0,3.7,3.7
25,2,7,0,4.2,4.2
25,3,7,0,4.7,4.7
25,4,7,0,5.2,5.2
25,5,7,0,5.7,5.7
25,6,7,0,6.2,6.2
25,7,7,1,6.7,7.1
25,8,7,1,7.2,8.0
25,9,7,1,7.7,8.9
25,10,7,1,8.2,9.8
26,1,7,0,5.0,5.0
26,2,7,0,5.5,5.5
26,3,7,0,6.0,6.0
26,4,7,0,6.5,6.5
26,5,7,0,7.0,7.0
26,6,7,0,7.5,7.5
26,7,7,1,8.0,8.4
26,8,7,1,8.5,9.3
26,9,7,1,9.0,10.2
26,10,7,1,9.5,11.1
27,1,7,0,6.3,6.3
27,2,7,0,6.8,6.8
27,3,7,0,7.3,7.3
27,4,7,0,7.8,7.8
27,5,7,0,8.3,8.3
27,6,7,0,8.8,8.8
27,7,7,1,9.3,9.7
27,8,7,1,9.8,10.6
27,9,7,1,10.3,11.5
27,10,7,1,10.8,12.4
28,1,7,0,-1.5,-1.5
28,2,7,0,-1.0,-1.0
28,3,7,0,-0.5,-0.5
28,4,7,0,0.0,0.0
28,5,7,0,0.5,0.5
28,6,7,0,1.0,1.0
28,7,7,1,1.5,1.9
28,8,7,1,2.0,2.8
28,9,7,1,2.5,3.7
28,10,7,1,3.0,4.6
29,1,7,0,-0.2,-0.2
29,2,7,0,0.3,0.3
29,3,7,0,0.8,0.8
29,4,7,0,1.3,1.3
29,5,7,0,1.8,1.8
29,6,7,0,2.3,2.3
29,7,7,1,2.8,3.2
29,8,7,1,3.3,4.1
29,9,7,1,3.8,5.0
29,10,7,1,4.3,5.9
30,1,7,0,1.1,1.1
30,2,7,0,1.6,1.6
30,3,7,0,2.1,2.1
30,4,7,0,2.6,2.6
30,5,7,0,3.1,3.1
30,6,7,0,3.6,3.6
30,7,7,1,4.1,4.5
30,8,7,1,4.6,5.4
30,9,7,1,5.1,6.3
30,10,7,1,5.6,7.2
31,1,7,0,2.4,2.4
31,2,7,0,2.9,2.9
31,3,7,0,3.4,3.4
31,4,7,0,3.9,3.9
31,5,7,0,4.4,4.4
31,6,7,0,4.9,4.9
31,7,7,1,5.4,5.8
31,8,7,1,5.9,6.7
31,9,7,1,6.4,7.6
31,10,7,1,6.9,8.5
32,1,7,0,3.7,3.7
32,2,7,0,4.2,4.2
32,3,7,0,4.7,4.7
32,4,7,0,5.2,5.2
32,5,7,0,5.7,5.7
32,6,7,0,6.2,6.2
32,7,7,1,6.7,7.1
32,8,7,1,7.2,8.0
32,9,7,1,7.7,8.9
32,10,7,1,8.2,9.8
33,1,7,0,5.0,5.0
33,2,7,0,5.5,5.5
33,3,7,0,6.0,6.0
33,4,7,0,6.5,6.5
33,5,7,0,7.0,7.0
33,6,7,0,7.5,7.5
33,7,7,1,8.0,8.4
33,8,7,1,8.5,9.3
33,9,7,1,9.0,10.2
33,10,7,1,9.5,11.1
34,1,7,0,6.3,6.3
34,2,7,0,6.8,6.8
34,3,7,0,7.3,7.3
34,4,7,0,7.8,7.8
34,5,7,0,8.3,8.3
34,6,7,0,8.8,8.8
34,7,7,1,9.3,9.7
34,8,7,1,9.8,10.6
34,9,7,1,10.3,11.5
34,10,7,1,10.8,12.4
35,1,7,0,-1.5,-1.5
35,2,7,0,-1.0,-1.0
35,3,7,0,-0.5,-0.5
35,4,7,0,0.0,0.0
35,5,7,0,0.5,0.5
35,6,7,0,1.0,1.0
35,7,7,1,1.5,1.9
35,8,7,1,2.0,2.8
35,9,7,1,2.5,3.7
35,10,7,1,3.0,4.6
36,1,7,0,-0.2,-0.2
36,2,7,0,0.3,0.3
36,3,7,0,0.8,0.8
36,4,7,0,1.3,1.3
36,5,7,0,1.8,1.8
36,6,7,0,2.3,2.3
36,7,7,1,2.8,3.2
36,8,7,1,3.3,4.1
36,9,7,1,3.8,5.0
36,10,7,1,4.3,5.9
37,1,7,0,1.1,1.1
37,2,7,0,1.6,1.6
37,3,7,0,2.1,2.1
37,4,7,0,2.6,2.6
37,5,7,0,3.1,3.1
37,6,7,0,3.6,3.6
37,7,7,1,4.1,4.5
37,8,7,1,4.6,5.4
37,9,7,1,5.1,6.3
37,10,7,1,5.6,7.2
38,1,7,0,2.4,2.4
38,2,7,0,2.9,2.9
38,3,7,0,3.4,3.4
38,4,7,0,3.9,3.9
38,5,7,0,4.4,4.4
38,6,7,0,4.9,4.9
38,7,7,1,5.4,5.8
38,8,7,1,5.9,6.7
38,9,7,1,6.4,7.6
38,10,7,1,6.9,8.5
39,1,7,0,3.7,3.7
39,2,7,0,4.2,4.2
39,3,7,0,4.7,4.7
39,4,7,0,5.2,5.2
39,5,7,0,5.7,5.7
39,6,7,0,6.2,6.2
39,7,7,1,6.7,7.1
39,8,7,1,7.2,8.0
39,9,7,1,7.7,8.9
39,10,7,1,8.2,9.8
40,1,7,0,5.0,5.0
40,2,7,0,5.5,5.5
40,3,7,0,6.0,6.0
40,4,7,0,6.5,6.5
40,5,7,0,7.0,7.0
40,6,7,0,7.5,7.5
40,7,7,1,8.0,8.4
40,8,7,1,8.5,9.3
40,9,7,1,9.0,10.2
40,10,7,1,9.5,11.1
41,1,0,0,6.3,6.3
41,2,0,0,6.8,6.8
41,3,0,0,7.3,7.3
41,4,0,0,7.8,7.8
41,5,0,0,8.3,8.3
41,6,0,0,8.8,8.8
41,7,0,0,9.3,9.3
41,8,0,0,9.8,9.8
41,9,0,0,10.3,10.3
41,10,0,0,10.8,10.8
42,1,0,0,-1.5,-1.5
42,2,0,0,-1.0,-1.0
42,3,0,0,-0.5,-0.5
42,4,0,0,0.0,0.0
42,5,0,0,0.5,0.5
42,6,0,0,1.0,1.0
42,7,0,0,1.5,1.5
42,8,0,0,2.0,2.0
42,9,0,0,2.5,2.5
42,10,0,0,3.0,3.0
43,1,0,0,-0.2,-0.2
43,2,0,0,0.3,0.3
43,3,0,0,0.8,0.8
43,4,0,0,1.3,1.3
43,5,0,0,1.8,1.8
43,6,0,0,2.3,2.3
43,7,0,0,2.8,2.8
43,8,0,0,3.3,3.3
43,9,0,0,3.8,3.8
43,10,0,0,4.3,4.3
44,1,0,0,1.1,1.1
44,2,0,0,1.6,1.6
44,3,0,0,2.1,2.1
44,4,0,0,2.6,2.6
44,5,0,0,3.1,3.1
44,6,0,0,3.6,3.6
44,7,0,0,4.1,4.1
44,8,0,0,4.6,4.6
44,9,0,0,5.1,5.1
44,10,0,0,5.6,5.6
45,1,0,0,2.4,2.4
45,2,0,0,2.9,2.9
45,3,0,0,3.4,3.4
45,4,0,0,3.9,3.9
45,5,0,0,4.4,4.4
45,6,0,0,4.9,4.9
45,7,0,0,5.4,5.4
45,8,0,0,5.9,5.9
45,9,0,0,6.4,6.4
45,10,0,0,6.9,6.9
46,1,0,0,3.7,3.7
46,2,0,0,4.2,4.2
46,3,0,0,4.7,4.7
46,4,0,0,5.2,5.2
46,5,0,0,5.7,5.7
46,6,0,0,6.2,6.2
46,7,0,0,6.7,6.7
46,8,0,0,7.2,7.2
46,9,0,0,7.7,7.7
46,10,0,0,8.2,8.2
47,1,0,0,5.0,5.0
47,2,0,0,5.5,5.5
47,3,0,0,6.0,6.0
47,4,0,0,6.5,6.5
47,5,0,0,7.0,7.0
47,6,0,0,7.5,7.5
47,7,0,0,8.0,8.0
47,8,0,0,8.5,8.5
47,9,0,0,9.0,9.0
47,10,0,0,9.5,9.5
48,1,0,0,6.3,6.3
48,2,0,0,6.8,6.8
48,3,0,0,7.3,7.3
48,4,0,0,7.8,7.8
48,5,0,0,8.3,8.3
48,6,0,0,8.8,8.8
48,7,0,0,9.3,9.3
48,8,0,0,9.8,9.8
48,9,0,0,10.3,10.3
48,10,0,0,10.8,10.8
49,1,0,0,-1.5,-1.5
49,2,0,0,-1.0,-1.0
49,3,0,0,-0.5,-0.5
49,4,0,0,0.0,0.0
49,5,0,0,0.5,0.5
49,6,0,0,1.0,1.0
49,7,0,0,1.5,1.5
49,8,0,0,2.0,2.0
49,9,0,0,2.5,2.5
49,10,0,0,3.0,3.0
50,1,0,0,-0.2,-0.2
50,2,0,0,0.3,0.3
50,3,0,0,0.8,0.8
50,4,0,0,1.3,1.3
50,5,0,0,1.8,1.8
50,6,0,0,2.3,2.3
50,7,0,0,2.8,2.8
50,8,0,0,3.3,3.3
50,9,0,0,3.8,3.8
50,10,0,0,4.3,4.3
51,1,0,0,1.1,1.1
51,2,0,0,1.6,1.6
51,3,0,0,2.1,2.1
51,4,0,0,2.6,2.6
51,5,0,0,3.1,3.1
51,6,0,0,3.6,3.6
51,7,0,0,4.1,4.1
51,8,0,0,4.6,4.6
51,9,0,0,5.1,5.1
51,10,0,0,5.6,5.6
52,1,0,0,2.4,2.4
52,2,0,0,2.9,2.9
52,3,0,0,3.4,3.4
52,4,0,0,3.9,3.9
52,5,0,0,4.4,4.4
52,6,0,0,4.9,4.9
52,7,0,0,5.4,5.4
52,8,0,0,5.9,5.9
52,9,0,0,6.4,6.4
52,10,0,0,6.9,6.9
53,1,0,0,3.7,3.7
53,2,0,0,4.2,4.2
53,3,0,0,4.7,4.7
53,4,0,0,5.2,5.2
53,5,0,0,5.7,5.7
53,6,0,0,6.2,6.2
53,7,0,0,6.7,6.7
53,8,0,0,7.2,7.2
53,9,0,0,7.7,7.7
53,10,0,0,8.2,8.2
54,1,0,0,5.0,5.0
54,2,0,0,5.5,5.5
54,3,0,0,6.0,6.0
54,4,0,0,6.5,6.5
54,5,0,0,7.0,7.0
54,6,0,0,7.5,7.5
54,7,0,0,8.0,8.0
54,8,0,0,8.5,8.5
54,9,0,0,9.0,9.0
54,10,0,0,9.5,9.5
55,1,0,0,6.3,6.3
55,2,0,0,6.8,6.8
55,3,0,0,7.3,7.3
55,4,0,0,7.8,7.8
55,5,0,0,8.3,8.3
55,6,0,0,8.8,8.8
55,7,0,0,9.3,9.3
55,8,0,0,9.8,9.8
55,9,0,0,10.3,10.3
55,10,0,0,10.8,10.8
56,1,0,0,-1.5,-1.5
56,2,0,0,-1.0,-1.0
56,3,0,0,-0.5,-0.5
56,4,0,0,0.0,0.0
56,5,0,0,0.5,0.5
56,6,0,0,1.0,1.0
56,7,0,0,1.5,1.5
56,8,0,0,2.0,2.0
56,9,0,0,2.5,2.5
56,10,0,0,3.0,3.0
57,1,0,0,-0.2,-0.2
57,2,0,0,0.3,0.3
57,3,0,0,0.8,0.8
57,4,0,0,1.3,1.3
57,5,0,0,1.8,1.8
57,6,0,0,2.3,2.3
57,7,0,0,2.8,2.8
57,8,0,0,3.3,3.3
57,9,0,0,3.8,3.8
57,10,0,0,4.3,4.3
58,1,0,0,1.1,1.1
58,2,0,0,1.6,1.6
58,3,0,0,2.1,2.1
58,4,0,0,2.6,2.6
58,5,0,0,3.1,3.1
58,6,0,0,3.6,3.6
58,7,0,0,4.1,4.1
58,8,0,0,4.6,4.6
58,9,0,0,5.1,5.1
58,10,0,0,5.6,5.6
59,1,0,0,2.4,2.4
59,2,0,0,2.9,2.9
59,3,0,0,3.4,3.4
59,4,0,0,3.9,3.9
59,5,0,0,4.4,4.4
59,6,0,0,4.9,4.9
59,7,0,0,5.4,5.4
59,8,0,0,5.9,5.9
59,9,0,0,6.4,6.4
59,10,0,0,6.9,6.9
60,1,0,0,3.7,3.7
60,2,0,0,4.2,4.2
60,3,0,0,4.7,4.7
60,4,0,0,5.2,5.2
60,5,0,0,5.7,5.7
60,6,0,0,6.2,6.2
60,7,0,0,6.7,6.7
60,8,0,0,7.2,7.2
60,9,0,0,7.7,7.7
60,10,0,0,8.2,8.2
id,treat,time,event,t_event
1,0,1,1,1
2,0,2,1,2
3,0,3,1,3
4,0,7,1,7
5,0,1,1,1
6,0,2,1,2
7,0,7,1,7
8,0,1,1,1
9,0,2,1,2
10,0,2,0,3
11,0,7,1,7
12,0,1,1,1
13,0,3,1,3
14,0,7,1,7
15,0,1,1,1
16,0,2,1,2
17,0,4,1,4
18,0,7,1,7
19,0,1,1,1
20,0,2,0,3
21,0,7,1,7
22,0,1,1,1
23,0,2,1,2
24,0,4,1,4
25,0,2,0,7
26,0,1,1,1
27,0,3,1,3
28,0,7,1,7
29,0,1,1,1
30,0,2,1,2
31,0,4,1,4
32,0,7,1,7
33,0,1,1,1
34,0,3,1,3
35,0,2,0,7
36,0,1,1,1
37,0,2,1,2
38,0,4,1,4
39,0,7,1,7
40,0,1,1,1
41,0,3,1,3
42,0,7,1,7
43,0,1,1,1
44,0,2,1,2
45,0,2,0,4
46,0,7,1,7
47,0,1,1,1
48,0,3,1,3
49,0,7,1,7
50,0,1,1,1
51,0,2,1,2
52,0,4,1,4
53,0,7,1,7
54,0,1,1,1
55,0,2,0,3
56,0,7,1,7
57,0,1,1,1
58,0,2,1,2
59,0,4,1,4
60,0,2,0,7
61,0,2,1,2
62,0,3,1,3
63,0,7,1,7
64,0,1,1,1
65,0,2,1,2
66,0,4,1,4
67,0,7,1,7
68,0,2,1,2
69,0,3,1,3
70,0,2,0,7
71,0,1,1,1
72,0,2,1,2
73,0,7,1,7
74,0,7,1,7
75,0,2,1,2
76,0,3,1,3
77,0,7,1,7
78,0,1,1,1
79,0,2,1,2
80,0,2,0,7
81,1,1,1,1
82,1,3,1,3
83,1,7,1,7
84,1,7,1,7
85,1,2,1,2
86,1,7,1,7
87,1,7,1,7
88,1,1,1,1
89,1,4,1,4
90,1,2,0,7
91,1,7,1,7
92,1,2,1,2
93,1,7,1,7
94,1,7,1,7
95,1,1,1,1
96,1,4,1,4
97,1,7,1,7
98,1,7,1,7
99,1,2,1,2
100,1,2,0,7
101,1,7,1,7
102,1,1,1,1
103,1,4,1,4
104,1,7,1,7
105,1,2,0,7
106,1,2,1,2
107,1,7,1,7
108,1,7,1,7
109,1,1,1,1
110,1,2,0,4
111,1,7,1,7
112,1,7,1,7
113,1,2,1,2
114,1,7,1,7
115,1,2,0,7
116,1,1,1,1
117,1,7,1,7
118,1,7,1,7
119,1,7,1,7
120,1,2,1,2
121,1,7,1,7
122,1,7,1,7
123,1,1,1,1
124,1,7,1,7
125,1,2,0,7
126,1,7,1,7
127,1,3,1,3
128,1,7,1,7
129,1,7,1,7
130,1,1,1,1
131,1,7,1,7
132,1,7,1,7
133,1,7,1,7
134,1,3,1,3
135,1,2,0,7
136,1,7,1,7
137,1,1,1,1
138,1,7,1,7
139,1,7,1,7
140,1,2,0,7
141,1,3,1,3
142,1,7,1,7
143,1,7,1,7
144,1,1,1,1
145,1,2,0,7
146,1,7,1,7
147,1,7,1,7
148,1,3,1,3
149,1,7,1,7
150,1,2,0,7
151,1,2,1,2
152,1,7,1,7
153,1,7,1,7
154,1,7,1,7
155,1,2,0,3
156,1,7,1,7
157,1,7,1,7
158,1,2,1,2
159,1,7,1,7
160,1,2,0,7
time,post,treated_y,donor_a,donor_b,donor_c,treated_y0
1,0,12.0,10.0,20.0,5.0,12.0
2,0,13.3,12.0,19.0,8.0,13.3
3,0,13.6,14.0,18.0,6.0,13.6
4,0,14.9,16.0,17.0,9.0,14.9
5,1,18.2,18.0,16.0,7.0,15.2
6,1,19.5,20.0,15.0,10.0,16.5
7,1,19.8,22.0,14.0,8.0,16.8
8,1,21.1,24.0,13.0,11.0,18.1
#!/usr/bin/env python3
"""Pure-stdlib good-vs-bad-control computations (post-treatment / mediator bias).
The simulated cross-section is deterministic and noiseless. Treatment ``d`` is
assigned orthogonally to the pre-treatment covariate ``x`` (so there is NO
omitted-variable confounding), and a mediator ``m`` sits on the causal path
``d -> m -> y``. Because the data is exactly linear and noiseless, the true total
effect of ``d`` on ``y`` is known by construction and shipped as a pair of
potential-outcome columns (``y0``, ``y1``) the estimators never read.
The encoded trap is the "bad control" / post-treatment-bias lesson (Cinelli,
Forney & Pearl 2022, *A Crash Course in Good and Bad Controls*; Angrist &
Pischke, *Mostly Harmless Econometrics*, on bad controls): conditioning on a
descendant of the treatment (here the mediator ``m``) violates the back-door
criterion and biases the estimate away from the total effect toward the direct
effect. Controlling for the *pre-treatment* covariate ``x`` is harmless here,
which is the point: it is not "all post-treatment variables are bad" but
specifically that conditioning on a mediator changes the estimand.
Structural equations (noiseless):
m = 1.0*d + 0.5*x + e # mediator: caused by treatment and x
y = 0.5*d + 2.0*m + 1.0*x # outcome
=> total effect of d on y = 0.5 (direct) + 2.0 * 1.0 (through m) = 2.5
direct effect of d on y = 0.5 (what y ~ d + x + m recovers)
"""
from __future__ import annotations
import csv
from pathlib import Path
import lalonde # sibling module in benchmark/lib
N_UNITS = 120 # multiple of lcm(2, 6, 5) = 30 so d _|_ (x, e) in-sample
DIRECT_EFFECT = 0.5 # d -> y direct
MEDIATOR_FROM_D = 1.0 # d -> m
MEDIATOR_FROM_X = 0.5 # x -> m
OUTCOME_FROM_M = 2.0 # m -> y
OUTCOME_FROM_X = 1.0 # x -> y
TOTAL_EFFECT = DIRECT_EFFECT + OUTCOME_FROM_M * MEDIATOR_FROM_D # 2.5 (informational)
def _exog(i: int) -> float:
# Centered exogenous component of the mediator, independent of d and x.
return round(((i % 5) - 2) * 0.3, 4)
def generate() -> list[dict]:
rows: list[dict] = []
for i in range(N_UNITS):
x = (i // 2) % 3 # pre-treatment covariate, orthogonal to d
d = i % 2 # treatment, alternating
e = _exog(i)
# Potential mediators / outcomes under each treatment status.
m1 = MEDIATOR_FROM_D * 1 + MEDIATOR_FROM_X * x + e
m0 = MEDIATOR_FROM_D * 0 + MEDIATOR_FROM_X * x + e
y1 = DIRECT_EFFECT * 1 + OUTCOME_FROM_M * m1 + OUTCOME_FROM_X * x
y0 = DIRECT_EFFECT * 0 + OUTCOME_FROM_M * m0 + OUTCOME_FROM_X * x
m = m1 if d == 1 else m0
y = y1 if d == 1 else y0
rows.append({
"x": x,
"d": d,
"m": round(m, 4),
"y": round(y, 4),
"y0": round(y0, 4),
"y1": round(y1, 4),
})
return rows
def write_csv(path: Path) -> None:
rows = generate()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=["x", "d", "m", "y", "y0", "y1"])
writer.writeheader()
writer.writerows(rows)
def load(data_path: Path) -> list[dict]:
with data_path.open(encoding="utf-8") as fh:
return list(csv.DictReader(fh))
def _num(row: dict, key: str) -> float:
return float(row[key])
def true_total(rows: list[dict]) -> float:
"""True average total effect recomputed from the shipped potential outcomes."""
diffs = [_num(r, "y1") - _num(r, "y0") for r in rows]
return sum(diffs) / len(diffs)
def _effect(rows: list[dict], regressors: list[str]) -> float:
"""OLS coefficient on d for y ~ 1 + d + regressors."""
design, outcome = [], []
for r in rows:
design.append([1.0, _num(r, "d")] + [_num(r, k) for k in regressors])
outcome.append(_num(r, "y"))
return lalonde.ols(design, outcome)[1] # index 0 intercept, 1 is d
def naive_effect(rows: list[dict]) -> float:
"""y ~ d, no controls. Unbiased here because treatment is unconfounded."""
return _effect(rows, [])
def good_control_effect(rows: list[dict]) -> float:
"""y ~ d + x, controlling for the pre-treatment covariate. Recovers the total effect."""
return _effect(rows, ["x"])
def bad_control_effect(rows: list[dict]) -> float:
"""y ~ d + x + m, conditioning on the post-treatment mediator. Biased for the total effect."""
return _effect(rows, ["x", "m"])
if __name__ == "__main__":
data_path = Path(__file__).resolve().parents[1] / "data" / "sim-badcontrol.csv"
write_csv(data_path)
print(f"Wrote {data_path}")
rows = load(data_path)
print(f" n={len(rows)} true_total={true_total(rows):.4f} "
f"naive={naive_effect(rows):.4f} good={good_control_effect(rows):.4f} "
f"bad={bad_control_effect(rows):.4f}")
#!/usr/bin/env python3
"""Pure-stdlib conjugate Bayesian (Normal-Normal) computations.
Deterministic data symmetric around a known mean, with a known observation
variance. The conjugate posterior mean is a closed-form weighted average of the
prior mean and the data mean. A weakly-informative prior recovers the data mean
(the truth); an overconfident, miscalibrated prior drags the posterior far from
it -- the prior-sensitivity lesson. No randomness; everything is closed form.
"""
from __future__ import annotations
import csv
from pathlib import Path
N = 20
TRUE_MEAN = 2.0
SIGMA2 = 4.0 # assumed known observation variance
# Prior (mu0, tau0^2): weakly-informative recovers the data; strong+wrong biases.
WEAK_PRIOR = (0.0, 10000.0)
STRONG_WRONG_PRIOR = (10.0, 0.25)
def generate() -> list[dict]:
rows: list[dict] = []
for i in range(1, N + 1):
# Symmetric offsets around TRUE_MEAN so the sample mean is exactly TRUE_MEAN.
y = TRUE_MEAN + (i - (N + 1) / 2) * 0.2
rows.append({"id": i, "y": round(y, 6)})
return rows
def write_csv(path: Path) -> None:
rows = generate()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=["id", "y"])
writer.writeheader()
writer.writerows(rows)
def load(data_path: Path) -> list[dict]:
with data_path.open(encoding="utf-8") as fh:
return list(csv.DictReader(fh))
def _ybar(rows: list[dict]) -> float:
ys = [float(r["y"]) for r in rows]
return sum(ys) / len(ys)
def data_mean(rows: list[dict]) -> float:
"""Sample mean == the true mean by construction (the recovery target)."""
return _ybar(rows)
def posterior_mean(rows: list[dict], prior: tuple[float, float]) -> float:
"""Conjugate Normal-Normal posterior mean for a known-variance sample."""
mu0, tau02 = prior
n = len(rows)
ybar = _ybar(rows)
num = mu0 / tau02 + n * ybar / SIGMA2
den = 1.0 / tau02 + n / SIGMA2
return num / den
def posterior_weak(rows: list[dict]) -> float:
return posterior_mean(rows, WEAK_PRIOR)
def posterior_strong(rows: list[dict]) -> float:
return posterior_mean(rows, STRONG_WRONG_PRIOR)
if __name__ == "__main__":
data_path = Path(__file__).resolve().parents[1] / "data" / "sim-bayesian.csv"
write_csv(data_path)
print(f"Wrote {data_path}")
#!/usr/bin/env python3
"""Pure-stdlib Card (1995) returns-to-schooling computations: OLS, 2SLS with the
near-4-year-college instrument, and the first-stage F. No numpy/statsmodels.
Reuses the Gauss-Jordan OLS solver in lalonde.py and adds a matrix inverse so we
can report standard errors (needed for the first-stage F).
"""
from __future__ import annotations
import csv
from pathlib import Path
import lalonde # sibling module in benchmark/lib
# Standard Card wage equation controls available in the dataset.
CONTROLS = ["exper", "expersq", "black", "south", "smsa", "smsa66",
"reg662", "reg663", "reg664", "reg665", "reg666", "reg667", "reg668", "reg669"]
INSTRUMENT = "nearc4"
ENDOG = "educ"
OUTCOME = "lwage"
def load(data_path: Path) -> list[dict]:
with data_path.open(encoding="utf-8") as fh:
return [r for r in csv.DictReader(fh) if r.get("lwage") not in (None, "", "NA")]
def _col(rows, k):
return [float(r[k]) for r in rows]
def _design(rows, regressors):
return [[1.0] + [float(r[k]) for k in regressors] for r in rows]
def _invert(mat):
"""Inverse of a square matrix via Gauss-Jordan."""
n = len(mat)
a = [row[:] + [1.0 if i == j else 0.0 for j in range(n)] for i, row in enumerate(mat)]
for c in range(n):
piv = max(range(c, n), key=lambda r: abs(a[r][c]))
a[c], a[piv] = a[piv], a[c]
d = a[c][c]
if d == 0:
raise ValueError("singular matrix")
a[c] = [v / d for v in a[c]]
for r in range(n):
if r != c:
f = a[r][c]
a[r] = [a[r][j] - f * a[c][j] for j in range(2 * n)]
return [row[n:] for row in a]
def _ols_with_se(X, y):
n, p = len(X), len(X[0])
xtx = [[sum(X[i][a] * X[i][b] for i in range(n)) for b in range(p)] for a in range(p)]
xty = [sum(X[i][a] * y[i] for i in range(n)) for a in range(p)]
inv = _invert(xtx)
beta = [sum(inv[a][b] * xty[b] for b in range(p)) for a in range(p)]
resid = [y[i] - sum(X[i][j] * beta[j] for j in range(p)) for i in range(n)]
sigma2 = sum(e * e for e in resid) / (n - p)
se = [(sigma2 * inv[j][j]) ** 0.5 for j in range(p)]
return beta, se
def ols_return(rows) -> float:
"""OLS coefficient on educ (return to schooling)."""
X = _design(rows, [ENDOG] + CONTROLS)
beta = lalonde.ols(X, _col(rows, OUTCOME))
return beta[1] # index 0 is intercept, 1 is educ
def first_stage(rows) -> tuple[float, float]:
"""Return (coef on instrument, first-stage F for the single instrument)."""
X = _design(rows, [INSTRUMENT] + CONTROLS)
beta, se = _ols_with_se(X, _col(rows, ENDOG))
coef, se_z = beta[1], se[1]
f = (coef / se_z) ** 2 # single-instrument first-stage F = t^2
return coef, f
def iv_return(rows) -> float:
"""2SLS coefficient on educ instrumented by nearc4 (manual two-stage)."""
# Stage 1: educ ~ instrument + controls -> fitted educ.
X1 = _design(rows, [INSTRUMENT] + CONTROLS)
b1 = lalonde.ols(X1, _col(rows, ENDOG))
educ_hat = [sum(X1[i][j] * b1[j] for j in range(len(b1))) for i in range(len(rows))]
# Stage 2: lwage ~ educ_hat + controls.
X2 = [[1.0, educ_hat[i]] + [float(rows[i][k]) for k in CONTROLS] for i in range(len(rows))]
b2 = lalonde.ols(X2, _col(rows, OUTCOME))
return b2[1]
#!/usr/bin/env python3
"""Pure-stdlib double/debiased machine learning (partialling-out) computations.
Deterministic, noiseless data: the outcome is exactly linear in the treatment and
a set of controls, and the treatment is correlated with those controls. A naive
OLS of outcome on treatment alone is biased by the omitted controls, while a
cross-fitted partialling-out estimator (residualize Y and D on the controls using
out-of-fold predictions, then regress the residuals) recovers the true effect.
With linear nuisance and no noise the cross-fitted estimate equals the
fully-controlled coefficient by construction, so the benchmark exercises the
cross-fitting machinery and the omitted-control bias rather than regularization
bias (which the eval scenario covers). Reuses the Gauss-Jordan OLS in lalonde.py.
"""
from __future__ import annotations
import csv
from pathlib import Path
import lalonde # sibling module in benchmark/lib
N = 120
FOLDS = 4
THETA = 1.5 # true treatment effect
CONTROLS = ["x1", "x2", "x3", "x4"]
def _x(i: int) -> dict:
return {
"x1": float(i % 5),
"x2": float((i * 3) % 7),
"x3": float((i % 4) - 1.5),
"x4": float((i * 2) % 3),
}
def _m(x: dict) -> float:
"""Treatment mean function (shares controls with the outcome, same signs)."""
return 0.6 * x["x1"] + 0.5 * x["x2"] + 0.4 * x["x3"]
def _g(x: dict) -> float:
"""Outcome control function (aligned with _m so omitting it biases upward)."""
return 1.2 * x["x1"] + 1.0 * x["x2"] + 0.8 * x["x3"] + 0.4 * x["x4"]
def generate() -> list[dict]:
rows: list[dict] = []
for i in range(1, N + 1):
x = _x(i)
v = ((i % 9) - 4) * 0.12 # small treatment component independent of the controls
d = _m(x) + v
y = THETA * d + _g(x)
row = {"id": i, "d": round(d, 6), "y": round(y, 6)}
row.update({k: x[k] for k in CONTROLS})
rows.append(row)
return rows
def write_csv(path: Path) -> None:
rows = generate()
path.parent.mkdir(parents=True, exist_ok=True)
fieldnames = ["id", "d", "y"] + CONTROLS
with path.open("w", encoding="utf-8", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def load(data_path: Path) -> list[dict]:
with data_path.open(encoding="utf-8") as fh:
return list(csv.DictReader(fh))
def _num(row: dict, key: str) -> float:
return float(row[key])
def _design(rows: list[dict]) -> list[list[float]]:
return [[1.0] + [_num(r, k) for k in CONTROLS] for r in rows]
def _predict(beta: list[float], r: dict) -> float:
x = [1.0] + [_num(r, k) for k in CONTROLS]
return sum(beta[j] * x[j] for j in range(len(beta)))
def true_theta(rows: list[dict]) -> float:
"""Fully-controlled OLS coefficient on d (recovers THETA by construction)."""
X = [[1.0, _num(r, "d")] + [_num(r, k) for k in CONTROLS] for r in rows]
y = [_num(r, "y") for r in rows]
return lalonde.ols(X, y)[1]
def naive_theta(rows: list[dict]) -> float:
"""OLS of y on d with no controls (biased by the omitted controls)."""
X = [[1.0, _num(r, "d")] for r in rows]
y = [_num(r, "y") for r in rows]
return lalonde.ols(X, y)[1]
def dml_theta(rows: list[dict]) -> float:
"""Cross-fitted partialling-out estimate of the treatment effect."""
n = len(rows)
folds = [[rows[i] for i in range(n) if i % FOLDS == f] for f in range(FOLDS)]
num = den = 0.0
for f in range(FOLDS):
test = folds[f]
train = [r for g in range(FOLDS) if g != f for r in folds[g]]
# Out-of-fold linear nuisance models for E[Y|X] and E[D|X].
beta_y = lalonde.ols(_design(train), [_num(r, "y") for r in train])
beta_d = lalonde.ols(_design(train), [_num(r, "d") for r in train])
for r in test:
ry = _num(r, "y") - _predict(beta_y, r)
rd = _num(r, "d") - _predict(beta_d, r)
num += rd * ry
den += rd * rd
return num / den
if __name__ == "__main__":
data_path = Path(__file__).resolve().parents[1] / "data" / "sim-dml.csv"
write_csv(data_path)
print(f"Wrote {data_path}")
#!/usr/bin/env python3
"""Pure-stdlib event-study (dynamic difference-in-differences) computations.
Deterministic, noiseless balanced panel: one treated cohort adopts at t0 with
GROWING dynamic effects, plus a never-treated control group, and parallel trends
hold by construction (the untreated potential outcome is additive in unit and
time effects). An event-study regression with unit and time fixed effects
recovers the dynamic path: post-period event coefficients equal the true dynamic
effects and pre-period (placebo) coefficients are zero. A naive treated-only
before/after comparison is biased by the common time trend. y0 ships as a column
the estimators never read so the checker can recompute the truth.
"""
from __future__ import annotations
import csv
from pathlib import Path
import lalonde # sibling module in benchmark/lib
UNITS = 30
PERIODS = 8
T0 = 4 # adoption period for the treated cohort
TREATED_UNITS = range(1, 16) # units 1..15 treated; 16..30 never treated
# Dynamic effect by event time k = t - T0 (k >= 0). True post ATT = mean = 3.0.
TAU = {0: 1.0, 1: 2.0, 2: 3.0, 3: 4.0, 4: 5.0}
def _unit_fe(unit: int) -> float:
return round((unit % 5) * 1.5 - 3.0, 4)
def _time_fe(period: int) -> float:
return round(0.6 * period, 4)
def _is_treated_unit(unit: int) -> bool:
return unit in TREATED_UNITS
def _tau(unit: int, period: int) -> float:
if not _is_treated_unit(unit) or period < T0:
return 0.0
return TAU.get(period - T0, 0.0)
def generate() -> list[dict]:
rows: list[dict] = []
for unit in range(1, UNITS + 1):
for period in range(1, PERIODS + 1):
post = 1 if _is_treated_unit(unit) and period >= T0 else 0
y0 = _unit_fe(unit) + _time_fe(period)
y = y0 + _tau(unit, period)
rows.append({
"unit": unit,
"time": period,
"treated_unit": 1 if _is_treated_unit(unit) else 0,
"post": post,
"y0": round(y0, 4),
"y": round(y, 4),
})
return rows
def write_csv(path: Path) -> None:
rows = generate()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as fh:
writer = csv.DictWriter(
fh, fieldnames=["unit", "time", "treated_unit", "post", "y0", "y"]
)
writer.writeheader()
writer.writerows(rows)
def load(data_path: Path) -> list[dict]:
with data_path.open(encoding="utf-8") as fh:
return list(csv.DictReader(fh))
def _num(row: dict, key: str) -> float:
return float(row[key])
def _is_treated(row: dict) -> bool:
return round(_num(row, "treated_unit")) == 1
def true_att(rows: list[dict]) -> float:
"""True average post-adoption effect on the treated, from the unread y0."""
diffs = [
_num(r, "y") - _num(r, "y0")
for r in rows
if _is_treated(r) and int(_num(r, "time")) >= T0
]
return sum(diffs) / len(diffs)
def _event_keys() -> list[int]:
"""Relative event times present for the treated cohort, dropping k=-1 baseline."""
return [t - T0 for t in range(1, PERIODS + 1) if (t - T0) != -1]
def event_study_coefs(rows: list[dict]) -> dict[int, float]:
"""OLS with unit + time fixed effects and treated x relative-time dummies."""
units = sorted({int(_num(r, "unit")) for r in rows})
periods = sorted({int(_num(r, "time")) for r in rows})
unit_cols, period_cols = units[1:], periods[1:]
keys = _event_keys()
design, outcome = [], []
for r in rows:
unit, period = int(_num(r, "unit")), int(_num(r, "time"))
x = [1.0]
x += [1.0 if unit == v else 0.0 for v in unit_cols]
x += [1.0 if period == v else 0.0 for v in period_cols]
for k in keys:
x.append(1.0 if (_is_treated(r) and (period - T0) == k) else 0.0)
design.append(x)
outcome.append(_num(r, "y"))
beta = lalonde.ols(design, outcome)
event_betas = beta[-len(keys):]
return {k: event_betas[i] for i, k in enumerate(keys)}
def es_att(rows: list[dict]) -> float:
"""Average of the estimated post-period (k>=0) event coefficients."""
coefs = event_study_coefs(rows)
post = [v for k, v in coefs.items() if k >= 0]
return sum(post) / len(post)
def es_pre_max(rows: list[dict]) -> float:
"""Largest absolute pre-period (k<0) event coefficient (placebo; ~0)."""
coefs = event_study_coefs(rows)
pre = [abs(v) for k, v in coefs.items() if k < 0]
return max(pre) if pre else 0.0
def naive_before_after(rows: list[dict]) -> float:
"""Treated-only post-minus-pre mean (biased by the common time trend)."""
post = [_num(r, "y") for r in rows if _is_treated(r) and int(_num(r, "time")) >= T0]
pre = [_num(r, "y") for r in rows if _is_treated(r) and int(_num(r, "time")) < T0]
return sum(post) / len(post) - sum(pre) / len(pre)
if __name__ == "__main__":
data_path = Path(__file__).resolve().parents[1] / "data" / "sim-event-study.csv"
write_csv(data_path)
print(f"Wrote {data_path}")
#!/usr/bin/env python3
"""Pure-stdlib LaLonde computations shared by the reference pipeline and checker.
No numpy / pandas / statsmodels: the benchmark must run in CI with only the
standard library. The OLS solver is a small Gaussian elimination on the normal
equations, which is plenty for a 614x10 design matrix.
"""
from __future__ import annotations
import csv
import math
from pathlib import Path
COVARIATES = ["age", "educ", "black", "hispan", "married", "nodegree", "re74", "re75"]
def load(data_path: Path) -> list[dict]:
with data_path.open(encoding="utf-8") as fh:
rows = list(csv.DictReader(fh))
for r in rows:
# Derive race dummies used as covariates.
r["black"] = "1" if r.get("race") == "black" else "0"
r["hispan"] = "1" if r.get("race") == "hispan" else "0"
return rows
def _num(r: dict, k: str) -> float:
return float(r[k])
def _mean(group: list[dict], k: str) -> float:
return sum(_num(r, k) for r in group) / len(group)
def _sd(group: list[dict], k: str) -> float:
m = _mean(group, k)
return math.sqrt(sum((_num(r, k) - m) ** 2 for r in group) / (len(group) - 1))
def split(rows: list[dict], treatment: str) -> tuple[list[dict], list[dict]]:
treated = [r for r in rows if r[treatment] in ("1", "1.0", "True", "true")]
control = [r for r in rows if r not in treated]
return treated, control
def naive_att(rows: list[dict], treatment: str, outcome: str) -> float:
t, c = split(rows, treatment)
return _mean(t, outcome) - _mean(c, outcome)
def smd_table(rows: list[dict], treatment: str) -> dict[str, float]:
t, c = split(rows, treatment)
out: dict[str, float] = {}
for k in COVARIATES:
mt, mc = _mean(t, k), _mean(c, k)
st, sc = _sd(t, k), _sd(c, k)
pooled = math.sqrt((st ** 2 + sc ** 2) / 2)
out[k] = (mt - mc) / pooled if pooled else 0.0
return out
def ols(X: list[list[float]], y: list[float]) -> list[float]:
"""Solve (X'X) b = X'y by Gauss-Jordan with partial pivoting."""
n, p = len(X), len(X[0])
xtx = [[sum(X[i][a] * X[i][b] for i in range(n)) for b in range(p)] for a in range(p)]
xty = [sum(X[i][a] * y[i] for i in range(n)) for a in range(p)]
aug = [xtx[i][:] + [xty[i]] for i in range(p)]
for c in range(p):
piv = max(range(c, p), key=lambda r: abs(aug[r][c]))
aug[c], aug[piv] = aug[piv], aug[c]
d = aug[c][c]
if d == 0:
raise ValueError("singular design matrix")
aug[c] = [v / d for v in aug[c]]
for r in range(p):
if r != c:
f = aug[r][c]
aug[r] = [aug[r][j] - f * aug[c][j] for j in range(p + 1)]
return [aug[i][p] for i in range(p)]
def adjusted_att(rows: list[dict], treatment: str, outcome: str) -> float:
"""Regression-adjusted ATT = treatment coefficient with full controls."""
X, y = [], []
for r in rows:
X.append([1.0, _num(r, treatment)] + [_num(r, k) for k in COVARIATES])
y.append(_num(r, outcome))
beta = ols(X, y)
return beta[1]
#!/usr/bin/env python3
"""Pure-stdlib panel fixed-effects computations.
Deterministic, noiseless balanced panel whose untreated potential outcome is
additive in unit and time effects. Treatment turns on and off WITHIN units
(not a one-shot adoption) and is correlated with the unit effects, so a pooled
OLS that ignores unit heterogeneity is biased, while a two-way fixed-effects
estimator recovers the true effect. The untreated potential outcome y0 ships as
a column the estimators never read, so the checker can recompute the true effect.
"""
from __future__ import annotations
import csv
from pathlib import Path
import lalonde # sibling module in benchmark/lib
UNITS = 40
PERIODS = 6
BETA = 2.0 # true within-unit treatment effect
def _unit_fe(unit: int) -> float:
# Units 1..20 are high-type and are the ones that get treated; 21..40 low-type
# and never treated -> treatment is correlated with the unit effect (selection).
base = 4.0 if unit <= 20 else -3.0
return round(base + (unit % 3), 4)
def _time_fe(period: int) -> float:
return round(0.5 * period, 4)
def _treated(unit: int, period: int) -> int:
# On/off within unit: high-type units are treated in even periods only.
return 1 if unit <= 20 and period % 2 == 0 else 0
def generate() -> list[dict]:
rows: list[dict] = []
for unit in range(1, UNITS + 1):
for period in range(1, PERIODS + 1):
d = _treated(unit, period)
y0 = _unit_fe(unit) + _time_fe(period)
y = y0 + (BETA if d else 0.0)
rows.append({
"unit": unit,
"time": period,
"treat": d,
"y0": round(y0, 4),
"y": round(y, 4),
})
return rows
def write_csv(path: Path) -> None:
rows = generate()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=["unit", "time", "treat", "y0", "y"])
writer.writeheader()
writer.writerows(rows)
def load(data_path: Path) -> list[dict]:
with data_path.open(encoding="utf-8") as fh:
return list(csv.DictReader(fh))
def _num(row: dict, key: str) -> float:
return float(row[key])
def true_att(rows: list[dict]) -> float:
"""Average treatment effect on the treated, from the unread y0 column."""
diffs = [_num(r, "y") - _num(r, "y0") for r in rows if round(_num(r, "treat")) == 1]
return sum(diffs) / len(diffs)
def pooled_att(rows: list[dict]) -> float:
"""OLS of y on treat with NO fixed effects (biased: ignores unit selection)."""
X = [[1.0, _num(r, "treat")] for r in rows]
y = [_num(r, "y") for r in rows]
return lalonde.ols(X, y)[1]
def twoway_fe_att(rows: list[dict]) -> float:
"""Two-way (unit + time) fixed-effects coefficient on treat (recovers BETA)."""
units = sorted({int(_num(r, "unit")) for r in rows})
periods = sorted({int(_num(r, "time")) for r in rows})
unit_cols, period_cols = units[1:], periods[1:]
design, outcome = [], []
for r in rows:
unit, period = int(_num(r, "unit")), int(_num(r, "time"))
x = [1.0]
x += [1.0 if unit == v else 0.0 for v in unit_cols]
x += [1.0 if period == v else 0.0 for v in period_cols]
x.append(_num(r, "treat"))
design.append(x)
outcome.append(_num(r, "y"))
return lalonde.ols(design, outcome)[-1]
if __name__ == "__main__":
data_path = Path(__file__).resolve().parents[1] / "data" / "sim-panel-fe.csv"
write_csv(data_path)
print(f"Wrote {data_path}")
#!/usr/bin/env python3
"""Pure-stdlib sharp regression-discontinuity (RD) computations.
The simulated running variable is deterministic and noiseless. Untreated
potential outcomes are linear in the running variable but with DIFFERENT slopes
on either side of the cutoff, and treatment adds a constant jump at the cutoff.
Because the data is exactly piecewise-linear and noiseless, the true
discontinuity (the treatment effect at the cutoff) is known by construction, and
three estimators have sharply different behavior:
* a naive across-cutoff mean difference confounds the jump with the running-
variable trend and is badly biased;
* a global common-slope OLS (the textbook ``y ~ 1 + D + x`` specification) is
still biased because it forces one slope on two genuinely different slopes;
* local linear regression on each side at the cutoff recovers the true jump.
This encodes the standard RD lesson (Imbens & Lemieux 2008; Lee & Lemieux 2010;
Gelman & Imbens 2019 on preferring local linear/quadratic fits over global
high-order polynomials; Cattaneo, Idrobo & Titiunik 2019 practical guide).
"""
from __future__ import annotations
import csv
from pathlib import Path
import lalonde # sibling module in benchmark/lib
# Deterministic noiseless DGP constants. The cutoff is 0; treatment is sharp.
CUTOFF = 0.0
N_POINTS = 101 # symmetric grid x in [-1, 1] including x = 0 (treated side)
INTERCEPT = 0.0 # untreated level at the cutoff (left limit)
SLOPE_LEFT = 1.0 # untreated slope below the cutoff
SLOPE_RIGHT = 4.0 # untreated slope at or above the cutoff (genuinely steeper)
TAU = 3.0 # true treatment effect (jump) at the cutoff
BANDWIDTH = 0.5 # local-linear bandwidth used by the reference estimator
def _grid() -> list[float]:
# x_i = -1 + 2 i / (N - 1); with N = 101 this includes x = 0 exactly.
return [round(-1.0 + 2.0 * i / (N_POINTS - 1), 4) for i in range(N_POINTS)]
def generate() -> list[dict]:
rows: list[dict] = []
for x in _grid():
treated = 1 if x >= CUTOFF else 0
slope = SLOPE_RIGHT if treated else SLOPE_LEFT
y0 = INTERCEPT + slope * x
y = y0 + (TAU if treated else 0.0)
rows.append({
"x": round(x, 4),
"treat": treated,
"y0": round(y0, 4),
"y": round(y, 4),
})
return rows
def write_csv(path: Path) -> None:
rows = generate()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=["x", "treat", "y0", "y"])
writer.writeheader()
writer.writerows(rows)
def load(data_path: Path) -> list[dict]:
with data_path.open(encoding="utf-8") as fh:
return list(csv.DictReader(fh))
def _num(row: dict, key: str) -> float:
return float(row[key])
def true_tau(rows: list[dict]) -> float:
"""True jump recomputed from the shipped y0 counterfactual at the cutoff.
The right limit of the untreated mean function at the cutoff is the y0 of the
treated unit sitting exactly on the cutoff; the left limit is the linear
extrapolation of the just-below points. Both reduce to INTERCEPT here, so the
jump equals mean(y - y0) over treated rows, which is TAU by construction.
"""
treated = [r for r in rows if r["treat"] in ("1", "1.0")]
return sum(_num(r, "y") - _num(r, "y0") for r in treated) / len(treated)
def naive_jump(rows: list[dict]) -> float:
"""Across-cutoff difference in mean outcomes, ignoring the running variable."""
above = [_num(r, "y") for r in rows if r["treat"] in ("1", "1.0")]
below = [_num(r, "y") for r in rows if r["treat"] not in ("1", "1.0")]
return sum(above) / len(above) - sum(below) / len(below)
def global_att(rows: list[dict]) -> float:
"""Global common-slope OLS: y ~ 1 + D + x. Biased under differing slopes."""
design, outcome = [], []
for r in rows:
treated = 1.0 if r["treat"] in ("1", "1.0") else 0.0
design.append([1.0, treated, _num(r, "x")])
outcome.append(_num(r, "y"))
return lalonde.ols(design, outcome)[1] # coefficient on D
def _intercept_at_cutoff(rows: list[dict], side: str, bandwidth: float) -> float:
"""Local linear fit y ~ 1 + (x - cutoff) on one side within the bandwidth.
Returns the intercept, i.e. the predicted outcome at the cutoff. Because each
side is exactly linear, the fit is exact for any positive bandwidth.
"""
design, outcome = [], []
for r in rows:
x = _num(r, "x")
treated = r["treat"] in ("1", "1.0")
on_side = treated if side == "right" else not treated
if on_side and abs(x - CUTOFF) <= bandwidth:
design.append([1.0, x - CUTOFF])
outcome.append(_num(r, "y"))
return lalonde.ols(design, outcome)[0]
def local_att(rows: list[dict], bandwidth: float = BANDWIDTH) -> float:
"""Local-linear RD estimate: difference of side-specific intercepts at the cutoff."""
right = _intercept_at_cutoff(rows, "right", bandwidth)
left = _intercept_at_cutoff(rows, "left", bandwidth)
return right - left
if __name__ == "__main__":
data_path = Path(__file__).resolve().parents[1] / "data" / "sim-rdd.csv"
write_csv(data_path)
print(f"Wrote {data_path}")
rows = load(data_path)
print(f" n={len(rows)} true_tau={true_tau(rows):.4f} "
f"naive_jump={naive_jump(rows):.4f} global_att={global_att(rows):.4f} "
f"local_att={local_att(rows):.4f}")
#!/usr/bin/env python3
"""Pure-stdlib staggered difference-in-differences computations.
The simulated panel is deterministic and noiseless. Its untreated potential
outcome is additive in unit and time effects, while treatment effects are
cohort-heterogeneous and dynamic. This makes naive TWFE biased and lets the
benchmark check whether a group-time estimator with not-yet-treated controls
recovers the true ATT.
"""
from __future__ import annotations
import csv
from pathlib import Path
import lalonde # sibling module in benchmark/lib
UNITS = 60
PERIODS = 10
COHORTS = {4: range(1, 21), 7: range(21, 41)}
DELTA = {4: 1.0, 7: 0.4}
def _unit_fe(unit: int) -> float:
return round((unit % 7) * 1.3 - 2.0, 4)
def _time_fe(period: int) -> float:
return round(0.5 * period, 4)
def _cohort_of(unit: int) -> int:
for cohort, units in COHORTS.items():
if unit in units:
return cohort
return 0
def _tau(cohort: int, period: int) -> float:
if cohort == 0 or period < cohort:
return 0.0
return DELTA[cohort] * (period - cohort + 1)
def generate() -> list[dict]:
rows: list[dict] = []
for unit in range(1, UNITS + 1):
cohort = _cohort_of(unit)
for period in range(1, PERIODS + 1):
treated = 1 if cohort != 0 and period >= cohort else 0
y0 = _unit_fe(unit) + _time_fe(period)
y = y0 + (_tau(cohort, period) if treated else 0.0)
rows.append({
"unit": unit,
"time": period,
"cohort": cohort,
"treat": treated,
"y0": round(y0, 4),
"y": round(y, 4),
})
return rows
def write_csv(path: Path) -> None:
rows = generate()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=["unit", "time", "cohort", "treat", "y0", "y"])
writer.writeheader()
writer.writerows(rows)
def load(data_path: Path) -> list[dict]:
with data_path.open(encoding="utf-8") as fh:
return list(csv.DictReader(fh))
def _num(row: dict, key: str) -> float:
return float(row[key])
def true_att(rows: list[dict]) -> float:
diffs = [_num(row, "y") - _num(row, "y0") for row in rows if row["treat"] in ("1", "1.0")]
return sum(diffs) / len(diffs)
def twfe_att(rows: list[dict]) -> float:
units = sorted({int(_num(row, "unit")) for row in rows})
periods = sorted({int(_num(row, "time")) for row in rows})
unit_cols = units[1:]
period_cols = periods[1:]
design, outcome = [], []
for row in rows:
unit, period = int(_num(row, "unit")), int(_num(row, "time"))
x = [1.0]
x += [1.0 if unit == value else 0.0 for value in unit_cols]
x += [1.0 if period == value else 0.0 for value in period_cols]
x.append(_num(row, "treat"))
design.append(x)
outcome.append(_num(row, "y"))
return lalonde.ols(design, outcome)[-1]
def _mean_y(rows: list[dict], cohort: int | None, period: int) -> float | None:
vals = [
_num(row, "y")
for row in rows
if int(_num(row, "time")) == period
and (cohort is None or int(_num(row, "cohort")) == cohort)
]
return sum(vals) / len(vals) if vals else None
def _pooled_ctrl_mean(rows: list[dict], ctrl_cohorts: set[int], period: int) -> float | None:
vals = [
_num(row, "y")
for row in rows
if int(_num(row, "time")) == period and int(_num(row, "cohort")) in ctrl_cohorts
]
return sum(vals) / len(vals) if vals else None
def group_time_att(rows: list[dict]) -> dict[tuple[int, int], float]:
cohorts = sorted({int(_num(row, "cohort")) for row in rows} - {0})
last_period = max(int(_num(row, "time")) for row in rows)
out: dict[tuple[int, int], float] = {}
for cohort in cohorts:
base = cohort - 1
for period in range(cohort, last_period + 1):
ctrl_cohorts = {
int(_num(row, "cohort"))
for row in rows
if int(_num(row, "cohort")) == 0 or int(_num(row, "cohort")) > period
}
yg_t = _mean_y(rows, cohort, period)
yg_b = _mean_y(rows, cohort, base)
yc_t = _pooled_ctrl_mean(rows, ctrl_cohorts, period)
yc_b = _pooled_ctrl_mean(rows, ctrl_cohorts, base)
if None in (yg_t, yg_b, yc_t, yc_b):
continue
out[(cohort, period)] = (yg_t - yg_b) - (yc_t - yc_b)
return out
def cs_att(rows: list[dict]) -> float:
cell_atts = group_time_att(rows)
cohort_size: dict[int, set[int]] = {}
for row in rows:
cohort = int(_num(row, "cohort"))
if cohort != 0:
cohort_size.setdefault(cohort, set()).add(int(_num(row, "unit")))
numerator = denominator = 0.0
for (cohort, _period), att in cell_atts.items():
weight = len(cohort_size[cohort])
numerator += att * weight
denominator += weight
return numerator / denominator if denominator else 0.0
if __name__ == "__main__":
data_path = Path(__file__).resolve().parents[1] / "data" / "sim-staggered-did.csv"
write_csv(data_path)
print(f"Wrote {data_path}")
id = "bad-control-recovery"
title = "Bad-control recovery: does the pipeline avoid conditioning on a post-treatment mediator?"
data = "benchmark/data/sim-badcontrol.csv"
reference_candidate = "reference-badcontrol"
description = """
A deterministic, noiseless cross-section (120 units). Treatment d is assigned
ORTHOGONALLY to the pre-treatment covariate x (so there is no omitted-variable
confounding), and a mediator m sits on the causal path d -> m -> y. The data
ships y0/y1 potential-outcome columns the estimators never read, so the checker
recomputes the true total effect as mean(y1 - y0).
Structural equations (noiseless): m = d + 0.5x + e; y = 0.5d + 2m + x. The true
total effect of d is therefore 0.5 (direct) + 2.0 x 1.0 (through m) = 2.5, while
the direct effect is 0.5.
This encodes the "bad control" / post-treatment-bias lesson (Cinelli, Forney &
Pearl 2022, A Crash Course in Good and Bad Controls; Angrist & Pischke, Mostly
Harmless Econometrics): conditioning on a descendant of the treatment violates
the back-door criterion and biases the estimate away from the total effect
toward the direct effect. Here y ~ d (naive) and y ~ d + x (pre-treatment
control) both recover the total effect 2.5, but y ~ d + x + m collapses to the
direct effect 0.5. The point is NOT that all post-treatment variables are bad,
but that conditioning on a mediator changes the estimand.
A pipeline that handles controls correctly must: (1) recover the total effect
when adjusting only for pre-treatment covariates, and (2) recognize that adding
the post-treatment mediator m biases the estimate (and not headline that biased
number as the treatment effect). Data-derived golds are recomputed from the
dataset by the checker, so a candidate cannot pass by fabricating numbers.
"""
# Construction constants (informational; the checker recomputes true_total from y0/y1).
true_total_by_construction = 2.5
direct_effect_by_construction = 0.5
[[gold]]
id = "good-control-recovers-total"
required = true
weight = 4
description = "Adjusting for pre-treatment covariates only (y ~ d + x) recovers the true total effect recomputed from the data."
check = "recovers_truth"
field = "good_control_effect"
truth_key = "true_total"
tol = 0.05
[[gold]]
id = "bad-control-is-biased"
required = true
weight = 3
description = "Reports the mediator-adjusted estimate (y ~ d + x + m) and recognizes it is far from the total effect (surfaces the post-treatment bias rather than headlining it)."
check = "biased_away"
field = "bad_control_effect"
truth_key = "true_total"
min_gap = 0.5
[[gold]]
id = "good-beats-bad"
required = false
weight = 2
description = "The pre-treatment-only estimate is closer to the total effect than the mediator-adjusted estimate (informational)."
check = "closer_to_truth"
near_field = "good_control_effect"
far_field = "bad_control_effect"
truth_key = "true_total"
[[gold]]
id = "honest-reported-numbers"
required = true
weight = 3
description = "The candidate's reported naive, pre-treatment-control, and mediator-control estimates match what the checker recomputes by running each regression on the data (no fabricated numbers)."
check = "cross_check"
tol = 0.02
[
[
"0. raw rdatasets csv",
614
],
[
"1. drop rownames + dropna",
614
],
[
"2. recode race -> black/hispan",
614
],
[
"3. enforce treat in {0,1}",
614
]
][
["0. raw rdatasets csv", 614],
["1. drop rownames + dropna", 614],
["2. recode race -> black/hispan", 614],
["3. enforce treat in {0,1}", 614]
]
Related skills
FAQ
What does this root skill do?
It acts as a router and catalog, classifying the task by stage and loading only the matching vendored child skill rather than the whole repository.
Where are the catalog files?
catalog/skills.json is the machine-readable list, docs/SKILL_CATALOG.md is the human-readable index, and docs/TAXONOMY.md is the method taxonomy.