CN: MF split normalised by per-probe reference BAF noise; genotype-based complete loss (CYTO-041)
The mosaic BAF split is now estimated over heterozygous calls against each probe's expected noise (the reference heterozygous spread scaled to the sample), inverting the median of |N(+-d, sigma)|. A mosaic loss lacking the heterozygous calls its PFB predicts gets MF 1 and is promoted (CYTO-020).
This commit is contained in:
1 parent
6bc42e53b7
commit
803f4aaec3
4 files changed
+90
-31
No files matched your search
+50
-17
@@ -20,8 +20,19 @@ F64 = npt.NDArray[np.float64]
|
||||
I64 = npt.NDArray[np.int64]
|
||||
GAP_FACTOR = 150
|
||||
MIN_MF_PROBES = 20
|
||||
LOH_EXPECTED_HETS = 5.0 # CYTO-041: expected heterozygous probes needed to call a loss complete
|
||||
LOH_HET_RATIO = 0.25 # CYTO-041: observed / expected heterozygotes below which a loss is complete
|
||||
LOSS_EXPECTED_HETS = 3.0 # CYTO-041: expected heterozygous calls needed to call a loss complete
|
||||
LOSS_HET_RATIO = 0.25 # CYTO-041: observed / expected heterozygous calls below which a loss is complete
|
||||
|
||||
|
||||
def complete_loss(geno: npt.NDArray[np.uint8], pfb: F64) -> bool:
|
||||
"""CYTO-041 [OURS]: a loss is complete when its called genotypes lack the heterozygotes the PFB predicts
|
||||
(Σ 2p(1 − p) ≥ 3 expected, fewer than a quarter observed); a mosaic loss keeps its heterozygotes."""
|
||||
called = geno > 0
|
||||
p = np.clip(np.where(np.isfinite(pfb), pfb, 0.5), 0.0, 1.0)[called]
|
||||
expected = float((2 * p * (1 - p)).sum())
|
||||
return expected >= LOSS_EXPECTED_HETS and float((geno[called] == 2).sum()) < LOSS_HET_RATIO * expected
|
||||
|
||||
|
||||
TYPES = ("del", "dup", "loh", "gainloh", "mdel", "mdup", "mloh")
|
||||
DEFAULTS = {"del": (10, 25_000), "dup": (10, 50_000), "loh": (500, 3_000_000), "gainloh": (300, 500_000),
|
||||
"mdel": (10, 25_000), "mdup": (10, 50_000), "mloh": (500, 3_000_000)} # fmt: skip
|
||||
@@ -117,21 +128,39 @@ def extract(chrom: str, path: npt.NDArray[np.int32], states: list[State], pos: I
|
||||
return out
|
||||
|
||||
|
||||
def _mf_from_baf(call: str, baf: F64, baf_sd: float = 0.0, pfb: F64 | None = None) -> float | None:
|
||||
"""CYTO-018 with the CYTO-041 refinements [OURS]: the split is corrected for BAF noise (the median |BAF − 0.5| of
|
||||
unsplit heterozygotes is 0.6745·σ_b), and a loss whose expected heterozygotes are absent is complete (MF 1)."""
|
||||
fin = np.isfinite(baf)
|
||||
mid = fin & (baf > 0.1) & (baf < 0.9)
|
||||
b = baf[mid]
|
||||
if call == "MDEL" and pfb is not None and fin.sum() >= MIN_MF_PROBES:
|
||||
p = np.clip(np.where(np.isfinite(pfb), pfb, 0.5), 0.0, 1.0)[fin]
|
||||
expected = float((2 * p * (1 - p)).sum())
|
||||
if expected >= LOH_EXPECTED_HETS and len(b) < LOH_HET_RATIO * expected:
|
||||
return 1.0
|
||||
def _phi(x: float) -> float:
|
||||
return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
|
||||
|
||||
|
||||
def _split_from_median(m: float) -> float:
|
||||
"""δ ≥ 0 with median |N(±δ, 1)| = m (bisection; 0 when m ≤ 0.6745)."""
|
||||
if _phi(m) - _phi(-m) <= 0.5:
|
||||
return 0.0
|
||||
lo, hi = 0.0, m + 10.0
|
||||
for _ in range(60):
|
||||
mid = 0.5 * (lo + hi)
|
||||
if _phi(m - mid) - _phi(-m - mid) > 0.5:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
return 0.5 * (lo + hi)
|
||||
|
||||
|
||||
def _mf_from_baf(call: str, baf: F64, sigma: F64 | float = 0.0) -> float | None:
|
||||
"""CYTO-018 with the CYTO-041 noise correction [OURS]. ``baf``: heterozygous-call BAF of the event; ``sigma``:
|
||||
each probe's expected BAF noise (reference heterozygous spread × the sample's scale). With m = median(|BAF − 0.5|
|
||||
/ σ), the split δ (in σ units) solves Φ(m − δ) − Φ(−m − δ) = ½ (the median of |N(±δ, 1)|; δ = 0 when
|
||||
m ≤ 0.6745, i.e. no more spread than noise), and d = median(σ)·δ."""
|
||||
sig = np.broadcast_to(np.asarray(sigma, dtype=np.float64), baf.shape)
|
||||
ok = np.isfinite(baf) & (baf > 0.1) & (baf < 0.9) & np.isfinite(sig)
|
||||
b, sg = baf[ok], sig[ok]
|
||||
if len(b) < MIN_MF_PROBES:
|
||||
return None
|
||||
d_obs = float(np.median(np.abs(b - 0.5)))
|
||||
d = math.sqrt(max(0.0, d_obs**2 - (0.6745 * baf_sd) ** 2))
|
||||
if float(np.median(sg)) > 0:
|
||||
sg = np.maximum(sg, 1e-3)
|
||||
d = float(np.median(sg)) * _split_from_median(float(np.median(np.abs(b - 0.5) / sg)))
|
||||
else:
|
||||
d = float(np.median(np.abs(b - 0.5)))
|
||||
if call == "MDEL":
|
||||
f = 2 * d / (0.5 + d)
|
||||
elif call == "MDUP":
|
||||
@@ -151,7 +180,7 @@ def _mf_from_lrr(call: str, lrr: F64, gamma: float) -> float | None:
|
||||
|
||||
def annotate_stats(
|
||||
ev: Event, pos: I64, lrr: F64, baf: F64, geno: npt.NDArray[np.uint8], post: F64, states: list[State], m: float,
|
||||
gamma: float, haploid: bool, baf_sd: float = 0.0, pfb: F64 | None = None,
|
||||
gamma: float, haploid: bool, sigma: F64 | None = None, pfb: F64 | None = None,
|
||||
) -> None: # fmt: skip
|
||||
"""CYTO-010/012/018/027 statistics in place (``lrr`` unsmoothed)."""
|
||||
sl = slice(ev.i0, ev.i1)
|
||||
@@ -170,7 +199,11 @@ def annotate_stats(
|
||||
p = float(post[sl][:, cls].sum(axis=1).mean()) if cls else 0.0
|
||||
ev.quality = 60.0 if p >= 1.0 else float(min(60, max(0, round(-10 * math.log10(max(1e-300, 1 - p))))))
|
||||
if ev.mosaic:
|
||||
mf = None if haploid else _mf_from_baf(ev.call, baf[sl], baf_sd, None if pfb is None else pfb[sl])
|
||||
if not haploid and ev.call == "MDEL" and pfb is not None and complete_loss(g, pfb[sl]):
|
||||
ev.mosaic_fraction = 1.0
|
||||
return
|
||||
hb = np.where(g == 2, baf[sl], np.nan) if sigma is not None else baf[sl]
|
||||
mf = None if haploid else _mf_from_baf(ev.call, hb, 0.0 if sigma is None else sigma[sl])
|
||||
ev.mosaic_fraction = mf if mf is not None else _mf_from_lrr(ev.call, x, gamma)
|
||||
|
||||
|
||||
|
||||
@@ -288,6 +288,26 @@ def _resolve_sex(si: SampleInput, chroms: npt.NDArray[np.str_], lrr: F64, geno:
|
||||
return "U", "unresolved", ["sex_unresolved"]
|
||||
|
||||
|
||||
def _baf_noise(
|
||||
baf: npt.NDArray[np.float64], geno: npt.NDArray[np.uint8], auto: npt.NDArray[np.bool_],
|
||||
ref_het_sd: npt.NDArray[np.float32] | None, baf_sd: float,
|
||||
) -> npt.NDArray[np.float64]: # fmt: skip
|
||||
"""CYTO-041: expected heterozygous BAF noise per probe, the reference spread scaled to this sample
|
||||
(median |BAF − 0.5| / spread over autosomal heterozygous calls = 0.6745 × scale); ``baf_sd`` without a reference
|
||||
spread or where a probe has none."""
|
||||
out = np.full(len(baf), baf_sd, dtype=np.float64)
|
||||
if ref_het_sd is None:
|
||||
return out
|
||||
h = np.asarray(ref_het_sd, dtype=np.float64)
|
||||
hc = auto & (geno == 2) & np.isfinite(baf) & np.isfinite(h) & (h > 0)
|
||||
if hc.sum() < 100:
|
||||
return out
|
||||
scale = float(np.median(np.abs(baf[hc] - 0.5) / np.maximum(h[hc], 1e-3))) / 0.6745
|
||||
ok = np.isfinite(h) & (h > 0)
|
||||
out[ok] = np.maximum(h[ok], 1e-3) * scale
|
||||
return out
|
||||
|
||||
|
||||
def call_sample(si: SampleInput, ctx: dict[str, Any], opts: CnCallOptions) -> dict[str, Any]:
|
||||
"""One sample: preprocessing, per-region HMM, events, VCF + segments. Returns the summary row + events."""
|
||||
from simurg.cn import hmm
|
||||
@@ -346,6 +366,7 @@ def call_sample(si: SampleInput, ctx: dict[str, Any], opts: CnCallOptions) -> di
|
||||
seg_sd = _robust_sd(lrr[auto], 0.05)
|
||||
b_het = baf[auto & (baf > 0.3) & (baf < 0.7)]
|
||||
baf_sd = _robust_sd(b_het, 0.02)
|
||||
sigma = _baf_noise(baf, geno, auto, model.ref_het_sd, baf_sd) # CYTO-041
|
||||
dip, hap = hmm.diploid_states(opts.mosaic_prior), hmm.haploid_states(opts.mosaic_prior)
|
||||
counts: dict[str, int] = {}
|
||||
events: list[Any] = []
|
||||
@@ -392,7 +413,7 @@ def call_sample(si: SampleInput, ctx: dict[str, Any], opts: CnCallOptions) -> di
|
||||
post = hmm.forward_backward(e, sw, prior)
|
||||
evs = extract(chrom, path, states, p)
|
||||
for ev in evs:
|
||||
annotate_stats(ev, p, x_raw, b, gt, post, states, m_sp, ctx["gamma"], haploid, baf_sd, pfb[idx])
|
||||
annotate_stats(ev, p, x_raw, b, gt, post, states, m_sp, ctx["gamma"], haploid, sigma[idx], pfb[idx])
|
||||
evs = post_filter(evs, [x_raw[ev.i0 : ev.i1] for ev in evs], th, counts)
|
||||
for s_, e_ in _runs(path):
|
||||
st = states[int(path[s_])]
|
||||
|
||||
Reference in new issue
Block a user