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.
- SFI/__init__.py +64 -0
- SFI/bases/__init__.py +85 -0
- SFI/bases/constants.py +492 -0
- SFI/bases/linear.py +325 -0
- SFI/bases/monomials.py +218 -0
- SFI/bases/pairs.py +998 -0
- SFI/bases/spde.py +1537 -0
- SFI/diagnostics/__init__.py +60 -0
- SFI/diagnostics/assess.py +87 -0
- SFI/diagnostics/dynamics_order.py +621 -0
- SFI/diagnostics/plotting.py +226 -0
- SFI/diagnostics/report.py +238 -0
- SFI/diagnostics/residual_tests.py +395 -0
- SFI/diagnostics/residuals.py +688 -0
- SFI/inference/__init__.py +58 -0
- SFI/inference/base.py +1460 -0
- SFI/inference/optimizers.py +200 -0
- SFI/inference/overdamped.py +1214 -0
- SFI/inference/parametric_core/__init__.py +34 -0
- SFI/inference/parametric_core/covariance.py +232 -0
- SFI/inference/parametric_core/driver.py +272 -0
- SFI/inference/parametric_core/flow.py +149 -0
- SFI/inference/parametric_core/flow_multi.py +362 -0
- SFI/inference/parametric_core/flow_ud.py +168 -0
- SFI/inference/parametric_core/jacobians.py +540 -0
- SFI/inference/parametric_core/objective.py +286 -0
- SFI/inference/parametric_core/objective_ud.py +253 -0
- SFI/inference/parametric_core/precision.py +229 -0
- SFI/inference/parametric_core/solve.py +763 -0
- SFI/inference/result.py +362 -0
- SFI/inference/serialization.py +245 -0
- SFI/inference/sparse/__init__.py +67 -0
- SFI/inference/sparse/base.py +43 -0
- SFI/inference/sparse/beam.py +303 -0
- SFI/inference/sparse/greedy.py +151 -0
- SFI/inference/sparse/hillclimb.py +307 -0
- SFI/inference/sparse/lasso.py +178 -0
- SFI/inference/sparse/metrics.py +78 -0
- SFI/inference/sparse/result.py +278 -0
- SFI/inference/sparse/scorer.py +323 -0
- SFI/inference/sparse/stlsq.py +165 -0
- SFI/inference/sparsity.py +40 -0
- SFI/inference/underdamped.py +1355 -0
- SFI/integrate/__init__.py +38 -0
- SFI/integrate/api.py +920 -0
- SFI/integrate/integrand.py +402 -0
- SFI/integrate/rk4.py +156 -0
- SFI/integrate/timeops.py +174 -0
- SFI/langevin/__init__.py +29 -0
- SFI/langevin/base.py +863 -0
- SFI/langevin/chunked.py +225 -0
- SFI/langevin/noise.py +446 -0
- SFI/langevin/overdamped.py +560 -0
- SFI/langevin/underdamped.py +448 -0
- SFI/statefunc/__init__.py +49 -0
- SFI/statefunc/basis.py +87 -0
- SFI/statefunc/core/runtime.py +47 -0
- SFI/statefunc/factory.py +346 -0
- SFI/statefunc/interactor.py +90 -0
- SFI/statefunc/layout/__init__.py +29 -0
- SFI/statefunc/layout/_base.py +186 -0
- SFI/statefunc/layout/_eval_compiler.py +453 -0
- SFI/statefunc/layout/_fd_atoms.py +196 -0
- SFI/statefunc/layout/_grid.py +878 -0
- SFI/statefunc/layout/_sectors.py +166 -0
- SFI/statefunc/memhint.py +222 -0
- SFI/statefunc/nodes/__init__.py +56 -0
- SFI/statefunc/nodes/base.py +318 -0
- SFI/statefunc/nodes/contract.py +422 -0
- SFI/statefunc/nodes/interactions/__init__.py +23 -0
- SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
- SFI/statefunc/nodes/interactions/prepare.py +161 -0
- SFI/statefunc/nodes/interactions/specs.py +362 -0
- SFI/statefunc/nodes/interactions/stencils.py +718 -0
- SFI/statefunc/nodes/leaf.py +530 -0
- SFI/statefunc/nodes/ops/__init__.py +27 -0
- SFI/statefunc/nodes/ops/concat.py +28 -0
- SFI/statefunc/nodes/ops/derivative.py +447 -0
- SFI/statefunc/nodes/ops/einsum.py +120 -0
- SFI/statefunc/nodes/ops/linear.py +153 -0
- SFI/statefunc/nodes/ops/mapn.py +138 -0
- SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
- SFI/statefunc/nodes/ops/slice.py +83 -0
- SFI/statefunc/params.py +309 -0
- SFI/statefunc/psf.py +112 -0
- SFI/statefunc/sf.py +104 -0
- SFI/statefunc/stateexpr.py +1243 -0
- SFI/statefunc/structexpr.py +1003 -0
- SFI/trajectory/__init__.py +31 -0
- SFI/trajectory/collection.py +1122 -0
- SFI/trajectory/dataset.py +1164 -0
- SFI/trajectory/degrade.py +1108 -0
- SFI/trajectory/io.py +1027 -0
- SFI/trajectory/reserved_extras.py +129 -0
- SFI/utils/__init__.py +17 -0
- SFI/utils/formatting.py +308 -0
- SFI/utils/maths.py +185 -0
- SFI/utils/neighbors.py +162 -0
- SFI/utils/plotting.py +1936 -0
- stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
- stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
- stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
- stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|