Skip to content

Wire lab z-scoring, run provenance, and a named eval split - #53

Merged
will-pang merged 1 commit into
ml4h-merge-tranche-1-wp-20260830from
rian/minimal-integration
Sep 4, 2026
Merged

Wire lab z-scoring, run provenance, and a named eval split#53
will-pang merged 1 commit into
ml4h-merge-tranche-1-wp-20260830from
rian/minimal-integration

Conversation

@Rian354

@Rian354 Rian354 commented Sep 4, 2026

Copy link
Copy Markdown

Five small changes on top of cdeb3e0, plus the paper launchers. Nothing here changes what data is collected.

Not included on purpose: the time_origin fix. cdeb3e0 already does it, and by inspection it's identical to the version on the trunk — same _hours_since helper, same anchor on admissions_to_process[0].timestamp, same call sites. No need to revert anything; this PR doesn't touch that logic.

1. Lab z-scoring — pyhealth/processors/lab_standardizer.py (130 lines) + ~13 lines of wiring

Per-feature z-score fit on the training split only; missing values stay missing. UnifiedMultimodalEmbeddingModel already accepted numeric_standardizers, so this is just the fit and the hand-off.

--no-lab-standardization runs raw labs as an ablation, so this is a default you can switch off rather than a commitment.

Measured on EHRMamba / labs+notes / seed 1 — one cell, one seed:

best val PR-AUC
z-scores on 0.8011
z-scores off 0.7473

Why it's worth the lines: the ten labs sit on very different scales (Na 138.3 ± 5.2, K 4.14 ± 0.79, Ca 1.12 ± 0.32). Un-standardised, sodium's numbers are ~33× potassium's, so the model weights a feature by how large its units happen to be rather than how abnormal the value is. Models with input normalisation (LayerNorm in the transformers) absorb some of that; a plain GRU doesn't.

The transform itself is one line — z = (values - mean) / std, unobserved → 0. Most of the remaining lines are _indices, which exists because SampleDataset subclasses litdata.StreamingDataset, whose __iter__/__len__ shard by WORLD_SIZE: naive iteration would silently fit on 1/N of the train split under torchrun.

Not clipped, deliberately — there's no universally valid physiological range for these MIMIC category aggregates, so out-of-support values stay as large finite z-scores and remain auditable.

2. write_run_configpyhealth/utils.py

metrics_history.json records what a run scored but not the conditions that produced it. This writes a run_config.json next to it with the resolved settings, not the raw flags.

That distinction matters: on an earlier branch a per-model optimizer override meant run_config stored adam_eps: null while the optimizer actually used 1e-6. A config logging flags would have shown nothing wrong.

Also records source_sha256 (a hash of the runner source), which is how you demonstrate a whole table came from one build, and torch. Written atomically, so a killed run can't leave a half-written config.

3. Named eval split

The inference loader fell back test or val or train. A run without a test split would evaluate on the training set and report the numbers as test, with nothing indicating it. Now the split is named, warned about, and recorded in run_config.json.

4. exp_name includes the task

It was {model}_seed{seed}, so --task labs and --task notes_labs at the same seed wrote to the same directory and the second run silently overwrote the first. With 36 paired cells planned, that would quietly destroy half the table. One line.

5. Restore emitted_data_version

cdeb3e0 removed self.emitted_data_version = 1. That field is part of vars(task), which is what the task-cache uuid5 key is built from — it's the only mechanism that invalidates a stale cache when emitted data changes. Without it, a cache built before a change is silently reused. cdeb3e0 changes every event timestamp, which is exactly when the bump is needed, so it's restored and set to 2.

scripts/paper/

common.sh holds the Tranche 1 protocol (identical for every cell); will.sh and rian.sh add only the data roots and the CPU tuning for their machine.

TASK=labs_notes MODEL=ehrmamba SEED=3 bash scripts/paper/rian.sh

will.sh keeps --num-workers 4 and no thread pinning, so it behaves exactly as it does today. rian.sh pins OMP threads and adds loader workers, because those nodes run several cells at once: unpinned, four concurrent cells put ~800 threads on 128 cores and epoch time went from 191 s to 8600 s with the GPUs sitting at 0–1%.

Split of work: rian seeds 1/3/5 on labs and labs+notes plus all of labs+notes+CXR; will seeds 2/4 on labs and labs+notes.

Verification

Ran a dev split before and after on this branch:

before (cdeb3e0) after, z-score on after, z-score off
output dir mlp_seed1 notes_labs_mlp_seed1 notes_labs_mlp_seed1
run_config.json absent present present
lab_standardization True False
eval_split test test
standardizer buffers in checkpoint mean/std/count present none (correct)

Fitted values on the dev train split look right (Na 138.39 ± 5.27, K 4.13 ± 0.57). tests/test_p2_lab_standardizer.py passes (4 tests).

Five small changes on top of cdeb3e0, plus the paper launchers.

1. lab_standardizer.py + wiring. Per-feature z-score fit on the training
   split only; missing values stay missing. UnifiedMultimodalEmbeddingModel
   already accepted numeric_standardizers, so this is only the fit + the
   hand-off. --no-lab-standardization runs raw labs as an ablation, so the
   default is a choice you can turn off rather than a commitment.
   Measured on EHRMamba/labs+notes/seed 1: 0.7473 without, 0.8011 with.

2. write_run_config. metrics_history.json records what a run scored but not
   the conditions that produced it, and it records resolved values rather
   than raw flags -- that distinction is what surfaced a per-model optimizer
   override where run_config stored adam_eps: null while the optimizer used
   1e-6. Also records source_sha256 so a table can be shown to come from one
   build.

3. eval_split. The inference loader fell back test-or-val-or-train, so a run
   without a test split reported TRAINING metrics as test with nothing saying
   so. The split is now named, warned about, and recorded.

4. exp_name includes the task. It was {model}_seed{seed}, so labs and
   notes_labs at one seed wrote to the same directory and the second run
   silently destroyed the first. This matters immediately: the plan is 36
   paired cells.

5. Restore emitted_data_version. cdeb3e0 removed it. It is part of vars(task),
   which is what the task-cache uuid5 key is built from, so without it a cache
   built before an emitted-data change is silently reused -- and cdeb3e0
   changes every event timestamp, which is exactly when the bump is needed.

Deliberately not included: the time_origin fix. cdeb3e0 already does it, and
by inspection it is identical to ours (same _hours_since helper, same anchor
on admissions_to_process[0].timestamp). No need to revert it.

scripts/paper: common.sh holds the protocol; will.sh and rian.sh add only the
data roots and the CPU tuning for their machine. rian.sh pins OMP threads and
uses loader workers because those nodes run several cells at once -- unpinned,
four concurrent cells put ~800 threads on 128 cores and epoch time went 191s
to 8600s with the GPUs at 0-1%. will.sh keeps num_workers=4 and no pinning.

Verified on a dev split before and after: output dir goes mlp_seed1 ->
notes_labs_mlp_seed1, run_config.json appears with eval_split=test, and the
fitted mean/std/count buffers land in the checkpoint with --no-lab-
standardization correctly removing them.

@will-pang will-pang left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me

Copilot stopped work on behalf of Rian354 due to an error September 4, 2026 20:46
@will-pang
will-pang merged commit d1c81d4 into ml4h-merge-tranche-1-wp-20260830 Sep 4, 2026
0 of 3 checks passed
@will-pang
will-pang deleted the rian/minimal-integration branch September 4, 2026 20:46
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.

2 participants