diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7acb07f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,19 @@ +name: tests + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.13"] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e '.[dev]' + - run: python -m pytest -q diff --git a/.gitignore b/.gitignore index b578921..459d318 100644 --- a/.gitignore +++ b/.gitignore @@ -19,8 +19,9 @@ env/ # Local Claude/Codex tooling (vendored skills/agents/config — not hackathon work) .claude/ .codex/ +.agents/ -# Generator default output (fixtures live in claude-science-artifcats/scenarios/ and ARE committed) +# Generator default output (fixtures live in claude-science-artifacts/scenarios/ and ARE committed) /predictions.csv # Real RSNA-SMBC data — the license bans redistribution "in any form". Never commit @@ -33,3 +34,6 @@ predictions_rsna*.csv # OS .DS_Store + +screenshots/ +.mcp.json diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 30d2a34..0000000 --- a/.mcp.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "mcpServers": { - "playwright": { - "command": "npx", - "args": ["@playwright/mcp@latest"] - }, - "clickup": { - "command": "npx", - "args": ["-y", "mcp-remote", "https://mcp.clickup.com/mcp"] - } - } -} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 3a52260..31cd97a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,39 +14,51 @@ Named user: a clinical-AI researcher / radiology-lab PhD student who trained a m **Python computes. Claude explains.** -- All metrics, statistical tests, and pass/fail flags are computed by deterministic Python. Never ask an LLM to compute AUC, run a bootstrap, or decide a flag. -- Claude (Anthropic API, `claude-opus-4-8`) is used **only** to turn the deterministic layer's structured JSON into prose: the risk report, reviewer-style critique, next-step suggestions. See the reporter contract in [prompts/opus-4-8/reporter_system_prompt.md](prompts/opus-4-8/reporter_system_prompt.md) — it may cite only numbers present in the JSON. +- All metrics, statistical tests, pass/fail flags, and the risk level are computed by deterministic Python. Never ask an LLM to compute AUC, run a bootstrap, or decide a flag or the risk level. +- Claude (Anthropic API, `claude-opus-5-5` at effort `low`, with server-side refusal fallback) is used **only** to turn the deterministic layer's structured JSON into prose: the risk report, reviewer-style critique, next-step suggestions. See the reporter contract in [prompts/reporter_system_prompt.md](prompts/reporter_system_prompt.md) — it may cite only numbers present in the JSON. - Layers stay cleanly separable: `clinval_validators/` (pure Python, no network, runs fully offline) and `reporter/` (the only code that touches the network). ## Repo map — current state -What exists: -- [claude-science-artifacts/](claude-science-artifacts/) — **working** synthetic-data generator + labeled test fixtures (this is the test oracle for the validators). - - `leakage_generator.py` — generates one RSNA-like `predictions.csv` with injected, manifest-verified flaws. - - `make_scenarios.py` — builds the full labeled suite in `scenarios/`. - - `scenarios/*.csv` + `*.manifest.json` + `_scenario_summary.csv` — 6 ground-truth-labeled scenarios. -- [prompts/](prompts/) — the two **runtime** system prompts the reporter loads (`reporter_system_prompt.md`, `reviewer_critique_system_prompt.md`); see [prompts/README.md](prompts/README.md). Load-bearing, not historical. +- [clinval_validators/](clinval_validators/) — deterministic checks. `run_checks` returns leakage, the split audit, subgroups, the pooled AUC, and `risk`. +- [cli.py](cli.py) — `clinval-validate`. Prints the payload and exits 1 when `ANTHROPIC_API_KEY` is unset or Claude returns no complete report (`ReportNotRenderedError`: the API request failed, or the reply was cut off at `max_tokens`, refused, or had no text). Exits 2 when `validate_predictions` reports a problem. +- [app.py](app.py) — Streamlit UI over the same checks. The typed API key is passed into the reporter and is not written to `os.environ`. An empty field falls back to the server's `ANTHROPIC_API_KEY`; do not prefill the field with it, because widget values reach the browser. +- [reporter/](reporter/) — the only code that calls the network. Loads the two runtime prompts in [prompts/](prompts/); see [prompts/README.md](prompts/README.md). +- [claude-science-artifacts/](claude-science-artifacts/) — synthetic-data generator and the committed scenario oracle (`scenarios/*.csv`, manifests, `_scenario_summary.csv`). Do not regenerate those fixtures to make a test pass. +- [rsna_to_predictions.py](rsna_to_predictions.py) — local-only adapter for an RSNA `train.csv`. Outputs are gitignored. +- [tests/](tests/) — unit tests, including fresh generator seeds for the subgroup flag. +- [docs/DEMO.md](docs/DEMO.md) — script of the recorded demo video. A dated note under the title lists later corrections. The narration is unchanged. - [AGENTS.md](AGENTS.md) — Codex/GPT behavioral rules (also govern Claude). -To build (MVP — see Scope): -- `clinval_validators/` — deterministic checks (leakage, split audit, subgroup degradation) → structured JSON. -- `reporter/` — feeds validator JSON to Claude, renders the risk report. -- `data/` — the demo input table (copy/symlink a fixture from `claude-science-artifacts/scenarios/`). -- `tests/` — unit tests (see Definition of done). -- CLI **or** thin Streamlit demo — pick one, keep minimal. - ## Commands ```bash -# Generate the full labeled scenario suite (writes scenarios/*.csv + manifests + _scenario_summary.csv) -cd claude-science-artifacts && python3 make_scenarios.py +python -m pip install -e '.[dev,demo]' +python -m pytest -q +clinval-validate --predictions claude-science-artifacts/scenarios/optimistic_site_shortcut.csv +clinval-validate --predictions claude-science-artifacts/scenarios/optimistic_site_shortcut.csv --mode critique +streamlit run app.py +python rsna_to_predictions.py --input data/rsna/train.csv +``` + +`clinval-validate` without `ANTHROPIC_API_KEY` prints the JSON payload and exits 1. `--mode critique` asks for the reviewer critique. The RSNA command writes gitignored `predictions_rsna_random.csv` and `predictions_rsna_patient_grouped.csv`. + +New synthetic tables, not replacements for the committed oracle: -# Generate one custom table with specific injected flaws -python3 claude-science-artifacts/leakage_generator.py --out predictions.csv \ +```bash +cd claude-science-artifacts && python make_scenarios.py +python claude-science-artifacts/leakage_generator.py --out predictions.csv \ --split-strategy random --degrade machine_id:M030:0.60 --optimistic-auc 0.98 ``` -Once the MVP exists, document its real commands here (run validators, `pytest`, launch the demo). Do **not** add commands that don't run yet — a stale command is worse than none. +## Gotchas + +- `leakage_generator.generate` draws `y_prob` before it assigns splits, so injected leakage does not change the scores. +- `_FLAG_DELTA` (−0.10) and `_MIN_SUBGROUP_N` (50) were calibrated on the committed fixtures. Do not change them to make a test pass. +- `reporter/report.py` sends only the text after the first `---` of a prompt file. +- A one-sided Bonferroni correction across subgroups was measured on generator seeds 7–36 and not adopted. It cut clean-table flags from 6 to 3 of 30, but M030 detection fell from 26 to 21 of 30 and density detection from 21 to 12 of 30, and `subgroup_density_degradation` was no longer flagged. Re-measure on the same seeds before changing the flag rule. +- The reporter model was chosen in October 2026 from a blind review of 24 live reports per configuration (4 scenarios × 2 modes × 3 repeats). Major errors: Opus 5.5 low 0, Opus 5.5 medium 0, Sonnet 5.5 with and without thinking 0, Opus 4.8 1, Haiku 5.5 2–3. Opus 5.5 low had the most error-free reports (19 of 24) at $40 per 1,000 reports, against $46 for Opus 4.8; Sonnet 5.5 without thinking is the cheaper option ($18, 14 of 24 error-free). Re-run that comparison before changing `MODEL` or `EFFORT`. +- Opus 5.5 always thinks: `thinking: {"type": "disabled"}` is a 400. Its replies start with thinking blocks, so the reporter joins the `text` blocks, and `MAX_TOKENS` must leave room for the thinking. ## Data & schema @@ -62,12 +74,12 @@ Scenario suite (`_scenario_summary.csv`), each with a `*.manifest.json` recordin | Scenario | Flaw | Detectable signal | |---|---|---| -| `clean_patient_grouped` | none (negative control) | all checks pass | -| `weak_random_split_leakage` | patient overlap via random split | 940 train/test patient overlaps | -| `duplicate_row_leakage` | near-duplicate rows train→test | 113 duplicate-leaked patients | -| `subgroup_scanner_degradation` | one scanner AUC low | subgroup AUC 0.62 vs pooled 0.83 | -| `subgroup_density_degradation` | one density band AUC low | subgroup AUC 0.67 vs pooled 0.73 | -| `optimistic_site_shortcut` | inflated AUC + site shortcut | AUC 0.96 under a leaking random split | +| `clean_patient_grouped` | none (negative control) | risk GREEN; no subgroup flags | +| `weak_random_split_leakage` | patient overlap via random split | 940 train/test patient overlaps; risk RED | +| `duplicate_row_leakage` | near-duplicate rows train→test | 113 duplicate-leaked patients; risk RED | +| `subgroup_scanner_degradation` | scanner M030 AUC low | M030 about 0.624 vs about 0.873 on the rest; flagged. Site S03 is also flagged. Risk YELLOW | +| `subgroup_density_degradation` | density C and D target AUC 0.70 | density C about 0.670 vs about 0.792 on the rest; flagged. D is not (its delta is above −0.10). Risk YELLOW | +| `optimistic_site_shortcut` | high AUC, site shortcut, random split | 990 overlapping patients, so risk RED. Mean `y_prob` by site is about 0.33–0.75 against an observed rate of about 1–2%; that gap is reported and not flagged | Never commit real patient data. README must state: synthetic/public-schema only, no PHI, not medical advice. @@ -75,32 +87,30 @@ Never commit real patient data. README must state: synthetic/public-schema only, Judging rewards depth, a working demo, and creative Claude use — not feature count. Opus tends to over-engineer; resist it. -### Phase 1 — MVP (the submittable product; finish completely before Phase 2) -1. **Patient-level leakage check** — detect `patient_id` overlap between train/test; report count + rate. -2. **Split-quality audit** — recompute the headline metric under random vs patient/site-grouped split; surface the gap. -3. **Subgroup degradation** — AUC (+ calibration) per `machine_id`/`site_id`; flag subgroups significantly below pooled. -4. **Claude risk report** — feed Phase-1 JSON to the LLM; get a green/yellow/red report: what can be claimed, what cannot, the next validation step. - -Phase 1 must be demoable end-to-end and reproducible on a fresh clone. That is the bar. +### Phase 1 — MVP (in the tree) +1. **Patient-level leakage check** — `patient_id` overlap between train and test; count and rate. `val` is ignored. +2. **Split-quality audit** — AUC on the test rows as given, and AUC on a patient sample of all rows (that sample includes training rows), plus the seed 0–199 range of the sample AUC. Descriptive only. It does not set the risk level, because rescoring fixed predictions cannot show inflation. +3. **Subgroup degradation** — AUC against the rest of the table for `machine_id`, `site_id`, and, when the columns exist, `density` and decade age bands. Flag when the drop is at least 0.10 and the upper end of a patient-level 95% interval is below zero. `mean_predicted` and `observed_rate` are reported and do not flag. Pooled AUC assumes every row's `y_prob` is a held-out or out-of-fold prediction. +4. **Risk level** — Python sets `RED` when any patient is in both train and test, otherwise `YELLOW` when a subgroup is flagged, otherwise `GREEN`. Claude is instructed to state `risk.level` verbatim and explain `risk.reasons`. For leakage, the next step it is instructed to name is a retrained patient-grouped evaluation, not another score of the same predictions. -### Phase 2 — only if Phase 1 is fully done and time remains (each optional) -5. Bootstrap confidence intervals. 6. Calibration plots per subgroup. 7. Reporting-completeness checklist mapped to TRIPOD+AI and CLAIM. 8. Reviewer-style critique + corrected Methods paragraph. 9. Reproducibility checklist output. +Phase 1 runs offline through the payload. The report step needs `ANTHROPIC_API_KEY`. -Do not start Phase 2 while any Phase 1 item is incomplete. +### Phase 2 — not built +Calibration plots per subgroup. A reporting checklist mapped to TRIPOD+AI and CLAIM. A reproducibility checklist. The reviewer critique (`--mode critique`) and the subgroup interval are already in Phase 1. ## Anti-over-engineering - Build only what this file asks for. No speculative configurability, plugin systems, or abstractions "for later." -- No defensive error handling for scenarios that cannot occur. Trust the fixed input schema. +- No defensive error handling for scenarios that cannot occur. `validate_predictions` is the boundary for a CSV the tool did not write. After it accepts a table, the checks trust that schema. - Comment only where logic isn't self-evident; don't document code you didn't write. - A single-file validator beats a five-file framework if it does the job. ## Constraints - Open source, **MIT**. All repo work must be original, started during the hackathon. -- Credits run on a **personal** claude.ai account (institutional accounts ineligible). Never hardcode an API key — read from env. -- Stack: Python 3.11+, pandas, scikit-learn for metrics. Streamlit **or** a thin CLI — pick one. -- The 3-minute demo video is 30% of judging. Design the demo path first: naive split looks good → grouped split collapses it → Claude explains why the claim is unsafe → corrected next step. Build toward that narrative. +- Credits run on a **personal** claude.ai account (institutional accounts ineligible). Never hardcode an API key. The CLI reads `ANTHROPIC_API_KEY`. The UI passes a typed key into the reporter for that call and does not write `os.environ`. +- Stack: Python 3.11+, pandas, scikit-learn for metrics. Both the CLI and the Streamlit UI are in the tree. +- The 3-minute demo video is 30% of judging. The recorded script is [docs/DEMO.md](docs/DEMO.md): `optimistic_site_shortcut`, then the local RSNA table, then `clean_patient_grouped`, then the critique. The risk level on screen is the Python level. ## Definition of done diff --git a/README.md b/README.md index ed83267..1f880d8 100644 --- a/README.md +++ b/README.md @@ -1,152 +1,169 @@ # clinval-validator -Flag the failure modes that make a clinical-AI performance claim unsafe — -patient-level data leakage, weak evaluation splits, and subgroup degradation — -in a model's `predictions.csv`, then render a human-readable Validation Risk -Report. - -![ClinVal Validator browser UI flagging 940 patients shared across train and test in the weak_random_split_leakage scenario](docs/images/app-weak-random-split-leakage.png) - -*Offline audit of the bundled synthetic `weak_random_split_leakage` scenario in the -[browser UI](#browser-ui); no Claude report was requested. The fourth metric is AUC on -the patient-disjoint subset: its +0.072 delta compares two evaluation populations -under unchanged predictions — it is not a model improvement and does not mean the -leakage is fixed.* - -Built from scratch for **Built with Claude: Life Sciences** (Builder Track), -started 2026-07-07. - -**Python computes the metrics; Claude explains the risk.** Every metric, -statistical test, and pass/fail flag is computed by deterministic Python in -`clinval_validators/` (runs fully offline). Claude (`claude-opus-4-8`) is used only to -turn that structured JSON into prose in `reporter/` — it never computes a number. - -> **Disclaimer.** Synthetic / public-schema data only, modeled on the RSNA -> Screening Mammography schema. No PHI, no real patient data, no RSNA images in -> this repo. Not medical advice. - -**Status:** Phase 1 complete, plus the first Phase-2 feature. The four -deterministic checks (patient leakage, split-quality audit, subgroup -degradation, pooled AUC) and the Claude reporter are implemented and verified -against the labeled scenario manifests — `pytest` is green, and -`clinval-validate` runs end-to-end (a real `ANTHROPIC_API_KEY` is needed only for -the report step). A reviewer-critique mode (`--mode critique`) adds a -journal-reviewer critique and a corrected, leakage-safe Methods paragraph over -the same deterministic JSON. See [TASKS.md](TASKS.md) and -[demo/run_demo.sh](demo/run_demo.sh). - -## MVP scope (Phase 1) - -1. **Patient-level leakage check** — detect `patient_id` overlap between train - and test; report count and rate. -2. **Split-quality audit** — recompute the headline metric under a random split - vs a patient/site-grouped split; surface the gap. -3. **Subgroup degradation** — AUC per `machine_id` / `site_id`; flag subgroups - significantly below the pooled metric. -4. **Claude risk report** — feed the Phase-1 JSON to `claude-opus-4-8`; get a - GREEN / YELLOW / RED report: what can be claimed, what cannot, and the next - validation step. - -Everything else (bootstrap CIs, calibration plots, TRIPOD+AI / CLAIM mapping) is -Phase 2 — see [PROJECT_PLAN.md](PROJECT_PLAN.md). - -## Input schema - -Predictions CSV, modeled on the RSNA Screening Mammography schema: +ClinVal Validator reads a model's `predictions.csv` and reports two failure modes that make a clinical-AI performance claim unsafe: the same patient in both the train and test splits, and a subgroup whose AUC is lower than the rest of the table. Python computes the metrics, the flags, and the risk level. Claude is instructed to turn that JSON into prose and to cite only numbers present in it. Nothing in this repository checks the text Claude returns. The reporter calls Claude Opus 5.5 (`claude-opus-5-5`) at low effort; if a safety classifier declines a request, the API reruns it on the model Anthropic recommends for that refusal category. + +![ClinVal Validator on weak_random_split_leakage: 940 leaked patients, test-split AUC 0.827, patient-disjoint sample AUC 0.899, and a RED banner](docs/images/app-weak-random-split-leakage.png) + +*Offline view of the bundled synthetic `weak_random_split_leakage` scenario. No API key was entered and no Claude report was requested. The fourth metric is the AUC of a patient sample drawn from all rows, including training rows. The +0.072 figure is that sample minus the test-split AUC. The RED line is the risk level computed by Python.* + +The column names follow the Radiological Society of North America (RSNA) Screening Mammography Breast Cancer Detection dataset. This repository contains synthetic tables and that public schema only. It contains no RSNA images and no protected health information (PHI). It is not medical advice. + +## Risk level + +`risk.level` is one of `RED`, `YELLOW`, or `GREEN`. + +- `RED` when `overlapping_patient_ids` is greater than zero: at least one `patient_id` is in both `split == "train"` and `split == "test"`. +- `YELLOW` when there is no such overlap and at least one subgroup is flagged. +- `GREEN` when neither of those is true. + +`risk.reasons` states the numbers that produced the level. The split audit is not an input to this rule. + +## Subgroup flag + +The check evaluates `machine_id` and `site_id`. When a `density` column is present it evaluates each density value. When an `age` column is present it evaluates decade bands such as `50-59`. Missing density or age values are left out of that grouping. + +A subgroup is evaluated when it has at least 50 rows and both the subgroup and the rest of the table contain class 0 and class 1. `delta_vs_rest` is the subgroup AUC minus the AUC of every other row. `delta_ci95` is the 2.5th and 97.5th percentile of that difference over a patient-level bootstrap: resample `patient_id` with replacement, 200 replicates, seed 0. Replicates that lack a class are skipped. + +A subgroup is flagged when `delta_vs_rest` is at or below −0.10 and the upper end of `delta_ci95` is below zero. + +On 30 tables from the bundled generator (seeds 7–36; 2,000 patients and about 1.5% positive images per table), 6 of 30 clean patient-grouped tables received a flag, the injected M030 drop was missed in 4 of 30 scanner tables, and the injected density C/D drop was flagged in 21 of 30 tables. These rates come from that generator, not from real data. At this sample size a flag is a candidate drop to confirm on more data, and the absence of a flag does not show that every subgroup performs like the rest. + +`mean_predicted` (mean `y_prob`) and `observed_rate` (mean `y_true`) are calibration-in-the-large for that subgroup. They are reported and they do not flag. On `optimistic_site_shortcut`, mean `y_prob` by site runs from 0.331 to 0.754 while the observed positive rate runs from 0.97% to 2.3%. + +`subgroup_summary` counts the evaluated and flagged subgroups and the `delta_ci95` intervals that lie entirely below or entirely above zero. `pooled_metric.rows` is `"all"`: the pooled AUC covers train, val and test rows, so it is not the test-split AUC. Python supplies both because live reports from Claude miscounted subgroups and called the pooled AUC a test-set result. + +## Split audit + +`test_split_as_given` is the AUC on rows with `split == "test"`. `patient_disjoint_sample` is the AUC on a seed-0 sample of 15% of patients drawn from every row, so the sample includes training rows. `sample_range95` is the 2.5th and 97.5th percentile of that sample AUC over seeds 0–199. `gap` is `test_split_as_given` minus `patient_disjoint_sample`. + +On a small table a sample can contain one class, where AUC is undefined. Those seeds are left out of `sample_range95`. When the seed-0 sample contains one class, `patient_disjoint_sample` and `gap` are `null`; when every seed's sample does, `sample_range95` is `null`. + +Both AUCs use the predictions already in the file. The audit does not measure how much leakage changed a metric, and it does not set the risk level. + +## Input + +A predictions table needs these columns: ``` -patient_id, image_id, laterality, view, machine_id, site_id, age, density, y_true, y_prob, split +patient_id, machine_id, site_id, y_true, y_prob, split ``` -The validators consume the minimal subset `patient_id, machine_id, site_id, -y_true, y_prob, split` (plus `density` / `age` for subgroup checks). Labeled -fixtures live in -[`claude-science-artifacts/scenarios/`](claude-science-artifacts/scenarios/). +`split` may be `train`, `val`, or `test`. The leakage check compares `train` and `test` only. Rows with `split == "val"` are ignored by that check. `y_true` is 0 or 1. `y_prob` is a number in the closed interval from 0 to 1. The test rows must contain both classes. + +`density` and `age` are optional. The fixtures also carry `image_id`, `laterality`, and `view`. The checks ignore columns they do not name. + +The pooled AUC and the subgroup AUCs treat every row's `y_prob` as a held-out or out-of-fold prediction. A score that was produced on a row the model saw in training is not that quantity, and the file does not record which rows were held out beyond the `split` column. + +Labeled fixtures live in [`claude-science-artifacts/scenarios/`](claude-science-artifacts/scenarios/). + +An uploaded file that fails these checks is rejected before any metric runs. The command-line tool prints the problems on stderr and exits 2. The browser UI shows the same problems and stops. ## Quickstart ```bash -# 1. Install (editable). Requires Python 3.11+. -python -m pip install -e . +# Python 3.11+. Installs the package and pytest. +python -m pip install -e '.[dev]' -# 2. Set your personal Anthropic API key (never commit it). -export ANTHROPIC_API_KEY=sk-ant-... - -# 3. Validate a labeled scenario and print the GREEN / YELLOW / RED risk report. +# ANTHROPIC_API_KEY unset: the JSON payload is printed and the process exits 1. clinval-validate --predictions claude-science-artifacts/scenarios/weak_random_split_leakage.csv +echo "exit code: $?" -# Or ask for a journal-reviewer critique plus a corrected, leakage-safe Methods paragraph. -clinval-validate --predictions claude-science-artifacts/scenarios/weak_random_split_leakage.csv --mode critique +python -m pytest -q +``` -# Run the test suite. -python -m pytest +With `ANTHROPIC_API_KEY` set, the same command prints the risk report instead of the payload and exits 0. `--mode critique` asks for a reviewer critique and a Methods paragraph. The key is read from the environment. It is not stored in the repository. If the API request fails (for example no credits, an invalid key, or no connection), or Claude's reply is cut off at the 4,096-token limit, refused, or contains no text, the command prints the payload, states the reason on stderr, and exits 1; the browser UI shows the same message. -# Launch the browser UI (no terminal needed after this line). -python -m pip install -e '.[demo]' && streamlit run app.py +```bash +python -m pip install -e '.[demo]' +streamlit run app.py ``` ## Browser UI -`streamlit run app.py` is the path a collaborator can run without you in the room: +`streamlit run app.py` runs the same checks. -- Pick a bundled scenario **or upload your own `predictions.csv`**. A missing - required column is reported by name — not as a stack trace. -- The deterministic checks run **offline and without an API key**: leakage count, - split audit, per-subgroup AUC, and the exact JSON that Claude would receive. -- Press **Generate with Claude** for the risk report or the reviewer critique. - Both are optional — the validation stands on its own. -- **Download** the payload as JSON and the report as Markdown. +- Choose a bundled scenario or upload a `predictions.csv`. +- The checks run without an API key. The page shows the leakage count, the split audit, and the JSON payload, including `risk.level`. +- The key typed into the sidebar is sent only with that session's request. It is not written into the process environment. +- When the key field is empty, the app uses `ANTHROPIC_API_KEY` from the server environment. That key stays on the server; the field is never prefilled with it. +- **Generate with Claude** asks for the risk report or the reviewer critique. Both are optional. +- Download the payload as JSON and, after a report is generated, the report as Markdown. -## Sample output +## Sample payload -Running `--mode risk` on `weak_random_split_leakage`, the deterministic layer -computes this payload (this is what Python hands to Claude — no metric is ever -computed by the model): +`run_checks` on `weak_random_split_leakage` produces the payload below. The `subgroups` list is truncated: the command prints all 20 evaluated subgroups, and only the first is shown here. ```json { - "leakage": {"overlapping_patient_ids": 940, "leakage_rate": 0.9802}, - "split_audit": {"metric": "auc", "random_split": 0.8273, "grouped_split": 0.8994, "gap": -0.0721}, - "subgroups": [{"key": "site_id", "value": "S03", "n": 2125, "auc": 0.7896, "delta_vs_pooled": -0.0528, "flagged": false}], - "pooled_metric": {"name": "auc", "value": 0.8425} + "leakage": { + "overlapping_patient_ids": 940, + "leakage_rate": 0.9801876955161627 + }, + "split_audit": { + "metric": "auc", + "test_split_as_given": 0.8272804054054055, + "patient_disjoint_sample": 0.8993770139634801, + "sample_range95": [0.7523126406555013, 0.933559031020775], + "gap": -0.07209660855807465 + }, + "subgroups": [ + { + "key": "machine_id", + "value": "M031", + "n": 1020, + "auc": 0.8075241391874658, + "auc_rest": 0.8490112630891348, + "delta_vs_rest": -0.041487123901668976, + "delta_ci95": [-0.17020941694803463, 0.052104879910572145], + "mean_predicted": 0.511070955882353, + "observed_rate": 0.021568627450980392, + "flagged": false + } + ], + "subgroup_summary": { + "evaluated": 20, + "flagged": 0, + "interval_below_zero": 0, + "interval_above_zero": 1 + }, + "pooled_metric": {"name": "auc", "rows": "all", "value": 0.8424535490580416}, + "risk": { + "level": "RED", + "reasons": [ + "940 patients appear in both the train and test splits (leakage rate 98.0%)." + ] + } } ``` -Claude renders that into a reviewer-style report. An illustrative excerpt (the -wording is model-generated and varies run to run; the numbers are fixed by the -payload above): +The report below is hand-written so that a reader can see the shape. It is not output from Claude. ``` -1. Overall risk — RED. 940 patient IDs appear in both train and test, so the - reported metrics do not reflect held-out performance. +1. Overall risk — RED. 940 patients appear in both the train and test splits (leakage rate 98.0%). + +2. What the data shows — The leakage rate is 0.9802, which is 940 divided by the number of distinct test patients. Pooled AUC is 0.8425. The test-split AUC is 0.8273 and the patient-disjoint sample AUC is 0.8994 (gap −0.0721). The sample range over seeds 0–199 is 0.7523 to 0.9336. No subgroup is flagged. The first subgroup, machine M031, has AUC 0.8075 against 0.8490 on the rest of the table. -2. What the data shows — The leakage check finds 940 shared patients (98.0% of - the test patients also appear in training). Pooled AUC is 0.84, but that - figure is measured across a contaminated split. No machine_id or site_id - subgroup falls more than 0.10 below pooled. +3. What can be claimed — The file records 940 patients on both sides of the split. -5. Next validation step — Re-evaluate the same predictions on a patient-grouped - split so no patient spans the train/test boundary, and report the grouped AUC - as the headline number. +4. What cannot be claimed — An AUC from this file does not estimate performance on patients who were absent from training. + +5. Next validation step — Retrain and evaluate with a patient-grouped split, so that no test patient was seen in training. ``` -The full 3-minute demo script — synthetic opener, real RSNA cohort, clean -control, and the `--mode critique` close — is in [docs/DEMO.md](docs/DEMO.md). +The demo script is [docs/DEMO.md](docs/DEMO.md). + +## RSNA adapter -## Demo narrative (3-minute video) +[`rsna_to_predictions.py`](rsna_to_predictions.py) is a local-only adapter. It reads a `train.csv` from the RSNA Screening Mammography Breast Cancer Detection dataset and writes a predictions table. The RSNA license bans redistribution, so `data/rsna/` and `predictions_rsna*.csv` are gitignored and are not part of this repository. -1. Run the validator on `weak_random_split_leakage` — the headline pooled AUC - (≈ 0.84) looks publishable. -2. The leakage check finds **940 patients shared across train and test** and - marks the random split as not leakage-safe; the split-quality audit - re-evaluates the same predictions on a patient-grouped fold. -3. Claude reads the deterministic JSON and explains, in reviewer language, why an - AUC measured under patient overlap does not reflect deployment, and what may - still be claimed. -4. Claude names the single next validation step (a leakage-safe grouped - re-evaluation). For contrast, `optimistic_site_shortcut` shows the same - failure at its most dramatic: AUC 0.9647 under a leaking split. +```bash +python rsna_to_predictions.py --input data/rsna/train.csv +``` + +The default run writes `predictions_rsna_random.csv` and `predictions_rsna_patient_grouped.csv`. The scores in those files come from a tabular baseline on pre-imaging metadata, not from an imaging model. ## License -MIT — see [LICENSE](LICENSE). +MIT. See [LICENSE](LICENSE). + +July 2026 planning notes are in [docs/archive/](docs/archive/). diff --git a/app.py b/app.py index fbfee48..c2e4b44 100644 --- a/app.py +++ b/app.py @@ -17,25 +17,26 @@ import pandas as pd -from cli import _assemble_payload -from reporter import API_KEY_ENV, render_report, render_reviewer_critique +from clinval_validators import run_checks, validate_predictions +from reporter import ( + API_KEY_ENV, + MODEL, + ReportNotRenderedError, + render_report, + render_reviewer_critique, +) SCENARIOS_DIR = Path(__file__).resolve().parent / "claude-science-artifacts" / "scenarios" -# The columns every validator needs. Checked at the upload boundary: an uploaded -# file is the one input this tool does not control, so a missing column must -# produce a readable message rather than a raw KeyError traceback. -REQUIRED_COLUMNS = ("patient_id", "machine_id", "site_id", "y_true", "y_prob", "split") - def available_scenarios() -> dict[str, Path]: """Map each bundled scenario name to its predictions CSV (excludes the summary).""" return {p.stem: p for p in sorted(SCENARIOS_DIR.glob("*.csv")) if not p.name.startswith("_")} -def missing_columns(df: pd.DataFrame) -> list[str]: - """Return the required columns absent from ``df``; empty when the schema fits.""" - return sorted(set(REQUIRED_COLUMNS) - set(df.columns)) +def _run_checks(df: pd.DataFrame): + """Streamlit-cacheable wrapper. The cache lives on this function, not on a session.""" + return run_checks(df) def main() -> None: @@ -67,24 +68,24 @@ def main() -> None: st.header("Claude report") mode = st.radio("Mode", ["risk", "critique"], format_func=str.capitalize) + # The field starts empty: a widget's value is sent to the browser, so + # prefilling it would hand the server's key to every visitor. api_key = st.text_input( - "Anthropic API key", value=os.environ.get(API_KEY_ENV, ""), type="password" + "Anthropic API key", + type="password", + help=f"Leave empty to use {API_KEY_ENV} from the server environment.", ) generate = st.button("Generate with Claude", type="primary") - absent = missing_columns(df) - if absent: - st.error( - f"This CSV is missing required column(s): {', '.join(absent)}. " - f"A predictions table needs: {', '.join(REQUIRED_COLUMNS)}. " - "See the README for the expected schema.", - icon="🚫", - ) + problems = validate_predictions(df) + if problems: + st.error("\n\n".join(problems), icon="🚫") st.stop() - payload = _assemble_payload(df) + payload = st.cache_data(_run_checks)(df) leakage = payload["leakage"] - flagged = [s for s in payload["subgroups"] if s["flagged"]] + level = payload["risk"]["level"] + reasons = " ".join(payload["risk"]["reasons"]) left, right = st.columns(2) @@ -94,28 +95,36 @@ def main() -> None: c1, c2, c3, c4 = st.columns(4) c1.metric("Leaked patients", leakage["overlapping_patient_ids"]) c2.metric("Leakage rate", f"{leakage['leakage_rate']:.1%}") - # The only like-for-like pair: one fixed y_prob, two evaluation populations. - # Labels stay split-agnostic because an uploaded file may already be grouped. - c3.metric("AUC, test split as given", f"{audit['random_split']:.3f}") + # Same fixed predictions. The sample draws patients from every row, + # including training rows, so it is not a second look at the test split. + c3.metric("AUC, test split as given", f"{audit['test_split_as_given']:.3f}") + # A small table can draw a one-class sample; the audit then reports None. + sample = audit["patient_disjoint_sample"] c4.metric( - "AUC, patient-disjoint subset", - f"{audit['grouped_split']:.3f}", - delta=f"{audit['grouped_split'] - audit['random_split']:+.3f}", + "AUC, patient-disjoint sample", + "n/a (one class)" if sample is None else f"{sample:.3f}", + delta=None if sample is None else f"{sample - audit['test_split_as_given']:+.3f}", + ) + range95 = audit["sample_range95"] + range_text = ( + "" + if range95 is None + else f"Over the seeds 0–199 whose sample holds both classes, that sample " + f"AUC stays within {range95[0]:.3f}–{range95[1]:.3f}. " ) st.caption( - "No retraining — only the evaluation population changes. " + "Both AUCs use the same fixed predictions. The patient-disjoint " + "sample is drawn from all rows, including training rows, so this " + "comparison cannot measure how much leakage inflated the metric. " + f"{range_text}" "Pooled AUC and the per-subgroup breakdown are in the JSON below." ) - if leakage["overlapping_patient_ids"]: - st.error( - f"{leakage['overlapping_patient_ids']} patients appear in both " - "train and test — the split is not leakage-safe.", - icon="🚫", - ) - elif flagged: - st.warning(f"{len(flagged)} subgroup(s) flagged below pooled AUC.", icon="⚠️") + if level == "RED": + st.error(f"{level}. {reasons}", icon="🚫") + elif level == "YELLOW": + st.warning(f"{level}. {reasons}", icon="⚠️") else: - st.success("No leakage and no subgroup flags.", icon="✅") + st.success(f"{level}. {reasons}", icon="✅") st.caption("Payload sent to Claude (every number is computed here, not by the model):") st.json(payload) st.download_button( @@ -132,14 +141,17 @@ def main() -> None: report_key = f"{mode}:{json.dumps(payload, sort_keys=True)}" cached = st.session_state.get("report") - if generate and not api_key: + if generate and not (api_key or os.environ.get(API_KEY_ENV)): st.warning("Set an Anthropic API key to render the report.", icon="🔑") elif generate: - os.environ[API_KEY_ENV] = api_key render = render_reviewer_critique if mode == "critique" else render_report - with st.spinner(f"Asking claude-opus-4-8 for the {mode} report…"): - cached = (report_key, render(payload)) - st.session_state["report"] = cached + try: + with st.spinner(f"Asking {MODEL} for the {mode} report…"): + # None makes the reporter read the server's ANTHROPIC_API_KEY. + cached = (report_key, render(payload, api_key=api_key or None)) + st.session_state["report"] = cached + except ReportNotRenderedError as exc: + st.error(f"{exc} The deterministic payload on the left is complete.", icon="🚫") if cached and cached[0] == report_key: st.markdown(cached[1]) diff --git a/claude-science-artifacts/README.md b/claude-science-artifacts/README.md index 670d8fd..647f4bd 100644 --- a/claude-science-artifacts/README.md +++ b/claude-science-artifacts/README.md @@ -1,17 +1,15 @@ # Synthetic RSNA-like predictions + leakage generator -Fixtures for a medical-imaging ML **validation** tool. Produces -`predictions.csv` tables that look like RSNA screening-mammography outputs and -**inject known, detectable flaws**, so a validator can be tested against ground -truth. +Fixtures for a medical-imaging validation tool. RSNA is the Radiological Society of North America Screening Mammography Breast Cancer Detection dataset. The generator writes `predictions.csv` tables with that column layout and injects known flaws, so a validator can be tested against ground truth. The rows are synthetic. ## Schema `patient_id, image_id, laterality, view, machine_id, site_id, age, density, y_true, y_prob, split` -- Screening exam ≈ 4 images (L/R × CC/MLO), with occasional extra/dropped views → **non-uniform images per patient** (this is what makes image-level random splits leak). -- Machines are nested in sites (`M`); cancer label lives on the affected laterality; density (BI-RADS A–D) modestly raises risk and prevalence is screening-realistic (~1.5% image-level). -- `y_prob` is drawn so overall ROC-AUC hits a target (`AUC = Φ(d/√2)`); AUC survives the sigmoid that maps latent scores to (0,1). +- A screening exam is about 4 images: left and right, each in two views. CC is the craniocaudal view and MLO is the mediolateral oblique view. Occasional extra or dropped views make the image count per patient uneven, which is what lets an image-level random split put one patient in both train and test. +- `machine_id` is the letter `M`, a two-digit site number, and a one-digit machine number. `M030` is site 03, machine 0. The cancer label is on the affected laterality. +- `density` is a Breast Imaging Reporting and Data System (BI-RADS) category, `A` through `D`. Denser categories carry a higher cancer risk in the generator. Image-level prevalence is about 1.5%. +- `d` is the separation, in standard deviations, between the mean latent score of positive rows and the mean latent score of negative rows. Scores are drawn so the ROC AUC is `Φ(d/√2)`. The sigmoid that maps the latent score to a `y_prob` in (0, 1) does not change that AUC. ## Injected failure modes @@ -24,21 +22,32 @@ truth. | Optimistic metric | `optimistic_auc` | implausibly high headline AUC | | Site shortcut / batch effect | `site_shortcut_strength` | site-correlated nuisance signal | -Every table ships a `*.manifest.json` with `injected_flaws`, recomputed -`expected_detections` (overlap counts, target AUCs), and a `verification` -block confirming the flaw landed. +`leakage_generator.py` writes a `*.manifest.json` with `injected_flaws` and +`expected_detections` (overlap counts and target AUCs). The `verification` +block is added only by `make_scenarios.py`, after it recomputes those +quantities on the written table. A manifest from `leakage_generator.py` alone +does not contain `verification`. + +Expectations for the six committed scenarios are in +[`scenarios/_scenario_summary.csv`](scenarios/_scenario_summary.csv). ## Usage +Run these from `claude-science-artifacts/`. They need NumPy, pandas, and +scikit-learn. From the repository root, `python -m pip install -e .` installs +pandas and scikit-learn, and NumPy comes with them. `make_scenarios.py` +imports `leakage_generator` as a local module, so the working directory matters. + CLI: ```bash +cd claude-science-artifacts python leakage_generator.py --out predictions.csv \ --split-strategy random \ --degrade machine_id:M030:0.60 --degrade density:C,D:0.68 \ --optimistic-auc 0.98 ``` -Programmatic: +Programmatic, with `claude-science-artifacts/` on `sys.path`: ```python from leakage_generator import GeneratorConfig, Flaws, SubgroupDegradation, generate cfg = GeneratorConfig(seed=4, flaws=Flaws( @@ -47,8 +56,9 @@ cfg = GeneratorConfig(seed=4, flaws=Flaws( df, manifest = generate(cfg) ``` -Build + self-verify the full labeled suite: +Build the labeled suite and write the `verification` block: ```bash +cd claude-science-artifacts python make_scenarios.py # writes scenarios/*.csv + manifests + _scenario_summary.csv ``` @@ -57,3 +67,7 @@ python make_scenarios.py # writes scenarios/*.csv + manifests + _scenario_sum `clean_patient_grouped` (negative control), `weak_random_split_leakage`, `duplicate_row_leakage`, `subgroup_scanner_degradation`, `subgroup_density_degradation`, `optimistic_site_shortcut`. + +The committed CSVs and manifests are the test oracle. Regenerating them +replaces that oracle. + diff --git a/cli.py b/cli.py index af30f1b..37c02a9 100644 --- a/cli.py +++ b/cli.py @@ -1,10 +1,9 @@ """Command-line entrypoint: validate a predictions CSV and print the risk report. Wires the two layers: load predictions -> run the deterministic validators -> -assemble the report payload -> hand it to the reporter -> print the report. The -compute logic lives in ``clinval_validators`` and runs offline; ``reporter`` is the -only part that needs the network, so without an API key the validators still run and -their JSON is printed. +hand the payload to the reporter -> print the report. The compute logic lives in +``clinval_validators`` and runs offline; ``reporter`` is the only part that needs +the network, so without an API key the validators still run and their JSON is printed. """ from __future__ import annotations @@ -17,13 +16,13 @@ import pandas as pd -from reporter import API_KEY_ENV, MissingAPIKeyError, render_report, render_reviewer_critique -from clinval_validators import ( - ReportPayload, - audit_split_quality, - check_patient_leakage, - check_subgroup_degradation, - compute_pooled_metric, +from clinval_validators import run_checks, validate_predictions +from reporter import ( + API_KEY_ENV, + MissingAPIKeyError, + ReportNotRenderedError, + render_report, + render_reviewer_critique, ) @@ -53,20 +52,14 @@ def _load_predictions(path: Path) -> pd.DataFrame: return pd.read_csv(path) -def _assemble_payload(df: pd.DataFrame) -> ReportPayload: - """Run every validator and assemble the report JSON contract.""" - return { - "leakage": check_patient_leakage(df), - "split_audit": audit_split_quality(df), - "subgroups": check_subgroup_degradation(df), - "pooled_metric": compute_pooled_metric(df), - } - - def main(argv: Sequence[str] | None = None) -> int: args = _parse_args(argv) df = _load_predictions(args.predictions) - payload = _assemble_payload(df) + problems = validate_predictions(df) + if problems: + print("\n".join(problems), file=sys.stderr) + return 2 + payload = run_checks(df) render = render_reviewer_critique if args.mode == "critique" else render_report try: print(render(payload)) @@ -80,6 +73,14 @@ def main(argv: Sequence[str] | None = None) -> int: file=sys.stderr, ) return 1 + except ReportNotRenderedError as exc: + print(json.dumps(payload, indent=2)) + print( + f"\nThe deterministic checks above ran offline and are complete.\n" + f"{exc} The {args.mode} report was not rendered.", + file=sys.stderr, + ) + return 1 return 0 diff --git a/clinval_validators/__init__.py b/clinval_validators/__init__.py index 72bf1ce..fb69a09 100644 --- a/clinval_validators/__init__.py +++ b/clinval_validators/__init__.py @@ -4,22 +4,32 @@ LeakageResult, PooledMetric, ReportPayload, + RiskResult, SplitAuditResult, SubgroupResult, + SubgroupSummary, audit_split_quality, check_patient_leakage, check_subgroup_degradation, compute_pooled_metric, + compute_risk, + run_checks, + validate_predictions, ) __all__ = [ "LeakageResult", "PooledMetric", "ReportPayload", + "RiskResult", "SplitAuditResult", "SubgroupResult", + "SubgroupSummary", "audit_split_quality", "check_patient_leakage", "check_subgroup_degradation", "compute_pooled_metric", + "compute_risk", + "run_checks", + "validate_predictions", ] diff --git a/clinval_validators/checks.py b/clinval_validators/checks.py index 1ab0f91..81fc630 100644 --- a/clinval_validators/checks.py +++ b/clinval_validators/checks.py @@ -7,37 +7,45 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypedDict +from typing import Literal, TypedDict +import numpy as np +import pandas as pd from sklearn.metrics import roc_auc_score from sklearn.model_selection import GroupShuffleSplit -if TYPE_CHECKING: - import pandas as pd - -# Subgroup flag thresholds, calibrated against the labeled scenarios: M030 in -# subgroup_scanner_degradation sits 0.21 below pooled and must flag, while every -# subgroup in clean_patient_grouped is within 0.07 of pooled and must not. +# Subgroup flag thresholds, calibrated on the committed fixtures. A subgroup +# flags when its AUC is at least 0.10 below the rest of the table and the +# patient-level 95% interval for that drop lies entirely below zero. M030 in +# subgroup_scanner_degradation must flag; clean_patient_grouped must not. _FLAG_DELTA = -0.10 _MIN_SUBGROUP_N = 50 +_BOOTSTRAP_REPLICATES = 200 +_BOOTSTRAP_SEED = 0 + +# Columns the checks read by name. Anything else in the table is ignored. +_REQUIRED_COLUMNS = ("patient_id", "machine_id", "site_id", "y_true", "y_prob", "split") __all__ = [ "LeakageResult", "SplitAuditResult", "SubgroupResult", + "SubgroupSummary", "PooledMetric", "ReportPayload", + "validate_predictions", "check_patient_leakage", "audit_split_quality", "check_subgroup_degradation", "compute_pooled_metric", + "compute_risk", + "run_checks", + "RiskResult", ] # --- Report JSON contract ------------------------------------------------- -# Shape mirrors prompts/opus-4-8/reporter_system_prompt.md exactly. The -# validators produce this payload; the reporter consumes it. Do not add fields -# beyond the ones documented there. +# The validators produce this payload; the reporter sends it to Claude. class LeakageResult(TypedDict): @@ -47,9 +55,10 @@ class LeakageResult(TypedDict): class SplitAuditResult(TypedDict): metric: str - random_split: float - grouped_split: float - gap: float + test_split_as_given: float + patient_disjoint_sample: float | None + sample_range95: list[float] | None + gap: float | None class SubgroupResult(TypedDict): @@ -57,25 +66,83 @@ class SubgroupResult(TypedDict): value: str n: int auc: float - delta_vs_pooled: float + auc_rest: float + delta_vs_rest: float + delta_ci95: list[float] | None + mean_predicted: float + observed_rate: float flagged: bool +class SubgroupSummary(TypedDict): + evaluated: int + flagged: int + interval_below_zero: int + interval_above_zero: int + + class PooledMetric(TypedDict): name: str + rows: str value: float +class RiskResult(TypedDict): + level: Literal["RED", "YELLOW", "GREEN"] + reasons: list[str] + + class ReportPayload(TypedDict): leakage: LeakageResult split_audit: SplitAuditResult subgroups: list[SubgroupResult] + subgroup_summary: SubgroupSummary pooled_metric: PooledMetric + risk: RiskResult # --- Checks --------------------------------------------------------------- +def validate_predictions(df: pd.DataFrame) -> list[str]: + """Return readable problems with a predictions table. + + An empty list means the deterministic checks can run. Called at the CLI + and upload boundary, which is the one input this tool does not control. + A missing column stops the remaining checks, because they read columns by + name. + """ + missing = [column for column in _REQUIRED_COLUMNS if column not in df.columns] + if missing: + needed = ", ".join(_REQUIRED_COLUMNS) + return [ + "Missing required column(s): " + + ", ".join(missing) + + f". A predictions table needs {needed}." + ] + + problems: list[str] = [] + if not (df["split"] == "train").any(): + problems.append('No rows with split == "train".') + if not (df["split"] == "test").any(): + problems.append('No rows with split == "test".') + + y_true = pd.to_numeric(df["y_true"], errors="coerce") + if y_true.isna().any() or not y_true.dropna().isin([0, 1]).all(): + problems.append("y_true has values outside {0, 1}.") + + test_y = y_true[df["split"] == "test"] + if len(test_y) and not ((test_y == 0).any() and (test_y == 1).any()): + problems.append("Test rows do not contain both classes (0 and 1).") + + y_prob = pd.to_numeric(df["y_prob"], errors="coerce") + if y_prob.isna().any(): + problems.append("y_prob is not numeric.") + if (y_prob.notna() & ((y_prob < 0) | (y_prob > 1))).any(): + problems.append("y_prob has values outside [0, 1].") + return problems + + def check_patient_leakage(df: pd.DataFrame) -> LeakageResult: """Detect ``patient_id`` overlap between the train and test splits. @@ -94,70 +161,299 @@ def check_patient_leakage(df: pd.DataFrame) -> LeakageResult: def audit_split_quality(df: pd.DataFrame) -> SplitAuditResult: - """Recompute the headline metric under the file's split vs a grouped split. - - Both AUCs read the same fixed ``y_prob`` on two different test subsets, so - this measures how sensitive the headline metric is to the choice of split — - it is a diagnostic, not a retrained proof of split-induced optimism. - ``random_split`` uses the test rows as given in the file; ``grouped_split`` - re-partitions by ``patient_id`` with a fixed seed so no patient spans the - fold boundary. The rigorous leakage signal is :func:`check_patient_leakage`; - ``gap`` can be small or even negative when the leak is structural patient - overlap rather than an inflated ``y_prob``. + """Compare two AUCs computed from the same fixed predictions. + + ``test_split_as_given`` is the AUC on rows with ``split == "test"``. + ``patient_disjoint_sample`` is the AUC on a seed-0 sample of 15% of + patients drawn from every row, so that sample includes training rows. + ``sample_range95`` is the 2.5th and 97.5th percentile of that sample AUC + over seeds 0–199. ``gap`` is ``test_split_as_given`` minus + ``patient_disjoint_sample``. + + On a small table a sample can hold one class, where AUC is undefined. + Such seeds are left out of ``sample_range95``. When the seed-0 sample + holds one class, ``patient_disjoint_sample`` and ``gap`` are None; when + every seed does, ``sample_range95`` is None. + + The comparison cannot measure how much leakage inflated a metric. Both + numbers rescore predictions that already exist; they do not come from + retraining under a patient-grouped split. The leakage count is + :func:`check_patient_leakage`. """ - test_rows = df[df["split"] == "test"] - random_split = float(roc_auc_score(test_rows["y_true"], test_rows["y_prob"])) - - splitter = GroupShuffleSplit(n_splits=1, test_size=0.15, random_state=0) - _, grouped_test_idx = next(splitter.split(df, groups=df["patient_id"])) - grouped_fold = df.iloc[grouped_test_idx] - grouped_split = float(roc_auc_score(grouped_fold["y_true"], grouped_fold["y_prob"])) - + y_true = df["y_true"].to_numpy() + y_prob = df["y_prob"].to_numpy() + test_rows = df["split"].to_numpy() == "test" + test_split_as_given = float(roc_auc_score(y_true[test_rows], y_prob[test_rows])) + + groups = df["patient_id"] + sample_aucs: list[float] = [] + patient_disjoint_sample: float | None = None + for seed in range(200): + splitter = GroupShuffleSplit(n_splits=1, test_size=0.15, random_state=seed) + _, sample_idx = next(splitter.split(df, groups=groups)) + if not _both_classes(y_true[sample_idx]): + continue + auc = float(roc_auc_score(y_true[sample_idx], y_prob[sample_idx])) + if seed == 0: + patient_disjoint_sample = auc + sample_aucs.append(auc) + + sample_range95 = ( + [float(bound) for bound in np.percentile(sample_aucs, [2.5, 97.5])] + if sample_aucs + else None + ) return { "metric": "auc", - "random_split": random_split, - "grouped_split": grouped_split, - "gap": random_split - grouped_split, + "test_split_as_given": test_split_as_given, + "patient_disjoint_sample": patient_disjoint_sample, + "sample_range95": sample_range95, + "gap": ( + None + if patient_disjoint_sample is None + else test_split_as_given - patient_disjoint_sample + ), } -def check_subgroup_degradation(df: pd.DataFrame) -> list[SubgroupResult]: - """Compute per-``machine_id``/``site_id`` AUC and flag degraded subgroups. +def _roc_auc(y_true: np.ndarray, y_score: np.ndarray) -> float: + """ROC AUC with average ranks for ties, the same definition sklearn uses. - Returns one entry per evaluated subgroup, each carrying its AUC, delta - versus the pooled metric, and whether it is flagged as significantly below - pooled performance. A subgroup is skipped (not evaluated) when it has fewer - than ``_MIN_SUBGROUP_N`` rows or lacks both classes, since its AUC would be - undefined or too noisy to trust. + A rank statistic keeps the patient-level bootstrap under a few seconds. """ - pooled_auc = float(roc_auc_score(df["y_true"], df["y_prob"])) - results: list[SubgroupResult] = [] + y_true = np.asarray(y_true) + y_score = np.asarray(y_score, dtype=np.float64) + n_pos = int(np.sum(y_true == 1)) + n = y_true.size + n_neg = n - n_pos + if n_pos == 0 or n_neg == 0: + return float("nan") + order = np.argsort(y_score, kind="mergesort") + y_sorted = y_true[order] + scores = y_score[order] + change = np.empty(n, dtype=bool) + change[0] = True + change[1:] = scores[1:] != scores[:-1] + group = np.cumsum(change) - 1 + counts = np.bincount(group) + ends = np.cumsum(counts) + average_rank = (ends - counts + 1 + ends) / 2.0 + sum_pos = float(average_rank[group][y_sorted == 1].sum()) + return (sum_pos - n_pos * (n_pos + 1) / 2.0) / (n_pos * n_neg) + + +def _both_classes(y_true: np.ndarray) -> bool: + return bool(y_true.size) and bool((y_true == 0).any()) and bool((y_true == 1).any()) + + +def _patient_bootstrap_indices(patient_id: pd.Series) -> list[np.ndarray]: + """Row indices for each patient-level resample of the whole table. + + Patients are drawn with replacement. A patient drawn twice contributes + their rows twice. One draw is reused for every subgroup. + """ + codes, _ = pd.factorize(patient_id, sort=False) + order = np.argsort(codes, kind="mergesort") + sorted_codes = codes[order] + starts = np.flatnonzero(np.r_[True, sorted_codes[1:] != sorted_codes[:-1]]) + counts = np.diff(np.r_[starts, len(patient_id)]) + n_patients = len(counts) + draws = np.random.default_rng(_BOOTSTRAP_SEED).integers( + 0, n_patients, size=(_BOOTSTRAP_REPLICATES, n_patients) + ) + indices: list[np.ndarray] = [] + for draw in draws: + repeated = counts[draw] + total = int(repeated.sum()) + within = np.arange(total) - np.repeat(np.cumsum(repeated) - repeated, repeated) + indices.append(order[np.repeat(starts[draw], repeated) + within]) + return indices + + +def _value_masks(values: np.ndarray) -> list[tuple[str, np.ndarray]]: + masks: list[tuple[str, np.ndarray]] = [] + for value in pd.unique(values): + if pd.isna(value): + continue + masks.append((str(value), values == value)) + return masks + + +def _age_band_masks(age: pd.Series) -> list[tuple[str, np.ndarray]]: + """Decade bands such as ``50-59``. Missing ages are skipped.""" + numeric = pd.to_numeric(age, errors="coerce").to_numpy(dtype=float) + valid = ~np.isnan(numeric) + decade = np.full(numeric.size, -1, dtype=int) + decade[valid] = (np.floor(numeric[valid] / 10.0) * 10).astype(int) + masks: list[tuple[str, np.ndarray]] = [] + for band in sorted(set(decade[valid].tolist())): + masks.append((f"{band}-{band + 9}", decade == band)) + return masks + + +def _subgroup_specs(df: pd.DataFrame) -> list[tuple[str, str, np.ndarray]]: + specs: list[tuple[str, str, np.ndarray]] = [] for key in ("machine_id", "site_id"): - for value, group in df.groupby(key): - if len(group) < _MIN_SUBGROUP_N or group["y_true"].nunique() < 2: + for label, mask in _value_masks(df[key].to_numpy()): + specs.append((key, label, mask)) + if "density" in df.columns: + for label, mask in _value_masks(df["density"].to_numpy()): + specs.append(("density", label, mask)) + if "age" in df.columns: + for label, mask in _age_band_masks(df["age"]): + specs.append(("age", label, mask)) + return specs + + +def check_subgroup_degradation(df: pd.DataFrame) -> list[SubgroupResult]: + """Compare each subgroup's AUC with the rest of the table. + + Evaluates ``machine_id`` and ``site_id``, and ``density`` and decade age + bands when those columns exist. A subgroup is evaluated only when it has + at least ``_MIN_SUBGROUP_N`` rows and both the subgroup and the rest of + the table contain both classes. + + ``delta_vs_rest`` is the subgroup AUC minus the AUC of every other row. + ``delta_ci95`` is the 2.5th and 97.5th percentile of that difference over + a patient-level bootstrap (resample ``patient_id`` with replacement, + 200 replicates, seed 0). Replicates that lack a class in the subgroup or + in the rest are skipped. The subgroup is flagged when ``delta_vs_rest`` + is at or below ``_FLAG_DELTA`` and the upper end of ``delta_ci95`` is + below zero. + + ``mean_predicted`` and ``observed_rate`` are calibration-in-the-large for + the subgroup. They are reported and never used to flag. + """ + y_true = df["y_true"].to_numpy() + y_prob = df["y_prob"].to_numpy(dtype=float) + boots = _patient_bootstrap_indices(df["patient_id"]) + results: list[SubgroupResult] = [] + for key, value, mask in _subgroup_specs(df): + n_sub = int(mask.sum()) + if n_sub < _MIN_SUBGROUP_N: + continue + y_sub = y_true[mask] + y_rest = y_true[~mask] + if not _both_classes(y_sub) or not _both_classes(y_rest): + continue + auc = _roc_auc(y_sub, y_prob[mask]) + auc_rest = _roc_auc(y_rest, y_prob[~mask]) + delta = auc - auc_rest + deltas: list[float] = [] + for idx in boots: + drawn = mask[idx] + if not drawn.any() or not (~drawn).any(): continue - auc = float(roc_auc_score(group["y_true"], group["y_prob"])) - delta = auc - pooled_auc - results.append( - { - "key": key, - "value": str(value), - "n": len(group), - "auc": auc, - "delta_vs_pooled": delta, - "flagged": delta <= _FLAG_DELTA, - } + y_draw = y_true[idx] + p_draw = y_prob[idx] + y_draw_sub = y_draw[drawn] + y_draw_rest = y_draw[~drawn] + if not _both_classes(y_draw_sub) or not _both_classes(y_draw_rest): + continue + deltas.append( + _roc_auc(y_draw_sub, p_draw[drawn]) - _roc_auc(y_draw_rest, p_draw[~drawn]) ) + if deltas: + low, high = (float(bound) for bound in np.percentile(deltas, [2.5, 97.5])) + ci: list[float] | None = [low, high] + flagged = bool(delta <= _FLAG_DELTA and high < 0) + else: + ci = None + flagged = False + results.append( + { + "key": key, + "value": value, + "n": n_sub, + "auc": float(auc), + "auc_rest": float(auc_rest), + "delta_vs_rest": float(delta), + "delta_ci95": ci, + "mean_predicted": float(y_prob[mask].mean()), + "observed_rate": float(np.mean(y_sub)), + "flagged": flagged, + } + ) return results def compute_pooled_metric(df: pd.DataFrame) -> PooledMetric: """Compute the pooled headline metric (AUC) over all rows. - This is the reference value that subgroup deltas are measured against and - the ``pooled_metric`` field of the report payload. AUC is computed over the - full table (not the test split alone) to match each manifest's - ``verification.overall_auc`` and give every subgroup enough positives for a - defined per-subgroup AUC at this dataset's ~1.5% prevalence. + Assumes every row's ``y_prob`` is a held-out or out-of-fold prediction. + AUC is computed over the full table (not the test split alone) to match + each manifest's ``verification.overall_auc``. ``rows`` says so in the + payload, so the reporter does not present it as the test-split AUC. """ - return {"name": "auc", "value": float(roc_auc_score(df["y_true"], df["y_prob"]))} + return { + "name": "auc", + "rows": "all", + "value": float(roc_auc_score(df["y_true"], df["y_prob"])), + } + + +def _summarize_subgroups(subgroups: list[SubgroupResult]) -> SubgroupSummary: + """Counts the reporter would otherwise have to tally from the list itself.""" + intervals = [sg["delta_ci95"] for sg in subgroups if sg["delta_ci95"] is not None] + return { + "evaluated": len(subgroups), + "flagged": sum(sg["flagged"] for sg in subgroups), + "interval_below_zero": sum(high < 0 for _, high in intervals), + "interval_above_zero": sum(low > 0 for low, _ in intervals), + } + + +def compute_risk(leakage: LeakageResult, subgroups: list[SubgroupResult]) -> RiskResult: + """Choose RED, YELLOW, or GREEN from leakage and subgroup flags. + + RED when any patient is in both train and test. Otherwise YELLOW when + any subgroup is flagged. Otherwise GREEN. The split audit is not an + input: rescoring the same predictions cannot show inflation. + """ + overlap = leakage["overlapping_patient_ids"] + if overlap > 0: + rate = leakage["leakage_rate"] + return { + "level": "RED", + "reasons": [ + f"{overlap} patients appear in both the train and test splits " + f"(leakage rate {rate:.1%})." + ], + } + + flagged = [subgroup for subgroup in subgroups if subgroup["flagged"]] + if flagged: + reasons: list[str] = [] + for subgroup in flagged: + interval = subgroup["delta_ci95"] + if interval is None: + interval_text = "no bootstrap interval was available" + else: + interval_text = f"95% interval {interval[0]:+.3f} to {interval[1]:+.3f}" + reasons.append( + f"{subgroup['key']} {subgroup['value']} has AUC {subgroup['auc']:.3f} " + f"against {subgroup['auc_rest']:.3f} on the rest of the table " + f"(delta {subgroup['delta_vs_rest']:+.3f}, {interval_text})." + ) + return {"level": "YELLOW", "reasons": reasons} + + return { + "level": "GREEN", + "reasons": [ + "No patient appears in both the train and test splits, and no subgroup is flagged." + ], + } + + +def run_checks(df: pd.DataFrame) -> ReportPayload: + """Run every deterministic check and attach the risk level.""" + leakage = check_patient_leakage(df) + subgroups = check_subgroup_degradation(df) + return { + "leakage": leakage, + "split_audit": audit_split_quality(df), + "subgroups": subgroups, + "subgroup_summary": _summarize_subgroups(subgroups), + "pooled_metric": compute_pooled_metric(df), + "risk": compute_risk(leakage, subgroups), + } diff --git a/data/README.md b/data/README.md index 7172a4e..6d97964 100644 --- a/data/README.md +++ b/data/README.md @@ -1,23 +1,7 @@ # data/ -No fixtures are copied or regenerated here. The validators read directly from -the labeled scenario suite that already exists in -[`claude-science-artifacts/scenarios/`](../claude-science-artifacts/scenarios/), -which is the validators' ground-truth test oracle. +This folder contains this README. A local `data/rsna/` directory may hold `train.csv` from the Radiological Society of North America (RSNA) Screening Mammography Breast Cancer Detection dataset. That directory is gitignored. The RSNA license bans redistribution, so the raw table is not in the repository. -Each scenario ships a `*.csv`, a `*.manifest.json` (injected flaws + -`expected_detections`), and is indexed in `_scenario_summary.csv`. +The fixtures the validators read are in [`claude-science-artifacts/scenarios/`](../claude-science-artifacts/scenarios/). Each scenario is a `*.csv` plus a `*.manifest.json`. -## Demo scenario - -The demo runs on **`weak_random_split_leakage`** — a random split with a known -940 train/test patient overlap and pooled AUC 0.8425. It exercises the two -headline checks (patient leakage + split-quality gap) in one table. - -Contrast scenarios for the demo narrative: - -- `clean_patient_grouped` — negative control; every check should pass (GREEN). -- `optimistic_site_shortcut` — inflated AUC 0.9647 under a leaking random split; - the "looks great until you group the split" escalation. - -Synthetic / public-schema data only. No PHI. Never commit real patient data. +The demo in [docs/DEMO.md](../docs/DEMO.md) uses `optimistic_site_shortcut`. diff --git a/demo/run_demo.sh b/demo/run_demo.sh index 4805f4c..12ee4d0 100755 --- a/demo/run_demo.sh +++ b/demo/run_demo.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Spine of the 3-minute demo: a GREEN control, then the leakage scenario. -# Requires ANTHROPIC_API_KEY (the report step calls claude-opus-4-8). +# Requires ANTHROPIC_API_KEY (the report step calls Claude; see MODEL in reporter/report.py). set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -20,5 +20,5 @@ run() { echo } -run clean_patient_grouped "expect GREEN — no leakage, no degraded subgroup" -run weak_random_split_leakage "expect a leakage flag: 940 shared train/test patients" +run clean_patient_grouped "expect GREEN — no leakage, no flagged subgroup" +run optimistic_site_shortcut "expect RED — 990 patients in both train and test" diff --git a/docs/DEMO.md b/docs/DEMO.md index 19f0ef1..313d0e4 100644 --- a/docs/DEMO.md +++ b/docs/DEMO.md @@ -1,5 +1,14 @@ # ClinVal Validator — 3-Minute Demo Script +Note (2026-10-18). The narration below is the script of a recorded video and is unchanged. Corrections since that recording: + +- The 0.9875 → 0.9521 drop lies inside the sample range the tool now reports. +- The "grouped" AUC comes from a patient sample of all rows, not from a subset of the test split. +- The synthetic scenario scores are simulated, and only the RSNA scores come from a tabular baseline. +- The risk level is now computed in Python. +- Install `.[dev,demo]` before running pytest on camera. +- Produce predictions_rsna_random.csv with `python rsna_to_predictions.py --input data/rsna/train.csv`. + **Logline.** A clinical-AI model reports an AUC that looks publishable. ClinVal Validator answers whether that number is real or an artifact of data leakage — deterministic Python computes every flag, Claude only explains the risk. diff --git a/PROJECT_PLAN.md b/docs/archive/PROJECT_PLAN.md similarity index 96% rename from PROJECT_PLAN.md rename to docs/archive/PROJECT_PLAN.md index 8695e28..f33c78c 100644 --- a/PROJECT_PLAN.md +++ b/docs/archive/PROJECT_PLAN.md @@ -1,3 +1,5 @@ +Hackathon planning snapshot (July 2026). Not maintained; current status is in README.md. + # PROJECT_PLAN — clinval-validator ## Problem statement @@ -91,7 +93,7 @@ All AUCs via scikit-learn `roc_auc_score`. Grouping via pandas. No LLM. The reporter sends the assembled `ReportPayload` (JSON) to `claude-opus-4-8` under the reviewer system prompt at -[`prompts/opus-4-8/reporter_system_prompt.md`](prompts/opus-4-8/reporter_system_prompt.md), +[`prompts/opus-4-8/reporter_system_prompt.md`](../../prompts/opus-4-8/reporter_system_prompt.md), loaded from disk (never duplicated inline). That prompt pins the output: an Overall risk line (GREEN/YELLOW/RED), What the data shows, What can be claimed, What cannot be claimed, and the Next validation step — every figure drawn only diff --git a/TASKS.md b/docs/archive/TASKS.md similarity index 96% rename from TASKS.md rename to docs/archive/TASKS.md index 61d8ee6..12fc20e 100644 --- a/TASKS.md +++ b/docs/archive/TASKS.md @@ -1,3 +1,5 @@ +Hackathon planning snapshot (July 2026). Not maintained; current status is in README.md. + # TASKS — clinval-validator Every item names a concrete artifact. Phases match CLAUDE.md — Phase 1 is @@ -50,7 +52,7 @@ complete; Phase 2 items are optional and started only now that Phase 1 is done. - [x] `demo/run_demo.sh` — scripted path over `clean_patient_grouped` then `weak_random_split_leakage`. - [x] `README.md` — verified run commands and a sample-output snippet (real deterministic JSON + illustrative report). - [x] Fresh-clone reproducibility: `pip install -e .[dev]` on Python 3.11+ → `python -m pytest` green (28 tests) → `clinval-validate` runs. -- [ ] Record the <3-minute demo video (naive → grouped collapse → Claude explains → next step). Script: [docs/DEMO.md](docs/DEMO.md). **Owner: user.** +- [ ] Record the <3-minute demo video (naive → grouped collapse → Claude explains → next step). Script: [docs/DEMO.md](../DEMO.md). **Owner: user.** ## Phase 2 — Stretch (Phase 1 is done; these are optional) diff --git a/docs/images/app-weak-random-split-leakage.png b/docs/images/app-weak-random-split-leakage.png index a2cc0fe..c0f264d 100644 Binary files a/docs/images/app-weak-random-split-leakage.png and b/docs/images/app-weak-random-split-leakage.png differ diff --git a/prompts/README.md b/prompts/README.md index 495784a..fc7f0fe 100644 --- a/prompts/README.md +++ b/prompts/README.md @@ -4,10 +4,10 @@ Runtime system prompts for the LLM reporting step. These are **load-bearing**: `reporter/report.py` reads them from disk at call time (see `SYSTEM_PROMPT_PATH` and `CRITIQUE_SYSTEM_PROMPT_PATH`), so renaming or deleting them breaks the tool. -- [`opus-4-8/reporter_system_prompt.md`](opus-4-8/reporter_system_prompt.md) — +- [`reporter_system_prompt.md`](reporter_system_prompt.md) — turns the validators' JSON into the GREEN/YELLOW/RED Validation Risk Report (`clinval-validate --mode risk`). -- [`opus-4-8/reviewer_critique_system_prompt.md`](opus-4-8/reviewer_critique_system_prompt.md) — +- [`reviewer_critique_system_prompt.md`](reviewer_critique_system_prompt.md) — turns the same JSON into a journal-reviewer critique plus a corrected, leakage-safe Methods paragraph (`clinval-validate --mode critique`). diff --git a/prompts/opus-4-8/reporter_system_prompt.md b/prompts/opus-4-8/reporter_system_prompt.md deleted file mode 100644 index 445d9be..0000000 --- a/prompts/opus-4-8/reporter_system_prompt.md +++ /dev/null @@ -1,36 +0,0 @@ -# Reporter system prompt (sent to claude-opus-4-8) - -This is the system prompt for the LLM step. It receives the deterministic validators' JSON output and returns the Validation Risk Report. It never computes numbers — every figure it cites comes from the JSON. - ---- - -You are a senior clinical-AI validation reviewer. You are reviewing the automated validation output for a medical-imaging machine-learning model. A deterministic analysis pipeline has already computed all metrics, statistical tests, and flags; you are given its results as JSON. Your job is to explain what these results mean for the safety of the model's performance claims — not to recompute anything. - -Rules: -- Use only the numbers present in the provided JSON. Never invent, estimate, or recompute a metric. If a value is absent, say the check did not run rather than guessing. -- Do not soften genuine problems and do not inflate them. State the finding, then its consequence for what may be claimed. -- Write for a researcher who trained the model, not for a lay audience. Assume they know AUC, calibration, and train/test splits. - -Input JSON shape (fields may be absent if a check did not run): -```json -{ - "leakage": {"overlapping_patient_ids": int, "leakage_rate": float}, - "split_audit": {"metric": "auc", "random_split": float, "grouped_split": float, "gap": float}, - "subgroups": [{"key": "machine_id", "value": "...", "n": int, "auc": float, "delta_vs_pooled": float, "flagged": bool}], - "pooled_metric": {"name": "auc", "value": float} -} -``` - -Produce a report in exactly these sections: - -1. **Overall risk** — one line: GREEN, YELLOW, or RED, with a one-sentence justification. RED if leakage is present or the random-vs-grouped gap is large; YELLOW if subgroup degradation is flagged without leakage; GREEN only if none of the checks fired. - -2. **What the data shows** — 3-5 sentences, each tied to a specific number from the JSON, in plain reviewer language. - -3. **What can be claimed** — the performance statements the results actually support. - -4. **What cannot be claimed** — the statements the results do NOT support, and why (e.g., a metric measured under a leaking split does not reflect deployment). - -5. **Next validation step** — the single most important experiment to run next, given the failure mode found. - -Keep the whole report under 300 words. No preamble, no restating these instructions — start at section 1. diff --git a/prompts/opus-4-8/reviewer_critique_system_prompt.md b/prompts/opus-4-8/reviewer_critique_system_prompt.md deleted file mode 100644 index 277c624..0000000 --- a/prompts/opus-4-8/reviewer_critique_system_prompt.md +++ /dev/null @@ -1,31 +0,0 @@ -# Reviewer critique system prompt (sent to claude-opus-4-8) - -This is the system prompt for the reviewer-critique output. It receives the same deterministic validators' JSON as the risk reporter and returns a journal-reviewer-style critique plus a corrected, leakage-safe Methods paragraph. It never computes numbers — every figure it cites comes from the JSON. - ---- - -You are a peer reviewer for a clinical-AI journal, evaluating a manuscript that reports a medical-imaging machine-learning model. A deterministic analysis pipeline has already computed all metrics, statistical tests, and flags; you are given its results as JSON. Your job is to judge what these results actually support and to rewrite the study's evaluation so it is leakage-safe — not to recompute anything. - -Rules: -- Use only the numbers present in the provided JSON. Do not invent, estimate, or recompute a metric. If a value is absent, say the check did not run rather than guessing. -- Do not soften genuine problems and do not inflate them. Name the finding, then its consequence for what the manuscript may claim. -- Write for the authors, who trained the model, not for a lay audience. Assume they know AUC, calibration, and train/test splits. -- The corrected Methods paragraph must describe an evaluation the JSON's own findings would pass: a patient-grouped (or site-grouped) split with no patient spanning the train/test boundary, and per-subgroup reporting where degradation was flagged. Cite only figures that appear in the JSON; state changes in method, not invented results. - -Input JSON shape (fields may be absent if a check did not run): -```json -{ - "leakage": {"overlapping_patient_ids": int, "leakage_rate": float}, - "split_audit": {"metric": "auc", "random_split": float, "grouped_split": float, "gap": float}, - "subgroups": [{"key": "machine_id", "value": "...", "n": int, "auc": float, "delta_vs_pooled": float, "flagged": bool}], - "pooled_metric": {"name": "auc", "value": float} -} -``` - -Produce a critique in exactly these two sections: - -1. **Reviewer verdict** — Open with a one-line recommendation: Accept, Major revision, or Reject, with a one-sentence reason. Then, in 3-5 sentences of plain reviewer prose, state what the reported numbers support and what they do not, each sentence tied to a specific figure from the JSON (e.g., an AUC measured under patient overlap does not estimate deployment performance). - -2. **Corrected Methods paragraph** — A single paragraph the authors could paste into their Methods section describing a leakage-safe evaluation that addresses every flaw the JSON found. Write it as publishable Methods prose, not as instructions to the authors. - -Keep the whole response under 350 words. No preamble, no restating these instructions — start at section 1. diff --git a/prompts/reporter_system_prompt.md b/prompts/reporter_system_prompt.md new file mode 100644 index 0000000..ef2ef06 --- /dev/null +++ b/prompts/reporter_system_prompt.md @@ -0,0 +1,64 @@ +# Reporter system prompt (sent to the model named by MODEL in reporter/report.py) + +This is the system prompt for the LLM step. It receives the deterministic validators' JSON output and returns the Validation Risk Report. It never computes numbers — every figure it cites comes from the JSON. + +--- + +You are a senior clinical-AI validation reviewer. You are reviewing the automated validation output for a medical-imaging machine-learning model. A deterministic analysis pipeline has already computed all metrics, intervals, flags, and the risk level; you are given its results as JSON. Your job is to explain what these results mean for the safety of the model's performance claims. Do not recompute anything, and do not change the risk level. + +Rules: +- Use only the numbers present in the provided JSON. Never invent, estimate, or recompute a metric. If a value is absent, say the check did not run rather than guessing. +- State risk.level verbatim. Explain each sentence in risk.reasons. Do not raise, lower, or rename the level. +- risk.level was computed in Python from the leakage count and the subgroup flags only. RED means overlapping_patient_ids is greater than zero. YELLOW means there is no patient overlap and at least one subgroup is flagged. GREEN means neither. split_audit did not determine the level. +- Do not soften genuine problems and do not inflate them. State the finding, then its consequence for what may be claimed. +- Write for a researcher who trained the model, not for a lay audience. Assume they know AUC, calibration, and train/test splits. +- split_audit is a diagnostic on fixed predictions. test_split_as_given is the AUC on rows with split "test". patient_disjoint_sample is the AUC on a seed-0 sample of patients drawn from all rows, so that sample includes training rows. sample_range95 is the 2.5th and 97.5th percentile of that sample AUC over seeds 0–199. gap is test_split_as_given minus patient_disjoint_sample. On a small table a sample can hold one class, where AUC is undefined: patient_disjoint_sample and gap are null when the seed-0 sample does, and sample_range95 leaves such seeds out and is null when every seed does. Say that the value is undefined; do not supply one. The comparison cannot show that leakage inflated a metric, and it cannot show that a metric is free of inflation. Do not use it as evidence either way: do not write that it shows, confirms, or is consistent with inflation or with its absence. Do not call either AUC a corrected, true, or retrained result. +- mean_predicted and observed_rate are calibration-in-the-large for a subgroup (mean predicted probability and the observed positive rate). Mention them when you discuss calibration. They are not flags. Give both numbers when you describe a calibration gap. A mean_predicted many times larger than observed_rate is a large miscalibration; do not call it small or consistent. +- pooled_metric is the AUC over all rows (rows: "all" means train, val and test together) and assumes every row's y_prob is a held-out or out-of-fold prediction. It is not the test-split AUC; that is split_audit.test_split_as_given. +- subgroup_summary gives the counts: subgroups evaluated, subgroups flagged, and how many delta_ci95 intervals lie entirely below zero or entirely above zero. Take counts from it instead of counting the subgroups list. Say that every interval spans zero only when interval_below_zero and interval_above_zero are both 0. + +Input JSON shape (fields may be absent if a check did not run): +```json +{ + "leakage": {"overlapping_patient_ids": int, "leakage_rate": float}, + "split_audit": { + "metric": "auc", + "test_split_as_given": float, + "patient_disjoint_sample": float | null, + "sample_range95": [float, float] | null, + "gap": float | null + }, + "subgroups": [{ + "key": "machine_id | site_id | density | age", + "value": "...", + "n": int, + "auc": float, + "auc_rest": float, + "delta_vs_rest": float, + "delta_ci95": [float, float] | null, + "mean_predicted": float, + "observed_rate": float, + "flagged": bool + }], + "subgroup_summary": {"evaluated": int, "flagged": int, "interval_below_zero": int, "interval_above_zero": int}, + "pooled_metric": {"name": "auc", "rows": "all", "value": float}, + "risk": {"level": "RED | YELLOW | GREEN", "reasons": [string]} +} +``` + +Produce a report in exactly these sections: + +1. **Overall risk** — one line that states risk.level verbatim (GREEN, YELLOW, or RED), then explain risk.reasons. Do not choose a different level. + +2. **What the data shows** — 3-5 sentences, each tied to a specific number from the JSON, in plain reviewer language. When you mention split_audit, describe it as a rescore of fixed predictions that says nothing about inflation in either direction. + +3. **What can be claimed** — the performance statements the results actually support. + +4. **What cannot be claimed** — the statements the results do not support, and why. A metric measured while the same patients appear in train and test does not estimate performance on unseen patients. + +5. **Next validation step** — the single most important experiment to run next. + - If risk.level is RED because patients overlap train and test: the next step is to retrain and evaluate with a patient-grouped split, so that no test patient was seen in training. Do not offer re-scoring the same predictions as a fix. + - If risk.level is YELLOW: the next step is to examine the flagged subgroup before making a pooled claim. + - If risk.level is GREEN: state that the checks that ran did not fire, and that this is not a clinical endorsement. + +Keep the whole report under 300 words. To stay within it, do not explain how the level is derived beyond risk.reasons, and name an unflagged subgroup only when its interval lies entirely above or below zero. No preamble, no restating these instructions — start at section 1. diff --git a/prompts/reviewer_critique_system_prompt.md b/prompts/reviewer_critique_system_prompt.md new file mode 100644 index 0000000..a6e1f6f --- /dev/null +++ b/prompts/reviewer_critique_system_prompt.md @@ -0,0 +1,57 @@ +# Reviewer critique system prompt (sent to the model named by MODEL in reporter/report.py) + +This is the system prompt for the reviewer-critique output. It receives the same deterministic validators' JSON as the risk reporter and returns a journal-reviewer-style critique plus a corrected, leakage-safe Methods paragraph. It never computes numbers — every figure it cites comes from the JSON. + +--- + +You are a peer reviewer for a clinical-AI journal, evaluating a manuscript that reports a medical-imaging machine-learning model. A deterministic analysis pipeline has already computed all metrics, intervals, flags, and the risk level; you are given its results as JSON. Your job is to judge what these results actually support and to rewrite the study's evaluation so it is leakage-safe. Do not recompute anything, and do not change the risk level. + +Rules: +- Use only the numbers present in the provided JSON. Do not invent, estimate, or recompute a metric. If a value is absent, say the check did not run rather than guessing. +- State risk.level verbatim. Explain risk.reasons. Do not raise, lower, or rename the level. +- risk.level was computed in Python from the leakage count and the subgroup flags only. split_audit did not determine the level. +- Give exactly one recommendation: Accept, Minor revision, Major revision, or Reject. Accept and Minor revision are allowed only when risk.level is GREEN. When risk.level is YELLOW, recommend Major revision. When risk.level is RED, choose Reject or Major revision and write only the one you chose. +- Do not soften genuine problems and do not inflate them. Name the finding, then its consequence for what the manuscript may claim. +- Write for the authors, who trained the model, not for a lay audience. Assume they know AUC, calibration, and train/test splits. +- split_audit is a diagnostic on fixed predictions. test_split_as_given is the AUC on rows with split "test". patient_disjoint_sample is the AUC on a seed-0 sample of patients drawn from all rows, including training rows. sample_range95 is the spread of that sample AUC. gap is test_split_as_given minus patient_disjoint_sample. On a small table a sample can hold one class, where AUC is undefined: patient_disjoint_sample and gap are null when the seed-0 sample does, and sample_range95 leaves such seeds out and is null when every seed does. Say that the value is undefined; do not supply one. The comparison cannot show that leakage inflated a metric, and it cannot show that a metric is free of inflation. Do not use it as evidence either way: do not write that it shows, confirms, or is consistent with inflation or with its absence. Do not call either AUC a corrected, true, or retrained result. +- mean_predicted and observed_rate are calibration-in-the-large. They are not flags. Give both numbers when you describe a calibration gap. A mean_predicted many times larger than observed_rate is a large miscalibration; do not call it small or consistent. auc_rest is the AUC on every row outside the subgroup, and delta_vs_rest is auc minus auc_rest. delta_ci95 is the patient-level 95% interval for that difference. +- pooled_metric is the AUC over all rows (rows: "all" means train, val and test together) and assumes every row's y_prob is a held-out or out-of-fold prediction. It is not the test-split AUC; that is split_audit.test_split_as_given. +- subgroup_summary gives the counts: subgroups evaluated, subgroups flagged, and how many delta_ci95 intervals lie entirely below zero or entirely above zero. Take counts from it instead of counting the subgroups list. Say that every interval spans zero only when interval_below_zero and interval_above_zero are both 0. +- The corrected Methods paragraph must describe an evaluation the JSON's own findings would pass. If patients overlap train and test, the fix is to retrain and evaluate with a patient-grouped split, so that no test patient was seen in training. Re-scoring the same predictions is not that fix. Where a subgroup is flagged, the paragraph reports that subgroup against the rest of the cohort. Cite only figures that appear in the JSON; state changes in method, not invented results. + +Input JSON shape (fields may be absent if a check did not run): +```json +{ + "leakage": {"overlapping_patient_ids": int, "leakage_rate": float}, + "split_audit": { + "metric": "auc", + "test_split_as_given": float, + "patient_disjoint_sample": float | null, + "sample_range95": [float, float] | null, + "gap": float | null + }, + "subgroups": [{ + "key": "machine_id | site_id | density | age", + "value": "...", + "n": int, + "auc": float, + "auc_rest": float, + "delta_vs_rest": float, + "delta_ci95": [float, float] | null, + "mean_predicted": float, + "observed_rate": float, + "flagged": bool + }], + "subgroup_summary": {"evaluated": int, "flagged": int, "interval_below_zero": int, "interval_above_zero": int}, + "pooled_metric": {"name": "auc", "rows": "all", "value": float}, + "risk": {"level": "RED | YELLOW | GREEN", "reasons": [string]} +} +``` + +Produce a critique in exactly these two sections: + +1. **Reviewer verdict** — Open with one line that holds only the recommendation you chose. Then state risk.level verbatim and one sentence drawn from risk.reasons. Then, in 3-5 sentences of plain reviewer prose, state what the reported numbers support and what they do not, each sentence tied to a specific figure from the JSON. + +2. **Corrected Methods paragraph** — A single paragraph the authors could paste into their Methods section describing a leakage-safe evaluation that addresses every flaw the JSON found. If the flaw is patient overlap, the paragraph describes retraining and evaluating with a patient-grouped split so that no test patient was seen in training. Write it as publishable Methods prose, not as instructions to the authors. + +Keep the whole response under 350 words. To stay within it, do not explain how the level is derived beyond risk.reasons, and name an unflagged subgroup only when its interval lies entirely above or below zero. No preamble, no restating these instructions — start at section 1. diff --git a/reporter/__init__.py b/reporter/__init__.py index cef8025..767f4d7 100644 --- a/reporter/__init__.py +++ b/reporter/__init__.py @@ -3,9 +3,12 @@ from .report import ( API_KEY_ENV, CRITIQUE_SYSTEM_PROMPT_PATH, + EFFORT, + MAX_TOKENS, MODEL, SYSTEM_PROMPT_PATH, MissingAPIKeyError, + ReportNotRenderedError, render_report, render_reviewer_critique, ) @@ -14,7 +17,10 @@ "render_report", "render_reviewer_critique", "MissingAPIKeyError", + "ReportNotRenderedError", "MODEL", + "EFFORT", + "MAX_TOKENS", "API_KEY_ENV", "SYSTEM_PROMPT_PATH", "CRITIQUE_SYSTEM_PROMPT_PATH", diff --git a/reporter/report.py b/reporter/report.py index 8ad677f..73b5f32 100644 --- a/reporter/report.py +++ b/reporter/report.py @@ -1,8 +1,8 @@ """LLM reporting step: turn the validators' JSON payload into prose. This is the only module in the project that touches the network. It sends the -deterministic report payload to Claude (``claude-opus-4-8``) under one of two -system prompts and returns the rendered text: :func:`render_report` produces the +deterministic report payload to Claude (``claude-opus-5-5`` at low effort) under +one of two system prompts and returns the rendered text: :func:`render_report` produces the GREEN/YELLOW/RED Validation Risk Report, :func:`render_reviewer_critique` produces a journal-reviewer critique with a corrected Methods paragraph. Neither computes a metric — every number in the output comes from the payload. @@ -15,14 +15,24 @@ from pathlib import Path from typing import TYPE_CHECKING -from anthropic import Anthropic +from anthropic import Anthropic, APIError if TYPE_CHECKING: from clinval_validators import ReportPayload -MODEL = "claude-opus-4-8" +MODEL = "claude-opus-5-5" +# Opus 5.5 always thinks; effort sets how much. In a blind review of 24 live +# reports per setting, neither "low" nor "medium" made a major error, and "low" +# cost less ($40 vs $45 per 1,000 reports). +EFFORT = "low" +# Thinking counts toward max_tokens. The longest live low-effort reply used 1,292 +# output tokens. Only generated tokens are billed; the cap is a ceiling, not a cost. +MAX_TOKENS = 4096 +# On a safety-classifier refusal the API reruns the request on the model Anthropic +# recommends for that refusal category, instead of returning the refusal. +FALLBACK_BETA = "server-side-fallback-2026-07-01" API_KEY_ENV = "ANTHROPIC_API_KEY" -_PROMPTS_DIR = Path(__file__).resolve().parent.parent / "prompts" / "opus-4-8" +_PROMPTS_DIR = Path(__file__).resolve().parent.parent / "prompts" SYSTEM_PROMPT_PATH = _PROMPTS_DIR / "reporter_system_prompt.md" CRITIQUE_SYSTEM_PROMPT_PATH = _PROMPTS_DIR / "reviewer_critique_system_prompt.md" @@ -30,7 +40,10 @@ "render_report", "render_reviewer_critique", "MissingAPIKeyError", + "ReportNotRenderedError", "MODEL", + "EFFORT", + "MAX_TOKENS", "API_KEY_ENV", "SYSTEM_PROMPT_PATH", "CRITIQUE_SYSTEM_PROMPT_PATH", @@ -45,6 +58,16 @@ class MissingAPIKeyError(RuntimeError): """ +class ReportNotRenderedError(RuntimeError): + """Raised when Claude's reply holds no complete report. + + That happens when the API request fails (for example no credits, an + invalid key, or no connection), or when the reply stops at ``max_tokens``, + is refused (``stop_reason == "refusal"``), or contains no text block. The + deterministic payload is unaffected, so callers can still show it. + """ + + def _load_system_prompt(path: Path) -> str: """Read the operative system prompt from disk. @@ -55,34 +78,56 @@ def _load_system_prompt(path: Path) -> str: return text.split("---", 1)[1].strip() -def _render(payload: ReportPayload, system_prompt_path: Path) -> str: - """Send the deterministic payload to ``claude-opus-4-8`` under one prompt. +def _render( + payload: ReportPayload, system_prompt_path: Path, api_key: str | None = None +) -> str: + """Send the deterministic payload to :data:`MODEL` under one prompt. - Reads the Anthropic API key from the ``ANTHROPIC_API_KEY`` environment - variable, loads the operative system prompt from ``system_prompt_path``, sends - the payload as JSON, and returns the model's text. The key is never - hard-coded, and no number is computed here — every figure comes from the JSON. + ``api_key`` overrides ``ANTHROPIC_API_KEY`` when it is passed, so a browser + session can use the key typed into that session without writing + ``os.environ``. The key is never hard-coded, and no number is computed here. """ - try: - api_key = os.environ[API_KEY_ENV] - except KeyError as exc: - raise MissingAPIKeyError(f"{API_KEY_ENV} is not set in the environment.") from exc + if api_key is None: + try: + api_key = os.environ[API_KEY_ENV] + except KeyError as exc: + raise MissingAPIKeyError( + f"{API_KEY_ENV} is not set in the environment." + ) from exc + if not api_key: + raise MissingAPIKeyError(f"{API_KEY_ENV} is not set in the environment.") client = Anthropic(api_key=api_key) - message = client.messages.create( - model=MODEL, - max_tokens=1024, - system=_load_system_prompt(system_prompt_path), - messages=[{"role": "user", "content": json.dumps(payload)}], - ) - return message.content[0].text - - -def render_report(payload: ReportPayload) -> str: + try: + message = client.beta.messages.create( + model=MODEL, + max_tokens=MAX_TOKENS, + system=_load_system_prompt(system_prompt_path), + messages=[{"role": "user", "content": json.dumps(payload)}], + output_config={"effort": EFFORT}, + betas=[FALLBACK_BETA], + # The SDK types `fallbacks` as a list of models; the "default" mode is a + # string the API accepts, so it goes in the raw body. + extra_body={"fallbacks": "default"}, + ) + except APIError as exc: + detail = str(exc).rstrip(".") + raise ReportNotRenderedError(f"The Claude API request failed: {detail}.") from exc + # Opus 5.5 returns thinking blocks (and, after a fallback, a fallback block) + # before the text, so the first block is not always the report. + text = "".join(block.text for block in message.content if block.type == "text") + if message.stop_reason in ("max_tokens", "refusal") or not text: + raise ReportNotRenderedError( + f"Claude returned no complete report (stop_reason: {message.stop_reason})." + ) + return text + + +def render_report(payload: ReportPayload, api_key: str | None = None) -> str: """Render the GREEN/YELLOW/RED Validation Risk Report from the payload.""" - return _render(payload, SYSTEM_PROMPT_PATH) + return _render(payload, SYSTEM_PROMPT_PATH, api_key) -def render_reviewer_critique(payload: ReportPayload) -> str: +def render_reviewer_critique(payload: ReportPayload, api_key: str | None = None) -> str: """Render a journal-reviewer critique plus a corrected Methods paragraph.""" - return _render(payload, CRITIQUE_SYSTEM_PROMPT_PATH) + return _render(payload, CRITIQUE_SYSTEM_PROMPT_PATH, api_key) diff --git a/tests/test_app.py b/tests/test_app.py index 4898a3f..d350c47 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,15 +1,17 @@ """Streamlit demo app: the pure scenario-listing helper. -The Streamlit UI itself is glue over already-tested functions (``_assemble_payload`` +The Streamlit UI itself is glue over already-tested functions (``run_checks`` and the two reporters), so only the pure helper is unit-tested here. Importing ``app`` must not require Streamlit — it is imported lazily inside ``main``. +The upload boundary is ``validate_predictions``, tested in ``test_validators``. """ from __future__ import annotations import pandas as pd -from app import REQUIRED_COLUMNS, available_scenarios, missing_columns +from app import available_scenarios +from clinval_validators import validate_predictions def test_available_scenarios_lists_bundled_csvs() -> None: @@ -25,16 +27,6 @@ def test_available_scenarios_excludes_the_summary_file() -> None: assert not any(name.startswith("_") for name in available_scenarios()) -def test_bundled_scenario_satisfies_the_required_schema() -> None: +def test_bundled_scenario_passes_validation() -> None: df = pd.read_csv(available_scenarios()["clean_patient_grouped"]) - assert missing_columns(df) == [] - - -def test_missing_columns_names_every_absent_required_column() -> None: - df = pd.DataFrame({"patient_id": [1], "y_true": [0], "y_prob": [0.5]}) - assert missing_columns(df) == ["machine_id", "site_id", "split"] - - -def test_missing_columns_ignores_extra_columns() -> None: - df = pd.DataFrame({column: [0] for column in REQUIRED_COLUMNS} | {"laterality": ["L"]}) - assert missing_columns(df) == [] + assert validate_predictions(df) == [] diff --git a/tests/test_cli.py b/tests/test_cli.py index dda2887..5a30be8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,23 +10,49 @@ from pathlib import Path import cli -from cli import _assemble_payload, _load_predictions +from clinval_validators import run_checks +from cli import _load_predictions REPO_ROOT = Path(__file__).resolve().parent.parent DEMO_CSV = REPO_ROOT / "claude-science-artifacts" / "scenarios" / "weak_random_split_leakage.csv" +def test_cli_rejects_an_invalid_table(tmp_path: Path, capsys) -> None: + path = tmp_path / "bad.csv" + path.write_text("patient_id,y_true,y_prob\n1,0,0.2\n", encoding="utf-8") + exit_code = cli.main(["--predictions", str(path)]) + assert exit_code == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "Missing required column" in captured.err + assert "machine_id" in captured.err + assert "Traceback" not in captured.err + + def test_load_predictions_reads_the_table() -> None: df = _load_predictions(DEMO_CSV) assert len(df) == 8342 assert {"patient_id", "machine_id", "site_id", "y_true", "y_prob", "split"} <= set(df.columns) -def test_assemble_payload_matches_contract_and_is_json_serializable() -> None: - payload = _assemble_payload(_load_predictions(DEMO_CSV)) - assert set(payload) == {"leakage", "split_audit", "subgroups", "pooled_metric"} +def test_run_checks_matches_contract_and_is_json_serializable() -> None: + payload = run_checks(_load_predictions(DEMO_CSV)) + assert set(payload) == { + "leakage", + "split_audit", + "subgroups", + "subgroup_summary", + "pooled_metric", + "risk", + } assert payload["leakage"]["overlapping_patient_ids"] == 940 - assert payload["pooled_metric"]["name"] == "auc" + assert payload["risk"]["level"] == "RED" + assert payload["pooled_metric"] == { + "name": "auc", + "rows": "all", + "value": payload["pooled_metric"]["value"], + } + assert payload["subgroup_summary"]["evaluated"] == len(payload["subgroups"]) assert isinstance(payload["subgroups"], list) # The reporter serializes the payload with json.dumps; guard that boundary. assert json.loads(json.dumps(payload)) == payload @@ -65,6 +91,21 @@ def test_cli_without_an_api_key_prints_the_payload_and_a_readable_error(monkeypa assert "KeyError" not in captured.err +def test_cli_prints_the_payload_when_claude_returns_no_complete_report(monkeypatch, capsys) -> None: + def cut_off(payload): + raise cli.ReportNotRenderedError("Claude returned no complete report (stop_reason: max_tokens).") + + monkeypatch.setattr(cli, "render_report", cut_off) + + exit_code = cli.main(["--predictions", str(DEMO_CSV)]) + + assert exit_code == 1 + captured = capsys.readouterr() + assert json.loads(captured.out)["risk"]["level"] == "RED" + assert "max_tokens" in captured.err + assert "Traceback" not in captured.err + + def test_cli_critique_mode_uses_reviewer_critique(monkeypatch, capsys) -> None: monkeypatch.setattr(cli, "render_report", lambda payload: "RISK-UNUSED") monkeypatch.setattr( diff --git a/tests/test_reporter.py b/tests/test_reporter.py index 2245680..e4e201d 100644 --- a/tests/test_reporter.py +++ b/tests/test_reporter.py @@ -3,45 +3,134 @@ from __future__ import annotations import json +import os from types import SimpleNamespace +import anthropic import pytest import reporter -from reporter import MODEL, MissingAPIKeyError, render_report, render_reviewer_critique +from reporter import ( + CRITIQUE_SYSTEM_PROMPT_PATH, + EFFORT, + MAX_TOKENS, + MODEL, + SYSTEM_PROMPT_PATH, + MissingAPIKeyError, + ReportNotRenderedError, + render_report, + render_reviewer_critique, +) -SAMPLE_PAYLOAD = { - "leakage": {"overlapping_patient_ids": 940, "leakage_rate": 0.4702}, - "split_audit": {"metric": "auc", "random_split": 0.8273, "grouped_split": 0.8994, "gap": -0.0721}, - "subgroups": [ - {"key": "machine_id", "value": "M030", "n": 1048, "auc": 0.6243, "delta_vs_pooled": -0.21, "flagged": True} - ], - "pooled_metric": {"name": "auc", "value": 0.8425}, -} +def _text(text: str) -> SimpleNamespace: + return SimpleNamespace(type="text", text=text) -def test_render_report_sends_payload_json_to_opus(monkeypatch) -> None: - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + +def _fake_client(monkeypatch, content: list, stop_reason: str = "end_turn") -> dict: + """Replace the Anthropic client; return the dict that records each request.""" captured: dict = {} class FakeMessages: def create(self, **kwargs): captured.update(kwargs) - return SimpleNamespace(content=[SimpleNamespace(text="RED: leakage present")]) + return SimpleNamespace(content=content, stop_reason=stop_reason) class FakeAnthropic: def __init__(self, api_key=None): captured["api_key"] = api_key - self.messages = FakeMessages() + self.beta = SimpleNamespace(messages=FakeMessages()) monkeypatch.setattr(reporter.report, "Anthropic", FakeAnthropic) + return captured + +# leakage_rate is 940 / 959 on weak_random_split_leakage. The other fields match +# the current payload contract; this dict is a stub, not a full scenario dump. +SAMPLE_PAYLOAD = { + "leakage": {"overlapping_patient_ids": 940, "leakage_rate": 0.9802}, + "split_audit": { + "metric": "auc", + "test_split_as_given": 0.8273, + "patient_disjoint_sample": 0.8994, + "sample_range95": [0.7523, 0.9336], + "gap": -0.0721, + }, + "subgroups": [ + { + "key": "machine_id", + "value": "M031", + "n": 1020, + "auc": 0.8075, + "auc_rest": 0.8490, + "delta_vs_rest": -0.0415, + "delta_ci95": [-0.1702, 0.0521], + "mean_predicted": 0.5111, + "observed_rate": 0.0216, + "flagged": False, + } + ], + "subgroup_summary": { + "evaluated": 20, + "flagged": 0, + "interval_below_zero": 0, + "interval_above_zero": 1, + }, + "pooled_metric": {"name": "auc", "rows": "all", "value": 0.8425}, + "risk": { + "level": "RED", + "reasons": [ + "940 patients appear in both the train and test splits (leakage rate 98.0%)." + ], + }, +} + + +def test_prompts_name_the_fields_python_computes() -> None: + field_names = ( + "test_split_as_given", + "patient_disjoint_sample", + "sample_range95", + "auc_rest", + "delta_vs_rest", + "delta_ci95", + "mean_predicted", + "observed_rate", + "subgroup_summary", + "interval_below_zero", + "interval_above_zero", + "risk", + "reasons", + ) + for path in (SYSTEM_PROMPT_PATH, CRITIQUE_SYSTEM_PROMPT_PATH): + text = path.read_text(encoding="utf-8") + missing = [name for name in field_names if name not in text] + assert missing == [], path.name + + +def test_prompts_keep_the_rules_live_reports_broke() -> None: + # Live reports read the split audit as evidence of no inflation and left the + # RED critique recommendation as "Reject or Major revision". + for path in (SYSTEM_PROMPT_PATH, CRITIQUE_SYSTEM_PROMPT_PATH): + assert "free of inflation" in path.read_text(encoding="utf-8"), path.name + assert "exactly one recommendation" in CRITIQUE_SYSTEM_PROMPT_PATH.read_text(encoding="utf-8") + + +def test_render_report_sends_payload_json_to_opus(monkeypatch) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + captured = _fake_client(monkeypatch, [_text("RED: leakage present")]) report_text = render_report(SAMPLE_PAYLOAD) assert report_text == "RED: leakage present" assert captured["api_key"] == "test-key" - assert captured["model"] == MODEL == "claude-opus-4-8" - assert captured["max_tokens"] == 1024 + assert captured["model"] == MODEL == "claude-opus-5-5" + assert captured["max_tokens"] == MAX_TOKENS == 4096 + # Opus 5.5 rejects thinking={"type": "disabled"}; effort is the only control. + assert "thinking" not in captured + assert captured["output_config"] == {"effort": EFFORT} == {"effort": "low"} + # A classifier refusal is retried on Anthropic's recommended model for its category. + assert captured["betas"] == ["server-side-fallback-2026-07-01"] + assert captured["extra_body"] == {"fallbacks": "default"} user_message = captured["messages"][0] assert user_message["role"] == "user" @@ -52,6 +141,17 @@ def __init__(self, api_key=None): assert "# Reporter system prompt" not in captured["system"] +def test_api_key_argument_reaches_the_client_without_changing_the_environment(monkeypatch) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "env-key") + captured = _fake_client(monkeypatch, [_text("ok")]) + + assert render_report(SAMPLE_PAYLOAD, api_key="input-key") == "ok" + assert captured["api_key"] == "input-key" + assert render_reviewer_critique(SAMPLE_PAYLOAD, api_key="critique-key") == "ok" + assert captured["api_key"] == "critique-key" + assert os.environ["ANTHROPIC_API_KEY"] == "env-key" + + def test_render_report_raises_a_named_error_when_the_key_is_absent(monkeypatch) -> None: # A missing key is a user-facing condition, not a bug: it must not surface as a # bare KeyError traceback. No network call is attempted. @@ -66,28 +166,67 @@ def test_render_reviewer_critique_raises_a_named_error_when_the_key_is_absent(mo render_reviewer_critique(SAMPLE_PAYLOAD) -def test_render_reviewer_critique_sends_payload_json_to_opus(monkeypatch) -> None: +def test_text_after_a_thinking_block_is_returned(monkeypatch) -> None: + # Models that think put a thinking block first; reading content[0].text would + # raise AttributeError on it. monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") - captured: dict = {} + thinking = SimpleNamespace(type="thinking", thinking="") + fallback = SimpleNamespace(type="fallback") # present when a refusal was rerun on another model + _fake_client(monkeypatch, [thinking, fallback, _text("1. Overall risk"), _text(" RED")]) + assert render_report(SAMPLE_PAYLOAD) == "1. Overall risk RED" - class FakeMessages: + +@pytest.mark.parametrize("stop_reason", ["max_tokens", "refusal"]) +def test_a_cut_off_or_refused_reply_raises_a_named_error(monkeypatch, stop_reason: str) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + _fake_client(monkeypatch, [_text("1. Overall risk — RED. 940 patients")], stop_reason) + with pytest.raises(ReportNotRenderedError, match=stop_reason): + render_report(SAMPLE_PAYLOAD) + + +def test_an_api_error_raises_a_named_error(monkeypatch) -> None: + # Without this the CLI printed a traceback and no payload, e.g. on a 403 for + # an account that has used its credits. + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + # The SDK's HTTP library differs by major version (httpx in 0.x, httpx2 in + # 1.x), so the response is a stand-in with the attributes the error reads. + response = SimpleNamespace(request=None, status_code=403, headers={}) + error = anthropic.PermissionDeniedError( + "credit balance is too low", response=response, body=None + ) + + class FailingMessages: def create(self, **kwargs): - captured.update(kwargs) - return SimpleNamespace(content=[SimpleNamespace(text="Reviewer verdict: reject")]) + raise error class FakeAnthropic: def __init__(self, api_key=None): - captured["api_key"] = api_key - self.messages = FakeMessages() + self.beta = SimpleNamespace(messages=FailingMessages()) monkeypatch.setattr(reporter.report, "Anthropic", FakeAnthropic) + with pytest.raises(ReportNotRenderedError, match="credit balance is too low") as caught: + render_report(SAMPLE_PAYLOAD) + assert caught.value.__cause__ is error + + +def test_a_reply_without_text_raises_a_named_error(monkeypatch) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + _fake_client(monkeypatch, [SimpleNamespace(type="thinking", thinking="")]) + with pytest.raises(ReportNotRenderedError): + render_reviewer_critique(SAMPLE_PAYLOAD) + + +def test_render_reviewer_critique_sends_payload_json_to_opus(monkeypatch) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + captured = _fake_client(monkeypatch, [_text("Reviewer verdict: reject")]) critique_text = render_reviewer_critique(SAMPLE_PAYLOAD) assert critique_text == "Reviewer verdict: reject" assert captured["api_key"] == "test-key" - assert captured["model"] == MODEL == "claude-opus-4-8" - assert captured["max_tokens"] == 1024 + assert captured["model"] == MODEL + assert captured["max_tokens"] == MAX_TOKENS + assert captured["output_config"] == {"effort": EFFORT} user_message = captured["messages"][0] assert user_message["role"] == "user" diff --git a/tests/test_validators.py b/tests/test_validators.py index ff757da..4df52ca 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -2,17 +2,31 @@ from __future__ import annotations +import json +import sys from pathlib import Path import pytest pd = pytest.importorskip("pandas") +_GENERATOR_DIR = Path(__file__).resolve().parent.parent / "claude-science-artifacts" +sys.path.insert(0, str(_GENERATOR_DIR)) + from clinval_validators import ( # noqa: E402 audit_split_quality, check_patient_leakage, check_subgroup_degradation, compute_pooled_metric, + compute_risk, + run_checks, + validate_predictions, +) +from leakage_generator import ( # noqa: E402 + Flaws, + GeneratorConfig, + SubgroupDegradation, + generate, ) SCENARIOS_DIR = Path(__file__).resolve().parent.parent / "claude-science-artifacts" / "scenarios" @@ -22,6 +36,69 @@ def _load(scenario: str) -> pd.DataFrame: return pd.read_csv(SCENARIOS_DIR / f"{scenario}.csv") +def _valid_frame() -> pd.DataFrame: + """Small table that satisfies every boundary check.""" + return pd.DataFrame( + { + "patient_id": ["P1", "P1", "P2", "P2"], + "machine_id": ["M010", "M010", "M020", "M020"], + "site_id": ["S01", "S01", "S02", "S02"], + "y_true": [0, 1, 0, 1], + "y_prob": [0.0, 1.0, 0.3, 0.7], + "split": ["train", "train", "test", "test"], + } + ) + + +# --- Input boundary ------------------------------------------------------ + + +def test_validate_predictions_accepts_a_clean_fixture() -> None: + assert validate_predictions(_load("clean_patient_grouped")) == [] + assert validate_predictions(_valid_frame()) == [] + + +def test_validate_predictions_reports_missing_columns() -> None: + df = _valid_frame().drop(columns=["machine_id", "split"]) + problems = validate_predictions(df) + assert len(problems) == 1 + assert "machine_id" in problems[0] + assert "split" in problems[0] + + +def test_validate_predictions_reports_a_missing_train_or_test_split() -> None: + df = _valid_frame() + df["split"] = "val" + problems = validate_predictions(df) + assert any('split == "train"' in problem for problem in problems) + assert any('split == "test"' in problem for problem in problems) + + +def test_validate_predictions_reports_y_true_outside_0_1() -> None: + df = _valid_frame() + df.loc[0, "y_true"] = 2 + assert validate_predictions(df) == ["y_true has values outside {0, 1}."] + + +def test_validate_predictions_reports_test_rows_without_both_classes() -> None: + df = _valid_frame() + df.loc[df["split"] == "test", "y_true"] = 0 + assert validate_predictions(df) == ["Test rows do not contain both classes (0 and 1)."] + + +def test_validate_predictions_reports_non_numeric_y_prob() -> None: + df = _valid_frame() + df["y_prob"] = df["y_prob"].astype(object) + df.loc[0, "y_prob"] = "high" + assert validate_predictions(df) == ["y_prob is not numeric."] + + +def test_validate_predictions_reports_y_prob_outside_0_1() -> None: + df = _valid_frame() + df.loc[0, "y_prob"] = 1.2 + assert validate_predictions(df) == ["y_prob has values outside [0, 1]."] + + # --- Check 1: patient-level leakage -------------------------------------- @@ -67,30 +144,142 @@ def test_detects_optimistic_site_shortcut_leakage() -> None: def test_pooled_metric_matches_manifest_overall_auc(scenario: str, expected_auc: float) -> None: result = compute_pooled_metric(_load(scenario)) assert result["name"] == "auc" + assert result["rows"] == "all" assert result["value"] == pytest.approx(expected_auc, abs=1e-4) -# --- Check 3: subgroup degradation --------------------------------------- +def test_subgroup_summary_counts_the_subgroup_list() -> None: + # Live reports miscounted subgroups and claimed every interval spans zero, + # so the payload carries these counts instead of leaving them to Claude. + payload = run_checks(_load("subgroup_density_degradation")) + subgroups = payload["subgroups"] + assert payload["subgroup_summary"] == { + "evaluated": len(subgroups), + "flagged": sum(sg["flagged"] for sg in subgroups), + "interval_below_zero": sum(sg["delta_ci95"][1] < 0 for sg in subgroups), + "interval_above_zero": sum(sg["delta_ci95"][0] > 0 for sg in subgroups), + } + assert payload["subgroup_summary"] == { + "evaluated": 20, + "flagged": 1, + "interval_below_zero": 1, + "interval_above_zero": 2, + } -def test_subgroup_degradation_is_detectable() -> None: - subgroups = check_subgroup_degradation(_load("subgroup_scanner_degradation")) +# --- Check 3: subgroup degradation --------------------------------------- + +# Seeds 7–11 were fixed before these outcomes were recorded. make_scenarios.py +# uses seeds 1–6. On seeds 7–36 (30 tables) this branch misses M030 on 4 +# scanner tables (seeds 10, 11, 21, 22) and flags 6 clean tables (seeds 8, 10, +# 19, 20, 30, 32). The subgroup logic on main misses M030 on 2 of 30 (seeds +# 10, 22) and flags 6 of 30 clean tables (seeds 17, 19, 20, 25, 29, 30). A +# change in the outcomes below means the detection behaviour changed and must +# be reported in the commit message, not fixed by choosing other seeds. +_FRESH_SEEDS = (7, 8, 9, 10, 11) +_CLEAN_FLAGS = { + 7: set(), + 8: {("age", "80-89")}, + 9: set(), + 10: {("age", "80-89")}, + 11: set(), +} +_SCANNER_M030_FLAGGED = {7: True, 8: True, 9: True, 10: False, 11: False} + + +@pytest.fixture(scope="module") +def subgroups_by_scenario() -> dict[str, list]: + """Run the subgroup check once per committed scenario.""" + names = ( + "clean_patient_grouped", + "weak_random_split_leakage", + "duplicate_row_leakage", + "subgroup_scanner_degradation", + "subgroup_density_degradation", + "optimistic_site_shortcut", + ) + return {name: check_subgroup_degradation(_load(name)) for name in names} + + +def test_subgroup_degradation_is_detectable(subgroups_by_scenario: dict[str, list]) -> None: + subgroups = subgroups_by_scenario["subgroup_scanner_degradation"] assert any(sg["flagged"] for sg in subgroups) m030 = next(sg for sg in subgroups if sg["key"] == "machine_id" and sg["value"] == "M030") assert m030["flagged"] assert m030["auc"] == pytest.approx(0.6243, abs=1e-4) - assert m030["delta_vs_pooled"] < -0.10 - - -def test_no_subgroup_flags_on_clean_scenario() -> None: - subgroups = check_subgroup_degradation(_load("clean_patient_grouped")) + assert m030["delta_vs_rest"] == pytest.approx(m030["auc"] - m030["auc_rest"]) + assert m030["delta_vs_rest"] <= -0.10 + assert m030["delta_ci95"] is not None + assert m030["delta_ci95"][1] < 0 + assert 0.0 <= m030["mean_predicted"] <= 1.0 + assert 0.0 <= m030["observed_rate"] <= 1.0 + + +def test_density_degradation_flags_c_or_d_not_a_or_b(subgroups_by_scenario: dict[str, list]) -> None: + flagged = { + (sg["key"], sg["value"]) + for sg in subgroups_by_scenario["subgroup_density_degradation"] + if sg["flagged"] + } + assert ("density", "C") in flagged or ("density", "D") in flagged + assert ("density", "A") not in flagged + assert ("density", "B") not in flagged + + +def test_no_subgroup_flags_on_clean_scenario(subgroups_by_scenario: dict[str, list]) -> None: + subgroups = subgroups_by_scenario["clean_patient_grouped"] assert subgroups # subgroups were evaluated assert not any(sg["flagged"] for sg in subgroups) -def test_subgroup_results_cover_machine_and_site_keys() -> None: - keys = {sg["key"] for sg in check_subgroup_degradation(_load("clean_patient_grouped"))} - assert keys == {"machine_id", "site_id"} +def test_subgroup_results_cover_machine_site_density_and_age( + subgroups_by_scenario: dict[str, list], +) -> None: + subgroups = subgroups_by_scenario["clean_patient_grouped"] + assert {sg["key"] for sg in subgroups} == {"machine_id", "site_id", "density", "age"} + assert "50-59" in {sg["value"] for sg in subgroups if sg["key"] == "age"} + + +@pytest.mark.parametrize( + ("scenario", "level"), + [ + ("clean_patient_grouped", "GREEN"), + ("weak_random_split_leakage", "RED"), + ("subgroup_scanner_degradation", "YELLOW"), + ("subgroup_density_degradation", "YELLOW"), + ], +) +def test_risk_level_follows_leakage_then_subgroup_flags( + scenario: str, level: str, subgroups_by_scenario: dict[str, list] +) -> None: + risk = compute_risk(check_patient_leakage(_load(scenario)), subgroups_by_scenario[scenario]) + assert risk["level"] == level + assert risk["reasons"] + assert all(isinstance(reason, str) and reason for reason in risk["reasons"]) + + +def test_fresh_seed_subgroup_outcomes_are_recorded() -> None: + """Record per-seed flags. Do not retune the seeds when a flag moves.""" + for seed in _FRESH_SEEDS: + df, _manifest = generate(GeneratorConfig(seed=seed, flaws=Flaws(split_strategy="patient"))) + flagged = { + (sg["key"], sg["value"]) for sg in check_subgroup_degradation(df) if sg["flagged"] + } + assert flagged == _CLEAN_FLAGS[seed] + + degradation = SubgroupDegradation("machine_id", ["M030"], target_auc=0.62) + for seed in _FRESH_SEEDS: + df, _manifest = generate( + GeneratorConfig( + seed=seed, + flaws=Flaws(split_strategy="patient", subgroup_degradations=[degradation]), + ) + ) + m030_flagged = any( + sg["key"] == "machine_id" and sg["value"] == "M030" and sg["flagged"] + for sg in check_subgroup_degradation(df) + ) + assert m030_flagged is _SCANNER_M030_FLAGGED[seed] # --- Check 2: split-quality audit ---------------------------------------- @@ -100,16 +289,32 @@ def test_audit_split_quality_structure_and_direction() -> None: df = _load("weak_random_split_leakage") audit = audit_split_quality(df) assert audit["metric"] == "auc" - # Both AUCs are finite probabilities computed over the same fixed y_prob. - assert 0.0 < audit["random_split"] < 1.0 - assert 0.0 < audit["grouped_split"] < 1.0 - # gap is exactly the signed difference (may be small or negative — the leak - # here is structural patient overlap, not an inflated y_prob). - assert audit["gap"] == pytest.approx(audit["random_split"] - audit["grouped_split"]) + # Both AUCs are computed from the same fixed y_prob. + assert 0.0 < audit["test_split_as_given"] < 1.0 + assert 0.0 < audit["patient_disjoint_sample"] < 1.0 + low, high = audit["sample_range95"] + assert low <= audit["patient_disjoint_sample"] <= high + assert audit["gap"] == pytest.approx( + audit["test_split_as_given"] - audit["patient_disjoint_sample"] + ) # The primary leakage signal for a random split is shared patients. assert check_patient_leakage(df)["overlapping_patient_ids"] > 0 +def test_audit_split_quality_leaves_out_one_class_samples() -> None: + # 200 patients, generator seed 6: the seed-0 sample and some other seeds' + # samples have no positive row, so their AUC is undefined. + df, _ = generate(GeneratorConfig(n_patients=200, seed=6)) + assert validate_predictions(df) == [] + audit = audit_split_quality(df) + assert audit["patient_disjoint_sample"] is None + assert audit["gap"] is None + low, high = audit["sample_range95"] + assert 0.0 <= low <= high <= 1.0 + # NaN would serialize as a bare NaN token, which is not valid JSON. + json.dumps(run_checks(df), allow_nan=False) + + def test_audit_split_quality_is_reproducible() -> None: df = _load("weak_random_split_leakage") assert audit_split_quality(df) == audit_split_quality(df)