Repository navigation
Make training reproducible: seeded per-loader shuffles, no global RNG side effects - #1280
Conversation
a9ed036 to
d983646
Compare
… side effects A fixed torch/numpy seed did not make PyHealth training reproducible: - InMemorySampleDataset shuffled with Python's global `random`, which torch.manual_seed and np.random.seed do not touch. - litdata's DataProcessor.run (used by create_sample_dataset(in_memory=False)) reseeds random/numpy/torch to 42 in the calling process, and set_task's cache write consumed Python's `random`; runs differed by whether the cache already existed. - The disk-backed shuffle ignored the user's seed. - get_dataloader changed the shared dataset's shuffle flag, so an evaluation loader turned off shuffling for a training loader. - With enable_logging=False the best epoch was never restored. - Importing pyhealth.models reseeded numpy and torch (gnn.py). Changes: - get_dataloader(..., seed=None): each loader owns its shuffle flag, seed and epoch counter (a view; the dataset is not modified). Each pass is a new deterministic order from (seed, epoch): NumPy default_rng in memory, litdata's own (seed, epoch) shuffle on disk via a shallow copy. With seed=None the seed is drawn from torch's generator at creation, so torch.manual_seed alone makes runs reproducible. A private torch.Generator keeps DataLoader's per-pass base-seed draw off the global state. set_shuffle(shuffle, seed=None) on datasets is seeded the same way. - pyhealth.utils.preserve_rng_state() saves/restores random, numpy, torch and CUDA states; set_task and create_sample_dataset are wrapped in it. - Trainer.train keeps a CPU copy of the best weights when there is no output directory and restores them (keep_best_in_memory=False opts out, with a warning). - No library code seeds global RNGs any more: removed gnn.py's import-time seeding (from sunlabuiuc#1227); interpret SHAP/LIME/RandomBaseline, SCRIB search, train_lstm_model and the KG splitter now seed only for the call. set_seed documents GPU flags and that PYTHONHASHSEED only affects subprocesses. - Docs: "Reproducible Runs" (trainer), "Shuffling and Reproducibility" (datasets), GNN note; example examples/reproducible_training.py. Tests (tests/core/test_reproducibility.py): seeded orders on both backends (same seed, different seeds, per-epoch reshuffle, restart, global RNG untouched, torch seed alone), loader independence, no RNG change from set_task (fresh and cached) or disk dataset creation, import side effects, seeded interpreter, best-epoch restore with and without logging plus the opt-out, and bit-identical MLP/RNN predictions across separate processes in memory and on disk (with and without an existing cache). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d983646 to
f8e4c07
Compare
There was a problem hiding this comment.
What the PR is doing
The PR is trying to make two training runs with the same seeds produce the same results. The author says held-out PR-AUC moved by up to 0.06 between identical runs. The six causes it lists boil down to:
InMemorySampleDataset.__iter__usedrandom.shuffle. That's Python's globalrandom, whichtorch.manual_seedandnp.random.seeddon't touch.- litdata calls
random.seed(42),np.random.seed(42)andtorch.manual_seed(42)in the calling process when it writes a cache. So whether your seed survived depended on whether the cache already existed. - The disk-backed (litdata) shuffle ignored the user's seed.
- Shuffle state lives on the dataset object.
get_dataloadercallsdataset.set_shuffle(shuffle), which changes the shared dataset. - Early stopping without logging didn't restore the best epoch.
gnn.pyreseeded NumPy when it was imported.
Why the custom IterableDataset (_LoaderView)
_LoaderView only addresses #4.
Today, SampleDataset is a litdata StreamingDataset, which is an IterableDataset. DataLoader can't apply a sampler or shuffle=True to an iterable dataset, so the shuffle has to happen inside the dataset's __iter__. That's why PyHealth uses a set_shuffle flag on the dataset. But the flag belongs to the dataset object, so:
train_loader = get_dataloader(ds, 32, shuffle=True)
eval_loader = get_dataloader(ds, 32, shuffle=False) # turns off shuffling for train_loader too_LoaderView is a thin wrapper that gives each loader its own (shuffle, seed, epoch). Its __iter__ calls dataset._iter_for_loader(shuffle, seed, epoch), which works on a copy and leaves the real dataset alone. It has to subclass IterableDataset so that DataLoader keeps calling __iter__ instead of trying index-based access. The __getattr__ passes through attributes like processors and input_schema. The epoch counter is there so each pass gives a new order that is still deterministic.
Is it worth it?
I'd argue mostly no:
- #4 rarely happens in practice. The normal workflow is
split_by_patient→ three separate dataset objects → three loaders. Two loaders on the same dataset object, one shuffled and one not, is unusual. - The reproducibility fix doesn't need the wrapper. Fixes #1–3 come down to replacing
random.shufflewithnp.random.default_rng([seed, epoch]), passing the seed to litdata, and wrappingset_task/create_sample_datasetinpreserve_rng_state(). All of that fits in the existingset_shuffle(shuffle, seed)plus an epoch counter on the dataset. - It adds hidden complexity:
loader.datasetis no longer aSampleDataset, soisinstancechecks will fail. Attribute access only works through the__getattr__pass-through. I checked our tree and found noloader.datasetusages today, but user code might have some.- The epoch counter likely breaks with
num_workers > 0. I reasoned this from how DataLoader works; I didn't run it. Without persistent workers, DataLoader sends a copy of the dataset to the worker processes on every pass and calls__iter__there. Soself.epoch += 1updates the workers' copies, the main process stays at epoch 0, and every epoch gets the same order.get_dataloaderdoesn't passnum_workerstoday, so it's dormant, but nothing stops it from being hit later. _iter_for_loadershallow-copies a litdataStreamingDatasetand sets private fields (shuffler,_state_dict,current_epoch). That relies on litdata internals.
Suggestion
- Keep: the seeded shuffles (#1–3),
preserve_rng_state, removing the import-time and library-level global seeding, and the Trainer best-weights fix. These are real bugs with clear fixes. - Drop
_LoaderView: put the seed and epoch onset_shuffle/the dataset instead. If the shared-dataset problem matters, document that one dataset object shouldn't back two loaders with different shuffle settings, or raise a warning when it happens.
That would remove most of the utils.py diff you linked and keep the actual reproducibility gains.
Tldr; It's unclear what the _LoaderView here does that improves reproducibility here.
jhnwu3
left a comment
There was a problem hiding this comment.
What the PR is doing
The PR is trying to make two training runs with the same seeds produce the same results. The author says held-out PR-AUC moved by up to 0.06 between identical runs. The six causes it lists boil down to:
InMemorySampleDataset.__iter__usedrandom.shuffle. That's Python's globalrandom, whichtorch.manual_seedandnp.random.seeddon't touch.- litdata calls
random.seed(42),np.random.seed(42)andtorch.manual_seed(42)in the calling process when it writes a cache. So whether your seed survived depended on whether the cache already existed. - The disk-backed (litdata) shuffle ignored the user's seed.
- Shuffle state lives on the dataset object.
get_dataloadercallsdataset.set_shuffle(shuffle), which changes the shared dataset. - Early stopping without logging didn't restore the best epoch.
gnn.pyreseeded NumPy when it was imported.
Why the custom IterableDataset (_LoaderView)
_LoaderView only addresses #4.
Today, SampleDataset is a litdata StreamingDataset, which is an IterableDataset. DataLoader can't apply a sampler or shuffle=True to an iterable dataset, so the shuffle has to happen inside the dataset's __iter__. That's why PyHealth uses a set_shuffle flag on the dataset. But the flag belongs to the dataset object, so:
train_loader = get_dataloader(ds, 32, shuffle=True)
eval_loader = get_dataloader(ds, 32, shuffle=False) # turns off shuffling for train_loader too_LoaderView is a thin wrapper that gives each loader its own (shuffle, seed, epoch). Its __iter__ calls dataset._iter_for_loader(shuffle, seed, epoch), which works on a copy and leaves the real dataset alone. It has to subclass IterableDataset so that DataLoader keeps calling __iter__ instead of trying index-based access. The __getattr__ passes through attributes like processors and input_schema. The epoch counter is there so each pass gives a new order that is still deterministic.
Is it worth it?
I'd argue mostly no:
- #4 rarely happens in practice. The normal workflow is
split_by_patient→ three separate dataset objects → three loaders. Two loaders on the same dataset object, one shuffled and one not, is unusual. - The reproducibility fix doesn't need the wrapper. Fixes #1–3 come down to replacing
random.shufflewithnp.random.default_rng([seed, epoch]), passing the seed to litdata, and wrappingset_task/create_sample_datasetinpreserve_rng_state(). All of that fits in the existingset_shuffle(shuffle, seed)plus an epoch counter on the dataset. - It adds hidden complexity:
loader.datasetis no longer aSampleDataset, soisinstancechecks will fail. Attribute access only works through the__getattr__pass-through. I checked our tree and found noloader.datasetusages today, but user code might have some.- The epoch counter likely breaks with
num_workers > 0. I reasoned this from how DataLoader works; I didn't run it. Without persistent workers, DataLoader sends a copy of the dataset to the worker processes on every pass and calls__iter__there. Soself.epoch += 1updates the workers' copies, the main process stays at epoch 0, and every epoch gets the same order.get_dataloaderdoesn't passnum_workerstoday, so it's dormant, but nothing stops it from being hit later. _iter_for_loadershallow-copies a litdataStreamingDatasetand sets private fields (shuffler,_state_dict,current_epoch). That relies on litdata internals.
Suggestion
Ask the author to split the PR:
- Keep: the seeded shuffles (#1–3),
preserve_rng_state, removing the import-time and library-level global seeding, and the Trainer best-weights fix. These are real bugs with clear fixes. - Drop
_LoaderView: put the seed and epoch onset_shuffle/the dataset instead. If the shared-dataset problem matters, document that one dataset object shouldn't back two loaders with different shuffle settings, or raise a warning when it happens.
That would remove most of the utils.py diff you linked and keep the actual reproducibility gains.
Review feedback on sunlabuiuc#1280: the _LoaderView wrapper hid the dataset behind loader.dataset, relied on litdata private fields, and its epoch counter did not advance with num_workers > 0 (iteration runs on worker copies, so every epoch repeated the same order). get_dataloader now returns a small DataLoader subclass that keeps its own (shuffle, seed, epoch) and applies them to the dataset with set_shuffle and set_epoch at the start of each pass, in the main process before workers copy the dataset. loader.dataset is the dataset again, only public litdata calls are used (set_shuffle, set_epoch, seed), and each epoch gets a new reproducible order with or without workers. The in-memory dataset also splits its samples across workers; it used to yield every sample once per worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, agreed on dropping |
Why
A downstream EHR project trained PyHealth models (MLP, RNN, Transformer, RETAIN) twice on identical data, identical fitted processors and fixed seeds (
torch.manual_seed,np.random.seed). Held-out PR-AUC still moved by up to 0.06 between runs, enough to flip whether a neural model was "tied" with an XGBoost baseline. Model initialization and CPU kernels were already deterministic; every difference came from random-number handling:random.InMemorySampleDataset.__iter__calledrandom.shuffle, which torch and NumPy seeds don't touch.DataProcessor.runcallsrandom.seed(42),np.random.seed(42)andtorch.manual_seed(42)in the calling process. This runs throughcreate_sample_dataset(in_memory=False), andset_task's write also consumed Python'srandom. Whether a user's seed survived therefore depended on whether a cache already existed. Filed upstream asDataProcessor.runreseeds the caller's globalrandom, NumPy and torch generators Lightning-AI/litData#944.get_dataloadercalleddataset.set_shuffle(...)on the shared dataset, so creating an unshuffled evaluation loader turned off shuffling for an existing training loader on the same dataset.Trainer(enable_logging=False), early stopping returned the weights frompatienceepochs after the best one, with no warning.pyhealth.modelsreseeded NumPy (gnn.py, the same fix as Remove module-level RNG reset from gnn.py #1227).What changes
get_dataloader(dataset, batch_size, shuffle=False, seed=None). The returned loader (a smallDataLoadersubclass) keeps its own shuffle flag, seed and epoch, and applies them to the dataset withset_shuffle(shuffle, seed)andset_epoch(epoch)at the start of every pass, in the main process.loader.datasetis the dataset itself. Each pass is a new, deterministic order derived from(seed, epoch), also withnum_workers > 0:default_rng([seed, epoch]);With
seed=None, the seed is drawn from torch's generator when the loader is created, sotorch.manual_seed(...)alone, which is what users already do, makes runs reproducible. Each loader also gets a privatetorch.Generator; otherwiseDataLoadertakes a draw from torch's global generator on every pass.set_shuffle(shuffle, seed=None)on datasets is seeded the same way. Nothing shuffles with Python's globalrandomanymore.pyhealth.utils.preserve_rng_state()saves and restores the Python, NumPy, torch and (if initialized) CUDA RNG states.BaseDataset.set_taskandcreate_sample_datasetare wrapped in it.Trainer.train(..., keep_best_in_memory=True). Whenmonitoris set and there's no output directory, the best weights are kept as a CPU copy and restored at the end.keep_best_in_memory=Falseopts out with a warning (the docs cover the memory cost). The on-disk behavior with logging is unchanged.No library code seeds global RNGs.
gnn.py's import-time seeding (from Remove module-level RNG reset from gnn.py #1227).train_lstm_modeland the KG splitter now seed only for their own call.pyhealth.utils.set_seed, which users call explicitly, stays. Its docs now cover the GPU flags and note thatPYTHONHASHSEEDonly affects child processes.Docs and example. "Reproducible Runs" in
docs/api/trainer.rst(including what GPU users additionally need, without changing those flags globally), "Shuffling and Reproducibility" indocs/api/datasets.rst, andexamples/reproducible_training.py.Validation (
tests/core/test_reproducibility.py, 17 tests)torch.manual_seed(s)alone, withseed=None, gives reproducible ordersloader.datasetis the dataset itself (both backends)num_workers=2: a new order each epoch, every sample exactly once per epoch, identical across two loaders with the same seed (both backends)set_task(fresh and existing cache) andcreate_sample_dataset(in_memory=False)leave global RNG states unchanged; later shuffles follow the user's seedimport pyhealth.modelsleaves NumPy and torch unchanged (subprocess)enable_logging=False(final validation PR-AUC equals the best, which isn't the last epoch); identical results with logging on; opt-out warnstest_processor_schemasreading a stale shared test cache, andtest_sdoh's working-directory path, fixed in Find test resources relative to the test file, not the working directory #1292).tools/check_pr_rules.pypasses.Changes after review
The first version wrapped the dataset in a per-loader
_LoaderView. Review pointed out that it hid the dataset behindloader.dataset, relied on litdata private fields, and its epoch counter didn't advance withnum_workers > 0. I confirmed the last point: with 2 workers, every epoch repeated the same order, because__iter__runs on worker copies of the wrapper._LoaderViewis gone. The loader now applies(shuffle, seed, epoch)to the dataset itself at the start of each pass, using only public litdata calls (set_shuffle,set_epoch,seed).__iter__would have the same worker problem.Notes
create_sample_datasetno longer callslitdata.optimize, so itspreserve_rng_stateguard is mostly defensive now; theset_taskguard is still needed.pyhealth.modelsstill advances (doesn't reseed) Python'srandomonce, through the third-partyrichpackage; seeding after import is unaffected.pyhealth/medcode/pretrained_embeddings/kg_embalready fails to import on master, so its splitter change is untested at runtime.🤖 Generated with Claude Code