Model cell lifetime and fate as competing risks - #1007
Open
aarmey wants to merge 6 commits into
Open
Conversation
added 6 commits
September 6, 2026 20:13
The Gamma/Bernoulli emissions treat a cell's fate and its phase duration as independent, and zero out the duration of any cell that dies, so every death time in the dataset is discarded. They also score a time-censored cell with P(division > t) when the correct statement is P(division > t AND death > t), which leaves the emission unnormalized whenever there is censoring. StateDistributionCR models each phase as a division clock racing a death clock and observes the first to fire. Death times now carry information, the censored term is correct, and the division probability is derived from the two clocks rather than fit as a free Bernoulli parameter. The G1 death clock is pinned to a constant hazard and G2's shape is free, which matches the data: pooled G1 death times give a Weibull shape of 1.03 while G2 gives 2.75, consistently across every drug and concentration. The flat params array keeps the Gamma/GaPhs layout in its leading entries so existing figure code that indexes it positionally is unaffected.
Shows why the two death clocks are shaped differently. G1 death times sit on the exponential (Weibull shape 0.89) while G2 death times are clearly not memoryless (shape 2.72) and arrive later than divisions do. The cumulative hazard panel makes the contrast direct: G1 death runs parallel to a constant hazard, G2 death and both divisions are much steeper.
Patching it onto the classes after definition left ty unable to see it.
StateDistribution.estimator built filtered copies of every observation array to drop cross-validation-masked cells, then passed the *unfiltered* arrays to bern_estimator. Masking negates a cell's whole observation, so a hidden dividing cell reads as -1: finite, and so counted in the denominator, but never equal to 1, and so absent from the numerator. With 25% of cells hidden that pulls a true 0.90 division probability down to 0.67. Only the one-condition M step was affected; atonce_estimator already filters before estimating. This is independent of the competing-risks work and can be taken on its own.
The state-distribution guide teaches the Bernoulli/Gamma form without noting that it discards death times and leaves the emission unnormalized under censoring. Add a section covering the competing-risks alternative, why the division probability becomes derived rather than fit, and why each phase gets a differently shaped death clock.
Covers rand_init, array censoring, hide_observation and the at-once M step in one pass, and confirms two states beat one on held-out likelihood.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses #648. Nothing is switched over — this adds the alternative emission and the means to compare it.
What prompted this
#648 asks how death times are distributed and notes they look exponential. Farnaz's follow-up identifies the blocker: death times are not independent of phase durations because of censoring, so fitting them directly is not straightforward.
That blocker is real, and it points at the fix rather than away from it. The dependence exists because death and division are two clocks racing each other and we only ever see the winner. Writing the likelihood that way makes the dependence part of the model instead of a problem to work around.
The gap in the current emission
StateDistributionGamma.logpdfhas:A cell that dies contributes only its Bernoulli fate; its duration is zeroed, and the matching mask in
estimatordrops it from the Gamma fit. A cell dying at 2 h and one dying at 90 h are indistinguishable to the model.There is a second, less visible problem. A time-censored live cell is scored with
sp.gamma.logsfof the division clock, i.e. P(division > t). The correct statement is P(division > t and death > t) — that cell also did not die. Because a death is scored as a bare atom with no time attached while the censored branch ignores death entirely, the two overlap and the emission is not normalized:(
compare_emissions.outcome_mass, covered by a test.) The excess is largest for short-horizon cells — which is exactly the ~37% of cells censored in each phase.What this adds
lineage/states/StateDistributionCR.pygives each phase two latent clocks — divisionT_Dand deathT_X— and observesmin(T_D, T_X)plus which one fired:Consequences:
∫ f_D(t) S_X(t) dt, rather than fit as a free Bernoulli parameter. The death fraction and the death timing are forced to agree.The M step is no harder. In the complete-data likelihood the two clocks separate, so each is a weighted right-censored Gamma fit over the same cells — once with "transition observed" as the event indicator, once with "death observed". Both reuse the existing
gamma_estimator.paramskeeps the Gamma/GaPhs layout in its leading six entries, so figure code that indexes it positionally (common.py:956-960is the only such place) works unchanged.Choosing the death clocks from the data
Pooled over the lapatinib and gemcitabine conditions (7,716 cells, 1,724 lineages):
So the read in #648 holds for G1: shape ≈ 1, a memoryless constant hazard. Across the full 114-file set it is 1.03, and it falls toward and below 1 as dose rises (0.98 at lapatinib 250 µM, 0.69 at gemcitabine 30 µM).
G2 is a different process. Shape 2.6–4.0 in every well, with deaths arriving later than divisions (41.6 h vs 26.6 h) — a wear-out hazard, consistent with arrest-then-catastrophe rather than a constant risk of dying. Fitting one exponential to "death time" across both phases would wash this out.
G1 therefore gets a one-parameter exponential death clock and G2 a two-parameter one. G1 costs no extra degrees of freedom versus the Bernoulli it replaces; only G2 adds one, taking
dof()per state from 6 to 7. NewfigureS18documents this.Evaluation
Setting the comparison up honestly
The two emissions are densities over different observation spaces — the competing-risks form puts a density on when a cell died, which the Gamma/Bernoulli form cannot express. Comparing raw likelihoods would charge the new model for predicting strictly more. (Raw training LL is indeed lower for CR, −17,226 vs −16,488 on lapatinib; that number is meaningless as a comparison.)
So the headline metric coarsens death timing away, leaving a space both models describe:
f_D(t) S_X(t)p f_D(t)1 − P(divide)1 − pS_D(c) S_X(c)S_D(c)The transition branches carry identical total mass, so that part is like for like. The censored branch is where the Gamma/Bernoulli form claims probability it is not entitled to — so this comparison is conservative, biased in the old model's favour.
Held-out likelihood
25% of cells masked per replicate; both models fit on identical masked data and scored on identical held-out sets (
lineage/compare_emissions.py). Higher is better; these are sums over ~1,300 held-out cells per replicate.Competing risks is ahead in every cell of the grid. On gemcitabine the margin is large and every replicate agrees; on lapatinib it is small and the replicates split, so that is a wash rather than a win.
Three replicates per cell except k=4, which has two — I stopped those runs early.
Number of states selected
Held-out likelihood by number of states, per model:
The two models select the same k — 4 in both conditions, and they agree on the ordering of k=2 against k=3 as well. Swapping the emission does not change how many states the data supports.
(Within a k the two models see identical masks, so the model comparison is exact. Masks differ between k values, so the comparison across k carries more noise than the numbers suggest.)
Fate and death timing
Fate log-loss is slightly worse under competing risks in most cells, which is expected: its division probability is derived from the two clocks rather than fit freely, so it trades a little fate accuracy for internal consistency and death timing.
The death-time density is around −4.0 to −4.4 nats/death, against −4.56 for a uniform over the 96 h window and −4.10 for an oracle per-phase gamma fit in sample on the same deaths. The death clock is well calibrated, not merely non-degenerate. The Gamma/Bernoulli model cannot produce this number at all.
State assignments
Viterbi assignments on the full unmasked data, against the Gamma/Bernoulli model:
At k=2 both emissions recover recognisably the same two states — the division clocks match closely and the death clocks then separate them further. In lapatinib the death-prone state's G1 death scale is 275 h against 51,000 h for the other; the Bernoulli could only express that as a fate probability.
At k=3 they diverge, badly so for gemcitabine. Death timing is new information and it moves the state boundaries. Anyone switching the emission should expect published state assignments to move with it.
Reading of the result
The competing-risks form wins on gemcitabine and is roughly a wash on lapatinib. That tracks how much death information each dataset carries: gemcitabine has 393 deaths in 4,537 cells (8.7%), lapatinib 187 in 5,290 (3.5%). Where deaths are plentiful the new terms pay for themselves; where they are scarce the extra parameter roughly cancels the gain.
Given that the comparison is stacked against it, and that it also fixes the normalization defect and derives rather than fits the division probability, I read this as worth adopting — but the k=3 assignment shift means it should not be swapped in silently.
Also in this branch
An independent bug fix, in its own commit and separable from the rest.
StateDistribution.estimatorbuilt filtered copies of every observation array to drop cross-validation-masked cells, then passed the unfiltered arrays tobern_estimator. Masking negates a cell's whole observation, so a hidden dividing cell reads as −1: finite, so counted in the denominator, but never equal to 1, so absent from the numerator. With 25% of cells hidden that pulls a true 0.90 division probability to 0.67. Only the one-condition M step was affected —atonce_estimatoralready filtered before estimating, which is why the cross validation above (four conditions at once) is unaffected by it.Limitations
gamma_estimatorcall per state per phase.🤖 Generated with Claude Code