PGx CN: pgx copy-number train|call (CLI-031/032, PCN-001..017, IN-050), region BED v1, demo identities (K-028)
ci / test (3.12) (push) Failing after 8s
ci / test (3.13) (push) Failing after 10s

- 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).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01814GKVzZVL3FuF3DeiZXhv
This commit is contained in:
kemalandClaude Opus 5.5 committed 2026-09-29 13:16:19 +01:00
1 parent 8c425182c3
commit 58e70bc894
14 files changed
+1306 -1

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
"""Targeted PGx copy number — spec PCN-001, PCN-009. Pure functions, no I/O.
Region value x = median over the region's unmasked probes of (sample-centred LRR − the probe's training reference),
minus the sample's scale (median of the same quantity over non-target autosomal probes).
CN mixture [OURS; array intensity compression as in cnvPartition/PennCNV, LIT: Wang et al. 2007 Genome Res 17:1665]:
component means µ_c = δ_r + γ_r · log2((c + 1) / 3) for CN c = 0 … 5 — a probe signal proportional to (c + β) with
a background β = 1 copy-equivalent, compressed by γ_r. δ_r (the region's CN-2 location), γ_r, σ_r and the weights
π_r are per region; γ_r has a Gaussian prior N(0.25, 0.08²). CN 0 may sit lower than the template (its own shift
ε₀ ≤ 0 and SD): GSTT1 probes drop by ~2 at CN 0, GSTM1 probes by ~0.3. The weights are constrained to Hardy–Weinberg
proportions of haplotype copy numbers (0 … 3 copies per haplotype, Dirichlet prior favouring one copy). Fitted by
EM from a grid of starts (each observed cluster labelled CN 0 … 4, CN 0 on the template or on the lowest values).
HWE and the template's spacing ratios identify the labels in multi-cluster regions (GSTM1 0/1/2 rather than 1/2/3,
which would imply unobserved CN-0 samples). On GSA v4 PGx CNV probes γ_r ≈ 0.19–0.56 (K-028).
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import numpy as np
import numpy.typing as npt
F64 = npt.NDArray[np.float64]
CN_MAX = 5 # "5 or more"
CNS = np.arange(CN_MAX + 1)
TEMPLATE = np.log2((CNS + 1) / 3.0)
HAP_ALPHA = np.array([1.2, 3.0, 1.2, 1.05]) # Dirichlet prior on haplotype copy frequencies f_0..f_3 (f_1 favoured)
SIGMA_FLOOR = 0.012
GAMMA_PRIOR = (0.25, 0.08) # mean, SD
GAMMA_RANGE = (0.15, 0.80)
PI_FLOOR = 0.002
CN0_PRIOR_N = 2.0
@dataclass(frozen=True)
class Region:
chrom: str
start0: int
end: int
name: str
gene: str
def parse_regions(text: str) -> list[Region]:
"""PCN-001 BED: ``chrom start0 end region gene``; ``#`` comments."""
out = []
for ln in text.splitlines():
if not ln.strip() or ln.startswith("#"):
continue
c, s, e, r, g = ln.split("\t")[:5]
out.append(Region(c, int(s), int(e), r, g))
return out
@dataclass
class RegionFit:
delta: float
gamma: float
sigma: float
weights: list[float]
n: int
cn0_shift: float = 0.0 # ≤ 0: extra drop of the CN-0 component below the template (probe-specific background)
cn0_sigma: float = 0.0 # SD of the CN-0 component (≥ sigma; 0 = sigma)
def means(self) -> F64:
m = self.delta + self.gamma * TEMPLATE
m[0] += self.cn0_shift
return m
def sds(self) -> F64:
s = np.full(len(CNS), self.sigma)
s[0] = max(self.sigma, self.cn0_sigma)
return s
def hwe_weights(f: F64) -> F64:
"""CN weights under Hardy–Weinberg from haplotype copy-number frequencies f_k (k = 0 … 3 copies):
π_c = Σ_{i+j=c} f_i f_j, CN ≥ 5 pooled into 5 [LIT: HWE; OURS: use as the CN mixture prior]."""
pi = np.zeros(len(CNS))
for i in range(len(f)):
for j in range(len(f)):
pi[min(i + j, CN_MAX)] += f[i] * f[j]
return pi
def _hap_update(r: F64, f: F64) -> F64:
"""M-step for f: split each sample's CN responsibilities over the haplotype pairs (i, j) with i + j = c."""
counts = HAP_ALPHA - 1.0
counts = counts.astype(np.float64).copy()
pi = hwe_weights(f)
for i in range(len(f)):
for j in range(len(f)):
c = min(i + j, CN_MAX)
share = f[i] * f[j] / max(pi[c], 1e-300)
w = float(r[:, c].sum()) * share
counts[i] += w
counts[j] += w
counts = np.maximum(counts, 1e-9)
out: F64 = counts / counts.sum()
return out
def _norm_pdf(x: F64, mu: F64, sd: float | F64) -> F64:
sd_ = np.broadcast_to(np.asarray(sd, dtype=np.float64), mu.shape)[None, :]
return np.exp(-0.5 * ((x[:, None] - mu[None, :]) / sd_) ** 2) / (sd_ * math.sqrt(2 * math.pi))
def _em_region(x: F64, gamma: float, delta: float, iters: int, eps0: float = 0.0) -> tuple[RegionFit, float]:
"""EM for one region; returns the fit and its penalised log-likelihood (HWE weights from haplotype copy
frequencies with a Dirichlet prior, Gaussian prior on γ).
CN 0 has its own shift ε₀ ≤ 0 below the template and its own SD, both shrunk towards the template with a prior
weight of ``CN0_PRIOR_N`` samples (on some probe sets CN 0 is far lower than the compressed template, GSTT1)."""
n = len(x)
f = np.array([0.1, 0.8, 0.07, 0.03])
pi = hwe_weights(f)
sd = max(SIGMA_FLOOR, float(np.std(x)) if n > 1 else 0.05)
sd0 = sd
if eps0 < 0: # a start with a far CN-0 cluster: the other components get the spread of the rest
rest = x[x > delta + gamma * TEMPLATE[0] + eps0 / 2]
sd = max(SIGMA_FLOOR, float(np.std(rest)) if len(rest) > 1 else 0.05)
sd0 = sd
g0, gs = GAMMA_PRIOR
ll = -math.inf
for _ in range(iters):
mu = delta + gamma * TEMPLATE
mu[0] += eps0
sds = np.full(len(CNS), sd)
sds[0] = sd0
dens = pi[None, :] * _norm_pdf(x, mu, sds) + 1e-300
tot = dens.sum(axis=1, keepdims=True)
ll = float(np.log(tot).sum() + ((HAP_ALPHA - 1) * np.log(np.maximum(f, 1e-12))).sum()
- 0.5 * ((gamma - g0) / gs) ** 2) # fmt: skip
r = dens / tot
f = _hap_update(r, f)
pi = hwe_weights(f)
# δ and γ: weighted least squares of x on (1, t_c) over CN ≥ 1, γ shrunk towards its prior
rr = r.copy()
rr[:, 0] = 0.0
w = rr.sum(axis=0)
sw, st, stt = float(w.sum()), float((w * TEMPLATE).sum()), float((w * TEMPLATE**2).sum())
sx, stx = float((rr * x[:, None]).sum()), float((rr * x[:, None] * TEMPLATE[None, :]).sum())
lam = sd**2 / gs**2
det = sw * (stt + lam) - st * st
if det > 0:
delta = (sx * (stt + lam) - st * (stx + lam * g0)) / det
gamma = float(np.clip((sw * (stx + lam * g0) - st * sx) / det, *GAMMA_RANGE))
mu = delta + gamma * TEMPLATE
n1 = float(w.sum())
if n1 > 0:
sd = max(SIGMA_FLOOR, math.sqrt(float((rr * (x[:, None] - mu[None, :]) ** 2).sum() / n1)))
r0 = r[:, 0]
n0 = float(r0.sum())
eps0 = min(0.0, float((r0 * (x - mu[0])).sum()) / (n0 + CN0_PRIOR_N))
v0 = float((r0 * (x - mu[0] - eps0) ** 2).sum())
sd0 = max(sd, math.sqrt((v0 + CN0_PRIOR_N * sd**2) / (n0 + CN0_PRIOR_N)))
return RegionFit(float(delta), float(gamma), sd, pi.tolist(), n, float(eps0), float(sd0)), ll
def fit_region(x: F64, iters: int = 200) -> RegionFit:
"""PCN-009 step 4 for one region: the best of EM runs started with each observed cluster labelled CN 0 … 4, each
with CN 0 on the template or on the lowest values."""
g0 = GAMMA_PRIOR[0]
cands = sorted({round(float(q) - g0 * float(t), 4) for q in np.quantile(x, [0.1, 0.3, 0.5, 0.7, 0.9])
for t in TEMPLATE[:5]}) # fmt: skip
lo = float(np.quantile(x, 0.02))
best: tuple[float, float, float] | None = None
for d0 in cands:
for e0 in sorted({0.0, round(min(0.0, lo - (d0 + g0 * float(TEMPLATE[0]))), 4)}):
_, ll = _em_region(x, g0, d0, 80, e0)
if best is None or ll > best[0] + 1e-9:
best = (ll, d0, e0)
assert best is not None
return _em_region(x, g0, best[1], iters, best[2])[0]
def fit_mixture(values: dict[str, F64], iters: int = 200) -> dict[str, RegionFit]:
"""Per-region fits for every region with ≥ 3 finite values."""
out = {}
for name, x in values.items():
x = x[np.isfinite(x)]
if len(x) >= 3:
out[name] = fit_region(x, iters)
return out
def posterior(x: float, fit: RegionFit) -> F64:
"""P(CN = c | x) for c = 0 … 5, with the training weights (floored) as prior."""
pi = np.maximum(np.asarray(fit.weights), PI_FLOOR)
pi = pi / pi.sum()
d = pi * _norm_pdf(np.array([x]), fit.means(), fit.sds())[0]
s = float(d.sum())
out: F64 = d / s if s > 0 else pi
return out
def call_cn(x: float, fit: RegionFit) -> tuple[int, int, F64]:
"""PCN-009 steps 5–6: (CN, QUAL, posterior)."""
p = posterior(x, fit)
cn = int(np.argmax(p))
pmax = float(p[cn])
qual = 60 if pmax >= 1.0 else int(min(60, max(0, round(-10 * math.log10(max(1e-300, 1 - pmax))))))
return cn, qual, p
@@ -0,0 +1,11 @@
# simurg PGx CN regions v1, GRCh38, from UCSC ncbiRefSeq (PCN-001)
chr1 109687816 109693745 GSTM1 GSTM1
chr4 68537172 68576322 UGT2B17 UGT2B17
chr10 133527362 133539123 CYP2E1 CYP2E1
chr16 28605257 28610044 SULT1A1 SULT1A1
chr19 40844772 40845293 CYP2A6.intron.7 CYP2A6
chr19 40849746 40850947 CYP2A6.exon.1 CYP2A6
chr22 42126498 42126752 CYP2D6.exon.9 CYP2D6
chr22 42129185 42129737 CYP2D6.intron.2 CYP2D6
chr22 42130810 42132810 CYP2D6.p5 CYP2D6
chr22_KI270879v1_alt 270313 278446 GSTT1 GSTT1