|
| 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() |
0 commit comments