ebin 1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ebin/gauss_rules.py ADDED
@@ -0,0 +1,297 @@
1
+ from functools import partial
2
+ try:
3
+ from .ftzkiller import FTZKiller
4
+ except ImportError: # allow running as a loose module
5
+ from ftzkiller import FTZKiller
6
+ from scipy.linalg import eigh_tridiagonal as scipy_eigh_tridiagonal
7
+ from jax import pure_callback
8
+ from scipy.special import roots_genlaguerre
9
+ from jax.scipy.special import gammaln
10
+ from jax.lax import scan
11
+ from jax import vmap
12
+ import jax.numpy as jnp
13
+ import numpy as np
14
+ import jax
15
+
16
+ from enum import Enum
17
+
18
+ class Rules(Enum):
19
+ GenLaguerre = 'GenLaguerre'
20
+ Charlier = 'Charlier'
21
+ ShiftedJacobi = 'ShiftedJacobi'
22
+
23
+
24
+
25
+ def _calculate_for_single_lambda(lam, diagonal, off_diagonal):
26
+ """
27
+ Helper function to compute log(|[v]_1|) for a particualr eigenvalue.
28
+ """
29
+
30
+ # Pre-calculate the first two components (indices 0 and 1)
31
+ u0 = 1.0
32
+ u1 = -(diagonal[0] - lam) * u0 / off_diagonal[0]
33
+
34
+ initial_carry = (u1, u0)
35
+
36
+ # The scan will compute components u[2] through u[n-1], which is n-2 steps.
37
+ # All input arrays for the scan must therefore have length n-2.
38
+ scan_diagonals = diagonal[1:-1] # Needs a_2, ..., a_{n-1} (0-indexed: diag[1]...diag[n-2])
39
+ scan_off_diagonals_prev = off_diagonal[:-1] # Needs b_1, ..., b_{n-2} (0-indexed: off[0]...off[n-3])
40
+ scan_off_diagonals_curr = off_diagonal[1:] # Needs b_2, ..., b_{n-1} (0-indexed: off[1]...off[n-2])
41
+
42
+ def _recurrence_step(carry, scan_inputs):
43
+ u_curr, u_prev = carry
44
+ a_k, b_k_minus_1, b_k = scan_inputs
45
+
46
+ u_next = -((a_k - lam) * u_curr + b_k_minus_1 * u_prev) / b_k
47
+
48
+ new_carry = (u_next, u_curr)
49
+ component_to_collect = u_next
50
+ return new_carry, component_to_collect
51
+
52
+ # Run the scan to get components u_2, ..., u_{n-1}
53
+ _, u_rest = scan(
54
+ _recurrence_step,
55
+ initial_carry,
56
+ (scan_diagonals, scan_off_diagonals_prev, scan_off_diagonals_curr)
57
+ )
58
+
59
+ # Combine all components into a single vector u
60
+ u = jnp.concatenate([jnp.array([u0, u1]), u_rest])
61
+
62
+ # Stably compute the log of the first component's magnitude
63
+ u_max = jnp.max(jnp.abs(u))
64
+ w = u / u_max
65
+
66
+ sum_w_sq = jnp.dot(w, w)
67
+ log_s_squared = 2 * jnp.log(u_max) + jnp.log(sum_w_sq)
68
+
69
+ result = jnp.where(u_max > 0, -0.5 * log_s_squared, -jnp.inf)
70
+
71
+ return result
72
+
73
+
74
+
75
+ @jax.jit
76
+ def calc_first_components(diagonal, off_diagonal, eigenvalues):
77
+ """
78
+ Calculates the logarithm of the absolute value of the first components of all
79
+ eigenvectors of a symmetric tridiagonal matrix.
80
+
81
+ Args:
82
+ diagonal (jnp.ndarray): The main diagonal of the matrix (a_1, ..., a_n).
83
+ off_diagonal (jnp.ndarray): The first off-diagonal (b_1, ..., b_{n-1}).
84
+ eigenvalues (jnp.ndarray): The pre-computed eigenvalues of the matrix.
85
+
86
+ Returns:
87
+ jnp.ndarray: An array containing log(|[v_j]_1|) for each eigenvector v_j.
88
+ """
89
+ n = diagonal.shape[0]
90
+
91
+ # Handle edge cases that don't fit the scan logic
92
+ if n == 0:
93
+ return jnp.array([])
94
+ if n == 1:
95
+ return jnp.array([0.0])
96
+ if n == 2:
97
+ def _calc_n2(lam, diag, offdiag):
98
+ u0 = 1.0
99
+ u1 = -(diag[0] - lam) * u0 / offdiag[0]
100
+ u = jnp.array([u0, u1])
101
+ u_max = jnp.max(jnp.abs(u))
102
+ w = u / u_max
103
+ sum_w_sq = jnp.dot(w, w)
104
+ log_s_squared = 2 * jnp.log(u_max) + jnp.log(sum_w_sq)
105
+ return -0.5 * log_s_squared
106
+
107
+ return vmap(_calc_n2, in_axes=(0, None, None))(eigenvalues, diagonal, off_diagonal)
108
+
109
+ vmapped_calculator = vmap(
110
+ _calculate_for_single_lambda, in_axes=(0, None, None)
111
+ )
112
+
113
+ return vmapped_calculator(eigenvalues, diagonal, off_diagonal)
114
+
115
+ @jax.custom_jvp
116
+ def eigh_tridiagonal(diag, offdiag):
117
+ def scipy_eigh_vals_only(d, o):
118
+ return scipy_eigh_tridiagonal(d, o, eigvals_only=True)
119
+
120
+ n = diag.shape[0]
121
+ with FTZKiller():
122
+ w = pure_callback(
123
+ scipy_eigh_vals_only,
124
+ jax.ShapeDtypeStruct((n,), diag.dtype), # Shape of the output
125
+ diag, offdiag,
126
+ vmap_method='sequential',
127
+ )
128
+ w = jax.block_until_ready(w)
129
+ return w
130
+
131
+
132
+ @eigh_tridiagonal.defjvp
133
+ def _eigh_tridiagonal_jvp(primals, tangents):
134
+ diag, offdiag = primals
135
+ diag_dot, offdiag_dot = tangents
136
+
137
+ def scipy_eigh_full(d, o):
138
+ with FTZKiller():
139
+ return scipy_eigh_tridiagonal(d, o, eigvals_only=False)
140
+
141
+ n = diag.shape[0]
142
+
143
+ w, v = pure_callback(
144
+ scipy_eigh_full,
145
+ (
146
+ jax.ShapeDtypeStruct((n,), diag.dtype),
147
+ jax.ShapeDtypeStruct((n, n), diag.dtype)
148
+ ),
149
+ diag, offdiag,
150
+ vmap_method='sequential',
151
+ )
152
+
153
+ delta_T = jnp.diag(diag_dot) + jnp.diag(offdiag_dot, k=1) + jnp.diag(offdiag_dot, k=-1)
154
+
155
+ w_dot = jnp.einsum('ik,ij,jk->k', v, delta_T, v)
156
+
157
+ return w, w_dot
158
+
159
+ def compute_roots_genlaguerre_scipy(n: int, alpha: float):
160
+ n = int(n)
161
+ alpha = float(alpha)
162
+
163
+ with FTZKiller():
164
+ roots, weights = roots_genlaguerre(n, alpha)
165
+ return roots.astype(np.float64), weights.astype(np.float64)
166
+
167
+ def approximate_charliet_smallest_root(n: int, mu: float) -> float:
168
+ """
169
+ This function uses the formula:
170
+ x_1 ≈ μ^n / Σ_{k=1 to n} [ C(n,k) * (k-1)! * μ^(n-k) ]
171
+ """
172
+
173
+ log_mu = jnp.log(mu)
174
+ log_numerator = n * log_mu
175
+
176
+ k_values = jnp.arange(1, n + 1)
177
+
178
+ log_binom_coeff = gammaln(n + 1) - gammaln(k_values + 1) - gammaln(n - k_values + 1)
179
+
180
+ log_factorial = gammaln(k_values)
181
+
182
+ log_mu_term = (n - k_values) * log_mu
183
+ log_terms_denominator = log_binom_coeff + log_factorial + log_mu_term
184
+
185
+ log_denominator = jax.scipy.special.logsumexp(log_terms_denominator)
186
+ log_root = log_numerator - log_denominator
187
+
188
+ return jnp.exp(log_root)
189
+
190
+ def construct_jacobi(alpha, beta, n):
191
+ """
192
+ Constructs the tridiagonal Jacobi matrix for w(x) = x^(alpha-1)(1-x)^(beta-1).
193
+
194
+ Args:
195
+ alpha (float): Parameter alpha > 0.
196
+ beta (float): Parameter beta > 0.
197
+ n (int): The size of the matrix (n x n).
198
+
199
+ Returns:
200
+ tuple[np.ndarray, np.ndarray]: A tuple containing two arrays:
201
+ - diag (main diagonal, size n)
202
+ - offdiag (sub/super-diagonal, size n-1)
203
+ """
204
+ if n <= 0:
205
+ return jnp.array([]), jnp.array([])
206
+
207
+ k = jnp.arange(n, dtype=np.float64)
208
+ ab = alpha + beta
209
+
210
+ diag = jnp.where(
211
+ k == 0,
212
+ alpha / ab,
213
+ 0.5 * (1 + ((alpha - 1)**2 - (beta - 1)**2) / ((2*k + ab - 2) * (2*k + ab)))
214
+ )
215
+
216
+ if n == 1:
217
+ return diag, jnp.array([])
218
+
219
+ k_off = k[:-1]
220
+ c = 2*k_off + ab
221
+ offdiag = jnp.where(
222
+ k_off == 0,
223
+ jnp.sqrt((alpha * beta) / (ab**2 * (ab + 1))),
224
+ (1/c) * jnp.sqrt(((k_off + 1)*(k_off + alpha)*(k_off + beta)*(k_off + ab - 1)) / (c**2 - 1))
225
+ )
226
+
227
+ return diag, offdiag
228
+
229
+ @partial(jax.jit, static_argnames=('num_nodes', 'rule', 'norm', 'eigh_solver',
230
+ ))
231
+ def compute_nodes_and_logweights(num_nodes: int, param: float, rule: Rules,
232
+ norm: bool = True, eigh_solver: bool = None):
233
+ if eigh_solver is None:
234
+ if rule == Rules.Charlier:
235
+ eigh_solver = True
236
+ else:
237
+ eigh_solver = False
238
+ k = jnp.arange(num_nodes)
239
+ lognorm = 0.0
240
+ if rule == Rules.GenLaguerre:
241
+ if not norm:
242
+ lognorm = gammaln(param + 1)
243
+ diag = 2 * k + param + 1
244
+ k_off = jnp.arange(1, num_nodes, dtype=float)
245
+ offdiag = -jnp.sqrt(k_off * (k_off + param))
246
+ elif rule == Rules.Charlier:
247
+ diag = k + param
248
+ k = jnp.arange(1, num_nodes)
249
+ offdiag = jnp.sqrt(k * param)
250
+ if not norm:
251
+ lognorm = param
252
+ elif rule == Rules.ShiftedJacobi:
253
+ diag, offdiag = construct_jacobi(*param, num_nodes)
254
+ if not norm:
255
+ lognorm = jax.scipy.special.betaln(*param)
256
+ if eigh_solver:
257
+ d = jnp.diag(diag) + jnp.diag(offdiag, 1) + jnp.diag(offdiag, -1)
258
+ with FTZKiller():
259
+ nodes, logweights = jnp.linalg.eigh(d)
260
+ logweights = jnp.log(jnp.abs(logweights.at[0].get()))
261
+ else:
262
+ nodes = eigh_tridiagonal(diag, offdiag)
263
+ logweights = calc_first_components(diag, offdiag, nodes)
264
+ logweights = lognorm + 2 * logweights
265
+ return jnp.clip(nodes, 0.0, None), logweights
266
+
267
+ # import numdifftools as nd
268
+
269
+ # jax.config.update('jax_enable_x64', True)
270
+ # jax.config.update('jax_platforms', 'cpu')
271
+
272
+
273
+ # @partial(jax.jit, static_argnames=('n', 'mode'))
274
+ # def fun(alpha, mode: bool, n=20):
275
+ # n, l = compute_nodes_and_logweights(n, alpha, rule=Rules.Charlier,
276
+ # eigh_solver=True)
277
+ # return ((l ) ).mean()
278
+
279
+ # f_old = partial(fun, mode=True)
280
+ # grad_old = jax.jit(jax.jacfwd(f_old, argnums=0))
281
+ # grad_old_nd = nd.Gradient(f_old)
282
+
283
+ # # f_new= partial(fun, mode=False)
284
+ # # grad_new = jax.jit(jax.jacrev(f_new, argnums=0))
285
+ # # grad_new_nd = nd.Gradient(f_new)
286
+
287
+
288
+ # alpha = 1e-3
289
+
290
+ # print('funs:', f_old(alpha), )
291
+ # print('grad:', grad_old(alpha), )
292
+ # print('grad_nd:', grad_old_nd(alpha), )
293
+ # print('graddiff', np.abs(grad_old(alpha) - grad_old_nd(alpha)))
294
+
295
+
296
+
297
+
ebin/initialize.py ADDED
@@ -0,0 +1,186 @@
1
+ """Starting values for the compound NB-Poisson model.
2
+
3
+ 1. Fit a zero-inflated NB (ZINB) independently to each of the S*B count columns.
4
+ 2. Take a W2 barycenter of those NBs (location-scale surrogate: mean of means,
5
+ mean of sds) as a reference NB.
6
+ 3. Map every column onto the reference by the mid-distribution quantile
7
+ transform, Z = F_ref^{-1}(F_sb(x) - 0.5 f_sb(x)); normalize per column, sum
8
+ over replicates, and row-normalize to get Pi0.
9
+ 4. R0 = r_hat / (Lambda0_s * mean_n Pi0) matches the model means to the
10
+ marginal fits, and P0 = (1 + R0) / (1/p_hat + R0) matches their dispersion.
11
+ """
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+ import numpy as np
16
+ import jax
17
+ import jax.numpy as jnp
18
+ from jax.scipy.special import gammaln
19
+ from scipy.optimize import minimize
20
+ from scipy.stats import nbinom as sp_nbinom
21
+
22
+ jax.config.update("jax_enable_x64", True)
23
+
24
+
25
+ @jax.jit
26
+ def _zinb_nll_grad(theta, xu, w):
27
+ def nll(theta):
28
+ a, c, d = theta
29
+ r = jnp.exp(a)
30
+ logp = -jnp.logaddexp(0.0, -c) # log sigmoid(c)
31
+ log1mp = -jnp.logaddexp(0.0, c)
32
+ log_pi0 = -jnp.logaddexp(0.0, -d)
33
+ log_1mpi0 = -jnp.logaddexp(0.0, d)
34
+ log_nb = (gammaln(xu + r) - gammaln(r) - gammaln(xu + 1.0)
35
+ + r * logp + xu * log1mp)
36
+ log_zinb = jnp.where(
37
+ xu == 0,
38
+ jnp.logaddexp(log_pi0, log_1mpi0 + log_nb),
39
+ log_1mpi0 + log_nb)
40
+ return -jnp.sum(w * log_zinb) / jnp.sum(w)
41
+ return jax.value_and_grad(nll)(theta)
42
+
43
+
44
+ def _pad_pow2(a, fill):
45
+ n = 1
46
+ while n < a.size:
47
+ n *= 2
48
+ out = np.full(n, fill, dtype=np.float64)
49
+ out[:a.size] = a
50
+ return out
51
+
52
+
53
+ def fit_zinb(x, weights=None):
54
+ """Fit ZINB(r, p, pi0) to counts ``x``. Returns (r, p, pi0, nll_per_obs)."""
55
+ x = np.asarray(x, dtype=np.float64)
56
+ if weights is None:
57
+ xu, w = np.unique(x, return_counts=True)
58
+ w = w.astype(np.float64)
59
+ else:
60
+ xu, w = x, np.asarray(weights, dtype=np.float64)
61
+ # pad to a power of two so jit recompiles only O(log U) shapes
62
+ xu_p = _pad_pow2(xu, 1.0)
63
+ w_p = _pad_pow2(w, 0.0)
64
+
65
+ wt = w.sum()
66
+ m = float((xu * w).sum() / wt)
67
+ v = float((w * (xu - m) ** 2).sum() / wt)
68
+ frac0 = float(w[xu == 0].sum() / wt) if (xu == 0).any() else 0.0
69
+ m_pos = m / max(1.0 - frac0, 1e-6) # crude nonzero-component mean
70
+ v_pos = max(v, m_pos * 1.5)
71
+ r0 = np.clip(m_pos ** 2 / max(v_pos - m_pos, 1e-6), 1e-3, 1e6)
72
+ p0 = np.clip(r0 / (r0 + m_pos), 1e-6, 1 - 1e-6)
73
+ pi0_0 = np.clip(frac0 * 0.5 + 1e-4, 1e-4, 0.9)
74
+ theta0 = np.array([np.log(r0), np.log(p0 / (1 - p0)),
75
+ np.log(pi0_0 / (1 - pi0_0))])
76
+
77
+ def fg(theta):
78
+ v, g = _zinb_nll_grad(jnp.asarray(theta), jnp.asarray(xu_p),
79
+ jnp.asarray(w_p))
80
+ return float(v), np.asarray(g)
81
+
82
+ res = minimize(fg, theta0, jac=True, method="L-BFGS-B",
83
+ bounds=[(-12, 18), (-25, 25), (-12, 12)],
84
+ options=dict(maxiter=300))
85
+ a, c, d = res.x
86
+ return (float(np.exp(a)), float(1 / (1 + np.exp(-c))),
87
+ float(1 / (1 + np.exp(-d))), float(res.fun))
88
+
89
+
90
+ @dataclass
91
+ class InitResult:
92
+ R: np.ndarray # (S, B)
93
+ P: np.ndarray # (S, B)
94
+ Pi: np.ndarray # (N, B)
95
+ Lambda: np.ndarray # (S,)
96
+ phi: float
97
+ r_hat: np.ndarray = field(repr=False, default=None) # marginal ZINB fits
98
+ p_hat: np.ndarray = field(repr=False, default=None)
99
+ pi0_hat: np.ndarray = field(repr=False, default=None)
100
+ r_ref: float = None
101
+ p_ref: float = None
102
+
103
+
104
+ def initialize(X, mask=None, lambda_init=50.0, verbose=False):
105
+ """Starting values (R0, P0, Pi0, Lambda0, phi0) for ``fit_normal``.
106
+
107
+ ``lambda_init`` mostly sets the resolution of the latent Poisson; R0
108
+ absorbs the scale.
109
+ """
110
+ X = np.asarray(X, dtype=np.float64)
111
+ N, S, B = X.shape
112
+ nan_mask = ~np.isnan(X)
113
+ mask = nan_mask if mask is None else (np.asarray(mask, bool) & nan_mask)
114
+ Xz = np.where(mask, np.nan_to_num(X), 0.0)
115
+
116
+ # ---- 1. per-column ZINB fits -------------------------------------------
117
+ r_hat = np.zeros((S, B))
118
+ p_hat = np.zeros((S, B))
119
+ pi0_hat = np.zeros((S, B))
120
+ for s in range(S):
121
+ for b in range(B):
122
+ xs = Xz[:, s, b][mask[:, s, b]]
123
+ r_hat[s, b], p_hat[s, b], pi0_hat[s, b], nll = fit_zinb(xs)
124
+ if verbose:
125
+ print(f" ZINB[{s},{b}]: r={r_hat[s,b]:.3f} "
126
+ f"p={p_hat[s,b]:.4f} pi0={pi0_hat[s,b]:.4f} "
127
+ f"nll={nll:.4f}")
128
+
129
+ # ---- 2. W2 barycenter reference NB -------------------------------------
130
+ means = r_hat * (1 - p_hat) / p_hat
131
+ sds = np.sqrt(means / p_hat)
132
+ mu_ref = float(means.mean())
133
+ sd_ref = float(sds.mean())
134
+ var_ref = max(sd_ref ** 2, mu_ref * (1 + 1e-6))
135
+ p_ref = np.clip(mu_ref / var_ref, 1e-8, 1 - 1e-8)
136
+ r_ref = mu_ref * p_ref / (1 - p_ref)
137
+
138
+ # ---- 3. quantile transform each column onto the reference --------------
139
+ Z = np.zeros((N, S, B))
140
+ for s in range(S):
141
+ for b in range(B):
142
+ obs = mask[:, s, b]
143
+ xu, inv = np.unique(Xz[:, s, b], return_inverse=True)
144
+ u = (sp_nbinom.cdf(xu, r_hat[s, b], p_hat[s, b])
145
+ - 0.5 * sp_nbinom.pmf(xu, r_hat[s, b], p_hat[s, b]))
146
+ u = np.clip(u, 1e-12, 1 - 1e-12)
147
+ z = sp_nbinom.ppf(u, r_ref, p_ref).astype(np.float64)[inv]
148
+ fill = z[obs].mean() if obs.any() else 0.0
149
+ Z[:, s, b] = np.where(obs, z, fill)
150
+
151
+ # ---- 4. normalize -> Pi0 ------------------------------------------------
152
+ Z = Z / np.maximum(Z.sum(axis=0, keepdims=True), 1e-300)
153
+ Znb = Z.sum(axis=1) # (N, B)
154
+ row = Znb.sum(axis=-1, keepdims=True)
155
+ Pi0 = np.where(row > 0, Znb / np.maximum(row, 1e-300), 1.0 / B)
156
+ Pi0 = np.clip(Pi0, 1e-8, 1.0)
157
+ Pi0 = Pi0 / Pi0.sum(axis=-1, keepdims=True)
158
+
159
+ # ---- 5. Lambda0, R0, P0, phi0 ------------------------------------------
160
+ Lambda0 = np.broadcast_to(np.asarray(lambda_init, dtype=np.float64),
161
+ (S,)).copy()
162
+ pi_bar = Pi0.mean(axis=0) # (B,)
163
+ R0 = r_hat / (Lambda0[:, None] * pi_bar[None, :])
164
+ P0 = np.clip((1.0 + R0) / (1.0 / p_hat + R0), 1e-6, 1 - 1e-6)
165
+ all_zero = (Xz * mask).sum(axis=(1, 2)) == 0
166
+ phi0 = float(np.clip(all_zero.mean(), 1e-4, 0.5))
167
+
168
+ return InitResult(R=R0, P=P0, Pi=Pi0, Lambda=Lambda0, phi=phi0,
169
+ r_hat=r_hat, p_hat=p_hat, pi0_hat=pi0_hat,
170
+ r_ref=float(r_ref), p_ref=float(p_ref))
171
+
172
+
173
+ def init_from_fit(fit):
174
+ """Build an ``InitResult`` from a previous fit, for warm starts.
175
+
176
+ The fitted profile Pi is enough to recover (mu, sigma); R/P/phi carry the
177
+ global block. Useful when refitting the same data under a different
178
+ abundance model.
179
+ """
180
+ get = (lambda k: fit[k]) if isinstance(fit, dict) \
181
+ else (lambda k: getattr(fit, k))
182
+ Pi = np.asarray(get("Pi"), float).copy()
183
+ Pi[~np.isfinite(Pi).all(1)] = 1.0 / Pi.shape[1] # fully-missing rows
184
+ return InitResult(R=np.asarray(get("R")), P=np.asarray(get("P")), Pi=Pi,
185
+ Lambda=np.asarray(get("rates")).reshape(-1),
186
+ phi=float(get("phi")))
ebin/model.py ADDED
@@ -0,0 +1,186 @@
1
+ """Marginal likelihood of the compound NB-Poisson count model.
2
+
3
+ T[n,s,b] ~ Poisson(a_n * Pi[n,b] * lambda_s) latent cells
4
+ X[n,s,b] | T[n,s,b] ~ NB(R[s,b] * T[n,s,b], P[s,b]) reads
5
+
6
+ Pi[n, :] is the object's bin profile (a simplex row), a_n its abundance and
7
+ lambda_s the per-replicate latent level. The reads of a cell are a sum over the
8
+ cells that landed in bin b, so the emission compounds with the Poisson: the
9
+ marginal pmf is a sum over the latent count tau, truncated at the point where
10
+ the Poisson tail is below ``tail_eps``. NB tables are evaluated on the unique
11
+ count values of each channel and gathered back.
12
+
13
+ lambda is not identified upward -- it rides the R*lambda ridge, and only
14
+ products like c*Pi*lambda with c = R(1-P)/P are identified -- so it is held
15
+ fixed (``lambda_fix``) and R absorbs the scale.
16
+ """
17
+
18
+ import numpy as np
19
+ import jax
20
+ import jax.numpy as jnp
21
+ from jax.scipy.special import gammaln, logsumexp
22
+ from scipy.stats import poisson as _sp_poisson
23
+
24
+ from .gauss_rules import compute_nodes_and_logweights, Rules
25
+
26
+ jax.config.update("jax_enable_x64", True)
27
+
28
+ _NEG_INF = -jnp.inf
29
+
30
+
31
+ def nb_logpmf(x, r, p):
32
+ """log NB(x | r, p), scipy convention (mean r(1-p)/p). r may be
33
+ non-integer; r == 0 is the point mass at 0."""
34
+ r_safe = jnp.maximum(r, 1e-300)
35
+ lp = (gammaln(x + r_safe) - gammaln(r_safe) - gammaln(x + 1.0)
36
+ + r_safe * jnp.log(p) + x * jnp.log1p(-p))
37
+ degenerate = jnp.where(x == 0, 0.0, _NEG_INF)
38
+ return jnp.where(r > 0, lp, degenerate)
39
+
40
+
41
+ def poisson_trunc_bound(lam_max, tail_eps=1e-10, slack=5):
42
+ """Smallest T with P(Poisson(lam_max) > T) < tail_eps, plus slack."""
43
+ return int(_sp_poisson.isf(tail_eps, max(lam_max, 1e-6))) + 1 + slack
44
+
45
+
46
+ def gamma_mixture_rule(alpha, mean, num_nodes):
47
+ """Gamma(alpha, theta=mean/alpha) discretized on ``num_nodes`` generalized
48
+ Gauss-Laguerre nodes. Differentiable in alpha through the custom-JVP
49
+ tridiagonal eigensolver in gauss_rules. Returns (nodes, log weights)."""
50
+ nodes, logw = compute_nodes_and_logweights(
51
+ num_nodes, alpha - 1.0, rule=Rules.GenLaguerre, norm=True)
52
+ return nodes * (mean / alpha), logw
53
+
54
+
55
+ @jax.checkpoint
56
+ def _logmarg_one_rate(log_em_n, mu_n, tau, lg):
57
+ """(N,) log sum_tau NB(x | R*tau, P) * Poisson(tau | mu_n) for one rate.
58
+
59
+ Checkpointed: the (N, T) intermediates are recomputed in the backward pass
60
+ instead of being stored."""
61
+ log_pois = (tau[None, :]
62
+ * jnp.log(jnp.maximum(mu_n, 1e-300))[:, None]
63
+ - mu_n[:, None] - lg[None, :])
64
+ return logsumexp(log_em_n + log_pois, axis=1)
65
+
66
+
67
+ class LoglikBuilder:
68
+ """Precomputes the X-dependent tables and exposes traceable likelihoods.
69
+
70
+ X : (N, S, B) counts (NaN = missing), mask : optional (N, S, B) bool.
71
+ rate_max : upper bound on any latent rate; sizes the tau grid and must
72
+ dominate the largest rate the optimizer can reach (a_max * lambda when
73
+ an abundance is modelled).
74
+ """
75
+
76
+ def __init__(self, X, mask=None, rate_max=200.0, tail_eps=1e-10):
77
+ X = np.asarray(X)
78
+ if X.ndim != 3:
79
+ raise ValueError("X must have shape (N, S, B)")
80
+ nan_mask = ~np.isnan(X) if np.issubdtype(X.dtype, np.floating) else \
81
+ np.ones(X.shape, bool)
82
+ mask = nan_mask if mask is None else (np.asarray(mask, bool) & nan_mask)
83
+ Xi = np.where(mask, np.nan_to_num(X, nan=0.0), 0.0)
84
+ if not np.allclose(Xi, np.round(Xi)):
85
+ raise ValueError("X must contain (masked) non-negative integers")
86
+ Xi = np.round(Xi).astype(np.int64)
87
+ if (Xi < 0).any():
88
+ raise ValueError("X must be non-negative")
89
+
90
+ self.N, self.S, self.B = Xi.shape
91
+ self.rate_max = float(rate_max)
92
+
93
+ # per-channel unique values: each (s,b) NB table only covers the counts
94
+ # that actually occur in that channel
95
+ self.xu = [[None] * self.B for _ in range(self.S)]
96
+ self.inv = [[None] * self.B for _ in range(self.S)]
97
+ for s in range(self.S):
98
+ for b in range(self.B):
99
+ xu, inv = np.unique(Xi[:, s, b], return_inverse=True)
100
+ self.xu[s][b] = jnp.asarray(xu, dtype=jnp.float64)
101
+ self.inv[s][b] = jnp.asarray(inv)
102
+ self.mask = jnp.asarray(mask)
103
+ self.X = Xi
104
+ self.all_zero = jnp.asarray((Xi * mask).sum(axis=(1, 2)) == 0)
105
+ self.n_observed = int(mask.sum())
106
+
107
+ T_full = poisson_trunc_bound(rate_max, tail_eps)
108
+ self.tau_full = jnp.arange(T_full, dtype=jnp.float64)
109
+ self.lg_tau_full = gammaln(self.tau_full + 1.0)
110
+
111
+ def _log_nb_gathered(self, s, b, r, p, tau):
112
+ """(N, T) log NB(x_nsb | r * tau, p) via the unique-value table."""
113
+ log_nb = nb_logpmf(self.xu[s][b][:, None], r * tau[None, :], p)
114
+ return log_nb[self.inv[s][b]]
115
+
116
+ def _acc_over_rates(self, s, tbls, Pi, rates_s):
117
+ """(N, K) sum over b of the masked log-marginals, one column per rate.
118
+
119
+ Scanned with a rematerialized body so only one rate's (N, T)
120
+ intermediates are live at a time; a plain loop over k lets XLA allocate
121
+ all K concurrently (tens of GB at N=30000).
122
+ """
123
+ tau, lg = self.tau_full, self.lg_tau_full
124
+ mask_s = [self.mask[:, s, b] for b in range(self.B)]
125
+
126
+ def body(carry, rate):
127
+ acc_k = jnp.zeros(self.N, dtype=jnp.float64)
128
+ for b in range(self.B):
129
+ lm = _logmarg_one_rate(tbls[b], Pi[:, b] * rate, tau, lg)
130
+ acc_k = acc_k + jnp.where(mask_s[b], lm, 0.0)
131
+ return carry, acc_k
132
+
133
+ _, accT = jax.lax.scan(jax.checkpoint(body), None, rates_s) # (K, N)
134
+ return accT.T
135
+
136
+ def _nb_tables(self, s, R, P):
137
+ return [self._log_nb_gathered(s, b, R[s, b], P[s, b], self.tau_full)
138
+ for b in range(self.B)]
139
+
140
+ @staticmethod
141
+ def _zero_inflate(inner, phi, all_zero):
142
+ phi_c = jnp.clip(phi, 0.0, 1.0 - 1e-12)
143
+ log_phi = jnp.log(jnp.maximum(phi_c, 1e-300))
144
+ log_1mphi = jnp.log1p(-phi_c)
145
+ with_zero = jnp.logaddexp(log_phi, log_1mphi + inner)
146
+ return jnp.where(all_zero, with_zero, log_1mphi + inner)
147
+
148
+ def object_loglik(self, R, P, Pi, rates, log_wmix, phi=0.0):
149
+ """(N,) per-object log-likelihood.
150
+
151
+ Pi : (N, B) rows fed to the latent Poisson (a_n * profile when an
152
+ abundance is fitted). rates / log_wmix : (S, K) latent rates and
153
+ their log-weights (K = 1 for the fixed-rate model). phi : object
154
+ level zero-inflation probability.
155
+ """
156
+ rates = jnp.atleast_2d(rates)
157
+ log_wmix = jnp.atleast_2d(log_wmix)
158
+ inner = jnp.zeros(self.N, dtype=jnp.float64)
159
+ for s in range(self.S):
160
+ acc = self._acc_over_rates(s, self._nb_tables(s, R, P), Pi,
161
+ rates[s]) # (N, K)
162
+ inner = inner + logsumexp(acc + log_wmix[s][None, :], axis=1)
163
+ return self._zero_inflate(inner, phi, self.all_zero)
164
+
165
+ def object_loglik_abund(self, R, P, Pi, base_lambda, a_nodes, log_wa,
166
+ phi=0.0):
167
+ """(N,) per-object log-likelihood with the abundance integrated/marginalized out:
168
+
169
+ a_n ~ sum_k exp(log_wa[k]) delta(a_nodes[k]),
170
+ T[n,s,b] | a_n ~ Poisson(a_n * Pi[n,b] * base_lambda[s]).
171
+
172
+ a_n couples every cell of the object, so the mixture is resolved
173
+ (logsumexp over nodes) only AFTER summing the per-cell log-marginals
174
+ over both s and b -- unlike ``object_loglik``, whose rate mixture is
175
+ resolved within each replicate. Nodes past the tau grid self-truncate;
176
+ they carry negligible weight, and truncating only shrinks their
177
+ contribution, so the bound is conservative.
178
+ """
179
+ a_nodes = jnp.asarray(a_nodes)
180
+ base_lambda = jnp.atleast_1d(jnp.asarray(base_lambda))
181
+ acc_total = jnp.zeros((self.N, a_nodes.shape[0]), dtype=jnp.float64)
182
+ for s in range(self.S):
183
+ acc_total = acc_total + self._acc_over_rates(
184
+ s, self._nb_tables(s, R, P), Pi, a_nodes * base_lambda[s])
185
+ inner = logsumexp(acc_total + log_wa[None, :], axis=1)
186
+ return self._zero_inflate(inner, phi, self.all_zero)