fix(determinism): make --seed actually reproducible - #6
Conversation
docs/prediction.md documents --seed as "Random seed for reproducible predictions", but two model inputs were drawn from RNGs that pl.seed_everything does not reach, so the same input could produce different atomistic features depending on --num_workers. 1. Ligand conformers. get_conformer embedded with ETKDGv3 and never set randomSeed (RDKit's default is -1, i.e. random). RDKit's global RNG starts from a fixed state per process, so a single-record run was already reproducible, but it advances with each embedding: in a batch the Nth ligand embedded inside a worker depends on how many preceded it, and therefore on how work is split across preprocess workers. Measured over 6 ligands, comparing --num_workers 1 vs 2 and 1 vs 4: 10 of 12 record/worker-count comparisons produced different geometry (up to 22.2 A); after the fix, 0 of 12. conformer_seed() now derives the seed from the base seed and the molecule's canonical SMILES, deliberately not from processing order. CCD and SDF ligands are unaffected: they already carry a conformer, so embedding never runs (verified: identical coordinates across seeds). 2. ref_pos augmentation. center_random_augmentation drew from the global torch RNG, which seed_everything(workers=True) seeds per DataLoader worker. With the conformer pinned so this was isolated, 0 of 6 records matched between --num_workers 0 and 1/2/4 (up to 14.5 A); after the fix, 6 of 6 match. The generator is now seeded from the existing per-record RandomState, so the augmentation distribution is unchanged. --seed is threaded to conformer generation so the flag stays meaningful, and base_seed=None keeps RDKit's previous non-deterministic behaviour. Also fixes `--num_workers 0`, which crashed with "max_workers must be greater than 0" because the value was reused for the preprocessing ProcessPoolExecutor. Note this is not output-preserving and cannot be: previous behaviour was random. The claim is identical output across runs and across --num_workers, not identical to any particular earlier run. Adds tests/test_determinism.py (6 tests). The two that rely only on existing APIs were confirmed to fail on main and pass here.
A note on the Ångström figuresThose are max absolute differences in a single atom coordinate, which mixes two things: a genuinely different conformation, and the arbitrary frame it happens to be generated in. A molecule that is merely rotated and translated shows a large raw coordinate difference while being chemically identical. To separate those, I compared rotation- and translation-invariant interatomic distance matrices across 4 unseeded ETKDG draws of the same ligand (imatinib, 37 heavy atoms, matching what nesso stores):
Radius of gyration across those draws spanned 5.55 to 6.36 Å, i.e. genuinely different conformations (extended versus folded), not one shape re-posed. As a sanity check on the method, applying a pure rotation plus translation to a single conformer gives a 17.6 Å raw coordinate difference and a 0.0000 Å interatomic distance difference. The fourth table row is the exception: there the conformer is pinned, so the remaining difference is purely rigid-body. That still reaches the output. In the sensitivity study, the arm with the conformer pinned and only the augmentation varying produced a prediction spread of 0.016 log10 units for tyrosine and 0.029 for imatinib. Consistent with that, the atom encoder embeds raw displacement vectors through |
docs/prediction.mddocuments--seedas "Random seed for reproducible predictions", but two model inputs were drawn from RNGs thatpl.seed_everythingdoes not reach. The practical symptom: the same input produced different predictions depending on--num_workers, on the order of your input files, and on what else was in the batch.That last one matters most for screening. Because RDKit's unseeded RNG advances with every embedding, a compound's 3D conformer depended on how many molecules were embedded before it. Adding compounds to a screening library silently changed the geometry, and therefore the predicted affinity, of compounds already in it.
The two causes
1. Ligand conformers.
get_conformerembedded withETKDGv3and never setrandomSeed(RDKit's default is-1, random). RDKit's global RNG starts from a fixed state per process, so a single-record run was already reproducible; it advances with each embedding, so batches were not.conformer_seed()now derives the seed from the base seed and the molecule's canonical SMILES, deliberately not from processing order, sincepreprocess_yamlsparses in aProcessPoolExecutorwhere a counter would vary with worker scheduling. Keying on the molecule also means a given ligand embeds identically wherever it appears.2.
ref_posaugmentation.center_random_augmentationdrew from the global torch RNG, whichseed_everything(workers=True)seeds per DataLoader worker. It now takes an optionaltorch.Generator, seeded from the existing per-recordRandomState. The augmentation distribution is unchanged, only its RNG source.Measurements
Same input, varying only the thing named:
--num_workers1 vs 2 vs 4 (6 ligands)--num_workers0 vs 1/2/4, conformer pinned (isolates cause 2)Does this reach the predictions? Measured on the released checkpoint (128 predictions: 4 arms × 16 replicates × 2 ligands), conformer choice moved
affinity_pred_valueacross a 0.232 log10 range for tyrosine, a ~1.7× spread in IC50, roughly a third of the model's own ensemble disagreement|value1 - value2|. The effect is ligand-dependent and not simply a function of flexibility: imatinib (37 heavy atoms) showed no spread above the augmentation baseline while tyrosine (13) did. I don't have an explanation for that and am not claiming one.Scope and honesty
--seedstill varies it, which is what a future conformer-ensembling option would use.base_seed=Nonerestores RDKit's previous non-deterministic behaviour for library callers.Why
base_seedis threaded rather than a module constantA module-level constant would make
--seedmeaningless for conformers, and the seed must be content-derived rather than process state to survive theProcessPoolExecutor. Every added parameter is keyword-with-default, so existing call signatures are unchanged (verified).Also included
--num_workers 0crashed withValueError: max_workers must be greater than 0, because the DataLoader value was reused for the preprocessing pool. Same flag, one-line fix (max(1, num_workers)).Tests
tests/test_determinism.py, 7 tests, ligand-only so they need no CCD asset and run on plain CI. Three of them use only pre-existing APIs and therefore run againstmainunchanged. All three fail there and pass here, so they genuinely catch the bug rather than merely describing the new code.Full suite: 27 passed, 7 skipped. The 7 are the pre-existing CCD-gated protein tests, which skip when the asset is absent (as on CI); with a CCD pickle present all 34 pass. ruff 0.11.13 clean.