Files
SimurgArray/src/simurg/pgx/star.py
T
kemal b96ae21c70 PGx: CYP2D6 structures from sub-region CN — *68+*4, *36+*10, *13 (PGX-013/018)
Sub-region CNs (p5, intron 2, exon 9) resolve full copies and *36/*68/*13 units; candidates carry the
tandem names, background alleles, and CPIC activity sums. GeT-RM: CYP2D6 13/15 top (NA12878 *3/*68+*4 and
NA11832 *1/*68+*4 exact), 15/15 within feasible; all genes 132/144.
2026-09-29 16:29:54 +01:00

244 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Star-allele / haplotype calling from array genotypes — spec PGX-012 … PGX-020. Pure functions, no I/O.
Evidence model (PGX-012) [OURS]: each defining variant v of a gene is ``called`` (alt dosage g, GenCall score GS,
BAF), ``no_call``, ``masked`` or ``not_covered``; only called variants contribute. With diploid CN 2 the genotype
likelihood is P(g | d) = 1 − ε for the expected alt dosage d and ε/2 otherwise, with ε = 1e-3 for GS ≥ 0.15 and
0.05 below (calibrated: after probe masking, 0 of 9740 GSA v4 PGx calls at DB variants disagreed with 1000G 30x in any
GS bin — errors are probe-specific and handled by masks, not by GS; K-029). When the gene's copy number n ≠ 2
(targeted CN), the BAF likelihood N(b; k/n, σ_b) replaces the
genotype term (k = expected alt copies). Haplotype positions with an IUPAC value (CPIC core definitions) are wildcards:
the best-fitting dosage is used.
Candidates (PGX-013) [DIFF: solver-free]: every unordered pair of defined haplotypes, and for n ≠ 2 the structural
forms of CYP2D6-like genes: n = 1 → one whole-gene deletion haplotype; n = 0 → two; n ≥ 3 → one haplotype duplicated
(``*1x2``, n − 1 copies). Diplotypes that no called variant separates are array-equivalent (PGX-017) and form one
solution, named after the member with the highest prior. Scores (PGX-014): raw_score = softmax(LL); score =
softmax(LL + log π), π = Σ over members of f(h1)·f(h2) (×2 for h1 ≠ h2), f floored at 1e-5.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
import numpy as np
import numpy.typing as npt
F64 = npt.NDArray[np.float64]
FREQ_FLOOR = 1e-5
CALLED, NO_CALL, MASKED, NOT_COVERED = 0, 1, 2, 3
STATE_NAMES = {CALLED: "called", NO_CALL: "no_call", MASKED: "masked", NOT_COVERED: "not_on_array"}
@dataclass
class GeneModel:
gene: str
variants: list[tuple[str, int, str, str]] # (chrom, pos, ref, alt), one per defining variant
variant_ids: list[tuple[str, str | None]] # (hgvs, rsid)
haplotypes: list[str]
carries: npt.NDArray[np.int8] # (H, V): 1 carries alt, 0 reference, −1 wildcard
freq: F64 # (H,) population frequency of each haplotype (prior)
deletion_allele: str | None = None # named whole-gene deletion (CYP2D6 *5), or "*0"
reference: str = ""
basis: str = "star_allele" # PGX-002 result basis: star_allele | haplotype_named | variant_based | tagging_variant
activity: dict[str, float] = field(default_factory=dict) # allele → activity value (variant-based ordering)
function: dict[str, str] = field(default_factory=dict)
@dataclass
class Observations:
state: npt.NDArray[np.int8] # (V,)
dosage: npt.NDArray[np.int8] # alt dosage (0 … ploidy), −1 when not called
gs: F64
baf: F64
@dataclass
class Solution:
haplotypes: tuple[str, ...] # representative, e.g. ("*1", "*4") or ("*2x2", "*4")
ll: float
prior: float
members: list[tuple[str, ...]] = field(default_factory=list)
raw_score: float = 0.0
score: float = 0.0
copy_number: int = 2
copies: tuple[int, ...] = () # haplotype index of every gene copy of the representative (SNV model)
@property
def genotype(self) -> str:
return "/".join(self.haplotypes)
ERR_CALLED = 1e-3
ERR_LOW_GS = 0.05
LOW_GS = 0.15
def gs_error(gs: F64) -> F64:
"""PGX-012: per-call error rate from the GenCall score (calibrated, see the module docstring)."""
return np.where(np.nan_to_num(gs, nan=0.0) >= LOW_GS, ERR_CALLED, ERR_LOW_GS)
def _dosage_ll(obs: Observations, expected: npt.NDArray[np.int16], wild: npt.NDArray[np.bool_]) -> F64:
"""(D, V) log-likelihood of each candidate's expected dosage (CN 2 genotype model)."""
eps = gs_error(obs.gs)[None, :]
g = obs.dosage[None, :].astype(np.int16)
hit = (expected == g) | wild
return np.where(hit, np.log(1 - eps), np.log(eps / 2))
def _baf_ll(obs: Observations, frac_lo: F64, frac_hi: F64, sigma: float) -> F64:
"""(D, V) BAF log-likelihood for expected alt fractions (a wildcard spans [lo, hi]: the closest value is used)."""
b = np.nan_to_num(obs.baf, nan=0.5)[None, :]
near = np.clip(b, frac_lo, frac_hi)
return np.log(0.98 * np.exp(-0.5 * ((b - near) / sigma) ** 2) / (sigma * math.sqrt(2 * math.pi)) + 0.02)
def candidates(m: GeneModel, cn: int | None) -> list[tuple[tuple[int, ...], tuple[str, ...]]]:
"""(haplotype index per gene copy, display names) for every candidate diplotype (PGX-013)."""
h = len(m.haplotypes)
out: list[tuple[tuple[int, ...], tuple[str, ...]]] = []
if cn is None or cn == 2:
for i in range(h):
for j in range(i, h):
out.append(((i, j), (m.haplotypes[i], m.haplotypes[j])))
return out
dele = m.deletion_allele or "*0"
if cn == 0:
return [((), (dele, dele))]
if cn == 1:
return [((i,), (m.haplotypes[i], dele)) for i in range(h)]
extra = cn - 2 # copies beyond two: one haplotype duplicated (e.g. CN 3 → *2x2/*4)
for i in range(h):
for j in range(h):
out.append(((i,) * (extra + 1) + (j,), (f"{m.haplotypes[i]}x{extra + 1}", m.haplotypes[j])))
return out
@dataclass(frozen=True)
class Cyp2d6Structure:
"""CYP2D6 copies from the sub-region CNs (PGX-013) [OURS; DOC: PharmVar CYP2D6 structural variation]:
``full`` complete CYP2D6 copies, ``u36`` *36-type units (CYP2D7 exon 9), ``u68`` *68-type units (CYP2D6 exon 1
only), ``u13`` *13-type units (CYP2D7 5′ part). CN(p5) = full + u36 + u68, CN(intron 2) = full + u36 + u13,
CN(exon 9) = full + u13; with u68 · u13 = 0 this has one solution."""
full: int
u36: int
u68: int
u13: int
def cyp2d6_structure(p5: int | None, intron2: int | None, exon9: int | None) -> Cyp2d6Structure | None:
if p5 is None:
return None
ni = p5 if intron2 is None else intron2
n9 = ni if exon9 is None else exon9
u36 = max(0, ni - n9)
x = p5 - u36 - n9
if x >= 0:
full, u68, u13 = n9, x, 0
else:
full, u68, u13 = p5 - u36, 0, -x
if full < 0:
return Cyp2d6Structure(max(0, p5), 0, 0, 0)
return Cyp2d6Structure(full, u36, u68, u13)
def structure_candidates(m: GeneModel, st: Cyp2d6Structure) -> list[tuple[tuple[int, ...], tuple[str, ...]]]:
"""Candidates for a CYP2D6 structure: SNV copies = full + u36 (a *36 unit carries the *10 sequence up to exon 8);
*36 units attach to a *10 copy (``*36+*10``), *68 units to *4 when present (``*68+*4``), *13 units are separate
haplotypes."""
snv_copies = st.full + st.u36
base = candidates(m, snv_copies)
i10 = m.haplotypes.index("*10") if "*10" in m.haplotypes else None
out = []
for ix, names in base:
names_l = list(names)
if st.u36:
if i10 is None or ix.count(i10) < st.u36 + 1: # the *36 unit(s) plus the *10 they precede
continue
k = next((n for n, x in enumerate(names_l) if x.startswith("*10")), None)
if k is None:
continue
unit = "*36" if st.u36 == 1 else f"*36x{st.u36}"
names_l[k] = f"{unit}+*10"
if st.u68:
unit = "*68" if st.u68 == 1 else f"*68x{st.u68}"
k = next((n for n, x in enumerate(names_l) if x.split("+")[-1].split("x")[0] == "*4"), 0)
names_l[k] = f"{unit}+{names_l[k]}"
if st.u13:
if len(names_l) == 2 and names_l[1] == (m.deletion_allele or "*0"):
names_l[1] = "*13" if st.u13 == 1 else f"*13x{st.u13}"
else:
names_l[0] = f"*13+{names_l[0]}"
out.append((ix, tuple(names_l)))
return out
def call_gene(
m: GeneModel, obs: Observations, cn: int | None = None, baf_sigma: float = 0.06, ploidy: int = 2,
max_candidates: int = 10, feasible_ratio: float = 0.5, structure: Cyp2d6Structure | None = None,
) -> tuple[list[Solution], list[Solution]]: # fmt: skip
"""All scored solutions (ranked) and the feasible ones (PGX-014/015)."""
called = obs.state == CALLED
haploid: list[tuple[tuple[int, ...], tuple[str, ...]]] = [((i,), (m.haplotypes[i],)) for i in
range(len(m.haplotypes))] # fmt: skip
if ploidy == 1:
cands = haploid
elif structure is not None and (structure.u36 or structure.u68 or structure.u13):
cands = structure_candidates(m, structure)
cn = structure.full + structure.u36
else:
cands = candidates(m, cn)
if not cands:
return [], []
carries = m.carries.astype(np.int16)
lo = np.zeros((len(cands), carries.shape[1]))
hi = np.zeros_like(lo)
for k, (ix, _) in enumerate(cands):
for i in ix:
c = carries[i]
lo[k] += np.where(c < 0, 0, c)
hi[k] += np.where(c < 0, 1, c)
ncp = np.array([max(1, len(ix)) for ix, _ in cands], dtype=np.float64)[:, None]
if ploidy == 1 or cn is None or cn == 2:
wild = hi != lo
ll_v = _dosage_ll(obs, lo.astype(np.int16), wild)
if wild.any(): # a wildcard may also take the higher dosage
ll_v = np.maximum(ll_v, _dosage_ll(obs, hi.astype(np.int16), wild))
else:
ll_v = _baf_ll(obs, lo / ncp, hi / ncp, baf_sigma)
ll = (ll_v * called[None, :]).sum(axis=1)
f = np.maximum(m.freq, FREQ_FLOOR)
# array-equivalence classes: identical expected dosages at every called variant (PGX-017)
keys: dict[bytes, list[int]] = {}
for k in range(len(cands)):
sig = np.round(np.concatenate([lo[k, called], hi[k, called]]) / ncp[k, 0], 6).tobytes()
keys.setdefault(sig + str(len(cands[k][0])).encode(), []).append(k)
sols = []
for members in keys.values():
pri = []
for k in members:
ix = cands[k][0]
if not ix:
pri.append(1e-6)
continue
p = float(np.prod([f[i] for i in ix]))
if len(set(ix)) > 1:
p *= 2
pri.append(p)
best = members[int(np.argmax(pri))]
sols.append(Solution(cands[best][1], float(ll[best]), float(sum(pri)), [cands[k][1] for k in members],
copy_number=cn if cn is not None else 2, copies=cands[best][0])) # fmt: skip
lls = np.array([s.ll for s in sols])
lp = lls + np.log([s.prior for s in sols])
raw = np.exp(lls - lls.max())
sc = np.exp(lp - lp.max())
raw, sc = raw / raw.sum(), sc / sc.sum()
for s, r, v in zip(sols, raw, sc, strict=True):
s.raw_score, s.score = float(r), float(v)
sols.sort(key=lambda s: (-s.score, -s.raw_score, s.genotype))
top = sols[0].score
feasible = [s for s in sols if s.score >= feasible_ratio * top]
return sols[: max(max_candidates, len(feasible))], feasible