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,1214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import jax.numpy as jnp
|
|
6
|
+
|
|
7
|
+
from SFI.bases.constants import constant_array
|
|
8
|
+
from SFI.inference.base import BaseLangevinInference
|
|
9
|
+
from SFI.integrate.api import integrate
|
|
10
|
+
from SFI.integrate.integrand import (
|
|
11
|
+
ConstOperand,
|
|
12
|
+
ExprOperand,
|
|
13
|
+
Integrand,
|
|
14
|
+
Term,
|
|
15
|
+
TimeOperand,
|
|
16
|
+
)
|
|
17
|
+
from SFI.integrate.timeops import stream, timeop, velocity
|
|
18
|
+
from SFI.statefunc import PSF, SF, Basis
|
|
19
|
+
from SFI.utils.maths import sqrtm_psd
|
|
20
|
+
|
|
21
|
+
from .result import InferenceResultSF # fitted SF with param_cov/meta
|
|
22
|
+
from .sparse import SparseScorer
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------- #
|
|
28
|
+
# OLI linear force-inference presets
|
|
29
|
+
# ---------------------------------------------------------------------------- #
|
|
30
|
+
# A single ``preset`` keyword bundles the (M_mode, G_mode) convention surface,
|
|
31
|
+
# mirroring the underdamped engine. ``'auto'`` keys off measurement noise; the
|
|
32
|
+
# ``legacy-*`` presets reproduce published SFI v1.0 conventions.
|
|
33
|
+
_FORCE_LINEAR_PRESETS: dict[str, tuple[str, str]] = {
|
|
34
|
+
# noise-robust default: Stratonovich moments + noise-decorrelating Gram
|
|
35
|
+
"robust": ("Strato", "shift"),
|
|
36
|
+
# sharper for verified clean / fine-sampled data
|
|
37
|
+
"clean": ("Ito", "trapeze"),
|
|
38
|
+
# Kramers–Moyal: plain Itô finite-difference moments, rectangle Gram
|
|
39
|
+
"KM": ("Ito", "rectangle"),
|
|
40
|
+
# SFI v1.0 (2020 PRX): Stratonovich moments, rectangle Gram
|
|
41
|
+
"legacy-sfi-v1.0": ("Strato", "rectangle"),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolve_force_linear_preset(
|
|
46
|
+
preset: str,
|
|
47
|
+
*,
|
|
48
|
+
M_mode: str | None = None,
|
|
49
|
+
G_mode: str | None = None,
|
|
50
|
+
noise_detected: bool | None = None,
|
|
51
|
+
) -> tuple[str, str, str]:
|
|
52
|
+
"""Resolve an overdamped force ``preset`` to a concrete ``(M_mode, G_mode)``.
|
|
53
|
+
|
|
54
|
+
``'auto'`` selects ``'robust'`` when measurement noise is detected and
|
|
55
|
+
``'clean'`` otherwise. Any non-``None`` ``M_mode`` / ``G_mode`` (other than
|
|
56
|
+
the legacy ``M_mode='auto'`` synonym) overrides the preset on that axis.
|
|
57
|
+
Returns ``(M_mode, G_mode, resolved_preset)``.
|
|
58
|
+
"""
|
|
59
|
+
if preset == "auto":
|
|
60
|
+
if noise_detected is None:
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
"preset='auto' needs the measurement-noise estimate; call "
|
|
63
|
+
"compute_diffusion_constant() first, or pass an explicit preset."
|
|
64
|
+
)
|
|
65
|
+
base = "robust" if noise_detected else "clean"
|
|
66
|
+
elif preset in _FORCE_LINEAR_PRESETS:
|
|
67
|
+
base = preset
|
|
68
|
+
else:
|
|
69
|
+
choices = ", ".join(repr(p) for p in ("auto", *_FORCE_LINEAR_PRESETS))
|
|
70
|
+
raise ValueError(f"unknown preset {preset!r}; choose from {choices}")
|
|
71
|
+
M0, G0 = _FORCE_LINEAR_PRESETS[base]
|
|
72
|
+
M = M0 if M_mode in (None, "auto") else M_mode
|
|
73
|
+
G = G0 if G_mode is None else G_mode
|
|
74
|
+
return M, G, base
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class OverdampedLangevinInference(BaseLangevinInference):
|
|
78
|
+
r"""Stochastic Force Inference concrete class for overdamped systems
|
|
79
|
+
|
|
80
|
+
This class provides tools for inferring force (drift) and
|
|
81
|
+
diffusion tensors from stochastic trajectory data based on overdamped
|
|
82
|
+
Langevin dynamics. It supports both linear and nonlinear basis
|
|
83
|
+
function methods.
|
|
84
|
+
|
|
85
|
+
Core Equation
|
|
86
|
+
~~~~~~~~~~~~~
|
|
87
|
+
The dynamics are described by the 1st order autonomous stochastic
|
|
88
|
+
differential equation (SDE)::
|
|
89
|
+
|
|
90
|
+
dx/dt = F(x) + sqrt(2D(x)) dxi(t)
|
|
91
|
+
|
|
92
|
+
where:
|
|
93
|
+
|
|
94
|
+
- ``F(x)`` is the Ito drift (force) term.
|
|
95
|
+
- ``D(x)`` is the diffusion tensor, evaluated in the Ito convention.
|
|
96
|
+
- ``dxi(t)`` is Gaussian white noise.
|
|
97
|
+
|
|
98
|
+
.. physics:: Overdamped Langevin SDE
|
|
99
|
+
:label: overdamped-langevin-sde
|
|
100
|
+
:category: Dynamical equations
|
|
101
|
+
|
|
102
|
+
.. math::
|
|
103
|
+
|
|
104
|
+
\frac{\mathrm{d}x}{\mathrm{d}t}
|
|
105
|
+
= F(x) + \sqrt{2\,D(x)}\;\mathrm{d}\xi(t)
|
|
106
|
+
|
|
107
|
+
:math:`F(x)` is the Itô drift, :math:`D(x)` the diffusion tensor
|
|
108
|
+
(Itô convention), and :math:`\mathrm{d}\xi` is Gaussian white noise.
|
|
109
|
+
|
|
110
|
+
Here x is a 2D array of shape Nparticles x dimension. All particles
|
|
111
|
+
are assumed to have identical properties.
|
|
112
|
+
|
|
113
|
+
This class provides tools to approximate F(x) and D(x) from a time series x(t) formatted as TrajectoryCollection.
|
|
114
|
+
|
|
115
|
+
Note that the `Ito` and `Strato` variants of the force inference
|
|
116
|
+
routines do NOT refer to the convention in which the SDE is
|
|
117
|
+
expressed (which is always Ito), but to the way stochastic
|
|
118
|
+
integrals are performed to compute parameters.
|
|
119
|
+
|
|
120
|
+
Key Features
|
|
121
|
+
~~~~~~~~~~~~
|
|
122
|
+
- Force Inference:
|
|
123
|
+
- Linear combination of basis functions (`infer_force_linear`).
|
|
124
|
+
- Parametric families (`infer_force` with a `Basis` or `PSF`).
|
|
125
|
+
- Diffusion Inference:
|
|
126
|
+
- Constant diffusion estimation (`compute_diffusion_constant`).
|
|
127
|
+
- State-dependent diffusion with basis functions (`infer_diffusion_linear` and `infer_diffusion`).
|
|
128
|
+
- Sparsification:
|
|
129
|
+
- Force sparsification for linear inference `sparsify_force`, implementing PASTIS and other information criteria.
|
|
130
|
+
- Error Estimation:
|
|
131
|
+
- Normalized mean-squared error (MSE) prediction for both force and diffusion.
|
|
132
|
+
- Comparison Tools for method benchmarking:
|
|
133
|
+
- Evaluate inferred fields against known exact models (`compare_to_exact`).
|
|
134
|
+
- Simulation:
|
|
135
|
+
- Generate trajectories using inferred fields (`simulate_bootstrapped_trajectory`).
|
|
136
|
+
|
|
137
|
+
Workflow
|
|
138
|
+
~~~~~~~~
|
|
139
|
+
1. Initialize with `TrajectoryCollection` containing the trajectory.
|
|
140
|
+
2. Use the `infer_*` methods to infer force and diffusion fields.
|
|
141
|
+
3. Optionally compute error estimates and/or compare with exact data for validation.
|
|
142
|
+
|
|
143
|
+
Indices Convention
|
|
144
|
+
~~~~~~~~~~~~~~~~~~
|
|
145
|
+
The code uses jnp.einsum for array manipulation, with a consistent index naming scheme for clarity:
|
|
146
|
+
- `t` : Time index, = 0..Ntimesteps-1
|
|
147
|
+
- `a, b, c...` : Basis function indices, = 0..Nfunctions - 1.
|
|
148
|
+
- `m, n, o...` : Spatial indices, = 0..dim-1.
|
|
149
|
+
- `i, j...` : Particle indices.
|
|
150
|
+
We also use these indices as shortcuts for array shapes. For
|
|
151
|
+
instance `basis_linear : im -> iam` reads `basis_linear has input
|
|
152
|
+
a jnp.array of shape (Nparticles,dim) and outputs a jnp.array of
|
|
153
|
+
shape (Nparticles,Nfunctions,dim)`.
|
|
154
|
+
|
|
155
|
+
Logging
|
|
156
|
+
~~~~~~~
|
|
157
|
+
Progress messages use Python ``logging``. Enable with
|
|
158
|
+
``SFI.enable_logging()`` or ``logging.getLogger('SFI').setLevel(logging.INFO)``.
|
|
159
|
+
|
|
160
|
+
Example
|
|
161
|
+
~~~~~~~
|
|
162
|
+
Fully documented examples in the "examples" folder: Lorenz model, ActiveBrownianParticles, Ornstein-Uhlenbeck...
|
|
163
|
+
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def compute_diffusion_constant(self, method: str = "auto") -> None:
|
|
167
|
+
"""Estimate a constant (spatially uniform) diffusion matrix.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
method : {"auto", "noisy", "WeakNoise", "MSD"}
|
|
172
|
+
Estimator to use. ``"noisy"`` is the noise-robust
|
|
173
|
+
Vestergaard–Blainey–Flyvbjerg estimator. ``"auto"`` selects
|
|
174
|
+
``"noisy"`` when the measurement-noise trace Tr(Λ) > 0
|
|
175
|
+
(localization noise detected), and ``"WeakNoise"`` otherwise.
|
|
176
|
+
|
|
177
|
+
Updates
|
|
178
|
+
-------
|
|
179
|
+
Sets ``diffusion_average``, ``diffusion_inferred``, ``A``,
|
|
180
|
+
``A_inv``, ``sqrtA``, ``sqrtA_inv``, ``Lambda``,
|
|
181
|
+
``Lambda_trace``, and ``metadata["diffusion_constant_method"]``.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
if hasattr(self, "diffusion_average"):
|
|
185
|
+
raise RuntimeError("Diffusion already computed; create a new inference object to recompute.")
|
|
186
|
+
|
|
187
|
+
# 1) measurement noise Λ
|
|
188
|
+
L_op = self.get_diffusion_timeop("Lambda")
|
|
189
|
+
L_prog = Integrand(times=[L_op], terms=[Term(eq="imn->imn", ops=(L_op.alias,))])
|
|
190
|
+
# Diffusion/noise are per-point quantities: each increment's estimate has
|
|
191
|
+
# constant relative variance regardless of dt, so combine them per-point
|
|
192
|
+
# (weight_by_dt=False), not dt-weighted (which is the force convention).
|
|
193
|
+
self.Lambda = integrate(self.data, L_prog, reduce="mean", weight_by_dt=False,
|
|
194
|
+
chunk_target_bytes=self._chunk_target_bytes)
|
|
195
|
+
self.Lambda_trace = float(jnp.trace(self.Lambda))
|
|
196
|
+
logger.info("Measurement noise trace: %s", self.Lambda_trace)
|
|
197
|
+
|
|
198
|
+
# 2) select instantaneous estimator
|
|
199
|
+
if method == "auto":
|
|
200
|
+
method = "noisy" if self.Lambda_trace > 0 else "WeakNoise"
|
|
201
|
+
self.metadata["diffusion_constant_method"] = method
|
|
202
|
+
|
|
203
|
+
# 3) time-average instantaneous diffusion
|
|
204
|
+
D_op = self.get_diffusion_timeop(method)
|
|
205
|
+
D_prog = Integrand(times=[D_op], terms=[Term(eq="imn->imn", ops=(D_op.alias,))])
|
|
206
|
+
self.diffusion_average = integrate(
|
|
207
|
+
self.data, D_prog, reduce="mean", weight_by_dt=False,
|
|
208
|
+
chunk_target_bytes=self._chunk_target_bytes,
|
|
209
|
+
)
|
|
210
|
+
self.diffusion_inferred = constant_array(self.diffusion_average)
|
|
211
|
+
|
|
212
|
+
# 4) normalization matrices
|
|
213
|
+
self.A = 2.0 * self.diffusion_average
|
|
214
|
+
self.A_inv = jnp.linalg.inv(self.A)
|
|
215
|
+
self.sqrtA = sqrtm_psd(self.A)
|
|
216
|
+
self.sqrtA_inv = jnp.linalg.inv(self.sqrtA)
|
|
217
|
+
|
|
218
|
+
def infer_force_linear(
|
|
219
|
+
self,
|
|
220
|
+
basis: Basis,
|
|
221
|
+
*,
|
|
222
|
+
preset: str = "auto",
|
|
223
|
+
M_mode: str | None = None,
|
|
224
|
+
G_mode: str | None = None,
|
|
225
|
+
):
|
|
226
|
+
r"""Infer the force field as a linear combination of basis functions (linear regression).
|
|
227
|
+
|
|
228
|
+
.. physics:: Linear force regression (overdamped)
|
|
229
|
+
:label: linear-force-regression-overdamped
|
|
230
|
+
:category: Inference
|
|
231
|
+
|
|
232
|
+
.. math::
|
|
233
|
+
|
|
234
|
+
\hat F(x) = \sum_a C_a\, b_a(x)
|
|
235
|
+
\qquad\text{where}\qquad
|
|
236
|
+
G\,C = M
|
|
237
|
+
|
|
238
|
+
:math:`G_{ab} = \langle b_a(x_t)\, b_b(x_t) \rangle` is the Gram
|
|
239
|
+
matrix, :math:`M_a = \langle v_t \cdot A^{-1} \cdot b_a \rangle` are
|
|
240
|
+
the force moments, and :math:`A = 2\bar D`.
|
|
241
|
+
|
|
242
|
+
This method computes the force field coefficients (`self.force_coefficients`) using
|
|
243
|
+
the provided Basis object. The force field is represented as:
|
|
244
|
+
|
|
245
|
+
inferred_force(x) = sum_a basis_linear(x)[:,a] * force_coefficients[a]
|
|
246
|
+
|
|
247
|
+
These coefficients are computed by solving a linear system::
|
|
248
|
+
|
|
249
|
+
G . force_coefficients = force_moments
|
|
250
|
+
|
|
251
|
+
and the different options account for the manner to compute G
|
|
252
|
+
and force_moments. In its simplest form::
|
|
253
|
+
|
|
254
|
+
G_ab = < b_a(xt) b_b(xt) > [G_mode = 'rectangle']
|
|
255
|
+
force_moments[a] = < dX[t]/dt b_a(xt) > [mode = 'Ito' ]
|
|
256
|
+
|
|
257
|
+
but this is rarely the best choice of parameters.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
|
|
261
|
+
basis: Basis
|
|
262
|
+
The fitting functions, encoded as a single callable Basis
|
|
263
|
+
object (see SFI.statefunc submodule for doc and SFI.bases for examples).
|
|
264
|
+
|
|
265
|
+
preset (str):
|
|
266
|
+
Single-keyword convention bundle (mirrors the underdamped
|
|
267
|
+
engine), default ``'auto'``. ``'auto'`` picks ``'robust'``
|
|
268
|
+
when measurement noise is detected (Tr(Λ) > 0) and ``'clean'``
|
|
269
|
+
otherwise. Presets: ``'robust'`` (Stratonovich moments +
|
|
270
|
+
``'shift'`` Gram, noise-robust); ``'clean'`` (Itô +
|
|
271
|
+
``'trapeze'``, clean / fine-sampled data); ``'KM'``
|
|
272
|
+
(Kramers–Moyal: Itô + ``'rectangle'``); ``'legacy-sfi-v1.0'``
|
|
273
|
+
(Stratonovich + ``'rectangle'``, the published SFI v1.0
|
|
274
|
+
convention).
|
|
275
|
+
|
|
276
|
+
M_mode (str, optional):
|
|
277
|
+
Override the preset's moment convention: ``'Ito'``,
|
|
278
|
+
``'Ito-shift'``, or ``'Strato'``. ``None`` (default) uses the
|
|
279
|
+
preset.
|
|
280
|
+
|
|
281
|
+
G_mode (str, optional):
|
|
282
|
+
Override the preset's Gram normalization: ``'rectangle'`` (2020
|
|
283
|
+
PRX), ``'trapeze'`` (2024 Amiri et al. PRR), or ``'shift'``
|
|
284
|
+
(``<b_a(xt) b_b(x_t+dt)>``, decorrelates measurement noise).
|
|
285
|
+
``None`` (default) uses the preset.
|
|
286
|
+
|
|
287
|
+
Outputs:
|
|
288
|
+
Updates the following attributes:
|
|
289
|
+
- self.force_scorer: SparseScorer for model selection.
|
|
290
|
+
- self.force_coefficients: The inferred coefficients for the basis functions.
|
|
291
|
+
- self.force_inferred: Callable function representing the inferred force field.
|
|
292
|
+
- self.force_G: The normalization matrix used in the inference process.
|
|
293
|
+
|
|
294
|
+
"""
|
|
295
|
+
noise = self.Lambda_trace > 0.0 if hasattr(self, "Lambda_trace") else None
|
|
296
|
+
M_mode, G_mode, _resolved_preset = _resolve_force_linear_preset(
|
|
297
|
+
preset, M_mode=M_mode, G_mode=G_mode, noise_detected=noise,
|
|
298
|
+
)
|
|
299
|
+
logger.info(
|
|
300
|
+
"Force inference: preset=%s -> M_mode=%s, G_mode=%s (Lambda trace: %s)",
|
|
301
|
+
_resolved_preset, M_mode, G_mode, getattr(self, "Lambda_trace", None),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
self._validate_basis(basis, expected_rank=1, label="force basis")
|
|
305
|
+
|
|
306
|
+
self.__force_M_mode__ = M_mode
|
|
307
|
+
self.__force_G_mode__ = G_mode
|
|
308
|
+
self.force_basis = basis
|
|
309
|
+
self.metadata["force_preset"] = _resolved_preset
|
|
310
|
+
self.metadata["force_M_mode"] = M_mode
|
|
311
|
+
self.metadata["force_G_mode"] = G_mode
|
|
312
|
+
|
|
313
|
+
if hasattr(self, "force_G_full"):
|
|
314
|
+
raise RuntimeError("Force has already been inferred on this object - create a new instance to re-infer.")
|
|
315
|
+
|
|
316
|
+
# Structural (CSR/stencil) arrays are exposed only for this evaluation and
|
|
317
|
+
# never persisted on the dataset (see ``_structural_scope``).
|
|
318
|
+
with self._structural_scope(self.force_basis):
|
|
319
|
+
self.force_G_full = self._force_G_matrix()
|
|
320
|
+
self.force_moments = self._force_moments()
|
|
321
|
+
|
|
322
|
+
self.force_scorer = SparseScorer(M=self.force_moments, G=self.force_G_full)
|
|
323
|
+
self._update_force_coefficients(self.force_scorer.total_C)
|
|
324
|
+
self.metadata["force_method"] = "linear"
|
|
325
|
+
|
|
326
|
+
def infer_diffusion_linear(
|
|
327
|
+
self,
|
|
328
|
+
basis: Basis = None,
|
|
329
|
+
*,
|
|
330
|
+
M_mode: str = "auto",
|
|
331
|
+
G_mode: str = "rectangle",
|
|
332
|
+
) -> None:
|
|
333
|
+
"""
|
|
334
|
+
Fit the diffusion field as a linear combination of basis functions.
|
|
335
|
+
|
|
336
|
+
This method computes the coefficients of the diffusion tensor field (`self.diffusion_coefficients`) using
|
|
337
|
+
the provided basis functions. The diffusion tensor is represented as:
|
|
338
|
+
|
|
339
|
+
diffusion_inferred(x, mask) = sum_a basis_linear(x, mask)[:,a] * diffusion_coefficients[a]
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
basis (Basis with rank = 2 or None): the fitting functions. When ``None``
|
|
343
|
+
(default), ``symmetric_matrix_basis(d)`` is used, spanning all constant
|
|
344
|
+
symmetric ``d×d`` diffusion matrices. Requires a prior
|
|
345
|
+
``compute_diffusion_constant()`` call to determine ``d``.
|
|
346
|
+
|
|
347
|
+
M_mode (str):
|
|
348
|
+
The method used for local diffusion tensor estimation and moments computation.
|
|
349
|
+
See _diffusion_estimator documentation for additional information.
|
|
350
|
+
|
|
351
|
+
G_mode (str):
|
|
352
|
+
The method used to compute the normalization matrix `G`.
|
|
353
|
+
Not investigated extensively yet for diffusion inference.
|
|
354
|
+
|
|
355
|
+
Updates:
|
|
356
|
+
self.diffusion_coefficients: The inferred coefficients for the diffusion basis functions.
|
|
357
|
+
self.diffusion_inferred: Callable representing the inferred diffusion tensor field.
|
|
358
|
+
self.diffusion_G: The normalization matrix used in the inference process.
|
|
359
|
+
|
|
360
|
+
Note:
|
|
361
|
+
This inferred tensor field is not guaranteed to be nonnegative.
|
|
362
|
+
"""
|
|
363
|
+
if basis is None:
|
|
364
|
+
if not hasattr(self, "diffusion_average"):
|
|
365
|
+
raise RuntimeError(
|
|
366
|
+
"infer_diffusion_linear() with no basis requires a prior "
|
|
367
|
+
"compute_diffusion_constant() call to determine spatial dimension."
|
|
368
|
+
)
|
|
369
|
+
from SFI.bases import symmetric_matrix_basis
|
|
370
|
+
basis = symmetric_matrix_basis(self.diffusion_average.shape[0])
|
|
371
|
+
|
|
372
|
+
if M_mode == "auto":
|
|
373
|
+
if self.Lambda_trace > 0.0:
|
|
374
|
+
M_mode = "noisy"
|
|
375
|
+
G_mode = "rectangle"
|
|
376
|
+
else:
|
|
377
|
+
M_mode = "WeakNoise"
|
|
378
|
+
G_mode = "rectangle"
|
|
379
|
+
logger.info(
|
|
380
|
+
"Auto-selecting diffusion inference: M_mode %s, G_mode %s (Lambda trace: %s)",
|
|
381
|
+
M_mode,
|
|
382
|
+
G_mode,
|
|
383
|
+
self.Lambda_trace,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
self._validate_basis(basis, expected_rank=2, label="diffusion basis")
|
|
387
|
+
|
|
388
|
+
self.diffusion_basis = basis
|
|
389
|
+
self.__diffusion_M_mode__ = M_mode
|
|
390
|
+
self.__diffusion_G_mode__ = G_mode
|
|
391
|
+
self.metadata["diffusion_M_mode"] = M_mode
|
|
392
|
+
self.metadata["diffusion_G_mode"] = G_mode
|
|
393
|
+
|
|
394
|
+
if hasattr(self, "diffusion_moments"):
|
|
395
|
+
raise RuntimeError(
|
|
396
|
+
"Diffusion has already been inferred on this object - create a new instance to re-infer."
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
with self._structural_scope(self.diffusion_basis):
|
|
400
|
+
self.diffusion_G_full = self._diffusion_G_matrix()
|
|
401
|
+
self.diffusion_moments = self._diffusion_moments()
|
|
402
|
+
|
|
403
|
+
self.diffusion_scorer = SparseScorer(M=self.diffusion_moments, G=self.diffusion_G_full)
|
|
404
|
+
self._update_diffusion_coefficients(self.diffusion_scorer.total_C)
|
|
405
|
+
|
|
406
|
+
def simulate_bootstrapped_trajectory(self, key, oversampling=1, simulate=True, dataset=0):
|
|
407
|
+
"""
|
|
408
|
+
Simulate an overdamped Langevin trajectory with the inferred force and diffusion fields.
|
|
409
|
+
|
|
410
|
+
This function generates a trajectory using the inferred force field and diffusion tensor inferred
|
|
411
|
+
from the input data, matching the original time series and initial conditions.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
key: JAX random key for generating noise in the simulation.
|
|
415
|
+
oversampling (int, optional): Factor for oversampling (i.e. number of intermediate simulated
|
|
416
|
+
points between two recorded points). Defaults to 1.
|
|
417
|
+
simulate: if True, performs the simulation with the first data point as initial position;
|
|
418
|
+
if False, returns an uninitialized object which can be simulated with flexible
|
|
419
|
+
initial position and parameters.
|
|
420
|
+
dataset (int, optional): Which experiment of a pooled fit to reproduce. The inferred
|
|
421
|
+
model is collapsed to this condition via
|
|
422
|
+
:meth:`~SFI.statefunc.StateExpr.specialize` (folding ``per_dataset_scalar`` /
|
|
423
|
+
``dataset_indicator`` at ``dataset``); the resulting process is a standalone
|
|
424
|
+
single-condition model that does not read ``dataset_index``. Defaults to 0.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
OverdampedProcess: Simulated Langevin process object.
|
|
428
|
+
"""
|
|
429
|
+
from SFI.langevin import OverdampedProcess
|
|
430
|
+
|
|
431
|
+
# Collapse a (possibly pooled) fit to the chosen experiment: per-dataset
|
|
432
|
+
# parameters are folded at ``dataset`` and the reserved ``dataset_index``
|
|
433
|
+
# disappears, so the simulated model is standalone (no dataset concept).
|
|
434
|
+
force_k = self.force_inferred.specialize(dataset=int(dataset))
|
|
435
|
+
diff_k = self.diffusion_inferred.specialize(dataset=int(dataset))
|
|
436
|
+
bootstrapped_process = OverdampedProcess(force_k._psf, diff_k._psf)
|
|
437
|
+
bootstrapped_process.set_params(theta_F=force_k.params, theta_D=diff_k.params)
|
|
438
|
+
ds = self.data.datasets[int(dataset)]
|
|
439
|
+
bootstrapped_process.set_extras(extras_global=ds.extras_global, extras_local=ds.extras_local)
|
|
440
|
+
# Only the particle count is framework-supplied; the specialized
|
|
441
|
+
# force does not read ``dataset_index``.
|
|
442
|
+
bootstrapped_process._reserved_overrides = {
|
|
443
|
+
"particle_index": jnp.arange(int(ds.N)),
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if simulate:
|
|
447
|
+
from SFI.trajectory import TrajectoryCollection
|
|
448
|
+
|
|
449
|
+
start_config = TrajectoryCollection.from_dataset(ds).peek_row(require={"X", "dt"})
|
|
450
|
+
x0 = jnp.asarray(start_config["X"])
|
|
451
|
+
if not jnp.all(jnp.isfinite(x0)):
|
|
452
|
+
x0 = self._find_finite_x0()
|
|
453
|
+
bootstrapped_process.initialize(x0)
|
|
454
|
+
data_bootstrap = bootstrapped_process.simulate(
|
|
455
|
+
dt=start_config["dt"],
|
|
456
|
+
Nsteps=ds.T,
|
|
457
|
+
key=key,
|
|
458
|
+
prerun=0,
|
|
459
|
+
oversampling=oversampling,
|
|
460
|
+
)
|
|
461
|
+
return data_bootstrap, bootstrapped_process
|
|
462
|
+
return bootstrapped_process
|
|
463
|
+
|
|
464
|
+
# ── Parametric (windowed) force inference ──
|
|
465
|
+
|
|
466
|
+
def infer_force(
|
|
467
|
+
self,
|
|
468
|
+
F,
|
|
469
|
+
theta0=None,
|
|
470
|
+
*,
|
|
471
|
+
D=None,
|
|
472
|
+
Lambda=None,
|
|
473
|
+
integrator: str = "rk4",
|
|
474
|
+
n_substeps: int = 1,
|
|
475
|
+
inner: str = "auto",
|
|
476
|
+
eiv="auto",
|
|
477
|
+
max_outer: int = 5,
|
|
478
|
+
inner_maxiter: int = 80,
|
|
479
|
+
extra_radius: int = 1,
|
|
480
|
+
) -> None:
|
|
481
|
+
r"""Infer the force field with the minimal parametric estimator.
|
|
482
|
+
|
|
483
|
+
Built on ``SFI.inference.parametric_core``: a single RK4 flow step
|
|
484
|
+
per observation interval defines the residual, the residual
|
|
485
|
+
covariance gives a windowed-precision NLL, and the parameters are
|
|
486
|
+
found by direct Gauss–Newton (linear-in-θ ``Basis``, with the
|
|
487
|
+
skip-trick errors-in-variables instrument) or frozen-precision
|
|
488
|
+
L-BFGS (nonlinear-in-θ ``PSF``). ``(D, Λ)`` are profiled
|
|
489
|
+
natively: moment-estimator init, then one windowed
|
|
490
|
+
conditional-NLL refinement at the fitted θ.
|
|
491
|
+
|
|
492
|
+
.. physics:: Parametric windowed force inference (overdamped)
|
|
493
|
+
:label: parametric-force-overdamped
|
|
494
|
+
:category: Inference
|
|
495
|
+
|
|
496
|
+
The observed positions follow
|
|
497
|
+
:math:`y_t = x_t + \eta_t` where
|
|
498
|
+
:math:`\eta \sim \mathcal{N}(0, \Lambda)`. The
|
|
499
|
+
deterministic flow
|
|
500
|
+
:math:`\Phi(x;\theta) = z(\Delta t) - x`
|
|
501
|
+
(one RK4 step by default) defines the residual
|
|
502
|
+
:math:`r_t = y_{t+1} - y_t - \Phi(y_t;\theta)`.
|
|
503
|
+
Residuals follow a banded Gaussian whose local precision
|
|
504
|
+
weights the Gauss–Newton normal equations; under
|
|
505
|
+
measurement noise the left factor is replaced by the
|
|
506
|
+
η-clean *skip* instrument
|
|
507
|
+
:math:`\psi_{\rm inst} = \partial\Phi/\partial\theta`
|
|
508
|
+
evaluated at the lagged clean point (``eiv=True``),
|
|
509
|
+
giving a consistent estimating equation.
|
|
510
|
+
|
|
511
|
+
Parameters
|
|
512
|
+
----------
|
|
513
|
+
F : PSF or Basis
|
|
514
|
+
Parametric drift model. A ``Basis`` is converted to a PSF
|
|
515
|
+
internally (coefficients initialised to zero) and PASTIS
|
|
516
|
+
sparsification is enabled; it runs the fast direct-GN path.
|
|
517
|
+
A ``PSF`` (possibly nonlinear in θ) runs the L-BFGS path.
|
|
518
|
+
theta0 : dict, array, or None
|
|
519
|
+
Initial drift parameters (default: zeros).
|
|
520
|
+
D : array (d, d), optional
|
|
521
|
+
Fixed diffusion matrix. If both ``D`` and ``Lambda``
|
|
522
|
+
are given, noise profiling is skipped entirely (fast path).
|
|
523
|
+
Lambda : array (d, d), optional
|
|
524
|
+
Fixed measurement-noise covariance.
|
|
525
|
+
integrator : {"rk4", "euler"}
|
|
526
|
+
Flow predictor (default ``"rk4"``, a single 4th-order step).
|
|
527
|
+
n_substeps : int
|
|
528
|
+
Integrator micro-steps per observation interval (default 1 —
|
|
529
|
+
the single-step minimal estimator).
|
|
530
|
+
inner : {"auto", "gn", "lbfgs"}
|
|
531
|
+
Inner solver. ``"auto"`` → direct Gauss–Newton for a linear
|
|
532
|
+
``Basis``, L-BFGS for a ``PSF``.
|
|
533
|
+
eiv : {"auto", True, False, float}
|
|
534
|
+
Measurement-noise errors-in-variables instrument. ``"auto"``
|
|
535
|
+
(default) → ``True`` for all models (interacting models use
|
|
536
|
+
the same N-body flow for the instrument as for the residual);
|
|
537
|
+
``True`` forces the η-clean skip instrument (consistent under
|
|
538
|
+
noise); ``False`` is the plain MLE; a float in ``[0, 1]``
|
|
539
|
+
blends. Active on the GN path only.
|
|
540
|
+
max_outer : int
|
|
541
|
+
Outer Gauss–Newton / IRLS iterations (default 5).
|
|
542
|
+
inner_maxiter : int
|
|
543
|
+
Inner L-BFGS iterations per outer step on the PSF path
|
|
544
|
+
(default 80; raise for large nonlinear families, e.g. NNs).
|
|
545
|
+
extra_radius : int
|
|
546
|
+
Precision-window padding beyond the covariance bandwidth
|
|
547
|
+
(default 1). Raise to 2–3 in the noise-dominated regime
|
|
548
|
+
β = Tr(Λ)/Tr(2DΔt) ≫ 1, where the windowed precision
|
|
549
|
+
decays slowly and the default window under-resolves it.
|
|
550
|
+
|
|
551
|
+
Updates
|
|
552
|
+
-------
|
|
553
|
+
Sets standard ``force_*`` attributes:
|
|
554
|
+
``force_inferred``, ``force_psf``, ``force_G``,
|
|
555
|
+
``force_G_pinv``, ``force_coefficients_full``,
|
|
556
|
+
``force_coefficients``, ``force_support``,
|
|
557
|
+
``force_moments``.
|
|
558
|
+
|
|
559
|
+
Also sets ``diffusion_average``, ``A``, ``A_inv``, ``Lambda``
|
|
560
|
+
from the profiled ``(D, Λ)``.
|
|
561
|
+
|
|
562
|
+
When ``F`` is a ``Basis``, additionally sets
|
|
563
|
+
``force_basis``, ``force_G_full``, and ``force_scorer``
|
|
564
|
+
so that ``sparsify_force()`` can be called afterwards.
|
|
565
|
+
|
|
566
|
+
See Also
|
|
567
|
+
--------
|
|
568
|
+
:ref:`parametric-concept` : Mathematical foundations.
|
|
569
|
+
:ref:`parametric-algorithm` : Detailed algorithm description.
|
|
570
|
+
"""
|
|
571
|
+
import time as _time
|
|
572
|
+
|
|
573
|
+
from SFI.inference.parametric_core.solve import _as_psf, solve_force_od
|
|
574
|
+
|
|
575
|
+
if hasattr(self, "force_inferred"):
|
|
576
|
+
raise RuntimeError("Force inference has already been run on this object.")
|
|
577
|
+
|
|
578
|
+
if integrator not in ("rk4", "euler"):
|
|
579
|
+
raise ValueError(f"integrator must be 'rk4' or 'euler', got {integrator!r}")
|
|
580
|
+
|
|
581
|
+
# ── Accept Basis → convert to PSF, enable PASTIS ──
|
|
582
|
+
basis_mode = isinstance(F, Basis)
|
|
583
|
+
if basis_mode:
|
|
584
|
+
self.force_basis = F
|
|
585
|
+
elif not (hasattr(F, "flatten_params") and hasattr(F, "unflatten_params")):
|
|
586
|
+
raise TypeError("F must be a PSF or a Basis. Got %s." % type(F).__name__)
|
|
587
|
+
F_psf = _as_psf(F)
|
|
588
|
+
|
|
589
|
+
with self._structural_scope(F_psf):
|
|
590
|
+
dt_val = float(self.data.peek_row(require={"dt"})["dt"])
|
|
591
|
+
|
|
592
|
+
logger.info(
|
|
593
|
+
"[infer_force] OD minimal parametric solve (n_params=%d, dt=%.4g, %s·n%d, basis_mode=%s)...",
|
|
594
|
+
int(F_psf.template.size), dt_val, integrator, n_substeps, basis_mode,
|
|
595
|
+
)
|
|
596
|
+
t0 = _time.perf_counter()
|
|
597
|
+
|
|
598
|
+
res = solve_force_od(
|
|
599
|
+
self.data, F, theta0=theta0, D=D, Lambda=Lambda,
|
|
600
|
+
integrator=integrator, n_substeps=n_substeps,
|
|
601
|
+
inner=inner, eiv=eiv, max_outer=max_outer,
|
|
602
|
+
inner_maxiter=inner_maxiter, extra_radius=extra_radius,
|
|
603
|
+
)
|
|
604
|
+
t_elapsed = _time.perf_counter() - t0
|
|
605
|
+
|
|
606
|
+
# ── Store results (standard force_* attributes) ──
|
|
607
|
+
theta_flat = res.theta
|
|
608
|
+
G = res.G
|
|
609
|
+
|
|
610
|
+
self.force_psf = F_psf
|
|
611
|
+
self.force_G = G
|
|
612
|
+
# Parameter covariance from the solver: the sandwich G⁻¹HG⁻ᵀ on the
|
|
613
|
+
# IV (eiv) path, the inverse information on the symmetric path.
|
|
614
|
+
G_inv = None
|
|
615
|
+
if bool(jnp.all(jnp.isfinite(res.theta_cov))):
|
|
616
|
+
G_inv = res.theta_cov
|
|
617
|
+
self.force_G_pinv = G_inv
|
|
618
|
+
|
|
619
|
+
self.force_coefficients_full = theta_flat
|
|
620
|
+
self.force_coefficients = theta_flat
|
|
621
|
+
self.force_support = jnp.arange(theta_flat.size)
|
|
622
|
+
self.force_moments = G @ theta_flat
|
|
623
|
+
|
|
624
|
+
# ── PASTIS plumbing (Basis mode only) ──
|
|
625
|
+
if basis_mode:
|
|
626
|
+
from SFI.inference.sparse import SparseScorer
|
|
627
|
+
|
|
628
|
+
self.force_G_full = G
|
|
629
|
+
self.force_scorer = SparseScorer(
|
|
630
|
+
M=self.force_moments,
|
|
631
|
+
G=self.force_G_full,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
self.diffusion_average = res.D
|
|
635
|
+
# Callable constant-D field, so the full result surface (incl.
|
|
636
|
+
# simulate_bootstrapped_trajectory) works without a separate
|
|
637
|
+
# compute_diffusion_constant() call; a later infer_diffusion()
|
|
638
|
+
# overwrites it with the state-dependent fit.
|
|
639
|
+
self.diffusion_inferred = constant_array(res.D)
|
|
640
|
+
self.A = 2.0 * res.D
|
|
641
|
+
self.A_inv = jnp.linalg.inv(self.A)
|
|
642
|
+
self.Lambda = res.Lambda
|
|
643
|
+
self.Lambda_trace = float(jnp.trace(res.Lambda))
|
|
644
|
+
|
|
645
|
+
beta = float(jnp.trace(res.Lambda) / (jnp.trace(res.D) * dt_val + 1e-30))
|
|
646
|
+
|
|
647
|
+
sf_F = SF(F_psf, F_psf.unflatten_params(theta_flat))
|
|
648
|
+
meta_F = dict(
|
|
649
|
+
kind="force",
|
|
650
|
+
inference="parametric",
|
|
651
|
+
n_params=int(F_psf.template.size),
|
|
652
|
+
beta=beta,
|
|
653
|
+
A_inv=jnp.asarray(self.A_inv),
|
|
654
|
+
)
|
|
655
|
+
self.force_inferred = InferenceResultSF(
|
|
656
|
+
sf_F,
|
|
657
|
+
param_cov=G_inv,
|
|
658
|
+
meta=meta_F,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
self.metadata["force_method"] = "parametric"
|
|
662
|
+
self.metadata["force_parametric_info"] = {
|
|
663
|
+
**res.info,
|
|
664
|
+
"integrator": integrator,
|
|
665
|
+
"beta": beta,
|
|
666
|
+
"D_matrix": res.D,
|
|
667
|
+
"Lambda": res.Lambda,
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
logger.info(
|
|
671
|
+
"[infer_force] Done in %.1f s. Λ=%s, D=%s, β=%.3f",
|
|
672
|
+
t_elapsed,
|
|
673
|
+
jnp.diag(res.Lambda),
|
|
674
|
+
jnp.diag(res.D),
|
|
675
|
+
beta,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
# ── State-dependent diffusion from parametric residuals ──
|
|
679
|
+
|
|
680
|
+
def infer_diffusion(
|
|
681
|
+
self,
|
|
682
|
+
basis=None,
|
|
683
|
+
*,
|
|
684
|
+
theta_D0=None,
|
|
685
|
+
integrator: str = "rk4",
|
|
686
|
+
n_substeps: int = 1,
|
|
687
|
+
maxiter: int = 100,
|
|
688
|
+
) -> None:
|
|
689
|
+
r"""Infer state-dependent diffusion D(x) from parametric residuals.
|
|
690
|
+
|
|
691
|
+
Requires a prior parametric :meth:`infer_force` call. Holds the
|
|
692
|
+
fitted force fixed and minimises the windowed conditional NLL over
|
|
693
|
+
the diffusion parameters (the log-det term makes the diffusion
|
|
694
|
+
level identifiable), reusing the same flow residuals and integrate
|
|
695
|
+
engine as the force solve.
|
|
696
|
+
|
|
697
|
+
.. physics:: State-dependent diffusion inference (overdamped)
|
|
698
|
+
:label: parametric-diffusion-overdamped
|
|
699
|
+
:category: Inference
|
|
700
|
+
|
|
701
|
+
With the force :math:`\hat F` held fixed, the state-dependent
|
|
702
|
+
diffusion :math:`D(x;\theta_D)` is optimised by minimising the
|
|
703
|
+
windowed conditional negative log-likelihood; :math:`\Lambda`
|
|
704
|
+
from the force inference is held fixed. A rank-2 basis gives
|
|
705
|
+
:math:`D(x) = \sum_j (\theta_D)_j\, d_j(x)`; a PSF is evaluated
|
|
706
|
+
directly.
|
|
707
|
+
|
|
708
|
+
Parameters
|
|
709
|
+
----------
|
|
710
|
+
basis : Basis (rank 2), PSF, or None
|
|
711
|
+
Diffusion model. A rank-2 ``Basis`` gives the linear
|
|
712
|
+
parameterisation; a ``PSF`` is used directly
|
|
713
|
+
(``D(x) = PSF(x; θ_D)``). When ``None`` (default),
|
|
714
|
+
``symmetric_matrix_basis(d)`` — all constant symmetric
|
|
715
|
+
``d×d`` diffusion matrices.
|
|
716
|
+
theta_D0 : dict, array, or None
|
|
717
|
+
Initial diffusion parameters (default zeros).
|
|
718
|
+
integrator : {"rk4", "euler"}
|
|
719
|
+
Flow predictor (default ``"rk4"``, matching :meth:`infer_force`).
|
|
720
|
+
n_substeps : int
|
|
721
|
+
Integrator micro-steps per Δt (default 1).
|
|
722
|
+
maxiter : int
|
|
723
|
+
L-BFGS maximum iterations (default 100).
|
|
724
|
+
|
|
725
|
+
Updates
|
|
726
|
+
-------
|
|
727
|
+
Sets ``diffusion_inferred``, ``diffusion_coefficients`` (and
|
|
728
|
+
``diffusion_basis`` when ``basis`` is a ``Basis``), plus metadata.
|
|
729
|
+
|
|
730
|
+
See Also
|
|
731
|
+
--------
|
|
732
|
+
:ref:`parametric-algorithm` : Full algorithm description.
|
|
733
|
+
"""
|
|
734
|
+
from SFI.bases import symmetric_matrix_basis
|
|
735
|
+
from SFI.inference.parametric_core.solve import _as_psf, solve_diffusion_od
|
|
736
|
+
|
|
737
|
+
if not hasattr(self, "force_inferred"):
|
|
738
|
+
raise RuntimeError("infer_diffusion() requires a prior infer_force() call.")
|
|
739
|
+
if self.metadata.get("force_method") not in ("parametric", "parametric_core"):
|
|
740
|
+
raise RuntimeError(
|
|
741
|
+
"infer_diffusion() requires parametric force inference (call infer_force(), not infer_force_linear)."
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
d = self.data.datasets[0].X.shape[-1]
|
|
745
|
+
if basis is None:
|
|
746
|
+
basis = symmetric_matrix_basis(d)
|
|
747
|
+
if isinstance(basis, Basis):
|
|
748
|
+
self._validate_basis(basis, expected_rank=2, label="diffusion basis")
|
|
749
|
+
self.diffusion_basis = basis
|
|
750
|
+
D_psf = _as_psf(basis)
|
|
751
|
+
Lambda = getattr(self, "Lambda", None)
|
|
752
|
+
if Lambda is None:
|
|
753
|
+
Lambda = jnp.zeros((d, d))
|
|
754
|
+
|
|
755
|
+
res = solve_diffusion_od(
|
|
756
|
+
self.data, self.force_psf, self.force_coefficients_full, D_psf,
|
|
757
|
+
Lambda=Lambda, theta_D0=theta_D0,
|
|
758
|
+
n_substeps=n_substeps, integrator=integrator, maxiter=maxiter,
|
|
759
|
+
)
|
|
760
|
+
theta_D = res.theta_D
|
|
761
|
+
self.diffusion_coefficients = theta_D
|
|
762
|
+
self.diffusion_coefficients_full = theta_D
|
|
763
|
+
sf_D = SF(D_psf, D_psf.unflatten_params(theta_D))
|
|
764
|
+
meta_D = dict(
|
|
765
|
+
kind="diffusion",
|
|
766
|
+
inference="parametric",
|
|
767
|
+
n_params=int(D_psf.template.size),
|
|
768
|
+
nll=res.info["nll"],
|
|
769
|
+
)
|
|
770
|
+
self.diffusion_inferred = InferenceResultSF(
|
|
771
|
+
sf_D,
|
|
772
|
+
param_cov=None,
|
|
773
|
+
meta=meta_D,
|
|
774
|
+
)
|
|
775
|
+
self.metadata["diffusion_method"] = "parametric"
|
|
776
|
+
self.metadata["diffusion_parametric_info"] = dict(res.info)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
#################################################################
|
|
780
|
+
################ BACKEND ############################
|
|
781
|
+
#################################################################
|
|
782
|
+
|
|
783
|
+
# Hooks required by BaseLangevinInference:
|
|
784
|
+
def _force_G_matrix(self) -> jnp.ndarray:
|
|
785
|
+
b_left = self.force_basis
|
|
786
|
+
b_right = self.force_basis @ self.A_inv
|
|
787
|
+
return self.__G_matrix__(b_left, b_right, self.__force_G_mode__, "ima,imb->iab")
|
|
788
|
+
|
|
789
|
+
def _force_moments(self):
|
|
790
|
+
r"""
|
|
791
|
+
Compute force moments ⟨ v · A_inv · b ⟩ with Ito / Ito-shift / Strato flavors.
|
|
792
|
+
|
|
793
|
+
.. physics:: Overdamped force moments (linear regression)
|
|
794
|
+
:label: force-moments-overdamped
|
|
795
|
+
:category: Inference
|
|
796
|
+
|
|
797
|
+
**Itô moments:**
|
|
798
|
+
|
|
799
|
+
.. math::
|
|
800
|
+
|
|
801
|
+
M_a = \bigl\langle v_t \cdot A^{-1} \cdot b_a(X_t) \bigr\rangle
|
|
802
|
+
|
|
803
|
+
**Stratonovich moments** (trapezoid + gradient correction):
|
|
804
|
+
|
|
805
|
+
.. math::
|
|
806
|
+
|
|
807
|
+
M_a^{\text{S}} = \tfrac{1}{2}\bigl\langle v_t \cdot A^{-1}
|
|
808
|
+
\cdot \bigl[b_a(X_t) + b_a(X_{t+1})\bigr] \bigr\rangle
|
|
809
|
+
\;-\; \bigl\langle D_{\text{inst}} : (A^{-1} \cdot \nabla_x b_a) \bigr\rangle
|
|
810
|
+
|
|
811
|
+
The force coefficients solve :math:`G \cdot C = M`
|
|
812
|
+
where :math:`G_{ab} = \langle b_a \cdot b_b \rangle` (with mode variants).
|
|
813
|
+
|
|
814
|
+
Contractions
|
|
815
|
+
------------
|
|
816
|
+
- RHS (Ito or Ito-shift):
|
|
817
|
+
eq='im,mn,ina->ia' with ops (V, A, B)
|
|
818
|
+
shapes:
|
|
819
|
+
V: (N, m) from velocity(dX, dt)
|
|
820
|
+
A: (m, n) constant A_inv
|
|
821
|
+
B: (i, n, a) basis at X or X_minus, features last
|
|
822
|
+
|
|
823
|
+
- Stratonovich v∘b:
|
|
824
|
+
trapezoid average over X and X_plus, same rhs contraction on each, then 0.5*(...+...)
|
|
825
|
+
|
|
826
|
+
- Stratonovich gradient correction:
|
|
827
|
+
eq='imn,ioma,no->ia' with ops (D, G, A)
|
|
828
|
+
shapes:
|
|
829
|
+
D: (i, m, n) instantaneous diffusion (N, d, d) e.g. noisy
|
|
830
|
+
G: (i, o, m, a) basis.d_x()(X) shape (N, d_deriv, d_force, F)
|
|
831
|
+
A: (m, n) A_inv (d, d)
|
|
832
|
+
|
|
833
|
+
Masking
|
|
834
|
+
-------
|
|
835
|
+
Mask is applied by the integrator on the leading particle axis i,
|
|
836
|
+
and forwarded to state-expression calls via `mask_out`. The leaf
|
|
837
|
+
fill policy (`zerostop` by default) replaces masked entries with 0
|
|
838
|
+
via `jnp.where`, which naturally gives zero tangents for masked
|
|
839
|
+
entries without blocking the Jacobian for active entries.
|
|
840
|
+
"""
|
|
841
|
+
if not hasattr(self, "A_inv"):
|
|
842
|
+
raise RuntimeError("A_inv not available. Compute diffusion first.")
|
|
843
|
+
|
|
844
|
+
# Common pieces
|
|
845
|
+
A = ConstOperand(self.A_inv, alias="A")
|
|
846
|
+
V = TimeOperand(velocity("dX", "dt"), alias="V")
|
|
847
|
+
|
|
848
|
+
mode = getattr(self, "__force_M_mode__", "Ito")
|
|
849
|
+
if mode not in ("Ito", "Ito-shift", "Strato"):
|
|
850
|
+
raise KeyError(f"Unknown __force_M_mode__: {mode}")
|
|
851
|
+
|
|
852
|
+
if mode in ("Ito", "Ito-shift"):
|
|
853
|
+
x_key = "X_minus" if mode == "Ito-shift" else "X"
|
|
854
|
+
B = ExprOperand(expr=self.force_basis, x=stream(x_key), alias="B")
|
|
855
|
+
prog = Integrand(
|
|
856
|
+
exprs=[B],
|
|
857
|
+
times=[V],
|
|
858
|
+
consts=[A],
|
|
859
|
+
terms=[Term(eq="im,mn,ina->ia", ops=("V", "A", "B"))],
|
|
860
|
+
)
|
|
861
|
+
logger.debug(
|
|
862
|
+
"Computing Ito-shift force coefficients."
|
|
863
|
+
if mode == "Ito-shift"
|
|
864
|
+
else "Computing Ito force coefficients."
|
|
865
|
+
)
|
|
866
|
+
return integrate(self.data, prog, reduce="sum", chunk_target_bytes=self._chunk_target_bytes)
|
|
867
|
+
|
|
868
|
+
# Stratonovich
|
|
869
|
+
logger.debug("Computing Strato force coefficients.")
|
|
870
|
+
|
|
871
|
+
# v ∘ b via trapezoid on basis
|
|
872
|
+
B0 = ExprOperand(expr=self.force_basis, x=stream("X"), alias="B0")
|
|
873
|
+
Bp = ExprOperand(expr=self.force_basis, x=stream("X_plus"), alias="Bp")
|
|
874
|
+
prog_B0 = Integrand(
|
|
875
|
+
exprs=[B0],
|
|
876
|
+
times=[V],
|
|
877
|
+
consts=[A],
|
|
878
|
+
terms=[Term(eq="im,mn,ina->ia", ops=("V", "A", "B0"))],
|
|
879
|
+
)
|
|
880
|
+
prog_Bp = Integrand(
|
|
881
|
+
exprs=[Bp],
|
|
882
|
+
times=[V],
|
|
883
|
+
consts=[A],
|
|
884
|
+
terms=[Term(eq="im,mn,ina->ia", ops=("V", "A", "Bp"))],
|
|
885
|
+
)
|
|
886
|
+
prog_strato_vb = 0.5 * (prog_B0 + prog_Bp)
|
|
887
|
+
self.force_v_moments = integrate(
|
|
888
|
+
self.data, prog_strato_vb, reduce="sum", chunk_target_bytes=self._chunk_target_bytes
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
logger.debug("Computing Strato gradient term.")
|
|
892
|
+
|
|
893
|
+
# Gradient correction: G = basis.d_x() with mask forwarded normally.
|
|
894
|
+
# The leaf's fill_policy='zerostop' uses jnp.where to zero masked
|
|
895
|
+
# entries, which preserves the Jacobian for active particles.
|
|
896
|
+
#
|
|
897
|
+
# For interacting bases (pdepth >= 1), the full cross-particle
|
|
898
|
+
# Jacobian d_x() has shape (N_out, N_in, d, d, F) which doesn't
|
|
899
|
+
# match the Strato einsum 'ioma'. We use same_particle=True to
|
|
900
|
+
# get the diagonal block (N, d, d, F) = (i, o, m, a) instead.
|
|
901
|
+
# This is physically correct: the Strato correction only needs
|
|
902
|
+
# ∂b_a(x_i)/∂x_i (same-particle gradient) IF the diffusion is
|
|
903
|
+
# diagonal on particle level (no noise correlations between particles).
|
|
904
|
+
_interacting = getattr(self.force_basis, "particles_input", False)
|
|
905
|
+
G = ExprOperand(
|
|
906
|
+
expr=self.force_basis.d_x(same_particle=_interacting),
|
|
907
|
+
x=stream("X"),
|
|
908
|
+
alias="G",
|
|
909
|
+
)
|
|
910
|
+
D = self.get_diffusion_timeop("noisy") # already a TimeOperand with alias
|
|
911
|
+
|
|
912
|
+
prog_grad = Integrand(
|
|
913
|
+
exprs=[G],
|
|
914
|
+
times=[D],
|
|
915
|
+
consts=[A],
|
|
916
|
+
terms=[Term(eq="imn,ioma,no->ia", ops=(D.alias, "G", "A"))],
|
|
917
|
+
)
|
|
918
|
+
self._force_D_grad_b_average = integrate(
|
|
919
|
+
self.data, prog_grad, reduce="sum", chunk_target_bytes=self._chunk_target_bytes
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
return self.force_v_moments - self._force_D_grad_b_average
|
|
923
|
+
|
|
924
|
+
def _diffusion_G_matrix(self) -> jnp.ndarray:
|
|
925
|
+
# Diffusion is a per-point quantity: weight the projection per-point
|
|
926
|
+
# (weight_by_dt=False), consistently with _diffusion_moments.
|
|
927
|
+
return self.__G_matrix__(
|
|
928
|
+
self.diffusion_basis,
|
|
929
|
+
self.diffusion_basis,
|
|
930
|
+
self.__diffusion_G_mode__,
|
|
931
|
+
"imna,imnb->iab",
|
|
932
|
+
weight_by_dt=False,
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
def _diffusion_moments(self) -> jnp.ndarray:
|
|
936
|
+
logger.debug("Computing diffusion linear moments.")
|
|
937
|
+
D_op = self.get_diffusion_timeop(self.__diffusion_M_mode__)
|
|
938
|
+
B = ExprOperand(expr=self.diffusion_basis, x=stream("X"), alias="B")
|
|
939
|
+
prog = Integrand(
|
|
940
|
+
exprs=[B],
|
|
941
|
+
times=[D_op],
|
|
942
|
+
terms=[Term(eq="imna,imn->ia", ops=("B", D_op.alias))],
|
|
943
|
+
)
|
|
944
|
+
# Per-point (weight_by_dt=False): each increment's diffusion estimate counts equally.
|
|
945
|
+
return integrate(self.data, prog, reduce="sum", weight_by_dt=False,
|
|
946
|
+
chunk_target_bytes=self._chunk_target_bytes)
|
|
947
|
+
|
|
948
|
+
def _update_force_inferred(self) -> None:
|
|
949
|
+
"""
|
|
950
|
+
Materialize the fitted force as an SF and wrap it with param covariance.
|
|
951
|
+
Produces: self.force_inferred : InferenceResultSF
|
|
952
|
+
"""
|
|
953
|
+
if hasattr(self, "force_basis"):
|
|
954
|
+
# Basis -> PSF is the supported path; tests already use .to_psf()
|
|
955
|
+
P = self.force_basis.to_psf()
|
|
956
|
+
theta = {"coeff": jnp.asarray(self.force_coefficients_full)}
|
|
957
|
+
elif hasattr(self, "force_psf"):
|
|
958
|
+
P = self.force_psf
|
|
959
|
+
theta = self.force_params_nonlinear
|
|
960
|
+
|
|
961
|
+
sf = SF(P, theta) # fixed-θ state function
|
|
962
|
+
|
|
963
|
+
meta = dict(
|
|
964
|
+
kind="force",
|
|
965
|
+
modes=dict(
|
|
966
|
+
M=getattr(self, "__force_M_mode__", None),
|
|
967
|
+
G=getattr(self, "__force_G_mode__", None),
|
|
968
|
+
),
|
|
969
|
+
A_inv=jnp.asarray(getattr(self, "A_inv", None)) if hasattr(self, "A_inv") else None,
|
|
970
|
+
basis_features=int(getattr(self.force_basis, "n_features", 0)),
|
|
971
|
+
basis_labels=getattr(self.force_basis, "labels", None),
|
|
972
|
+
)
|
|
973
|
+
cov = getattr(self, "force_coefficients_cov", None) # may be absent
|
|
974
|
+
self.force_inferred = InferenceResultSF(sf, param_cov=cov, meta=meta)
|
|
975
|
+
|
|
976
|
+
def _update_diffusion_inferred(self) -> None:
|
|
977
|
+
"""
|
|
978
|
+
Materialize the fitted diffusion tensor as an SF and wrap it.
|
|
979
|
+
Produces: self.diffusion_inferred : InferenceResultSF
|
|
980
|
+
"""
|
|
981
|
+
if self.diffusion_basis is None:
|
|
982
|
+
raise RuntimeError("_update_diffusion_inferred called before diffusion was fitted.")
|
|
983
|
+
P: "PSF" = self.diffusion_basis.to_psf() # rank-2 PSF
|
|
984
|
+
theta = {"coeff": jnp.asarray(self.diffusion_coefficients_full)}
|
|
985
|
+
sf = SF(P, theta) # callable (x[, mask/extras]) -> (i, m, n)
|
|
986
|
+
|
|
987
|
+
meta = dict(
|
|
988
|
+
kind="diffusion",
|
|
989
|
+
mode=getattr(self, "__diffusion_M_mode__", None),
|
|
990
|
+
A_inv=jnp.asarray(getattr(self, "A_inv", None)) if hasattr(self, "A_inv") else None,
|
|
991
|
+
basis_features=int(getattr(self.diffusion_basis, "n_features", 0)),
|
|
992
|
+
basis_labels=getattr(self.diffusion_basis, "labels", None),
|
|
993
|
+
)
|
|
994
|
+
cov = getattr(self, "diffusion_coefficients_cov", None) # may be absent
|
|
995
|
+
self.diffusion_inferred = InferenceResultSF(sf, param_cov=cov, meta=meta)
|
|
996
|
+
|
|
997
|
+
def __G_matrix__(
|
|
998
|
+
self,
|
|
999
|
+
b_left: callable,
|
|
1000
|
+
b_right: callable,
|
|
1001
|
+
G_mode: str,
|
|
1002
|
+
einsum_string: str,
|
|
1003
|
+
subsampling: int = 1,
|
|
1004
|
+
weight_by_dt: bool = True,
|
|
1005
|
+
) -> jnp.ndarray:
|
|
1006
|
+
"""
|
|
1007
|
+
Compute Gram/normalization matrix G = < b_left ⊗ b_right > with chosen mode.
|
|
1008
|
+
|
|
1009
|
+
Arguments
|
|
1010
|
+
---------
|
|
1011
|
+
b_left, b_right : stateexpr callables
|
|
1012
|
+
Each must follow the contract expr(x, v=..., mask=..., extras=..., params=...),
|
|
1013
|
+
producing arrays with features on the last axis. Particle axis is leading i.
|
|
1014
|
+
G_mode : {'rectangle','trapeze','shift'}
|
|
1015
|
+
rectangle: b_left(X_t) ⊗ b_right(X_t)
|
|
1016
|
+
trapeze: b_left(X_t) ⊗ 0.5 [ b_right(X_t) + b_right(X_{t+}) ]
|
|
1017
|
+
shift: b_left(X_t) ⊗ b_right(X_{t+})
|
|
1018
|
+
einsum_string : str
|
|
1019
|
+
Einstein string including the particle index in inputs (e.g. 'iam,ibn->iabmn').
|
|
1020
|
+
subsampling : int
|
|
1021
|
+
Use every `subsampling`-th time row.
|
|
1022
|
+
|
|
1023
|
+
Returns
|
|
1024
|
+
-------
|
|
1025
|
+
jnp.ndarray
|
|
1026
|
+
Time-averaged Gram matrix with particle axis reduced by the integrator.
|
|
1027
|
+
"""
|
|
1028
|
+
logger.debug("Computing G matrix with einsum: %s", einsum_string)
|
|
1029
|
+
|
|
1030
|
+
BL = ExprOperand(expr=b_left, x=stream("X"), alias="BL")
|
|
1031
|
+
BR0 = ExprOperand(expr=b_right, x=stream("X"), alias="BR0")
|
|
1032
|
+
BRp = ExprOperand(expr=b_right, x=stream("X_plus"), alias="BRP")
|
|
1033
|
+
|
|
1034
|
+
if G_mode == "rectangle":
|
|
1035
|
+
prog = Integrand(exprs=[BL, BR0], terms=[Term(eq=einsum_string, ops=("BL", "BR0"))])
|
|
1036
|
+
elif G_mode == "trapeze":
|
|
1037
|
+
rect = Integrand(exprs=[BL, BR0], terms=[Term(eq=einsum_string, ops=("BL", "BR0"))])
|
|
1038
|
+
shift = Integrand(exprs=[BL, BRp], terms=[Term(eq=einsum_string, ops=("BL", "BRP"))])
|
|
1039
|
+
prog = 0.5 * (rect + shift)
|
|
1040
|
+
elif G_mode == "shift":
|
|
1041
|
+
prog = Integrand(exprs=[BL, BRp], terms=[Term(eq=einsum_string, ops=("BL", "BRP"))])
|
|
1042
|
+
else:
|
|
1043
|
+
raise KeyError("Wrong G_mode argument")
|
|
1044
|
+
|
|
1045
|
+
# Mask-aware reduction over particles; Teff mean over time
|
|
1046
|
+
return integrate(
|
|
1047
|
+
self.data,
|
|
1048
|
+
prog,
|
|
1049
|
+
reduce="sum",
|
|
1050
|
+
reduce_over_particles=True,
|
|
1051
|
+
subsampling=subsampling,
|
|
1052
|
+
weight_by_dt=weight_by_dt,
|
|
1053
|
+
chunk_target_bytes=self._chunk_target_bytes,
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
def _build_diffusion_timeoperands(self):
|
|
1057
|
+
"""
|
|
1058
|
+
Construct and cache TimeOperands for diffusion estimators.
|
|
1059
|
+
Safe to call multiple times; idempotent.
|
|
1060
|
+
"""
|
|
1061
|
+
if hasattr(self, "_diff_ops"):
|
|
1062
|
+
return
|
|
1063
|
+
self._diff_ops = {
|
|
1064
|
+
"MSD": TimeOperand(_D_msd, alias="D_msd"),
|
|
1065
|
+
"noisy": TimeOperand(_D_noisy, alias="D_noisy"),
|
|
1066
|
+
"WeakNoise": TimeOperand(_D_weaknoise, alias="D_weaknoise"),
|
|
1067
|
+
"Lambda": TimeOperand(_Lambda, alias="Lambda"),
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
def get_diffusion_timeop(self, method: str) -> TimeOperand:
|
|
1071
|
+
"""
|
|
1072
|
+
Return the requested overdamped diffusion estimator as a TimeOperand.
|
|
1073
|
+
Output is (N, d, d). Required streams are declared on the wrapped TimeOp.
|
|
1074
|
+
"""
|
|
1075
|
+
self._build_diffusion_timeoperands()
|
|
1076
|
+
try:
|
|
1077
|
+
return self._diff_ops[method] # type: ignore[attr-defined]
|
|
1078
|
+
except KeyError as e:
|
|
1079
|
+
raise KeyError(f"Unknown diffusion estimator method: {method}") from e
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
# -------------------- Overdamped diffusion estimators as TimeOps --------------------
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
@timeop(name="D_msd", batch_safe=True)
|
|
1086
|
+
def _D_msd(**streams):
|
|
1087
|
+
r"""
|
|
1088
|
+
MSD estimator (per particle): 0.5 * dX ⊗ (dX / dt)
|
|
1089
|
+
Returns (..., N, d, d) — batch-safe.
|
|
1090
|
+
|
|
1091
|
+
.. physics:: MSD diffusion estimator (overdamped)
|
|
1092
|
+
:label: D-msd-overdamped
|
|
1093
|
+
:category: Estimator
|
|
1094
|
+
|
|
1095
|
+
.. math::
|
|
1096
|
+
|
|
1097
|
+
\hat D_{\text{MSD}}(t)
|
|
1098
|
+
= \tfrac{1}{2}\,\mathrm{d}X_t \otimes
|
|
1099
|
+
\frac{\mathrm{d}X_t}{\mathrm{d}t}
|
|
1100
|
+
|
|
1101
|
+
Simplest estimator; biased by measurement noise.
|
|
1102
|
+
"""
|
|
1103
|
+
dX = streams["dX"]
|
|
1104
|
+
dt = streams["dt"]
|
|
1105
|
+
while dt.ndim < dX.ndim:
|
|
1106
|
+
dt = dt[..., jnp.newaxis]
|
|
1107
|
+
v = dX / dt
|
|
1108
|
+
return 0.5 * jnp.einsum("...m,...n->...mn", dX, v)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
_D_msd._requires = frozenset({"dX", "dt"}) # type: ignore[attr-defined]
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
@timeop(name="D_noisy", batch_safe=True)
|
|
1115
|
+
def _D_noisy(**streams):
|
|
1116
|
+
r"""
|
|
1117
|
+
Noisy diffusion estimator — Vestergaard–Blainey–Flyvbjerg (per particle):
|
|
1118
|
+
1/4 [ dX⊗(dX/dt) + 2 dX⊗(dX^-/dt) + 2 dX^-⊗(dX/dt) + dX^-⊗(dX^-/dt) ].
|
|
1119
|
+
Returns (N, d, d).
|
|
1120
|
+
|
|
1121
|
+
.. physics:: Noisy (Vestergaard–Blainey–Flyvbjerg) diffusion estimator
|
|
1122
|
+
:label: D-noisy
|
|
1123
|
+
:category: Estimator
|
|
1124
|
+
|
|
1125
|
+
.. math::
|
|
1126
|
+
|
|
1127
|
+
\hat D_{\text{noisy}}(t) = \tfrac{1}{4}\bigl[
|
|
1128
|
+
\mathrm{d}X_t \otimes v_t
|
|
1129
|
+
+ 2\,\mathrm{d}X_t \otimes v_{t-1}
|
|
1130
|
+
+ 2\,\mathrm{d}X_{t-1} \otimes v_t
|
|
1131
|
+
+ \mathrm{d}X_{t-1} \otimes v_{t-1}
|
|
1132
|
+
\bigr]
|
|
1133
|
+
|
|
1134
|
+
Two-point estimator robust to measurement noise
|
|
1135
|
+
(CL Vestergaard, PC Blainey, H Flyvbjerg - Physical Review E, 2014).
|
|
1136
|
+
"""
|
|
1137
|
+
dX = streams["dX"]
|
|
1138
|
+
dXm = streams["dX_minus"]
|
|
1139
|
+
dt = streams["dt"]
|
|
1140
|
+
while dt.ndim < dX.ndim:
|
|
1141
|
+
dt = dt[..., jnp.newaxis]
|
|
1142
|
+
invdt = 1.0 / dt
|
|
1143
|
+
a = jnp.einsum("...m,...n->...mn", dX, dX * invdt)
|
|
1144
|
+
b = jnp.einsum("...m,...n->...mn", dX, dXm * invdt)
|
|
1145
|
+
c = jnp.einsum("...m,...n->...mn", dXm, dX * invdt)
|
|
1146
|
+
d = jnp.einsum("...m,...n->...mn", dXm, dXm * invdt)
|
|
1147
|
+
return 0.25 * (a + 2 * b + 2 * c + d)
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
_D_noisy._requires = frozenset({"dX", "dX_minus", "dt"}) # type: ignore[attr-defined]
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
@timeop(name="D_weaknoise", batch_safe=True)
|
|
1154
|
+
def _D_weaknoise(**streams):
|
|
1155
|
+
r"""
|
|
1156
|
+
Weak-noise estimator (per particle):
|
|
1157
|
+
1/4 * ( (dX - dX^-) ⊗ (dX/dt - dX^-/dt) ).
|
|
1158
|
+
Returns (N, d, d).
|
|
1159
|
+
|
|
1160
|
+
.. physics:: Weak-noise diffusion estimator (overdamped)
|
|
1161
|
+
:label: D-weaknoise-overdamped
|
|
1162
|
+
:category: Estimator
|
|
1163
|
+
|
|
1164
|
+
.. math::
|
|
1165
|
+
|
|
1166
|
+
\hat D_{\text{WN}}(t)
|
|
1167
|
+
= \tfrac{1}{4}\bigl(\mathrm{d}X_t - \mathrm{d}X_{t-1}\bigr)
|
|
1168
|
+
\otimes \bigl(v_t - v_{t-1}\bigr)
|
|
1169
|
+
|
|
1170
|
+
Uses successive-displacement differences; suitable when localization
|
|
1171
|
+
noise is negligible.
|
|
1172
|
+
"""
|
|
1173
|
+
dX = streams["dX"]
|
|
1174
|
+
dXm = streams["dX_minus"]
|
|
1175
|
+
dt = streams["dt"]
|
|
1176
|
+
while dt.ndim < dX.ndim:
|
|
1177
|
+
dt = dt[..., jnp.newaxis]
|
|
1178
|
+
invdt = 1.0 / dt
|
|
1179
|
+
ddx = dX - dXm
|
|
1180
|
+
dv = dX * invdt - dXm * invdt
|
|
1181
|
+
return 0.25 * jnp.einsum("...m,...n->...mn", ddx, dv)
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
_D_weaknoise._requires = frozenset({"dX", "dX_minus", "dt"}) # type: ignore[attr-defined]
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
@timeop(name="Lambda_meas_noise", batch_safe=True)
|
|
1188
|
+
def _Lambda(**streams):
|
|
1189
|
+
r"""
|
|
1190
|
+
Measurement-noise cross term (per particle):
|
|
1191
|
+
Λ_i = -0.5 [ dX_i ⊗ dX^-_i + dX^-_i ⊗ dX_i ].
|
|
1192
|
+
Returns (N, d, d). No 1/dt factor inside.
|
|
1193
|
+
|
|
1194
|
+
.. physics:: Measurement noise estimator (overdamped)
|
|
1195
|
+
:label: Lambda-overdamped
|
|
1196
|
+
:category: Estimator
|
|
1197
|
+
|
|
1198
|
+
.. math::
|
|
1199
|
+
|
|
1200
|
+
\hat\Lambda_i
|
|
1201
|
+
= -\,\tfrac{1}{2}\bigl[
|
|
1202
|
+
\mathrm{d}X_i \otimes \mathrm{d}X_{i-1}
|
|
1203
|
+
+ \mathrm{d}X_{i-1} \otimes \mathrm{d}X_i
|
|
1204
|
+
\bigr]
|
|
1205
|
+
|
|
1206
|
+
Estimates localization / measurement noise from anti-correlation
|
|
1207
|
+
of successive increments.
|
|
1208
|
+
"""
|
|
1209
|
+
dX = streams["dX"]
|
|
1210
|
+
dXm = streams["dX_minus"]
|
|
1211
|
+
return -0.5 * (jnp.einsum("...m,...n->...mn", dX, dXm) + jnp.einsum("...m,...n->...mn", dXm, dX))
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
_Lambda._requires = frozenset({"dX", "dX_minus"}) # type: ignore[attr-defined]
|