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,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)
|