Diagnose and resolve SNP/variant-id mismatch in S-PrediXcan and Predict.py - #229
Open
hakyim wants to merge 5 commits into
Open
Diagnose and resolve SNP/variant-id mismatch in S-PrediXcan and Predict.py#229hakyim wants to merge 5 commits into
hakyim wants to merge 5 commits into
Conversation
added 2 commits
September 8, 2026 20:42
SNP/variant-ID mismatch between the model DB and GWAS input is the single largest recurring source of user-reported failures: the exact rsid merge silently succeeds even at ~0% overlap, producing a near-empty results file with no explanation. Add get_gwas_snps() alongside the existing get_model_snps() on both context classes, and check_snp_overlap() to compare them before running associations. When overlap is below a low default threshold, log a warning with the overlap percentage, sample SNP ids from both sides, and the specific flags to try (--model_db_snp_key varID, --snp_map_file, --keep_non_rsid, build mismatch). Default behavior is unchanged for the common case of healthy overlap.
…ype path) Extends the diagnostic added for S-PrediXcan to the individual-level genotype prediction path, which has the same silent-failure shape: an INFO-only PercentReporter that never escalates even at ~0% overlap between model rsids and genotype variant ids. Predict.py streams genotypes (VCF/BGEN/dosage) rather than materializing a full variant-id set, so a true pre-flight check isn't cheap here. Instead, reuse the id-match count already tracked during the prediction loop and warn post-loop if it's low, via a new shared helper (check_snp_overlap_from_counts) tailored to genotype-specific remediation flags (--variant_mapping, --on_the_fly_mapping, --skip_palindromic, --liftover). Default behavior is unchanged for the common case of healthy overlap.
hakyim
force-pushed
the
snp-overlap-diagnostic
branch
from
September 9, 2026 01:44
59d62a5 to
c7eef21
Compare
Testing the existing diagnostic against real MASHR + GWAS data showed the percentage threshold was wrong in both directions. It missed the canonical MASHR mistake. Pointing --snp_column at a varID column while the model is loaded by rsid lands at 8% overlap, not 0%, because a MASHR db's rsid column falls back to the varID string for variants that have no rsID -- those few match and clear the 1% floor, so the run silently reported 3 of 15 genes. Raising the floor is not the fix either: a GWAS covering a single chromosome legitimately overlaps a genome-wide model by a few percent, and any floor high enough to catch the above false-alarms on that. So the primary signal is now the variant id format differing between the two sides, which holds regardless of how much of the genome the GWAS covers. min_pct stays only as a floor for what formats cannot see, namely an hg19/hg38 mismatch where both sides are chr_pos_ref_alt but no position ever lines up. Two unrecognized formats are never called a mismatch, since a false alarm is worse than a missed one here. The advice in the message is now branch-specific, and a partial-genome run is told it can ignore the warning instead of being pointed at id flags. Add check_covariance_overlap for a silent failure the GWAS check cannot structurally see: PredictDB's MASHR covariances are keyed by varID, so an rsid-keyed model matches the GWAS fine, then finds nothing in the covariance, computes every gene from zero SNPs and writes an all-NA results file. Streamed covariances never have the full id set, so the check is skipped there. Test fixtures are a real, public 15-gene chr1 slice of GTEx v8 MASHR Whole Blood and the matching variants of the imputed CARDIoGRAM_C4D_CAD GWAS, from the QGT course data. That GWAS carries both an rsID and a varID column, so the mismatch reproduces by changing only --snp_column. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A PredictDB model db carries both an `rsid` and a `varID` column, but only one of them is the matching key, and the default (`rsid`) is the wrong one for every MASHR/GTEx v8 model whose GWAS was harmonized to varIDs. Getting it wrong is silent: the merge succeeds with nothing in it and the run finishes with an empty or all-NA results file. The same holds for the GWAS loader's rsid-only filter, which drops every row of a varID-keyed GWAS unless --keep_non_rsid. Sample the ids on all three sides -- model db columns, GWAS snp column, covariance -- before anything is loaded, and match on the column that agrees with the input. Nothing is constructed or rewritten: only a column already in the db is chosen, so matching stays the same exact-string merge and the allele handling in align_data_to_alleles is untouched. An explicit --model_db_snp_key is never overridden, only diagnosed, and an id format we can't classify is left alone rather than guessed at. Against the QGT chr1 fixtures this makes the two canonical mistakes produce byte-identical output to the correct invocation: --snp_column panel_variant_id 3/15 genes -> 15/15 --snp_column panel_variant_id --model_db_snp_key varID 0 genes -> 15/15 The remaining case -- an rsID GWAS against a varID-keyed covariance -- can't be fixed by choosing a column, since matching one side empties the other. It now says exactly that at pre-flight instead of writing 15 rows of NA. Resolution runs on a copy of args: MetaMany drives SPrediXcan.run in a loop over model dbs with a single args, and a key resolved for one db need not exist in the next. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Choosing a column fixes the cases where the GWAS and the covariance agree on a format. It can't fix the last one: an rsID GWAS against a varID-keyed MASHR covariance, where matching the GWAS empties the covariance and matching the covariance empties the GWAS. That combination writes 15 rows of NA today and the previous commit could only say so out loud. But the db carries both ids for the same row, hence for the same variant with the same alleles, so it is itself an exact rsid<->varID table. Key the model on whichever column the covariance uses, and rename the GWAS's ids through that table on the way in. No position is parsed, no allele is inferred, no id is constructed: this is a lookup, and align_data_to_alleles still does the allele matching and sign flipping afterwards on unchanged input. Ids that name more than one variant in the target column are dropped rather than resolved arbitrarily. --gwas_snp_key names the source column, for doing this by hand; it is worked out from the data when not given, and giving it (or --snp_map_file) turns the automatic resolution off. On the QGT chr1 fixtures the last row of the table goes from 15 genes of NA to 15 genes computed from 34 of 37 variants. The 3 it loses are the ones the db has no rsID for -- its rsid column holds the varID string instead -- so they cannot be named on the GWAS's side of the lookup at all. Two tests cover the failure mode that would be silent rather than empty: a GWAS with every variant reported against the other allele (effect and non-effect swapped, zscore negated) has to come out bit for bit identical, through the translation and through the key switch. And every gene that keeps all its snps has to match the explicitly-correct invocation exactly. patch_variant_names is factored out of load_model so the translation applies the same chr-prefix patch the key column gets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Summary
SNP/variant-id mismatch between the model DB and the GWAS/genotype input (rsid vs varID, hg19 vs hg38, non-rsID GWAS ids, a covariance keyed differently from the model) is the single largest recurring source of user-reported failures against this repo. Matching is a plain exact-string merge that silently succeeds even at ~0% overlap, producing a near-empty or all-NA results file with only an
INFO-level log line as a clue.This PR does two things: it makes that failure loud, and then it stops most of it from happening.
Diagnostics —
check_snp_overlap()compares the model's SNP set against the GWAS's before any per-gene work, andcheck_covariance_overlap()compares the model's against the covariance's. Each logs aWARNINGnaming the overlap %, example ids from both sides, and the specific flags to try.Predict.py(individual-level genotype) has the same silent-failure shape; since it streams genotypes rather than materializing a variant-id set, it reuses the id-match count already tracked during the prediction loop and warns post-loop, with genotype-specific advice (--variant_mapping,--on_the_fly_mapping,--skip_palindromic,--liftover).Resolution —
metax/misc/SnpKeyResolution.pysamples the ids on all three sides before anything is loaded and lines them up: it picks which of the model db's id columns to match on, translates the GWAS's ids to it when needed, and lifts the loader's rsid-only filter when the GWAS isn't rsID-keyed. It says what it did, atWARNING.Why the diagnostic keys on id format, not a percentage
The first draft fired on a low overlap percentage alone. Testing against real data showed that was wrong in both directions:
--snp_column panel_variant_id --model_db_snp_key varID --keep_non_rsid(correct)--snp_column panel_variant_id --keep_non_rsid(classic mistake)--snp_column panel_variant_id --model_db_snp_key varID--snp_column variant_idrsidcolumn falls back to the varID string for variants that have no rsID, and those few match.So the primary signal is the variant id format differing between the two sides (
metax/misc/SnpOverlapDiagnostics.py), which holds regardless of how much of the genome the GWAS covers.min_pct=1.0is kept only as a floor for what formats can't see — an hg19/hg38 mismatch, where both sides arechr_pos_ref_altbut no position ever lines up. Two unrecognized formats are never called a mismatch, since a false alarm is worse than a miss here.Row 4 is a failure the GWAS-overlap check structurally cannot see: model↔GWAS matching is fine, but PredictDB's MASHR covariances are keyed by varID, so an rsID-keyed model finds nothing in the covariance and writes an all-NA file.
check_covariance_overlapapplies the same format-aware rule one layer over, backed by a newMatrixManager.snps().StreamedMatrixManager.snps()returnsNoneand the check is skipped, since a streamed covariance never has the full id set in hand.How the resolution works, and why it constructs nothing
A PredictDB model db carries both
rsidandvarIDfor the same weights row — the same variant, with the same alleles. So the db is itself an exact id table, and no id has to be built out of chromosome/position/alleles. That matters: constructing achr_pos_ref_altkey means inferring allele orientation, and a mistake there silently flips the sign of an association rather than failing loudly.Downstream is untouched: the same exact-string merge, and
align_data_to_allelesstill doing the allele matching and sign correction on unchanged input.Overrides:
--model_db_snp_keyis never overridden, only diagnosed. New--gwas_snp_keynames the GWAS's source column by hand.--snp_map_fileturns resolution off entirely, since ids are then already being mapped. Resolution runs on a copy ofargs, becauseMetaManydrivesSPrediXcan.runin a loop over model dbs with oneargsand a key resolved for one db need not exist in the next.Known limit: a variant the db has no rsID for can't be named on the GWAS's side of an rsID→varID lookup, so it's dropped and the count is logged. On the fixtures that's 3 of 37 — row 4 produces 15/15 genes from 34 variants rather than 37.
Test plan
python -m unittest discover -s . -p "test_*.py" -t .fromsoftware/), except one pre-existing failure noted below.software/tests/_td/qgt_chr1_subset/(36 KB) holds a public slice of the QGT course data — a 15-gene chr1 subset of GTEx v8 MASHR Whole Blood plus its covariance, and the 37 matching variants of the imputed CARDIoGRAM_C4D_CAD GWAS. The GWAS carries bothvariant_id(rsID) andpanel_variant_id(varID), so every row of the table is reproducible by changing only--snp_column.test_snp_overlap_diagnostics.py), overlap checks (test_M04_zscores.py), key choice and translation (test_snp_key_resolution.py).Predict.pyverified to import/compile cleanly; no genotype fixtures exist intests/_tdfor a fullPredict.run()integration test.imlabtoolsconda env.Two notes for whoever runs the suite, both pre-existing and unrelated to this PR:
tests/test_M01_covariances_correlations.py::testProcessWeightDBRunfails on master. Commit a6278e5 ("Replace covariance column delimiter by tab instead of single space") changedM01_covariances_correlations.pywithout updating the test, which still expects space-delimited output. The covariance reader usessep="\s+"so both parse, but any downstream consumer expecting single spaces would break — worth deciding whether the test or the format change is the thing to fix.software/; the-s testsform in CLAUDE.md fails on the relative imports.sqlalchemyis needed for the model-db tests and is missing fromconda_env.yaml.🤖 Generated with Claude Code