Skip to content

Commit 6183acb

Browse files
aarmeyclaude
andcommitted
Run the palbociclib heritability analysis and add a root-selection sensitivity check
- Parallelize warm starts alongside random restarts in fit_best. - Figure 22: emissions, lifetime fit, per-dose persistence with bootstrap CIs, memory eigenvalue vs the no-memory model, per-dose ROC, and BIC. - Sensitivity refit dropping root lifetimes, which are selected on division, with a calibration check on cells born into drug. - Commit the analysis results so the figure builds without the ~90 min run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MTbZnG5sNwnZBR9uQerm2D
1 parent 39b44ac commit 6183acb

8 files changed

Lines changed: 11329 additions & 34 deletions

‎lineage/figures/figure22.py‎

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
from sklearn.metrics import roc_curve
1111
from statsmodels.duration.survfunc import SurvfuncRight
1212

13+
from ..BaumWelch import do_E_step
1314
from ..palbociclib_analysis import CONDITIONS, OUTPUT
1415
from ..palbociclib_loader import load_lineages
16+
from ..states.CensoredWeibullGaussian import StateDistribution
17+
from ..tHMM import tHMM
1518
from .common import getSetup, subplotLabel
1619

1720
STATE_NAMES = ("sensitive", "tolerant")
@@ -24,37 +27,52 @@
2427
}
2528

2629

30+
def posteriors(res: dict, pops: list) -> list[np.ndarray]:
31+
"""Per-cell state posteriors under the saved per-dose fit (one E step, no refitting)."""
32+
E = [StateDistribution(*p) for p in res["emissions"]]
33+
out = []
34+
for pop, T, pi in zip(pops, res["point"]["T"], res["pi"], strict=True):
35+
tO = tHMM(pop, num_states=len(E), fpi=np.array(pi), fT=np.array(T), fE=E)
36+
out.append(np.vstack(do_E_step(tO)[3]))
37+
return out
38+
39+
2740
def makeFigure():
2841
with open(OUTPUT) as f:
2942
res = json.load(f)
3043
pops = [load_lineages(c) for c in CONDITIONS]
3144
obs = [np.vstack([lin.obs for lin in pop]) for pop in pops]
45+
gammas = posteriors(res, pops)
3246
E = np.array(res["emissions"]) # [mu, sigma, kappa, lam] per state
3347

3448
ax, f = getSetup((12, 7), (2, 3))
3549

36-
# (a) CDK2 activation rate with the fitted state mixture for each condition.
50+
# (a) CDK2 activation rate with the fitted state mixture for each condition, weighted
51+
# by how many of the measured cells the model puts in each state.
3752
grid = np.linspace(-0.3, 1.2, 300)
3853
for d, (cond, o) in enumerate(zip(CONDITIONS, obs, strict=True)):
39-
x = o[np.isfinite(o[:, 0]), 0]
40-
ax[0].hist(x, bins=np.linspace(-0.3, 1.2, 60), density=True, alpha=0.35, color=COND_COLORS[d], label=cond)
41-
pi = res["pi"][d]
42-
mix = sum(pi[k] * sp.norm.pdf(grid, E[k, 0], E[k, 1]) for k in range(len(pi)))
54+
m = np.isfinite(o[:, 0])
55+
ax[0].hist(o[m, 0], bins=np.linspace(-0.3, 1.2, 60), density=True, alpha=0.35, color=COND_COLORS[d], label=cond)
56+
w = gammas[d][m].mean(axis=0)
57+
mix = sum(w[k] * sp.norm.pdf(grid, E[k, 0], E[k, 1]) for k in range(len(w)))
4358
ax[0].plot(grid, mix, color=COND_COLORS[d])
4459
ax[0].set_xlabel("max CDK2 activation rate in G1 (1/h)")
4560
ax[0].set_ylabel("density")
4661
ax[0].legend()
4762

48-
# (b) Kaplan-Meier of lifetimes vs the fitted Weibull mixture.
63+
# (b) Kaplan-Meier of lifetimes vs the fitted Weibull mixture over the same cells.
64+
# Only cells born into drug: lineage roots are in the data because they divided, so
65+
# their lifetimes are selected on the outcome.
4966
tgrid = np.linspace(0, 25, 200)
5067
for d, (cond, o) in enumerate(zip(CONDITIONS, obs, strict=True)):
51-
m = np.isfinite(o[:, 1])
68+
not_root = np.concatenate([np.arange(len(lin)) > 0 for lin in pops[d]])
69+
m = np.isfinite(o[:, 1]) & not_root
5270
km = SurvfuncRight(o[m, 1], o[m, 2])
5371
ax[1].step(km.surv_times, km.surv_prob, where="post", color=COND_COLORS[d], label=f"{cond} (KM)")
54-
pi = res["pi"][d]
55-
S = sum(pi[k] * np.exp(-((tgrid / E[k, 3]) ** E[k, 2])) for k in range(len(pi)))
72+
w = gammas[d][m].mean(axis=0)
73+
S = sum(w[k] * np.exp(-((tgrid / E[k, 3]) ** E[k, 2])) for k in range(len(w)))
5674
ax[1].plot(tgrid, S, "--", color=COND_COLORS[d], label=f"{cond} (tHMM)")
57-
ax[1].set_xlabel("time since birth (h)")
75+
ax[1].set_xlabel("time since birth into drug (h)")
5876
ax[1].set_ylabel("fraction undivided")
5977
ax[1].set_ylim(0, 1.02)
6078
ax[1].legend(fontsize=7)
@@ -76,14 +94,14 @@ def makeFigure():
7694
ax[2].set_ylim(0, 1)
7795
ax[2].legend(fontsize=7)
7896

79-
# (d) Heritability beyond state frequency: T_22 against the stationary tolerant
80-
# fraction (the value T_22 takes with no memory), and the memory eigenvalue.
81-
pi_tol = np.array([p[-1] for p in res["pi"]])
97+
# (d) Heritability beyond state frequency: T_22 against its value in the fitted
98+
# no-memory model (identical rows), and the memory eigenvalue.
99+
no_memory = np.array([T_ind[1][1] for T_ind in res["independent_T"]])
82100
lam = np.array(res["point"]["memory_eigenvalue"])
83101
lam_lo, lam_hi = (np.array(a) for a in res["bootstrap"]["memory_eigenvalue"])
84102
xpos = np.arange(len(CONDITIONS))
85103
ax[3].bar(xpos - width / 2, T[:, 1, 1], width, label="$T_{22}$", color="#dd8452")
86-
ax[3].bar(xpos + width / 2, pi_tol, width, label=r"$\pi_2$ (no-memory $T_{22}$)", color="#bbbbbb")
104+
ax[3].bar(xpos + width / 2, no_memory, width, label=r"$T_{22}$, no-memory model", color="#bbbbbb")
87105
ax[3].errorbar(xpos, lam, yerr=np.vstack([lam - lam_lo, lam_hi - lam]), fmt="ko", capsize=3, label=r"$\lambda_2$")
88106
ax[3].set_xticks(xpos, list(CONDITIONS))
89107
ax[3].set_ylim(0, 1)
@@ -123,6 +141,7 @@ def makeFigure():
123141
ax[5].set_xlabel("number of states")
124142
ax[5].set_ylabel("BIC")
125143
ax[5].set_xticks([s["states"] for s in sel])
144+
ax[5].margins(x=0.08, y=0.08)
126145

127146
subplotLabel(ax)
128147
return f

‎lineage/heritability.py‎

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
null directly with a likelihood-ratio test.
2020
"""
2121

22+
from collections.abc import Callable
2223
from concurrent.futures import ProcessPoolExecutor
2324
from copy import deepcopy
2425
from dataclasses import dataclass, field
@@ -118,6 +119,11 @@ def _random_fit(pop_list, num_states, shared_T, independent_T, estimate_pi, seed
118119
return objs, LL
119120

120121

122+
def _warm_fit(pop_list, template, shared_T, independent_T, estimate_pi, seed):
123+
objs, LL, _ = refit(pop_list, template, shared_T, independent_T, estimate_pi, rng=seed)
124+
return objs, LL
125+
126+
121127
def fit_best(
122128
pop_list: list,
123129
num_states: int,
@@ -136,19 +142,21 @@ def fit_best(
136142
time course they are cells from before the drug -- and the stationary tie also
137143
breaks the monotonicity of EM, which the likelihood-ratio tests rely on.
138144
139-
:param n_jobs: run the random restarts in this many processes
145+
:param n_jobs: run the random restarts and warm starts in this many processes
140146
"""
141147
rng = np.random.default_rng(rng)
142-
seeds = rng.integers(2**32, size=n_starts)
148+
seeds = rng.integers(2**32, size=n_starts + len(starts))
143149
args = (pop_list, num_states, shared_T, independent_T, estimate_pi)
144-
if n_jobs > 1:
145-
with ProcessPoolExecutor(min(n_jobs, n_starts)) as exe:
146-
fits = list(exe.map(_random_fit, *zip(*[(*args, s) for s in seeds], strict=True)))
150+
jobs: list[tuple[Callable, tuple]] = [(_random_fit, (*args, s)) for s in seeds[:n_starts]]
151+
jobs += [
152+
(_warm_fit, (pop_list, st, shared_T, independent_T, estimate_pi, s))
153+
for st, s in zip(starts, seeds[n_starts:], strict=True)
154+
]
155+
if n_jobs > 1 and len(jobs) > 1:
156+
with ProcessPoolExecutor(min(n_jobs, len(jobs))) as exe:
157+
fits = [f.result() for f in [exe.submit(fn, *a) for fn, a in jobs]]
147158
else:
148-
fits = [_random_fit(*args, s) for s in seeds]
149-
for s in starts:
150-
objs, LL, _ = refit(pop_list, s, shared_T, independent_T, estimate_pi, rng=rng)
151-
fits.append((objs, LL))
159+
fits = [fn(*a) for fn, a in jobs]
152160
return max(fits, key=lambda f: f[1])
153161

154162

@@ -207,11 +215,22 @@ def dose_sweep(
207215
shared = fit_best(pops_by_dose, K, shared_T=True, **kw)
208216
indep = fit_best(pops_by_dose, K, shared_T=False, independent_T=True, **kw)
209217
per_dose = fit_best(pops_by_dose, K, shared_T=False, starts=(shared[0], indep[0]), **kw)
210-
shared = fit_best(pops_by_dose, K, shared_T=True, starts=(shared[0], per_dose[0]), n_starts=0, rng=rng)
218+
shared = fit_best(
219+
pops_by_dose, K, shared_T=True, starts=(shared[0], per_dose[0]), n_starts=0, n_jobs=n_jobs, rng=rng
220+
)
211221
indep = fit_best(
212-
pops_by_dose, K, shared_T=False, independent_T=True, starts=(indep[0], per_dose[0]), n_starts=0, rng=rng
222+
pops_by_dose,
223+
K,
224+
shared_T=False,
225+
independent_T=True,
226+
starts=(indep[0], per_dose[0]),
227+
n_starts=0,
228+
n_jobs=n_jobs,
229+
rng=rng,
230+
)
231+
per_dose = fit_best(
232+
pops_by_dose, K, shared_T=False, starts=(per_dose[0], shared[0], indep[0]), n_starts=0, n_jobs=n_jobs, rng=rng
213233
)
214-
per_dose = fit_best(pops_by_dose, K, shared_T=False, starts=(per_dose[0], shared[0], indep[0]), n_starts=0, rng=rng)
215234

216235
for fit in (per_dose, shared, indep):
217236
order_by_lifetime(fit[0])

‎lineage/palbociclib_analysis.py‎

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Issue #1016: heritability of palbociclib escape in Spencer-lab MCF10A lineages.
22
33
Run as ``OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 python -m lineage.palbociclib_analysis
4-
[n_boot]``. The fits run in parallel worker processes, so BLAS's own threads should be
4+
[n_boot | sensitivity]``. The fits run in parallel worker processes, so BLAS's own threads should be
55
pinned to one, or the workers oversubscribe the machine. Results are written to
66
``output/palbociclib_analysis.json`` and drawn by :mod:`lineage.figures.figure22`.
77
@@ -86,6 +86,10 @@ def percentile_ci(samples: np.ndarray) -> list:
8686
return [np.asarray(lo).tolist(), np.asarray(hi).tolist()]
8787

8888

89+
def log(msg: str, t0: float):
90+
print(f"[{time.time() - t0:7.0f} s] {msg}", file=sys.stderr, flush=True)
91+
92+
8993
def run(n_boot: int = 200, n_workers: int = 16, seed: int = 0) -> dict:
9094
rng = np.random.default_rng(seed)
9195
pops = [load_lineages(c) for c in CONDITIONS]
@@ -102,8 +106,10 @@ def run(n_boot: int = 200, n_workers: int = 16, seed: int = 0) -> dict:
102106

103107
t0 = time.time()
104108
res["state_selection"] = select_states(pops, n_jobs=n_workers, rng=rng)
109+
log("state selection done", t0)
105110

106111
sweep = dose_sweep(pops, list(DOSES_NM), num_states=2, n_starts=12, n_jobs=n_workers, rng=rng)
112+
log("dose sweep done", t0)
107113
res["LL"] = {k: float(v) for k, v in sweep.LL.items()}
108114
res["lrt"] = sweep.lrt
109115
res["emissions"] = [e.params.tolist() for e in sweep.per_dose[0].estimate.E]
@@ -121,6 +127,7 @@ def run(n_boot: int = 200, n_workers: int = 16, seed: int = 0) -> dict:
121127
with ProcessPoolExecutor(n_workers) as exe:
122128
futs = [exe.submit(bootstrap_transitions, sweep, pops, len(c), s) for c, s in zip(chunks, seeds, strict=True)]
123129
boots = np.concatenate([f.result() for f in futs if f.result().size])
130+
log("bootstrap done", t0)
124131
summaries = [summarize_T(b) for b in boots]
125132
boot: dict = {
126133
key: percentile_ci(np.array([s[key] for s in summaries], dtype=float))
@@ -142,6 +149,7 @@ def run(n_boot: int = 200, n_workers: int = 16, seed: int = 0) -> dict:
142149
res["n_boot"] = int(boots.shape[0])
143150

144151
cv = cross_validated_scores(sweep, pops, horizon=HORIZON_H, n_folds=5, escape_col=ESCAPE_COL, rng=rng)
152+
log("cross-validated forecasts done", t0)
145153
res["auc"] = auc_table(cv, n_boot=1000, rng=rng)
146154
pairs = cv["pairs"]
147155
res["pairs"] = {
@@ -159,10 +167,63 @@ def run(n_boot: int = 200, n_workers: int = 16, seed: int = 0) -> dict:
159167
return res
160168

161169

170+
SENSITIVITY_OUTPUT = os.path.join("output", "palbociclib_sensitivity.json")
171+
172+
173+
def sensitivity(n_workers: int = 16, seed: int = 1) -> dict:
174+
"""Refit the dose sweep without the roots' lifetimes, which are selected on dividing.
175+
176+
Also reports how well each fit reproduces division among cells born into drug, the
177+
cells not selected on their outcome: the observed fraction divided by 20 h (Kaplan-Meier)
178+
against the fraction the fitted states predict.
179+
"""
180+
from statsmodels.duration.survfunc import SurvfuncRight
181+
182+
from .BaumWelch import do_E_step
183+
from .tHMM import tHMM
184+
185+
rng = np.random.default_rng(seed)
186+
out: dict = {}
187+
for label, keep in (("with_root_lifetimes", True), ("without_root_lifetimes", False)):
188+
pops = [load_lineages(c, root_lifetimes=keep) for c in CONDITIONS]
189+
sweep = dose_sweep(pops, list(DOSES_NM), num_states=2, n_starts=12, n_jobs=n_workers, rng=rng)
190+
E = sweep.per_dose[0].estimate.E
191+
entry = {
192+
"LL": {k: float(v) for k, v in sweep.LL.items()},
193+
"lrt": sweep.lrt,
194+
"emissions": [e.params.tolist() for e in E],
195+
"pi": [tO.estimate.pi.tolist() for tO in sweep.per_dose],
196+
"point": summarize_T(sweep.T),
197+
"divided_by_20h": [],
198+
}
199+
# Always score division on the full data, whichever lineages were fit.
200+
full = [load_lineages(c) for c in CONDITIONS]
201+
for pop, tO in zip(full, sweep.per_dose, strict=True):
202+
fixed = tHMM(pop, 2, fpi=tO.estimate.pi, fT=tO.estimate.T, fE=E)
203+
gam = np.vstack(do_E_step(fixed)[3])
204+
obs = np.vstack([lin.obs for lin in pop])
205+
m = np.isfinite(obs[:, 1]) & np.concatenate([np.arange(len(lin)) > 0 for lin in pop])
206+
km = SurvfuncRight(obs[m, 1], obs[m, 2])
207+
w = gam[m].mean(axis=0)
208+
S20 = sum(w[k] * np.exp(-((20.0 / e.params[3]) ** e.params[2])) for k, e in enumerate(E))
209+
entry["divided_by_20h"].append(
210+
{"observed": float(1.0 - np.interp(20.0, km.surv_times, km.surv_prob)), "model": float(1.0 - S20)}
211+
)
212+
out[label] = entry
213+
return out
214+
215+
162216
if __name__ == "__main__":
163-
n_boot = int(sys.argv[1]) if len(sys.argv) > 1 else 200
164-
out = run(n_boot=n_boot)
165-
os.makedirs(os.path.dirname(OUTPUT), exist_ok=True)
166-
with open(OUTPUT, "w") as f:
167-
json.dump(out, f, indent=1, default=float)
168-
print(json.dumps({k: v for k, v in out.items() if not k.startswith("roc_")}, indent=1, default=float))
217+
if len(sys.argv) > 1 and sys.argv[1] == "sensitivity":
218+
out = sensitivity()
219+
os.makedirs(os.path.dirname(SENSITIVITY_OUTPUT), exist_ok=True)
220+
with open(SENSITIVITY_OUTPUT, "w") as f:
221+
json.dump(out, f, indent=1, default=float)
222+
print(json.dumps(out, indent=1, default=float))
223+
else:
224+
n_boot = int(sys.argv[1]) if len(sys.argv) > 1 else 200
225+
out = run(n_boot=n_boot)
226+
os.makedirs(os.path.dirname(OUTPUT), exist_ok=True)
227+
with open(OUTPUT, "w") as f:
228+
json.dump(out, f, indent=1, default=float)
229+
print(json.dumps({k: v for k, v in out.items() if not k.startswith("roc_")}, indent=1, default=float))

‎lineage/palbociclib_loader.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,9 @@ def cell_obs(df: pd.DataFrame) -> np.ndarray:
189189
)
190190

191191

192-
def load_lineages(condition: str, table: pd.DataFrame | None = None, E=None) -> list[LineageTree]:
192+
def load_lineages(
193+
condition: str, table: pd.DataFrame | None = None, E=None, root_lifetimes: bool = True
194+
) -> list[LineageTree]:
193195
"""Lineages of cells born after drug addition in one condition.
194196
195197
Each lineage is rooted at a cell that divided after the drug went on; its daughters,
@@ -202,6 +204,10 @@ def load_lineages(condition: str, table: pd.DataFrame | None = None, E=None) ->
202204
:param condition: ``"control"``, ``"palbociclib"``, ``"MEKi"``, or ``"Nutlin"``
203205
:param table: a table from :func:`build_cell_table`; defaults to :data:`CELL_TABLE`
204206
:param E: emissions to attach to the lineages (only used as a template for fitting)
207+
:param root_lifetimes: keep the roots' lifetimes. Roots are only in the data because
208+
they divided before the movie ended, so their lifetimes are selected on the
209+
outcome; pass False to drop them (keeping the roots' biosensor readings) as a
210+
sensitivity check on that selection.
205211
"""
206212
if table is None:
207213
table = pd.read_csv(CELL_TABLE)
@@ -236,5 +242,7 @@ def load_lineages(condition: str, table: pd.DataFrame | None = None, E=None) ->
236242
if root not in movie.index:
237243
# A mother with no usable sensor frames at all is kept for her topology only.
238244
obs[0, :] = np.nan
245+
if not root_lifetimes:
246+
obs[0, 1:3] = np.nan
239247
lineages.append(LineageTree(tree, E, obs=obs))
240248
return lineages

‎lineage/tests/test_palbociclib_loader.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,12 @@ def test_shipped_table_loads():
141141
assert np.all(parents < daughters)
142142
# Two daughters, or one when the sister never had a usable sensor frame.
143143
assert np.all(np.isin(np.bincount(parents, minlength=len(lin))[parents], (1, 2)))
144+
145+
146+
def test_load_lineages_without_root_lifetimes(movie_dir):
147+
table = build_cell_table(str(movie_dir))
148+
[lin] = load_lineages("palbociclib", table, root_lifetimes=False)
149+
[full] = load_lineages("palbociclib", table)
150+
assert np.all(np.isnan(lin.obs[0, 1:3]))
151+
np.testing.assert_array_equal(lin.obs[0, 0], full.obs[0, 0])
152+
np.testing.assert_array_equal(lin.obs[1:], full.obs[1:])

0 commit comments

Comments
 (0)