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,621 @@
|
|
|
1
|
+
"""Overdamped-vs-underdamped dynamics classifier.
|
|
2
|
+
|
|
3
|
+
Reads raw trajectory data and decides whether the dynamics are overdamped
|
|
4
|
+
(OD, first-order Langevin) or underdamped (UD, second-order / inertial),
|
|
5
|
+
robust to high localization noise and coarse sampling. Entry point:
|
|
6
|
+
:func:`classify_dynamics`.
|
|
7
|
+
|
|
8
|
+
The discriminator is the lag-resolved displacement covariance
|
|
9
|
+
``C_k(dt) = <Delta x_t . Delta x_{t+k}>`` with ``Delta x_t = x_{t+1} - x_t``:
|
|
10
|
+
|
|
11
|
+
* White localization noise (variance ``sigma^2`` per axis) contributes ``+2
|
|
12
|
+
sigma^2`` to ``C0``, ``-sigma^2`` to ``C1`` and **exactly 0 to ``C_{k>=2}``**
|
|
13
|
+
— so lag >= 2 is measurement-noise-immune by construction.
|
|
14
|
+
* A force field cannot fake inertia: for OD the apparent kinetic energy
|
|
15
|
+
``C0/dt^2`` diverges as ``2D/dt`` while the lag-2 velocity correlation stays
|
|
16
|
+
finite, so the noise-corrected ratio ``rho2 = C2 / (C0 + 2 C1)`` (the
|
|
17
|
+
combination ``C0 + 2 C1`` cancels white localization noise exactly) tends to
|
|
18
|
+
0; for UD the displacement is ballistic (``Delta x ~ v dt``) so ``rho2`` rises
|
|
19
|
+
to a positive plateau. Scanning ``dt`` (via
|
|
20
|
+
:meth:`TrajectoryCollection.degrade`) removes the force confound; using lag 2
|
|
21
|
+
removes the noise.
|
|
22
|
+
|
|
23
|
+
The pipeline (see :func:`classify_dynamics`) layers a model-free
|
|
24
|
+
``dt``-scaling test (:func:`_scaling_statistics`), a parametric covariance fit
|
|
25
|
+
(:func:`_fit_dynamics`) and an overdamped-fit residual-autocorrelation
|
|
26
|
+
cross-check (:func:`_cross_check`) into an OD / UD / inconclusive verdict.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Optional
|
|
34
|
+
|
|
35
|
+
import jax.numpy as jnp
|
|
36
|
+
import numpy as np
|
|
37
|
+
from scipy.optimize import least_squares
|
|
38
|
+
|
|
39
|
+
from SFI.diagnostics.report import _to_jsonable
|
|
40
|
+
from SFI.integrate import Integrand, Term, TimeOperand, integrate, timeop
|
|
41
|
+
|
|
42
|
+
# --------------------------------------------------------------------------- #
|
|
43
|
+
# Lag-0/1/2 displacement-covariance time-operators.
|
|
44
|
+
#
|
|
45
|
+
# Each returns a per-particle (..., N, d, d) tensor, mirroring the diffusion /
|
|
46
|
+
# measurement-noise estimators in SFI.inference.overdamped (``_Lambda`` etc.).
|
|
47
|
+
# The dataset stream layer supplies the increments:
|
|
48
|
+
# dX = X[t+1] - X[t] (offset 0..+1)
|
|
49
|
+
# dX_minus = X[t] - X[t-1] (offset -1..0)
|
|
50
|
+
# dX_plus = X[t+2] - X[t+1] (offset +1..+2)
|
|
51
|
+
# so <dX . dX_minus> is the lag-1 covariance and <dX_minus . dX_plus> is the
|
|
52
|
+
# lag-2 covariance (displacements one step apart).
|
|
53
|
+
# --------------------------------------------------------------------------- #
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@timeop(name="dyn_cov_lag0", batch_safe=True)
|
|
57
|
+
def _cov_lag0(**streams):
|
|
58
|
+
"""Lag-0 displacement covariance ``dX (x) dX``."""
|
|
59
|
+
dX = streams["dX"]
|
|
60
|
+
return _outer(dX, dX)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@timeop(name="dyn_cov_lag1", batch_safe=True)
|
|
64
|
+
def _cov_lag1(**streams):
|
|
65
|
+
"""Symmetrised lag-1 covariance ``1/2 (dX (x) dX^- + dX^- (x) dX)``."""
|
|
66
|
+
dX = streams["dX"]
|
|
67
|
+
dXm = streams["dX_minus"]
|
|
68
|
+
return 0.5 * (_outer(dX, dXm) + _outer(dXm, dX))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@timeop(name="dyn_cov_lag2", batch_safe=True)
|
|
72
|
+
def _cov_lag2(**streams):
|
|
73
|
+
"""Symmetrised lag-2 covariance ``1/2 (dX^- (x) dX^+ + dX^+ (x) dX^-)``."""
|
|
74
|
+
dXm = streams["dX_minus"]
|
|
75
|
+
dXp = streams["dX_plus"]
|
|
76
|
+
return 0.5 * (_outer(dXm, dXp) + _outer(dXp, dXm))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_cov_lag0._requires = frozenset({"dX"}) # type: ignore[attr-defined]
|
|
80
|
+
_cov_lag1._requires = frozenset({"dX", "dX_minus"}) # type: ignore[attr-defined]
|
|
81
|
+
_cov_lag2._requires = frozenset({"dX_minus", "dX_plus"}) # type: ignore[attr-defined]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _outer(a, b):
|
|
85
|
+
"""Outer product over the trailing spatial axis: ``a_m b_n``."""
|
|
86
|
+
return jnp.einsum("...m,...n->...mn", a, b)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --------------------------------------------------------------------------- #
|
|
90
|
+
# Covariance backbone
|
|
91
|
+
# --------------------------------------------------------------------------- #
|
|
92
|
+
def _integrate_tensor(coll, op, alias):
|
|
93
|
+
"""Time- and particle-averaged ``(d, d)`` value of a covariance time-op."""
|
|
94
|
+
operand = TimeOperand(op, alias=alias)
|
|
95
|
+
prog = Integrand(times=[operand], terms=[Term(eq="imn->imn", ops=(alias,))])
|
|
96
|
+
return np.asarray(integrate(coll, prog, reduce="mean"))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _mean_dt(coll):
|
|
100
|
+
"""Representative scalar step (uniform within a degraded collection)."""
|
|
101
|
+
dts = []
|
|
102
|
+
for ds in coll.datasets:
|
|
103
|
+
dt = getattr(ds, "dt", None)
|
|
104
|
+
if dt is None:
|
|
105
|
+
t = getattr(ds, "t", None)
|
|
106
|
+
if t is None:
|
|
107
|
+
continue
|
|
108
|
+
dt = np.diff(np.asarray(t))
|
|
109
|
+
dts.append(float(np.mean(np.asarray(dt, dtype=float))))
|
|
110
|
+
if not dts:
|
|
111
|
+
raise ValueError("Cannot determine dt: provide dt or t on the trajectories.")
|
|
112
|
+
return float(np.mean(dts))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _n_eff(coll, require):
|
|
116
|
+
"""Number of valid (masked) increment samples available for ``require``."""
|
|
117
|
+
n = 0
|
|
118
|
+
for ds in coll.datasets:
|
|
119
|
+
idx = ds.valid_indices(frozenset(require))
|
|
120
|
+
n += int(np.asarray(idx).size) * int(ds.N)
|
|
121
|
+
return n
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _increment_covariances(coll):
|
|
125
|
+
"""Lag-0/1/2 displacement covariances of ``coll`` at its native ``dt``.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
coll : TrajectoryCollection
|
|
130
|
+
Trajectories sharing a (uniform) time step.
|
|
131
|
+
|
|
132
|
+
Returns
|
|
133
|
+
-------
|
|
134
|
+
dict
|
|
135
|
+
``C0``, ``C1``, ``C2`` : ``(d, d)`` displacement covariance tensors;
|
|
136
|
+
``dt`` : representative scalar step; ``n_eff`` : valid sample count.
|
|
137
|
+
"""
|
|
138
|
+
C0 = _integrate_tensor(coll, _cov_lag0, "c0")
|
|
139
|
+
C1 = _integrate_tensor(coll, _cov_lag1, "c1")
|
|
140
|
+
C2 = _integrate_tensor(coll, _cov_lag2, "c2")
|
|
141
|
+
return {
|
|
142
|
+
"C0": C0,
|
|
143
|
+
"C1": C1,
|
|
144
|
+
"C2": C2,
|
|
145
|
+
"dt": _mean_dt(coll),
|
|
146
|
+
"n_eff": _n_eff(coll, {"dX_minus", "dX_plus"}),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _covariances_vs_dt(coll, strides):
|
|
151
|
+
"""Lag-0/1/2 covariances across a set of coarse-graining strides.
|
|
152
|
+
|
|
153
|
+
Each stride ``s`` coarse-grains the trajectories via
|
|
154
|
+
:meth:`TrajectoryCollection.degrade` (effective step ``s * dt``), then the
|
|
155
|
+
lag-0/1/2 covariances are recomputed. This is the dt-scan that separates
|
|
156
|
+
the overdamped force confound (vanishes as ``dt -> 0``) from genuine
|
|
157
|
+
inertia (saturates).
|
|
158
|
+
|
|
159
|
+
Parameters
|
|
160
|
+
----------
|
|
161
|
+
coll : TrajectoryCollection
|
|
162
|
+
strides : sequence of int
|
|
163
|
+
Positive coarse-graining factors (``1`` keeps the native step).
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
dict
|
|
168
|
+
``strides``, ``dt`` : ``(S,)`` arrays; ``C0``, ``C1``, ``C2`` :
|
|
169
|
+
``(S, d, d)`` covariance tensors; ``n_eff`` : ``(S,)`` sample counts.
|
|
170
|
+
"""
|
|
171
|
+
strides = [int(s) for s in strides]
|
|
172
|
+
dts, C0s, C1s, C2s, n_effs = [], [], [], [], []
|
|
173
|
+
for s in strides:
|
|
174
|
+
sub = coll if s == 1 else coll.degrade(downsample=s)
|
|
175
|
+
cov = _increment_covariances(sub)
|
|
176
|
+
dts.append(cov["dt"])
|
|
177
|
+
C0s.append(cov["C0"])
|
|
178
|
+
C1s.append(cov["C1"])
|
|
179
|
+
C2s.append(cov["C2"])
|
|
180
|
+
n_effs.append(cov["n_eff"])
|
|
181
|
+
return {
|
|
182
|
+
"strides": np.asarray(strides),
|
|
183
|
+
"dt": np.asarray(dts),
|
|
184
|
+
"C0": np.stack(C0s),
|
|
185
|
+
"C1": np.stack(C1s),
|
|
186
|
+
"C2": np.stack(C2s),
|
|
187
|
+
"n_eff": np.asarray(n_effs),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --------------------------------------------------------------------------- #
|
|
192
|
+
# Parametric forward model for the lag-0/1/2 increment covariances
|
|
193
|
+
# --------------------------------------------------------------------------- #
|
|
194
|
+
def _dynamics_model(h, sigma2, D, V, gamma):
|
|
195
|
+
r"""Predicted scalar (per-axis) lag-0/1/2 increment covariances.
|
|
196
|
+
|
|
197
|
+
Three physical contributions to ``c_k(h) = Cov(dx_t, dx_{t+k})`` for step
|
|
198
|
+
``h``:
|
|
199
|
+
|
|
200
|
+
* **Localization noise** ``sigma^2``: ``+2 sigma^2`` at lag 0, ``-sigma^2``
|
|
201
|
+
at lag 1, ``0`` at lag 2 (white, dt-independent).
|
|
202
|
+
* **Overdamped diffusion** ``D``: ``+2 D h`` at lag 0, ``0`` elsewhere.
|
|
203
|
+
* **Inertia** — an Ornstein--Uhlenbeck velocity with variance ``V = <v^2>``
|
|
204
|
+
and relaxation rate ``gamma`` (so ``tau_v = 1/gamma``). Integrating
|
|
205
|
+
``<v(0) v(t)> = V e^{-gamma|t|}`` over the sampling cells gives
|
|
206
|
+
|
|
207
|
+
.. math::
|
|
208
|
+
|
|
209
|
+
c_0^{inert} &= \tfrac{2V}{\gamma^2}(\gamma h - 1 + e^{-\gamma h}), \\
|
|
210
|
+
c_m^{inert} &= \tfrac{2V}{\gamma^2}(\cosh\gamma h - 1)\,
|
|
211
|
+
e^{-m\,\gamma h} \quad (m \ge 1).
|
|
212
|
+
|
|
213
|
+
As ``gamma h -> 0`` this is ballistic (``c_k -> V h^2``); the lag-2 term
|
|
214
|
+
decays as ``e^{-2 gamma h}``.
|
|
215
|
+
|
|
216
|
+
Parameters
|
|
217
|
+
----------
|
|
218
|
+
h : float or array
|
|
219
|
+
Sampling step(s).
|
|
220
|
+
sigma2, D, V, gamma : float
|
|
221
|
+
Localization-noise variance, diffusion constant, velocity variance,
|
|
222
|
+
and velocity relaxation rate (``gamma > 0``).
|
|
223
|
+
|
|
224
|
+
Returns
|
|
225
|
+
-------
|
|
226
|
+
(c0, c1, c2) : arrays shaped like ``h``.
|
|
227
|
+
"""
|
|
228
|
+
h = np.asarray(h, dtype=float)
|
|
229
|
+
gh = gamma * h
|
|
230
|
+
inv_g2 = 1.0 / (gamma * gamma)
|
|
231
|
+
em1 = -np.expm1(-gh) # = 1 - exp(-gamma h), in [0, 1]; overflow-free
|
|
232
|
+
# (cosh(gamma h) - 1) exp(-gamma h) = (1 - exp(-gamma h))^2 / 2, so:
|
|
233
|
+
g0 = 2.0 * inv_g2 * (gh + np.expm1(-gh)) # = (2/g^2)(gamma h - 1 + e^{-gamma h})
|
|
234
|
+
g1 = inv_g2 * em1 * em1
|
|
235
|
+
g2 = np.exp(-gh) * inv_g2 * em1 * em1
|
|
236
|
+
c0 = 2.0 * sigma2 + 2.0 * D * h + V * g0
|
|
237
|
+
c1 = -sigma2 + V * g1
|
|
238
|
+
c2 = V * g2 + np.zeros_like(h)
|
|
239
|
+
return c0, c1, c2
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --------------------------------------------------------------------------- #
|
|
243
|
+
# Parametric fit of {D, sigma^2, V, gamma} to the covariance scan
|
|
244
|
+
# --------------------------------------------------------------------------- #
|
|
245
|
+
def _scalar_lags(scan):
|
|
246
|
+
"""Isotropic per-axis scalar lags ``c_k = Tr(C_k) / d`` from a scan."""
|
|
247
|
+
d = scan["C0"].shape[-1]
|
|
248
|
+
c0 = np.einsum("sii->s", scan["C0"]) / d
|
|
249
|
+
c1 = np.einsum("sii->s", scan["C1"]) / d
|
|
250
|
+
c2 = np.einsum("sii->s", scan["C2"]) / d
|
|
251
|
+
return c0, c1, c2
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _aicc(ssr, m, k):
|
|
255
|
+
"""Small-sample-corrected AIC for a Gaussian least-squares fit."""
|
|
256
|
+
if m <= 0 or ssr <= 0:
|
|
257
|
+
ssr = max(ssr, 1e-300)
|
|
258
|
+
aic = m * np.log(ssr / m) + 2.0 * k
|
|
259
|
+
denom = m - k - 1
|
|
260
|
+
if denom > 0:
|
|
261
|
+
aic += 2.0 * k * (k + 1) / denom
|
|
262
|
+
return aic
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _lsq_stderr(res, m, n):
|
|
266
|
+
"""Parameter standard errors from a ``least_squares`` result."""
|
|
267
|
+
J = np.asarray(res.jac, dtype=float)
|
|
268
|
+
JTJ = J.T @ J
|
|
269
|
+
try:
|
|
270
|
+
cov = np.linalg.inv(JTJ)
|
|
271
|
+
except np.linalg.LinAlgError:
|
|
272
|
+
cov = np.linalg.pinv(JTJ)
|
|
273
|
+
dof = max(m - n, 1)
|
|
274
|
+
s2 = 2.0 * res.cost / dof
|
|
275
|
+
return np.sqrt(np.clip(np.diag(cov) * s2, 0.0, None))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _fit_dynamics(scan):
|
|
279
|
+
"""Fit the diffusion + inertia + localization model to a covariance scan.
|
|
280
|
+
|
|
281
|
+
Fits the four-parameter model ``{sigma^2, D, V, gamma}`` and the nested
|
|
282
|
+
overdamped null ``V = 0`` (``{sigma^2, D}``), and compares them with the
|
|
283
|
+
small-sample-corrected AIC.
|
|
284
|
+
|
|
285
|
+
Returns
|
|
286
|
+
-------
|
|
287
|
+
dict
|
|
288
|
+
``params`` (``sigma2, D, V, gamma``), ``tau_v = 1/gamma``, ``stderr``
|
|
289
|
+
(per parameter), ``V_z`` (``V / stderr_V``), ``aicc_full``,
|
|
290
|
+
``aicc_od``, ``delta_aicc`` (``aicc_od - aicc_full``; ``> 0`` favours
|
|
291
|
+
the inertial model), and the raw ``ssr_full`` / ``ssr_od``.
|
|
292
|
+
"""
|
|
293
|
+
h = np.asarray(scan["dt"], dtype=float)
|
|
294
|
+
c0, c1, c2 = _scalar_lags(scan)
|
|
295
|
+
n_eff = np.asarray(scan["n_eff"], dtype=float)
|
|
296
|
+
w = np.sqrt(np.clip(n_eff, 1.0, None))
|
|
297
|
+
|
|
298
|
+
data = np.concatenate([c0, c1, c2])
|
|
299
|
+
wvec = np.concatenate([w, w, w])
|
|
300
|
+
scale = max(float(np.median(np.abs(c0))), 1e-12)
|
|
301
|
+
|
|
302
|
+
def _residuals(sigma2, D, V, gamma):
|
|
303
|
+
m0, m1, m2 = _dynamics_model(h, sigma2, D, V, gamma)
|
|
304
|
+
model = np.concatenate([m0, m1, m2])
|
|
305
|
+
return wvec * (model - data) / scale
|
|
306
|
+
|
|
307
|
+
# --- initial guesses from the lag structure ---------------------------- #
|
|
308
|
+
gamma_lo = 1e-3 / float(h.max())
|
|
309
|
+
gamma_hi = 1e3 / float(h.min())
|
|
310
|
+
V0 = max(c2[0] / h[0] ** 2, 1e-6)
|
|
311
|
+
s20 = max(-c1[0] + V0 * h[0] ** 2, 1e-9)
|
|
312
|
+
g_init = min(max(1.0 / float(np.median(h)), gamma_lo * 1.01), gamma_hi * 0.99)
|
|
313
|
+
g0_tail = (2.0 / g_init**2) * (g_init * h[-1] - 1.0 + np.exp(-g_init * h[-1]))
|
|
314
|
+
D0 = max((c0[-1] - 2.0 * s20 - V0 * g0_tail) / (2.0 * h[-1]), 1e-6)
|
|
315
|
+
|
|
316
|
+
res_full = least_squares(
|
|
317
|
+
lambda p: _residuals(*p),
|
|
318
|
+
x0=[s20, D0, V0, g_init],
|
|
319
|
+
bounds=([0.0, 0.0, 0.0, gamma_lo], [np.inf, np.inf, np.inf, gamma_hi]),
|
|
320
|
+
method="trf",
|
|
321
|
+
max_nfev=5000,
|
|
322
|
+
)
|
|
323
|
+
sigma2, D, V, gamma = (float(x) for x in res_full.x)
|
|
324
|
+
|
|
325
|
+
res_od = least_squares(
|
|
326
|
+
lambda p: _residuals(p[0], p[1], 0.0, 1.0),
|
|
327
|
+
x0=[s20, D0],
|
|
328
|
+
bounds=([0.0, 0.0], [np.inf, np.inf]),
|
|
329
|
+
method="trf",
|
|
330
|
+
max_nfev=5000,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
m = data.size
|
|
334
|
+
ssr_full = 2.0 * float(res_full.cost)
|
|
335
|
+
ssr_od = 2.0 * float(res_od.cost)
|
|
336
|
+
aicc_full = _aicc(ssr_full, m, 4)
|
|
337
|
+
aicc_od = _aicc(ssr_od, m, 2)
|
|
338
|
+
stderr = _lsq_stderr(res_full, m, 4)
|
|
339
|
+
V_stderr = float(stderr[2]) if stderr[2] > 0 else 0.0
|
|
340
|
+
V_z = V / V_stderr if V_stderr > 0 else np.inf
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
"params": {"sigma2": sigma2, "D": D, "V": V, "gamma": gamma},
|
|
344
|
+
"tau_v": 1.0 / gamma,
|
|
345
|
+
"stderr": {"sigma2": float(stderr[0]), "D": float(stderr[1]), "V": V_stderr, "gamma": float(stderr[3])},
|
|
346
|
+
"V_z": V_z,
|
|
347
|
+
"ssr_full": ssr_full,
|
|
348
|
+
"ssr_od": ssr_od,
|
|
349
|
+
"aicc_full": aicc_full,
|
|
350
|
+
"aicc_od": aicc_od,
|
|
351
|
+
"delta_aicc": aicc_od - aicc_full,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# --------------------------------------------------------------------------- #
|
|
356
|
+
# Model-free scaling statistics (Layer 1) and verdict
|
|
357
|
+
# --------------------------------------------------------------------------- #
|
|
358
|
+
def _scaling_statistics(scan):
|
|
359
|
+
r"""Noise-immune lag-2 persistence and apparent-KE scaling across ``dt``.
|
|
360
|
+
|
|
361
|
+
The Vestergaard combination ``c0 + 2 c1`` cancels white localization noise
|
|
362
|
+
exactly (``2 sigma^2 + 2(-sigma^2) = 0``), leaving the noise-free variance.
|
|
363
|
+
From it:
|
|
364
|
+
|
|
365
|
+
* ``rho2 = c2 / (c0 + 2 c1)`` — noise-immune normalized lag-2 correlation;
|
|
366
|
+
``-> 0`` for overdamped (a force only adds an ``O(h^2)`` piece to a
|
|
367
|
+
variance that grows like ``h``) and to a positive plateau for inertia.
|
|
368
|
+
* ``K = (c0 + 2 c1) / h^2`` — noise-corrected apparent kinetic energy;
|
|
369
|
+
log-log slope ``beta -> -1`` (overdamped, ``K ~ 2D/h``) vs ``-> 0``
|
|
370
|
+
(underdamped, ``K -> const``) at fine sampling.
|
|
371
|
+
|
|
372
|
+
Returns
|
|
373
|
+
-------
|
|
374
|
+
dict
|
|
375
|
+
``rho2``, ``K`` : ``(S,)`` arrays; ``noise_free_var`` : ``(S,)``;
|
|
376
|
+
``beta`` : log-log slope of ``K`` vs ``dt`` (OLS over positive ``K``).
|
|
377
|
+
"""
|
|
378
|
+
h = np.asarray(scan["dt"], dtype=float)
|
|
379
|
+
c0, c1, c2 = _scalar_lags(scan)
|
|
380
|
+
noise_free_var = c0 + 2.0 * c1
|
|
381
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
382
|
+
rho2 = np.where(np.abs(noise_free_var) > 0, c2 / noise_free_var, 0.0)
|
|
383
|
+
K = noise_free_var / h**2
|
|
384
|
+
good = K > 0
|
|
385
|
+
beta = float(np.polyfit(np.log(h[good]), np.log(K[good]), 1)[0]) if good.sum() >= 2 else float("nan")
|
|
386
|
+
return {"rho2": rho2, "K": K, "noise_free_var": noise_free_var, "beta": beta}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
#: Verdict thresholds (documented; the full report exposes the raw statistics).
|
|
390
|
+
_AICC_MARGIN = 2.0 # AICc gain required to prefer the inertial model
|
|
391
|
+
_V_Z_MIN = 3.0 # significance of the fitted velocity variance V
|
|
392
|
+
_RHO2_UD = 0.15 # lag-2 persistence (at finest dt) confirming resolved inertia
|
|
393
|
+
_RHO2_OD = 0.05 # lag-2 persistence below which the data looks overdamped
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _decide_verdict(fit, scaling, scan):
|
|
397
|
+
"""Combine the parametric fit and the scaling test into a verdict.
|
|
398
|
+
|
|
399
|
+
Returns ``"UD"`` when the inertial model is decisively preferred *and* the
|
|
400
|
+
momentum is resolved (significant, persistent lag-2 correlation at the
|
|
401
|
+
finest step); ``"OD"`` when there is no inertial signal; ``"inconclusive"``
|
|
402
|
+
in the marginal band — typically coarse sampling (``gamma * dt >~ 1``),
|
|
403
|
+
where the data is on the boundary of being effectively overdamped.
|
|
404
|
+
"""
|
|
405
|
+
h = np.asarray(scan["dt"], dtype=float)
|
|
406
|
+
rho2_fine = float(scaling["rho2"][int(np.argmin(h))])
|
|
407
|
+
ud_preferred = (fit["delta_aicc"] > _AICC_MARGIN) and (fit["V_z"] > _V_Z_MIN)
|
|
408
|
+
|
|
409
|
+
if ud_preferred and rho2_fine > _RHO2_UD:
|
|
410
|
+
return "UD"
|
|
411
|
+
if (not ud_preferred) or rho2_fine < _RHO2_OD:
|
|
412
|
+
return "OD"
|
|
413
|
+
return "inconclusive"
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# --------------------------------------------------------------------------- #
|
|
417
|
+
# Layer 3: overdamped-fit residual-autocorrelation cross-check
|
|
418
|
+
# --------------------------------------------------------------------------- #
|
|
419
|
+
def _cross_check(data, *, force_order: int = 3):
|
|
420
|
+
"""Corroborate the verdict via an overdamped fit's residual autocorrelation.
|
|
421
|
+
|
|
422
|
+
Fits a flexible (cubic by default) overdamped force and runs the existing
|
|
423
|
+
Ljung--Box residual-autocorrelation test from
|
|
424
|
+
:func:`SFI.diagnostics.assess`. An overdamped model cannot capture
|
|
425
|
+
momentum, so on underdamped data its residuals stay serially correlated and
|
|
426
|
+
the test fires. This is *corroboration only*: a low p-value flags that an
|
|
427
|
+
overdamped model leaves structure — usually inertia, but a force too
|
|
428
|
+
complex for the cubic basis would also trip it — so the verdict itself rests
|
|
429
|
+
on the noise-immune scaling test and the parametric fit, not on this.
|
|
430
|
+
|
|
431
|
+
Best-effort: returns ``ljung_box_pvalue = None`` (with an ``error``) if the
|
|
432
|
+
quick fit fails for any reason.
|
|
433
|
+
"""
|
|
434
|
+
try:
|
|
435
|
+
from SFI.bases.constants import unit_vector_basis
|
|
436
|
+
from SFI.bases.monomials import monomials_up_to
|
|
437
|
+
from SFI.diagnostics.assess import assess
|
|
438
|
+
from SFI.inference.overdamped import OverdampedLangevinInference
|
|
439
|
+
|
|
440
|
+
d = int(data.datasets[0].d)
|
|
441
|
+
inf = OverdampedLangevinInference(data)
|
|
442
|
+
inf.compute_diffusion_constant(method="auto")
|
|
443
|
+
basis = monomials_up_to(order=force_order, dim=d, include_constant=True, include_x=True, include_v=False)
|
|
444
|
+
inf.infer_force_linear(basis * unit_vector_basis(d))
|
|
445
|
+
inf.compute_force_error()
|
|
446
|
+
report = assess(inf, level="standard")
|
|
447
|
+
lb = report.residuals.get("autocorr", {}).get("ljung_box", {})
|
|
448
|
+
return {"ljung_box_pvalue": lb.get("pvalue"), "ljung_box_stat": lb.get("statistic")}
|
|
449
|
+
except Exception as exc: # pragma: no cover - corroboration only
|
|
450
|
+
return {"ljung_box_pvalue": None, "error": repr(exc)}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# --------------------------------------------------------------------------- #
|
|
454
|
+
# Public report + entry point
|
|
455
|
+
# --------------------------------------------------------------------------- #
|
|
456
|
+
def _num_frames(ds):
|
|
457
|
+
T = getattr(ds, "T", None)
|
|
458
|
+
if T is None:
|
|
459
|
+
T = np.asarray(ds.X).shape[0]
|
|
460
|
+
return int(T)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _default_strides(coll, max_strides=6, min_samples=200):
|
|
464
|
+
"""Geometric strides ``1, 2, 4, ...`` capped so the coarsest keeps stats."""
|
|
465
|
+
T = min(_num_frames(ds) for ds in coll.datasets)
|
|
466
|
+
strides, s = [], 1
|
|
467
|
+
while len(strides) < max_strides and (T // s) >= min_samples:
|
|
468
|
+
strides.append(s)
|
|
469
|
+
s *= 2
|
|
470
|
+
return strides or [1]
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@dataclass
|
|
474
|
+
class DynamicsOrderReport:
|
|
475
|
+
"""Result of :func:`classify_dynamics`.
|
|
476
|
+
|
|
477
|
+
Attributes
|
|
478
|
+
----------
|
|
479
|
+
verdict : str
|
|
480
|
+
``"OD"``, ``"UD"`` or ``"inconclusive"``.
|
|
481
|
+
fit : dict
|
|
482
|
+
Parametric fit output (``params`` = ``sigma2, D, V, gamma``, ``tau_v``,
|
|
483
|
+
``stderr``, ``V_z``, ``delta_aicc`` and the raw SSR / AICc values).
|
|
484
|
+
scaling : dict
|
|
485
|
+
Model-free statistics (``rho2``, ``K``, ``beta``).
|
|
486
|
+
scan : dict
|
|
487
|
+
Lag-0/1/2 covariances across the dt scan (``strides``, ``dt``, ``C0``,
|
|
488
|
+
``C1``, ``C2``, ``n_eff``).
|
|
489
|
+
cross_check : dict or None
|
|
490
|
+
Overdamped-fit Ljung--Box result, or ``None`` if disabled.
|
|
491
|
+
meta : dict
|
|
492
|
+
Sampling summary (``d``, ``n_datasets``, ``strides``, ``dt_min/max``,
|
|
493
|
+
``gamma_dt_min``).
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
verdict: str
|
|
497
|
+
fit: dict = field(default_factory=dict)
|
|
498
|
+
scaling: dict = field(default_factory=dict)
|
|
499
|
+
scan: dict = field(default_factory=dict)
|
|
500
|
+
cross_check: Optional[dict] = None
|
|
501
|
+
meta: dict = field(default_factory=dict)
|
|
502
|
+
|
|
503
|
+
def to_dict(self) -> dict:
|
|
504
|
+
"""JSON-serialisable representation of the report."""
|
|
505
|
+
return _to_jsonable(
|
|
506
|
+
{
|
|
507
|
+
"verdict": self.verdict,
|
|
508
|
+
"fit": self.fit,
|
|
509
|
+
"scaling": self.scaling,
|
|
510
|
+
"scan": self.scan,
|
|
511
|
+
"cross_check": self.cross_check,
|
|
512
|
+
"meta": self.meta,
|
|
513
|
+
}
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
def to_json(self, indent: int = 2) -> str:
|
|
517
|
+
return json.dumps(self.to_dict(), indent=indent, default=str)
|
|
518
|
+
|
|
519
|
+
def print_summary(self) -> None:
|
|
520
|
+
"""Print a human-readable summary of the classification."""
|
|
521
|
+
p = self.fit.get("params", {})
|
|
522
|
+
sc = self.scaling
|
|
523
|
+
meta = self.meta
|
|
524
|
+
print("\n=== SFI dynamics-order classification ===")
|
|
525
|
+
print(f"verdict : {self.verdict}")
|
|
526
|
+
if meta:
|
|
527
|
+
print(
|
|
528
|
+
f"sampling: d={meta.get('d', '?')}, "
|
|
529
|
+
f"dt in [{meta.get('dt_min', float('nan')):.4g}, "
|
|
530
|
+
f"{meta.get('dt_max', float('nan')):.4g}] over "
|
|
531
|
+
f"{len(meta.get('strides', []))} strides"
|
|
532
|
+
)
|
|
533
|
+
if p:
|
|
534
|
+
sigma = float(np.sqrt(max(p.get("sigma2", 0.0), 0.0)))
|
|
535
|
+
print("\n-- parametric fit (diffusion + inertia + localization) --")
|
|
536
|
+
print(f" tau_v = 1/gamma = {self.fit.get('tau_v', float('nan')):.4g} (momentum relaxation time)")
|
|
537
|
+
print(
|
|
538
|
+
f" <v^2> = {p.get('V', float('nan')):.4g} "
|
|
539
|
+
f"D = {p.get('D', float('nan')):.4g} sigma_loc = {sigma:.4g}"
|
|
540
|
+
)
|
|
541
|
+
print(
|
|
542
|
+
f" AICc(OD) - AICc(UD) = {self.fit.get('delta_aicc', float('nan')):+.2f} "
|
|
543
|
+
f"(>0 favours inertia) V/stderr = {self.fit.get('V_z', float('nan')):.2f}"
|
|
544
|
+
)
|
|
545
|
+
if sc:
|
|
546
|
+
rho2 = np.asarray(sc.get("rho2", []))
|
|
547
|
+
rfine = float(rho2[0]) if rho2.size else float("nan")
|
|
548
|
+
print("\n-- model-free scaling (noise-immune at lag>=2) --")
|
|
549
|
+
print(f" rho2(finest dt) = {rfine:.3f} (OD -> 0, UD -> positive)")
|
|
550
|
+
print(f" apparent-KE log-log slope = {sc.get('beta', float('nan')):+.2f} (OD -> -1, UD -> 0)")
|
|
551
|
+
if self.cross_check and self.cross_check.get("ljung_box_pvalue") is not None:
|
|
552
|
+
print("\n-- cross-check (OD-fit residual autocorrelation) --")
|
|
553
|
+
print(
|
|
554
|
+
f" Ljung-Box p = {self.cross_check['ljung_box_pvalue']:.2e} "
|
|
555
|
+
"(small => overdamped model leaves structure)"
|
|
556
|
+
)
|
|
557
|
+
if self.verdict == "inconclusive":
|
|
558
|
+
gdt = meta.get("gamma_dt_min")
|
|
559
|
+
tail = f" (gamma*dt_min ~ {gdt:.2f}); sample finer than tau_v to decide." if gdt else "."
|
|
560
|
+
print("\n Note: momentum only marginally resolved" + tail)
|
|
561
|
+
print()
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def classify_dynamics(data, *, strides=None, cross_check: bool = True) -> DynamicsOrderReport:
|
|
565
|
+
"""Classify trajectory data as overdamped, underdamped, or inconclusive.
|
|
566
|
+
|
|
567
|
+
Robust to high localization noise (lag >= 2 covariances are noise-immune)
|
|
568
|
+
and reports *inconclusive* at coarse sampling where the momentum is
|
|
569
|
+
unresolved (``gamma * dt >~ 1``), rather than guessing.
|
|
570
|
+
|
|
571
|
+
Parameters
|
|
572
|
+
----------
|
|
573
|
+
data : TrajectoryCollection
|
|
574
|
+
Raw trajectories (positions). Velocities are *not* required — the test
|
|
575
|
+
reconstructs the dynamics order from position increments alone.
|
|
576
|
+
strides : sequence of int, optional
|
|
577
|
+
Coarse-graining factors for the dt scan. Default: a geometric set
|
|
578
|
+
``1, 2, 4, ...`` capped so the coarsest still has enough samples.
|
|
579
|
+
cross_check : bool
|
|
580
|
+
Run the Layer-3 overdamped-fit residual-autocorrelation corroboration
|
|
581
|
+
(default ``True``).
|
|
582
|
+
|
|
583
|
+
Returns
|
|
584
|
+
-------
|
|
585
|
+
DynamicsOrderReport
|
|
586
|
+
|
|
587
|
+
Notes
|
|
588
|
+
-----
|
|
589
|
+
Assumes *white* localization noise; spatially uniform, isotropic pooling of
|
|
590
|
+
components. Strong memory (a generalized Langevin / viscoelastic bath) can
|
|
591
|
+
also produce velocity persistence without inertia — out of scope; the test
|
|
592
|
+
detects trajectory smoothness, which inertia produces.
|
|
593
|
+
|
|
594
|
+
Examples
|
|
595
|
+
--------
|
|
596
|
+
>>> report = classify_dynamics(collection) # doctest: +SKIP
|
|
597
|
+
>>> report.verdict # doctest: +SKIP
|
|
598
|
+
'UD'
|
|
599
|
+
"""
|
|
600
|
+
if strides is None:
|
|
601
|
+
strides = _default_strides(data)
|
|
602
|
+
strides = [int(s) for s in strides]
|
|
603
|
+
|
|
604
|
+
scan = _covariances_vs_dt(data, strides)
|
|
605
|
+
fit = _fit_dynamics(scan)
|
|
606
|
+
scaling = _scaling_statistics(scan)
|
|
607
|
+
verdict = _decide_verdict(fit, scaling, scan)
|
|
608
|
+
cross = _cross_check(data) if cross_check else None
|
|
609
|
+
|
|
610
|
+
dt = np.asarray(scan["dt"], dtype=float)
|
|
611
|
+
meta = {
|
|
612
|
+
"d": int(data.datasets[0].d),
|
|
613
|
+
"n_datasets": len(data.datasets),
|
|
614
|
+
"strides": [int(s) for s in scan["strides"]],
|
|
615
|
+
"dt_min": float(dt.min()),
|
|
616
|
+
"dt_max": float(dt.max()),
|
|
617
|
+
"gamma_dt_min": float(fit["params"]["gamma"] * dt.min()),
|
|
618
|
+
}
|
|
619
|
+
return DynamicsOrderReport(
|
|
620
|
+
verdict=verdict, fit=fit, scaling=scaling, scan=scan, cross_check=cross, meta=meta
|
|
621
|
+
)
|