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/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
+