Make the pipeline's outputs reproducible - #106
Merged
ahmedhosny merged 1 commit intoAug 17, 2026
Merged
Conversation
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
force-pushed
the
mr/deterministic-outputs
branch
from
August 17, 2026 02:54
662d261 to
e737ab6
Compare
ahmedhosny
approved these changes
Aug 17, 2026
ahmedhosny
left a comment
Contributor
There was a problem hiding this comment.
Looks good. Three independent sources of run-to-run map variation, each with a matching fix:
- PCA now gets the existing
random_stateandsvd_solver="full", so maps with >500 proteins no longer draw a randomized projection from the global numpy RNG.calculate_PCAis only called frommain()in this file, and the new argument is threaded the same way as t-SNE/UMAP. - Similarity-matrix columns are sorted so they match the already-sorted rows instead of depending on
PYTHONHASHSEED. - Aggregated accessions are sorted before
download_pdbstruncates tomax_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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
foldseekversion 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.pyconstructedPCA(n_components=n_components, **kwargs)— norandom_state, andsvd_solverleft at its default of'auto'.main()passesrandom_statetocalculate_TSNEandcalculate_UMAPbut never tocalculate_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: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: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_statethrough and requestingsvd_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_datareturnedtargetsas aset, andpivot_foldseek_resultswrites the header and every row by iterating it. Python salts string hashing per process, so the column order changed on every run, and nothing setsPYTHONHASHSEED.Rows were already
sorted()while columns were not, soall_by_all_tmscore_pivoted.tsvwas 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_hitsaccumulates accessions into asetand writes them in iteration order.download_pdbsthen truncates that file withaccessions[: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:Fixed by sorting.
This makes the choice reproducible, not principled. The retained hits are now the first
max_structuresin 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
PYTHONHASHSEED1/2/3.ruff check,ruff format --check,snakefmt --checkclean 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
mainafter #103 merged. The conflict was confined todim_reduction.py; #103's structure is kept and the seed added on top.calculate_PCAonmainstill takes norandom_state, so the defect this PR fixes is live and unaffected by #103.test_pipeline_in_cluster_modeand the unit tests pass on the rebased branch, as does lint under CI's pinned versions. (The earlier note here, thatmake testfailed on this base for theenvs/analysis.ymldrift, no longer applies: #103 pinned matplotlib and setuptools, andmainis green.)Implications
nrvsrefseq_proteinquestion in Fix search-mode reliability for BLAST, UniProt, AFDB, and small maps #103.