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,688 @@
1
+ """Per-backend residual builders.
2
+
3
+ Each builder takes a fitted inference object and returns a
4
+ :class:`ResidualBundle` containing pooled standardized residuals
5
+ :math:`z = \\Sigma^{-1/2} r` ready to feed into the statistical tests.
6
+
7
+ Measurement-noise-aware, banded whitening
8
+ -----------------------------------------
9
+ Both residuals carry two correlation sources that a single-residual
10
+ whitening ignores:
11
+
12
+ * **Measurement noise** :math:`\\Sigma_\\eta`. The diagnostic residual
13
+ covariance is :math:`C = \\text{(thermal)} + c\\,\\Sigma_\\eta`, not the
14
+ thermal part alone. The estimator's profiled :math:`\\Sigma_\\eta`
15
+ (``inferer.Lambda``) is folded into ``C`` so that a *well-recovered but
16
+ noisy* fit still whitens to unit variance instead of tripping every
17
+ flag. On clean data :math:`\\Sigma_\\eta\\approx 0` and this reduces to
18
+ the thermal whitening.
19
+ * **Serial correlation.** Localisation error is shared between
20
+ neighbouring residuals, so the residual series is a moving-average
21
+ process (overdamped increment → MA(1) with lag-1 block
22
+ :math:`-\\Sigma_\\eta`; the kept underdamped acceleration series → MA(1)
23
+ with lag-1 block :math:`\\Sigma_\\eta/\\Delta t^4`). A *banded*
24
+ whitening — the sequential block-Cholesky innovations of the
25
+ tridiagonal residual covariance (:func:`_sequential_innovations`) —
26
+ decorrelates the stream, exactly paralleling the parametric core's
27
+ banded precision. On clean data the off-diagonal block vanishes and
28
+ the innovations coincide with the marginal whitening.
29
+
30
+ The whitened stream ``z`` (moments / normality / autocorrelation) uses
31
+ the banded innovations; the per-row Mahalanobis norms ``z_squared_norms``
32
+ (the chi-square / MSE-consistency *bias* check) keep the **marginal**
33
+ noise-aware form, which faithfully preserves a slowly-varying force bias
34
+ that the innovations would partly difference out.
35
+
36
+ Residual conventions
37
+ --------------------
38
+ **Overdamped**:
39
+
40
+ .. math::
41
+
42
+ r_{t,n} = X_{t+1,n} - X_{t,n} - F(X_{t,n})\\,\\Delta t,
43
+ \\qquad C_{t} = 2\\,\\bar D\\,\\Delta t + 2\\,\\Sigma_\\eta,
44
+
45
+ with lag-1 covariance :math:`-\\Sigma_\\eta`. For the linear path the
46
+ thermal part is the exact ML residual; for the parametric path it is an
47
+ approximation that is nevertheless consistent (whitened residuals should
48
+ have unit variance and no autocorrelation if the model is well
49
+ specified).
50
+
51
+ **Underdamped**: symmetric acceleration
52
+ :math:`\\hat a_t = (X_{t+1} - 2X_t + X_{t-1})/\\Delta t^2`,
53
+
54
+ .. math::
55
+
56
+ r_t = \\hat a_t - F(\\hat x_t, \\hat v_t),
57
+ \\qquad C_t = \\tfrac23\\,\\frac{2\\bar D}{\\Delta t}
58
+ + \\frac{6\\,\\Sigma_\\eta}{\\Delta t^4}.
59
+
60
+ For both regimes residuals are pooled across time, particles, and
61
+ spatial components, applying the dataset's ``dynamic_mask`` (for
62
+ overdamped) or its 1-step erosion (for underdamped, which needs three
63
+ consecutive valid observations).
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ from dataclasses import dataclass, field
69
+
70
+ import jax
71
+ import jax.numpy as jnp
72
+ import numpy as np
73
+ from jax import lax
74
+
75
+
76
+ @dataclass
77
+ class ResidualBundle:
78
+ """Standardised residuals + metadata.
79
+
80
+ Attributes
81
+ ----------
82
+ z : np.ndarray
83
+ Whitened residuals, shape ``(K,)``. Pooled across time,
84
+ particles and spatial components after masking.
85
+ z_components : np.ndarray
86
+ Whitened residuals organised by spatial component, shape
87
+ ``(K_per_component, d)``. Used for per-axis statistics.
88
+ z_squared_norms : np.ndarray
89
+ Per-row squared Mahalanobis norm
90
+ :math:`r_t^\\top \\Sigma_t^{-1} r_t`, shape ``(K_per_row,)``.
91
+ Used for the diffusion / "chi-square" check.
92
+ force_quadratic_form : np.ndarray
93
+ Per-row quadratic form
94
+ :math:`F^\\top A^{-1} F` evaluated on the same valid samples
95
+ used to build ``z``. Pre-computing it here avoids a second
96
+ evaluation of ``F`` in the MSE-consistency check downstream.
97
+ mean_dt : float
98
+ Average step size used in the residual construction.
99
+ n_obs : int
100
+ Number of valid (un-masked) observations used to build ``z``.
101
+ d : int
102
+ Spatial dimension.
103
+ regime : str
104
+ ``"OD"`` or ``"UD"``.
105
+ backend : str
106
+ Coarse tag of the inference path (``"linear"``, ``"parametric"``,
107
+ ``"nonlinear"``). For diagnostic display only.
108
+ n_particles : int
109
+ Maximum number of particles in any dataset.
110
+ nmse_excess_factor : float
111
+ Conversion factor from the chi-square excess to the force NMSE in
112
+ :func:`mse_consistency`. ``1.0`` for the overdamped increment
113
+ residual; :data:`KAPPA_UD` for the underdamped acceleration
114
+ residual (see that constant for the derivation).
115
+ whitened : list of (np.ndarray, np.ndarray)
116
+ Per-dataset ``(z_full, mask)`` pairs with ``z_full`` of shape
117
+ ``(K, N, d)`` (time-major) and ``mask`` of shape ``(K, N)``.
118
+ Kept so that autocorrelation can be measured strictly along
119
+ time, per particle and per component — pooling the flattened
120
+ ``z`` stream would mix particles and components at short lags.
121
+ """
122
+
123
+ z: np.ndarray
124
+ z_components: np.ndarray
125
+ z_squared_norms: np.ndarray
126
+ force_quadratic_form: np.ndarray
127
+ mean_dt: float
128
+ n_obs: int
129
+ d: int
130
+ regime: str
131
+ backend: str
132
+ n_particles: int
133
+ nmse_excess_factor: float = 1.0
134
+ whitened: list = field(default_factory=list)
135
+
136
+
137
+ # --------------------------------------------------------------------- #
138
+ # Helpers
139
+ # --------------------------------------------------------------------- #
140
+ def _inv_sqrt_psd(S: jnp.ndarray) -> jnp.ndarray:
141
+ """Batched symmetric inverse square root ``S^{-1/2}`` (eigen-clamped).
142
+
143
+ ``S`` has shape ``(..., d, d)`` and is symmetrised and floored before
144
+ inversion so a marginally-indefinite conditional covariance (high
145
+ measurement noise) stays well-posed.
146
+ """
147
+ S = 0.5 * (S + jnp.swapaxes(S, -1, -2))
148
+ w, U = jnp.linalg.eigh(S)
149
+ floor = jnp.maximum(jnp.max(w, axis=-1, keepdims=True) * 1e-12, 1e-30)
150
+ w = jnp.clip(w, floor, None)
151
+ return jnp.einsum("...am,...m,...bm->...ab", U, 1.0 / jnp.sqrt(w), U)
152
+
153
+
154
+ @jax.jit
155
+ def _sequential_innovations(r, mask, contiguous, A_blocks, Lambda, offdiag_coef):
156
+ r"""Whiten a tridiagonal (MA(1)) residual stream by block-Cholesky innovations.
157
+
158
+ The residual series has diagonal covariance blocks ``A_blocks[k]`` and
159
+ lag-1 blocks ``C_k = Cov(r_{k-1}, r_k) = offdiag_coef[k]·Λ``. The
160
+ LDL\\ :sup:`T` factorisation of the resulting block-tridiagonal
161
+ covariance gives the innovations recursion (the exact whitening, the
162
+ diagnostic twin of the parametric core's banded precision):
163
+
164
+ .. math::
165
+
166
+ M_k = C_k\\,S_{k-1}^{-1},\\quad
167
+ w_k = r_k - M_k w_{k-1},\\quad
168
+ S_k = A_k - M_k C_k,\\quad
169
+ z_k = S_k^{-1/2} w_k,
170
+
171
+ so ``z`` has unit covariance *and no serial correlation* — unlike the
172
+ marginal whitening ``A_k^{-1/2} r_k``, which leaves the measurement-noise
173
+ off-diagonal in place. The recursion **resets** (drops to the marginal
174
+ form ``z_k = A_k^{-1/2} r_k``) at the start of each contiguous run: where
175
+ ``contiguous[k]`` is ``False`` (the kept index is not the immediate
176
+ successor of the previous one) or either endpoint is masked, so gaps in
177
+ the trajectory do not couple unrelated residuals.
178
+
179
+ Parameters
180
+ ----------
181
+ r : ``(K, N, d)`` time-major residuals.
182
+ mask : ``(K, N)`` bool validity.
183
+ contiguous : ``(K,)`` bool — ``True`` where index ``k`` is the sampling
184
+ successor of ``k-1`` (so the lag-1 block applies).
185
+ A_blocks : ``(K, d, d)`` diagonal covariance blocks (= the marginal,
186
+ noise-aware ``C``).
187
+ Lambda : ``(d, d)`` measurement-noise covariance ``Λ``.
188
+ offdiag_coef : ``(K,)`` lag-1 scalar coefficient (overdamped ``-1``;
189
+ kept underdamped ``1/Δt^4``).
190
+
191
+ Returns
192
+ -------
193
+ z : ``(K, N, d)`` whitened innovations (zero on masked rows).
194
+ """
195
+ K, N, d = r.shape
196
+ I_d = jnp.eye(d, dtype=r.dtype)
197
+
198
+ def body(carry, x):
199
+ w_prev, S_prev, valid_prev = carry
200
+ r_k, A_k, coef_k, contig_k, valid_k = x
201
+ C = coef_k * Lambda # (d, d) symmetric lag-1 block
202
+ couple = contig_k & valid_prev & valid_k # (N,)
203
+ S_safe = jnp.where(valid_prev[:, None, None], S_prev, I_d[None])
204
+ # M_k = C S_prev^{-1} = (S_prev^{-1} C)^T (C, S_prev symmetric)
205
+ M = jnp.swapaxes(jnp.linalg.solve(S_safe, jnp.broadcast_to(C, (N, d, d))), -1, -2)
206
+ M = jnp.where(couple[:, None, None], M, 0.0)
207
+ w_k = r_k - jnp.einsum("nij,nj->ni", M, w_prev)
208
+ S_k = A_k[None] - jnp.einsum("nij,jk->nik", M, C) # A_k − M C
209
+ z_k = jnp.einsum("nij,nj->ni", _inv_sqrt_psd(S_k), w_k)
210
+ # Carry the innovation covariance forward; reset masked rows so the
211
+ # next step decouples from them.
212
+ w_out = jnp.where(valid_k[:, None], w_k, 0.0)
213
+ S_out = jnp.where(valid_k[:, None, None], S_k, I_d[None])
214
+ z_out = jnp.where(valid_k[:, None], z_k, 0.0)
215
+ return (w_out, S_out, valid_k), z_out
216
+
217
+ init = (
218
+ jnp.zeros((N, d), r.dtype),
219
+ jnp.broadcast_to(I_d, (N, d, d)),
220
+ jnp.zeros((N,), bool),
221
+ )
222
+ _, z = lax.scan(body, init, (r, A_blocks, offdiag_coef, contiguous, mask))
223
+ return z
224
+
225
+
226
+ def _coerce_F_value(value, K: int, N: int, d: int) -> jnp.ndarray:
227
+ """Reshape an SF / Basis / callable output to ``(K, N, d)``.
228
+
229
+ The OD / UD ``force_inferred`` callable accepts batched inputs
230
+ of shape ``(M, d)`` and returns ``(M, d)``; we always feed it the
231
+ flattened ``(K * N, d)`` form and reshape back.
232
+ """
233
+ arr = jnp.asarray(value)
234
+ if arr.shape == (K * N, d):
235
+ return arr.reshape(K, N, d)
236
+ if arr.shape == (K, N, d):
237
+ return arr
238
+ raise ValueError(f"Force callable returned shape {arr.shape}; expected ({K * N}, {d}) or ({K}, {N}, {d}).")
239
+
240
+
241
+ def _measurement_noise(inferer, d: int) -> jnp.ndarray:
242
+ """PSD measurement-noise covariance Λ (``inferer.Lambda``), or zero.
243
+
244
+ The parametric estimator profiles Λ natively and the diffusion
245
+ estimators expose it as ``Lambda``; on clean data Λ ≈ 0 so the
246
+ noise-aware whitening reduces to the thermal case. The estimate can
247
+ be marginally non-PSD on clean data, which the ``6Λ/Δt⁴`` weighting
248
+ would amplify — so we clamp to the PSD cone.
249
+ """
250
+ Lam = getattr(inferer, "Lambda", None)
251
+ if Lam is None:
252
+ return jnp.zeros((d, d))
253
+ Lam = jnp.asarray(Lam)
254
+ if Lam.shape != (d, d):
255
+ return jnp.zeros((d, d))
256
+ w, U = jnp.linalg.eigh(0.5 * (Lam + Lam.T))
257
+ return (U * jnp.maximum(w, 0.0)) @ U.T
258
+
259
+
260
+ def _backend_tag(inferer) -> str:
261
+ if hasattr(inferer, "metadata") and isinstance(inferer.metadata, dict):
262
+ return str(inferer.metadata.get("force_method", "linear"))
263
+ return "linear"
264
+
265
+
266
+ # Continuous-limit noise factor for the underdamped acceleration residual.
267
+ #
268
+ # The underdamped diagnostic residual is the symmetric finite-difference
269
+ # acceleration â(t) = (x_{t+1} - 2 x_t + x_{t-1}) / dt² minus the fitted
270
+ # force F(x̂, v̂) — the same quantity the underdamped force estimator fits
271
+ # (the symmetric ULI kinematics in SFI.inference.underdamped: _A_sym_uli /
272
+ # _V_sym_uli / _X_sym_uli).
273
+ #
274
+ # For dx = v dt, dv = F dt + sqrt(2D) dW the position is C¹, so the noise
275
+ # part of â is the second difference of the integrated velocity noise.
276
+ # Writing N_t = sqrt(2D) ∫ B over one sampling cell (B the velocity
277
+ # Brownian motion), the adjacent-cell autocovariance integral gives
278
+ #
279
+ # Var(x_{t+1} - 2 x_t + x_{t-1}) = (4/3) D dt³ = (2/3) (2D) dt³,
280
+ #
281
+ # hence Var(â_noise) = (2/3) (2D) / dt = KAPPA_UD · A / dt with A = 2D.
282
+ #
283
+ # This factor is exact for continuously sampled data (the physical case
284
+ # for experimental trajectories) and is the limit that finely oversampled
285
+ # simulations converge to. The thermal residual is a clean MA(1) process in
286
+ # time (lag-1 ≈ 1/4, lag ≥ 2 ≈ 0), so the builder keeps every second valid
287
+ # time index to remove that thermal lag-1. The leftover measurement-noise
288
+ # correlation (a lag-1 block Λ/Δt⁴ in the kept series) is removed by the
289
+ # banded innovations whitening (_sequential_innovations).
290
+ KAPPA_UD = 2.0 / 3.0
291
+
292
+
293
+ def _process_chunk(
294
+ *,
295
+ F_at: jnp.ndarray, # (K, N, d)
296
+ r: jnp.ndarray, # (K, N, d) raw residual
297
+ dt: jnp.ndarray, # (K,) physical step (pooled into mean_dt)
298
+ mask: jnp.ndarray, # (K, N) bool
299
+ A: jnp.ndarray, # (d, d) = 2 D
300
+ A_inv: jnp.ndarray, # (d, d)
301
+ contiguous: np.ndarray, # (K,) bool sampling-successor flag
302
+ offdiag_coef: jnp.ndarray, # (K,) lag-1 coefficient on Λ
303
+ var_scale: jnp.ndarray | None = None, # (K,) thermal coefficient on A
304
+ Lambda: jnp.ndarray | None = None, # (d, d) measurement-noise covariance Λ
305
+ noise_scale: jnp.ndarray | None = None, # (K,) coefficient on Λ
306
+ ):
307
+ """Whiten residuals, compute Mahalanobis norms and ``F^T A^{-1} F``,
308
+ and return only the masked-valid rows pooled along ``(K, N)``.
309
+
310
+ Two whitenings are produced from the same residual covariance
311
+ ``C = var_scale·A + noise_scale·Λ`` (diagonal blocks) with lag-1 block
312
+ ``offdiag_coef·Λ``:
313
+
314
+ * **Banded innovations** ``z`` (returned for the moments / normality /
315
+ autocorrelation tests) — the sequential block-Cholesky whitening
316
+ (:func:`_sequential_innovations`) of the tridiagonal covariance. It
317
+ has unit variance *and no serial correlation*, so it does not trip the
318
+ Ljung--Box test on measurement-noise-correlated residuals.
319
+ * **Marginal Mahalanobis norms** ``sqn = rᵀ C⁻¹ r`` (returned for the
320
+ MSE-consistency / chi-square *bias* check) — the single-residual form,
321
+ kept because the banded innovations would partly difference out a
322
+ slowly-varying force bias that this check is meant to detect.
323
+
324
+ The **measurement-noise term** ``noise_scale · Λ`` makes both
325
+ noise-aware: for the underdamped acceleration residual it scales as
326
+ ``6 Λ / Δt⁴`` and otherwise overwhelms the thermal term
327
+ ``(2/3)(2D)/Δt`` even when the force is well recovered. When ``Λ`` is
328
+ zero (clean data, where the estimator profiles ``Λ ≈ 0``) the
329
+ off-diagonal block vanishes and both reduce to the thermal whitening
330
+ ``(var_scale·A)^{-1/2} r``. ``var_scale`` defaults to ``dt`` (the
331
+ overdamped Euler scale).
332
+ """
333
+ scale = dt if var_scale is None else var_scale
334
+ Lam = jnp.zeros_like(A) if Lambda is None else jnp.asarray(Lambda)
335
+ if Lambda is not None and noise_scale is not None:
336
+ # Drop a spuriously-small Λ: if its residual contribution is a tiny
337
+ # fraction of the thermal scale the data is *effectively clean*, and
338
+ # folding it in would deflate the chi-square / MSE-consistency bias
339
+ # signal — masking genuine misspecification. Real underdamped noise is
340
+ # amplified as 6Λ/Δt⁴, far above this floor, so it is never dropped.
341
+ thermal = jnp.mean(scale) * jnp.trace(A)
342
+ noise = jnp.mean(noise_scale) * jnp.trace(Lam)
343
+ Lam = jnp.where(noise < 0.2 * thermal, jnp.zeros_like(Lam), Lam)
344
+ C = scale[:, None, None] * A[None] # (K, d, d) diagonal blocks
345
+ if Lambda is not None and noise_scale is not None:
346
+ C = C + noise_scale[:, None, None] * Lam[None]
347
+
348
+ # Marginal Mahalanobis norms (bias / chi-square channel): rᵀ C⁻¹ r.
349
+ w, U = jnp.linalg.eigh(C) # (K, d), (K, d, d)
350
+ C_inv = jnp.einsum("kam,km,kbm->kab", U, 1.0 / w, U)
351
+ sqn = jnp.einsum("kni,kij,knj->kn", r, C_inv, r) # (K, N)
352
+ F2 = jnp.einsum("kjm,mp,kjp->kj", F_at, A_inv, F_at) # (K, N)
353
+
354
+ # Banded innovations (serial-decorrelation channel) used for z / z_full.
355
+ z = _sequential_innovations(
356
+ jnp.asarray(r),
357
+ jnp.asarray(mask),
358
+ jnp.asarray(contiguous),
359
+ C,
360
+ jnp.asarray(Lam),
361
+ jnp.asarray(offdiag_coef),
362
+ ) # (K, N, d)
363
+
364
+ m_np = np.asarray(mask)
365
+ z_full = np.asarray(z) # (K, N, d) time-major, pre-masking
366
+ z_np = z_full[m_np] # (Kv, d)
367
+ sqn_np = np.asarray(sqn)[m_np] # (Kv,)
368
+ F2_np = np.asarray(F2)[m_np] # (Kv,)
369
+ # Per-valid-row dt (broadcast scalar dt over particles)
370
+ K, N = m_np.shape
371
+ dt_kn = np.broadcast_to(np.asarray(dt)[:, None], (K, N))[m_np]
372
+ return z_np, sqn_np, F2_np, dt_kn, z_full, m_np
373
+
374
+
375
+ # --------------------------------------------------------------------- #
376
+ # Overdamped builder
377
+ # --------------------------------------------------------------------- #
378
+ def build_overdamped_residuals(inferer, data=None) -> ResidualBundle:
379
+ """Build standardised Euler--Maruyama residuals for an OD inferer.
380
+
381
+ Routes data access through ``TrajectoryDataset.make_batch_producer`` —
382
+ the same low-level streaming layer used by ``SFI.integrate`` — so
383
+ multi-particle, masked, and multi-dataset trajectories are handled
384
+ transparently.
385
+
386
+ Works for any overdamped inference path (linear, parametric, nonlinear)
387
+ as long as ``inferer.force_inferred`` is callable and
388
+ ``inferer.A_inv`` is available.
389
+ """
390
+ if not hasattr(inferer, "force_inferred") or inferer.force_inferred is None:
391
+ raise RuntimeError("inferer.force_inferred is missing; run a force-inference method first.")
392
+ if not hasattr(inferer, "A_inv"):
393
+ raise RuntimeError("inferer.A_inv is missing; run compute_diffusion_constant() first.")
394
+
395
+ F = inferer.force_inferred
396
+ A = jnp.asarray(getattr(inferer, "A", 2.0 * inferer.diffusion_average))
397
+ A_inv = jnp.asarray(inferer.A_inv)
398
+ d = int(A.shape[0])
399
+ Lambda = _measurement_noise(inferer, d) # Λ; increment noise = 2 Λ
400
+
401
+ require = {"X", "X_plus"}
402
+ z_chunks: list[np.ndarray] = []
403
+ sqnorm_chunks: list[np.ndarray] = []
404
+ F2_chunks: list[np.ndarray] = []
405
+ dt_chunks: list[np.ndarray] = []
406
+ whitened_chunks: list = []
407
+ n_particles_max = 0
408
+ backend = _backend_tag(inferer)
409
+
410
+ collection = data if data is not None else inferer.data
411
+ for ds_idx, ds in enumerate(collection.datasets):
412
+ t_idx = ds.valid_indices(require)
413
+ if t_idx.size == 0:
414
+ continue
415
+ d_ds = int(ds.d)
416
+ if d_ds != d:
417
+ raise ValueError(f"Dataset dimension {d_ds} does not match A_inv dimension {d}.")
418
+ n_particles_max = max(n_particles_max, int(ds.N))
419
+
420
+ producer = ds.make_batch_producer(
421
+ require,
422
+ include_mask=True,
423
+ include_dt=True,
424
+ force_dt_keys={"dt"},
425
+ )
426
+ row = producer(t_idx)
427
+ X = row["X"] # (K, N, d)
428
+ Xp = row["X_plus"] # (K, N, d)
429
+ dt = row["dt"] # (K,)
430
+ mask = row["mask_out"] # (K, N) bool
431
+ K, N, _ = X.shape
432
+ extras = ds.build_extras(t_idx, dataset_index=ds_idx)
433
+
434
+ # Sampling adjacency: the lag-1 measurement-noise block (−Λ) only
435
+ # couples residuals at consecutive frames; reset across any gap.
436
+ t_arr = np.asarray(t_idx)
437
+ contiguous = np.zeros(K, dtype=bool)
438
+ contiguous[1:] = np.diff(t_arr) == 1
439
+ offdiag_coef = -np.ones(K) # Cov(r_{k-1}, r_k) = −Λ
440
+
441
+ if N > 1:
442
+ # Multiparticle: each frame has N interacting particles — compute
443
+ # the force on each frame separately to preserve particle structure.
444
+ # Per-particle extras (N, ...) reach each particle on the (N,) batch.
445
+ F_frames = [np.asarray(F(np.asarray(X[t]), extras=extras)) for t in range(K)]
446
+ F_at = np.stack(F_frames, axis=0) # (K, N, d)
447
+ else:
448
+ F_at = _coerce_F_value(F(X.reshape(K * N, d), extras=extras), K, N, d)
449
+ r = (Xp - X) - F_at * dt[:, None, None]
450
+ z_v, sqn_v, F2_v, dt_v, z_full, mask_full = _process_chunk(
451
+ F_at=F_at,
452
+ r=r,
453
+ dt=dt,
454
+ mask=mask,
455
+ A=A,
456
+ A_inv=A_inv,
457
+ contiguous=contiguous,
458
+ offdiag_coef=jnp.asarray(offdiag_coef),
459
+ var_scale=jnp.asarray(dt),
460
+ Lambda=Lambda,
461
+ noise_scale=2.0 * jnp.ones_like(jnp.asarray(dt)),
462
+ )
463
+ z_chunks.append(z_v)
464
+ sqnorm_chunks.append(sqn_v)
465
+ F2_chunks.append(F2_v)
466
+ dt_chunks.append(dt_v)
467
+ whitened_chunks.append((z_full, mask_full))
468
+
469
+ return _assemble_bundle(
470
+ z_chunks,
471
+ sqnorm_chunks,
472
+ F2_chunks,
473
+ dt_chunks,
474
+ whitened_chunks,
475
+ d=d,
476
+ regime="OD",
477
+ backend=backend,
478
+ n_particles=n_particles_max,
479
+ )
480
+
481
+
482
+ # --------------------------------------------------------------------- #
483
+ # Underdamped builder
484
+ # --------------------------------------------------------------------- #
485
+ def build_underdamped_residuals(inferer, data=None) -> ResidualBundle:
486
+ """Build standardised innovations for a UD inferer from the symmetric
487
+ acceleration residual.
488
+
489
+ Uses the symmetric ULI kinematics that the underdamped force estimator
490
+ itself fits (see ``SFI.inference.underdamped``):
491
+
492
+ .. math::
493
+
494
+ \\hat x = \\tfrac13(X_{t-1}+X_t+X_{t+1}), \\quad
495
+ \\hat v = \\frac{X_{t+1}-X_{t-1}}{2\\Delta t}, \\quad
496
+ \\hat a = \\frac{X_{t+1}-2X_t+X_{t-1}}{\\Delta t^2},
497
+
498
+ and forms the residual :math:`r_t = \\hat a - F(\\hat x, \\hat v)`.
499
+ Its thermal noise covariance is :math:`\\tfrac23 A/\\Delta t` (see
500
+ :data:`KAPPA_UD`); with measurement noise the diagonal block gains
501
+ :math:`6\\Sigma_\\eta/\\Delta t^4`. The thermal residual is MA(1), so
502
+ only every second valid index is kept (removing the thermal lag-1);
503
+ the residual measurement-noise correlation (lag-1 block
504
+ :math:`\\Sigma_\\eta/\\Delta t^4`) is removed by the banded innovations
505
+ whitening, leaving a serially independent stream.
506
+
507
+ Like :func:`build_overdamped_residuals`, all data access uses
508
+ ``TrajectoryDataset.make_batch_producer`` so masking and
509
+ multi-dataset / multi-particle pooling are handled by the same
510
+ streaming layer that powers ``SFI.integrate``.
511
+ """
512
+ if not hasattr(inferer, "force_inferred") or inferer.force_inferred is None:
513
+ raise RuntimeError("inferer.force_inferred is missing; run a force-inference method first.")
514
+ if not hasattr(inferer, "A_inv"):
515
+ raise RuntimeError("inferer.A_inv is missing; run compute_diffusion_constant() first.")
516
+
517
+ F = inferer.force_inferred
518
+ A = jnp.asarray(getattr(inferer, "A", 2.0 * inferer.diffusion_average))
519
+ A_inv = jnp.asarray(inferer.A_inv)
520
+ d = int(A.shape[0])
521
+ Lambda = _measurement_noise(inferer, d) # Λ; acceleration noise = 6 Λ / Δt⁴
522
+
523
+ # Symmetric 3-point stencil: X_{t-1}, X_t, X_{t+1}.
524
+ require = {"X_minus", "X", "X_plus"}
525
+ z_chunks: list[np.ndarray] = []
526
+ sqnorm_chunks: list[np.ndarray] = []
527
+ F2_chunks: list[np.ndarray] = []
528
+ dt_chunks: list[np.ndarray] = []
529
+ whitened_chunks: list = []
530
+ n_particles_max = 0
531
+ backend = _backend_tag(inferer)
532
+
533
+ collection = data if data is not None else inferer.data
534
+ for ds_idx, ds in enumerate(collection.datasets):
535
+ t_idx = ds.valid_indices(require)
536
+ # Adjacent acceleration residuals share two of three positions, so the
537
+ # *thermal* series is MA(1). Keeping every second valid index removes
538
+ # that thermal lag-1 correlation (lag ≥ 2 thermal ≈ 0). Under
539
+ # measurement noise the kept series still carries a lag-1 block
540
+ # Λ/Δt⁴ (the original lag-2), which the banded whitening below
541
+ # decorrelates; on clean data that block vanishes.
542
+ t_idx = t_idx[::2]
543
+ if t_idx.size == 0:
544
+ continue
545
+ d_ds = int(ds.d)
546
+ if d_ds != d:
547
+ raise ValueError(f"Dataset dimension {d_ds} does not match A_inv dimension {d}.")
548
+ n_particles_max = max(n_particles_max, int(ds.N))
549
+
550
+ producer = ds.make_batch_producer(
551
+ require,
552
+ include_mask=True,
553
+ include_dt=True,
554
+ force_dt_keys={"dt"},
555
+ )
556
+ row = producer(t_idx)
557
+ Xm = row["X_minus"] # (K, N, d) at t-1
558
+ X = row["X"] # (K, N, d) at t
559
+ Xp = row["X_plus"] # (K, N, d) at t+1
560
+ dt0 = row["dt"] # (K,) step t -> t+1
561
+ mask = row["mask_out"] # (K, N) bool — already AND'd
562
+ K, N, _ = X.shape
563
+
564
+ # Strided sampling adjacency: the lag-1 block (Λ/Δt⁴) only couples
565
+ # kept residuals two original frames apart; reset across any gap.
566
+ t_arr = np.asarray(t_idx)
567
+ contiguous = np.zeros(K, dtype=bool)
568
+ contiguous[1:] = np.diff(t_arr) == 2
569
+
570
+ # Symmetric ULI kinematics (match SFI.inference.underdamped):
571
+ # x̂ = (X₋ + X + X₊)/3, v̂ = (X₊ − X₋)/(2 dt), â = (X₊ − 2X + X₋)/dt²
572
+ dt_b = dt0[:, None, None]
573
+ x_hat = (Xm + X + Xp) / 3.0
574
+ v_hat = (Xp - Xm) / (2.0 * dt_b)
575
+ a_hat = (Xp - 2.0 * X + Xm) / (dt_b * dt_b)
576
+
577
+ extras = ds.build_extras(t_idx, dataset_index=ds_idx)
578
+ if N > 1:
579
+ # Preserve the particle axis so bases with per-particle extras
580
+ # (e.g. per-agent home ranges) see each particle's own value.
581
+ F_at = _coerce_F_value(F(x_hat, v=v_hat, extras=extras), K, N, d)
582
+ else:
583
+ F_at = _coerce_F_value(
584
+ F(x_hat.reshape(K * N, d), v=v_hat.reshape(K * N, d), extras=extras),
585
+ K,
586
+ N,
587
+ d,
588
+ )
589
+ # Residual in acceleration units; thermal Cov = (2/3) A / dt
590
+ # (KAPPA_UD), measurement-noise Cov = 6 Λ / dt⁴ (second difference of
591
+ # i.i.d. localisation errors: variance (1+4+1) Λ / dt⁴).
592
+ r = a_hat - F_at
593
+ var_scale = KAPPA_UD / dt0 # (K,)
594
+ noise_scale = 6.0 / (dt0 ** 4) # (K,)
595
+ offdiag_coef = 1.0 / (dt0 ** 4) # Cov(r_{k-1}, r_k) = Λ/Δt⁴
596
+ z_v, sqn_v, F2_v, dt_pool, z_full, mask_full = _process_chunk(
597
+ F_at=F_at,
598
+ r=r,
599
+ dt=dt0,
600
+ mask=mask,
601
+ A=A,
602
+ A_inv=A_inv,
603
+ contiguous=contiguous,
604
+ offdiag_coef=offdiag_coef,
605
+ var_scale=var_scale,
606
+ Lambda=Lambda,
607
+ noise_scale=noise_scale,
608
+ )
609
+ z_chunks.append(z_v)
610
+ sqnorm_chunks.append(sqn_v)
611
+ F2_chunks.append(F2_v)
612
+ dt_chunks.append(dt_pool)
613
+ whitened_chunks.append((z_full, mask_full))
614
+
615
+ return _assemble_bundle(
616
+ z_chunks,
617
+ sqnorm_chunks,
618
+ F2_chunks,
619
+ dt_chunks,
620
+ whitened_chunks,
621
+ d=d,
622
+ regime="UD",
623
+ backend=backend,
624
+ n_particles=n_particles_max,
625
+ nmse_excess_factor=KAPPA_UD,
626
+ )
627
+
628
+
629
+ def _assemble_bundle(
630
+ z_chunks,
631
+ sqnorm_chunks,
632
+ F2_chunks,
633
+ dt_chunks,
634
+ whitened_chunks,
635
+ *,
636
+ d: int,
637
+ regime: str,
638
+ backend: str,
639
+ n_particles: int,
640
+ nmse_excess_factor: float = 1.0,
641
+ ) -> ResidualBundle:
642
+ if z_chunks:
643
+ z_components = np.concatenate(z_chunks, axis=0)
644
+ sqn_pooled = np.concatenate(sqnorm_chunks, axis=0)
645
+ F2_pooled = np.concatenate(F2_chunks, axis=0)
646
+ dt_pooled = np.concatenate(dt_chunks, axis=0)
647
+ mean_dt = float(np.mean(dt_pooled)) if dt_pooled.size else float("nan")
648
+ else:
649
+ z_components = np.zeros((0, d))
650
+ sqn_pooled = np.zeros((0,))
651
+ F2_pooled = np.zeros((0,))
652
+ mean_dt = float("nan")
653
+ z_pooled = z_components.reshape(-1)
654
+ return ResidualBundle(
655
+ z=z_pooled,
656
+ z_components=z_components,
657
+ z_squared_norms=sqn_pooled,
658
+ force_quadratic_form=F2_pooled,
659
+ mean_dt=mean_dt,
660
+ n_obs=int(z_components.shape[0]),
661
+ d=d,
662
+ regime=regime,
663
+ backend=backend,
664
+ n_particles=n_particles,
665
+ nmse_excess_factor=nmse_excess_factor,
666
+ whitened=whitened_chunks,
667
+ )
668
+
669
+
670
+ # --------------------------------------------------------------------- #
671
+ # Dispatch
672
+ # --------------------------------------------------------------------- #
673
+ def build_residuals(inferer, data=None) -> ResidualBundle:
674
+ """Dispatch to the OD / UD residual builder based on the engine class.
675
+
676
+ ``data`` (optional) evaluates the residuals on an independent
677
+ :class:`~SFI.trajectory.TrajectoryCollection` instead of the
678
+ training data — the held-out path used by ``holdout_score``.
679
+ """
680
+ cls = type(inferer).__name__
681
+ if "Underdamped" in cls:
682
+ return build_underdamped_residuals(inferer, data=data)
683
+ if "Overdamped" in cls:
684
+ return build_overdamped_residuals(inferer, data=data)
685
+ # Fallback: try by attribute
686
+ if hasattr(inferer, "_force_inference_underdamped"):
687
+ return build_underdamped_residuals(inferer, data=data)
688
+ return build_overdamped_residuals(inferer, data=data)