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/__init__.py +32 -0
- ebin/__main__.py +76 -0
- ebin/activity.py +158 -0
- ebin/data.py +93 -0
- ebin/effects.py +161 -0
- ebin/fit.py +512 -0
- ebin/ftzkiller.py +70 -0
- ebin/gauss_rules.py +297 -0
- ebin/initialize.py +186 -0
- ebin/model.py +186 -0
- ebin/pipeline.py +173 -0
- ebin/plots.py +251 -0
- ebin/truncation.py +59 -0
- ebin-1.0.dist-info/METADATA +12 -0
- ebin-1.0.dist-info/RECORD +18 -0
- ebin-1.0.dist-info/WHEEL +5 -0
- ebin-1.0.dist-info/entry_points.txt +2 -0
- ebin-1.0.dist-info/top_level.txt +1 -0
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)
|