PGx validation on GSE165845: WGS-derived star truth, 2-fold masks, array-resolvable concordance

- scripts/pgx/wgs_star_truth.py: star alleles from the 1000G 30x phased panel with the DB definitions
  (wildcard-aware haplotype assignment; checked against GeT-RM on the demo CEPH individuals).
- validate_star_getrm.py: --all-genes, --individuals, --coverage (projection onto what the array can see);
  replicate_concordance.py.
- 120 individuals x 3 GSA v1 arrays: array-resolvable 1637/1710 (95.7 %), top 1223/1800 (chip lacks many
  defining variants), GeT-RM subset 28/35, replicates 99.2 % without the failed array (V2_pgx_star.md, K-029).
This commit is contained in:
kemal committed 2026-09-29 19:00:34 +01:00
1 parent 3fa97df61e
commit cfae926fbc
6 files changed
+4213 -1

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Replicate concordance of `pgx star-allele call`: the top genotype of every gene compared between the replicate
arrays of one individual (PGX-029 reproducibility on real replicates).
python scripts/pgx/replicate_concordance.py --calls STAR_DIR/pgx_calls.json --identity identity.tsv --out rep.json
"""
from __future__ import annotations
import argparse
import csv
import itertools
import json
from collections import defaultdict
from pathlib import Path
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--calls", required=True, type=Path)
ap.add_argument("--identity", required=True, type=Path)
ap.add_argument("--out", required=True, type=Path)
a = ap.parse_args()
with a.identity.open() as fh:
ident = {r["sample_id"]: r["individual"] for r in csv.DictReader((ln for ln in fh if not ln.startswith("#")),
delimiter="\t")} # fmt: skip
calls: dict[str, dict[str, dict[str, str]]] = defaultdict(dict)
for s in json.loads(a.calls.read_text())["samples"]:
ind = ident.get(s["sample_id"])
if ind:
calls[ind][s["sample_id"]] = {g["gene"]: (g["candidates"][0]["genotype"] if g["candidates"] else "")
for g in s["genes"]} # fmt: skip
per_gene: dict[str, list[int]] = defaultdict(lambda: [0, 0])
discord = []
for ind, reps in sorted(calls.items()):
for (_s1, c1), (_s2, c2) in itertools.combinations(sorted(reps.items()), 2):
for gene in sorted(set(c1) & set(c2)):
ok = c1[gene] == c2[gene]
per_gene[gene][0] += ok
per_gene[gene][1] += 1
if not ok:
discord.append({"individual": ind, "gene": gene, "a": c1[gene], "b": c2[gene]})
tot = [sum(v[0] for v in per_gene.values()), sum(v[1] for v in per_gene.values())]
out = {"pairs_by_gene": {g: {"concordant": v[0], "pairs": v[1]} for g, v in sorted(per_gene.items())},
"total": {"concordant": tot[0], "pairs": tot[1], "rate": round(tot[0] / tot[1], 4) if tot[1] else None},
"discordant": discord} # fmt: skip
a.out.write_text(json.dumps(out, indent=1))
print(json.dumps(out["total"]), f"{len(discord)} discordant gene pair(s)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+43
View File
@@ -79,13 +79,42 @@ def main() -> int:
ap.add_argument("--truth", required=True, type=Path)
ap.add_argument("--identity", required=True, type=Path)
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--all-genes", action="store_true",
help="truth with the same definitions (wgs_star_truth.py): no legacy-name exclusions") # fmt: skip
ap.add_argument("--individuals", type=Path, help="only these individuals (one per line)")
ap.add_argument("--coverage", type=Path, help="pgx_coverage.tsv of the array: also report array-resolvable "
"concordance (both diplotypes projected onto the alleles the array can tell apart)") # fmt: skip
a = ap.parse_args()
if a.all_genes:
LEGACY_NOMENCLATURE.clear()
only = set(a.individuals.read_text().split()) if a.individuals else None
proj: dict[tuple[str, str], str] = {}
refs: dict[str, str] = {}
if a.coverage:
with a.coverage.open() as fh:
for r in csv.DictReader((ln for ln in fh if not ln.startswith("#")), delimiter="\t"):
if r["allele_kind"] == "reference":
refs[r["gene"]] = r["allele"]
st = r["status"]
proj[(r["gene"], r["allele"])] = st.split(":", 1)[1] if st.startswith("equivalent_to:") else (
r["allele"] if st == "callable" else "") # fmt: skip
def project(gene: str, allele: str) -> str:
core = re.sub(r"x\d+$|xN$", "", allele.split("+")[-1])
v = proj.get((gene, core), core)
if v:
return v
ref = refs.get(gene, core) # not covered: indistinguishable from the reference's class
return proj.get((gene, ref)) or ref
with a.identity.open() as fh:
ident = {r["sample_id"]: r["individual"] for r in csv.DictReader((ln for ln in fh if not ln.startswith("#")),
delimiter="\t")} # fmt: skip
truth: dict[tuple[str, str], str] = {}
with a.truth.open() as fh:
for r in csv.DictReader(fh, delimiter="\t"):
if r.get("flag") == "incomplete" or (only is not None and r["individual"] not in only):
continue
truth[(r["individual"], r["gene"])] = r["diplotype"]
calls = json.loads(a.calls.read_text())
per_gene: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
@@ -135,11 +164,21 @@ def main() -> int:
if len(e.split("/")) >= 2) # fmt: skip
st["top"] += ok_top
st["equivalent"] += ok_eq
if a.coverage and len(top) == 2 and len(opts) == 1 and all(len(x) == 1 for x in opts[0]):
tt = sorted(project(g["gene"], next(iter(x))) for x in opts[0])
cc = sorted(project(g["gene"], x) for x in top)
st["array_resolvable_n"] += 1
st["array_resolvable"] += tt == cc
if not ok_top:
detail.append({"individual": ind, "gene": g["gene"], "truth": t, "call": g["genotype"],
"within_equivalents": ok_eq}) # fmt: skip
comparable = {k: v for k, v in per_gene.items() if k not in LEGACY_NOMENCLATURE}
tot_ar = [
sum(v.get("array_resolvable", 0) for v in comparable.values()),
sum(v.get("array_resolvable_n", 0) for v in comparable.values()),
]
summary = {"individuals": len(seen), "genes": {k: dict(v) for k, v in sorted(per_gene.items())},
"array_resolvable_total": {"concordant": tot_ar[0], "n": tot_ar[1]},
"legacy_nomenclature_genes": sorted(LEGACY_NOMENCLATURE),
"total": {k: sum(v.get(k, 0) for v in comparable.values()) for k in ("n", "top", "equivalent")},
"discordant": detail} # fmt: skip
@@ -147,8 +186,12 @@ def main() -> int:
for g, v in sorted(per_gene.items()):
print(f"{g:8s} top {v['top']}/{v['n']} within equivalents {v['equivalent']}/{v['n']}"
+ (f" (not comparable {v['not_comparable']})" if v.get("not_comparable") else "")
+ (f" array-resolvable {v.get('array_resolvable', 0)}/{v.get('array_resolvable_n', 0)}"
if a.coverage else "")
+ (" [legacy nomenclature]" if g in LEGACY_NOMENCLATURE else "")) # fmt: skip
print("total (excluding legacy-nomenclature genes)", summary["total"])
if a.coverage:
print("array-resolvable total", summary["array_resolvable_total"])
return 0
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Star-allele truth from 1000 Genomes 30x phased genotypes with the PGx DB definitions (SNV/indel alleles only).
python scripts/pgx/wgs_star_truth.py --db DB.sqlite --individuals ids.txt --out wgs_truth.tsv [--genes G,G]
For every star-allele gene of the DB the defining variants are fetched from the NYGC phased panel (pysam over HTTPS,
one window per gene; ``uv pip install pysam``). Each phased haplotype gets the allele whose defining variants it
carries completely and that explains most of the haplotype's carried variants — its defining ones plus those at its
wildcard (IUPAC) positions, so a *4 haplotype carrying the *10 variants stays *4 (ties: more defining variants,
higher CPIC/PharmVar frequency, natural order); a haplotype carrying none is the reference allele. A variant absent
from the filtered panel counts as reference only when rare (max allele frequency < 5 %); a gene with a common
defining variant missing from the panel is written with ``incomplete`` (not comparable). Structural alleles (CYP2D6
*5, xN, hybrids; CYP2A6 *4) are invisible to this truth: the diplotypes are SNV-level only.
"""
from __future__ import annotations
import argparse
import os
import re
import sqlite3
from collections import defaultdict
from pathlib import Path
PANEL = (
"https://ftp.1000genomes.ebi.ac.uk/vol1/ftp/data_collections/1000G_2504_high_coverage/working/"
"20220422_3202_phased_SNV_INDEL_SV/1kGP_high_coverage_Illumina.{c}.filtered.SNV_INDEL_SV_phased_panel.vcf.gz"
)
def natural(a: str) -> tuple[object, ...]:
return tuple(int(x) if x.isdigit() else x for x in re.split(r"(\d+)", a))
def main() -> int:
import pysam
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--db", required=True, type=Path)
ap.add_argument("--individuals", required=True, type=Path)
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--genes", default="")
ap.add_argument("--cache", type=Path, default=Path("data/pgx/cache"))
a = ap.parse_args()
inds = sorted(set(a.individuals.read_text().split()))
out_path = a.out.resolve()
con = sqlite3.connect(a.db)
genes = [g for (g,) in con.execute("SELECT gene FROM genes WHERE result_basis = 'star_allele' ORDER BY gene")]
if a.genes:
genes = [g for g in genes if g in a.genes.split(",")]
a.cache.mkdir(parents=True, exist_ok=True)
os.chdir(a.cache)
rows = []
for gene in genes:
q_alle = "SELECT allele FROM alleles WHERE gene = ? AND defined = 1 AND structural = 0"
alle = [x for (x,) in con.execute(q_alle, (gene,))]
q_ref = "SELECT allele FROM alleles WHERE gene = ? AND kind = 'reference'"
ref_allele = next((x for (x,) in con.execute(q_ref, (gene,))), "*1")
defs: dict[str, set[tuple[int, str, str]]] = defaultdict(set)
chrom = None
loc_keys: dict[int, set[tuple[int, str, str]]] = defaultdict(set)
q_var = "SELECT allele, chrom, pos, ref, alt, location_id FROM allele_variants WHERE gene = ?"
for allele, c, p, r, al, lid in con.execute(q_var, (gene,)):
defs[allele].add((p, r, al))
loc_keys[lid].add((p, r, al))
chrom = c
wild: dict[str, set[tuple[int, str, str]]] = defaultdict(set)
for allele, lid in con.execute("SELECT allele, location_id FROM allele_wildcards WHERE gene = ?", (gene,)):
wild[allele] |= loc_keys.get(lid, set())
if chrom is None:
continue
freq: dict[str, float] = defaultdict(float)
for allele, f in con.execute("SELECT allele, AVG(freq) FROM frequencies WHERE gene = ? GROUP BY allele",
(gene,)): # fmt: skip
freq[allele] = float(f or 0)
vmax: dict[tuple[int, str, str], float] = defaultdict(float)
for allele, vs in defs.items():
for v in vs:
vmax[v] = max(vmax[v], freq.get(allele, 0.0))
keys = set().union(*defs.values()) if defs else set()
core = chrom.removeprefix("chr")
vf = pysam.VariantFile(PANEL.format(c=f"chr{core}"))
present = [s for s in inds if s in vf.header.samples]
vf.subset_samples(present)
lo = min(p for p, _, _ in keys) - 10
hi = max(p + len(r) for p, r, _ in keys) + 10
hap_alts: dict[tuple[str, int], set[tuple[int, str, str]]] = defaultdict(set)
seen = set()
for rec in vf.fetch(f"chr{core}", lo, hi):
for ai, alt in enumerate(rec.alts or (), start=1):
k = (rec.pos, rec.ref, alt)
if k not in keys:
continue
seen.add(k)
for s in present:
gt = rec.samples[s]["GT"]
for h, x in enumerate(gt[:2]):
if x == ai:
hap_alts[(s, h)].add(k)
missing_common = [k for k in keys - seen if vmax[k] >= 0.05]
for s in present:
haps = []
for h in (0, 1):
carried = hap_alts[(s, h)]
# the allele that explains most carried variants (its defining ones plus those at its wildcard
# positions), then the most specific, then the most frequent
best = (0, 0, 0.0)
pick = ref_allele
for x in alle:
d = defs.get(x, set())
if not d or not d <= carried:
continue
score = (len(carried & (d | wild.get(x, set()))), len(d), freq.get(x, 0.0))
if score > best or (score == best and natural(x) < natural(pick)):
best, pick = score, x
haps.append(pick)
dip = "/".join(sorted(haps, key=natural))
rows.append((s, gene, dip, "incomplete" if missing_common else ""))
print(f"{gene}: {len(keys)} defining variant(s), {len(seen)} in the panel, "
f"{len(missing_common)} common missing", flush=True) # fmt: skip
with out_path.open("w") as fh:
fh.write("individual\tgene\tdiplotype\tflag\n")
for r in sorted(rows):
fh.write("\t".join(r) + "\n")
print(f"{len(rows)} gene call(s) -> {out_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())