Skip to content

Commit cc75ed2

Browse files
aarmeyclaude
andcommitted
Vectorize StateDistributionCR.logpdf against scipy.special
logpdf() runs once per state on every E step over the whole lineage array, but still went through the frozen rv_continuous machinery (self.div_clock.logpdf/logsf, self.death_clock.logpdf/logsf) for that - the same class of overhead already fixed in division_probability(), just not applied here. Replaced with direct vectorized formulas against scipy.special (gammaln/gammaincc), matching the old output to float precision (verified numerically) while cutting test_works_through_crossval from ~14.7s to ~6.3s in isolation. The div_clock/death_clock properties are left in place since compare_emissions.py still uses them outside this hot path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent fe21e41 commit cc75ed2

1 file changed

Lines changed: 22 additions & 5 deletions

File tree

‎lineage/states/StateDistributionCR.py‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@
3939
TIME_FLOOR = 1e-10
4040

4141

42+
def _gamma_logpdf(t: np.ndarray, a: float, scale: float) -> np.ndarray:
43+
"""log Gamma(a, scale) pdf, vectorized directly against scipy.special."""
44+
return (a - 1.0) * np.log(t) - t / scale - a * np.log(scale) - gammaln(a)
45+
46+
47+
def _gamma_logsf(t: np.ndarray, a: float, scale: float) -> np.ndarray:
48+
"""log Gamma(a, scale) survival function, vectorized directly against scipy.special."""
49+
return np.log(gammaincc(a, t / scale))
50+
51+
4252
def event_masks(x: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
4353
"""Split cells into the three competing-risks cases.
4454
@@ -242,19 +252,26 @@ def dof(self) -> int:
242252
return 3 if self.fixed_death_shape else 4
243253

244254
def logpdf(self, x: np.ndarray) -> np.ndarray:
245-
"""Competing-risks log likelihood of each cell's phase observation."""
255+
"""Competing-risks log likelihood of each cell's phase observation.
256+
257+
Called once per state on every E step, over the whole lineage array, so it is
258+
vectorized directly against ``scipy.special`` rather than going through the
259+
frozen ``rv_continuous`` machinery (``self.div_clock.logpdf`` etc.), which pays
260+
generic validation/broadcasting overhead on every call regardless of array size.
261+
"""
246262
divided, died, censored = event_masks(x)
247263
timed = divided | died | censored
248264
t = np.clip(x[:, 1], TIME_FLOOR, None)
249265

250-
div, death = self.div_clock, self.death_clock
266+
a1, s1 = self.params[1], self.params[2]
267+
a2, s2 = self.params[3], self.params[4]
251268

252269
ll = np.zeros(x.shape[0])
253270
# Every timed cell survived both clocks up to t; the one that fired then
254271
# swaps its survival term for a density.
255-
ll[timed] += div.logsf(t[timed]) + death.logsf(t[timed])
256-
ll[divided] += div.logpdf(t[divided]) - div.logsf(t[divided])
257-
ll[died] += death.logpdf(t[died]) - death.logsf(t[died])
272+
ll[timed] += _gamma_logsf(t[timed], a1, s1) + _gamma_logsf(t[timed], a2, s2)
273+
ll[divided] += _gamma_logpdf(t[divided], a1, s1) - _gamma_logsf(t[divided], a1, s1)
274+
ll[died] += _gamma_logpdf(t[died], a2, s2) - _gamma_logsf(t[died], a2, s2)
258275

259276
assert not np.any(np.isnan(ll))
260277
return ll

0 commit comments

Comments
 (0)