Files
SimurgArray/src/simurg/pgx/star.py
T
kemal abea028818 PGx: full call detail (PGX-016..020, 027/028, 031), pgx coverage (PGX-009/035), full HGVS
- supporting_variants.tsv, unresolved_variants.tsv, variants_not_covered.tsv with PGX-026 metadata; per-allele
  supporting/unresolved sites for both alleles of every candidate; variant_change vs top, distant_alternative,
  unexplained_variants, alleles_impacted_by_no_call, alleles_tested.
- simurg pgx coverage: callable / not_covered / masked / equivalent_to per allele and manifest.
- HGVS on GRCh38 RefSeq accessions (NC_...:g.).
2026-09-29 16:16:39 +01:00

174 lines
7.5 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 = ""
@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
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,
) -> tuple[list[Solution], list[Solution]]: # fmt: skip
"""All scored solutions (ranked) and the feasible ones (PGX-014/015)."""
called = obs.state == CALLED
haploid = [((i,), (m.haplotypes[i],)) for i in range(len(m.haplotypes))]
cands = haploid if ploidy == 1 else 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