Skip to content

Commit ca2c14f

Browse files
ahmed-shuaibiclaude
andcommitted
Add BMR-calibration + subtype-split analyses (rebuttal corroboration)
bmr_calibration: per-sample observed-vs-expected (CBaSE/Dig flat r=0 vs MutSigCV2 r~1). split_cohort_by_subtype: official cBioPortal SUBTYPE -> per-subtype MAFs for stratified DIALECT (UCEC CN/MSI/POLE, BRCA PAM50). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 36467c1 commit ca2c14f

2 files changed

Lines changed: 219 additions & 0 deletions

File tree

analysis/bmr_calibration.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Show WHY a per-gene BMR (CBaSE/Dig) is miscalibrated under hypermutation.
2+
3+
For each tumor sample we compare its OBSERVED total mutation count (over the analyzed
4+
gene-effects) to the EXPECTED background total under each BMR model:
5+
6+
- CBaSE / Dig give ONE background distribution per gene, shared across all samples, so
7+
the expected background total is (very nearly) a CONSTANT across samples -- it cannot
8+
track the orders-of-magnitude per-sample burden variation in a hypermutated cohort
9+
(UCEC MSI/POLE vs CN-low). The excess observed counts in hypermutators are therefore
10+
mis-read as DRIVER signal -> inflated co-occurrence.
11+
- The sample-specific MutSigCV2 BMR gives a per-(gene,sample) Poisson mean (lambda),
12+
so the expected background total SCALES with each sample's burden and tracks observed.
13+
14+
Headline readout: Pearson r(observed, expected) across samples -- ~0 for the per-gene
15+
models (flat), high for the sample-specific model. Plus per-gene observed-vs-expected
16+
summed over all samples.
17+
18+
Usage: python -m analysis.bmr_calibration --cohort UCEC
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import argparse
24+
from pathlib import Path
25+
26+
import matplotlib as mpl
27+
28+
mpl.use("Agg")
29+
import matplotlib.pyplot as plt
30+
import numpy as np
31+
import pandas as pd
32+
33+
from analysis.bmr_overcorrection_check import load_lambda
34+
35+
PANCAN = Path("output/pancan")
36+
MUTSIG = Path("output/mutsigsrc")
37+
38+
39+
def cbase_expected(pmf_fn: Path) -> pd.Series:
40+
"""E[B] per gene-effect = sum_c c * P(count=c) from a CBaSE/Dig bmr_pmfs.csv."""
41+
df = pd.read_csv(pmf_fn, index_col=0)
42+
counts = df.columns.astype(int).to_numpy(dtype=float)
43+
p = df.to_numpy(dtype=float)
44+
p[~np.isfinite(p)] = 0.0 # guard against malformed tail entries
45+
rowsum = p.sum(axis=1, keepdims=True)
46+
rowsum[rowsum == 0] = 1.0
47+
p = p / rowsum # PMFs should sum to 1; renormalize defensively
48+
return pd.Series(p @ counts, index=df.index)
49+
50+
51+
def mutsig_expected_per_sample(cohort: str, gene_effects: list[str],
52+
samples: list[str]) -> pd.Series | None:
53+
"""Sum of per-(gene,sample) lambda over the analyzed gene-effects, per sample."""
54+
root = MUTSIG / cohort
55+
if not (root / "persample_lambda.f32").exists():
56+
return None
57+
lam, gidx = load_lambda(cohort)
58+
pat_txt = (root / "persample_patients.txt").read_text().splitlines()
59+
pats = [p.strip() for p in pat_txt]
60+
pidx = {p: i for i, p in enumerate(pats)}
61+
# align samples (count_matrix index) to lambda patient columns
62+
keep = [s for s in samples if s in pidx]
63+
cols = [pidx[s] for s in keep]
64+
total = np.zeros(len(keep))
65+
for ge in gene_effects:
66+
base, suf = ge.rsplit("_", 1)
67+
if base not in gidx:
68+
continue
69+
e = 0 if suf == "M" else 1
70+
total += lam[gidx[base], cols, e]
71+
return pd.Series(total, index=keep)
72+
73+
74+
def main() -> None:
75+
"""Compute + plot observed-vs-expected calibration for one cohort."""
76+
p = argparse.ArgumentParser(description=__doc__)
77+
p.add_argument("--cohort", default="UCEC")
78+
p.add_argument("--out", default="figures/bmr_calibration")
79+
args = p.parse_args()
80+
c = args.cohort
81+
cdir = PANCAN / c
82+
83+
cm = pd.read_csv(cdir / "count_matrix.csv", index_col=0)
84+
samples = cm.index.tolist()
85+
gene_effects = cm.columns.tolist()
86+
observed = cm.sum(axis=1) # observed total per sample
87+
88+
expected = {}
89+
cb = cbase_expected(cdir / "bmr_pmfs.csv").reindex(gene_effects).fillna(0)
90+
expected["CBaSE"] = pd.Series(float(cb.sum()), index=samples) # constant per sample
91+
dig_fn = cdir / "bmr_pmfs.dig.csv"
92+
if dig_fn.exists():
93+
dg = cbase_expected(dig_fn).reindex(gene_effects).fillna(0)
94+
expected["Dig"] = pd.Series(float(dg.sum()), index=samples)
95+
ms = mutsig_expected_per_sample(c, gene_effects, samples)
96+
if ms is not None:
97+
expected["MutSigCV2"] = ms
98+
99+
print(f"\n=== {c}: observed vs expected background (N={len(samples)} samples) ===")
100+
print(f"observed total/sample: median={observed.median():.0f} "
101+
f"min={observed.min():.0f} max={observed.max():.0f} "
102+
f"(fold range {observed.max()/max(observed.min(),1):.0f}x)\n")
103+
print(f"{'BMR model':12} {'type':16} {'pearson_r':>10} {'exp/sample':>22}")
104+
rows = []
105+
for name, exp in expected.items():
106+
aligned_obs = observed.reindex(exp.index)
107+
if float(exp.std(ddof=0)) == 0:
108+
r = 0.0
109+
kind = "per-gene (constant)"
110+
espan = f"{exp.iloc[0]:.0f} (flat)"
111+
else:
112+
r = float(np.corrcoef(aligned_obs.to_numpy(), exp.to_numpy())[0, 1])
113+
kind = "sample-specific"
114+
espan = f"{exp.min():.0f}-{exp.max():.0f}"
115+
print(f"{name:12} {kind:16} {r:10.3f} {espan:>22}")
116+
rows.append({
117+
"cohort": c, "bmr": name, "kind": kind, "pearson_r": round(r, 3),
118+
"exp_min": round(float(exp.min()), 1), "exp_max": round(float(exp.max()), 1),
119+
"obs_median": float(observed.median()), "obs_max": float(observed.max()),
120+
})
121+
122+
# plot: observed (x) vs expected (y) per sample
123+
fig, ax = plt.subplots(figsize=(7.5, 6.5))
124+
colors = {"CBaSE": "#7f7f7f", "Dig": "#1f77b4", "MutSigCV2": "#d62728"}
125+
lim = max(observed.max(), *(float(e.max()) for e in expected.values())) * 1.05
126+
ax.plot([0, lim], [0, lim], ls="--", color="#bbb", lw=1, label="obs = exp")
127+
for name, exp in expected.items():
128+
ax.scatter(observed.reindex(exp.index), exp, s=14, alpha=0.6,
129+
color=colors.get(name, "#333"), label=f"{name}", edgecolors="none")
130+
ax.set_xscale("log")
131+
ax.set_yscale("log")
132+
ax.set_xlabel("observed mutations per sample", fontsize=12)
133+
ax.set_ylabel("expected background per sample", fontsize=12)
134+
ax.set_title(f"{c}: per-gene BMR cannot track per-sample burden", fontsize=13,
135+
fontweight="bold")
136+
ax.legend(fontsize=10, loc="upper left")
137+
fig.tight_layout()
138+
out = Path(args.out)
139+
out.mkdir(parents=True, exist_ok=True)
140+
fig.savefig(out / f"{c}_calibration.png", dpi=150, bbox_inches="tight")
141+
pd.DataFrame(rows).to_csv(out / f"{c}_calibration.csv", index=False)
142+
print(f"\nwrote {out}/{c}_calibration.png + .csv")
143+
144+
145+
if __name__ == "__main__":
146+
main()

scripts/split_cohort_by_subtype.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Split a cohort MAF into subtype-specific MAFs for stratified DIALECT runs.
2+
3+
Uses the official cBioPortal molecular SUBTYPE from data_clinical_patient.txt:
4+
UCEC: UCEC_POLE / UCEC_MSI / UCEC_CN_HIGH / UCEC_CN_LOW (the reviewer's four subtypes)
5+
BRCA: BRCA_LumA / BRCA_LumB / BRCA_Basal / BRCA_Her2 / BRCA_Normal (PAM50)
6+
7+
The reviewer's argument (R1-1) is that CBaSE's single global BMR is miscalibrated because a
8+
cohort pools subtypes whose per-sample burdens differ by orders of magnitude; analyzing
9+
within a (more burden-homogeneous) subtype should de-saturate the CO calls -- most in the
10+
non-hypermutated subtypes (UCEC CN-low); within-POLE hypermutators still need the
11+
sample-specific BMR.
12+
13+
Writes data/mafs_subtype/<COHORT>_<SUBTYPE>.maf. Run the pipeline per subtype via
14+
MAF_DIR=data/mafs_subtype ROOT=output/subtype \
15+
bash scripts/run_cohort_pipeline.sh UCEC_POLE
16+
17+
Usage: python scripts/split_cohort_by_subtype.py UCEC
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import io
23+
import subprocess
24+
import sys
25+
from pathlib import Path
26+
27+
import pandas as pd
28+
29+
OUT = Path("data/mafs_subtype")
30+
DOWNLOADS = Path.home() / "Downloads"
31+
32+
33+
def _patient_clinical(study: str) -> pd.DataFrame:
34+
tarball = DOWNLOADS / f"{study}_tcga_pan_can_atlas_2018.tar.gz"
35+
member = f"{study}_tcga_pan_can_atlas_2018/data_clinical_patient.txt"
36+
txt = subprocess.run(["tar", "-xzf", str(tarball), "-O", member],
37+
capture_output=True, text=True, check=False).stdout
38+
return pd.read_csv(io.StringIO(txt), sep="\t", comment="#", low_memory=False)
39+
40+
41+
def label_official(cohort: str, maf: pd.DataFrame) -> dict:
42+
"""Sample -> official molecular SUBTYPE (from patient clinical), prefix stripped."""
43+
clin = _patient_clinical(cohort.lower())
44+
sub = dict(zip(clin["PATIENT_ID"], clin["SUBTYPE"].astype(str), strict=False))
45+
prefix = f"{cohort}_"
46+
out = {}
47+
for s in maf["Tumor_Sample_Barcode"].unique():
48+
patient = "-".join(s.split("-")[:3]) # TCGA-XX-XXXX-01 -> TCGA-XX-XXXX
49+
lab = sub.get(patient, "")
50+
out[s] = lab.replace(prefix, "") if lab and lab != "nan" else "NA"
51+
return out
52+
53+
54+
def main() -> None:
55+
"""Write per-subtype MAFs for the requested cohort."""
56+
cohort = (sys.argv[1] if len(sys.argv) > 1 else "UCEC").upper()
57+
maf = pd.read_csv(f"data/mafs_pancan/{cohort}.maf", sep="\t", low_memory=False)
58+
labels = label_official(cohort, maf)
59+
maf["__sub"] = maf["Tumor_Sample_Barcode"].map(labels)
60+
OUT.mkdir(parents=True, exist_ok=True)
61+
print(f"{cohort} subtype split (official SUBTYPE):")
62+
for sub, grp in maf.groupby("__sub"):
63+
n = grp["Tumor_Sample_Barcode"].nunique()
64+
if sub in ("NA", "nan", "None") or n < 10:
65+
print(f" skip {sub}: {n} samples")
66+
continue
67+
fn = OUT / f"{cohort}_{sub}.maf"
68+
grp.drop(columns="__sub").to_csv(fn, sep="\t", index=False)
69+
print(f" {cohort}_{sub}: {n} samples, {len(grp)} muts -> {fn}")
70+
71+
72+
if __name__ == "__main__":
73+
main()

0 commit comments

Comments
 (0)