CN: MF split normalised by per-probe reference BAF noise; genotype-based complete loss (CYTO-041)
ci / test (3.12) (push) Failing after 9s
ci / test (3.13) (push) Failing after 11s

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

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 10:56:21 +01:00
1 parent 19bc6ca986
commit 662addf5eb
4 files changed
+90 -31

No files matched your search

+9 -7
View File
@@ -2437,13 +2437,15 @@ about **algorithms or numerics**, the plan wins. Each such conflict found during
- **CYTO-041** `§10.3 · v2` **MF refinements [OURS].** Two refinements of CYTO-018, from real-data validation - **CYTO-041** `§10.3 · v2` **MF refinements [OURS].** Two refinements of CYTO-018, from real-data validation
(docs/validation/V2_cn.md, K-027): (docs/validation/V2_cn.md, K-027):
- *noise-corrected split:* the median |BAF − 0.5| of unsplit heterozygotes is 0.6745·σ_b (σ_b = the sample's - *noise-corrected split:* per-probe expected noise σ_i = the reference's heterozygous BAF spread of the probe ×
heterozygous BAF SD), so `d = √max(0, d_obs² − (0.6745·σ_b)²)`. Without this, noise alone gave MF ≈ 0.2 in the sample's scale (median |BAF − 0.5| / spread over autosomal heterozygous calls, ÷ 0.6745; the sample's
diploid regions and waves were called as mosaic losses. heterozygous BAF SD without a reference spread). Over the event's heterozygous calls, m = median(|BAF − 0.5| /
- *complete loss:* a mosaic loss with ≥ 20 finite-BAF probes, ≥ 5 expected heterozygotes (Σ 2p(1−p) over PFB p), σ_i); the split in σ units δ solves Φ(m − δ) − Φ(−m − δ) = ½ (δ = 0 when m ≤ 0.6745) and d = median(σ_i)·δ.
and fewer than 0.25 × expected probes with 0.1 < BAF < 0.9 has MF = 1. With the default Without this, noise alone gave MF ≈ 0.1–0.3 and genomic waves were called as mosaic losses.
`--max-mosaic-fraction 1.0` it is therefore promoted (CYTO-020) to a constitutional DEL with `HIGHFRACTION`. - *complete loss:* a mosaic loss whose called genotypes hold fewer than a quarter of the heterozygotes the PFB
This catches constitutional deletions whose LRR is compressed towards the mosaic states. predicts (Σ 2p(1−p) ≥ 3) has MF = 1. With the default `--max-mosaic-fraction 1.0` it is therefore promoted
(CYTO-020) to a constitutional DEL with `HIGHFRACTION`. This catches constitutional deletions whose LRR is
compressed towards the mosaic states.
--- ---
+50 -17
View File
@@ -20,8 +20,19 @@ F64 = npt.NDArray[np.float64]
I64 = npt.NDArray[np.int64] I64 = npt.NDArray[np.int64]
GAP_FACTOR = 150 GAP_FACTOR = 150
MIN_MF_PROBES = 20 MIN_MF_PROBES = 20
LOH_EXPECTED_HETS = 5.0 # CYTO-041: expected heterozygous probes needed to call a loss complete LOSS_EXPECTED_HETS = 3.0 # CYTO-041: expected heterozygous calls needed to call a loss complete
LOH_HET_RATIO = 0.25 # CYTO-041: observed / expected heterozygotes below which a loss is 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") 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), 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 "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 return out
def _mf_from_baf(call: str, baf: F64, baf_sd: float = 0.0, pfb: F64 | None = None) -> float | None: def _phi(x: float) -> float:
"""CYTO-018 with the CYTO-041 refinements [OURS]: the split is corrected for BAF noise (the median |BAF − 0.5| of return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
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) def _split_from_median(m: float) -> float:
b = baf[mid] """δ ≥ 0 with median |N(±δ, 1)| = m (bisection; 0 when m ≤ 0.6745)."""
if call == "MDEL" and pfb is not None and fin.sum() >= MIN_MF_PROBES: if _phi(m) - _phi(-m) <= 0.5:
p = np.clip(np.where(np.isfinite(pfb), pfb, 0.5), 0.0, 1.0)[fin] return 0.0
expected = float((2 * p * (1 - p)).sum()) lo, hi = 0.0, m + 10.0
if expected >= LOH_EXPECTED_HETS and len(b) < LOH_HET_RATIO * expected: for _ in range(60):
return 1.0 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: if len(b) < MIN_MF_PROBES:
return None return None
d_obs = float(np.median(np.abs(b - 0.5))) if float(np.median(sg)) > 0:
d = math.sqrt(max(0.0, d_obs**2 - (0.6745 * baf_sd) ** 2)) 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": if call == "MDEL":
f = 2 * d / (0.5 + d) f = 2 * d / (0.5 + d)
elif call == "MDUP": elif call == "MDUP":
@@ -151,7 +180,7 @@ def _mf_from_lrr(call: str, lrr: F64, gamma: float) -> float | None:
def annotate_stats( def annotate_stats(
ev: Event, pos: I64, lrr: F64, baf: F64, geno: npt.NDArray[np.uint8], post: F64, states: list[State], m: float, 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 ) -> None: # fmt: skip
"""CYTO-010/012/018/027 statistics in place (``lrr`` unsmoothed).""" """CYTO-010/012/018/027 statistics in place (``lrr`` unsmoothed)."""
sl = slice(ev.i0, ev.i1) 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 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)))))) 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: 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) ev.mosaic_fraction = mf if mf is not None else _mf_from_lrr(ev.call, x, gamma)
+22 -1
View File
@@ -288,6 +288,26 @@ def _resolve_sex(si: SampleInput, chroms: npt.NDArray[np.str_], lrr: F64, geno:
return "U", "unresolved", ["sex_unresolved"] 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]: 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.""" """One sample: preprocessing, per-region HMM, events, VCF + segments. Returns the summary row + events."""
from simurg.cn import hmm 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) seg_sd = _robust_sd(lrr[auto], 0.05)
b_het = baf[auto & (baf > 0.3) & (baf < 0.7)] b_het = baf[auto & (baf > 0.3) & (baf < 0.7)]
baf_sd = _robust_sd(b_het, 0.02) 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) dip, hap = hmm.diploid_states(opts.mosaic_prior), hmm.haploid_states(opts.mosaic_prior)
counts: dict[str, int] = {} counts: dict[str, int] = {}
events: list[Any] = [] 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) post = hmm.forward_backward(e, sw, prior)
evs = extract(chrom, path, states, p) evs = extract(chrom, path, states, p)
for ev in evs: 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) evs = post_filter(evs, [x_raw[ev.i0 : ev.i1] for ev in evs], th, counts)
for s_, e_ in _runs(path): for s_, e_ in _runs(path):
st = states[int(path[s_])] st = states[int(path[s_])]
+9 -6
View File
@@ -12,7 +12,7 @@ from typer.testing import CliRunner
from simurg.cli.main import app from simurg.cli.main import app
from simurg.cn import hmm from simurg.cn import hmm
from simurg.cn.events import Event, Thresholds, _mf_from_baf, apply_thresholds, effective_size, extract from simurg.cn.events import Event, Thresholds, _mf_from_baf, apply_thresholds, complete_loss, effective_size, extract
from simurg.cn.gcwave import gc_correct from simurg.cn.gcwave import gc_correct
from simurg.cn.model import gc_fractions, population_bfreq from simurg.cn.model import gc_fractions, population_bfreq
from simurg.formats.gtc import Gtc, write_gtc from simurg.formats.gtc import Gtc, write_gtc
@@ -237,12 +237,15 @@ def test_mf_refinements() -> None: # CYTO-018 / CYTO-041
b = 0.5 + rng.normal(0, sd, 400) b = 0.5 + rng.normal(0, sd, 400)
mf = _mf_from_baf("MDEL", b, sd) mf = _mf_from_baf("MDEL", b, sd)
assert mf is not None and mf < 0.08 assert mf is not None and mf < 0.08
assert (_mf_from_baf("MDEL", b, 0.0) or 0) > 0.05 # uncorrected, the old estimate was biased upwards assert (_mf_from_baf("MDEL", b) or 0) > 0.05 # uncorrected, the old estimate was biased upwards
# a true 40 % mosaic loss is still recovered # a true 40 % mosaic loss is still recovered
d = 0.4 / (2 * (2 - 0.4)) d = 0.4 / (2 * (2 - 0.4))
b = 0.5 + rng.choice([-d, d], 400) + rng.normal(0, sd, 400) b = 0.5 + rng.choice([-d, d], 400) + rng.normal(0, sd, 400)
assert abs((_mf_from_baf("MDEL", b, sd) or 0) - 0.4) < 0.08 assert abs((_mf_from_baf("MDEL", b, sd) or 0) - 0.4) < 0.08
# no heterozygotes where ~50 are expected: a complete (constitutional) loss # no heterozygous calls where ~12 are expected: a complete (constitutional) loss
b = rng.choice([0.0, 1.0], 100) + rng.normal(0, 0.01, 100) hom = np.full(30, 1, dtype=np.uint8)
assert _mf_from_baf("MDEL", np.clip(b, 0, 1), sd, np.full(100, 0.3)) == 1.0 assert complete_loss(hom, np.full(30, 0.3))
assert _mf_from_baf("MDEL", np.clip(b, 0, 1), sd, np.full(100, 0.01)) is None # too few expected assert not complete_loss(hom, np.full(30, 0.02)) # too few expected
mixed = hom.copy()
mixed[:8] = 2
assert not complete_loss(mixed, np.full(30, 0.3))