Skip to content

Commit 15ef7c8

Browse files
aarmeyclaude
andcommitted
Analyze MEK/ERK inhibitor exposure series from S-BSST314 (Min et al. 2020)
MCF10A DHB lineages with MEKi or ERKi on for 1, 3, 6, 9 h or left on, ~33 h of imaging after drug and up to 3-4 generations below each root. - mitogen_loader: plate map, per-cell table (shipped, 4.6 MB), lineages. - mitogen_analysis: per-drug dose sweep over exposure with truncated roots, lineage bootstrap, division calibration, refits under the other root treatments, and model-free relative correlations. Lineages are subsampled to 2,500 per condition to keep EM tractable. - Figure 23. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MTbZnG5sNwnZBR9uQerm2D
1 parent 6183acb commit 15ef7c8

20 files changed

Lines changed: 9369 additions & 3686 deletions

‎docs/stateDistributions.rst‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,13 @@ closed form, and for the Weibull the scale is profiled out in closed form, leavi
363363
one-dimensional score equation for the shape that is strictly decreasing, and so is
364364
solved by Newton-Raphson safeguarded with bisection (``lineage.states.weibullFit``).
365365

366+
Two optional columns, ``[x, t, delta, t_lo, t_hi]``, truncate the lifetime to a window.
367+
They are for cells that are only in the data because of their lifetime, such as a lineage
368+
root included because it divided after a drug was added: its Weibull factor is divided by
369+
``S_W(t_lo) - S_W(t_hi)``, the probability of dividing inside the window. NaN means no
370+
bound. When any weighted lifetime is truncated, the scale no longer has a closed form, and
371+
the Weibull M step maximizes the likelihood numerically from the closed-form fit.
372+
366373
When several conditions are fit at once, this emission shares its parameters across
367374
them, so that a state means the same phenotype in every condition. Pass
368375
``shared_T=False`` to ``Analyze_list`` to give each condition its own transition matrix,

‎lineage/BaumWelch.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,9 @@ def do_M_E_step_atonce(all_tHMMobj: list[tHMM], all_gammas: list[list[np.ndarray
224224
"""
225225
gms = [np.vstack(gm) for gm in all_gammas]
226226

227-
all_cells_0 = np.vstack([lineage.obs for lineage in all_tHMMobj[0].X])
228-
phase = all_cells_0.shape[1] == 6
227+
E0 = all_tHMMobj[0].estimate.E[0]
228+
# Six-column observations are G1 and S-G2 phases, unless the emission says otherwise.
229+
phase = getattr(E0, "split_phases", all_tHMMobj[0].X[0].obs.shape[1] == 6)
229230

230231
G1cells = []
231232
G2cells = []
@@ -239,7 +240,7 @@ def do_M_E_step_atonce(all_tHMMobj: list[tHMM], all_gammas: list[list[np.ndarray
239240
cells.append(all_cells)
240241

241242
# Emission classes may supply their own at-once estimator; fall back to the Gamma one.
242-
atonce_estimator = getattr(all_tHMMobj[0].estimate.E[0], "atonce_estimator", gamma_atonce_estimator)
243+
atonce_estimator = getattr(E0, "atonce_estimator", gamma_atonce_estimator)
243244

244245
# reshape the gammas so that each list in this list of lists is for each state.
245246
if phase:
4.4 MB
Binary file not shown.

‎lineage/figures/figure22.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,14 @@ def makeFigure():
104104
ax[3].bar(xpos + width / 2, no_memory, width, label=r"$T_{22}$, no-memory model", color="#bbbbbb")
105105
ax[3].errorbar(xpos, lam, yerr=np.vstack([lam - lam_lo, lam_hi - lam]), fmt="ko", capsize=3, label=r"$\lambda_2$")
106106
ax[3].set_xticks(xpos, list(CONDITIONS))
107-
ax[3].set_ylim(0, 1)
107+
ax[3].axhline(0.0, color="k", lw=0.5)
108+
ax[3].set_ylim(-1, 1)
108109
ax[3].set_ylabel("probability / eigenvalue")
109110
ax[3].legend(fontsize=7)
110111
diff = res["bootstrap"]["diff"]["memory_eigenvalue"]
111112
ax[3].set_title(
112113
f"heritability LRT p = {res['lrt']['heritability']['p']:.1e}\n"
113-
rf"$\Delta\lambda_2$ = {diff['point']:.2f} [{diff['ci'][0]:.2f}, {diff['ci'][1]:.2f}]",
114+
rf"$\Delta\lambda_2$ = {diff['point'][0]:.2f} [{diff['ci'][0][0]:.2f}, {diff['ci'][0][1]:.2f}]",
114115
fontsize=8,
115116
)
116117

‎lineage/figures/figure23.py‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Figure 23: heritability of the CDK2 state under timed MEK or ERK inhibition.
2+
3+
Draws the results written by ``python -m lineage.mitogen_analysis``; run that first.
4+
"""
5+
6+
import json
7+
8+
import numpy as np
9+
import scipy.stats as sp
10+
11+
from ..mitogen_analysis import OUTPUT
12+
from ..palbociclib_loader import ROOT_MODES
13+
from .common import getSetup, subplotLabel
14+
15+
DRUG_COLORS = {"MEKi": "#4c72b0", "ERKi": "#c44e52"}
16+
MODE_MARKERS = {"truncate": "o", "keep": "s", "drop": "^"}
17+
RELATIVES = {"mother_daughter": "mother-daughter", "sisters": "sisters", "cousins": "cousins"}
18+
19+
20+
def pulse_labels(pulses) -> list[str]:
21+
return ["none" if p == 0 else "on" if np.isinf(p) else f"{p:g} h" for p in pulses]
22+
23+
24+
def makeFigure():
25+
with open(OUTPUT) as f:
26+
res = json.load(f)
27+
drugs, pulses = res["drugs"], np.array(res["pulses_h"], dtype=float)
28+
labels = pulse_labels(pulses)
29+
xpos = np.arange(len(pulses))
30+
offset = {d: (i - 0.5) * 0.2 for i, d in enumerate(drugs)}
31+
32+
ax, f = getSetup((12, 7), (2, 3))
33+
34+
# (a) What the two states are: biosensor densities, labelled with mean lifetimes.
35+
grid = np.linspace(-0.3, 1.2, 300)
36+
for d in drugs:
37+
for k, (e, life) in enumerate(zip(res[d]["emissions"], res[d]["mean_lifetime_h"], strict=True)):
38+
ax[0].plot(
39+
grid,
40+
sp.norm.pdf(grid, e[0], e[1]),
41+
color=DRUG_COLORS[d],
42+
ls="-" if k else "--",
43+
label=f"{d} state {k + 1}: mean lifetime {life:.0f} h",
44+
)
45+
ax[0].set_xlabel("max CDK2 activation rate in G1 (1/h)")
46+
ax[0].set_ylabel("density")
47+
ax[0].legend(fontsize=6)
48+
49+
# (b) Division by 20 h among cells born into each condition (not selected on their
50+
# outcome): observed against the fit, for each treatment of the roots' lifetimes.
51+
for d in drugs:
52+
fits = {"truncate": res[d]["divided_by_20h"]} | {
53+
m: res[d]["root_modes"][m]["divided_by_20h"] for m in ROOT_MODES[1:]
54+
}
55+
for mode in ROOT_MODES:
56+
obs = [c["observed"] for c in fits[mode]]
57+
mod = [c["model"] for c in fits[mode]]
58+
ax[1].scatter(obs, mod, marker=MODE_MARKERS[mode], color=DRUG_COLORS[d], s=18, label=f"{d}, roots {mode}")
59+
ax[1].plot([0, 1], [0, 1], "k:", lw=0.5)
60+
ax[1].set_xlabel("observed fraction divided by 20 h (KM)")
61+
ax[1].set_ylabel("predicted")
62+
ax[1].legend(fontsize=6)
63+
64+
# (c) Tolerant-state persistence against exposure, with bootstrap 95% CIs.
65+
# (d) The memory eigenvalue, which is zero when daughters' states do not depend on
66+
# their mother's.
67+
for d in drugs:
68+
for a, key in ((ax[2], "T_tolerant"), (ax[3], "memory_eigenvalue")):
69+
val = np.array(res[d]["point"][key])
70+
lo, hi = (np.array(b) for b in res[d]["bootstrap"][key])
71+
a.errorbar(
72+
xpos + offset[d],
73+
val,
74+
yerr=np.vstack([val - lo, hi - val]),
75+
fmt="o-",
76+
capsize=3,
77+
color=DRUG_COLORS[d],
78+
label=d,
79+
)
80+
ax[2].set_ylabel("$T_{22}$: P(tolerant daughter | tolerant mother)")
81+
ax[2].set_ylim(0, 1)
82+
ax[3].axhline(0.0, color="k", lw=0.5)
83+
ax[3].set_ylabel(r"memory eigenvalue $\lambda_2$")
84+
ax[3].set_ylim(-1, 1)
85+
ax[3].set_title(
86+
"heritability LRT: " + ", ".join(f"{d} p = {res[d]['lrt']['heritability']['p']:.1e}" for d in drugs), fontsize=8
87+
)
88+
for a in ax[2:4]:
89+
a.set_xticks(xpos, labels)
90+
a.set_xlabel("exposure")
91+
a.legend(fontsize=7)
92+
93+
# (e) Model-free check: CDK2-rate correlation between relatives born into the
94+
# condition, for no drug and for each drug left on.
95+
groups = [("none", res[drugs[0]]["relatives"][0])] + [(f"{d} on", res[d]["relatives"][-1]) for d in drugs]
96+
width = 0.8 / len(groups)
97+
for i, (name, rel) in enumerate(groups):
98+
r = [rel[k]["r"] for k in RELATIVES]
99+
ax[4].bar(np.arange(len(RELATIVES)) + (i - (len(groups) - 1) / 2) * width, r, width, label=name)
100+
ax[4].axhline(0.0, color="k", lw=0.5)
101+
ax[4].set_xticks(np.arange(len(RELATIVES)), list(RELATIVES.values()))
102+
ax[4].set_ylabel("Pearson r of CDK2 activation rate")
103+
ax[4].set_ylim(-0.2, 1.0)
104+
ax[4].legend(fontsize=7)
105+
106+
# (f) Generations below the root, per condition: the depth this data adds over the
107+
# palbociclib movies.
108+
for d in drugs:
109+
depth = res[d]["depth_counts"]
110+
frac2 = [sum(c[2:]) / sum(c[1:]) for c in depth]
111+
frac3 = [sum(c[3:]) / sum(c[1:]) for c in depth]
112+
ax[5].plot(xpos + offset[d], frac2, "o-", color=DRUG_COLORS[d], label=f"{d}: 2+ generations")
113+
ax[5].plot(xpos + offset[d], frac3, "s--", color=DRUG_COLORS[d], label=f"{d}: 3+ generations")
114+
ax[5].set_xticks(xpos, labels)
115+
ax[5].set_xlabel("exposure")
116+
ax[5].set_ylabel("fraction of cells born into the condition")
117+
ax[5].set_ylim(0, None)
118+
ax[5].legend(fontsize=6)
119+
120+
subplotLabel(ax)
121+
return f

‎lineage/mitogen_analysis.py‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Heritability of the CDK2 state under timed MEK or ERK inhibition (S-BSST314 Figure 1).
2+
3+
Run as ``OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 python -m lineage.mitogen_analysis
4+
[n_boot]``. For each drug, the six exposures (none, 1, 3, 6, 9 h, left on) are fit as a
5+
dose series by :func:`.heritability.dose_sweep`, with lineage roots' lifetimes truncated
6+
to the window in which they were selected (see :func:`.palbociclib_loader.build_lineages`).
7+
Results are written to ``output/mitogen_analysis.json`` and drawn by
8+
:mod:`lineage.figures.figure23`.
9+
10+
See :mod:`lineage.mitogen_loader` for the data.
11+
"""
12+
13+
import json
14+
import os
15+
import sys
16+
import time
17+
from concurrent.futures import ThreadPoolExecutor
18+
19+
import numpy as np
20+
21+
from .heritability import dose_sweep
22+
from .mitogen_loader import DRUGS, PULSES_H, generation_depths, load_lineages
23+
from .palbociclib_analysis import bootstrap_summary, division_calibration, log, summarize_T
24+
from .palbociclib_loader import ROOT_MODES
25+
from .states.CensoredWeibullGaussian import StateDistribution
26+
27+
OUTPUT = os.path.join("output", "mitogen_analysis.json")
28+
29+
#: Lineages kept per condition. The movies hold 2,000-18,000 lineages per condition, and
30+
#: EM time is dominated by per-lineage overhead, so each condition is subsampled to about
31+
#: the size of the palbociclib data (the stay-in MEKi wells have fewer and are kept whole).
32+
MAX_LINEAGES = 2500
33+
34+
35+
def condition_lineages(drug: str, rng, roots: tuple[str, ...] = ROOT_MODES) -> tuple[dict[str, list[list]], list[int]]:
36+
"""Per-condition lineages under each root treatment, with the same random subset of
37+
lineages for every treatment, and how many lineages each condition had to draw from."""
38+
out: dict[str, list[list]] = {mode: [] for mode in roots}
39+
available = []
40+
for p in PULSES_H:
41+
full = {mode: load_lineages(drug, p, roots=mode) for mode in roots}
42+
n = len(full[roots[0]])
43+
available.append(n)
44+
keep = np.sort(rng.choice(n, size=min(n, MAX_LINEAGES), replace=False))
45+
for mode in roots:
46+
out[mode].append([full[mode][i] for i in keep])
47+
return out, available
48+
49+
50+
def relative_correlations(lineages: list) -> dict[str, dict[str, float]]:
51+
"""Model-free check on inheritance: correlation of the CDK2 activation rate between
52+
mother-daughter, sister, and first-cousin pairs born into the condition.
53+
54+
Under a hidden state with memory, sisters (one division apart from a shared mother)
55+
correlate more than cousins (two apart from a shared grandmother).
56+
"""
57+
pairs: dict[str, list] = {"mother_daughter": [], "sisters": [], "cousins": []}
58+
for lin in lineages:
59+
x = lin.obs[:, 0]
60+
parents, daughters = lin.edges
61+
mother = np.full(len(lin), -1)
62+
mother[daughters] = parents
63+
kids: dict[int, list[int]] = {}
64+
for p, d in zip(parents, daughters, strict=True):
65+
kids.setdefault(int(p), []).append(int(d))
66+
if p > 0: # the root's G1 was before the drug
67+
pairs["mother_daughter"].append((x[p], x[d]))
68+
for sibs in kids.values():
69+
if len(sibs) == 2:
70+
pairs["sisters"].append((x[sibs[0]], x[sibs[1]]))
71+
a, b = (kids.get(s, []) for s in sibs)
72+
pairs["cousins"] += [(x[i], x[j]) for i in a for j in b]
73+
out = {}
74+
for key, v in pairs.items():
75+
arr = np.array(v, dtype=float).reshape(-1, 2)
76+
arr = arr[np.all(np.isfinite(arr), axis=1)]
77+
r = float(np.corrcoef(arr.T)[0, 1]) if arr.shape[0] > 2 else np.nan
78+
out[key] = {"r": r, "n": int(arr.shape[0])}
79+
return out
80+
81+
82+
def fit_drug(drug: str, n_boot: int, n_workers: int, rng, t0: float) -> dict:
83+
lineages, available = condition_lineages(drug, rng)
84+
pops, full = lineages["truncate"], lineages["keep"]
85+
res: dict = {
86+
"lineages_available": available,
87+
"cells": [int(sum(len(lin) for lin in pop)) for pop in pops],
88+
"lineages": [len(pop) for pop in pops],
89+
"depth_counts": [np.bincount(generation_depths(pop)).tolist() for pop in pops],
90+
"relatives": [relative_correlations(pop) for pop in pops],
91+
}
92+
sweep = dose_sweep(pops, list(PULSES_H), num_states=2, n_starts=8, n_jobs=n_workers, rng=rng)
93+
log(f"{drug}: dose sweep done", t0)
94+
res["LL"] = {k: float(v) for k, v in sweep.LL.items()}
95+
res["lrt"] = sweep.lrt
96+
res["emissions"] = [e.params.tolist() for e in sweep.per_dose[0].estimate.E]
97+
res["mean_lifetime_h"] = [
98+
e.mean_lifetime() for e in sweep.per_dose[0].estimate.E if isinstance(e, StateDistribution)
99+
]
100+
res["pi"] = [tO.estimate.pi.tolist() for tO in sweep.per_dose]
101+
res["independent_T"] = [tO.estimate.T.tolist() for tO in sweep.independent]
102+
res["point"] = summarize_T(sweep.T)
103+
104+
res["divided_by_20h"] = division_calibration(full, sweep)
105+
res["bootstrap"], res["n_boot"] = bootstrap_summary(sweep, pops, n_boot, n_workers, rng, res["point"])
106+
log(f"{drug}: bootstrap done", t0)
107+
108+
# The same sweep with the roots' lifetimes taken at face value or dropped.
109+
res["root_modes"] = {}
110+
for mode in ROOT_MODES[1:]:
111+
alt = dose_sweep(
112+
lineages[mode],
113+
list(PULSES_H),
114+
num_states=2,
115+
n_starts=4,
116+
n_jobs=n_workers,
117+
rng=rng,
118+
)
119+
res["root_modes"][mode] = {
120+
"lrt": alt.lrt,
121+
"emissions": [e.params.tolist() for e in alt.per_dose[0].estimate.E],
122+
"point": summarize_T(alt.T),
123+
"divided_by_20h": division_calibration(full, alt),
124+
}
125+
log(f"{drug}: roots={mode} done", t0)
126+
return res
127+
128+
129+
def run(n_boot: int = 96, n_workers: int = 44, seed: int = 0) -> dict:
130+
"""Fit the drugs concurrently, each with its own share of the worker processes (the
131+
fits themselves run in those processes, so threads are enough to overlap them)."""
132+
t0 = time.time()
133+
rngs = np.random.default_rng(seed).spawn(len(DRUGS))
134+
with ThreadPoolExecutor(len(DRUGS)) as exe:
135+
futs = {
136+
d: exe.submit(fit_drug, d, n_boot, n_workers // len(DRUGS), r, t0) for d, r in zip(DRUGS, rngs, strict=True)
137+
}
138+
out: dict = {"drugs": list(DRUGS), "pulses_h": [float(p) for p in PULSES_H]}
139+
out |= {d: f.result() for d, f in futs.items()}
140+
out["runtime_s"] = time.time() - t0
141+
return out
142+
143+
144+
if __name__ == "__main__":
145+
out = run(n_boot=int(sys.argv[1]) if len(sys.argv) > 1 else 96)
146+
os.makedirs(os.path.dirname(OUTPUT), exist_ok=True)
147+
with open(OUTPUT, "w") as f:
148+
json.dump(out, f, indent=1, default=float)
149+
print(json.dumps({d: {k: out[d][k] for k in ("lrt", "point")} for d in DRUGS}, indent=1, default=float))

0 commit comments

Comments
 (0)