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/fit.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""Maximum-marginal-likelihood fit of the normal-effects compound model.
|
|
2
|
+
|
|
3
|
+
Parameters split into a global block theta = [log R, logit P, logit phi] (S*B
|
|
4
|
+
channels) and a per-object block eta = [mu, log sigma, (log a | log kappa)].
|
|
5
|
+
The optimizer runs a short block-coordinate warmup (global block over a
|
|
6
|
+
SLSQP/TNC/L-BFGS-B cycle, then the eta block) followed by joint finishing rounds
|
|
7
|
+
(TNC, falling back to L-BFGS-B). Steps are accepted on the PENALIZED objective,
|
|
8
|
+
which is what ``minimize`` actually descends.
|
|
9
|
+
|
|
10
|
+
Regularization (all in the pinned gauge, where the cut spacing is 1):
|
|
11
|
+
|
|
12
|
+
* ``sigma_prior`` Normal prior on log sigma_n. The per-object MLE is
|
|
13
|
+
unbounded: an object whose reads land only in the outer bins wants the
|
|
14
|
+
profile (t, 0, 0, 1-t), reachable only as sigma -> inf along mu = c*sigma,
|
|
15
|
+
where the Gaussian degenerates into two point masses. Such objects otherwise
|
|
16
|
+
run to the log-sigma bound.
|
|
17
|
+
* ``mu_prior`` Normal prior on the effect means. sigma_prior bounds the
|
|
18
|
+
effect SPREAD but not its LOCATION, so a single-bin low-count object still
|
|
19
|
+
runs mu_n large and its Pi walks to a simplex vertex (activity exactly 1 or
|
|
20
|
+
B). The gauge pins Q50 = 0 and Q25 = -1, i.e. population sd ~ 1.5 and
|
|
21
|
+
between-object sd ~ 1.1, so the gauge-consistent tau is ~1.3, not 3.
|
|
22
|
+
* the soft gauge pin, which costs nothing at the optimum (the likelihood
|
|
23
|
+
gradient along the gauge is exactly zero) and is applied exactly on exit.
|
|
24
|
+
|
|
25
|
+
Abundance. Without one, every object has the same expected latent total
|
|
26
|
+
(lambda_s), so any real depth variation has nowhere to go but Pi, which tilts
|
|
27
|
+
toward the bins with the largest channel scale c = R(1-P)/P -- Pi stops being a
|
|
28
|
+
profile and becomes an abundance proxy. Either fit a free a_n
|
|
29
|
+
(``abundance=True``) or integrate it out under a Gamma prior
|
|
30
|
+
(``abundance_prior='gamma'``), which costs one global parameter instead of N and
|
|
31
|
+
propagates the abundance uncertainty into the effect.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import time
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
import jax
|
|
39
|
+
import jax.numpy as jnp
|
|
40
|
+
from scipy.optimize import minimize
|
|
41
|
+
from scipy.special import ndtri
|
|
42
|
+
|
|
43
|
+
from .model import LoglikBuilder, gamma_mixture_rule
|
|
44
|
+
from .initialize import initialize
|
|
45
|
+
from .effects import mixture_quantiles, normal_bin_probs, gauge_normalize
|
|
46
|
+
from .truncation import log_zero_prob, log_zero_prob_abund, log1mexp
|
|
47
|
+
|
|
48
|
+
jax.config.update("jax_enable_x64", True)
|
|
49
|
+
|
|
50
|
+
_GLOBAL_CYCLE = ["SLSQP", "TNC", "L-BFGS-B"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class FitResult:
|
|
55
|
+
R: np.ndarray # (S, B) emission size
|
|
56
|
+
P: np.ndarray # (S, B) emission probability
|
|
57
|
+
mu: np.ndarray # (N,) effect means (pinned gauge)
|
|
58
|
+
sigma: np.ndarray # (N,) effect sds (pinned gauge)
|
|
59
|
+
cuts: np.ndarray # (B-1,) shared bin cut points
|
|
60
|
+
Pi: np.ndarray # (N, B) implied bin profile
|
|
61
|
+
rates: np.ndarray # (S, K) latent Poisson rates
|
|
62
|
+
log_w: np.ndarray # (S, K) their log weights
|
|
63
|
+
phi: float # structural-zero probability (0 if truncated)
|
|
64
|
+
loglik: float
|
|
65
|
+
n_observed: int
|
|
66
|
+
converged: bool
|
|
67
|
+
observed: np.ndarray # (N,) objects entering the fit
|
|
68
|
+
a: np.ndarray = None # (N,) fitted abundance (ones if not fitted)
|
|
69
|
+
n_zero_dropped: int = 0 # all-zero rows removed by the truncation
|
|
70
|
+
extras: dict = None # kappa / abundance CV under a Gamma prior
|
|
71
|
+
history: list = field(repr=False, default=None)
|
|
72
|
+
config: dict = field(repr=False, default=None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _solver_options(method, budget):
|
|
76
|
+
"""Per-solver budgets and explicit tolerances.
|
|
77
|
+
|
|
78
|
+
The objective is scaled by 1/n_observed, so scipy's default stopping tests
|
|
79
|
+
fire while thousands of nats remain. These are deliberately tight; the
|
|
80
|
+
iteration budget and the outer test (tol * n_obs) are the real stopping
|
|
81
|
+
rules.
|
|
82
|
+
"""
|
|
83
|
+
if method == "TNC":
|
|
84
|
+
return dict(maxfun=int(budget), ftol=1e-12, xtol=1e-14, gtol=1e-11)
|
|
85
|
+
if method == "L-BFGS-B":
|
|
86
|
+
return dict(maxiter=int(budget), maxcor=20, ftol=1e-14, gtol=1e-11)
|
|
87
|
+
if method == "SLSQP":
|
|
88
|
+
return dict(maxiter=int(budget), ftol=1e-12)
|
|
89
|
+
return dict(maxiter=int(budget))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _res_line(res, scale):
|
|
93
|
+
msg = str(getattr(res, "message", ""))
|
|
94
|
+
return (f"status={getattr(res, 'status', '?')} nit={getattr(res, 'nit', -1)} "
|
|
95
|
+
f"nfev={getattr(res, 'nfev', -1)} ll={-res.fun / scale:.4f} "
|
|
96
|
+
f"| {msg[:60]}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _Globals:
|
|
100
|
+
"""The channel block: theta = [log R (S*B), logit P (S*B), logit phi]."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, S, B, lambda_fix):
|
|
103
|
+
self.S, self.B, self.lambda_fix = S, B, float(lambda_fix)
|
|
104
|
+
n = S * B
|
|
105
|
+
self.idx_R = slice(0, n)
|
|
106
|
+
self.idx_P = slice(n, 2 * n)
|
|
107
|
+
self.idx_phi = 2 * n
|
|
108
|
+
self.n_params = 2 * n + 1
|
|
109
|
+
|
|
110
|
+
def pack(self, init):
|
|
111
|
+
th = np.zeros(self.n_params)
|
|
112
|
+
th[self.idx_R] = np.log(np.asarray(init.R)).ravel()
|
|
113
|
+
Pc = np.clip(np.asarray(init.P), 1e-12, 1 - 1e-12)
|
|
114
|
+
th[self.idx_P] = np.log(Pc / (1 - Pc)).ravel()
|
|
115
|
+
phi = float(np.clip(init.phi, 1e-6, 0.95))
|
|
116
|
+
th[self.idx_phi] = np.log(phi / (1 - phi))
|
|
117
|
+
return th
|
|
118
|
+
|
|
119
|
+
def bounds(self):
|
|
120
|
+
n = self.S * self.B
|
|
121
|
+
return [(-20.0, 20.0)] * n + [(-25.0, 25.0)] * n + [(-14.0, 3.0)]
|
|
122
|
+
|
|
123
|
+
def unpack(self, theta):
|
|
124
|
+
R = jnp.exp(theta[self.idx_R]).reshape(self.S, self.B)
|
|
125
|
+
P = jax.nn.sigmoid(theta[self.idx_P]).reshape(self.S, self.B)
|
|
126
|
+
phi = jax.nn.sigmoid(theta[self.idx_phi])
|
|
127
|
+
rates = jnp.full((self.S,), self.lambda_fix)[:, None]
|
|
128
|
+
return R, P, rates, jnp.zeros((self.S, 1)), phi
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _eta_init(Pi0, target_probs):
|
|
132
|
+
"""Probit least squares per object: with q_j the standard-normal baseline
|
|
133
|
+
cuts, q_j ~ mu_n + sigma_n * ndtri(cumsum(Pi0)_nj)."""
|
|
134
|
+
Pi0 = np.asarray(Pi0, dtype=float)
|
|
135
|
+
B = Pi0.shape[1]
|
|
136
|
+
c = np.clip(np.cumsum(Pi0, axis=1)[:, :B - 1], 1e-4, 1 - 1e-4)
|
|
137
|
+
z = ndtri(c) # (N, B-1)
|
|
138
|
+
q0 = ndtri(np.asarray(target_probs, dtype=float)) # (B-1,)
|
|
139
|
+
zbar = z.mean(axis=1)
|
|
140
|
+
var_z = ((z - zbar[:, None]) ** 2).sum(axis=1)
|
|
141
|
+
cov = ((z - zbar[:, None]) * (q0 - q0.mean())[None, :]).sum(axis=1)
|
|
142
|
+
s = np.clip(np.where(var_z > 1e-10, cov / np.maximum(var_z, 1e-10), 1.0),
|
|
143
|
+
1e-3, 1e3)
|
|
144
|
+
return q0.mean() - s * zbar, s
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def fit_effects(X, mask=None, *, lambda_fix=50.0, lambda_init=50.0,
|
|
148
|
+
conditional=False, abundance=True, a_max=15.0,
|
|
149
|
+
abundance_prior=None, n_abund_nodes=24,
|
|
150
|
+
kappa_init=None, kappa_bounds=(0.5, 4096.0),
|
|
151
|
+
sigma_prior=(0.0, 0.5), mu_prior=1.3, mu_center=None,
|
|
152
|
+
pin_median=0.0, pin_left=-1.0, pin_weight=100.0,
|
|
153
|
+
pi_floor=1e-12, init=None,
|
|
154
|
+
max_outer=3, tol=1e-6, global_maxiter=80, eta_maxiter=40,
|
|
155
|
+
finish_maxiter=5000, finish_rounds=6, verbose=True):
|
|
156
|
+
"""Fit the normal-effects model to one cell line's (N, S, B) counts.
|
|
157
|
+
|
|
158
|
+
conditional : zero-TRUNCATED likelihood -- all-zero rows are dropped from
|
|
159
|
+
both the likelihood and the mixture that sets the cuts, and each
|
|
160
|
+
kept object is conditioned on Sum X > 0. The default (False) is
|
|
161
|
+
zero-INFLATION instead: all-zero rows are fitted with a structural-zero
|
|
162
|
+
probability phi and so enter the mixture that sets the cuts, which is
|
|
163
|
+
what aligns the cuts with the read-count structure.
|
|
164
|
+
abundance / abundance_prior : fit a free per-object a_n, or integrate a_n
|
|
165
|
+
out under a Gamma(kappa, mean=1) prior discretized on ``n_abund_nodes``
|
|
166
|
+
Gauss-Laguerre nodes (mutually exclusive). ``a_max`` bounds a_n and
|
|
167
|
+
sizes the latent tau grid; under the Gamma prior it is only the grid
|
|
168
|
+
ceiling, since the far nodes carry negligible weight.
|
|
169
|
+
sigma_prior : (m, s) Normal prior on log sigma_n, or None.
|
|
170
|
+
mu_prior, mu_center : sd and target of the Normal prior on mu_n (None
|
|
171
|
+
disables). ``mu_center`` may be an (N,) array.
|
|
172
|
+
init : an ``InitResult`` to start from (see ``init_from_fit`` for warm
|
|
173
|
+
starts); ``None`` runs the per-column ZINB initialization.
|
|
174
|
+
|
|
175
|
+
Defaults reproduce the shipped configuration.
|
|
176
|
+
"""
|
|
177
|
+
t0 = time.time()
|
|
178
|
+
X = np.asarray(X)
|
|
179
|
+
N, S, B = X.shape
|
|
180
|
+
if B < 3:
|
|
181
|
+
raise ValueError("B >= 3 is required for per-object sigma to be "
|
|
182
|
+
"identified")
|
|
183
|
+
target_probs = np.arange(1, B) / B
|
|
184
|
+
mid = (B - 1) // 2
|
|
185
|
+
|
|
186
|
+
abund_marg = abundance_prior is not None # abundance integrated out
|
|
187
|
+
if abund_marg:
|
|
188
|
+
if abundance_prior != "gamma":
|
|
189
|
+
raise ValueError(f"unknown abundance_prior {abundance_prior!r} "
|
|
190
|
+
"(only 'gamma')")
|
|
191
|
+
if abundance:
|
|
192
|
+
raise ValueError("abundance_prior integrates a_n OUT; it is "
|
|
193
|
+
"mutually exclusive with abundance=True")
|
|
194
|
+
if n_abund_nodes < 2:
|
|
195
|
+
raise ValueError("n_abund_nodes must be >= 2")
|
|
196
|
+
|
|
197
|
+
if init is None:
|
|
198
|
+
if verbose:
|
|
199
|
+
print("[init] fitting per-column ZINBs / quantile transform ...")
|
|
200
|
+
init = initialize(X, mask=mask, lambda_init=lambda_init, verbose=False)
|
|
201
|
+
|
|
202
|
+
spec = _Globals(S, B, lambda_fix)
|
|
203
|
+
# with an abundance the latent rate reaches a_max * lambda, so the
|
|
204
|
+
# truncated tau grid must be sized for it
|
|
205
|
+
rate_max = lambda_fix * (a_max if (abundance or abund_marg) else 1.0)
|
|
206
|
+
builder = LoglikBuilder(X, mask=mask, rate_max=rate_max)
|
|
207
|
+
|
|
208
|
+
observed = np.asarray(builder.mask).any(axis=(1, 2))
|
|
209
|
+
if conditional:
|
|
210
|
+
all_zero = np.asarray(builder.all_zero)
|
|
211
|
+
kept = observed & ~all_zero
|
|
212
|
+
n_zero_dropped = int((observed & all_zero).sum())
|
|
213
|
+
n_obs = int(np.asarray(builder.mask)[kept].sum())
|
|
214
|
+
else:
|
|
215
|
+
kept = observed
|
|
216
|
+
n_zero_dropped = 0
|
|
217
|
+
n_obs = builder.n_observed
|
|
218
|
+
if not kept.any():
|
|
219
|
+
raise ValueError("no objects left to fit")
|
|
220
|
+
|
|
221
|
+
scale = 1.0 / max(n_obs, 1)
|
|
222
|
+
kept_j = jnp.asarray(kept)
|
|
223
|
+
w_np = kept.astype(np.float64)
|
|
224
|
+
w_np /= w_np.sum()
|
|
225
|
+
w_mix = jnp.asarray(w_np) # uniform over the objects that count
|
|
226
|
+
i_kappa = 2 * N # eta = [mu, log sigma, log kappa]
|
|
227
|
+
if verbose:
|
|
228
|
+
print(f"[fit] N={N} S={S} B={B} kept={int(kept.sum())} "
|
|
229
|
+
f"(missing={int((~observed).sum())}, "
|
|
230
|
+
f"zero-dropped={n_zero_dropped}) conditional={conditional} "
|
|
231
|
+
f"abundance={'gamma' if abund_marg else abundance} "
|
|
232
|
+
f"tau_grid={builder.tau_full.shape[0]} "
|
|
233
|
+
f"sigma_prior={sigma_prior} mu_prior={mu_prior}")
|
|
234
|
+
|
|
235
|
+
# --- objective ----------------------------------------------------------
|
|
236
|
+
def _pi_and_cuts(eta):
|
|
237
|
+
mu = eta[:N]
|
|
238
|
+
sig = jnp.exp(eta[N:2 * N])
|
|
239
|
+
cuts = mixture_quantiles(mu, sig, w_mix, target_probs)
|
|
240
|
+
return normal_bin_probs(mu, sig, cuts, floor=pi_floor), cuts
|
|
241
|
+
|
|
242
|
+
def _rows_and_cuts(eta):
|
|
243
|
+
"""Rows fed to the latent Poisson: the profile, or a_n * profile."""
|
|
244
|
+
Pi, cuts = _pi_and_cuts(eta)
|
|
245
|
+
if not abundance:
|
|
246
|
+
return Pi, cuts
|
|
247
|
+
return jnp.exp(eta[2 * N:3 * N])[:, None] * Pi, cuts
|
|
248
|
+
|
|
249
|
+
def _abund_rule(eta):
|
|
250
|
+
"""(nodes, log weights) of the Gamma(kappa, mean=1) abundance prior."""
|
|
251
|
+
return gamma_mixture_rule(jnp.exp(eta[i_kappa]), 1.0, n_abund_nodes)
|
|
252
|
+
|
|
253
|
+
def _data_neg_ll(theta, eta):
|
|
254
|
+
R, P, rates, log_w, phi = spec.unpack(theta)
|
|
255
|
+
M, _ = _rows_and_cuts(eta)
|
|
256
|
+
if abund_marg:
|
|
257
|
+
a_nodes, log_wa = _abund_rule(eta)
|
|
258
|
+
base_lambda = rates[:, 0] # (S,)
|
|
259
|
+
obj = builder.object_loglik_abund(
|
|
260
|
+
R, P, M, base_lambda, a_nodes, log_wa,
|
|
261
|
+
0.0 if conditional else phi)
|
|
262
|
+
if conditional:
|
|
263
|
+
lz = jnp.minimum(log_zero_prob_abund(
|
|
264
|
+
R, P, M, base_lambda, a_nodes, log_wa, builder.mask),
|
|
265
|
+
-1e-12)
|
|
266
|
+
total = jnp.sum(jnp.where(kept_j, obj - log1mexp(lz), 0.0))
|
|
267
|
+
else:
|
|
268
|
+
total = jnp.sum(obj)
|
|
269
|
+
return -total * scale
|
|
270
|
+
if conditional:
|
|
271
|
+
obj = builder.object_loglik(R, P, M, rates, log_w, 0.0)
|
|
272
|
+
lz = jnp.minimum(log_zero_prob(R, P, M, rates, log_w,
|
|
273
|
+
builder.mask), -1e-12)
|
|
274
|
+
total = jnp.sum(jnp.where(kept_j, obj - log1mexp(lz), 0.0))
|
|
275
|
+
else:
|
|
276
|
+
total = jnp.sum(builder.object_loglik(R, P, M, rates, log_w, phi))
|
|
277
|
+
return -total * scale
|
|
278
|
+
|
|
279
|
+
def _gauge_pen(eta):
|
|
280
|
+
"""Soft pin of the effect-axis gauge (and of the a-scale ridge that a_n
|
|
281
|
+
shares with R: only c*a*lambda is identified, so pin the geometric mean
|
|
282
|
+
of a to 1)."""
|
|
283
|
+
_, cuts = _pi_and_cuts(eta)
|
|
284
|
+
pen = pin_weight * jnp.square(cuts[mid] - pin_median)
|
|
285
|
+
if pin_left is not None and mid > 0:
|
|
286
|
+
pen = pen + pin_weight * jnp.square(cuts[0] - pin_left)
|
|
287
|
+
if abundance:
|
|
288
|
+
la = eta[2 * N:3 * N]
|
|
289
|
+
pen = pen + pin_weight * jnp.square(
|
|
290
|
+
jnp.sum(jnp.where(kept_j, la, 0.0)) / jnp.sum(kept_j))
|
|
291
|
+
return pen
|
|
292
|
+
|
|
293
|
+
def _sigma_pen(eta):
|
|
294
|
+
"""-log prior on log sigma, on the same 1/n_obs scale as the data term
|
|
295
|
+
(unlike the gauge pins, its strength relative to the likelihood
|
|
296
|
+
matters)."""
|
|
297
|
+
if sigma_prior is None:
|
|
298
|
+
return 0.0
|
|
299
|
+
m_s, s_s = sigma_prior
|
|
300
|
+
quad = jnp.sum(jnp.where(kept_j, (eta[N:2 * N] - m_s) ** 2, 0.0))
|
|
301
|
+
return scale * quad / (2.0 * s_s ** 2)
|
|
302
|
+
|
|
303
|
+
mu_center_j = (jnp.asarray(mu_center, dtype=jnp.float64)
|
|
304
|
+
if mu_center is not None else jnp.float64(0.0))
|
|
305
|
+
|
|
306
|
+
def _mu_pen(eta):
|
|
307
|
+
"""-log prior on the effect means, on the 1/n_obs scale."""
|
|
308
|
+
if mu_prior is None:
|
|
309
|
+
return 0.0
|
|
310
|
+
quad = jnp.sum(jnp.where(kept_j, (eta[:N] - mu_center_j) ** 2, 0.0))
|
|
311
|
+
return scale * quad / (2.0 * mu_prior ** 2)
|
|
312
|
+
|
|
313
|
+
def _neg_obj(theta, eta):
|
|
314
|
+
return (_data_neg_ll(theta, eta) + _gauge_pen(eta)
|
|
315
|
+
+ _sigma_pen(eta) + _mu_pen(eta))
|
|
316
|
+
|
|
317
|
+
vg_global = jax.jit(jax.value_and_grad(_neg_obj, argnums=0))
|
|
318
|
+
vg_eta = jax.jit(jax.value_and_grad(
|
|
319
|
+
lambda eta, theta: _neg_obj(theta, eta), argnums=0))
|
|
320
|
+
n_glob = spec.n_params
|
|
321
|
+
vg_joint = jax.jit(jax.value_and_grad(
|
|
322
|
+
lambda x: _neg_obj(x[:n_glob], x[n_glob:])))
|
|
323
|
+
ll_fn = jax.jit(lambda theta, eta: -_data_neg_ll(theta, eta) / scale)
|
|
324
|
+
pll_fn = jax.jit(lambda theta, eta: -_neg_obj(theta, eta) / scale)
|
|
325
|
+
cuts_fn = jax.jit(lambda eta: _pi_and_cuts(eta)[1])
|
|
326
|
+
|
|
327
|
+
# --- starting point -----------------------------------------------------
|
|
328
|
+
theta = spec.pack(init)
|
|
329
|
+
g_bounds = spec.bounds()
|
|
330
|
+
|
|
331
|
+
mu0, s0 = _eta_init(init.Pi, target_probs)
|
|
332
|
+
eta = np.concatenate([mu0, np.log(s0)])
|
|
333
|
+
e_bounds = [(-40.0, 40.0)] * N + [(-7.0, 7.0)] * N
|
|
334
|
+
if abundance:
|
|
335
|
+
# start a at the object's per-cell total relative to the group mean
|
|
336
|
+
# (the quantity a exists to explain), geometric mean pinned to 1
|
|
337
|
+
Xz = np.where(np.asarray(builder.mask), np.nan_to_num(X, nan=0.0), 0.0)
|
|
338
|
+
cells = np.maximum(np.asarray(builder.mask).sum(axis=(1, 2)), 1)
|
|
339
|
+
rate_n = Xz.sum(axis=(1, 2)) / cells # depth-robust per-cell mean
|
|
340
|
+
ref = np.exp(np.mean(np.log(rate_n[kept] + 1e-6)))
|
|
341
|
+
la0 = np.log(np.clip(rate_n / max(ref, 1e-12), 1e-3, a_max * 0.9))
|
|
342
|
+
la0[~kept] = 0.0
|
|
343
|
+
la0 -= la0[kept].mean()
|
|
344
|
+
eta = np.concatenate([eta, la0])
|
|
345
|
+
e_bounds += [(np.log(1e-4), np.log(a_max))] * N
|
|
346
|
+
if abund_marg:
|
|
347
|
+
# start the Gamma shape from the object-total CV: a Gamma(kappa,
|
|
348
|
+
# mean=1) has CV = 1/sqrt(kappa)
|
|
349
|
+
if kappa_init is None:
|
|
350
|
+
Xz = np.where(np.asarray(builder.mask),
|
|
351
|
+
np.nan_to_num(X, nan=0.0), 0.0)
|
|
352
|
+
tot = Xz.sum(axis=(1, 2))[kept]
|
|
353
|
+
mu_t = tot.mean()
|
|
354
|
+
cv = tot.std() / mu_t if mu_t > 0 else 1.0
|
|
355
|
+
k0 = 1.0 / max(cv, 1e-3) ** 2
|
|
356
|
+
else:
|
|
357
|
+
k0 = float(kappa_init)
|
|
358
|
+
k0 = float(np.clip(k0, kappa_bounds[0] * 1.05, kappa_bounds[1] * 0.95))
|
|
359
|
+
eta = np.concatenate([eta, [np.log(k0)]])
|
|
360
|
+
e_bounds += [(np.log(kappa_bounds[0]), np.log(kappa_bounds[1]))]
|
|
361
|
+
if verbose:
|
|
362
|
+
print(f"[fit] gamma abundance: nodes={n_abund_nodes} "
|
|
363
|
+
f"kappa_init={k0:.3g} (CV~{1/np.sqrt(k0):.2f})")
|
|
364
|
+
|
|
365
|
+
# start in the pinned gauge
|
|
366
|
+
mu0, s0, _ = gauge_normalize(eta[:N], np.exp(eta[N:2 * N]),
|
|
367
|
+
np.asarray(cuts_fn(jnp.asarray(eta))),
|
|
368
|
+
pin_median=pin_median, pin_left=pin_left)
|
|
369
|
+
eta[:N] = mu0
|
|
370
|
+
eta[N:2 * N] = np.log(s0)
|
|
371
|
+
|
|
372
|
+
history = []
|
|
373
|
+
|
|
374
|
+
def current_ll(theta, eta):
|
|
375
|
+
return float(ll_fn(jnp.asarray(theta), jnp.asarray(eta)))
|
|
376
|
+
|
|
377
|
+
def current_pll(theta, eta):
|
|
378
|
+
return float(pll_fn(jnp.asarray(theta), jnp.asarray(eta)))
|
|
379
|
+
|
|
380
|
+
def record(stage, ll, res=None):
|
|
381
|
+
history.append(dict(stage=stage, loglik=ll, time=time.time() - t0))
|
|
382
|
+
if verbose:
|
|
383
|
+
line = f"[{time.time()-t0:7.1f}s] {stage:<24} loglik = {ll:.4f}"
|
|
384
|
+
if res is not None:
|
|
385
|
+
line += f" [{_res_line(res, scale)}]"
|
|
386
|
+
print(line, flush=True)
|
|
387
|
+
|
|
388
|
+
ll, pll = current_ll(theta, eta), current_pll(theta, eta)
|
|
389
|
+
record("init", ll)
|
|
390
|
+
|
|
391
|
+
# --- block-coordinate warmup --------------------------------------------
|
|
392
|
+
cyc = 0
|
|
393
|
+
cycle_tol = tol * 10
|
|
394
|
+
converged = False
|
|
395
|
+
for outer in range(1, max_outer + 1):
|
|
396
|
+
pll_prev_outer = pll
|
|
397
|
+
|
|
398
|
+
opt = _GLOBAL_CYCLE[cyc % len(_GLOBAL_CYCLE)]
|
|
399
|
+
res = minimize(
|
|
400
|
+
lambda th: tuple(map(np.asarray, vg_global(
|
|
401
|
+
jnp.asarray(th), jnp.asarray(eta)))),
|
|
402
|
+
theta, jac=True, method=opt, bounds=g_bounds,
|
|
403
|
+
options=_solver_options(opt, global_maxiter))
|
|
404
|
+
if -res.fun / scale >= pll - 1e-12:
|
|
405
|
+
theta = res.x
|
|
406
|
+
pll_new = current_pll(theta, eta)
|
|
407
|
+
if pll_new - pll < cycle_tol * n_obs:
|
|
408
|
+
cyc += 1
|
|
409
|
+
pll = pll_new
|
|
410
|
+
ll = current_ll(theta, eta)
|
|
411
|
+
record(f"outer{outer}:global({opt})", ll, res)
|
|
412
|
+
|
|
413
|
+
res = minimize(
|
|
414
|
+
lambda e: tuple(map(np.asarray, vg_eta(
|
|
415
|
+
jnp.asarray(e), jnp.asarray(theta)))),
|
|
416
|
+
eta, jac=True, method="L-BFGS-B", bounds=e_bounds,
|
|
417
|
+
options=_solver_options("L-BFGS-B", eta_maxiter))
|
|
418
|
+
if -res.fun / scale >= pll - 1e-12:
|
|
419
|
+
eta = res.x
|
|
420
|
+
ll, pll = current_ll(theta, eta), current_pll(theta, eta)
|
|
421
|
+
record(f"outer{outer}:eta(L-BFGS-B)", ll, res)
|
|
422
|
+
|
|
423
|
+
if pll - pll_prev_outer < tol * n_obs:
|
|
424
|
+
converged = True
|
|
425
|
+
break
|
|
426
|
+
|
|
427
|
+
# --- joint finishing rounds: TNC first, L-BFGS-B on failure --------------
|
|
428
|
+
converged = False
|
|
429
|
+
finish_opt = "TNC"
|
|
430
|
+
for rnd in range(1, finish_rounds + 1):
|
|
431
|
+
pll_prev = pll
|
|
432
|
+
x0 = np.concatenate([theta, eta])
|
|
433
|
+
res = minimize(
|
|
434
|
+
lambda x: tuple(map(np.asarray, vg_joint(jnp.asarray(x)))),
|
|
435
|
+
x0, jac=True, method=finish_opt, bounds=g_bounds + e_bounds,
|
|
436
|
+
options=_solver_options(finish_opt, finish_maxiter))
|
|
437
|
+
if -res.fun / scale >= pll - 1e-12:
|
|
438
|
+
theta, eta = res.x[:n_glob], res.x[n_glob:]
|
|
439
|
+
ll, pll = current_ll(theta, eta), current_pll(theta, eta)
|
|
440
|
+
record(f"finish{rnd}:joint({finish_opt})", ll, res)
|
|
441
|
+
gain = pll - pll_prev
|
|
442
|
+
tnc_healthy = res.success or getattr(res, "status", -1) == 3
|
|
443
|
+
if finish_opt == "TNC" and (not tnc_healthy or gain < tol * n_obs):
|
|
444
|
+
if verbose and rnd < finish_rounds:
|
|
445
|
+
print(f" TNC stalled (gain {gain:.4f}); "
|
|
446
|
+
f"switching finish optimizer to L-BFGS-B")
|
|
447
|
+
finish_opt = "L-BFGS-B"
|
|
448
|
+
if gain >= tol * n_obs:
|
|
449
|
+
continue
|
|
450
|
+
pll_prev2 = pll
|
|
451
|
+
x0 = np.concatenate([theta, eta])
|
|
452
|
+
res = minimize(
|
|
453
|
+
lambda x: tuple(map(np.asarray, vg_joint(jnp.asarray(x)))),
|
|
454
|
+
x0, jac=True, method="L-BFGS-B", bounds=g_bounds + e_bounds,
|
|
455
|
+
options=_solver_options("L-BFGS-B", finish_maxiter))
|
|
456
|
+
if -res.fun / scale >= pll - 1e-12:
|
|
457
|
+
theta, eta = res.x[:n_glob], res.x[n_glob:]
|
|
458
|
+
ll, pll = current_ll(theta, eta), current_pll(theta, eta)
|
|
459
|
+
record(f"finish{rnd}b:joint(L-BFGS-B)", ll, res)
|
|
460
|
+
if pll - pll_prev2 < tol * n_obs:
|
|
461
|
+
converged = True
|
|
462
|
+
break
|
|
463
|
+
elif gain < tol * n_obs:
|
|
464
|
+
converged = True
|
|
465
|
+
break
|
|
466
|
+
|
|
467
|
+
# --- exact gauge normalization + invariance check -----------------------
|
|
468
|
+
# the affine gauge acts on (mu, sigma, cuts) only; a_n and kappa are
|
|
469
|
+
# invariant under it
|
|
470
|
+
tail = np.asarray(eta[2 * N:], dtype=float) # log a | log kappa
|
|
471
|
+
mu_hat = np.asarray(eta[:N], dtype=float)
|
|
472
|
+
sig_hat = np.exp(np.asarray(eta[N:2 * N], dtype=float))
|
|
473
|
+
mu_hat, sig_hat, cuts_hat = gauge_normalize(
|
|
474
|
+
mu_hat, sig_hat, np.asarray(cuts_fn(jnp.asarray(eta))),
|
|
475
|
+
pin_median=pin_median, pin_left=pin_left)
|
|
476
|
+
eta_norm = np.concatenate([mu_hat, np.log(sig_hat), tail])
|
|
477
|
+
ll_norm = current_ll(theta, eta_norm)
|
|
478
|
+
if abs(ll_norm - ll) > max(1e-6 * abs(ll), 1e-3):
|
|
479
|
+
# should never happen (exact invariance) -- keep the better point
|
|
480
|
+
if verbose:
|
|
481
|
+
print(f"[warn] gauge normalization moved loglik {ll:.4f} -> "
|
|
482
|
+
f"{ll_norm:.4f}; keeping the raw optimum")
|
|
483
|
+
mu_hat = np.asarray(eta[:N], dtype=float)
|
|
484
|
+
sig_hat = np.exp(np.asarray(eta[N:2 * N], dtype=float))
|
|
485
|
+
cuts_hat = np.asarray(cuts_fn(jnp.asarray(eta)))
|
|
486
|
+
eta_final = np.asarray(eta, dtype=float)
|
|
487
|
+
else:
|
|
488
|
+
ll = ll_norm
|
|
489
|
+
eta_final = eta_norm
|
|
490
|
+
record("gauge-normalized", ll)
|
|
491
|
+
|
|
492
|
+
a_hat = np.exp(tail[:N]) if abundance else np.ones(N)
|
|
493
|
+
kappa_hat = float(np.exp(eta_final[i_kappa])) if abund_marg else None
|
|
494
|
+
R, P, rates, log_w, phi = spec.unpack(jnp.asarray(theta))
|
|
495
|
+
Pi = np.asarray(normal_bin_probs(jnp.asarray(mu_hat), jnp.asarray(sig_hat),
|
|
496
|
+
jnp.asarray(cuts_hat), floor=pi_floor))
|
|
497
|
+
|
|
498
|
+
return FitResult(
|
|
499
|
+
R=np.asarray(R), P=np.asarray(P), mu=mu_hat, sigma=sig_hat,
|
|
500
|
+
cuts=cuts_hat, Pi=Pi, rates=np.asarray(rates), log_w=np.asarray(log_w),
|
|
501
|
+
phi=0.0 if conditional else float(phi), loglik=ll, a=a_hat,
|
|
502
|
+
n_observed=n_obs, converged=converged, observed=kept,
|
|
503
|
+
n_zero_dropped=n_zero_dropped, history=history,
|
|
504
|
+
extras=({} if not abund_marg else
|
|
505
|
+
dict(kappa=kappa_hat, abund_cv=kappa_hat ** -0.5)),
|
|
506
|
+
config=dict(lambda_fix=lambda_fix, conditional=conditional,
|
|
507
|
+
abundance=abundance, a_max=a_max,
|
|
508
|
+
abundance_prior=abundance_prior,
|
|
509
|
+
n_abund_nodes=n_abund_nodes, kappa=kappa_hat,
|
|
510
|
+
sigma_prior=sigma_prior, mu_prior=mu_prior,
|
|
511
|
+
pin_median=pin_median, pin_left=pin_left,
|
|
512
|
+
pin_weight=pin_weight, pi_floor=pi_floor))
|
ebin/ftzkiller.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import mmap
|
|
2
|
+
import ctypes as ct
|
|
3
|
+
from ctypes import pythonapi, py_object, Structure, byref, c_size_t, c_int, c_void_p, c_char_p, POINTER
|
|
4
|
+
|
|
5
|
+
class Py_buffer(Structure):
|
|
6
|
+
_fields_ = [
|
|
7
|
+
('buf', c_void_p),
|
|
8
|
+
('obj', py_object),
|
|
9
|
+
('len', c_size_t),
|
|
10
|
+
('itemsize', c_size_t),
|
|
11
|
+
('readonly', c_int),
|
|
12
|
+
('ndim', c_int),
|
|
13
|
+
('format', c_char_p),
|
|
14
|
+
('shape', POINTER(c_size_t)),
|
|
15
|
+
('strides', POINTER(c_size_t)),
|
|
16
|
+
('suboffsets', POINTER(c_size_t)),
|
|
17
|
+
('internal', c_void_p),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
pythonapi.PyObject_GetBuffer.argtypes = [py_object, POINTER(Py_buffer), c_int]
|
|
21
|
+
pythonapi.PyObject_GetBuffer.restype = c_int
|
|
22
|
+
pythonapi.PyBuffer_Release.argtypes = [POINTER(Py_buffer)]
|
|
23
|
+
pythonapi.PyBuffer_Release.restype = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MXCSR(ct.c_uint32):
|
|
27
|
+
RESET_VALUE = 0x1f80 # Default power-on state: FTZ and DAZ are cleared
|
|
28
|
+
|
|
29
|
+
def get_buffer_pointer(obj):
|
|
30
|
+
buf_struct = Py_buffer()
|
|
31
|
+
if pythonapi.PyObject_GetBuffer(obj, byref(buf_struct), 0) != 0:
|
|
32
|
+
raise RuntimeError("Failed to get buffer")
|
|
33
|
+
try:
|
|
34
|
+
return buf_struct.buf
|
|
35
|
+
finally:
|
|
36
|
+
pythonapi.PyBuffer_Release(byref(buf_struct))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
get_mxcsr_asm = b'\x0f\xae\x1f\xc3'
|
|
40
|
+
get_mxcsr_page = mmap.mmap(-1, mmap.PAGESIZE, prot=mmap.PROT_READ | mmap.PROT_WRITE | mmap.PROT_EXEC)
|
|
41
|
+
get_mxcsr_page.write(get_mxcsr_asm)
|
|
42
|
+
_get_mxcsr = ct.CFUNCTYPE(None, ct.c_void_p)(get_buffer_pointer(get_mxcsr_page))
|
|
43
|
+
|
|
44
|
+
set_mxcsr_asm = b'\x0f\xae\x17\xc3'
|
|
45
|
+
set_mxcsr_page = mmap.mmap(-1, mmap.PAGESIZE, prot=mmap.PROT_READ | mmap.PROT_WRITE | mmap.PROT_EXEC)
|
|
46
|
+
set_mxcsr_page.write(set_mxcsr_asm)
|
|
47
|
+
_set_mxcsr = ct.CFUNCTYPE(None, ct.c_void_p)(get_buffer_pointer(set_mxcsr_page))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_mxcsr() -> int:
|
|
51
|
+
buf = mmap.mmap(-1, 4, prot=mmap.PROT_READ | mmap.PROT_WRITE)
|
|
52
|
+
pointer = get_buffer_pointer(buf)
|
|
53
|
+
_get_mxcsr(pointer)
|
|
54
|
+
return int.from_bytes(buf.read(4), 'little')
|
|
55
|
+
|
|
56
|
+
def set_mxcsr(val: int):
|
|
57
|
+
buf = mmap.mmap(-1, 4, prot=mmap.PROT_READ | mmap.PROT_WRITE)
|
|
58
|
+
buf.write(val.to_bytes(4, 'little'))
|
|
59
|
+
pointer = get_buffer_pointer(buf)
|
|
60
|
+
_set_mxcsr(pointer)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FTZKiller():
|
|
64
|
+
def __enter__(self):
|
|
65
|
+
self.mxsr = get_mxcsr()
|
|
66
|
+
set_mxcsr(MXCSR.RESET_VALUE)
|
|
67
|
+
return self
|
|
68
|
+
def __exit__(self, exception_type, exception_value, exception_traceback):
|
|
69
|
+
set_mxcsr(self.mxsr)
|
|
70
|
+
|