ArrayTrain (plan §12): simurg train — EGT cluster files from a cohort's GTCs

- simurg/train/clusters.py: vectorised per-locus θ mixture (AA/AB/BB + outlier component, θ-ordered bounds),
  cluster selection and merging, lone clusters homozygous, missing clusters imputed by cross-locus regression
  per assay type, cluster_sep and train (GenTrain-field) scores.
- formats/egt.py: write_egt (EGT v3, data block 9, round-trips through the reader).
- pipeline/train.py: normalised θ/R from GTC raw X/Y + transforms, sex-aware (X females, Y males, MT haploid),
  intensity-only loci as one R cluster.
- scripts/train/concordance_chr.py: array vs 1000G 30x on one chromosome.
Demo (36 GSA v4 PGx arrays, chr22 vs 1000G): trained 99.29 % / NRD 2.6 % vs vendor EGT 99.57 % / 1.55 %.
This commit is contained in:
kemal committed 2026-09-29 19:53:53 +01:00
1 parent cfae926fbc
commit 7cfb0fd962
9 files changed
+618

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
"""``simurg train`` — ArrayTrain: an EGT cluster file from a cohort's GTCs (plan §12). I/O and orchestration; the
numerics live in ``simurg.train.clusters``.
Normalised intensities come from each GTC's raw X/Y and normalisation transforms (independent of the cluster file the
GTC was called with). Sex-aware training: X loci (outside the PAR) from female samples, Y loci from males (haploid),
MT haploid, PAR and autosomes from everyone; the sex is the GTC's ``gender`` field. Intensity-only loci get a single
R cluster.
"""
from __future__ import annotations
import datetime as _dt
import json
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
import numpy.typing as npt
from simurg import __version__
from simurg.core.errors import ExitCode, SimurgError
from simurg.core.log import Reporter
BLOCK = 2000
@dataclass
class TrainOptions:
gtcs: Path
bpm: Path
out: Path
min_samples: int = 100
max_samples: int = 2000
force: bool = False
argv: list[str] = field(default_factory=list)
def _load(paths: list[Path], lookup: npt.NDArray[np.int32], rep: Reporter) -> tuple[np.ndarray, np.ndarray, list[str]]:
from simurg.formats.gtc import read_gtc
from simurg.signal.normalization.apply import normalize, polar
n = len(lookup)
theta = np.full((len(paths), n), np.nan, dtype=np.float32)
r = np.full((len(paths), n), np.nan, dtype=np.float32)
sexes = []
for i, p in enumerate(paths):
g = read_gtc(p)
if len(g.raw_x) != n:
raise SimurgError("E_EGT_MANIFEST_MISMATCH", f"{p} has {len(g.raw_x)} loci, the manifest {n}")
nx, ny = normalize(g.raw_x, g.raw_y, lookup, g.normalization_transforms)
t, rr = polar(nx, ny)
zero = (g.raw_x == 0) & (g.raw_y == 0)
t[zero] = np.nan
rr[zero] = np.nan
theta[i], r[i] = t, rr
sexes.append(g.gender.decode(errors="replace")[:1].upper() if g.gender else "U")
if (i + 1) % 50 == 0:
rep.info(f"loaded {i + 1}/{len(paths)} GTC(s)")
return theta, r, sexes
def run_train(o: TrainOptions, rep: Reporter) -> int:
"""``simurg train`` (plan §12)."""
from simurg.formats.bpm import read_bpm
from simurg.formats.discovery import discover_gtcs
from simurg.formats.egt import write_egt
from simurg.genotype.sex import chromosome_classes
from simurg.train.clusters import BlockFit, fit_block, impute_missing, learn_relations
if o.out.exists() and not o.force:
raise SimurgError("E_OUTPUT_EXISTS", f"{o.out} exists", hint="give --force to overwrite")
import re
if not re.fullmatch(r"[A-Za-z0-9_-]+", o.out.stem):
rep.warn("W_EGT_NAME", f"{o.out.name}: characters outside [A-Za-z0-9_-] may trouble other tools (IN-045)")
man = read_bpm(o.bpm)
paths = sorted(discover_gtcs(o.gtcs).values())
if len(paths) > o.max_samples:
idx = np.random.default_rng(42).choice(len(paths), o.max_samples, replace=False)
paths = [paths[i] for i in sorted(idx)]
if len(paths) < o.min_samples:
raise SimurgError("E_TRAIN_TOO_SMALL", f"{len(paths)} GTC(s); training needs at least {o.min_samples}",
hint="lower --min-samples only for research use") # fmt: skip
theta, r, sexes = _load(paths, man.normalization_lookups(), rep)
sx = np.array(sexes)
is_x, is_y, _auto = chromosome_classes(list(man.chroms))
core = np.array([c.upper().removeprefix("CHR") for c in man.chroms])
is_mt = np.isin(core, ["MT", "M"])
females, males = sx == "F", sx == "M"
rep.info(f"training on {len(paths)} GTC(s): {int(females.sum())} F, {int(males.sum())} M, "
f"{int((~females & ~males).sum())} U") # fmt: skip
nl = man.num_loci
haploid = is_y | is_mt
use_rows = {"all": np.ones(len(paths), bool), "F": females, "M": males}
parts: list[BlockFit] = []
for s in range(0, nl, BLOCK):
e = min(nl, s + BLOCK)
t, rr = theta[:, s:e].T, r[:, s:e].T
use = np.ones_like(t, dtype=bool)
use[is_x[s:e]] = use_rows["F"][None, :] if females.sum() >= 10 else True
use[is_y[s:e]] = use_rows["M"][None, :]
parts.append(fit_block(t, rr, use, haploid[s:e]))
if (s // BLOCK) % 50 == 0:
rep.info(f"clustered {e}/{nl} loci")
fit = BlockFit(*(np.concatenate([getattr(p, f) for p in parts]) for f in BlockFit.__dataclass_fields__))
# missing clusters, per assay type (Infinium II vs I)
for inf1 in (False, True):
sel = (man.assay_types > 0) == inf1
if not sel.any():
continue
sub = BlockFit(*(getattr(fit, f)[sel] for f in BlockFit.__dataclass_fields__))
rel = learn_relations(sub)
impute_missing(sub, rel)
for f in BlockFit.__dataclass_fields__:
getattr(fit, f)[sel] = getattr(sub, f)
rep.info(f"Infinium {'I' if inf1 else 'II'}: θ AA→AB {rel.d_aa:.3f}, AB→BB {rel.d_bb:.3f}, "
f"R AA/AB {rel.r_aa:.2f}, BB/AB {rel.r_bb:.2f}") # fmt: skip
# intensity-only loci: one R cluster
io = man.intensity_only
if io.any():
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
med_r = np.nanmedian(r[:, io], axis=0)
sd_r = np.nanstd(r[:, io], axis=0)
nv = np.isfinite(r[:, io]).sum(axis=0)
fit.n[io] = np.stack([nv, np.zeros_like(nv), np.zeros_like(nv)], axis=1)
fit.theta_mean[io] = np.array([0.0, 0.5, 1.0], np.float32)
fit.theta_dev[io] = 0.05
fit.r_mean[io] = np.nan_to_num(med_r, nan=1.0)[:, None]
fit.r_dev[io] = np.nan_to_num(sd_r, nan=0.1)[:, None]
fit.train_score[io] = 0.0
fit.cluster_sep[io] = 0.0
o.out.parent.mkdir(parents=True, exist_ok=True)
now = _dt.datetime.now()
write_egt(o.out, list(man.names), man.address_a, fit.n, fit.theta_mean, fit.theta_dev, fit.r_mean, fit.r_dev,
fit.cluster_sep, fit.train_score, man.manifest_name, cluster_version=f"SimurgArray {__version__}",
date_created=f"{now.month}/{now.day}/{now.year} {now:%I:%M:%S %p}") # fmt: skip
ncl = (fit.n > 0).sum(axis=1)[~io]
stats = {"samples": len(paths), "loci": nl, "intensity_only": int(io.sum()),
"clusters_3": int((ncl == 3).sum()), "clusters_2": int((ncl == 2).sum()),
"clusters_1": int((ncl == 1).sum()), "clusters_0": int((ncl == 0).sum()),
"median_train_score": float(np.median(fit.train_score[~io])),
"median_cluster_sep": float(np.median(fit.cluster_sep[~io]))} # fmt: skip
Path(str(o.out) + ".json").write_text(json.dumps({"software": f"SimurgArray {__version__}",
"manifest": man.manifest_name, **stats}, indent=1)) # fmt: skip
rep.info("ArrayTrain: " + ", ".join(f"{k} {v:.3f}" if isinstance(v, float) else f"{k} {v}"
for k, v in stats.items()) + f" → {o.out}") # fmt: skip
return ExitCode.OK