Guard pretrained transfer, normalise content scale, and fit the standardiser on the full split - #48
Open
Rian354 wants to merge 10 commits into
Open
Guard pretrained transfer, normalise content scale, and fit the standardiser on the full split#48Rian354 wants to merge 10 commits into
Rian354 wants to merge 10 commits into
Conversation
…ser on the full split Four defects gave results that looked correct and were not. A checkpoint that matched 6 of 30 backbone tensors trained a mostly random backbone. The loader built the full target state dict, overwrote the keys that matched, then called strict=False, so PyTorch reported no missing keys. load_pretrained_state_dict now maps the backbone name for each architecture and requires full coverage. The content term reached a norm of 761 for raw laboratory values against 3.2 for a BERT [CLS] vector, through the same code path. The constant time and type terms then dominated the text channel: cosine similarity across patients was 0.9953. normalize_content applies a parameter-free layer_norm to the content term before the additive terms, so an existing checkpoint still loads. SampleDataset subclasses litdata.StreamingDataset, which divides __len__ and __iter__ by WORLD_SIZE. torchrun sets WORLD_SIZE but not GLOBAL_RANK, so every rank fitted statistics on the same first 1/N of the train split. The fit now reads patient_to_index, which WORLD_SIZE does not divide. max_length had the default 128, which cut 95% of extracted discharge notes to about one fifth of their length. It is now 512, the position-embedding limit of Bio_ClinicalBERT, and padding is per batch rather than a fixed width. Also: SSL pretraining now freezes the text encoder by default, to match the downstream path and to let the [CLS] cache operate; the cache is keyed on the token identifiers and the attention mask, is bounded, and only serves a frozen field. This supersedes draft #45, whose package is included here.
…s from the right field
The collator pads short samples to the longest in the batch and fills value and
time with 0.0. Nothing recorded that padding. No processor emits an event-level
mask either, so the unified embedding took the `mask is None` branch and marked
every slot valid. A padded slot then looked exactly like a real measurement
taken at admission time.
Ordering made it worse. Padding carries time 0.0, so the ascending sort placed
it BEFORE every real event, while all three backbone families assume the
opposite: RNNLayer packs the first mask.sum() steps, get_last_visit indexes
mask.sum() - 1, and TransformerLayer reads position 0 as its CLS vector.
The collator now emits pad_mask for each temporal field, because the collator
creates the padding and nothing else can know about it. The unified embedding
uses it for event validity, sorts invalid slots past every real one, and zeroes
their embeddings. The sort is now stable: the key is heavily tied, since all
padding shares time 0.0 and events from one admission share offsets, and an
unstable sort changes RNN and Mamba outputs between torch builds and between
CPU and CUDA.
pad_mask is deliberately not called mask. A field may carry its own
{field}_mask meaning "was this value observed", which is a different question
from "is this slot real". The standardiser needs the former, and it was reading
feat_dict["mask"], which no processor has ever populated, so it raised on every
run that enabled it. It now reads the sibling {field}_mask field.
Restoring the zero-length clamp in RNNLayer is required by the above. While
every mask was all ones, a zero length was unreachable; a correct mask makes a
sample with no valid event reachable, and pack_padded_sequence rejects it.
Found while reviewing the same defects in the upstream consolidation branch.
…t index map The fit was driven by patient_to_index. SampleDataset.subset copies that map unchanged, so after split_by_patient it still holds indices into the PARENT dataset while __getitem__ is restricted to the subset's own region. Fitting on a real training split therefore raised: ValueError: The provided index 237 didn't find a match within the chunk intervals [Interval(chunk_start=0, roi_start_idx=135, roi_end_idx=136, chunk_end=297), ...] region_of_interest is the only unsharded description of what a dataset actually holds, and it is correct for a subset as well as for a full dataset. Measured against real litdata with 300 samples: the full fit sees 300, an ROI-restricted split sees its own 40, and under WORLD_SIZE=4 the fit still sees 300 with statistics identical to the single-process fit. This preserves the property the original change was for. len() and __iter__ are still sharded by WORLD_SIZE; indexing and the region of interest are not. Caught by a smoke run on real MIMIC-IV. The unit tests passed because their fake dataset modelled provenance through patient_to_index, which is the mechanism that turned out to be wrong; the fake now models the litdata contract that was verified against real litdata.
The previous commit added pad_mask to collate_temporal. Nothing calls it.
get_dataloader uses collate_fn_dict_with_padding in pyhealth/datasets/utils.py,
so the padding fix was inert in production while its unit tests passed, because
those tests called collate_temporal directly.
collate_fn_dict_with_padding now reports which event slots are real, under the
parallel key {field}__pad_mask. _build_unified_inputs keys the field dict off
schema(), and no processor's schema contains "mask", so the validity has to
arrive on a separate key or it never reaches the model. All four backbones that
build unified inputs now thread it through.
A batch whose rows are already equal length is not padded, and no mask is
invented for it.
Three new tests cover the real path: the dataloader collate records padding, an
unpadded batch reports none, and the backbone threads it into the unified
inputs. The last of these is the test that would have caught the inert fix.
Found by a smoke run on real MIMIC-IV.
Contribution 5 changed the text processor to pad to the longest note in a sample rather than to a fixed max_length, so a note of 4 tokens no longer costs 512 slots. That makes the token dimension vary across samples as well as the event dimension, and pad_sequence pads dimension 0 only and requires every trailing dimension to match already: RuntimeError: The size of tensor a (7) must match the size of tensor b (14) at non-singleton dimension 1 _pad_stack right-pads every dimension to the batch maximum and reduces to pad_sequence when only dimension 0 differs. This is the other half of the token-budget change and should have shipped with it. Caught by a smoke run on real MIMIC-IV notes; no unit test tokenised two samples of different length into one batch.
The previous commit made the event-validity variable prefer pad_mask. The TEXT
branch reuses that same variable as the attention mask it hands to the encoder,
so an event-level (B, N) mask reached a view that expects token level:
RuntimeError: shape '[96, 512]' is invalid for input of size 96
Three masks meet in this loop and are now named apart: mask is token level from
the processor schema and is what a text encoder needs; pad_mask is event level
from the collator and says which slots are real; {field}_mask is a separate
field meaning "was this value observed" and belongs to the standardiser.
Event validity now prefers the collator's report and falls back to reducing the
token mask, with the nested-CODE flattening handled.
The collator pads each row to the widest note in its batch, and batch composition changes every epoch because the loader shuffles. A key over the padded row therefore gave one note a different key each epoch, so the cache never hit. Measured on the full-scale notes run: epoch time did not fall after the first epoch, and rose slightly as the miss path also paid for hashing: 3458s, 3936s, 4048s, 3835s The key now covers only the positions the attention mask marks real, which is invariant to how wide the batch happened to be.
…t map The fit has read region_of_interest since the subset-index fix. The error messages and one test docstring still said patient_to_index, which is the mechanism that raised on a real training split. Co-authored-by: Cursor <cursoragent@cursor.com>
NestedSequenceProcessor used padding_idx=None so empty visits could have a non-zero vector. Index 0 then received gradients, which moved a real code that shared the pad slot. Empty visits are now zero events, so the pad row stays frozen zeros. The test checks both the zeros and a zero gradient on that row. Co-authored-by: Cursor <cursoragent@cursor.com>
The other unified heads already forwarded {field}__pad_mask. MLP did not, so a padded lab slot still looked like a real measurement at time 0. The new test is the same collate-to-inputs check that would have caught the inert Transformer fix.
Co-authored-by: Cursor <cursoragent@cursor.com>
Author
|
Nested code embeddings keep |
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
Six defects in the encoder and tokenisation path produced results that looked correct and were
not. A pretrained checkpoint trained a random backbone and reported no missing keys. A text
channel was 94% constant across patients. A standardiser fitted on 1/N of the train split under
torchrun. A note was cut to one fifth of its length.Batch padding was the worst of them. No processor emits an event-level mask, so the unified
embedding marked every slot valid, and padding sorted ahead of every real event. All three backbone
families then read it.
This PR repairs them, and it includes the SSL pretraining package so that the frozen-encoder
correction has a file to change. It supersedes draft #45.
The pretraining section is deliberately short. Pretraining is not yet useful in practice here, and
this PR makes no claim that a pretrained model is better than a model trained from the start.
Depends on #46.
scripts/pretrain_ssl.pyimportsLabsOnlyMIMIC4, andtests/test_pretrain.pyimports
sample_oversample. Both arrive in #46. Merge #46 first.Implemented
1. Strict transfer of a pretrained checkpoint
pyhealth/models/base_model.py_unified_backbonefor transformer,_unified_jambafor jamba,_unified_blocksfor ehrmamba.model.state_dict(), overwrote the keys that matched, thencalled
strict=False. Every key was present, so PyTorch reported no missing keys.backbone that was mostly random, and the run looked correct.
load_pretrained_state_dictmaps the keys for each architecture, refuses a shape mismatch,requires full backbone coverage, and returns the tensor counts.
_checkpoint_config,_dummy_param) are not counted. To count them makesa complete load appear partial. A
TransformerLayercarries_checkpoint_config, which alonegives 24/25 = 0.96 and fails
min_backbone_match=1.0.2. Scale-safe unified embedding
pyhealth/models/embedding/unified.pyfinal = (content + time_emb + type_emb) * validity. The measured norms atembedding_dim=128:time_embSinusoidalTimeEmbedding(t)type_embnn.Embedding(n_modalities, D)[CLS]through a defaultLinear(768, D)Linear(10, D)the output scale of its encoder and not from a modelling decision.
0.9215 for the content alone and 0.9953 for the content with the constant terms. The signal
was 5.9% of the squared norm of the final vector.
normalize_contentappliesF.layer_normto the content term before the additive terms.3. Standardiser that reads the full train split
pyhealth/processors/lab_standardizer.py(new), wired into the numeric path ofunified.pySampleDatasetis a subclass oflitdata.StreamingDataset._DistributedEnvdivides both__len__and__iter__byWORLD_SIZE.torchrunsetsWORLD_SIZEbut does not setGLOBAL_RANK, andtorch.distributedis not initialised when the dataset is built. Every ranktherefore reported
global_rank=0and fitted the same first part.Measured against real litdata with 20 samples:
len()WORLD_SIZE=4torchrun. On 4 GPUs the statistics came from 25% of the split, andthe digest recorded
train/4samples. The single-process downstream runner could never matchthat digest.
region_of_interest, whichWORLD_SIZEdoes not divide. An earlier attemptused
patient_to_index.SampleDataset.subsetcopies that map from the parent, so aftersplit_by_patientit still holds parent indices while__getitem__is restricted to thesubset. Fitting on a real training split raised
ValueError: index 237 didn't find a match within the chunk intervals. The fit refuses to iterate aStreamingDatasetthat has noregion of interest.
transform after it cannot correct a feature whose physical unit gives it 300 times the magnitude
of another.
state_dict. A checkpoint applies at inference thesame transform that it trained under.
4. Frozen text encoder during pretraining
scripts/pretrain_ssl.py,configs/pretrain/base.yamlfreeze_encoder: false. Every downstream run uses--freeze-encoder.inference then discards. The
[CLS]cache serves only a frozen field, so the cache could notoperate.
laboratory values. A 12-hour limit stopped the run at 5 of 10 epochs.
true, and--no-freeze-encoderselects the previous behaviour.5. Token budget of 512
pyhealth/processors/tuple_time_text_processor.pymax_lengthhad the default 128. This cuts 95% of the extracted discharge notes toapproximately one fifth of their length. Measured on 4,017 samples.
without a long-context tokenizer now gives an error. Before this change, Hugging Face reduced the
value in silence.
longestfor each batch and not a fixed width. A note of 4 tokens no longer costs512 slots.
6. Cache for the frozen text encoder
pyhealth/models/embedding/unified.pymillion parameters 50 times.
_frozen_text_fields, so a trainableencoder can never read it. The key covers only the tokens the attention mask marks real, so
batch padding cannot give the same note a different key each epoch. A key over the padded row
never hit under shuffling: epoch times on the full-scale notes run were 3458s, 3936s, 4048s,
3835s. The cache has a maximum size, and it calculates a row again when the cache is full.
batch holds many identical rows.
train()sets a frozen encoder to.eval(). Without this, dropout changes the output betweenpasses, and the cache becomes an approximation instead of an identity.
7. Batch padding is masked in the unified sequence
pyhealth/datasets/collate.py,pyhealth/models/embedding/unified.py,pyhealth/models/rnn.pyNothing recorded that padding. No processor emits an event-level mask either, so the unified
embedding took the
mask is Nonebranch and marked every slot valid. A padded slot thenlooked exactly like a real measurement taken at admission time.
every real event, while all three backbone families assume the opposite:
RNNLayerpacks thefirst
mask.sum()steps,get_last_visitindexesmask.sum() - 1, andTransformerLayerreads position 0 as its CLS vector.
pad_masktocollate_temporal, which has zero callers. Thedataloader uses
collate_fn_dict_with_padding. That function now reports{field}__pad_mask._build_unified_inputskeys the field dict offschema(), so validity has to arrive on aseparate key or it never reaches the model. All four backbones that build unified inputs thread
it through. The unified embedding uses it for event validity, sorts invalid slots past every
real one, and zeroes their embeddings.
events from one admission share offsets. An unstable sort changes RNN and Mamba outputs between
torch builds and between CPU and CUDA.
pad_maskis deliberately not calledmask. A field may carry its own{field}_maskmeaning"was this value observed", which is a different question from "is this slot real".
8. The standardiser reads the observation field
pyhealth/models/embedding/unified.pyfeat_dict["mask"]for observation flags. No processor has everpopulated that key, so the standardiser raised on every run that enabled it:
ValueError: The standardiser for 'labs' needs a paired labs_mask field.sibling
{field}_maskfield, which is where the observation flags actually live.9. Zero-length guard in
RNNLayerpyhealth/models/rnn.pycorrect mask makes a sample with no valid event reachable, and
pack_padded_sequencerejectsit.
lengthsis clamped to 1, so the caller sees a finite value instead of a crash.10. Pad every ragged dimension, not only dimension 0
pyhealth/datasets/utils.pymax_length. Thatmakes both the event dimension and the token dimension vary across samples.
pad_sequencepadsdimension 0 only and requires every trailing dimension to match, so a batch of notes raised
RuntimeError: The size of tensor a (7) must match the size of tensor b (14)._pad_stackright-pads every dimension to the batch maximum.11. Token mask and padding mask are not the same variable
pyhealth/models/embedding/unified.pypad_maskfor event validity reused that(B, N)tensor as the attention maskhanded to the text encoder, which expects token level:
RuntimeError: shape '[96, 512]' is invalid for input of size 96.maskis token level from theprocessor;
pad_maskis event level from the collator;{field}_maskis "was this valueobserved" and belongs to the standardiser.
Validation
The cache does not change the result
Full scale, 144,586 train samples, the same configuration with and without
--no-text-cache:The two runs agree to four decimal places across four epochs.
SSL encoders complete on corrected data
Transformer backbone, 128 dimensions, 2 layers, 4 heads, 10 epochs, campus cluster.
labs_onlyMAElabs_onlySimMIMnotes_labsMAEnotes_labsSimMIMAll four completed. The decrease from 1618 to 1176 seconds was measured before the padded-key
fix above. A later full-scale supervised run did not show that decrease, because shuffle changed
the pad width every epoch. Against the previous attempt with a trainable encoder: 5 of 10 epochs
at approximately 8,600 seconds each, stopped by the 12-hour limit.
One result is not clean:
notes_labsMAE increases at epoch 2, from 0.6297 to 0.6443, and thendecreases.
Unit tests
File:
tests/test_unified_padding.py. Later commits added tests for the dataloader collate, anunpadded batch, and the backbone threading
pad_maskinto unified inputs. The last of those isthe test that would have caught the inert
collate_temporalfix.File:
tests/test_encoders_and_tokenisation.py. Later commits added a fit-on-a-split test and atest that the frozen-text cache key ignores batch padding.
WORLD_SIZEdoes not change the statistics, and the statistics are instate_dict15 of the 16 fail on
main. The one that passes,test_content_normalisation_restores_separation_between_patients, demonstrates the mechanism withtensors and calls no library code, so it is a description and not a guard.
tests/test_pretrain.py::test_load_pretrained_into_downstream_transformernow runs a real SimMIMcheckpoint into a real
Transformerthrough the guarded loader and asserts full backbone coverage.It previously called a private helper in the end-to-end runner.
No regression
The full suite was run two times: on
mainalone, and onmainwith #46 and this PR applied.mainmain+ #46 + this PRThe 34 remaining failures are also present on
main. They come from the environment and not fromthe code:
test_tfm_tokenizerandtest_tuple_time_text_tokenizercannot build a fast tokenizerwithout
sentencepiece, andtest_audio_processorcannot loadtorchaudioagainst this build oftorch.
One LIME test differed between the two runs in each direction. Both pass 4 times of 4 in isolation
on both trees, so the difference comes from the order of the tests and from the global random
state, and not from this PR.
What this PR does not claim
No comparison between a pretrained model and a model trained from the start. The 26 encoders from
earlier work are superseded. They were trained on raw laboratory values, on notes that were
approximately 90% placeholder, and any encoder trained under
torchrunholds statistics from 1/Nof the split. The transfer guard refuses them, and that is correct.
Questions for the reviewer
layer_normthe correct correction? It also raises a low-signal modalityto equal scale.
fit_scopedigest too strict? A nested tuning subsplit can never reproduce theSSL split digest.
freeze_encodernow has the defaulttruefor pretraining. Is any planned run dependent on theprevious default?