|
30 | 30 | import scipy.stats as sp |
31 | 31 | from scipy.integrate import quad |
32 | 32 | from scipy.sparse import csr_array |
| 33 | +from scipy.special import gammaincc, gammaln |
33 | 34 |
|
34 | 35 | from .stateCommon import censor_lineage_gamma, censor_lineage_gaphs, gamma_estimator |
35 | 36 |
|
|
38 | 39 | TIME_FLOOR = 1e-10 |
39 | 40 |
|
40 | 41 |
|
| 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 | + |
41 | 52 | def event_masks(x: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: |
42 | 53 | """Split cells into the three competing-risks cases. |
43 | 54 |
|
@@ -197,13 +208,23 @@ def death_clock(self): |
197 | 208 | return sp.gamma(a=self.params[3], scale=self.params[4]) |
198 | 209 |
|
199 | 210 | def division_probability(self) -> float: |
200 | | - """P(the division clock fires first) = int f_D(t) S_X(t) dt.""" |
201 | | - div, death = self.div_clock, self.death_clock |
| 211 | + """P(the division clock fires first) = int f_D(t) S_X(t) dt. |
| 212 | +
|
| 213 | + ``quad`` evaluates the integrand at ~100-200 points per call, and this runs |
| 214 | + once per state per M step. Using the frozen ``rv_continuous`` machinery for |
| 215 | + each point (as ``div.pdf`` / ``death.sf`` do) costs orders of magnitude more |
| 216 | + than the arithmetic itself, so the integrand is written directly against |
| 217 | + ``scipy.special`` instead. |
| 218 | + """ |
| 219 | + a1, s1 = self.params[1], self.params[2] |
| 220 | + a2, s2 = self.params[3], self.params[4] |
| 221 | + log_norm = -gammaln(a1) - a1 * np.log(s1) |
202 | 222 |
|
203 | 223 | def integrand(t): |
204 | | - return div.pdf(t) * death.sf(t) |
| 224 | + logpdf = log_norm + (a1 - 1.0) * np.log(t) - t / s1 |
| 225 | + return np.exp(logpdf) * gammaincc(a2, t / s2) |
205 | 226 |
|
206 | | - upper = div.ppf(1.0 - 1e-9) |
| 227 | + upper = self.div_clock.ppf(1.0 - 1e-9) |
207 | 228 | val = quad(integrand, 0.0, upper, limit=100)[0] |
208 | 229 | return float(np.clip(val, 0.0, 1.0)) |
209 | 230 |
|
@@ -231,19 +252,26 @@ def dof(self) -> int: |
231 | 252 | return 3 if self.fixed_death_shape else 4 |
232 | 253 |
|
233 | 254 | def logpdf(self, x: np.ndarray) -> np.ndarray: |
234 | | - """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 | + """ |
235 | 262 | divided, died, censored = event_masks(x) |
236 | 263 | timed = divided | died | censored |
237 | 264 | t = np.clip(x[:, 1], TIME_FLOOR, None) |
238 | 265 |
|
239 | | - 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] |
240 | 268 |
|
241 | 269 | ll = np.zeros(x.shape[0]) |
242 | 270 | # Every timed cell survived both clocks up to t; the one that fired then |
243 | 271 | # swaps its survival term for a density. |
244 | | - ll[timed] += div.logsf(t[timed]) + death.logsf(t[timed]) |
245 | | - ll[divided] += div.logpdf(t[divided]) - div.logsf(t[divided]) |
246 | | - 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) |
247 | 275 |
|
248 | 276 | assert not np.any(np.isnan(ll)) |
249 | 277 | return ll |
|
0 commit comments