Skip to content

Fit processors on the training split in set_task (split=PatientSplit) - #1273

Merged
jhnwu3 merged 6 commits into
sunlabuiuc:masterfrom
solarsys:feat/split-aware-set-task
Oct 7, 2026
Merged

jhnwu3 merged 6 commits into
sunlabuiuc:masterfrom
solarsys:feat/split-aware-set-task

Conversation

@solarsys

@solarsys solarsys commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #1266 (partial processors), so this diff includes its commit. Merge #1266 first; this PR's own change is the last commit, 9ce56ba.

Implements option A + S1 from the design note on fitting processors on the training split, as agreed.

What it does

from pyhealth.datasets import PatientSplit

train, val, test = dataset.set_task(task, split=PatientSplit(ratios=(0.7, 0.1, 0.2), seed=42))
train.fit_split   # {'kind': 'patient', 'ratios': [0.7, 0.1, 0.2], 'seed': 42}
  • Fitting: every processor is fitted on the training patients only, vocabularies included, so test-only codes become <unk>.
  • Processing: validation and test are processed with those frozen processors.
  • Return value: a tuple of SampleDataset.subset views over one processed cache: (train, val, test), or (train, test) for two ratios.
  • Determinism: PatientSplit sorts patient IDs before the seeded shuffle, so it gives the same patients on every run and machine.
  • Default unchanged: set_task(task) without split fits on all samples and returns one dataset, with the same cache key as before.
  • New flag FeatureProcessor.learns_statistics (default False). When a processor with the flag set is fitted on all samples, one warning points to split=. Vocabulary processors stay quiet. Nothing in core sets it yet; Add training-fitted normalization to time-series processors #1231's normalization would.

Streaming (S1)

  1. Index pass: unchanged. It builds patient_to_index, which master already keeps in memory and pickles into schema.pkl.
  2. Split: from that index. Patients are sorted, shuffled and cut by ratio. The only new memory is one int64 index array per part plus a boolean training mask: about 9 bytes per sample.
  3. Fit: each processor reads the same sequential stream through a masked view that skips other patients. There is no random access and nothing is collected, and it decodes exactly what master's fit decodes.
  4. Process: unchanged, one streamed pass.
  5. Cache: split.npz stores the part indices, written before the samples, whose index.json marks the cache complete. The processed-cache key gains the split spec only when split is 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, with MIMIC3Dataset and MortalityPredictionMIMIC3. 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.

Patients Samples Event rows Workers Mode set_task time set_task peak memory
20,000 49,884 1.4M 1 no split / split 116 s / 105 s 627 / 768 MiB
100,000 250,144 7.2M 1 no split / split 551 s / 541 s 1,482 / 1,461 MiB
300,000 749,901 21.5M 4 no split / split 243 s / 245 s 5,753 / 5,358 MiB

Whole-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.fit on the real 100k task output, 3 repetitions each in one process:

250,144 samples, 83,146 patients No split Split
Fit time (median) 46.1 s 45.2 s
Peak Python allocations during fit 91.9 MiB 85.4 MiB
patient_to_index (exists on master) 8.6 MiB, 36 bytes/sample same
Added by S1 (index arrays + mask) — 2.1 MiB, ~9 bytes/sample

For laptops, extrapolating linearly:

  • Patient index: the one master already holds is about 36 MB per million samples.
  • S1: adds about 9 MB per million samples.
  • The real peak in these runs comes from the event-table build and the task workers, which this PR doesn't change.

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:

  • The parts match their indices exactly: 174,902 / 24,986 / 49,973 patients.
  • Zero patients appear in more than one part.
  • The sample counts add up to the unsplit total at every scale.

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/bench

Tests

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:

  • parts that are disjoint and complete;
  • the mean-learning processor's mean equalling the training mean;
  • test-only codes mapping to <unk>;
  • fit_split recorded;
  • a rerun reusing the cache and the same split;
  • another seed giving another split and cache;
  • the two-part split;
  • the unsplit cache key unchanged;
  • supplied processors kept;
  • the warning (and none for vocabulary-only);
  • invalid ratios;
  • fit with a split staying under 5 MiB of peak allocations on a ~50 MB lazy stream (it also fails if 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_task signature check in test_caching.py now includes split.

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.py passes. The docs get a section on the splitter page and a pointer on the processors page, and there's a new runnable examples/split_aware_set_task_mimic3demo.py.

Not in this PR

🤖 Generated with Claude Code

solarsys and others added 2 commits October 2, 2026 20:58
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]>
@solarsys
solarsys requested a review from jhnwu3 October 6, 2026 07:09
jhnwu3 and others added 4 commits October 6, 2026 12:43
_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 jhnwu3 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I've added a couple of additional fixes to the problem here

  1. 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
  2. The doc strings should probably highlight more of the necessary functions here.
  3. Bumped up the litdata version, so we no longer have a bug that unintentionally leaks patient data at higher worker counts.

@jhnwu3
jhnwu3 merged commit e94ca1d into sunlabuiuc:master Oct 7, 2026
2 checks passed
@solarsys
solarsys deleted the feat/split-aware-set-task branch October 9, 2026 04:30
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