Skip to content

Compute risk and subgroup flags in Python; switch the reporter to Opus 5.5 - #2

Merged
vbabenko97 merged 20 commits into
mainfrom
fix/review-findings
Oct 18, 2026
Merged

vbabenko97 merged 20 commits into
mainfrom
fix/review-findings

Conversation

@vbabenko97

@vbabenko97 vbabenko97 commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

This branch fixes problems a code and methods review found in the validators, the reporter, and the docs. It also switches the reporter to Claude Opus 5.5 at low effort. Python computes every metric, flag, and the risk level; Claude only writes prose from the payload.

Validators (clinval_validators/checks.py)

  • validate_predictions rejects a CSV with a missing column, no train or test rows, a label outside {0, 1}, a one-class test set, or a probability outside [0, 1]. The CLI prints the problems and exits 2; the UI shows them and stops.
  • compute_risk sets the level: RED when any patient is in both train and test, YELLOW when a subgroup is flagged, GREEN otherwise. The prompt used to ask Claude to choose the level from an undefined "large" gap.
  • Each subgroup is compared with the rest of the table instead of the pooled AUC, which contains the degraded rows. A subgroup is flagged when its AUC is at least 0.10 below the rest and the upper end of a patient-level bootstrap 95% interval is below zero. Density and decade age bands are evaluated when those columns exist. On subgroup_density_degradation, density C (0.670 against 0.792 on the rest) was not flagged before and is flagged now.
  • The split audit fields are renamed to test_split_as_given, patient_disjoint_sample, and sample_range95. Its patient sample includes training rows and it rescores fixed predictions, so it cannot measure inflation. It no longer enters the risk rule. On a small table a patient sample can contain one class, where AUC is undefined; such seeds are left out of sample_range95, and patient_disjoint_sample and gap are null when the seed-0 sample is one of them. The payload no longer contains NaN, which is not valid JSON.
  • The payload adds subgroup_summary (evaluated, flagged, and intervals entirely below or above zero) and pooled_metric.rows: "all". Live reports had miscounted subgroups and called the pooled AUC a test-set AUC.

Reporter (reporter/report.py, prompts/)

  • Model claude-opus-5-5, effort low, max_tokens 4096, with server-side refusal fallback (beta server-side-fallback-2026-07-01). The choice comes from a blind review of 24 live reports for each of seven configurations; CLAUDE.md records the results under Gotchas.
  • The reporter joins every text block, because a model that thinks puts a thinking block first. It raises ReportNotRenderedError when the API request fails (no credits, bad key, rate limit, no connection), or when the reply stops at max_tokens, is refused, or has no text. The CLI then prints the payload and exits 1, and the UI shows the reason instead of a traceback.
  • The UI passes the typed API key into the reporter. It used to write the key into os.environ, which every Streamlit session in the process shares. The key field also used to be prefilled with the server's ANTHROPIC_API_KEY, which sent that key to every browser; it now starts empty, and an empty field uses the server key on the server.
  • The prompts move from prompts/opus-4-8/ to prompts/. They add rules for mistakes seen in live reports: the split audit is not evidence for or against inflation, a calibration gap is stated with both numbers, and the critique gives exactly one recommendation.

Tests and CI

  • The fresh-seed subgroup test uses generator seeds 7–11 and asserts the measured outcomes, misses and false alarms included. Over seeds 7–36, 6 of 30 clean tables get a flag, the M030 drop is missed on 4 of 30, and the density drop is flagged on 21 of 30.
  • A GitHub Actions workflow runs pytest on Python 3.11 and 3.13 with actions/checkout@v7 and actions/setup-python@v7 (Node 24). It uses no secrets and makes no API call.

Docs

README, CLAUDE.md, and the data and scenario READMEs describe the current code. CLAUDE.md records why a one-sided Bonferroni correction was measured and not adopted. The July 2026 planning notes move to docs/archive/.

Verification

  • python -m pytest -q: 59 passed on anthropic 0.116.0 and on 1.15.0, the version a fresh install gets. ruff check: clean.
  • An offline run against a mock transport sent the same request on both SDK versions: model, effort, the fallback beta header, and fallbacks: "default".
  • A Streamlit AppTest run with a stand-in server key and a client that returns 403 showed an empty key field, no server key in the widget tree, and the API error as an on-page message.
  • With an API key, clinval-validate --predictions claude-science-artifacts/scenarios/optimistic_site_shortcut.csv returned a complete RED report whose numbers match the payload.

Known limit: risk reports from every model tested sometimes run past the prompt's 300-word limit.

.agents/ is editor-local configuration, the same class of file as .claude/
and .codex/, so it should not appear as untracked project work. The
scenarios comment also misspelled the fixtures directory.
An uploaded CSV is the one input the tool does not control. A missing
column, a split with no train or test rows, a label outside {0, 1}, a
one-class test set, or a probability that is not a number in [0, 1]
used to surface as a KeyError or a scikit-learn traceback.

validate_predictions reports those problems in plain language. The CLI
prints them on stderr and exits 2, before any check runs. The browser
UI shows the same list and stops.
The patient sample behind the second AUC is drawn from every row, and
about two thirds of those rows are training rows. Calling it a grouped
split implied a held-out fold and a measurement of how much leakage
inflated the metric. The scores are fixed before the split is assigned,
so that comparison cannot show inflation.

Rename the two AUCs, keep gap as their difference, and report the
2.5th–97.5th percentile of the sample AUC over seeds 0–199. The same
seed-0 sample remains inside that range, and repeating the audit
returns the same numbers.
Comparing a subgroup with the pooled AUC hides the drop, because the
degraded rows are inside the pool. On subgroup_density_degradation,
density C is 0.670 against 0.792 on the other rows, and the old check
flagged nothing.

Each subgroup is now compared with the rest of the table. machine_id
and site_id are always evaluated; density and decade age bands are
evaluated when those columns are present. A subgroup is flagged when
its AUC is at least 0.10 below the rest and the upper end of a
patient-level 95% interval (200 resamples, seed 0) is below zero.
Mean predicted probability and the observed rate are reported and do
not flag. The pooled AUC is unchanged, and it assumes every row is a
held-out or out-of-fold prediction.
The reporter prompt told Claude to choose RED when leakage was present
or the random-versus-grouped gap was "large", and nothing defined large.
The level is a pass/fail decision, so it belongs with the other flags.

compute_risk returns RED when any patient is in both train and test,
YELLOW when a subgroup is flagged, and GREEN otherwise. The split audit
stays in the payload as a diagnostic and does not enter the rule.
run_checks is the single payload the CLI, the UI, and the tests share.
The prompts tell Claude to state that level verbatim, to treat the
split audit as a rescore that cannot show inflation, and, when patients
overlap, to ask for a retrained patient-grouped evaluation rather than
another score of the same predictions.
app.py wrote the key from the password box into os.environ. That map is
process-wide, so one Streamlit session could overwrite the key seen by
another session in the same process.

render_report and render_reviewer_critique now take an optional api_key
and use it in place of ANTHROPIC_API_KEY. The UI passes the typed key
and leaves the environment alone. The sample payload in the reporter
test also used a leakage rate of 0.4702; on weak_random_split_leakage
the rate is 0.9802.
The README still told Claude to judge whether a split gap was large,
and it still described the second AUC as a grouped retest. Python now
chooses RED, YELLOW, or GREEN from patient overlap and subgroup flags,
and the split audit only rescores the predictions already in the file.

The sample payload is the output of run_checks on
weak_random_split_leakage, with the subgroup list marked as truncated.
The report excerpt is hand-written and asks for a retrained
patient-grouped evaluation. The demo script runs the clean control and
then optimistic_site_shortcut, matching the recorded narration. The
July 2026 planning notes move to docs/archive.
CLAUDE.md still described the validators, the CLI, and the UI as work
left to build, and it still said a subgroup was compared with pooled
AUC. Both entry points exist. Python sets the risk level from patient
overlap and from subgroup flags against the rest of the table, and the
split audit does not enter that rule.

The command list is the install, pytest, clinval-validate, Streamlit,
and the local RSNA adapter. Three gotchas are easy to miss from the
code alone: scores are drawn before the split, the flag thresholds
were calibrated on the committed fixtures, and only the prompt text
after the first --- is sent.
A fresh clone had no check that the validators still pass on the two
Python versions the project claims. The workflow installs the dev
extra and runs pytest. It does not use secrets, and it does not call
the Anthropic API.
The fresh-seed test asserted that every degraded table flagged M030 and
that every clean table was quiet. Its seed lists skipped the misses and
the false alarms: M030 is missed on 4 of 30 scanner tables, and 6 of 30
clean tables receive a flag.

Seeds 7–11 are contiguous and were fixed before the assertions. The test
now records those outcomes: M030 is flagged on 7, 8, and 9 and not on 10
and 11; clean seeds 8 and 10 each flag age 80-89, and 7, 9, and 11 flag
nothing. The README states the 30-seed rates from the bundled generator.
The README gave the false-alarm and M030 miss rates but not how often the
density drop is caught, and it did not say what a flag means at 2,000
patients. A flag is a candidate drop to confirm on more data, and a missing
flag does not clear a subgroup.

A one-sided Bonferroni correction across subgroups was measured on seeds
7-36 and rejected: clean-table flags fell from 6 to 3 of 30, but M030
detection fell from 26 to 21 of 30, density detection from 21 to 12 of 30,
and the committed density scenario went unflagged. CLAUDE.md records this
so the correction is not added again without re-measuring.
A live run of the reporter (24 reports per model) showed Claude counting
the subgroup list wrong ("22 subgroups" for 21), claiming every interval
spans zero when some did not, and calling the pooled AUC a test-set AUC.
Each of these is a number or label Python can supply.

The payload gains subgroup_summary: subgroups evaluated, subgroups
flagged, and how many delta_ci95 intervals lie entirely below or entirely
above zero. pooled_metric gains rows: "all", and both prompts say the
pooled AUC covers train, val and test rows and is not the test-split AUC.
report.py returned message.content[0].text. A model that thinks puts a
thinking block first, so a live run with Claude Haiku 5.5 would have raised
AttributeError on every report. The reporter now joins the text blocks.

It also raises ReportNotRenderedError when the reply stops at max_tokens,
is refused, or holds no text. The CLI prints the payload and the stop
reason and exits 1; the UI shows the message and keeps the payload.

max_tokens rises from 1024 to 4096: the longest live Opus 4.8 report used
998 output tokens, and only generated tokens are billed. The UI spinner
names the model from MODEL instead of a hard-coded string.
A blind review of 48 live reports found the same slips in both models
tested: the split audit cited as "no evidence of inflation" or as
"consistent with" the leakage story, calibration gaps of 0.50 against 0.02
called small or consistent, risk reports over the 300-word limit (Opus
4.8 exceeded it in 12 of 12), and every RED critique from Haiku 5.5
writing "Reject or Major revision" without choosing.

Both prompts now say the audit cannot show inflation or its absence and
must not be used as evidence either way, ask for both numbers whenever a
calibration gap is described, and name what to cut to stay within the
word limit. The critique asks for exactly one recommendation on the first
line.
A blind review of 24 live reports per configuration compared seven
setups across four scenarios. Opus 5.5 at low effort made no major error
and had the most error-free reports (19 of 24), at $40 per 1,000 reports
against $46 for Opus 4.8, which made one major error and had 11 of 24
error-free. Medium effort made no major error either and cost more.

Opus 5.5 always thinks, so the request sets output_config.effort and sends
no thinking parameter; replies start with thinking blocks, which the
reporter already skips. Requests opt into server-side refusal fallback
(fallbacks "default" under the server-side-fallback-2026-07-01 beta), so a
safety-classifier decline is rerun on the model Anthropic recommends for
that category instead of failing the report. The SDK types fallbacks as a
list, so the "default" string goes in extra_body.

The prompts move from prompts/opus-4-8/ to prompts/, since they no longer
belong to one model. CLAUDE.md records the comparison so the choice is not
changed without re-running it.
@vbabenko97

Copy link
Copy Markdown
Owner Author

Code review

Found 1 issue:

  1. sample_range95 is [NaN, NaN] when any of the 200 patient samples has one class. roc_auc_score returns NaN for that seed, and np.percentile keeps the NaN, so one undefined sample replaces the whole range. validate_predictions still accepts the table, because it only requires both classes on the test split. A 200-patient table with 12 positive images (1.5% of rows) passed validation and returned a numeric seed-0 AUC with sample_range95: [NaN, NaN]. The subgroup bootstrap drops replicates that lack a class before it takes a percentile. (bug due to audit_split_quality writing every seed into sample_aucs and passing that array to np.percentile)

splitter = GroupShuffleSplit(n_splits=1, test_size=0.15, random_state=seed)
_, sample_idx = next(splitter.split(df, groups=groups))
sample_aucs[seed] = roc_auc_score(y_true[sample_idx], y_prob[sample_idx])
low, high = np.percentile(sample_aucs, [2.5, 97.5])
patient_disjoint_sample = float(sample_aucs[0])

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

On a small table a 15% patient sample can contain no positive row. Its
AUC is undefined, roc_auc_score returned NaN, and np.percentile then
turned sample_range95 into [NaN, NaN]. When the seed-0 sample was the
one-class draw, patient_disjoint_sample and gap were NaN as well.
json.dumps writes NaN as a bare token, which is not valid JSON, so that
payload reached Claude and the JSON download in that form.
validate_predictions accepts such tables, because it requires both
classes only on the test split. On 200-patient tables from the bundled
generator, 7 of seeds 0-19 hit this.

Seeds whose sample holds one class are now left out of sample_range95,
the same way the subgroup bootstrap skips one-class replicates.
patient_disjoint_sample and gap are null when the seed-0 sample holds
one class, and sample_range95 is null when every seed's sample does.
The UI shows "n/a" for a null sample AUC, and both prompts say a null
value is undefined and must not be filled in. The prompts' JSON shape
also marks delta_ci95 as nullable, which the code already allowed.

The payloads of the six committed scenarios are byte-identical before
and after this change.
The reporter let anthropic.APIError escape. A request that failed with
403 (no credits), 401 (bad key), 429, 529, or a dropped connection
printed a traceback from the CLI and no payload, and the browser UI
showed the same traceback. _render now raises ReportNotRenderedError
from the API error, so both entry points print or show the payload and
the reason, as they already did for a cut-off or refused reply, and the
CLI exits 1.

The UI prefilled the key field with the server's ANTHROPIC_API_KEY.
A widget's value is sent to the browser, so anyone who opened a
deployed app could reveal that key with the password field's show
button. The field now starts empty, and an empty field makes the
reporter read ANTHROPIC_API_KEY on the server.
actions/checkout@v4 and actions/setup-python@v5 run on Node 20, which
GitHub has deprecated for actions. Version 7 of both runs on Node 24.
Their breaking changes concern pull_request_target, workflow_run, and
the removed pip-install input, none of which this workflow uses.
CI installs the newest anthropic release, 1.15.0, which moved its HTTP
layer from httpx to httpx2, so the test module failed to import there.
The test now hands PermissionDeniedError a stand-in response with the
three attributes the error reads.

The reporter itself needs no change for 1.x. An offline run against a
mock transport sent the same request on anthropic 0.116.0 and 1.15.0
(model, effort, the fallback beta header and fallbacks "default"), and
the full suite passes on both.
@vbabenko97
vbabenko97 merged commit ad919c2 into main Oct 18, 2026
4 checks passed
@vbabenko97
vbabenko97 deleted the fix/review-findings branch October 18, 2026 16:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant