StochasticForceInference 2.0.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.
Files changed (104) hide show
  1. SFI/__init__.py +64 -0
  2. SFI/bases/__init__.py +85 -0
  3. SFI/bases/constants.py +492 -0
  4. SFI/bases/linear.py +325 -0
  5. SFI/bases/monomials.py +218 -0
  6. SFI/bases/pairs.py +998 -0
  7. SFI/bases/spde.py +1537 -0
  8. SFI/diagnostics/__init__.py +60 -0
  9. SFI/diagnostics/assess.py +87 -0
  10. SFI/diagnostics/dynamics_order.py +621 -0
  11. SFI/diagnostics/plotting.py +226 -0
  12. SFI/diagnostics/report.py +238 -0
  13. SFI/diagnostics/residual_tests.py +395 -0
  14. SFI/diagnostics/residuals.py +688 -0
  15. SFI/inference/__init__.py +58 -0
  16. SFI/inference/base.py +1460 -0
  17. SFI/inference/optimizers.py +200 -0
  18. SFI/inference/overdamped.py +1214 -0
  19. SFI/inference/parametric_core/__init__.py +34 -0
  20. SFI/inference/parametric_core/covariance.py +232 -0
  21. SFI/inference/parametric_core/driver.py +272 -0
  22. SFI/inference/parametric_core/flow.py +149 -0
  23. SFI/inference/parametric_core/flow_multi.py +362 -0
  24. SFI/inference/parametric_core/flow_ud.py +168 -0
  25. SFI/inference/parametric_core/jacobians.py +540 -0
  26. SFI/inference/parametric_core/objective.py +286 -0
  27. SFI/inference/parametric_core/objective_ud.py +253 -0
  28. SFI/inference/parametric_core/precision.py +229 -0
  29. SFI/inference/parametric_core/solve.py +763 -0
  30. SFI/inference/result.py +362 -0
  31. SFI/inference/serialization.py +245 -0
  32. SFI/inference/sparse/__init__.py +67 -0
  33. SFI/inference/sparse/base.py +43 -0
  34. SFI/inference/sparse/beam.py +303 -0
  35. SFI/inference/sparse/greedy.py +151 -0
  36. SFI/inference/sparse/hillclimb.py +307 -0
  37. SFI/inference/sparse/lasso.py +178 -0
  38. SFI/inference/sparse/metrics.py +78 -0
  39. SFI/inference/sparse/result.py +278 -0
  40. SFI/inference/sparse/scorer.py +323 -0
  41. SFI/inference/sparse/stlsq.py +165 -0
  42. SFI/inference/sparsity.py +40 -0
  43. SFI/inference/underdamped.py +1355 -0
  44. SFI/integrate/__init__.py +38 -0
  45. SFI/integrate/api.py +920 -0
  46. SFI/integrate/integrand.py +402 -0
  47. SFI/integrate/rk4.py +156 -0
  48. SFI/integrate/timeops.py +174 -0
  49. SFI/langevin/__init__.py +29 -0
  50. SFI/langevin/base.py +863 -0
  51. SFI/langevin/chunked.py +225 -0
  52. SFI/langevin/noise.py +446 -0
  53. SFI/langevin/overdamped.py +560 -0
  54. SFI/langevin/underdamped.py +448 -0
  55. SFI/statefunc/__init__.py +49 -0
  56. SFI/statefunc/basis.py +87 -0
  57. SFI/statefunc/core/runtime.py +47 -0
  58. SFI/statefunc/factory.py +346 -0
  59. SFI/statefunc/interactor.py +90 -0
  60. SFI/statefunc/layout/__init__.py +29 -0
  61. SFI/statefunc/layout/_base.py +186 -0
  62. SFI/statefunc/layout/_eval_compiler.py +453 -0
  63. SFI/statefunc/layout/_fd_atoms.py +196 -0
  64. SFI/statefunc/layout/_grid.py +878 -0
  65. SFI/statefunc/layout/_sectors.py +166 -0
  66. SFI/statefunc/memhint.py +222 -0
  67. SFI/statefunc/nodes/__init__.py +56 -0
  68. SFI/statefunc/nodes/base.py +318 -0
  69. SFI/statefunc/nodes/contract.py +422 -0
  70. SFI/statefunc/nodes/interactions/__init__.py +23 -0
  71. SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
  72. SFI/statefunc/nodes/interactions/prepare.py +161 -0
  73. SFI/statefunc/nodes/interactions/specs.py +362 -0
  74. SFI/statefunc/nodes/interactions/stencils.py +718 -0
  75. SFI/statefunc/nodes/leaf.py +530 -0
  76. SFI/statefunc/nodes/ops/__init__.py +27 -0
  77. SFI/statefunc/nodes/ops/concat.py +28 -0
  78. SFI/statefunc/nodes/ops/derivative.py +447 -0
  79. SFI/statefunc/nodes/ops/einsum.py +120 -0
  80. SFI/statefunc/nodes/ops/linear.py +153 -0
  81. SFI/statefunc/nodes/ops/mapn.py +138 -0
  82. SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
  83. SFI/statefunc/nodes/ops/slice.py +83 -0
  84. SFI/statefunc/params.py +309 -0
  85. SFI/statefunc/psf.py +112 -0
  86. SFI/statefunc/sf.py +104 -0
  87. SFI/statefunc/stateexpr.py +1243 -0
  88. SFI/statefunc/structexpr.py +1003 -0
  89. SFI/trajectory/__init__.py +31 -0
  90. SFI/trajectory/collection.py +1122 -0
  91. SFI/trajectory/dataset.py +1164 -0
  92. SFI/trajectory/degrade.py +1108 -0
  93. SFI/trajectory/io.py +1027 -0
  94. SFI/trajectory/reserved_extras.py +129 -0
  95. SFI/utils/__init__.py +17 -0
  96. SFI/utils/formatting.py +308 -0
  97. SFI/utils/maths.py +185 -0
  98. SFI/utils/neighbors.py +162 -0
  99. SFI/utils/plotting.py +1936 -0
  100. stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
  101. stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
  102. stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
  103. stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
  104. stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,763 @@
1
+ # SFI/inference/parametric_core/solve.py
2
+ """
3
+ Orchestration for the overdamped parametric-core force solve.
4
+
5
+ Ties the pieces together — **everything through the ``SFI.integrate``
6
+ engine** (chunking, masking, JIT); no hand-rolled trajectory passes:
7
+
8
+ flow → banded covariance → windowed precision/NLL (integrate programs)
9
+ + (D, Λ) profiled by the windowed conditional NLL
10
+ + IRLS driver
11
+ → θ̂, D̂, Σ̂_η, Gram (for covariance / sparsity).
12
+
13
+ ``(D, Λ)`` are full symmetric matrices throughout. Two noise/objective
14
+ roles share one program:
15
+
16
+ * ``ODLossProgram`` — frozen-precision quadratic, minimised over θ (fast
17
+ inner solve);
18
+ * ``ODCondNLLProgram`` — windowed *conditional* NLL (local Schur log-det,
19
+ non-degenerate in D, Λ), minimised over the noise to profile ``(D, Λ)``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+
26
+ import jax
27
+ import jax.numpy as jnp
28
+ import numpy as np
29
+ from scipy.optimize import minimize as _sp_minimize
30
+
31
+ from SFI.integrate.api import make_parametric_integrator
32
+ from SFI.utils.maths import default_float_dtype
33
+
34
+ from .driver import condition_cap_ridge, gn_minimize, irls_minimize
35
+ from .objective import (
36
+ ODCondNLLProgram,
37
+ ODDiffNLLProgram,
38
+ ODGramProgram,
39
+ ODLossProgram,
40
+ unpack_gram,
41
+ )
42
+ from .objective_ud import UDCondNLLProgram, UDDiffNLLProgram, UDGramProgram, UDLossProgram
43
+
44
+ __all__ = [
45
+ "ForceSolveResult", "DiffusionSolveResult",
46
+ "solve_force_od", "solve_force_ud", "solve_diffusion_od", "solve_diffusion_ud",
47
+ ]
48
+
49
+
50
+ @dataclass
51
+ class ForceSolveResult:
52
+ theta: jnp.ndarray # (n_params,) fitted parameters
53
+ D: jnp.ndarray # (d, d) diffusion
54
+ Lambda: jnp.ndarray # (d, d) measurement-noise covariance
55
+ G: jnp.ndarray # (n_params, n_params) GN Gram (symmetrised)
56
+ f: jnp.ndarray # (n_params,) score at optimum
57
+ theta_cov: jnp.ndarray # (n_params, n_params) parameter covariance:
58
+ # the sandwich G⁻¹ H G⁻ᵀ with H = ψ_leftᵀPψ_left
59
+ # (= G⁻¹ on the symmetric path, where H = G)
60
+ info: dict
61
+
62
+
63
+ @dataclass
64
+ class DiffusionSolveResult:
65
+ theta_D: jnp.ndarray # (n_D,) fitted diffusion parameters
66
+ info: dict
67
+
68
+
69
+ def _is_linear_basis(F):
70
+ """True when ``F`` is a (linear-in-θ) ``Basis`` rather than a ``PSF``.
71
+
72
+ A ``Basis`` exposes ``to_psf`` and has no ``template`` (the PSF attribute);
73
+ its force is ``F = Σ_a θ_a b_a(x)``, so the windowed Gram is the GLS fixed
74
+ point and the direct Gauss–Newton path applies. A user-supplied ``PSF`` is
75
+ treated as potentially nonlinear-in-θ (L-BFGS default) unless the caller
76
+ forces ``inner="gn"``.
77
+ """
78
+ return hasattr(F, "to_psf") and not hasattr(F, "template")
79
+
80
+
81
+ def _resolve_inner(inner, is_linear):
82
+ """Resolve ``inner="auto"`` to ``"gn"`` (linear) or ``"lbfgs"`` (nonlinear)."""
83
+ if inner == "auto":
84
+ return "gn" if is_linear else "lbfgs"
85
+ if inner not in ("gn", "lbfgs"):
86
+ raise ValueError(f"inner must be 'auto', 'gn', or 'lbfgs'; got {inner!r}")
87
+ return inner
88
+
89
+
90
+ def _resolve_eiv_auto(eiv, F_psf):
91
+ """Resolve ``eiv="auto"`` (the default) from the force model.
92
+
93
+ ``"auto"`` → ``True`` for every model, including interacting ones
94
+ (``particles_input=True``). Interacting models briefly defaulted to
95
+ ``False``: the original instrument evaluated the force on isolated
96
+ single-particle frames, which zeroed (or crashed) every pair-feature
97
+ column and made the IV Gram structurally singular on the interaction
98
+ parameters — the noise-independent NMSE plateau on the aligning-ABP
99
+ port. The instrument now uses the same N-body flow as the residual
100
+ (:func:`flow_multi.multi_od_instrument` / ``multi_ud_instrument``), so
101
+ the consistent estimator is the default everywhere. Explicit
102
+ ``True``/``False``/float always wins.
103
+ """
104
+ if eiv == "auto":
105
+ return True
106
+ return eiv
107
+
108
+
109
+ def _resolve_w(eiv):
110
+ """Resolve ``eiv`` to a scalar instrument blend weight ``w ∈ [0, 1]``.
111
+
112
+ ``False`` → ``0`` (plain MLE); ``True`` → ``1`` (pure η-clean instrument);
113
+ a float passes through (clamped to ``[0, 1]``) — the manual bias-variance blend.
114
+ """
115
+ if eiv is False:
116
+ return 0.0
117
+ if eiv is True:
118
+ return 1.0
119
+ if isinstance(eiv, (int, float)) and not isinstance(eiv, bool):
120
+ return float(min(max(eiv, 0.0), 1.0))
121
+ raise ValueError(f"eiv must be 'auto', bool, or float in [0, 1]; got {eiv!r}")
122
+
123
+
124
+ def _as_psf(F):
125
+ """Accept a PSF directly or convert a Basis via ``to_psf()``."""
126
+ if hasattr(F, "to_psf") and not hasattr(F, "template"):
127
+ return F.to_psf()
128
+ return F
129
+
130
+
131
+ def _chol_vec_to_mat(z, d):
132
+ """Unconstrained ``z`` (lower-tri, log-diagonal) → SPD matrix ``L Lᵀ``."""
133
+ ii, jj = np.tril_indices(d)
134
+ L = jnp.zeros((d, d), dtype=z.dtype).at[ii, jj].set(z)
135
+ di = np.diag_indices(d)
136
+ L = L.at[di].set(jnp.exp(jnp.diagonal(L)))
137
+ return L @ L.T
138
+
139
+
140
+ def _mat_to_chol_vec(M, d):
141
+ """SPD matrix → unconstrained ``z`` (inverse of :func:`_chol_vec_to_mat`)."""
142
+ L = jnp.linalg.cholesky(M + 1e-10 * jnp.eye(d, dtype=M.dtype))
143
+ ii, jj = np.tril_indices(d)
144
+ z = L[ii, jj]
145
+ diag_pos = np.where(ii == jj)[0]
146
+ return z.at[diag_pos].set(jnp.log(jnp.clip(jnp.diagonal(L), 1e-12, None)))
147
+
148
+
149
+ def _collection_dt(collection):
150
+ dt = collection.datasets[0].dt
151
+ return float(jnp.asarray(dt).reshape(-1)[0])
152
+
153
+
154
+ def _lbfgs(obj_and_grad, x0, dtype, maxiter, *, ftol=1e-12, gtol=1e-12):
155
+ """scipy L-BFGS-B over a flat real vector with a jax value-and-grad.
156
+
157
+ Infeasible iterates (NaN/inf objective or gradient — e.g. a line-search
158
+ trial step proposing a non-PSD diffusion) are mapped to a finite penalty
159
+ so the Fortran line search backtracks instead of aborting at the
160
+ incumbent point (which silently returns ``x0``). The penalty is kept on
161
+ the scale of the objective: an astronomically large value (e.g. 1e15)
162
+ makes the line search's quadratic interpolation collapse the trial step
163
+ to ~0, where the f-decrease falls below ``ftol`` and scipy reports
164
+ convergence at the initial point.
165
+ """
166
+ state: dict[str, float | None] = {"f_ref": None}
167
+
168
+ def _obj(x):
169
+ v, g = obj_and_grad(jnp.asarray(x, dtype=dtype))
170
+ v = float(v)
171
+ g = np.asarray(g, dtype=np.float64)
172
+ if not np.isfinite(v) or not np.all(np.isfinite(g)):
173
+ f_ref = state["f_ref"]
174
+ penalty = 1e15 if f_ref is None else f_ref + 1e3 * (1.0 + abs(f_ref))
175
+ return penalty, np.zeros_like(g)
176
+ if state["f_ref"] is None or v < state["f_ref"]:
177
+ state["f_ref"] = v
178
+ return v, g
179
+
180
+ res = _sp_minimize(
181
+ _obj, np.asarray(x0, dtype=np.float64), jac=True, method="L-BFGS-B",
182
+ options={"maxiter": maxiter, "ftol": ftol, "gtol": gtol},
183
+ )
184
+ return jnp.asarray(res.x, dtype=dtype), float(res.fun)
185
+
186
+
187
+ # (D, Λ) profiling tolerances: each profile evaluation is a full trajectory
188
+ # pass, and the noise matrices only set the precision weighting of the θ solve,
189
+ # so they need far less accuracy than the θ optimum itself (which keeps 1e-12).
190
+ _PROFILE_FTOL = 1e-9
191
+ _PROFILE_GTOL = 1e-8
192
+
193
+
194
+ def _spd_floor(M, dtype, rel=1e-6, abs_floor=1e-12):
195
+ """Symmetrise a moment estimate and floor its eigenvalues to SPD.
196
+
197
+ Moment estimators fluctuate below zero when the true matrix is (near)
198
+ zero — e.g. Λ on clean data — and the Cholesky reparameterisation of
199
+ the profile needs a strictly positive matrix to start from.
200
+ """
201
+ M = jnp.asarray(M, dtype=dtype)
202
+ M = 0.5 * (M + M.T)
203
+ w, V = jnp.linalg.eigh(M)
204
+ floor = jnp.maximum(rel * jnp.max(jnp.abs(w)), abs_floor)
205
+ w = jnp.clip(w, floor, None)
206
+ return (V * w) @ V.T
207
+
208
+
209
+ def _moment_init(collection, *, dynamics, d, dtype, drop_se):
210
+ """Closed-form moment (D, Λ) initialisation — replaces the θ=0 profile.
211
+
212
+ One trajectory pass per matrix, no optimizer: the noise-robust pair
213
+ 'noisy' (Vestergaard) D + increment-anticorrelation Λ (overdamped) or
214
+ ULI 'noisy' D + ULI Λ (underdamped) — the same estimators behind
215
+ ``compute_diffusion_constant``. The windowed conditional NLL then
216
+ refines ``(D, Λ)`` once at the fitted θ (the reprofile step), so the
217
+ init only needs to be the right order of magnitude.
218
+ """
219
+ from SFI.integrate.api import integrate
220
+ from SFI.integrate.integrand import Integrand, Term, TimeOperand
221
+
222
+ if dynamics == "od":
223
+ from SFI.inference.overdamped import _D_noisy, _Lambda
224
+ D_fn, L_fn = _D_noisy, _Lambda
225
+ else:
226
+ from SFI.inference.underdamped import _D_noisy_uli, _Lambda_uli
227
+ D_fn, L_fn = _D_noisy_uli, _Lambda_uli
228
+
229
+ def _mean(fn, alias):
230
+ op = TimeOperand(fn, alias=alias)
231
+ prog = Integrand(times=[op], terms=[Term(eq="imn->imn", ops=(op.alias,))])
232
+ return integrate(collection, prog, reduce="mean")
233
+
234
+ D0 = _spd_floor(_mean(D_fn, "D_mom"), dtype)
235
+ if drop_se:
236
+ return D0, jnp.zeros((d, d), dtype=dtype)
237
+ return D0, _spd_floor(_mean(L_fn, "Lambda_mom"), dtype)
238
+
239
+
240
+ def _init_theta_D(D_psf, collection, *, dynamics, dtype, n_D):
241
+ """Default θ_D init: project the moment-estimated constant D̂ onto the model.
242
+
243
+ θ_D = 0 means D(x) ≡ 0 — a singular covariance where the conditional NLL
244
+ (and its gradient) is non-finite, so L-BFGS cannot leave the start point.
245
+ Solve min_θ ‖D(x;θ) − D̂‖² at sampled trajectory points instead (exact for
246
+ linear-in-θ models, first-order otherwise). Falls back to zeros if the
247
+ model cannot be evaluated standalone (e.g. needs extras).
248
+ """
249
+ try:
250
+ X = jnp.asarray(collection.datasets[0].X)
251
+ if X.ndim == 3:
252
+ X = X[:, 0, :]
253
+ d = X.shape[-1]
254
+ D_const, _ = _moment_init(collection, dynamics=dynamics, d=d, dtype=dtype,
255
+ drop_se=True)
256
+ dt = _collection_dt(collection)
257
+ idx = np.linspace(0, X.shape[0] - 2, min(64, X.shape[0] - 1)).astype(int)
258
+ Xs = X[idx]
259
+ Vs = (X[idx + 1] - X[idx]) / dt if dynamics == "ud" else None
260
+ cols = []
261
+ for j in range(n_D):
262
+ th = D_psf.unflatten_params(jnp.zeros(n_D, dtype=dtype).at[j].set(1.0))
263
+ if Vs is None:
264
+ Bj = jax.vmap(lambda x: D_psf(x[None], params=th)[0])(Xs)
265
+ else:
266
+ Bj = jax.vmap(lambda x, v: D_psf(x[None], v=v[None], params=th)[0])(Xs, Vs)
267
+ cols.append(Bj.reshape(-1))
268
+ A = jnp.stack(cols, axis=1)
269
+ b = jnp.broadcast_to(D_const, (Xs.shape[0],) + D_const.shape).reshape(-1)
270
+ th0 = jnp.linalg.lstsq(A, b)[0]
271
+ if bool(jnp.all(jnp.isfinite(th0))):
272
+ return jnp.asarray(th0, dtype=dtype)
273
+ except Exception:
274
+ pass
275
+ return jnp.zeros(n_D, dtype=dtype)
276
+
277
+
278
+ def _integrator(collection, program, *, reduce_over_particles=False):
279
+ _, run = make_parametric_integrator(
280
+ collection, program, reduce="sum",
281
+ reduce_over_particles=reduce_over_particles, weight_by_dt=False,
282
+ )
283
+ return run
284
+
285
+
286
+ def _init_theta(theta0, F_psf, n_params, dtype):
287
+ if theta0 is None:
288
+ return jnp.zeros(n_params, dtype=dtype)
289
+ if isinstance(theta0, dict):
290
+ return jnp.asarray(F_psf.flatten_params(theta0), dtype=dtype)
291
+ return jnp.asarray(theta0, dtype=dtype).reshape(-1)
292
+
293
+
294
+ def _resolve_n_substeps(n_substeps):
295
+ """Validate the fixed substep count (``"auto"`` was removed in v2.0)."""
296
+ if n_substeps == "auto":
297
+ raise ValueError(
298
+ "n_substeps='auto' was removed in v2.0; pass an int (default 1)."
299
+ )
300
+ return int(n_substeps)
301
+
302
+
303
+ def _orchestrate(build_runs, n_sub, theta, D, Lambda, *,
304
+ d, n_params, dtype, max_outer, inner_maxiter, profile_maxiter,
305
+ label, inner, noise_init, gram_cond_max=1e10, eiv=False):
306
+ """Shared solve + (D,Λ) profiling + Gram.
307
+
308
+ Dynamics-agnostic. ``build_runs(n_substeps) -> (loss_run, nll_run,
309
+ gram_run)`` constructs the integrate-engine runners at the fixed substep
310
+ count.
311
+
312
+ The inner solve is ``inner="gn"`` (direct Gauss–Newton on the windowed
313
+ Gram — the linear-in-θ fast path) or ``inner="lbfgs"`` (frozen-precision
314
+ L-BFGS IRLS — the nonlinear-in-θ path).
315
+ """
316
+ nsym = d * (d + 1) // 2
317
+ fixed_noise = D is not None and Lambda is not None
318
+ w = 0.0 # EIV instrument blend weight (resolved once the noise scale is known)
319
+ is_od = label.startswith("od")
320
+ # Drop Λ profiling for the *explicit overdamped MLE* (eiv=False) only — see
321
+ # _profile. Set once `w` (and thus the MLE intent) is known; NOT for an
322
+ # eiv=True solve that merely got downgraded to w=0 on the L-BFGS path (still
323
+ # noise-aware), and NOT for UD (velocity-EIV is a distinct, intended effect).
324
+ drop_se_mle = False
325
+
326
+ def _machinery(runs):
327
+ """Build the (profiler, loss-vg, gram-fn, nll-scalar) for one set of runs.
328
+
329
+ Each ``@jax.jit`` compiles lazily on first call, so the GN path never
330
+ compiles the (grad-through-flow) loss program and the L-BFGS path never
331
+ compiles the Gram program beyond the final covariance.
332
+ """
333
+ loss_run, nll_run, gram_run = runs
334
+
335
+ @jax.jit
336
+ def _profile_vg(z, th):
337
+ return jax.value_and_grad(
338
+ lambda zz: jnp.sum(nll_run((
339
+ th, _chol_vec_to_mat(zz[:nsym], d), _chol_vec_to_mat(zz[nsym:], d),
340
+ )))
341
+ )(z)
342
+
343
+ @jax.jit
344
+ def _profile_D_vg(zD, th):
345
+ # D-only profiler (Λ held at 0) — for the w=0 / MLE path.
346
+ return jax.value_and_grad(
347
+ lambda zz: jnp.sum(nll_run((
348
+ th, _chol_vec_to_mat(zz, d), jnp.zeros((d, d), dtype=dtype),
349
+ )))
350
+ )(zD)
351
+
352
+ @jax.jit
353
+ def _loss_vg(theta_live, theta_frozen, D_, Se_):
354
+ return jax.value_and_grad(lambda tl: jnp.sum(loss_run((tl, theta_frozen, D_, Se_))))(theta_live)
355
+
356
+ def _profile(th, D_cur, Se_cur):
357
+ # The explicit overdamped MLE (eiv=False) is the *no-measurement-noise*
358
+ # estimator: profiling Λ into its symmetric precision is unidentifiable on
359
+ # stiff/large-Δt clean data (the conditional NLL cannot separate process noise
360
+ # ∝Δt from a Δt-independent Λ), and the spurious estimate collapses the Gram
361
+ # (θ→0; OU Δt=0.4 NMSE 5e12, Lorenz 3.74). So profile D only and hold Λ=0 —
362
+ # matching legacy's symmetric path, which carries no Λ term. Every other
363
+ # solve (η-clean skip w>0, eiv=True downgraded to w=0 on L-BFGS, all UD) keeps
364
+ # the joint (D,Λ) profile.
365
+ if not drop_se_mle:
366
+ z0 = jnp.concatenate([_mat_to_chol_vec(D_cur, d), _mat_to_chol_vec(Se_cur, d)])
367
+ z, _ = _lbfgs(lambda z: _profile_vg(z, th), z0, dtype, profile_maxiter,
368
+ ftol=_PROFILE_FTOL, gtol=_PROFILE_GTOL)
369
+ return _chol_vec_to_mat(z[:nsym], d), _chol_vec_to_mat(z[nsym:], d)
370
+ zD0 = _mat_to_chol_vec(D_cur, d)
371
+ zD, _ = _lbfgs(lambda z: _profile_D_vg(z, th), zD0, dtype, profile_maxiter,
372
+ ftol=_PROFILE_FTOL, gtol=_PROFILE_GTOL)
373
+ return _chol_vec_to_mat(zD, d), jnp.zeros((d, d), dtype=dtype)
374
+
375
+ def _gram_fn(th, D_, Se_):
376
+ # Symmetrise only the MLE Gram; the EIV path (w>0) is an asymmetric
377
+ # estimating equation and must keep ψ_left ≠ ψ_right intact.
378
+ G, f, _, nll = unpack_gram(gram_run((th, D_, Se_)), n_params)
379
+ return (G if w > 0.0 else 0.5 * (G + G.T)), f, nll
380
+
381
+ def _nll_scalar(th, D_, Se_):
382
+ return float(jnp.sum(nll_run((th, D_, Se_))))
383
+
384
+ return _profile, _loss_vg, _gram_fn, _nll_scalar
385
+
386
+ def _solve_pass(mach, theta, D0, Lambda0, prof):
387
+ """One inner solve given prebuilt machinery; returns (θ, D, Λ, info)."""
388
+ _profile, _loss_vg, _gram_fn, _nll_scalar = mach
389
+
390
+ if inner == "gn":
391
+ # GN caps cond(G) internally via condition_cap_ridge (gram_cond_max).
392
+ # reprofile_iters=1: solve → one warm (D, Λ) profile at the fitted θ →
393
+ # re-solve. For linear-in-θ this is the IRLS fixed point; profiling at
394
+ # every outer iteration only repeats the dominant-cost L-BFGS for ~no
395
+ # change in θ̂.
396
+ theta_h, D_h, Se_h, sinfo = gn_minimize(
397
+ theta, _gram_fn, D0, Lambda0, profile_fn=prof, nll_fn=_nll_scalar,
398
+ max_iter=max_outer, reprofile_iters=1,
399
+ gram_cond_max=gram_cond_max, label=label,
400
+ merit=("phi" if w > 0.0 else "nll"),
401
+ )
402
+ return theta_h, D_h, Se_h, {**sinfo, "ridge_lambda": 0.0}
403
+
404
+ # L-BFGS IRLS: Tikhonov ½λ‖θ‖² (λ from the Gram at the pass start) bounds
405
+ # θ along ill-conditioned directions instead of letting L-BFGS run to ±∞.
406
+ lam = 0.0
407
+ if gram_cond_max and np.isfinite(gram_cond_max):
408
+ G0, _, _ = _gram_fn(theta, D0, Lambda0)
409
+ lam = float(condition_cap_ridge(G0, gram_cond_max))
410
+
411
+ def build_inner(theta_frozen, D_, Se_):
412
+ def base(theta_live):
413
+ return _loss_vg(theta_live, theta_frozen, D_, Se_)
414
+
415
+ if lam <= 0.0:
416
+ return base
417
+
418
+ def innerf(theta_live):
419
+ v, g = base(theta_live)
420
+ return v + 0.5 * lam * jnp.dot(theta_live, theta_live), g + lam * theta_live
421
+
422
+ return innerf
423
+
424
+ theta_h, D_h, Se_h, sinfo = irls_minimize(
425
+ theta, build_inner, D0, Lambda0, profile_fn=prof, reprofile_iters=1,
426
+ max_outer=max_outer, inner_maxiter=inner_maxiter, label=label,
427
+ )
428
+ return theta_h, D_h, Se_h, {**sinfo, "ridge_lambda": lam}
429
+
430
+ # ── EIV blend weight ──────────────────────────────────────────────────────
431
+ # The instrument weight is known up front, so the first solve already uses the
432
+ # η-clean instrument — no wasted (and, under noise, explosive) symmetric cold
433
+ # pass. The asymmetric estimating equation has no loss potential ⇒ Gauss–Newton
434
+ # only; on the L-BFGS path (nonlinear-in-θ PSF) fall back to the MLE.
435
+ w = _resolve_w(eiv)
436
+ # Explicit MLE (the user asked for w=0 up front, not a downgrade): OD-only Λ drop.
437
+ drop_se_mle = is_od and (w == 0.0) and not fixed_noise
438
+ if w > 0.0 and inner != "gn":
439
+ w = 0.0
440
+
441
+ runs = build_runs(n_sub, w=w)
442
+ mach = _machinery(runs)
443
+ if fixed_noise:
444
+ D0 = jnp.asarray(D, dtype=dtype)
445
+ Lambda0 = jnp.asarray(Lambda, dtype=dtype)
446
+ prof = None
447
+ else:
448
+ # Closed-form moment (D, Λ) init — one trajectory pass, no optimizer.
449
+ # The windowed conditional NLL refines it once at the fitted θ (reprofile).
450
+ D0, Lambda0 = noise_init(drop_se_mle)
451
+ prof = mach[0]
452
+
453
+ theta, D_hat, Lambda_hat, sinfo = _solve_pass(mach, theta, D0, Lambda0, prof)
454
+ info = {**sinfo, "n_substeps": n_sub, "inner": inner, "eiv_w": w}
455
+
456
+ _, _, gram_run = runs
457
+ G_raw, f, H, _ = unpack_gram(gram_run((theta, D_hat, Lambda_hat)), n_params)
458
+ # Parameter covariance: sandwich G⁻¹ H G⁻ᵀ. On the symmetric path
459
+ # H = G and this is the usual inverse information; on the IV path
460
+ # (asymmetric G) the sandwich is the correct asymptotic covariance —
461
+ # G⁻¹ alone would mis-state the error bars under measurement noise.
462
+ Ginv = jnp.linalg.inv(G_raw + 1e-300 * jnp.eye(n_params, dtype=dtype))
463
+ theta_cov = Ginv @ H @ Ginv.T
464
+ theta_cov = 0.5 * (theta_cov + theta_cov.T)
465
+ G = 0.5 * (G_raw + G_raw.T)
466
+ return ForceSolveResult(theta=theta, D=D_hat, Lambda=Lambda_hat, G=G, f=f,
467
+ theta_cov=theta_cov, info=info)
468
+
469
+
470
+ def solve_force_od(
471
+ collection,
472
+ F,
473
+ *,
474
+ theta0=None,
475
+ D=None,
476
+ Lambda=None,
477
+ n_substeps=1,
478
+ integrator="rk4",
479
+ max_outer=5,
480
+ inner_maxiter=80,
481
+ n_cond=3,
482
+ profile_maxiter=20,
483
+ inner="auto",
484
+ eiv="auto",
485
+ extra_radius=1,
486
+ ):
487
+ r"""Solve the overdamped parametric force problem.
488
+
489
+ Parameters
490
+ ----------
491
+ collection : TrajectoryCollection
492
+ F : PSF or Basis (Basis is converted with ``to_psf()``).
493
+ theta0 : dict, array, or None initial parameters (default zeros).
494
+ D, Lambda : ``(d, d)`` or None. When given, held fixed; otherwise
495
+ profiled: moment-estimator init (Vestergaard/ULI + Λ), then one
496
+ windowed-conditional-NLL refinement at the fitted θ.
497
+ n_substeps : int RK4/Euler micro-steps per Δt (default 1 — the
498
+ single-step minimal estimator).
499
+ integrator : {"rk4", "euler"} Flow predictor. Default ``"rk4"`` (a single
500
+ RK4 step — the minimal estimator). A single ``"euler"`` step is also
501
+ available, but note it cannot carry the force into the position update
502
+ over one step, so the underdamped skip-trick (``eiv``) is unavailable
503
+ for ``integrator="euler", n_substeps=1`` (auto-disabled with a warning).
504
+ max_outer, inner_maxiter : solve budget.
505
+ n_cond : int past residuals conditioned on in the windowed NLL
506
+ (window = ``n_cond + 2`` points).
507
+ inner : {"auto", "gn", "lbfgs"} inner solver. ``"auto"`` → direct
508
+ Gauss–Newton for a linear ``Basis`` (the fast path), L-BFGS for a
509
+ (possibly nonlinear-in-θ) ``PSF``.
510
+ eiv : {"auto", True, False, float} measurement-noise errors-in-variables
511
+ instrument. ``"auto"`` (default) resolves to ``True`` for all
512
+ models, interacting ones included (the multi-particle instrument
513
+ uses the same N-body flow as the residual). ``True`` uses the pure
514
+ η-clean *skip* instrument as the left factor (``w = 1``) — the
515
+ consistent estimator under measurement noise; ``False`` is the plain
516
+ MLE (``w = 0``); a float in ``[0, 1]`` fixes the blend
517
+ ``ψ_left = (1−w)ψ_right + w ψ_inst``. Active only on the
518
+ Gauss–Newton path (linear ``Basis``); the L-BFGS/PSF path falls
519
+ back to the MLE.
520
+ extra_radius : int precision-window padding beyond the covariance
521
+ bandwidth (default 1). Raise to 2–3 in the noise-dominated
522
+ regime β = Λ/(2DΔt) ≫ 1, where the precision of the residual
523
+ process decays slowly (rate λ = 2|ρ|/(1+√(1−4ρ²)) with
524
+ |ρ| = β/(2β+1)) and the default window under-resolves it.
525
+
526
+ Returns
527
+ -------
528
+ ForceSolveResult
529
+ """
530
+ F_psf = _as_psf(F)
531
+ inner = _resolve_inner(inner, _is_linear_basis(F))
532
+ eiv = _resolve_eiv_auto(eiv, F_psf)
533
+ dtype = default_float_dtype()
534
+ n_params = int(F_psf.template.size)
535
+ dt0 = _collection_dt(collection)
536
+ d = collection.datasets[0].X.shape[-1]
537
+ theta = _init_theta(theta0, F_psf, n_params, dtype)
538
+ n_sub = _resolve_n_substeps(n_substeps)
539
+
540
+ mp = dict(reduce_over_particles=True)
541
+
542
+ def build_runs(n_sub, w=0.0):
543
+ # The η-clean instrument (w>0) widens the Gram window; the w=0 cold/clean
544
+ # pass keeps the efficient center MLE window (ODGramProgram decides from w).
545
+ loss_run = _integrator(
546
+ collection,
547
+ ODLossProgram(F_psf, dt=dt0, n_substeps=n_sub, integrator=integrator, extra_radius=extra_radius),
548
+ **mp,
549
+ )
550
+ nll_run = _integrator(
551
+ collection,
552
+ ODCondNLLProgram(F_psf, dt=dt0, n_substeps=n_sub, integrator=integrator, n_cond=n_cond),
553
+ **mp,
554
+ )
555
+ gram_run = _integrator(
556
+ collection,
557
+ ODGramProgram(F_psf, dt=dt0, n_substeps=n_sub, integrator=integrator, w=w, extra_radius=extra_radius),
558
+ **mp,
559
+ )
560
+ return loss_run, nll_run, gram_run
561
+
562
+ return _orchestrate(
563
+ build_runs, n_sub, theta, D, Lambda,
564
+ d=d, n_params=n_params, dtype=dtype, max_outer=max_outer,
565
+ inner_maxiter=inner_maxiter, profile_maxiter=profile_maxiter,
566
+ label="od-force", inner=inner,
567
+ noise_init=lambda drop_se: _moment_init(
568
+ collection, dynamics="od", d=d, dtype=dtype, drop_se=drop_se),
569
+ eiv=eiv,
570
+ )
571
+
572
+
573
+ def solve_diffusion_od(
574
+ collection,
575
+ F_psf,
576
+ theta_F,
577
+ D_basis,
578
+ *,
579
+ Lambda,
580
+ theta_D0=None,
581
+ n_substeps=1,
582
+ integrator="rk4",
583
+ n_cond=3,
584
+ maxiter=100,
585
+ ):
586
+ r"""Infer state-dependent diffusion ``D(x; θ_D)`` with the force fixed.
587
+
588
+ Minimises the windowed conditional NLL over ``θ_D`` (the log-det term
589
+ makes the diffusion level identifiable), reusing the same objective and
590
+ integrate engine as the force solve. ``D_basis`` is a rank-2 Basis or
591
+ PSF; ``Lambda`` (from the force inference) is held fixed.
592
+
593
+ Returns
594
+ -------
595
+ DiffusionSolveResult (``theta_D``)
596
+ """
597
+ F_psf = _as_psf(F_psf)
598
+ D_psf = _as_psf(D_basis)
599
+ dtype = default_float_dtype()
600
+ n_D = int(D_psf.template.size)
601
+ dt0 = _collection_dt(collection)
602
+ theta_F = jnp.asarray(theta_F, dtype=dtype).reshape(-1)
603
+ Lambda = jnp.asarray(Lambda, dtype=dtype)
604
+
605
+ if theta_D0 is None:
606
+ theta_D = _init_theta_D(D_psf, collection, dynamics="od", dtype=dtype, n_D=n_D)
607
+ elif isinstance(theta_D0, dict):
608
+ theta_D = jnp.asarray(D_psf.flatten_params(theta_D0), dtype=dtype)
609
+ else:
610
+ theta_D = jnp.asarray(theta_D0, dtype=dtype).reshape(-1)
611
+
612
+ prog = ODDiffNLLProgram(
613
+ F_psf, theta_F, D_psf, dt=dt0, n_substeps=n_substeps, integrator=integrator, n_cond=n_cond)
614
+ nll_run = _integrator(collection, prog, reduce_over_particles=True)
615
+
616
+ @jax.jit
617
+ def _vg(td):
618
+ return jax.value_and_grad(lambda t: jnp.sum(nll_run((t, Lambda))))(td)
619
+
620
+ theta_D, nll_val = _lbfgs(_vg, theta_D, dtype, maxiter)
621
+ return DiffusionSolveResult(theta_D=theta_D, info={"nll": nll_val, "n_D": n_D})
622
+
623
+
624
+ def solve_diffusion_ud(
625
+ collection,
626
+ F_psf,
627
+ theta_F,
628
+ D_basis,
629
+ *,
630
+ Lambda,
631
+ theta_D0=None,
632
+ n_substeps=1,
633
+ integrator="rk4",
634
+ n_cond=4,
635
+ maxiter=100,
636
+ ):
637
+ r"""Infer state- and velocity-dependent diffusion ``D(x, v; θ_D)`` (UD, force fixed).
638
+
639
+ Mirror of :func:`solve_diffusion_od` on the pentadiagonal underdamped
640
+ program: ``D`` is evaluated at the shooting velocity ``(Yₙ, v̂ₙ)``.
641
+ ``D_basis`` is a rank-2 ``needs_v=True`` Basis/PSF.
642
+
643
+ Returns
644
+ -------
645
+ DiffusionSolveResult (``theta_D``)
646
+ """
647
+ F_psf = _as_psf(F_psf)
648
+ D_psf = _as_psf(D_basis)
649
+ dtype = default_float_dtype()
650
+ n_D = int(D_psf.template.size)
651
+ dt0 = _collection_dt(collection)
652
+ theta_F = jnp.asarray(theta_F, dtype=dtype).reshape(-1)
653
+ Lambda = jnp.asarray(Lambda, dtype=dtype)
654
+
655
+ if theta_D0 is None:
656
+ theta_D = _init_theta_D(D_psf, collection, dynamics="ud", dtype=dtype, n_D=n_D)
657
+ elif isinstance(theta_D0, dict):
658
+ theta_D = jnp.asarray(D_psf.flatten_params(theta_D0), dtype=dtype)
659
+ else:
660
+ theta_D = jnp.asarray(theta_D0, dtype=dtype).reshape(-1)
661
+
662
+ prog = UDDiffNLLProgram(
663
+ F_psf, theta_F, D_psf, dt=dt0, n_substeps=n_substeps, integrator=integrator, n_cond=n_cond)
664
+ nll_run = _integrator(collection, prog, reduce_over_particles=True)
665
+
666
+ @jax.jit
667
+ def _vg(td):
668
+ return jax.value_and_grad(lambda t: jnp.sum(nll_run((t, Lambda))))(td)
669
+
670
+ theta_D, nll_val = _lbfgs(_vg, theta_D, dtype, maxiter)
671
+ return DiffusionSolveResult(theta_D=theta_D, info={"nll": nll_val, "n_D": n_D})
672
+
673
+
674
+ def solve_force_ud(
675
+ collection,
676
+ F,
677
+ *,
678
+ theta0=None,
679
+ D=None,
680
+ Lambda=None,
681
+ n_substeps=1,
682
+ integrator="rk4",
683
+ max_outer=5,
684
+ inner_maxiter=80,
685
+ n_cond=4,
686
+ profile_maxiter=20,
687
+ inner="auto",
688
+ eiv="auto",
689
+ ):
690
+ r"""Solve the underdamped parametric force problem.
691
+
692
+ Same orchestration as :func:`solve_force_od`, on the bandwidth-2
693
+ (pentadiagonal) underdamped programs: the unobserved velocity is
694
+ resolved by shooting, residuals are 3-point, and the process noise
695
+ enters at ``Δt³``. ``F`` is a velocity-dependent PSF (``needs_v=True``).
696
+ ``inner`` behaves as in :func:`solve_force_od`.
697
+ ``eiv`` behaves as in :func:`solve_force_od`; the underdamped instrument
698
+ is built from the clean lagged position pair (the velocity-EIV is stronger
699
+ here, since the shooting velocity divides the position noise by ``Δt``).
700
+
701
+ Returns
702
+ -------
703
+ ForceSolveResult
704
+ """
705
+ F_psf = _as_psf(F)
706
+ inner = _resolve_inner(inner, _is_linear_basis(F))
707
+ eiv = _resolve_eiv_auto(eiv, F_psf)
708
+ dtype = default_float_dtype()
709
+ n_params = int(F_psf.template.size)
710
+ dt0 = _collection_dt(collection)
711
+ d = collection.datasets[0].X.shape[-1]
712
+ theta = _init_theta(theta0, F_psf, n_params, dtype)
713
+ n_sub = _resolve_n_substeps(n_substeps)
714
+
715
+ # Degeneracy guard (underdamped only): a single Euler phase-space step updates
716
+ # position as x + v·dt — independent of the force θ — so the skip-trick EIV
717
+ # instrument ∂Φˣ/∂θ is identically zero and the asymmetric Gram is rank-zero
718
+ # (θ collapses to 0 / explodes). Disable the instrument (fall back to the MLE)
719
+ # with a warning instead of returning a silent singular fit. RK4 (the default)
720
+ # or n_substeps≥2 carry θ into the position, so the instrument is well-posed.
721
+ if integrator == "euler" and n_sub == 1 and _resolve_w(eiv) > 0.0:
722
+ import warnings
723
+ warnings.warn(
724
+ "Underdamped skip-trick (eiv) is unavailable for a single Euler step "
725
+ "(integrator='euler', n_substeps=1): the Euler position update does not "
726
+ "depend on the force, so the instrument is degenerate. Falling back to "
727
+ "eiv=False. Use integrator='rk4' (the default) or n_substeps>=2 to keep "
728
+ "the skip-trick.",
729
+ RuntimeWarning, stacklevel=2,
730
+ )
731
+ eiv = False
732
+
733
+ mp = dict(reduce_over_particles=True)
734
+
735
+ def build_runs(n_sub, w=0.0):
736
+ # The η-clean instrument (w>0) widens the Gram window; the w=0 cold/clean
737
+ # pass keeps the efficient center MLE window (UDGramProgram decides from w).
738
+ loss_run = _integrator(
739
+ collection,
740
+ UDLossProgram(F_psf, dt=dt0, n_substeps=n_sub, integrator=integrator),
741
+ **mp,
742
+ )
743
+ nll_run = _integrator(
744
+ collection,
745
+ UDCondNLLProgram(F_psf, dt=dt0, n_substeps=n_sub, integrator=integrator, n_cond=n_cond),
746
+ **mp,
747
+ )
748
+ gram_run = _integrator(
749
+ collection,
750
+ UDGramProgram(F_psf, dt=dt0, n_substeps=n_sub, integrator=integrator, w=w),
751
+ **mp,
752
+ )
753
+ return loss_run, nll_run, gram_run
754
+
755
+ return _orchestrate(
756
+ build_runs, n_sub, theta, D, Lambda,
757
+ d=d, n_params=n_params, dtype=dtype, max_outer=max_outer,
758
+ inner_maxiter=inner_maxiter, profile_maxiter=profile_maxiter,
759
+ label="ud-force", inner=inner,
760
+ noise_init=lambda drop_se: _moment_init(
761
+ collection, dynamics="ud", d=d, dtype=dtype, drop_se=drop_se),
762
+ eiv=eiv,
763
+ )