Add the end-to-end runner, chest X-ray as a third modality, and run provenance - #49
Open
Rian354 wants to merge 12 commits into
Open
Add the end-to-end runner, chest X-ray as a third modality, and run provenance#49Rian354 wants to merge 12 commits into
Rian354 wants to merge 12 commits into
Conversation
…rovenance Chest X-ray joins notes and laboratory values through the unified embedding. CXRMultimodalMIMIC4 adds cxr_only, cxr_labs and cxr_notes_labs. An image event has a real position on the timeline: StudyDate and StudyTime give hours from admission, the same convention that laboratory values use. Only a study inside the observation window enters a sample. Two fallbacks changed the measurement in silence. _split_dataset changed from split_by_patient to split_by_sample when the patient split was empty, which leaks, because the admissions of one patient can then be in both splits. The reported predictions came from test_loader or val_loader or train_loader, so a run with no test split reported validation or training performance as test performance. Both now warn and record what they used. metrics_history.json records the score of a run but not its conditions, so a frozen-encoder run and a fine-tuned run are indistinguishable once the job output is gone. write_run_config records the RESOLVED settings, because --freeze-encoder is an alias and the raw flag describes the run incorrectly. Code identity is a git commit and a SHA-256 digest of the package source, since a cluster run starts from an unpacked archive where git gives no result. encoder_lr gives a pretrained text encoder a gentler rate than the randomly initialised layers around it. With the encoder frozen, every encoders.* parameter has requires_grad=False, so the projection is the only trainable text parameter and joins the group; otherwise it keeps the base rate. An epoch mean cannot show the difference between a run that starts badly and a run that becomes worse inside the epoch, so each epoch also records train_loss_first_step, train_loss_first100 and train_loss_last100.
The run directory was named from the model and the seed only. A paired comparison holds both fixed and varies the task, so --task labs_only and --task notes_labs at seed 42 resolved to one directory: transformer_seed42. The second run overwrote the first run's metrics_history.json, run_config.json and predictions CSV. The loss is silent. The surviving directory looks like a complete run, and the provenance this PR adds would describe only the arm that finished last. Found while reviewing the same defect in the upstream consolidation branch.
…ently The runner called NotesLabsMIMIC4 with include_labs, note_extraction, note_source, discharge_note_policy and text_normalize. The task class on this branch accepts none of them, so --task notes_labs, the primary arm of the comparison in this PR, died at construction: TypeError: NotesLabsMIMIC4.__init__() got an unexpected keyword argument 'include_labs' The runner now passes only the parameters the task class declares. A flag that the class cannot honour stops the run instead of being dropped, because a silently ignored --discharge-note-policy would record one protocol in run_config.json and execute another, which is the failure mode this PR exists to remove. Note collection on this branch therefore uses the section extraction that main already has, through _collect_notes with DISCHARGE_CLINICAL_HEADERS. Caught by a smoke run on real MIMIC-IV rather than by the unit tests, which do not build a task from parsed arguments.
…ain() Three more calls were written against a checkout this branch does not have. UnifiedMultimodalEmbeddingModel takes freeze_text_encoder, a boolean, not text_finetune_mode, so every run died at model construction: TypeError: UnifiedMultimodalEmbeddingModel.__init__() got an unexpected keyword argument 'text_finetune_mode' use_amp and amp_dtype are parameters of Trainer.train, not of Trainer, because mixed precision is a property of the training loop and not of the object. get_dataloader gains num_workers in the performance PR, which is not in this branch, so the audit batch uses the default loader. An AST check over the runner now reports no keyword argument that the imported pyhealth signatures reject.
The worker options were expanded into get_dataloader with **loader_kwargs, which an AST check over keyword arguments cannot see, so the previous audit reported the runner clean while every run still died: TypeError: get_dataloader() got an unexpected keyword argument 'num_workers' Those options arrive with the performance PR, which is not in this branch. The runner now passes only what the installed signature declares, and stops if the caller asked for an option it cannot honour, so a requested option is never silently ignored.
binary_metrics_fn has no f1_opt, so validation aborted at the end of epoch 1: ValueError: Unknown metric for binary classification: f1_opt Model selection uses pr_auc, a rank metric that needs no threshold, so dropping the threshold-optimised F1 does not change which checkpoint is chosen.
MIMIC4Dataset takes cxr_variant, and the runner never passed it, so every CXR run used the default layout. The resized set that this project uses is the sunlab layout, and the default config expects a column it does not have, so all three CXR arms failed at dataset build: KeyError: 'studytime_normalized' The default config reads mimic-cxr-2.0.0-metadata-pyhealth.csv and needs studytime_normalized. The sunlab variant reads the resized set, normalises StudyTime itself, and derives image paths from dicom_id. The help text names the exact failure so the wrong choice is diagnosable from the flag rather than from a pandas KeyError inside dask.
The sunlab CXR variant required a directory literally named "images". The
resized set this project uses holds the same flattened {dicom_id}.jpg files
under "resized_images", so all three CXR arms failed on a complete and correct
dataset of 377,110 images:
FileNotFoundError: Sunlab images directory not found: .../images
Both names are now accepted, the derived image_path follows whichever was
found, and the error lists what was looked for.
The last commit accepted both directory names after a complete cohort failed on a hardcoded images path. The class docstring still named only images, and no test covered the lookup. Co-authored-by: Cursor <cursoragent@cursor.com>
The unified embedding sizes its patch embedding from processor.in_channels and falls back to 3. TimeImageProcessor never exposed that attribute, so a greyscale CXR task built a 3-channel patch embedding and fed it 1-channel images. The mismatch appeared only at the first forward pass, after the full image cache had been built: RuntimeError: Given groups=1, weight of size [128, 3, 16, 16], expected input[16, 1, 224, 224] to have 3 channels, but got 1 in_channels now follows n_channels when set and otherwise the PIL mode, matching _zero_image_tensor exactly so a placeholder cannot differ from a real image.
prepare_metadata wrote mimic-cxr-2.0.0-metadata-pyhealth-sunlab.csv into the PhysioNet root. That path is not writable on the cluster, so CXR setup failed after a complete image directory had already been found. Cache is tried first, and the generated YAML is rewritten to the absolute CSV path. The test chmods the root to 555 and checks the CSV lands in cache. Co-authored-by: Cursor <cursoragent@cursor.com>
The runner compared Jamba against Transformer and RNN while the library defaulted to 2+6, so the extra Mamba stack was an uncontrolled capacity difference. Class and CLI defaults are now 2+2; the test checks both the constructor defaults and the layer schedule. Co-authored-by: Cursor <cursoragent@cursor.com>
Author
|
The sunlab CXR loader writes the metadata CSV under |
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.
Summary
The end-to-end runner, chest X-ray as a third modality, and run provenance.
With the corrections in #46 and the encoders in #48, this loop produces the primary result of the
project. Notes and laboratory values together give PR-AUC 0.4062, against 0.2956 for
laboratory values alone. The paired difference is +0.1106, with a 95% interval of
+/- 0.0030 over three seeds.
Both arms use an identical backbone: 128 embedding dimensions, 128 hidden units, 2 layers, 4 heads.
The comparison is matched on parameters and depth. Only the token count is different.
One limit is stated in full below: the note-presence confound is not yet controlled, so this
result shows that
notes_labsis better thanlabs_only, and not yet that the note content is thereason.
The results below predate the padding fix in #48. That fix changes what the model reads, so
every number in this description must be produced again once #48 merges. They are kept here because
they document the pipeline and the controls, not because they are final.
Depends on #46 and #48. The runner imports
sample_oversampleandLabsOnlyMIMIC4from #46,and
fit_lab_standardizerfrom #48. Merge those first.Implemented
1. Chest X-ray as a third modality
pyhealth/tasks/multimodal_mimic4.py,pyhealth/datasets/mimic4.py,pyhealth/datasets/configs/mimic4_cxr.yamlCXRMultimodalMIMIC4with the armscxr_only,cxr_labsandcxr_notes_labs.hadm_id. The task links an image to an admission only whenStudyDateandStudyTimeof the same subject fall inside the observation interval of that admission.the same convention that laboratory values use, so the unified embedding reads every modality the
same way.
amount for the same patient. The difference is therefore correct.
prepare_metadatareuses a prepared metadata file when one is present. To rewrite a 100+ MB CSVfor every CXR experiment is needless shared-filesystem IO, and it races a concurrent reader. An
installation with raw metadata only still uses the builder.
2. Run provenance
pyhealth/utils.py,examples/mortality_prediction/unified_embedding_e2e_mimic4.pymetrics_history.jsonrecords the score of a run but not the conditions. A run with a frozenencoder and a run with a trainable encoder look identical after the job output is gone. This is
the reason that the frozen state of earlier results cannot be recovered.
write_run_config(), which writesrun_config.jsonbeside the metrics.--freeze-encoderis an alias, so a run can recordtext_finetune_mode="full"and train with afrozen encoder. The raw flag gives the wrong description.
from an unpacked archive, where the git command gives no result.
finishes with no provenance is the case this feature exists to prevent.
3. Two fallbacks that changed the measurement in silence
examples/mortality_prediction/unified_embedding_e2e_mimic4.py_split_datasetchanged fromsplit_by_patienttosplit_by_samplewhen the patient split wasempty. That fallback leaks, because the admissions of one patient can then be in the train split
and the test split. It occurs with a small cohort, which is the scale used for pipeline tests. It
now gives a warning and records
split_mode.test_loader or val_loader or train_loader. With no testsplit, the run reported validation performance, or training performance, as test performance. It
now gives a warning and records
eval_split.4. Report of note availability
examples/mortality_prediction/unified_embedding_e2e_mimic4.pyassociation is real and it is large.
_note_availability_report()measures this on the train split only. It strides across the splitrather than reading a prefix, because samples are grouped by patient and a prefix is not
representative. It prints the result, records it in
run_config.json, and warns above a ratio of1.5.
5. Discriminative learning rate for the text pathway
pyhealth/trainer.pyencoder_lr, which gives a pretrained text encoder a gentler rate than the randomlyinitialised layers around it.
embedding_model.encoders.*parameter hasrequires_grad=False, so a group matched on that prefix alone is empty, andencoder_lrcontrols nothing, while the only trainable text parameters,
projections.*, keep the base rate.discriminative fine-tuning still gives the base rate to a projection with random values.
6. Loss trajectory for each epoch
pyhealth/trainer.pytrain_loss_first_step,train_loss_first100andtrain_loss_last100.becomes worse inside the epoch. This difference cost significant diagnostic time.
7. The run directory name includes the task
examples/mortality_prediction/unified_embedding_e2e_mimic4.pyf"{model}_seed{seed}". A paired comparison holds both of those fixed and variesthe task, so
--task labs_onlyand--task notes_labsat seed 42 both resolved totransformer_seed42. The second run overwrote the first run'smetrics_history.json,run_config.jsonand predictions CSV.in contribution 2 would describe only the arm that finished last.
f"{task}_{model}_seed{seed}".8. The runner matches this checkout's APIs
Caught by a smoke run on real MIMIC-IV. The unit tests did not build a task from parsed
arguments.
NotesLabsMIMIC4on this branch does not takeinclude_labs,note_extraction,note_source,discharge_note_policyortext_normalize. The runner passed all of them, so--task notes_labsdied at construction. It now passes only the parameters the class declares,and a flag the class cannot honour stops the run instead of being dropped.
UnifiedMultimodalEmbeddingModeltakesfreeze_text_encoder, nottext_finetune_mode.use_amp/amp_dtypeare parameters ofTrainer.train, not ofTrainer.**loader_kwargshid them froman AST check. The runner now passes only what the installed
get_dataloadersignaturedeclares.
binary_metrics_fnhas nof1_opt. Model selection usespr_auc, so dropping it does notchange which checkpoint is chosen.
9. CXR layout variant and image directory
MIMIC4Datasettakescxr_variant. The runner never passed it, so every CXR run used thedefault layout. The resized set is the sunlab layout; the default config expects
studytime_normalized, which that set does not have:KeyError: 'studytime_normalized'.images. The resized set livesunder
resized_images, so all three CXR arms failed on a complete 377,110-image cohort. Bothnames are now accepted.
Validation
Primary result
Full scale MIMIC-IV in-hospital mortality: 144,586 train samples and 18,074 test samples. The split
is pinned across the arms. The metrics use the intersection of the test patients (n = 18,041),
because the two tasks accept slightly different cohorts (846 positives against 842). An
almost-paired comparison is not a paired comparison.
labs_onlynotes_labsThree seeds, prevalence 0.0467. The interval uses the t quantile for 2 degrees of freedom, which is
4.303, and not a normal quantile. With three seeds the difference is large.
PR-AUC is separable: the difference is approximately 37 times its interval. ROC-AUC, Brier and
log-loss all move in the same direction, but their intervals are wide at three seeds, so PR-AUC is
the metric that carries the claim.
Both runs use balanced sampling (
balanced_ratio=1.0) and leavepos_weightunset. This isrecorded in
run_config.jsonfor all six runs.Both arms used the same explicit settings,
--embedding-dim 128 --hidden-dim 128 --num-layers 2 --heads 4. This is a matched comparison at one setting and not a tuned comparison. Neither armreceived a hyperparameter search.
The presence confound is NOT yet controlled
A note is absent for a part of the cohort, and a missing note is a constant placeholder embedding.
The runner measures the association and reports it, but no run yet separates the two explanations
of the +0.1106.
Two controls are needed, and neither has been run: a baseline that receives the presence indicator
alone, and a comparison restricted to complete cases, where presence is constant and carries no
signal. Until those run, the correct statement is that
notes_labsis better thanlabs_only, andnot that the note content is the reason.
Chest X-ray
Complete cohort: 377,110 of 377,110 images present, 0 dropped. The physionet directory on the
cluster contains approximately 23% of the images. p10 and p11 are complete, p12 is at 24.5%, and
p13 to p19 are absent, so a run that reached a p13 patient stopped on a missing file. These runs use
the complete resized set, which is 256x256 greyscale and 3.3 GB.
Same backbone. One cohort, so the arms are paired. 18,542 train samples and 2,285 test samples, prevalence 0.0565, split seed 42 pinned, 6 epochs.
cxr_onlycxr+ labscxr+ notes + labsEach additional modality improves the result. Three limits: one seed for each arm, a CXR cohort that
is 13% of the full cohort because it needs a study inside the window, and 6 epochs against 20 for
the primary table. These numbers compare with each other and not with the primary table.
cxr_onlyis close to the prevalence (0.0602 against 0.0565). The advantage comes from thecombination of modalities and not from the images alone.
Provenance, verified on the six runs of the primary table
The splits and the code digest are identical, so the comparison is paired. A difference cannot come
from the composition of the split or from a change to the code.
Unit tests
File:
tests/test_run_directory_naming.py, 5 tests. Three fail on the pre-fix commit.File:
tests/test_run_provenance_and_pathways.py.resized_imagesas well asimagesThese pass on this branch and fail on
main.No regression
The full suite was run on
main, and onmainwith #46, #48 and this PR applied.mainmain+ #46 + #48 + this PREvery one of the 33 remaining failures is also present on
main. They come from the environment:test_tfm_tokenizerandtest_tuple_time_text_tokenizercannot build a fast tokenizer withoutsentencepiece, andtest_audio_processorcannot loadtorchaudioagainst this build of torch.Not in this PR
Two fusion baselines,
CrossAttentionFusionandSimpleLateFusion, are held for a later PR. Theyare separate architectures, and no result in this PR uses them.
Merge notes
pyhealth/tasks/multimodal_mimic4.py.Git reports a conflict there. Both classes are kept, and there is no shared code between them.
jc/medfuse-multimodal) adds a different MIMIC-IV EHR/CXR task. Two CXR tasks must notmerge independently. Please contact the author of Add MedFuse model + MIMIC-IV EHR/CXR mortality task + runner #44 before merge.
Question for the reviewer
The discharge-note policy uses admission-context section extraction, from Lee et al. (2023).
Retrieval covers the admission, and the extraction of admission-context sections is the temporal
control. The summary is written with knowledge of the outcome, and extraction reduces but does not
remove this condition. The alternative permits no information from after the prediction time, but it
removes approximately 90% of the notes, and it makes the presence of a note equivalent to a short
length of stay, which correlates with the outcome. Which default is correct for the paper?