Skip to content

Make the pipeline's outputs reproducible - #106

Merged
ahmedhosny merged 1 commit into
Arcadia-Science:mainfrom
mrubash1:mr/deterministic-outputs
Aug 17, 2026
Merged

Make the pipeline's outputs reproducible#106
ahmedhosny merged 1 commit into
Arcadia-Science:mainfrom
mrubash1:mr/deterministic-outputs

Conversation

@mrubash1

@mrubash1 mrubash1 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Running the pipeline twice on identical inputs produces different maps. There are three independent causes, all pre-existing on main. This PR fixes each.

This came out of designing the validation for a possible foldseek version bump: a before/after output diff turns out to have no power, because the outputs already differ between two runs of the same version.

1. PCA was unseeded and used a randomized solver

dim_reduction.py constructed PCA(n_components=n_components, **kwargs) — no random_state, and svd_solver left at its default of 'auto'. main() passes random_state to calculate_TSNE and calculate_UMAP but never to calculate_PCA.

'auto' selects the randomized solver once the matrix exceeds 500 rows or columns, and that solver draws its projection matrix from the global numpy random state:

N= 500 -> svd_solver = full
N= 501 -> svd_solver = randomized

Both configured plotting modes (pca_tsne, pca_umap) run a 30-component PCA and feed the result into t-SNE/UMAP, so seeding those two did not make anything reproducible — their input was changing. Measured on a 600-protein matrix:

two unseeded PCA runs identical? False
max abs diff across runs      : 2.52
std of last PC                : 0.35

The trailing components are several times their own standard deviation of pure run-to-run noise, and all 30 are handed downstream.

Fixed by threading the existing random_state through and requesting svd_solver="full", which is exact and deterministic. Verified afterwards that two runs are bit-identical and that the result no longer depends on column order (which matters for cause 2).

2. The similarity matrix's column order was hash-randomized

reading_data returned targets as a set, and pivot_foldseek_results writes the header and every row by iterating it. Python salts string hashing per process, so the column order changed on every run, and nothing sets PYTHONHASHSEED.

Rows were already sorted() while columns were not, so all_by_all_tmscore_pivoted.tsv was a matrix whose row i and column i were different proteins, with the 1.0 diagonal scattered through it.

Harmless on its own for Euclidean row distances, but not harmless in combination with cause 1, because the randomized solver is sensitive to column order. Fixed by sorting, which also makes the column order match the row order.

3. Which proteins reached the map was decided by hash randomization

aggregate_hits accumulates accessions into a set and writes them in iteration order. download_pdbs then truncates that file with accessions[:max_structures] (default 5000).

So whenever there were more hits than max_structures — the situation the parameter exists for — the proteins that survived were an arbitrary sample chosen by hash order. Same accessions, three processes:

seed=1 -> first 3 kept: ['A0A286Q506', 'P60713', 'D7RIF5']
seed=2 -> first 3 kept: ['D7RIF5', 'P60713', 'A0A286Q506']
seed=3 -> first 3 kept: ['A0A850ZFV5', 'Q6QAQ1', 'P60713']

Fixed by sorting.

This makes the choice reproducible, not principled. The retained hits are now the first max_structures in accession order, which is stable but still not biologically meaningful. Ranking by significance (best e-value across queries for BLAST hits, TM-score for Foldseek hits) would be the better rule, but it changes which proteins appear in a map rather than only making the existing selection stable, so it belongs in its own PR with its own discussion.

Verification

  • PCA: two runs bit-identical, and invariant to a random column permutation (max diff < 1e-9).
  • Ordering: both fixes produce identical output across PYTHONHASHSEED 1/2/3.
  • Unit tests pass; cluster-mode DAG resolves unchanged (16 rules).
  • ruff check, ruff format --check, snakefmt --check clean under Python 3.9.

What was not verified

I have not measured the end-to-end effect on a real map, i.e. how much a published figure changes between two runs today. The PCA measurement above is on a synthetic 600×600 matrix. The direction is not in doubt, but the magnitude on real data is unquantified.

Rebased onto main after #103 merged. The conflict was confined to dim_reduction.py; #103's structure is kept and the seed added on top. calculate_PCA on main still takes no random_state, so the defect this PR fixes is live and unaffected by #103.

test_pipeline_in_cluster_mode and the unit tests pass on the rebased branch, as does lint under CI's pinned versions. (The earlier note here, that make test failed on this base for the envs/analysis.yml drift, no longer applies: #103 pinned matplotlib and setuptools, and main is green.)

Implications

  • Any map of more than ~500 proteins cannot currently be regenerated exactly from its inputs, so figures cannot be reproduced from archived inputs alone.
  • Any before/after comparison — a foldseek bump, a BLAST database change, a dependency update — is confounded until this lands. That includes the nr vs refseq_protein question in Fix search-mode reliability for BLAST, UniProt, AFDB, and small maps #103.
  • After this, the right check is a self-diff: run the pipeline twice at the same versions and confirm the outputs are identical. That is the precondition for any version-bump validation being meaningful.

Three separate sources of run-to-run variation meant that running the pipeline
twice on identical inputs produced different maps.

1. `calculate_PCA` never received a random state, and left `svd_solver` at its
   default of 'auto'. sklearn's 'auto' switches to the randomized solver once
   the matrix exceeds 500 rows or columns, and that solver draws its projection
   from the global numpy random state. So for any map of more than ~500
   proteins the principal components differed on every run. Since `pca_tsne`
   and `pca_umap` -- the two configured plotting modes -- feed 30 components
   into t-SNE and UMAP, seeding only those two did not make the result
   reproducible. Measured on a 600-protein matrix, two runs differed by up to
   2.52 on components whose own standard deviation is 0.35.

   Fixed by passing the existing `random_state` through and asking for the
   'full' solver, which is exact, deterministic, and invariant to column order.

2. `reading_data` returned the similarity matrix's targets as a set, and
   `pivot_foldseek_results` wrote the columns in that set's iteration order.
   Python randomizes string hashing per process, so the same input produced a
   differently-ordered matrix on every run. Rows were already sorted, so the
   matrix's row i and column i were also different proteins.

3. `aggregate_hits` wrote the aggregated accessions in set iteration order, and
   `download_pdbs` truncates that file to `max_structures`. So whenever there
   were more hits than that -- the case the parameter exists for -- which
   proteins ended up in the map was decided by hash randomization rather than
   by anything about the proteins.

(2) and (3) are fixed by sorting.

Note that (3) makes the choice reproducible, not principled: the retained hits
are now the first `max_structures` in accession order. Ranking by significance
instead would be a better selection rule, but it changes which proteins are in
the map rather than only making the existing choice stable, so it is left for
a separate change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLpJZQ4W4XsL9NUUjjsry9
@mrubash1
mrubash1 force-pushed the mr/deterministic-outputs branch from 662d261 to e737ab6 Compare August 17, 2026 02:54

@ahmedhosny ahmedhosny left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good. Three independent sources of run-to-run map variation, each with a matching fix:

  1. PCA now gets the existing random_state and svd_solver="full", so maps with >500 proteins no longer draw a randomized projection from the global numpy RNG. calculate_PCA is only called from main() in this file, and the new argument is threaded the same way as t-SNE/UMAP.
  2. Similarity-matrix columns are sorted so they match the already-sorted rows instead of depending on PYTHONHASHSEED.
  3. Aggregated accessions are sorted before download_pdbs truncates to max_structures, so which proteins survive is stable.

Agree that (3) makes the truncation reproducible rather than biologically ranked; accession order is the right change for this PR, and ranking by e-value/TM-score belongs in a follow-up.

@ahmedhosny
ahmedhosny merged commit fb02bcc into Arcadia-Science:main Aug 17, 2026
3 checks passed
mrubash1 added a commit to mrubash1/ProteinCartography that referenced this pull request Aug 27, 2026
FOLLOWUPS Arcadia-Science#106, and NOT the way the ticket proposed.

The ticket said: make both paths cluster the same matrix. That is the wrong
fix. `Snakefile`'s `leiden_clustering` clusters the raw all-versus-all matrix
and `diagnose_space` clusters it after `reduce_space.fuse_blocks` applies the
block's declared `unit_mean_distance` -- differing by 19.851134 on the actin
cohort -- and that difference is LEGITIMATE, because a positive scalar cannot
change a partition. Re-aligning them would put a behaviour change on the default
path, costing byte-identity, to remove a difference that is allowed.

What was missing was a test of the invariant itself.
`test_a_partition_does_not_depend_on_the_scale_of_its_input` clusters a fixture
and the same fixture times 40 and requires identical labels protein for protein.
Both sites now state the difference and name the test that holds it up.

**The obvious fixture for this test cannot fail, and I nearly shipped it.**
Three well-separated blobs at n=249 pass on a BROKEN toolchain at every scale:
Leiden recovers obvious structure even from a saturated graph. Measured before
the fixture was replaced. The one that works is many weakly-separated blobs,
which is what a protein cohort looks like:

    numba 0.66.0   scale 1 -> 11 clusters (92 of 300 rows saturated)
                   scale 40 ->  8 clusters (300 of 300)      ARI 0.7871
    numba 0.60.0   scale 1 -> 12 clusters (0 saturated)
                   scale 40 -> 12 clusters (0)               ARI 1.0000

12 is the number of blobs planted, so the invariant-respecting answer is also
the correct one. And note the broken toolchain is ALREADY corrupted at scale 1 —
92 saturated rows — it simply does not change the answer there. Only comparing
ACROSS scales exposes it, which is the argument for asserting an invariant
rather than a value, and the docstring says so to stop the fixture being
"simplified" back to one that cannot fail.

Unit 1641 passed / 122 skipped. Clustering suite 27 passed on the fixed
toolchain, +3.6s. Lint, snakefmt, DAGs 17/26/37.

One unrelated thing found: `demo/search-mode/output` held an incomplete file
from an interrupted run, which made its dry run exit 1. Removed; the directory
is gitignored and the battery clears it first, which is why the battery never
saw it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pei5dBxuNYPHozaUguzbV9
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