Wire lab z-scoring, run provenance, and a named eval split - #53
Merged
will-pang merged 1 commit intoSep 4, 2026
Merged
Conversation
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.
Copilot stopped work on behalf of
Rian354 due to an error
September 4, 2026 20:46
will-pang
merged commit Sep 4, 2026
d1c81d4
into
ml4h-merge-tranche-1-wp-20260830
0 of 3 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Five small changes on top of
cdeb3e0, plus the paper launchers. Nothing here changes what data is collected.Not included on purpose: the
time_originfix.cdeb3e0already does it, and by inspection it's identical to the version on the trunk — same_hours_sincehelper, same anchor onadmissions_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 wiringPer-feature z-score fit on the training split only; missing values stay missing.
UnifiedMultimodalEmbeddingModelalready acceptednumeric_standardizers, so this is just the fit and the hand-off.--no-lab-standardizationruns 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:
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 becauseSampleDatasetsubclasseslitdata.StreamingDataset, whose__iter__/__len__shard byWORLD_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_config—pyhealth/utils.pymetrics_history.jsonrecords what a run scored but not the conditions that produced it. This writes arun_config.jsonnext to it with the resolved settings, not the raw flags.That distinction matters: on an earlier branch a per-model optimizer override meant
run_configstoredadam_eps: nullwhile the optimizer actually used1e-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, andtorch. 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 inrun_config.json.4.
exp_nameincludes the taskIt was
{model}_seed{seed}, so--task labsand--task notes_labsat 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_versioncdeb3e0removedself.emitted_data_version = 1. That field is part ofvars(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.cdeb3e0changes every event timestamp, which is exactly when the bump is needed, so it's restored and set to 2.scripts/paper/common.shholds the Tranche 1 protocol (identical for every cell);will.shandrian.shadd only the data roots and the CPU tuning for their machine.will.shkeeps--num-workers 4and no thread pinning, so it behaves exactly as it does today.rian.shpins 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:
cdeb3e0)mlp_seed1notes_labs_mlp_seed1notes_labs_mlp_seed1run_config.jsonlab_standardizationTrueFalseeval_splittesttestFitted values on the dev train split look right (Na 138.39 ± 5.27, K 4.13 ± 0.57).
tests/test_p2_lab_standardizer.pypasses (4 tests).