Skip to content

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

Open
mooreneural wants to merge 2 commits into
recursionpharma:mainfrom
mooreneural:fix/deterministic-conformer-seed
Open

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

Conversation

@mooreneural

@mooreneural mooreneural commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

docs/prediction.md documents --seed as "Random seed for reproducible predictions", but three 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 three 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 per-record RandomState. The augmentation distribution is unchanged, only its RNG source.

3. Batch position leaking into the per-record RNG. preprocess_yamls collected results with as_completed, which yields in completion order rather than submission order, so the manifest order followed worker scheduling. InferenceDataset then seeded its RandomState from the dataset index, feeding that scrambled order into the augmentation. Fixing 2 alone was not sufficient, because the index it keyed on was itself unstable.

Results are now collected by submission index, and record_rng_seed() derives the seed from the record id via sha256 rather than from position. Order-preservation alone is not enough: the index still shifts whenever inputs are added, removed, renamed or reordered. hash() is unusable here because it is salted per process.

Measurements

Geometry, varying only the thing named:

Varied Before After
Screening library grew (5 compounds to 8 or 9) 0 to 2 of 5 original compounds changed geometry, up to 10.7 Å (see note) 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
All 27 features via the CLI, --num_workers 4 twice ref_pos differed in 39 of 40 records, all others identical 0 of 40

Note on the first row: whether growing a batch perturbs a given compound depends on how the added molecules shift RDKit's RNG stream across workers, so it varies between repeats. I measured 2 of 5 in one run and 0 of 5 in another with a different set of added compounds. The order and worker-count rows reproduced identically on every repeat. That run-to-run variability is itself the defect being fixed, so it is worth stating rather than reporting a single figure as if it were stable.

End-to-end predictions, 40 diverse ligands against one target, run twice through the CLI:

Comparison Before After
nw=1 twice 0/40 0/40
nw=4 twice 17/40 0/40
nw=1 vs nw=4 39/40 0/40
nw=1 vs nw=2 (the default) not measured 0/40

Every max delta is exactly 0.0000 and every Spearman correlation exactly 1.000000, so this is bit-identical rather than merely close.

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 that output becomes identical across runs, worker counts, batch composition and input order. It is not a claim of identity with 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.
  • An earlier version of this description claimed reproducibility that the first commit did not actually deliver at --num_workers > 1. I measured it, found the third cause above, and fixed it; the comments below have the full diagnosis.

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, 9 tests, ligand-only so they need no CCD asset and run on plain CI. Four of them use only pre-existing APIs and therefore run against main unchanged; all four fail there and pass here, so they genuinely catch the bug rather than merely describing the new code. test_ref_pos_independent_of_record_position featurizes the same records in forward and reversed manifest order and was confirmed to fail without the position fix.

Full suite: 29 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 36 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

mooreneural commented Jul 28, 2026

Copy link
Copy Markdown
Contributor 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.

One case is different. In a separate run I pinned the conformer and varied only the ref_pos augmentation, so the remaining difference was purely rigid-body rather than a change in shape. That still reaches the output: that arm of the sensitivity study 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.

@mooreneural

mooreneural commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Superseded. The root cause turned out to be as_completed scrambling the manifest order in preprocess_yamls, not the DataLoader transfer path. Fixed in eaaf9c4; see the comment below. Leaving this here for the diagnostic record, but the conclusions in it are wrong.

Correcting something in the description above before this goes further.

I ran a screening-scale check (40 diverse ligands against one target, run twice through the CLI) and found my "identical output across runs and worker counts" claim was too strong.

Comparison Compounds changed max delta (log10) Spearman
pristine main, --num_workers 1 twice 0/40 0.0000 1.000000
this PR, --num_workers 1 twice 0/40 0.0000 1.000000
this PR, --num_workers 4 twice 17/40 0.0727 0.998687
pristine main, nw 1 vs 4 39/40 0.1985 0.980488
this PR, nw 1 vs 4 19/40 0.1362 0.991932

The correction: --num_workers > 1 is nondeterministic run to run, before and after this PR. nw=4 against itself gives 17/40 changed. That is not a worker-count effect, and this PR does not fix it. Since the default is --num_workers 2, that affects the default path.

I chased the residual variance rather than guess at it. Comparing nw=1 against nw=4 on this branch:

  • dumped structures: 0/40 differ
  • ESM embeddings: identical, max diff 0.0
  • featurizer output, ligand-only: 0/40 differ
  • featurizer output, protein plus ligand: 0/12 differ
  • model forward on a fixed batch, run twice in-process and again after perturbing the CUDA allocator: bit-identical

So the input pipeline this PR targets really is deterministic. The remaining variance enters somewhere in the multi-worker DataLoader path. pin_memory=True combined with non_blocking=True transfers in transfer_batch_to_device is my suspicion, but I have not proven it and am not claiming it.

One thing worth separating out, since the first two rows of that table can be read as "this PR does nothing at nw=1": that control used an identical library in identical order, so it only tested rerun identity. It did not test the failure modes this PR targets. Measured at --num_workers 1, reversing input file order:

compounds with changed geometry
pristine main 5/5, up to 20.4 Å
this PR 0/5

So even single-threaded, reordering or renaming input files changes every compound's conformer on main. Same for extending a library.

What this PR does, stated accurately:

  • Conformers, structures and ref_pos no longer depend on batch composition, input order, or worker count. The geometry table in the description covers this and those measurements stand.
  • It roughly halves the multi-worker variance: 39/40 to 19/40 compounds changed, mean delta 0.041 to 0.021, Spearman 0.980 to 0.992.
  • --num_workers 0 no longer crashes.

What it does not do: make multi-worker inference reproducible. That looks like a separate pre-existing bug, and it also cannot be fixed on its own; whoever addresses the DataLoader path still needs deterministic conformer generation underneath it or the result will not be reproducible either way. Happy to open a separate issue with the reproducer if useful.

Seeding the ref_pos augmentation was not sufficient on its own. Two further
layers made a record's features depend on where it sat in the batch:

1. preprocess_yamls collected results with as_completed, which yields in
   completion order, so the manifest order followed worker scheduling.
   Measured over 40 records: order was stable at --num_workers 1 but differed
   between runs at 2 and 4. Results are now collected by submission index.

2. InferenceDataset seeded RandomState from the dataset index, so that
   scrambled order reached the augmentation. Even with order preserved, the
   index still shifts whenever inputs are added, removed, renamed or
   reordered. record_rng_seed() now derives the seed from the record id via
   sha256, matching how conformer_seed keys on the molecule. hash() is unusable
   here because it is salted per process.

Diagnosis: hashing every feature tensor reaching predict_step across two
--num_workers 4 CLI runs showed ref_pos differing in 39 of 40 records with all
26 other features identical, which pointed at the per-record RNG rather than
the transfer path. An earlier suspicion that pin_memory plus non_blocking
transfers were responsible was tested directly and disproved.

End to end over 40 ligands against one target, same library run twice:

    comparison           before      after
    nw=1 twice            0/40       0/40
    nw=4 twice           17/40       0/40
    nw=1 vs nw=4         39/40       0/40
    nw=1 vs nw=2          n/a        0/40

All max deltas are exactly 0.0000 and all Spearman correlations exactly
1.000000, so this is bit-identical rather than close.

Adds two tests. test_ref_pos_independent_of_record_position featurizes the
same records in forward and reversed manifest order and was confirmed to fail
without the fix.
@mooreneural

Copy link
Copy Markdown
Contributor Author

Found the root cause of the residual multi-worker nondeterminism I flagged above, and it is fixed in eaaf9c4. My earlier suspicion was wrong.

It was not the DataLoader transfer path. I tested non_blocking=False directly and it changed nothing (40/40 compounds still differed). The actual chain was:

  1. preprocess_yamls collected results with as_completed, which yields in completion order rather than submission order. Over 40 records the manifest order was stable at --num_workers 1 but differed between runs at 2 and 4.
  2. InferenceDataset seeded its per-record RandomState from the dataset index, so that scrambled order fed straight into the ref_pos augmentation.

Seeding the augmentation in the first commit was necessary but not sufficient, because the index it was keyed on was itself unstable.

How I found it: hashing every feature tensor reaching predict_step across two --num_workers 4 runs showed ref_pos differing in 39 of 40 records with all 26 other features identical. That ruled out the transfer path and pointed at the per-record RNG.

The fix, two parts:

  • preprocess_yamls now collects results by submission index, so manifest order no longer follows worker scheduling.
  • record_rng_seed() derives the seed from the record id via sha256 rather than from the dataset index. Order-preservation alone is not enough, since the index still shifts whenever inputs are added, removed, renamed or reordered. This mirrors how conformer_seed keys on the molecule. hash() is unusable here because it is salted per process.

Result. Same 40 ligands against one target, run twice through the CLI:

Comparison Before After
nw=1 twice 0/40 0/40
nw=4 twice 17/40 0/40
nw=1 vs nw=4 39/40 0/40
nw=1 vs nw=2 (the default) not measured 0/40

Every max delta is exactly 0.0000 and every Spearman correlation exactly 1.000000, so this is bit-identical rather than merely close.

That supersedes the "what it does not do" paragraph in my previous comment. End-to-end predictions are now reproducible across runs, worker counts, batch composition and input order, not just at --num_workers 1. I have updated the description accordingly.

Two new tests. test_ref_pos_independent_of_record_position featurizes the same records in forward and reversed manifest order, and I confirmed it fails without the fix. Full suite: 36 passed, ruff 0.11.13 clean.

@mooreneural

Copy link
Copy Markdown
Contributor Author

@shenoynikhil this is settled now and ready for review whenever you have time.

Apologies the thread ran long. I posted a claim, found while verifying at screening scale that it was too strong, and tracing that turned up two further causes rather than the one I expected. I left the corrections in place instead of force-pushing over them, so the comments read as the investigation rather than a clean summary. The description at the top is current and accurate; the comments below are the working record if you want the detail.

Final state: 2 commits, three RNG causes fixed, CI green on all 7 checks. I am not planning to amend anything further.

@shenoynikhil

Copy link
Copy Markdown
Collaborator

Thanks @mooreneural, I'll review it over the coming days

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