Skip to content

Diagnose and resolve SNP/variant-id mismatch in S-PrediXcan and Predict.py - #229

Open
hakyim wants to merge 5 commits into
masterfrom
snp-overlap-diagnostic
Open

Diagnose and resolve SNP/variant-id mismatch in S-PrediXcan and Predict.py#229
hakyim wants to merge 5 commits into
masterfrom
snp-overlap-diagnostic

Conversation

@hakyim

@hakyim hakyim commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

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.

Diagnosticscheck_snp_overlap() compares the model's SNP set against the GWAS's before any per-gene work, and check_covariance_overlap() compares the model's against the covariance's. Each logs a WARNING naming 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).

Resolutionmetax/misc/SnpKeyResolution.py samples 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, at WARNING.

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:

invocation model↔GWAS model↔cov usable genes (before) after
--snp_column panel_variant_id --model_db_snp_key varID --keep_non_rsid (correct) 100% 100% 15/15 15/15
--snp_column panel_variant_id --keep_non_rsid (classic mistake) 8.11% 8.11% 3/15 15/15
--snp_column panel_variant_id --model_db_snp_key varID 0% 100% 0 15/15
--snp_column variant_id 91.89% 8.11% 0 (all NA) 15/15
  1. A 1% floor misses the canonical MASHR mistake (row 2, at 8.11%): a MASHR db's rsid column falls back to the varID string for variants that have no rsID, and those few match.
  2. Raising the floor is not the fix — a GWAS covering a single chromosome legitimately overlaps a genome-wide model by a few percent, so any floor high enough to catch row 2 false-alarms on that.

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.0 is kept only as a floor for what formats can't see — 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 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_overlap applies the same format-aware rule one layer over, backed by a new MatrixManager.snps(). StreamedMatrixManager.snps() returns None and 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 rsid and varID for 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 a chr_pos_ref_alt key means inferring allele orientation, and a mistake there silently flips the sign of an association rather than failing loudly.

  1. Pick the model's key column. Chosen to agree with the covariance when the covariance's format is known, otherwise with the GWAS — a model the covariance can't be looked up in yields all-NA no matter how well the GWAS matched.
  2. Translate the GWAS's ids to that key through the db's own weights table, when the GWAS names its variants by a different db column (row 4). An id naming more than one variant in the target column is dropped rather than resolved arbitrarily.
  3. Lift the rsid-only load filter when the GWAS's ids aren't rsIDs and would otherwise all be dropped at read time (row 3).

Downstream is untouched: the same exact-string merge, and align_data_to_alleles still doing the allele matching and sign correction on unchanged input.

Overrides: --model_db_snp_key is never overridden, only diagnosed. New --gwas_snp_key names the GWAS's source column by hand. --snp_map_file turns resolution off entirely, since ids are then already being mapped. Resolution runs on a copy of args, because MetaMany drives SPrediXcan.run in a loop over model dbs with one args and 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

  • 150 tests pass (python -m unittest discover -s . -p "test_*.py" -t . from software/), except one pre-existing failure noted below.
  • Real-data fixtures: 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 both variant_id (rsID) and panel_variant_id (varID), so every row of the table is reproducible by changing only --snp_column.
  • Allele-flip coverage, since a sign error is the failure mode that would be silent rather than empty: the fixture GWAS with every variant reported against the other allele (effect/non-effect swapped, zscore negated) must produce bit-identical results, both through the key switch and through the translation.
  • Rows 2 and 3 now produce byte-identical output to the explicitly-correct row 1. For row 4, every gene that keeps all its snps matches row 1 exactly, and the rest differ only by having had fewer snps.
  • Unit tests for the pure helpers: id classification and dominant format (test_snp_overlap_diagnostics.py), overlap checks (test_M04_zscores.py), key choice and translation (test_snp_key_resolution.py).
  • Predict.py verified to import/compile cleanly; no genotype fixtures exist in tests/_td for a full Predict.run() integration test.
  • Maintainer: run the full suite in the imlabtools conda env.

Two notes for whoever runs the suite, both pre-existing and unrelated to this PR:

  • tests/test_M01_covariances_correlations.py::testProcessWeightDBRun fails on master. Commit a6278e5 ("Replace covariance column delimiter by tab instead of single space") changed M01_covariances_correlations.py without updating the test, which still expects space-delimited output. The covariance reader uses sep="\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.
  • The suite must be run as a package from software/; the -s tests form in CLAUDE.md fails on the relative imports. sqlalchemy is needed for the model-db tests and is missing from conda_env.yaml.

🤖 Generated with Claude Code

@hakyim hakyim changed the title Loud SNP-overlap diagnostics for S-PrediXcan and Predict.py Prominent SNP-overlap diagnostics for S-PrediXcan and Predict.py Sep 9, 2026
hki@mbp-26-128 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
hakyim force-pushed the snp-overlap-diagnostic branch from 59d62a5 to c7eef21 Compare September 9, 2026 01:44
hki@mbp-26-128 and others added 3 commits September 9, 2026 01:14
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>
@hakyim hakyim changed the title Prominent SNP-overlap diagnostics for S-PrediXcan and Predict.py Diagnose and resolve SNP/variant-id mismatch in S-PrediXcan and Predict.py Sep 9, 2026
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