Skip to content

fix(determinism): make --seed actually reproducible - #6

Open
mooreneural wants to merge 1 commit into
recursionpharma:mainfrom
mooreneural:fix/deterministic-conformer-seed
Open

fix(determinism): make --seed actually reproducible#6
mooreneural wants to merge 1 commit into
recursionpharma:mainfrom
mooreneural:fix/deterministic-conformer-seed

Conversation

@mooreneural

@mooreneural mooreneural commented Jul 28, 2026

Copy link
Copy Markdown

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. 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_conformer embedded with ETKDGv3 and never set randomSeed (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, since preprocess_yamls parses in a ProcessPoolExecutor where a counter would vary with worker scheduling. Keying on the molecule also means a given ligand embeds identically wherever it appears.

2. ref_pos augmentation. center_random_augmentation drew from the global torch RNG, which seed_everything(workers=True) seeds per DataLoader worker. It now takes an optional torch.Generator, seeded from the existing per-record RandomState. The augmentation distribution is unchanged, only its RNG source.

Measurements

Same input, varying only the thing named:

Varied Before After
Screening library grew (5 compounds to 9) 2 of 5 original compounds changed geometry, up to 10.7 Å 0 of 5
Input file order reversed 5 of 5 changed, up to 8.9 Å 0 of 5
--num_workers 1 vs 2 vs 4 (6 ligands) 10 of 12 comparisons differed, up to 22.2 Å 0 of 12
--num_workers 0 vs 1/2/4, conformer pinned (isolates cause 2) 0 of 6 records matched, up to 14.5 Å 6 of 6

Does this reach the predictions? Measured on the released checkpoint (128 predictions: 4 arms × 16 replicates × 2 ligands), conformer choice moved affinity_pred_value across 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

  • This is not output-preserving, and cannot be, since the previous behaviour was random. The claim is identical output across runs, worker counts, batch composition and input order, not identical to any particular earlier run.
  • The default seed selects one arbitrary conformer. Varying --seed still varies it, which is what a future conformer-ensembling option would use.
  • CCD and SDF ligands are unaffected: they arrive with a conformer, so embedding never runs.
  • base_seed=None restores RDKit's previous non-deterministic behaviour for library callers.
  • This removes input-side nondeterminism. It does not make nesso bit-reproducible across RDKit versions, GPUs, precisions, or with/without cuEquivariance kernels. Those are separate and larger.

Why base_seed is threaded rather than a module constant

A module-level constant would make --seed meaningless for conformers, and the seed must be content-derived rather than process state to survive the ProcessPoolExecutor. Every added parameter is keyword-with-default, so existing call signatures are unchanged (verified).

Also included

--num_workers 0 crashed with ValueError: 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 against main unchanged. 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.

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.
@mooreneural

Copy link
Copy Markdown
Author

A note on the Ångström figures

Those 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):

comparison max change in an interatomic distance mean change
draw 0 vs 1 2.29 Å 0.43 Å
draw 0 vs 2 2.04 Å 0.35 Å
draw 0 vs 3 7.88 Å 1.08 Å

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 embed_atompair_ref_pos(d), which is not rotation-invariant.

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.

1 participant