PGx CN: pgx copy-number train|call (CLI-031/032, PCN-001..017, IN-050), region BED v1, demo identities (K-028)

- simurg/pgx/cn.py: per-region CN mixture, mu_c = delta + gamma*log2((c+1)/3), free lower CN-0 component,
  Hardy-Weinberg-constrained weights over haplotype copy numbers, EM from a grid of label starts.
- pipeline/pgx_cn.py: model file (.simurg-pgxcn), batch rules (PCN-004), per-sample CN VCF + tbi, batch and
  sample summaries.
- The GSA v4 PGx demo arrays are 30 CEPH/1000G individuals (scripts/pgx/demo36_identity.tsv); against NYGC 30x
  SV RD_CN 224/240 region calls agree (GSTM1, UGT2B17 30/30), in-sample (docs/validation/V2_pgx_cn.md).
This commit is contained in:
kemal committed 2026-09-29 13:16:19 +01:00
1 parent 518bfbe059
commit c3e0681b6e
14 files changed
+1306 -1

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Build the PGx CN target-region BED (spec PCN-001) from UCSC ncbiRefSeq (GRCh38).
python scripts/pgx/build_cn_regions.py ncbiRefSeq.hg38.txt.gz OUT.bed [--version N]
Regions (0-based half-open BED: chrom start0 end region gene), from the MANE/first-listed transcript:
CYP2D6.p5 2 kb 5' flank of CYP2D6 (upstream of exon 1; the gene is on the minus strand)
CYP2D6.intron.2 CYP2D6 intron 2
CYP2D6.exon.9 CYP2D6 exon 9
CYP2A6.exon.1 CYP2A6 exon 1 (± 500 bp)
CYP2A6.intron.7 CYP2A6 intron 7
GSTM1, GSTT1 (on chr22_KI270879v1_alt, IN-062), UGT2B17, SULT1A1, CYP2E1: whole gene.
Exons and introns are numbered in transcript order (strand-aware). [DB: NCBI RefSeq via UCSC ncbiRefSeq]
"""
from __future__ import annotations
import argparse
import gzip
from pathlib import Path
TRANSCRIPT = {"CYP2D6": "NM_000106", "CYP2A6": "NM_000762", "GSTM1": "NM_000561", "GSTT1": "NM_000853",
"UGT2B17": "NM_001077", "SULT1A1": "NM_001055", "CYP2E1": "NM_000773"} # fmt: skip
FLANK = 2000
PAD = 500
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("refseq", type=Path)
ap.add_argument("out", type=Path)
ap.add_argument("--version", type=int, default=1)
a = ap.parse_args()
tx: dict[str, tuple[str, str, list[int], list[int]]] = {}
with gzip.open(a.refseq, "rt") as fh:
for line in fh:
f = line.rstrip("\n").split("\t")
name, chrom, strand, gene = f[1], f[2], f[3], f[12]
if gene not in TRANSCRIPT or not name.startswith(TRANSCRIPT[gene] + "."):
continue
if "_" in chrom and not (gene == "GSTT1" and chrom == "chr22_KI270879v1_alt"):
continue # primary assembly, except GSTT1 which is only on the alt contig
starts = [int(x) for x in f[9].rstrip(",").split(",")]
ends = [int(x) for x in f[10].rstrip(",").split(",")]
tx.setdefault(gene, (chrom, strand, starts, ends))
missing = set(TRANSCRIPT) - set(tx)
if missing:
raise SystemExit(f"transcripts not found: {sorted(missing)}")
rows: list[tuple[str, int, int, str, str]] = []
def exon(gene: str, k: int) -> tuple[int, int]:
chrom, strand, s, e = tx[gene]
i = k - 1 if strand == "+" else len(s) - k
return s[i], e[i]
def intron(gene: str, k: int) -> tuple[int, int]:
chrom, strand, s, e = tx[gene]
a_, b_ = exon(gene, k), exon(gene, k + 1)
return (a_[1], b_[0]) if strand == "+" else (b_[1], a_[0])
for gene in ("CYP2D6", "CYP2A6"):
chrom, strand, s, e = tx[gene]
if gene == "CYP2D6":
p5 = (e[-1], e[-1] + FLANK) if strand == "-" else (s[0] - FLANK, s[0])
rows += [(chrom, *p5, "CYP2D6.p5", gene), (chrom, *intron(gene, 2), "CYP2D6.intron.2", gene),
(chrom, *exon(gene, 9), "CYP2D6.exon.9", gene)] # fmt: skip
else:
x1 = exon(gene, 1)
rows += [(chrom, x1[0] - PAD, x1[1] + PAD, "CYP2A6.exon.1", gene),
(chrom, *intron(gene, 7), "CYP2A6.intron.7", gene)] # fmt: skip
for gene in ("GSTM1", "GSTT1", "UGT2B17", "SULT1A1", "CYP2E1"):
chrom, strand, s, e = tx[gene]
rows.append((chrom, s[0], e[-1], gene, gene))
order = {f"chr{i}": i for i in range(1, 23)}
rows.sort(key=lambda r: (order.get(r[0].split("_")[0], 99), r[0], r[1]))
with a.out.open("w") as fh:
fh.write(f"# simurg PGx CN regions v{a.version}, GRCh38, from UCSC ncbiRefSeq (PCN-001)\n")
for r in rows:
fh.write("\t".join(map(str, r)) + "\n")
print(a.out.read_text())
return 0
if __name__ == "__main__":
raise SystemExit(main())
+39
View File
@@ -0,0 +1,39 @@
# GSA v4 PGx demo (idats_Demo_36) sample identities: best match among the 3202 1000 Genomes 30x individuals
# by genotype concordance on 4647 chr22 SNPs with 1000G AF 0.05-0.95 (next best <= 0.91: relatives)
sample_id individual concordance second_best second_concordance
207895980019_R03C01 NA12875 0.9920 NA12865 0.7038
207895980019_R03C02 NA12156 0.9933 NA10831 0.7021
207895980019_R04C01 NA12156 0.9931 NA10831 0.7027
207895980019_R05C01 NA12044 0.9931 NA10857 0.6969
207895980019_R06C01 NA11992 0.9931 NA10860 0.6848
207895980019_R07C01 NA06993 0.9940 NA06991 0.7209
207895980019_R07C03 NA07019 0.9953 NA07056 0.7051
207895980019_R08C01 NA12239 0.9938 NA10847 0.7022
207895980019_R09C01 NA12716 0.9944 NA12707 0.6896
207895980019_R09C03 NA06991 0.9931 NA06993 0.7215
207895980019_R10C01 NA12878 0.9933 NA12892 0.7022
207895980019_R11C01 NA12760 0.9948 NA12752 0.7171
207895980019_R11C02 NA06985 0.9948 NA06991 0.6996
207895980019_R12C01 NA11993 0.9961 NA10860 0.7168
207895980019_R12C02 NA12003 0.9935 NA10838 0.6998
207895980019_R13C03 NA06994 0.9948 NA07029 0.7114
207895980019_R14C03 NA11993 0.9959 NA10860 0.7163
207895980019_R15C02 NA12003 0.9931 NA10838 0.6993
207895980035_R03C03 NA07029 0.9952 NA07000 0.7237
207895980035_R04C03 NA12892 0.9957 NA12878 0.7005
207895980035_R05C03 NA12248 0.9950 NA10835 0.7143
207895980035_R06C03 NA10846 0.9920 NA12145 0.7056
207895980035_R07C01 NA07000 0.9957 NA07029 0.7231
207895980035_R07C03 NA12155 0.9935 NA10831 0.6903
207895980035_R08C01 NA11832 0.9942 NA10855 0.7252
207895980035_R08C03 NA06993 0.9940 NA06991 0.7205
207895980035_R09C03 NA10838 0.9948 NA12004 0.7007
207895980035_R10C01 NA12813 0.9937 NA07045 0.9103
207895980035_R10C03 NA12249 0.9938 NA10835 0.6858
207895980035_R11C03 NA12812 0.9942 NA12801 0.7061
207895980035_R12C03 NA11881 0.9935 NA10859 0.7138
207895980035_R13C01 NA11995 0.9953 NA10861 0.6857
207895980035_R13C03 NA12248 0.9950 NA10835 0.7145
207895980035_R14C01 NA12891 0.9942 NA12878 0.6998
207895980035_R15C03 NA12043 0.9946 NA10857 0.7162
207895980042_R15C02 NA11832 0.9942 NA10855 0.7253
1 # GSA v4 PGx demo (idats_Demo_36) sample identities: best match among the 3202 1000 Genomes 30x individuals
2 # by genotype concordance on 4647 chr22 SNPs with 1000G AF 0.05-0.95 (next best <= 0.91: relatives)
3 sample_id individual concordance second_best second_concordance
4 207895980019_R03C01 NA12875 0.9920 NA12865 0.7038
5 207895980019_R03C02 NA12156 0.9933 NA10831 0.7021
6 207895980019_R04C01 NA12156 0.9931 NA10831 0.7027
7 207895980019_R05C01 NA12044 0.9931 NA10857 0.6969
8 207895980019_R06C01 NA11992 0.9931 NA10860 0.6848
9 207895980019_R07C01 NA06993 0.9940 NA06991 0.7209
10 207895980019_R07C03 NA07019 0.9953 NA07056 0.7051
11 207895980019_R08C01 NA12239 0.9938 NA10847 0.7022
12 207895980019_R09C01 NA12716 0.9944 NA12707 0.6896
13 207895980019_R09C03 NA06991 0.9931 NA06993 0.7215
14 207895980019_R10C01 NA12878 0.9933 NA12892 0.7022
15 207895980019_R11C01 NA12760 0.9948 NA12752 0.7171
16 207895980019_R11C02 NA06985 0.9948 NA06991 0.6996
17 207895980019_R12C01 NA11993 0.9961 NA10860 0.7168
18 207895980019_R12C02 NA12003 0.9935 NA10838 0.6998
19 207895980019_R13C03 NA06994 0.9948 NA07029 0.7114
20 207895980019_R14C03 NA11993 0.9959 NA10860 0.7163
21 207895980019_R15C02 NA12003 0.9931 NA10838 0.6993
22 207895980035_R03C03 NA07029 0.9952 NA07000 0.7237
23 207895980035_R04C03 NA12892 0.9957 NA12878 0.7005
24 207895980035_R05C03 NA12248 0.9950 NA10835 0.7143
25 207895980035_R06C03 NA10846 0.9920 NA12145 0.7056
26 207895980035_R07C01 NA07000 0.9957 NA07029 0.7231
27 207895980035_R07C03 NA12155 0.9935 NA10831 0.6903
28 207895980035_R08C01 NA11832 0.9942 NA10855 0.7252
29 207895980035_R08C03 NA06993 0.9940 NA06991 0.7205
30 207895980035_R09C03 NA10838 0.9948 NA12004 0.7007
31 207895980035_R10C01 NA12813 0.9937 NA07045 0.9103
32 207895980035_R10C03 NA12249 0.9938 NA10835 0.6858
33 207895980035_R11C03 NA12812 0.9942 NA12801 0.7061
34 207895980035_R12C03 NA11881 0.9935 NA10859 0.7138
35 207895980035_R13C01 NA11995 0.9953 NA10861 0.6857
36 207895980035_R13C03 NA12248 0.9950 NA10835 0.7145
37 207895980035_R14C01 NA12891 0.9942 NA12878 0.6998
38 207895980035_R15C03 NA12043 0.9946 NA10857 0.7162
39 207895980042_R15C02 NA11832 0.9942 NA10855 0.7253
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Validate `simurg pgx copy-number call` against the NYGC 30x SV callset (1KGP_3202 freeze V3) — PCN-017.
python scripts/pgx/validate_cn_truth.py --calls CALL_DIR --identity scripts/pgx/demo36_identity.tsv \
--sv 1KGP_3202.sv_freeze_V3.vcf.gz --bcftools BCFTOOLS --out summary.json
Truth per region = RD_CN of one SV record per region (below, chosen as the record spanning the whole region; for
GSTM1 the read-depth DUP/CNV record, whose RD_CN gives 0/1/2 in Hardy–Weinberg proportions — the DEL record there
reports CN 1 for 27/30 individuals). CYP2D6 sub-regions share one gene-level record, so exon-9 conversions (*36) and
2D7-2D6 hybrids (*13, *68) legitimately differ from it. GSTT1 lies on an alt contig with no SV calls: only its CN
distribution is reported. Replicate arrays of one individual are counted once (the first array).
"""
from __future__ import annotations
import argparse
import collections
import csv
import json
import subprocess
from pathlib import Path
TRUTH_RECORD = {"GSTM1": "HGSV_9691", "UGT2B17": "HGSV_63272", "SULT1A1": "HGSV_207805",
"CYP2A6.intron.7": "HGSV_232737", "CYP2A6.exon.1": "HGSV_232737", "CYP2D6.exon.9": "HGSV_247317",
"CYP2D6.intron.2": "HGSV_247317", "CYP2D6.p5": "HGSV_247317"} # fmt: skip
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("--sv", required=True)
ap.add_argument("--bcftools", default="bcftools")
ap.add_argument("--out", required=True, type=Path)
a = ap.parse_args()
ident = {}
with a.identity.open() as fh:
for r in csv.DictReader((ln for ln in fh if not ln.startswith("#")), delimiter="\t"):
ident[r["sample_id"]] = r["individual"]
calls: dict[str, dict[str, str]] = {}
with (a.calls / "pgx_cn_sample_summary.tsv").open() as fh:
for r in csv.DictReader(fh, delimiter="\t"):
ind = ident.get(r["sample_id"])
if ind and ind not in calls:
calls[ind] = {k[3:]: v for k, v in r.items() if k.startswith("cn_")}
ids = sorted(set(TRUTH_RECORD.values()))
q = subprocess.run([a.bcftools, "query", "-H", "-s", ",".join(sorted(calls)), "--force-samples", "-i",
"ID=@/dev/stdin", "-f", "%ID[\t%RD_CN]\n", a.sv], input="\n".join(ids), capture_output=True,
text=True, check=True).stdout.splitlines() # fmt: skip
samples = [h.split("]")[1].split(":")[0] for h in q[0].split("\t")[1:]]
truth = {ln.split("\t")[0]: dict(zip(samples, ln.split("\t")[1:], strict=True)) for ln in q[1:]}
res: dict[str, dict[str, object]] = {}
tot = [0, 0]
for region, rid in TRUTH_RECORD.items():
pairs = [(truth[rid][ind], c[region]) for ind, c in calls.items() if ind in truth.get(rid, {})]
pairs = [(t, c) for t, c in pairs if t not in (".", "")]
ok = sum(t == c for t, c in pairs)
tot[0] += ok
tot[1] += len(pairs)
res[region] = {"record": rid, "concordant": ok, "n": len(pairs),
"rate": round(ok / len(pairs), 4) if pairs else None,
"truth_to_call_mismatches": dict(collections.Counter(f"{t}->{c}" for t, c in pairs if t != c))} # fmt: skip
summary = {"individuals": len(calls), "regions": res, "overall": {"concordant": tot[0], "n": tot[1],
"rate": round(tot[0] / tot[1], 4) if tot[1] else None},
"gstt1_cn_distribution": dict(sorted(collections.Counter(c.get("GSTT1", ".") for c in calls.values()).items()))} # fmt: skip
a.out.write_text(json.dumps(summary, indent=1))
print(json.dumps(summary, indent=1))
return 0
if __name__ == "__main__":
raise SystemExit(main())