Files
SimurgArray/scripts/v2/cn_synthetic_eval.py
T
kemalandClaude Opus 5.5 88f41314b8
ci / test (3.12) (push) Failing after 8s
ci / test (3.13) (push) Failing after 10s
v2 CN: synthetic implant evaluation (CYTO-022); reference GTC discovery follows symlinks
216 implanted events in 22 synthetic GSA samples (4 replicates per kind x fraction x
size; reference model from 30 other samples): constitutional DEL/DUP 16/16 at 5-20 Mb,
mosaic loss/gain/CN-LOH at 15 % / 5 Mb 12/12.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01814GKVzZVL3FuF3DeiZXhv
2026-09-29 01:30:07 +01:00

148 lines
7.3 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.
#!/usr/bin/env python3
"""Sensitivity of `simurg cn call` on implanted events (spec CYTO-022, plan §16.4).
Real GTCs of normal samples are copied and events are written into their LRR/BAF:
constitutional DEL (CN1: LRR − 0.45, heterozygous BAF → 0/1), DUP (CN3: LRR + 0.3, het → 1/3 or 2/3), LOH (het → 0/1);
mosaic loss / gain / CN-LOH with cell fraction f (LRR shifted by f × the constitutional shift, heterozygous BAF
moved to 0.5 ± d(f), sign random per probe as in unphased data), keeping each probe's original noise.
Every synthetic sample gets several events on different chromosomes. `simurg cn call` then runs on them and an
implanted event counts as detected when a called event of the same class (loss / gain / LOH) overlaps ≥ 50 % of it.
python scripts/v2/cn_synthetic_eval.py --bpm M.bpm --model MODEL --gtcs BASE_DIR --out OUT [--threads N]
"""
from __future__ import annotations
import argparse
import copy
import gzip
import json
import subprocess
import sys
from pathlib import Path
import numpy as np
from simurg.formats.bpm import read_bpm
from simurg.formats.discovery import discover_gtcs
from simurg.formats.gtc import read_gtc, write_gtc
KINDS = [("DEL", 1.0), ("DUP", 1.0), ("LOH", 1.0)] + [
(k, f) for k in ("MDEL", "MDUP", "MLOH") for f in (0.05, 0.10, 0.15, 0.20, 0.30)
]
SIZES = (1_000_000, 5_000_000, 20_000_000)
CLASS = {"DEL": "loss", "MDEL": "loss", "DUP": "gain", "MDUP": "gain", "LOH": "loh", "MLOH": "loh", "GAINLOH": "gain"}
CHROMS = [str(c) for c in range(1, 21)] # leave 21, 22, X, Y untouched
def implant(lrr: np.ndarray, baf: np.ndarray, idx: np.ndarray, kind: str, f: float, rng: np.random.Generator) -> None:
het = (baf[idx] > 0.15) & (baf[idx] < 0.85)
h = idx[het]
noise = baf[h] - 0.5
sign = rng.choice([-1.0, 1.0], len(h))
if kind in ("DEL", "LOH"):
if kind == "DEL":
lrr[idx] -= 0.45
baf[h] = np.clip(np.where(sign > 0, 1.0, 0.0) + noise * 0.2, 0, 1)
elif kind == "DUP":
lrr[idx] += 0.30
baf[h] = np.clip(np.where(sign > 0, 2 / 3, 1 / 3) + noise, 0, 1)
else:
d = {"MDEL": f / (2 * (2 - f)), "MDUP": f / (2 * (2 + f)), "MLOH": f / 2}[kind]
lrr[idx] += {"MDEL": -0.45 * f, "MDUP": 0.30 * f, "MLOH": 0.0}[kind]
baf[h] = np.clip(0.5 + sign * d + noise, 0, 1)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--bpm", required=True)
ap.add_argument("--model", required=True)
ap.add_argument("--gtcs", required=True, type=Path, help="normal base samples (not in the model's reference)")
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--threads", type=int, default=0)
ap.add_argument("--seed", type=int, default=7)
ap.add_argument("--reps", type=int, default=4, help="replicates of every (kind, fraction, size) cell")
a = ap.parse_args()
rng = np.random.default_rng(a.seed)
man = read_bpm(a.bpm)
chroms = np.array(man.chroms)
pos = man.map_infos.astype(np.int64)
base = sorted(discover_gtcs(a.gtcs).values())
syn = a.out / "gtcs"
syn.mkdir(parents=True, exist_ok=True)
events = [(k, f, s) for k, f in KINDS for s in SIZES for _ in range(a.reps)]
rng.shuffle(events)
truth = []
per_sample = len(CHROMS) // 2 # one event per chromosome pair slot, spaced out
for n, start in enumerate(range(0, len(events), per_sample)):
chunk = events[start : start + per_sample]
src = base[n % len(base)]
g = copy.deepcopy(read_gtc(src))
lrr = g.logr_ratios.astype(np.float64)
baf = g.b_allele_freqs.astype(np.float64)
chosen = rng.permutation(CHROMS)[: len(chunk)]
sid = f"syn{n:03d}"
for (kind, f, size), c in zip(chunk, chosen, strict=True):
sel = np.flatnonzero((chroms == c) & (pos > 0))
lo, hi = int(pos[sel].min()), int(pos[sel].max())
s0 = int(rng.integers(lo + 2_000_000, max(lo + 2_000_001, hi - size - 2_000_000)))
idx = sel[(pos[sel] >= s0) & (pos[sel] < s0 + size)]
implant(lrr, baf, idx, kind, f, rng)
truth.append({"sample": sid, "chrom": c, "start": s0, "end": s0 + size - 1, "kind": kind, "fraction": f,
"size": size, "probes": len(idx), "base": src.stem}) # fmt: skip
g.logr_ratios = lrr.astype(np.float32)
g.b_allele_freqs = baf.astype(np.float32)
(syn / sid).mkdir(exist_ok=True)
write_gtc(g, syn / sid / f"{sid}.gtc")
(a.out / "truth.json").write_text(json.dumps(truth, indent=1))
simurg = str(Path(sys.executable).with_name("simurg"))
cmd = [simurg, "cn", "call", "--bpm", a.bpm, "--model", a.model, "--gtcs", str(syn), "--out", str(a.out / "call"),
"--threads", str(a.threads), "--unknown-sex-policy", "female", "--force", "--quiet"] # fmt: skip
subprocess.run(cmd, check=True)
calls: dict[str, list[tuple[str, int, int, str, str]]] = {}
for vcf in (a.out / "call").glob("*.cyto.cnv.vcf.gz"):
sid = vcf.name.split(".")[0]
with gzip.open(vcf, "rt") as fh:
lines = [ln for ln in fh if not ln.startswith("#")]
for line in lines:
c, p, vid, _, _alt, _q, filt, info = line.split("\t")[:8]
end = int(next(x for x in info.split(";") if x.startswith("END="))[4:])
call = vid.split(":")[0]
cls = {"DEL": "loss", "MosaicDEL": "loss", "DUP": "gain", "MosaicDUP": "gain", "AOH": "loh",
"CNLOH": "loh", "GAINLOH": "gain"}[call] # fmt: skip
calls.setdefault(sid, []).append((c, int(p) + 1, end, cls, filt))
for t in truth:
cover = 0
seen = []
for c, s, e, cls, filt in calls.get(t["sample"], []):
ov = max(0, min(e, t["end"]) - max(s, t["start"]) + 1) if c == t["chrom"] else 0
if ov:
seen.append(f"{cls}:{filt}:{ov / (t['end'] - t['start'] + 1):.2f}")
if cls == CLASS[t["kind"]]:
cover += ov # fragments of one event add up
frac = cover / (t["end"] - t["start"] + 1)
t["detected"] = frac >= 0.5
t["overlap"] = round(frac, 3)
t["overlapping_calls"] = seen
cells: dict[str, dict[str, list[bool]]] = {}
for t in truth:
key = t["kind"] if t["fraction"] == 1.0 else f"{t['kind']} f={t['fraction']:.2f}"
cells.setdefault(key, {}).setdefault(f"{t['size'] // 1_000_000} Mb", []).append(t["detected"])
table = {k: {s: f"{sum(v)}/{len(v)}" for s, v in d.items()} for k, d in cells.items()}
n_calls = sum(len(v) for v in calls.values())
implanted_hits = sum(t["detected"] for t in truth)
summary = {"events": len(truth), "detected": implanted_hits, "table": table, "calls_total": n_calls}
(a.out / "summary.json").write_text(json.dumps(summary, indent=1))
(a.out / "truth_detected.json").write_text(json.dumps(truth, indent=1))
for k, v in table.items():
print(f"{k:14s} " + " ".join(f"{s}: {v.get(s, '-')}" for s in ("1 Mb", "5 Mb", "20 Mb")))
print(f"detected {implanted_hits}/{len(truth)}; {n_calls} calls in {len(calls)} samples")
for t in truth:
if not t["detected"] and t["kind"] in ("DUP", "DEL", "LOH") and t["size"] >= 5_000_000:
print(" missed", t["kind"], t["size"], t["probes"], "probes:", t["overlapping_calls"])
return 0
if __name__ == "__main__":
raise SystemExit(main())