Repository navigation
Fit processors on the training split in set_task (split=PatientSplit) - #1273
Merged
Merged
Conversation
SampleBuilder fitted input (or output) processors only when none were supplied. Supplying a pre-fitted processor for one field left every other field on that side without a processor, so its raw value (e.g. a Python list) reached the model with no error. This is the common fit-on-train, reuse-for-test pattern when a user reuses only some processors. - SampleBuilder.fit fits a processor for every schema field that has none; supplied processors are used as they are, never refitted. - SampleBuilder copies the supplied processor dicts, so fitting never adds keys to the caller's dict (e.g. the training set's processors). - tests/core/test_partial_processors.py: missing input and output processors are fitted, supplied ones are not refitted, and create_sample_dataset gives tensors for every field. - docs/api/processors.rst: "Reusing Fitted Processors". - examples/reuse_train_processors.py: fit on train, reuse for test. Co-Authored-By: Claude Opus 5.5 <[email protected]>
set_task fitted every processor (vocabularies, and any statistics a processor learns) on all samples, so validation and test patients shaped preprocessing. Splitting afterwards cannot refit. - New PatientSplit(ratios, seed): deterministic patient-level split (patients sorted by ID, then a seeded shuffle), 2 or 3 parts. - set_task(task, split=PatientSplit(...)) fits every processor on the training patients only and returns (train, val, test) or (train, test) as SampleDataset.subset views of one processed cache. - Streaming (S1): the split comes from patient_to_index, which fit() already builds; each processor then reads the same sequential sample stream through a masked view that skips other patients. No random access, nothing collected; the only new memory is the per-part int64 indices and a training mask (~9 bytes per sample). - Cache: split.npz holds the part indices; the processed-cache key adds the split spec only when given, so existing unsplit caches keep their key. schema.pkl records fit_split; SampleDataset.fit_split exposes it. - FeatureProcessor.learns_statistics (default False): when such a processor is fitted on all samples, SampleBuilder logs one warning pointing to split=. Vocabulary processors stay quiet. - tests/core/test_split_set_task.py (14): disjoint and complete parts, statistics from training patients (test shifted +100), test-only codes map to <unk>, fit_split recorded, cache reuse and per-seed caches, two-part split, unchanged unsplit cache key, supplied processors kept, the warning, invalid ratios, and fit streaming with a bounded peak. test_caching's signature check now includes split. - docs: splitter page section; processors page pointer. - examples/split_aware_set_task_mimic3demo.py and examples/benchmark_perf/benchmark_split_set_task.py (synthetic MIMIC-III at any scale, split vs no split, time and peak memory). Co-Authored-By: Claude Opus 5.5 <[email protected]>
_proc_transform gives each worker a contiguous range of task samples, and
patient_to_index / record_to_index are built in task order before
processing, so they assume processed sample i is task sample i.
_litdata_merge delegated to litdata's BinaryWriter.merge, which
concatenates the per-worker "{rank}.index.json" files in string order
(0, 1, 10, 11, ..., 19, 2, ...). With more than 10 workers that reorders
the worker blocks, so patient_to_index points at other patients' samples
and split_by_patient / set_task(split=...) return parts that share
patients. On full MIMIC-IV mortality with num_workers=32, 120,222 of
128,236 indices were wrong and ~3.6k patients appeared in both train and
val.
Merge the indexes ourselves, ordered by integer rank. Adds a unit test for
the merge with 12 ranks and an end-to-end 12-worker set_task test; both
fail on the old merge.
Caches built with more than 10 workers before this fix keep the wrong
order and are still reused, because the cache key does not change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
… merge
The previous commit fixed the worker-index merge order by reimplementing
BinaryWriter.merge in _litdata_merge. litdata 0.2.64 already sorts the
per-worker "{rank}.index.json" files numerically, so go back to calling
BinaryWriter.merge and require litdata >= 0.2.64 instead; this drops our
copy of litdata's index format.
Cap below 0.2.68: from 0.2.68 on, iterating SampleDataset.subset() parts
(what split_by_patient and split= return) on a cache built with 0.2.68+ is
~10-35x slower (MIMIC-IV train part of a 0.2.76-built cache: 28-35 s with
0.2.68-0.2.76 vs 1.5-3.1 s with 0.2.64-0.2.67).
The regression tests from the previous commit are unchanged: they fail
with litdata 0.2.59 and pass with 0.2.64, 0.2.67 and 0.2.76. On full
MIMIC-IV with num_workers=32 and litdata 0.2.67, unmodified master and
this branch both give 0 misplaced samples and 0 patients shared between
parts.
pixi.lock is not updated here: it is already out of date with
pyproject.toml, so re-locking also moves torch, dask and the CUDA wheels;
that is a separate PR.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
set_task(task, split=...) uses two methods of the split: split_indices() to get the sample indices of each part (part 0 is the training part the processors are fitted on) and to_dict() for the cache key and fit_split. Define them on an abstract Split class so custom splits have a documented contract; PatientSplit is now a Split and the reference implementation. set_task uses the parts exactly as returned: they may overlap or leave samples out. PatientSplit accepted only 2 or 3 ratios, although nothing downstream needs that. It now takes two or more ratios, training part first, e.g. (train, val, cal, test). The ratios must still be non-negative and sum to 1; each check has its own error message. Tests: a 4-part PatientSplit, a custom held-out-patients Split through set_task (test-only code maps to <unk>), and a custom Split with overlapping parts that set_task returns as given. Docs gain a "Writing your own split" section. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The split_by_* functions return dataset.subset(...) views, which are SampleDatasets over the same processed samples, but their docstrings still described torch.utils.data.Subset objects with a .dataset attribute. Describe the actual return values, document get_index on split_by_sample, add a usage example to each (required by tools/check_pr_rules.py for modified public functions), and add a note that the dataset's processors were fitted on all samples, with a pointer to set_task(split=...) with PatientSplit or a custom Split. PatientSplit's docstring now says how it differs from split_by_patient: parts are chosen before fitting, and split_by_patient shuffles patients in dataset order, so it generally picks different patients for the same ratios and seed. Docstrings only; no behavior change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
jhnwu3
approved these changes
Oct 7, 2026
jhnwu3
left a comment
Collaborator
There was a problem hiding this comment.
I've added a couple of additional fixes to the problem here
- Added a Split() abstract class. There are many different types of splits that exist out there that move beyond just a 3 way split. Other uses
- The doc strings should probably highlight more of the necessary functions here.
- Bumped up the litdata version, so we no longer have a bug that unintentionally leaks patient data at higher worker counts.
This was referenced Oct 7, 2026
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.
Implements option A + S1 from the design note on fitting processors on the training split, as agreed.
What it does
<unk>.SampleDataset.subsetviews over one processed cache: (train, val, test), or (train, test) for two ratios.PatientSplitsorts patient IDs before the seeded shuffle, so it gives the same patients on every run and machine.set_task(task)withoutsplitfits on all samples and returns one dataset, with the same cache key as before.FeatureProcessor.learns_statistics(defaultFalse). When a processor with the flag set is fitted on all samples, one warning points tosplit=. Vocabulary processors stay quiet. Nothing in core sets it yet; Add training-fitted normalization to time-series processors #1231's normalization would.Streaming (S1)
patient_to_index, whichmasteralready keeps in memory and pickles intoschema.pkl.int64index array per part plus a boolean training mask: about 9 bytes per sample.master's fit decodes.split.npzstores the part indices, written before the samples, whoseindex.jsonmarks the cache complete. The processed-cache key gains the split spec only whensplitis given.Benchmarks
Synthetic MIMIC-III-format data, so no patient data was used. Generated and run by the new
examples/benchmark_perf/benchmark_split_set_task.py, withMIMIC3DatasetandMortalityPredictionMIMIC3. Each mode ran in its own process with a fresh cache. Peak memory is the process plus its workers, sampled every 0.1 s. Machine: 10-core Mac, 34 GB.set_tasktimeset_taskpeak memoryWhole-pipeline peaks are noisy. The event-table stage, identical code in both modes, peaked at 2,856 vs 4,234 MiB at 20k. So I also isolated
SampleBuilder.fiton the real 100k task output, 3 repetitions each in one process:patient_to_index(exists on master)For laptops, extrapolating linearly:
masteralready holds is about 36 MB per million samples.Correctness at scale (300k, 4 workers). I reloaded the split from cache (3.2 s), streamed all 749,901 samples, and compared each part's streamed patients with its index:
To run the same on a laptop:
python examples/benchmark_perf/benchmark_split_set_task.py --patients 100000 # 1 worker python examples/benchmark_perf/benchmark_split_set_task.py --patients 300000 --num-workers 4 --workdir /tmp/benchTests
New
tests/core/test_split_set_task.py(14 tests) runs on a config-based dataset where test patients' values are shifted by +100 and carry a test-only code. It covers:<unk>;fit_splitrecorded;len()is called on the stream).A mutation check confirmed the tests catch leakage: forcing fit on all samples makes 4 of them fail. The
set_tasksignature check intest_caching.pynow includessplit.Full core suite: 1,415 tests. The one failure was that pinned-signature test, which is updated here; it and the split tests then pass.
tools/check_pr_rules.pypasses. The docs get a section on the splitter page and a pointer on the processors page, and there's a new runnableexamples/split_aware_set_task_mimic3demo.py.Not in this PR
split=values.🤖 Generated with Claude Code