StochasticForceInference 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. SFI/__init__.py +64 -0
  2. SFI/bases/__init__.py +85 -0
  3. SFI/bases/constants.py +492 -0
  4. SFI/bases/linear.py +325 -0
  5. SFI/bases/monomials.py +218 -0
  6. SFI/bases/pairs.py +998 -0
  7. SFI/bases/spde.py +1537 -0
  8. SFI/diagnostics/__init__.py +60 -0
  9. SFI/diagnostics/assess.py +87 -0
  10. SFI/diagnostics/dynamics_order.py +621 -0
  11. SFI/diagnostics/plotting.py +226 -0
  12. SFI/diagnostics/report.py +238 -0
  13. SFI/diagnostics/residual_tests.py +395 -0
  14. SFI/diagnostics/residuals.py +688 -0
  15. SFI/inference/__init__.py +58 -0
  16. SFI/inference/base.py +1460 -0
  17. SFI/inference/optimizers.py +200 -0
  18. SFI/inference/overdamped.py +1214 -0
  19. SFI/inference/parametric_core/__init__.py +34 -0
  20. SFI/inference/parametric_core/covariance.py +232 -0
  21. SFI/inference/parametric_core/driver.py +272 -0
  22. SFI/inference/parametric_core/flow.py +149 -0
  23. SFI/inference/parametric_core/flow_multi.py +362 -0
  24. SFI/inference/parametric_core/flow_ud.py +168 -0
  25. SFI/inference/parametric_core/jacobians.py +540 -0
  26. SFI/inference/parametric_core/objective.py +286 -0
  27. SFI/inference/parametric_core/objective_ud.py +253 -0
  28. SFI/inference/parametric_core/precision.py +229 -0
  29. SFI/inference/parametric_core/solve.py +763 -0
  30. SFI/inference/result.py +362 -0
  31. SFI/inference/serialization.py +245 -0
  32. SFI/inference/sparse/__init__.py +67 -0
  33. SFI/inference/sparse/base.py +43 -0
  34. SFI/inference/sparse/beam.py +303 -0
  35. SFI/inference/sparse/greedy.py +151 -0
  36. SFI/inference/sparse/hillclimb.py +307 -0
  37. SFI/inference/sparse/lasso.py +178 -0
  38. SFI/inference/sparse/metrics.py +78 -0
  39. SFI/inference/sparse/result.py +278 -0
  40. SFI/inference/sparse/scorer.py +323 -0
  41. SFI/inference/sparse/stlsq.py +165 -0
  42. SFI/inference/sparsity.py +40 -0
  43. SFI/inference/underdamped.py +1355 -0
  44. SFI/integrate/__init__.py +38 -0
  45. SFI/integrate/api.py +920 -0
  46. SFI/integrate/integrand.py +402 -0
  47. SFI/integrate/rk4.py +156 -0
  48. SFI/integrate/timeops.py +174 -0
  49. SFI/langevin/__init__.py +29 -0
  50. SFI/langevin/base.py +863 -0
  51. SFI/langevin/chunked.py +225 -0
  52. SFI/langevin/noise.py +446 -0
  53. SFI/langevin/overdamped.py +560 -0
  54. SFI/langevin/underdamped.py +448 -0
  55. SFI/statefunc/__init__.py +49 -0
  56. SFI/statefunc/basis.py +87 -0
  57. SFI/statefunc/core/runtime.py +47 -0
  58. SFI/statefunc/factory.py +346 -0
  59. SFI/statefunc/interactor.py +90 -0
  60. SFI/statefunc/layout/__init__.py +29 -0
  61. SFI/statefunc/layout/_base.py +186 -0
  62. SFI/statefunc/layout/_eval_compiler.py +453 -0
  63. SFI/statefunc/layout/_fd_atoms.py +196 -0
  64. SFI/statefunc/layout/_grid.py +878 -0
  65. SFI/statefunc/layout/_sectors.py +166 -0
  66. SFI/statefunc/memhint.py +222 -0
  67. SFI/statefunc/nodes/__init__.py +56 -0
  68. SFI/statefunc/nodes/base.py +318 -0
  69. SFI/statefunc/nodes/contract.py +422 -0
  70. SFI/statefunc/nodes/interactions/__init__.py +23 -0
  71. SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
  72. SFI/statefunc/nodes/interactions/prepare.py +161 -0
  73. SFI/statefunc/nodes/interactions/specs.py +362 -0
  74. SFI/statefunc/nodes/interactions/stencils.py +718 -0
  75. SFI/statefunc/nodes/leaf.py +530 -0
  76. SFI/statefunc/nodes/ops/__init__.py +27 -0
  77. SFI/statefunc/nodes/ops/concat.py +28 -0
  78. SFI/statefunc/nodes/ops/derivative.py +447 -0
  79. SFI/statefunc/nodes/ops/einsum.py +120 -0
  80. SFI/statefunc/nodes/ops/linear.py +153 -0
  81. SFI/statefunc/nodes/ops/mapn.py +138 -0
  82. SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
  83. SFI/statefunc/nodes/ops/slice.py +83 -0
  84. SFI/statefunc/params.py +309 -0
  85. SFI/statefunc/psf.py +112 -0
  86. SFI/statefunc/sf.py +104 -0
  87. SFI/statefunc/stateexpr.py +1243 -0
  88. SFI/statefunc/structexpr.py +1003 -0
  89. SFI/trajectory/__init__.py +31 -0
  90. SFI/trajectory/collection.py +1122 -0
  91. SFI/trajectory/dataset.py +1164 -0
  92. SFI/trajectory/degrade.py +1108 -0
  93. SFI/trajectory/io.py +1027 -0
  94. SFI/trajectory/reserved_extras.py +129 -0
  95. SFI/utils/__init__.py +17 -0
  96. SFI/utils/formatting.py +308 -0
  97. SFI/utils/maths.py +185 -0
  98. SFI/utils/neighbors.py +162 -0
  99. SFI/utils/plotting.py +1936 -0
  100. stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
  101. stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
  102. stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
  103. stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
  104. stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1355 @@
1
+ # SFI/inference/underdamped.py
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+
6
+ import jax.numpy as jnp
7
+
8
+ from SFI.bases.constants import constant_array
9
+ from SFI.inference.base import BaseLangevinInference
10
+ from SFI.integrate.api import integrate
11
+ from SFI.integrate.integrand import (
12
+ ConstOperand,
13
+ ExprOperand,
14
+ Integrand,
15
+ Term,
16
+ TimeOperand,
17
+ )
18
+ from SFI.integrate.timeops import TimeOp, stream, timeop, velocity
19
+ from SFI.statefunc import PSF, SF, Basis
20
+ from SFI.utils.maths import sqrtm_psd
21
+
22
+ from .result import InferenceResultSF
23
+ from .sparse import SparseScorer
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ # ---------------------------------------------------------------------------- #
29
+ # ULI linear force-inference presets
30
+ # ---------------------------------------------------------------------------- #
31
+ # The full (M_mode, G_mode, diffusion_method) surface is combinatorial and hard
32
+ # for users to navigate. The choice that actually matters is the diffusion
33
+ # estimator (``noisy`` vs ``WeakNoise``), keyed on measurement noise; the broadly
34
+ # robust kinematics/Gram settings are ``M_mode='symmetric'`` and
35
+ # ``G_mode='trapeze'`` (the ULI Gram is returned transposed — grid assessments on
36
+ # the VdP limit cycle and a 2-D noise-driven OU both place it at/near the per-cell
37
+ # oracle, ~1.07-1.08x, with a better in-band worst case). We expose two named
38
+ # presets and an ``auto`` switch.
39
+ _FORCE_LINEAR_PRESETS: dict[str, tuple[str, str, str]] = {
40
+ # broadly applicable default — flat across measurement noise in its band
41
+ "robust": ("symmetric", "trapeze", "noisy"),
42
+ # sharper refinement for verified noise-free data (coarse-dt, low-noise)
43
+ "clean": ("symmetric", "trapeze", "WeakNoise"),
44
+ # SFI v1.0 reproductions (explicit only; never selected by 'auto')
45
+ "legacy-clean-v1.0": ("early", "rectangle", "MSD"),
46
+ "legacy-noisy-v1.0": ("symmetric", "rectangle", "noisy"),
47
+ }
48
+
49
+
50
+ def _resolve_force_linear_preset(
51
+ preset: str,
52
+ *,
53
+ M_mode: str | None = None,
54
+ G_mode: str | None = None,
55
+ diffusion_method: str | None = None,
56
+ noise_detected: bool | None = None,
57
+ ) -> tuple[tuple[str, str, str], str]:
58
+ """Resolve a force-inference ``preset`` to a concrete mode triple.
59
+
60
+ Parameters
61
+ ----------
62
+ preset : {'auto', 'robust', 'clean'}
63
+ ``'robust'`` and ``'clean'`` map directly to fixed mode triples (see
64
+ ``_FORCE_LINEAR_PRESETS``). ``'auto'`` selects ``'robust'`` (the
65
+ ``noisy`` diffusion estimator) when measurement noise is present and
66
+ ``'clean'`` (``WeakNoise``) otherwise.
67
+ M_mode, G_mode, diffusion_method : str, optional
68
+ Power-user overrides. Any non-``None`` value replaces the preset's
69
+ choice on that axis (so explicit callers are unaffected by the preset).
70
+ noise_detected : bool, optional
71
+ Measurement-noise indicator for ``preset='auto'`` (``Lambda_trace > 0``).
72
+ ``True`` -> ``robust``/``noisy``; ``False`` -> ``clean``/``WeakNoise``.
73
+ ``None`` (indicator unavailable) falls back to the safe ``robust`` path.
74
+
75
+ Returns
76
+ -------
77
+ (M_mode, G_mode, diffusion_method) : tuple of str
78
+ The resolved mode triple.
79
+ resolved_preset : str
80
+ The concrete base preset used (``'robust'`` or ``'clean'``) — useful for
81
+ metadata/logging when ``preset='auto'``.
82
+ """
83
+ if preset == "auto":
84
+ base = "clean" if noise_detected is False else "robust"
85
+ elif preset in _FORCE_LINEAR_PRESETS:
86
+ base = preset
87
+ else:
88
+ choices = ", ".join(repr(p) for p in ("auto", *_FORCE_LINEAR_PRESETS))
89
+ raise ValueError(f"unknown preset {preset!r}; choose from {choices}")
90
+
91
+ M0, G0, D0 = _FORCE_LINEAR_PRESETS[base]
92
+ resolved = (
93
+ M0 if M_mode is None else M_mode,
94
+ G0 if G_mode is None else G_mode,
95
+ D0 if diffusion_method is None else diffusion_method,
96
+ )
97
+ return resolved, base
98
+
99
+
100
+ class UnderdampedLangevinInference(BaseLangevinInference):
101
+ r"""
102
+ Stochastic Force Inference concrete class for underdamped systems (velocities unobserved).
103
+
104
+ Core equations (Ito convention):
105
+ dx/dt = v
106
+ dv/dt = F(x, v) + sqrt(2 D(x, v)) dW_t
107
+
108
+ Only x(t) is observed; v(t) is reconstructed from finite differences ("secant velocities").
109
+
110
+ .. physics:: Underdamped Langevin SDE
111
+ :label: underdamped-langevin-sde
112
+ :category: Dynamical equations
113
+
114
+ .. math::
115
+
116
+ \frac{\mathrm{d}x}{\mathrm{d}t} = v, \qquad
117
+ \frac{\mathrm{d}v}{\mathrm{d}t}
118
+ = F(x,v) + \sqrt{2\,D(x,v)}\;\mathrm{d}W_t
119
+
120
+ Only positions :math:`x(t)` are observed; velocities are
121
+ reconstructed from finite differences.
122
+ """
123
+
124
+ # ------------------------------ public API ------------------------------ #
125
+
126
+ def infer_force_linear(
127
+ self,
128
+ basis: Basis,
129
+ *,
130
+ preset: str = "auto",
131
+ M_mode: str | None = None,
132
+ G_mode: str | None = None,
133
+ diffusion_method: str | None = None,
134
+ ) -> None:
135
+ """
136
+ Infer F(x, v) as a linear combination of basis functions.
137
+
138
+ The estimator is configured by a ``preset`` that consolidates the
139
+ ``(M_mode, G_mode, diffusion_method)`` surface into two broadly-validated
140
+ choices, with an ``auto`` switch keyed on measurement noise. Power users
141
+ can still pass any of the three modes explicitly to override the preset.
142
+
143
+ Parameters
144
+ ----------
145
+ preset : {'auto', 'robust', 'clean', 'legacy-clean-v1.0', 'legacy-noisy-v1.0'}, default 'auto'
146
+ * ``'robust'`` — ``symmetric`` / ``trapeze`` / ``noisy``. The
147
+ broadly applicable default: flat across measurement noise within
148
+ its feasible band and never catastrophic.
149
+ * ``'clean'`` — ``symmetric`` / ``trapeze`` / ``WeakNoise``. A
150
+ sharper refinement for verified noise-free data (helps mainly in
151
+ the coarse-dt, low-noise corner); fragile under measurement noise.
152
+ * ``'auto'`` — pick ``'robust'`` when measurement noise is detected and
153
+ ``'clean'`` otherwise, on the sign of the localization-noise estimate
154
+ ``Lambda_trace`` (``>0`` -> noise present). ``Lambda_trace`` is
155
+ reused from a prior :meth:`compute_diffusion_constant` call or
156
+ estimated inline. Validated on the ``vdp_uli_10x`` benchmark: the
157
+ zero-crossing tracks the ``noisy``<->``WeakNoise`` transition with no
158
+ harmful misroutes.
159
+ * ``'legacy-clean-v1.0'`` — ``early`` / ``rectangle`` / ``MSD``.
160
+ Reproduces the published SFI v1.0 clean-data convention
161
+ (explicit only; never selected by ``'auto'``).
162
+ * ``'legacy-noisy-v1.0'`` — ``symmetric`` / ``rectangle`` /
163
+ ``noisy``. Reproduces the SFI v1.0 noisy-data convention
164
+ (explicit only).
165
+ basis : Basis
166
+ Rank-1 basis producing per-particle vectors.
167
+ Must accept v=... keyword in evaluation.
168
+ M_mode : str, optional
169
+ Override the preset's kinematics / moments convention:
170
+ 'symmetric' | 'early' | 'anticipated'. ``None`` uses the preset.
171
+ G_mode : str, optional
172
+ Override the preset's Gram construction mode: 'rectangle' | 'trapeze'
173
+ | 'shift' | 'doubleshift' (each returned transposed). ``None`` uses
174
+ the preset.
175
+ diffusion_method : str, optional
176
+ Override the preset's instantaneous diffusion estimator used in the
177
+ ULI correction term: 'noisy' | 'WeakNoise' | 'MSD'. ``None`` uses the
178
+ preset (and re-enables the ``auto`` measurement-noise switch).
179
+ """
180
+ # Legacy alias: M_mode='auto' historically meant 'symmetric'.
181
+ if M_mode == "auto":
182
+ M_mode = "symmetric"
183
+
184
+ # Resolve the mode triple from the preset. preset='auto' selects the
185
+ # diffusion estimator on the sign of Lambda_trace, which is set by the
186
+ # required prior compute_diffusion_constant() call (the force inference
187
+ # also needs its A_inv normalization). If absent, fall back to the safe
188
+ # 'robust' branch; the missing-diffusion error surfaces downstream.
189
+ noise_detected: bool | None = None
190
+ if preset == "auto" and diffusion_method is None:
191
+ lam = getattr(self, "Lambda_trace", None)
192
+ noise_detected = None if lam is None else (lam > 0)
193
+ (M_mode, G_mode, diffusion_method), resolved_preset = _resolve_force_linear_preset(
194
+ preset,
195
+ M_mode=M_mode,
196
+ G_mode=G_mode,
197
+ diffusion_method=diffusion_method,
198
+ noise_detected=noise_detected,
199
+ )
200
+ self.metadata["force_preset"] = preset
201
+ if preset == "auto":
202
+ logger.info(
203
+ "Auto force preset -> %s (M_mode=%s, G_mode=%s, diffusion_method=%s; "
204
+ "Lambda_trace sign: %s)",
205
+ resolved_preset, M_mode, G_mode, diffusion_method,
206
+ None if noise_detected is None else ("+" if noise_detected else "-"),
207
+ )
208
+
209
+ self._validate_basis(basis, expected_rank=1, label="force basis")
210
+
211
+ self.force_basis = basis
212
+ self.__force_M_mode__ = M_mode
213
+ self.__force_G_mode__ = G_mode
214
+ self.__force_diffusion_method__ = diffusion_method
215
+ self.metadata["force_M_mode"] = M_mode
216
+ self.metadata["force_G_mode"] = G_mode
217
+ self.metadata["force_diffusion_method"] = diffusion_method
218
+
219
+ if hasattr(self, "force_G_full"):
220
+ raise RuntimeError("Force has already been inferred on this object - create a new instance to re-infer.")
221
+
222
+ # Structural arrays are exposed only for this evaluation and never
223
+ # persisted on the dataset (see ``_structural_scope``).
224
+ with self._structural_scope(self.force_basis):
225
+ self.force_G_full = self._force_G_matrix()
226
+ self.force_moments = self._force_moments()
227
+
228
+ # Solve and expose a scorer for further model selection.
229
+ self.force_scorer = SparseScorer(M=self.force_moments, G=self.force_G_full)
230
+ self._update_force_coefficients(self.force_scorer.total_C)
231
+ self.metadata["force_method"] = "linear"
232
+
233
+ def infer_diffusion_linear(
234
+ self,
235
+ basis: Basis,
236
+ *,
237
+ M_mode: str = "noisy",
238
+ G_mode: str = "rectangle",
239
+ ) -> None:
240
+ """
241
+ Fit D(x, v) as a linear combination of basis functions.
242
+ """
243
+ if M_mode == "auto":
244
+ M_mode = "noisy"
245
+
246
+ self.diffusion_basis = basis
247
+ self.__diffusion_M_mode__ = M_mode
248
+ self.__diffusion_G_mode__ = G_mode
249
+ self.metadata["diffusion_M_mode"] = M_mode
250
+ self.metadata["diffusion_G_mode"] = G_mode
251
+
252
+ if hasattr(self, "diffusion_moments"):
253
+ raise RuntimeError(
254
+ "Diffusion has already been inferred on this object - create a new instance to re-infer."
255
+ )
256
+
257
+ with self._structural_scope(self.diffusion_basis):
258
+ self.diffusion_G_full = self._diffusion_G_matrix()
259
+ self.diffusion_moments = self._diffusion_moments()
260
+
261
+ self.diffusion_scorer = SparseScorer(M=self.diffusion_moments, G=self.diffusion_G_full)
262
+ self._update_diffusion_coefficients(self.diffusion_scorer.total_C)
263
+
264
+ def _estimate_lambda_trace(self) -> float:
265
+ """Estimate the localization (measurement) noise Λ and cache its trace.
266
+
267
+ Sets ``self.Lambda`` (the ULI 'Lambda' instantaneous estimator,
268
+ time-averaged) and ``self.Lambda_trace = tr(Λ)`` and returns the trace.
269
+ ``Lambda_trace > 0`` indicates measurement noise dominates the
270
+ velocity-increment autocorrelation; ``< 0`` indicates force persistence
271
+ dominates (clean dynamics). Shared by :meth:`compute_diffusion_constant`
272
+ and the ``preset='auto'`` switch in :meth:`infer_force_linear`.
273
+ """
274
+ L_op = self.get_diffusion_timeop("Lambda")
275
+ L_prog = Integrand(times=[L_op], terms=[Term(eq="imn->imn", ops=(L_op.alias,))])
276
+ # Diffusion/noise are per-point quantities: combine per-point, not dt-weighted.
277
+ self.Lambda = integrate(
278
+ self.data, L_prog, reduce="mean", weight_by_dt=False,
279
+ chunk_target_bytes=self._chunk_target_bytes,
280
+ )
281
+ self.Lambda_trace = float(jnp.trace(self.Lambda))
282
+ logger.info("Measurement noise trace: %s", self.Lambda_trace)
283
+ return self.Lambda_trace
284
+
285
+ def compute_diffusion_constant(self, method: str = "auto") -> None:
286
+ """
287
+ Underdamped constant diffusion inference (ULI estimators).
288
+
289
+ 1) Estimate measurement noise Λ with method "Lambda".
290
+ 2) If method == 'auto': choose 'noisy' if tr(Λ)>0 else 'WeakNoise'.
291
+ 3) Average chosen instantaneous estimator to obtain D̄.
292
+ 4) Set A = 2 D̄ and derive A_inv, sqrtA, sqrtA_inv.
293
+ """
294
+ if hasattr(self, "diffusion_average"):
295
+ raise RuntimeError("Diffusion already computed; create a new inference object to recompute.")
296
+
297
+ # 1) measurement noise Λ
298
+ self._estimate_lambda_trace()
299
+
300
+ # 2) select instantaneous estimator
301
+ if method == "auto":
302
+ method = "noisy" if self.Lambda_trace > 0 else "WeakNoise"
303
+ self.metadata["diffusion_constant_method"] = method
304
+
305
+ # 3) time-average instantaneous diffusion
306
+ D_op = self.get_diffusion_timeop(method)
307
+ D_prog = Integrand(times=[D_op], terms=[Term(eq="imn->imn", ops=(D_op.alias,))])
308
+ self.diffusion_average = integrate(
309
+ self.data, D_prog, reduce="mean", weight_by_dt=False,
310
+ chunk_target_bytes=self._chunk_target_bytes,
311
+ )
312
+ self.diffusion_inferred = constant_array(self.diffusion_average)
313
+
314
+ # 4) normalization matrices
315
+ self.A = 2.0 * self.diffusion_average
316
+ self.A_inv = jnp.linalg.inv(self.A)
317
+ self.sqrtA = sqrtm_psd(self.A)
318
+ self.sqrtA_inv = jnp.linalg.inv(self.sqrtA)
319
+
320
+ def simulate_bootstrapped_trajectory(self, key, oversampling: int = 1, simulate: bool = True, dataset: int = 0):
321
+ """
322
+ Simulate an underdamped Langevin trajectory using the inferred force and diffusion.
323
+
324
+ Args:
325
+ key: JAX random key.
326
+ oversampling: number of integration substeps between saved frames.
327
+ simulate: if True, runs a simulation initialized from the first row of data;
328
+ if False, returns an uninitialized process object.
329
+ dataset: which experiment of a pooled fit to reproduce. The inferred model is
330
+ collapsed to this condition via :meth:`~SFI.statefunc.StateExpr.specialize`
331
+ (per-dataset parameters folded at ``dataset``); the resulting process is a
332
+ standalone single-condition model that does not read ``dataset_index``.
333
+ Defaults to 0.
334
+
335
+ Returns:
336
+ If simulate=True: (TrajectoryCollection, UnderdampedProcess)
337
+ Else: UnderdampedProcess
338
+ """
339
+ from SFI.langevin import UnderdampedProcess
340
+
341
+ if not hasattr(self, "force_inferred"):
342
+ raise RuntimeError("Force not inferred. Call infer_force_linear() (or nonlinear later) first.")
343
+
344
+ # Collapse a (possibly pooled) fit to the chosen experiment: per-dataset
345
+ # parameters are folded at ``dataset`` and the reserved ``dataset_index``
346
+ # disappears, so the simulated model is standalone (no dataset concept).
347
+ force_k = self.force_inferred.specialize(dataset=int(dataset))
348
+ diff_k = self.diffusion_inferred.specialize(dataset=int(dataset))
349
+ bootstrapped_process = UnderdampedProcess(force_k._psf, diff_k._psf)
350
+ bootstrapped_process.set_params(theta_F=force_k.params, theta_D=diff_k.params)
351
+
352
+ if not simulate:
353
+ # Just return the process, leave extras and state uninitialized
354
+ return bootstrapped_process
355
+
356
+ ds = self.data.datasets[int(dataset)]
357
+ bootstrapped_process.set_extras(extras_global=ds.extras_global, extras_local=ds.extras_local)
358
+ # Only the particle count is framework-supplied; the specialized
359
+ # force does not read ``dataset_index``.
360
+ bootstrapped_process._reserved_overrides = {
361
+ "particle_index": jnp.arange(int(ds.N)),
362
+ }
363
+ # Initialize from first available row; velocity from dX/dt as requested.
364
+ from SFI.trajectory import TrajectoryCollection
365
+
366
+ start_config = TrajectoryCollection.from_dataset(ds).peek_row(require={"X", "dX", "dt"})
367
+ x0 = start_config["X"]
368
+ v0 = start_config["dX"] / start_config["dt"]
369
+
370
+ bootstrapped_process.initialize(x0, v0=v0)
371
+
372
+ data_bootstrap = bootstrapped_process.simulate(
373
+ dt=start_config["dt"],
374
+ Nsteps=ds.T,
375
+ key=key,
376
+ prerun=0,
377
+ oversampling=oversampling,
378
+ )
379
+ return data_bootstrap, bootstrapped_process
380
+
381
+ # ------------------------------------------------------------------ #
382
+ # Parametric — underdamped parametric windowed inference
383
+ # ------------------------------------------------------------------ #
384
+
385
+ def infer_force(
386
+ self,
387
+ F,
388
+ theta0=None,
389
+ *,
390
+ D=None,
391
+ Lambda=None,
392
+ integrator: str = "rk4",
393
+ n_substeps: int = 1,
394
+ inner: str = "auto",
395
+ eiv="auto",
396
+ max_outer: int = 5,
397
+ inner_maxiter: int = 80,
398
+ ) -> None:
399
+ r"""Infer force F(x,v) with the minimal parametric estimator.
400
+
401
+ Built on ``SFI.inference.parametric_core``: the unobserved velocity
402
+ is resolved by shooting through a single RK4 phase-space step, the
403
+ 3-point residual covariance is pentadiagonal (bandwidth 2), and the
404
+ parameters are found by direct Gauss–Newton (linear-in-θ ``Basis``,
405
+ with the skip-trick errors-in-variables instrument) or
406
+ frozen-precision L-BFGS (nonlinear-in-θ ``PSF``). ``(D, Λ)`` are
407
+ profiled natively: moment-estimator init, then one windowed
408
+ conditional-NLL refinement at the fitted θ.
409
+
410
+ .. physics:: Parametric windowed force inference (underdamped)
411
+ :label: parametric-force-underdamped
412
+ :category: Inference
413
+
414
+ The phase-space dynamics
415
+ :math:`\dot{x}=v,\;\mathrm{d}v = F(x,v)\mathrm{d}t
416
+ + \sqrt{2D}\,\mathrm{d}W` are factored into deterministic
417
+ flow + noise. Three-point shooting residuals follow a
418
+ pentadiagonal Gaussian whose local precision weights the
419
+ Gauss–Newton normal equations; the process noise enters at
420
+ :math:`\Delta t^3`. Under measurement noise the left factor
421
+ is the η-clean *skip* instrument built from the lagged clean
422
+ position pair (``eiv=True``) — consistent where the naive
423
+ MLE is velocity-EIV-biased.
424
+
425
+ Parameters
426
+ ----------
427
+ F : PSF (``needs_v=True``) or Basis
428
+ Parametric force model. A ``Basis`` is converted to a PSF
429
+ internally (coefficients initialised to zero) and PASTIS
430
+ sparsification is enabled; it runs the fast direct-GN path.
431
+ theta0 : dict, array, or None
432
+ Initial force parameters (default: zeros).
433
+ D : array (d, d), optional
434
+ Fixed diffusion matrix. If both ``D`` and ``Lambda``
435
+ are given, noise profiling is skipped entirely (fast path).
436
+ Lambda : array ``(d, d)``, optional
437
+ Fixed measurement-noise covariance.
438
+ integrator : {"rk4", "euler"}
439
+ Flow predictor (default ``"rk4"``, a single 4th-order step).
440
+ A single ``"euler"`` step cannot carry the force into the
441
+ position update, so the skip-trick is auto-disabled (with a
442
+ warning) for ``integrator="euler", n_substeps=1``.
443
+ n_substeps : int
444
+ Integrator micro-steps per observation interval (default 1 —
445
+ the single-step minimal estimator).
446
+ inner : {"auto", "gn", "lbfgs"}
447
+ Inner solver. ``"auto"`` → direct Gauss–Newton for a linear
448
+ ``Basis``, L-BFGS for a ``PSF``.
449
+ eiv : {"auto", True, False, float}
450
+ Measurement-noise errors-in-variables instrument. ``"auto"``
451
+ (default) → ``True`` for all models (interacting models use
452
+ the same N-body flow for the instrument as for the residual);
453
+ ``True`` forces the η-clean skip instrument; ``False`` is the
454
+ plain MLE; a float in ``[0, 1]`` blends. GN path only.
455
+ max_outer : int
456
+ Outer Gauss–Newton / IRLS iterations (default 5).
457
+ inner_maxiter : int
458
+ Inner L-BFGS iterations per outer step on the PSF path
459
+ (default 80; raise for large nonlinear families, e.g. NNs).
460
+
461
+ Updates
462
+ -------
463
+ Sets standard ``force_*`` attributes: ``force_psf``,
464
+ ``force_G``, ``force_G_pinv``, ``force_coefficients_full``,
465
+ ``force_coefficients``, ``force_support``, ``force_moments``,
466
+ ``force_inferred``, ``diffusion_average``, ``A``, ``A_inv``,
467
+ ``Lambda``.
468
+
469
+ When ``F`` is a ``Basis``, additionally sets
470
+ ``force_basis``, ``force_G_full``, and ``force_scorer``
471
+ so that ``sparsify_force()`` can be called afterwards.
472
+
473
+ See Also
474
+ --------
475
+ :ref:`parametric-concept` : Mathematical foundations.
476
+ :ref:`parametric-algorithm` : Detailed algorithm description.
477
+ """
478
+ import time as _time
479
+
480
+ from SFI.inference.parametric_core.solve import _as_psf, solve_force_ud
481
+ from SFI.statefunc import SF
482
+ from SFI.statefunc.basis import Basis
483
+
484
+ from .result import InferenceResultSF
485
+
486
+ if hasattr(self, "force_inferred"):
487
+ raise RuntimeError("Force inference has already been run on this object.")
488
+
489
+ if integrator not in ("rk4", "euler"):
490
+ raise ValueError(f"integrator must be 'rk4' or 'euler', got {integrator!r}")
491
+
492
+ # ── Accept Basis → convert to PSF, enable PASTIS ──
493
+ basis_mode = isinstance(F, Basis)
494
+ if basis_mode:
495
+ self.force_basis = F
496
+ elif not (hasattr(F, "flatten_params") and hasattr(F, "unflatten_params")):
497
+ raise TypeError("F must be a PSF or a Basis. Got %s." % type(F).__name__)
498
+ F_psf = _as_psf(F)
499
+
500
+ # Structural arrays (interaction lists, etc.) are exposed only for this
501
+ # parametric solve and never persisted (see ``_structural_scope``).
502
+ with self._structural_scope(F_psf):
503
+ dt_val = float(self.data.peek_row(require={"dt"})["dt"])
504
+
505
+ logger.info(
506
+ "[uli_parametric] UD minimal parametric solve (n_params=%d, dt=%.4g, %s·n%d, basis_mode=%s)...",
507
+ int(F_psf.template.size), dt_val, integrator, n_substeps, basis_mode,
508
+ )
509
+ t0 = _time.perf_counter()
510
+
511
+ res = solve_force_ud(
512
+ self.data, F, theta0=theta0, D=D, Lambda=Lambda,
513
+ integrator=integrator, n_substeps=n_substeps,
514
+ inner=inner, eiv=eiv, max_outer=max_outer,
515
+ inner_maxiter=inner_maxiter,
516
+ )
517
+ t_elapsed = _time.perf_counter() - t0
518
+
519
+ # ── Standard force attributes ──
520
+ theta_flat = res.theta
521
+ G = res.G
522
+
523
+ self.force_psf = F_psf
524
+ self.diffusion_average = res.D
525
+ # Callable constant-D field, so the full result surface (incl.
526
+ # simulate_bootstrapped_trajectory) works without a separate
527
+ # compute_diffusion_constant() call; a later infer_diffusion()
528
+ # overwrites it with the state-dependent fit.
529
+ self.diffusion_inferred = constant_array(res.D)
530
+ self.A = 2 * res.D
531
+ self.A_inv = jnp.linalg.inv(self.A)
532
+ self.Lambda = res.Lambda
533
+ self.Lambda_trace = float(jnp.trace(res.Lambda))
534
+
535
+ self.force_G = G
536
+ # Parameter covariance from the solver: the sandwich G⁻¹HG⁻ᵀ on the
537
+ # IV (eiv) path, the inverse information on the symmetric path.
538
+ G_inv = None
539
+ if bool(jnp.all(jnp.isfinite(res.theta_cov))):
540
+ G_inv = res.theta_cov
541
+ self.force_G_pinv = G_inv
542
+
543
+ self.force_coefficients_full = theta_flat
544
+ self.force_coefficients = theta_flat
545
+ self.force_support = jnp.arange(theta_flat.size)
546
+ self.force_moments = G @ theta_flat
547
+
548
+ # ── PASTIS plumbing (Basis mode only) ──
549
+ if basis_mode:
550
+ from SFI.inference.sparse import SparseScorer
551
+
552
+ self.force_G_full = G
553
+ self.force_scorer = SparseScorer(
554
+ M=self.force_moments,
555
+ G=self.force_G_full,
556
+ )
557
+
558
+ sf_F = SF(F_psf, F_psf.unflatten_params(theta_flat))
559
+ beta = float(jnp.trace(res.Lambda) / (jnp.trace(res.D) * dt_val + 1e-30))
560
+
561
+ meta_F = dict(
562
+ kind="force",
563
+ inference="parametric",
564
+ n_params=int(theta_flat.size),
565
+ beta=beta,
566
+ A_inv=jnp.asarray(self.A_inv),
567
+ )
568
+ self.force_inferred = InferenceResultSF(
569
+ sf_F,
570
+ param_cov=G_inv,
571
+ meta=meta_F,
572
+ )
573
+
574
+ self.metadata["force_method"] = "parametric"
575
+ self.metadata["force_parametric_info"] = {
576
+ **res.info,
577
+ "integrator": integrator,
578
+ "beta": beta,
579
+ "D_matrix": res.D,
580
+ "Lambda": res.Lambda,
581
+ }
582
+
583
+ logger.info(
584
+ "[uli_parametric] Done in %.1f s. Λ=%s, D=%s, β=%.3f",
585
+ t_elapsed,
586
+ jnp.diag(res.Lambda),
587
+ jnp.diag(res.D),
588
+ beta,
589
+ )
590
+
591
+ # ── State-dependent diffusion regression ──
592
+
593
+ def infer_diffusion(
594
+ self,
595
+ basis=None,
596
+ *,
597
+ theta_D0=None,
598
+ integrator: str = "rk4",
599
+ n_substeps: int = 1,
600
+ maxiter: int = 100,
601
+ ) -> None:
602
+ r"""Infer state- and velocity-dependent diffusion D(x, v).
603
+
604
+ Requires a prior parametric :meth:`infer_force` call. Holds the
605
+ fitted force fixed and minimises the pentadiagonal windowed
606
+ conditional NLL over the diffusion parameters, with ``D(x, v;
607
+ θ_D)`` evaluated at the shooting velocity.
608
+
609
+ .. physics:: State-dependent diffusion inference (underdamped)
610
+ :label: parametric-diffusion-underdamped
611
+ :category: Inference
612
+
613
+ With the force :math:`\hat F(x,v)` held fixed, the
614
+ state-dependent diffusion :math:`D(x,v;\theta_D)` is optimised
615
+ by minimising the windowed conditional NLL on pentadiagonal
616
+ (bandwidth-2) covariance windows; :math:`\Lambda` from the
617
+ force inference is held fixed.
618
+
619
+ Parameters
620
+ ----------
621
+ basis : Basis (rank 2), PSF, or None
622
+ Diffusion model. A rank-2 ``Basis`` gives the linear
623
+ parameterisation ``D = Σ_j θ_j d_j``; a ``PSF`` (optionally
624
+ ``needs_v=True``) is used directly. When ``None`` (default),
625
+ ``symmetric_matrix_basis(d)`` — all constant symmetric
626
+ ``d×d`` diffusion matrices.
627
+ theta_D0 : dict, array, or None
628
+ Initial diffusion parameters (default zeros).
629
+ integrator : {"rk4", "euler"}
630
+ Flow predictor (default ``"rk4"``, matching :meth:`infer_force`).
631
+ n_substeps : int
632
+ Integrator micro-steps per Δt (default 1).
633
+ maxiter : int
634
+ L-BFGS maximum iterations (default 100).
635
+
636
+ Updates
637
+ -------
638
+ Sets ``diffusion_inferred``, ``diffusion_coefficients`` (and
639
+ ``diffusion_basis`` when ``basis`` is a ``Basis``), plus metadata.
640
+
641
+ See Also
642
+ --------
643
+ :ref:`parametric-algorithm` : Full algorithm description.
644
+ """
645
+ from SFI.bases import symmetric_matrix_basis
646
+ from SFI.inference.parametric_core.solve import _as_psf, solve_diffusion_ud
647
+
648
+ if not hasattr(self, "force_inferred"):
649
+ raise RuntimeError("infer_diffusion() requires a prior infer_force() call.")
650
+ if self.metadata.get("force_method") not in ("parametric", "parametric_core"):
651
+ raise RuntimeError(
652
+ "infer_diffusion() requires parametric force inference (call infer_force(), not infer_force_linear)."
653
+ )
654
+
655
+ from SFI.statefunc import SF
656
+ from SFI.statefunc.basis import Basis
657
+
658
+ d = self.data.datasets[0].X.shape[-1]
659
+ if basis is None:
660
+ basis = symmetric_matrix_basis(d)
661
+ if isinstance(basis, Basis):
662
+ self._validate_basis(basis, expected_rank=2, label="diffusion basis")
663
+ self.diffusion_basis = basis
664
+ D_psf = _as_psf(basis)
665
+ Lambda = getattr(self, "Lambda", None)
666
+ if Lambda is None:
667
+ Lambda = jnp.zeros((d, d))
668
+
669
+ res = solve_diffusion_ud(
670
+ self.data, self.force_psf, self.force_coefficients_full, D_psf,
671
+ Lambda=Lambda, theta_D0=theta_D0,
672
+ n_substeps=n_substeps, integrator=integrator, maxiter=maxiter,
673
+ )
674
+ theta_D = res.theta_D
675
+ self.diffusion_coefficients = theta_D
676
+ self.diffusion_coefficients_full = theta_D
677
+ sf_D = SF(D_psf, D_psf.unflatten_params(theta_D))
678
+ meta_D = dict(
679
+ kind="diffusion",
680
+ inference="parametric",
681
+ n_params=int(D_psf.template.size),
682
+ nll=res.info["nll"],
683
+ )
684
+ self.diffusion_inferred = InferenceResultSF(
685
+ sf_D,
686
+ param_cov=None,
687
+ meta=meta_D,
688
+ )
689
+ self.metadata["diffusion_method"] = "parametric"
690
+ self.metadata["diffusion_parametric_info"] = dict(res.info)
691
+
692
+ def _force_G_matrix(self) -> jnp.ndarray:
693
+ if not hasattr(self, "A_inv"):
694
+ raise RuntimeError("A_inv not available. Compute diffusion first.")
695
+ b_left = self.force_basis
696
+ b_right = self.force_basis @ self.A_inv
697
+ return self.__G_matrix__(b_left, b_right, self.__force_G_mode__, "ima,imb->iab")
698
+
699
+ def _force_moments(self) -> jnp.ndarray:
700
+ r"""
701
+ ULI force moments expressed via Integrand.
702
+
703
+ With reconstructed kinematics (x̂, v̂, â):
704
+
705
+ M_a = < â · A_inv · b_a(x̂, v̂) > + w * < - D_inst : (A_inv · ∂_v b_a) >
706
+
707
+ where:
708
+ - b_a is the vector basis (i, dim, a)
709
+ - ∂_v b_a is the velocity-gradient basis (i, dim, dim, a)
710
+ - w = (1 + 2 l)/3 with l=1 (symmetric), l=0 (early), l=-1/2 (anticipated)
711
+ and the anticipated mode uses w=0 (no gradient correction).
712
+
713
+ .. physics:: Underdamped force moments (ULI)
714
+ :label: force-moments-underdamped
715
+ :category: Inference
716
+
717
+ .. math::
718
+
719
+ M_a = \bigl\langle \hat a_t \cdot A^{-1} \cdot b_a(\hat x_t, \hat v_t)
720
+ \bigr\rangle
721
+ \;+\; w \,\bigl\langle -D_{\text{inst}} : (A^{-1}\cdot\partial_v b_a)
722
+ \bigr\rangle
723
+
724
+ where :math:`w = (1+2\ell)/3`, with :math:`\ell=1` (symmetric),
725
+ :math:`\ell=0` (early), :math:`\ell=-\tfrac{1}{2}` (anticipated).
726
+
727
+ Contractions
728
+ ------------
729
+ - phi term:
730
+ eq='im,mn,ina->ia' with ops (â, A_inv, b)
731
+
732
+ - correction term:
733
+ eq='imn,no,imoa->ia' with ops (D_inst, A_inv, ∂_v b)
734
+ (the third axis of ∂_v b is the derivative index; we label it 'o' above).
735
+ """
736
+ if not hasattr(self, "A_inv"):
737
+ raise RuntimeError("A_inv not available. Compute diffusion first.")
738
+
739
+ mode = getattr(self, "__force_M_mode__", "symmetric")
740
+ if mode not in ("symmetric", "early", "anticipated"):
741
+ raise KeyError(f"Unknown __force_M_mode__: {mode}")
742
+
743
+ x_hat = self._get_x_hat(M_mode=mode)
744
+ v_hat = self._get_v_hat(M_mode=mode)
745
+ a_hat = self._get_a_hat(M_mode=mode)
746
+
747
+ # Main moment term: < â · A_inv · b >
748
+ A = ConstOperand(self.A_inv, alias="A")
749
+ a_op = TimeOperand(a_hat, alias="a")
750
+ B = ExprOperand(expr=self.force_basis, x=x_hat, v=v_hat, alias="B")
751
+
752
+ prog_phi = Integrand(
753
+ exprs=[B],
754
+ times=[a_op],
755
+ consts=[A],
756
+ terms=[Term(eq="im,mn,ina->ia", ops=("a", "A", "B"))],
757
+ )
758
+ phi_moments = integrate(self.data, prog_phi, reduce="sum", chunk_target_bytes=self._chunk_target_bytes)
759
+ self._phi_moments = phi_moments
760
+ # Determine w prefactor
761
+ if mode == "symmetric":
762
+ lam = 1.0
763
+ elif mode == "early":
764
+ lam = 0.0
765
+ else: # anticipated
766
+ lam = -0.5
767
+ w_pref = (1.0 + 2.0 * lam) / 3.0
768
+
769
+ # In anticipated mode, w_pref==0 and we skip the correction term.
770
+ if w_pref == 0.0:
771
+ return phi_moments
772
+
773
+ logger.debug("Computing ULI correction term (D_inst × grad_v basis).")
774
+
775
+ D_op = self.get_diffusion_timeop(getattr(self, "__force_diffusion_method__", "noisy"))
776
+ # For pair-dispatched bases (pdepth>=1), the Ito correction needs
777
+ # the same-particle velocity Jacobian ∂f_i/∂v_i (not cross ∂f_i/∂v_j).
778
+ _same = getattr(self.force_basis, "pdepth", 0) >= 1
779
+ Gv = ExprOperand(
780
+ expr=self.force_basis.d_v(same_particle=_same),
781
+ x=x_hat,
782
+ v=v_hat,
783
+ alias="Gv",
784
+ )
785
+
786
+ prog_w = Integrand(
787
+ exprs=[Gv],
788
+ times=[D_op],
789
+ consts=[A],
790
+ terms=[
791
+ Term(
792
+ eq="imn,no,imoa->ia",
793
+ ops=(D_op.alias, "A", "Gv"),
794
+ scale=-float(w_pref),
795
+ )
796
+ ],
797
+ )
798
+ w_moments = integrate(self.data, prog_w, reduce="sum", chunk_target_bytes=self._chunk_target_bytes)
799
+ self._w_moments = w_moments
800
+
801
+ return phi_moments + w_moments
802
+
803
+ def _diffusion_G_matrix(self) -> jnp.ndarray:
804
+ # Diffusion is per-point: weight the projection per-point (weight_by_dt=False).
805
+ return self.__G_matrix__(
806
+ self.diffusion_basis,
807
+ self.diffusion_basis,
808
+ self.__diffusion_G_mode__,
809
+ "imna,imnb->iab",
810
+ weight_by_dt=False,
811
+ )
812
+
813
+ def _diffusion_moments(self) -> jnp.ndarray:
814
+ """
815
+ ULI diffusion linear moments.
816
+
817
+ For each method, define kinematics (X_t, V_t) and use the corresponding instantaneous
818
+ diffusion estimator D_inst(t):
819
+
820
+ noisy:
821
+ X_t = 0.25*(X + X_minus + X_plus + X_plusplus)
822
+ V_t = (4 dX + dX_plus + dX_minus) / (6 dt)
823
+ MSD:
824
+ X_t = (X + X_minus + X_plus)/3
825
+ V_t = (dX + dX_minus)/(2 dt)
826
+ WeakNoise:
827
+ X_t = (X + X_plus)/2
828
+ V_t = dX/dt
829
+
830
+ Then:
831
+ M_a = < B_a(X_t, V_t) : D_inst(t) >
832
+ = sum_t sum_i einsum('imna,imn->ia', B, D_inst)
833
+ """
834
+ method = getattr(self, "__diffusion_M_mode__", None)
835
+ if method is None:
836
+ raise RuntimeError("Missing __diffusion_M_mode__. Call infer_diffusion_linear() first.")
837
+
838
+ if method == "noisy":
839
+ x_hat = _X_noisy_uli
840
+ v_hat = _V_noisy_uli
841
+ elif method == "MSD":
842
+ # Reuse the symmetric ULI reconstruction
843
+ x_hat = _X_sym_uli
844
+ v_hat = _V_sym_uli
845
+ elif method == "WeakNoise":
846
+ x_hat = _X_weaknoise_uli
847
+ v_hat = velocity("dX", "dt")
848
+ else:
849
+ raise KeyError(f"Unknown underdamped diffusion moment method: {method!r}")
850
+
851
+ logger.debug("Computing diffusion linear moments (method=%s).", method)
852
+
853
+ # Instantaneous diffusion estimator matching the method string
854
+ D_op = self.get_diffusion_timeop(method)
855
+
856
+ # Evaluate diffusion basis on (x_hat, v_hat)
857
+ B = ExprOperand(expr=self.diffusion_basis, x=x_hat, v=v_hat, alias="B")
858
+
859
+ prog = Integrand(
860
+ exprs=[B],
861
+ times=[D_op],
862
+ terms=[Term(eq="imna,imn->ia", ops=("B", D_op.alias))],
863
+ )
864
+ # Per-point (weight_by_dt=False): each increment's diffusion estimate counts equally.
865
+ return integrate(self.data, prog, reduce="sum", weight_by_dt=False,
866
+ chunk_target_bytes=self._chunk_target_bytes)
867
+
868
+ def _update_force_inferred(self) -> None:
869
+ """
870
+ Materialize fitted force as SF and wrap in InferenceResultSF.
871
+ """
872
+ P: PSF = self.force_basis.to_psf()
873
+ theta = {"coeff": jnp.asarray(self.force_coefficients_full)}
874
+ sf = SF(P, theta)
875
+
876
+ meta = dict(
877
+ kind="force",
878
+ modes=dict(
879
+ M=getattr(self, "__force_M_mode__", None),
880
+ G=getattr(self, "__force_G_mode__", None),
881
+ ),
882
+ diffusion_method=getattr(self, "__force_diffusion_method__", None),
883
+ A_inv=jnp.asarray(getattr(self, "A_inv", None)) if hasattr(self, "A_inv") else None,
884
+ basis_features=int(getattr(self.force_basis, "n_features", 0)),
885
+ basis_labels=getattr(self.force_basis, "labels", None),
886
+ )
887
+ cov = getattr(self, "force_coefficients_covariance", None)
888
+ self.force_inferred = InferenceResultSF(sf, param_cov=cov, meta=meta)
889
+
890
+ def _update_diffusion_inferred(self) -> None:
891
+ """
892
+ Materialize fitted diffusion as SF and wrap in InferenceResultSF.
893
+ """
894
+ if self.diffusion_basis is None:
895
+ raise RuntimeError("_update_diffusion_inferred called before diffusion was fitted.")
896
+ P: PSF = self.diffusion_basis.to_psf()
897
+ theta = {"coeff": jnp.asarray(self.diffusion_coefficients_full)}
898
+ sf = SF(P, theta)
899
+
900
+ meta = dict(
901
+ kind="diffusion",
902
+ modes=dict(
903
+ M=getattr(self, "__diffusion_M_mode__", None),
904
+ G=getattr(self, "__diffusion_G_mode__", None),
905
+ ),
906
+ A_inv=jnp.asarray(getattr(self, "A_inv", None)) if hasattr(self, "A_inv") else None,
907
+ basis_features=int(getattr(self.diffusion_basis, "n_features", 0)),
908
+ basis_labels=getattr(self.diffusion_basis, "labels", None),
909
+ )
910
+ cov = getattr(self, "diffusion_coefficients_cov", None)
911
+ self.diffusion_inferred = InferenceResultSF(sf, param_cov=cov, meta=meta)
912
+
913
+ # ------------------------------ shared helpers ------------------------------ #
914
+
915
+ def __G_matrix__(
916
+ self,
917
+ b_left: callable,
918
+ b_right: callable,
919
+ G_mode: str,
920
+ einsum_string: str,
921
+ subsampling: int = 1,
922
+ weight_by_dt: bool = True,
923
+ ) -> jnp.ndarray:
924
+ """
925
+ ULI Gram matrix.
926
+
927
+ Uses secant velocities from raw displacements:
928
+ V(t) = dX/dt
929
+ V_minus(t)= dX_minus/dt
930
+ V_plus(t) = dX_plus/dt
931
+
932
+ Modes — the returned Gram is the transpose Gᵀ of the tabulated outer
933
+ product. The averaging is asymmetric in the left/right factors, and
934
+ grid assessments (VdP, OU) show Gᵀ is the better-conditioned regressor,
935
+ so it is the canonical form:
936
+
937
+ rectangle: < bL(X, V) ⊗ bR(X, V) >
938
+ shift: < bL(X, V) ⊗ bR(X_minus, V_minus) >
939
+ trapeze: < bL(X, V) ⊗ 0.5*(bR(X, V) + bR(X_minus, V_minus)) >
940
+ doubleshift: < bL(X_plus, V_plus) ⊗ bR(X_minus, V_minus) >
941
+ """
942
+ logger.debug(
943
+ "Computing ULI G matrix (mode=%s) with einsum: %s",
944
+ G_mode,
945
+ einsum_string,
946
+ )
947
+
948
+ # Rectangle program
949
+ BL = ExprOperand(expr=b_left, x=stream("X"), v=velocity("dX", "dt"), alias="BL")
950
+ BR0 = ExprOperand(expr=b_right, x=stream("X"), v=velocity("dX", "dt"), alias="BR0")
951
+ prog_rect = Integrand(exprs=[BL, BR0], terms=[Term(eq=einsum_string, ops=("BL", "BR0"))])
952
+
953
+ if G_mode == "rectangle":
954
+ prog = prog_rect
955
+
956
+ elif G_mode in ("trapeze", "shift", "doubleshift"):
957
+ BRm = ExprOperand(
958
+ expr=b_right,
959
+ x=stream("X_minus"),
960
+ v=velocity("dX_minus", "dt"),
961
+ alias="BRm",
962
+ )
963
+ prog_shift = Integrand(exprs=[BL, BRm], terms=[Term(eq=einsum_string, ops=("BL", "BRm"))])
964
+
965
+ if G_mode == "shift":
966
+ prog = prog_shift
967
+ elif G_mode == "trapeze":
968
+ prog = 0.5 * (prog_rect + prog_shift)
969
+ else: # doubleshift
970
+ BLp = ExprOperand(
971
+ expr=b_left,
972
+ x=stream("X_plus"),
973
+ v=velocity("dX_plus", "dt"),
974
+ alias="BLp",
975
+ )
976
+ prog = Integrand(exprs=[BLp, BRm], terms=[Term(eq=einsum_string, ops=("BLp", "BRm"))])
977
+
978
+ else:
979
+ raise KeyError(f"Wrong G_mode argument for ULI: {G_mode}")
980
+
981
+ # The ULI Gram is returned transposed (Gᵀ): the averaging is asymmetric
982
+ # in the (left, right) factors and Gᵀ is the better-conditioned regressor.
983
+ G = integrate(
984
+ self.data,
985
+ prog,
986
+ reduce="sum",
987
+ reduce_over_particles=True,
988
+ subsampling=subsampling,
989
+ weight_by_dt=weight_by_dt,
990
+ chunk_target_bytes=self._chunk_target_bytes,
991
+ )
992
+ # ``integrate`` returns the scalar ``0.0`` when the plan has no chunks,
993
+ # i.e. the collection yielded no valid frames for this integrand. Feeding
994
+ # that scalar to ``swapaxes`` below would raise a cryptic
995
+ # "index -2 is out of bounds for axis 0 with size 0". Surface the real
996
+ # cause instead.
997
+ if jnp.ndim(G) < 2:
998
+ raise ValueError(
999
+ f"ULI Gram integration (G_mode={G_mode!r}) found no valid frames: "
1000
+ "the collection produced zero usable rows for this basis, so the "
1001
+ "Gram collapsed to a scalar instead of an (i, a, b) array. Common "
1002
+ "causes: the trajectory is too short for the requested stencil "
1003
+ "(secant velocities need a neighbouring frame), or — for an "
1004
+ "interaction/structural basis — the collection is missing the "
1005
+ "reserved structural extras the basis needs (frequent with "
1006
+ "bootstrapped collections built without those extras). Verify the "
1007
+ "collection has valid frames and carries the extras the basis requires."
1008
+ )
1009
+ return jnp.swapaxes(G, -1, -2)
1010
+
1011
+ # ------------------------------ diffusion timeops ------------------------------ #
1012
+
1013
+ def _build_diffusion_timeoperands(self) -> None:
1014
+ if hasattr(self, "_diff_ops"):
1015
+ return
1016
+ self._diff_ops = {
1017
+ "MSD": TimeOperand(_D_msd_uli, alias="D_msd"),
1018
+ "WeakNoise": TimeOperand(_D_weaknoise_uli, alias="D_weaknoise"),
1019
+ "noisy": TimeOperand(_D_noisy_uli, alias="D_noisy"),
1020
+ "Lambda": TimeOperand(_Lambda_uli, alias="Lambda"),
1021
+ }
1022
+
1023
+ def get_diffusion_timeop(self, method: str) -> TimeOperand:
1024
+ self._build_diffusion_timeoperands()
1025
+ try:
1026
+ return self._diff_ops[method] # type: ignore[attr-defined]
1027
+ except KeyError as e:
1028
+ raise KeyError(f"Unknown underdamped diffusion estimator method: {method}") from e
1029
+
1030
+ # ------------------------------ kinematics selectors ------------------------------ #
1031
+
1032
+ def _build_kinematics_timeoperands(self) -> None:
1033
+ if hasattr(self, "_kin_ops"):
1034
+ return
1035
+ self._kin_ops = {
1036
+ "x": {
1037
+ "symmetric": _X_sym_uli,
1038
+ "early": _X_early_uli,
1039
+ "anticipated": _X_anticipated_uli,
1040
+ },
1041
+ "v": {
1042
+ "symmetric": _V_sym_uli,
1043
+ "early": _V_early_uli,
1044
+ "anticipated": _V_anticipated_uli,
1045
+ },
1046
+ "a": {
1047
+ "symmetric": _A_sym_uli,
1048
+ "early": _A_sym_uli,
1049
+ "anticipated": _A_anticipated_uli,
1050
+ },
1051
+ }
1052
+
1053
+ def _get_x_hat(self, *, M_mode: str, at: str = "t") -> TimeOp:
1054
+ if at != "t":
1055
+ raise NotImplementedError("x_hat shifting not implemented yet (only at='t').")
1056
+ self._build_kinematics_timeoperands()
1057
+ return self._kin_ops["x"][M_mode] # type: ignore[index]
1058
+
1059
+ def _get_v_hat(self, *, M_mode: str, at: str = "t") -> TimeOp:
1060
+ if at != "t":
1061
+ raise NotImplementedError("v_hat shifting not implemented yet (only at='t').")
1062
+ self._build_kinematics_timeoperands()
1063
+ return self._kin_ops["v"][M_mode] # type: ignore[index]
1064
+
1065
+ def _get_a_hat(self, *, M_mode: str, at: str = "t") -> TimeOp:
1066
+ if at != "t":
1067
+ raise NotImplementedError("a_hat shifting not implemented yet (only at='t').")
1068
+ self._build_kinematics_timeoperands()
1069
+ return self._kin_ops["a"][M_mode] # type: ignore[index]
1070
+
1071
+
1072
+ # -------------------- Underdamped (ULI) kinematics as TimeOps -------------------- #
1073
+ # .. physics:: ULI kinematic reconstructions
1074
+ # :label: uli-kinematics
1075
+ # :category: Kinematics
1076
+ #
1077
+ # The underdamped inference reconstructs (x̂, v̂, â) from observed positions
1078
+ # via finite differences. Three modes:
1079
+ #
1080
+ # Symmetric: x̂ = (x_{t-1}+x_t+x_{t+1})/3, v̂ = (dX+dX⁻)/(2dt), â = (dX−dX⁻)/dt²
1081
+ # Early: x̂ = x_t, v̂ = dX⁻/dt
1082
+ # Anticipated: x̂ = (x_t+x_{t+1}+x_{t+2})/3, â = (dX⁺−dX)/dt²
1083
+
1084
+
1085
+ @timeop(name="X_sym_uli")
1086
+ def _X_sym_uli(**streams):
1087
+ r"""
1088
+ .. physics:: Symmetric ULI kinematic reconstruction
1089
+ :label: uli-kinematics-symmetric
1090
+ :category: Kinematics
1091
+
1092
+ .. math::
1093
+
1094
+ \\hat x(t) = \\tfrac{1}{3}\\bigl[x_{t-1}+x_t+x_{t+1}\\bigr],
1095
+ \\quad
1096
+ \\hat v(t) = \\frac{\\mathrm{d}X_t+\\mathrm{d}X_{t-1}}{2\\,\\mathrm{d}t},
1097
+ \\quad
1098
+ \\hat a(t) = \\frac{\\mathrm{d}X_t - \\mathrm{d}X_{t-1}}{\\mathrm{d}t^2}
1099
+ """
1100
+ # x̂(t) = (x(t-1) + x(t) + x(t+1)) / 3
1101
+ return (streams["X"] + streams["X_minus"] + streams["X_plus"]) / 3.0
1102
+
1103
+
1104
+ _X_sym_uli._requires = frozenset({"X", "X_minus", "X_plus"}) # type: ignore[attr-defined]
1105
+
1106
+
1107
+ @timeop(name="X_early_uli")
1108
+ def _X_early_uli(**streams):
1109
+ # x̂(t) = x(t)
1110
+ return streams["X"]
1111
+
1112
+
1113
+ _X_early_uli._requires = frozenset({"X"}) # type: ignore[attr-defined]
1114
+
1115
+
1116
+ @timeop(name="X_anticipated_uli")
1117
+ def _X_anticipated_uli(**streams):
1118
+ # x̂(t) = (x(t) + x(t+1) + x(t+2)) / 3
1119
+ return (streams["X"] + streams["X_plus"] + streams["X_plusplus"]) / 3.0
1120
+
1121
+
1122
+ _X_anticipated_uli._requires = frozenset({"X", "X_plus", "X_plusplus"}) # type: ignore[attr-defined]
1123
+
1124
+
1125
+ @timeop(name="V_sym_uli")
1126
+ def _V_sym_uli(**streams):
1127
+ # v̂(t) = (dX(t) + dX_minus(t)) / (2 dt)
1128
+ dt = streams["dt"][..., None, None]
1129
+ return 0.5 * (streams["dX"] + streams["dX_minus"]) / dt
1130
+
1131
+
1132
+ _V_sym_uli._requires = frozenset({"dX", "dX_minus", "dt"}) # type: ignore[attr-defined]
1133
+
1134
+
1135
+ @timeop(name="V_early_uli")
1136
+ def _V_early_uli(**streams):
1137
+ # v̂(t) = dX_minus(t) / dt
1138
+ dt = streams["dt"][..., None, None]
1139
+ return streams["dX_minus"] / dt
1140
+
1141
+
1142
+ _V_early_uli._requires = frozenset({"dX_minus", "dt"}) # type: ignore[attr-defined]
1143
+
1144
+
1145
+ @timeop(name="V_anticipated_uli")
1146
+ def _V_anticipated_uli(**streams):
1147
+ # v̂(t) = dX_minus(t) / dt
1148
+ dt = streams["dt"][..., None, None]
1149
+ return streams["dX_minus"] / dt
1150
+
1151
+
1152
+ _V_anticipated_uli._requires = frozenset({"dX_minus", "dt"}) # type: ignore[attr-defined]
1153
+
1154
+
1155
+ @timeop(name="A_sym_uli")
1156
+ def _A_sym_uli(**streams):
1157
+ # â(t) = (dX(t) - dX_minus(t)) / dt^2
1158
+ dt = streams["dt"][..., None, None]
1159
+ return (streams["dX"] - streams["dX_minus"]) / (dt**2)
1160
+
1161
+
1162
+ _A_sym_uli._requires = frozenset({"dX", "dX_minus", "dt"}) # type: ignore[attr-defined]
1163
+
1164
+
1165
+ @timeop(name="A_anticipated_uli")
1166
+ def _A_anticipated_uli(**streams):
1167
+ # â(t) = (dX_plus(t) - dX(t)) / dt^2
1168
+ dt = streams["dt"][..., None, None]
1169
+ return (streams["dX_plus"] - streams["dX"]) / (dt**2)
1170
+
1171
+
1172
+ _A_anticipated_uli._requires = frozenset({"dX_plus", "dX", "dt"}) # type: ignore[attr-defined]
1173
+
1174
+ # -------------------- Underdamped (ULI) diffusion kinematics as TimeOps -------------------- #
1175
+
1176
+
1177
+ @timeop(name="X_noisy_uli")
1178
+ def _X_noisy_uli(**streams):
1179
+ # X_t (noisy diffusion): 0.25*(X + X_minus + X_plus + X_plusplus)
1180
+ return 0.25 * (streams["X"] + streams["X_minus"] + streams["X_plus"] + streams["X_plusplus"])
1181
+
1182
+
1183
+ _X_noisy_uli._requires = frozenset({"X", "X_minus", "X_plus", "X_plusplus"}) # type: ignore[attr-defined]
1184
+
1185
+
1186
+ @timeop(name="V_noisy_uli")
1187
+ def _V_noisy_uli(**streams):
1188
+ # V_t (noisy diffusion): (4 dX + dX_plus + dX_minus) / (6 dt)
1189
+ dt = streams["dt"][..., None, None]
1190
+ return (4.0 * streams["dX"] + streams["dX_plus"] + streams["dX_minus"]) / (6.0 * dt)
1191
+
1192
+
1193
+ _V_noisy_uli._requires = frozenset({"dX", "dX_plus", "dX_minus", "dt"}) # type: ignore[attr-defined]
1194
+
1195
+
1196
+ @timeop(name="X_weaknoise_uli")
1197
+ def _X_weaknoise_uli(**streams):
1198
+ # X_t (WeakNoise diffusion): 0.5*(X + X_plus)
1199
+ return 0.5 * (streams["X"] + streams["X_plus"])
1200
+
1201
+
1202
+ _X_weaknoise_uli._requires = frozenset({"X", "X_plus"}) # type: ignore[attr-defined]
1203
+
1204
+ # -------------------- Underdamped (ULI) diffusion estimators as TimeOps -------------------- #
1205
+
1206
+
1207
+ def _symmetrize_imn(M: jnp.ndarray) -> jnp.ndarray:
1208
+ return 0.5 * (M + jnp.swapaxes(M, -1, -2))
1209
+
1210
+
1211
+ @timeop(name="D_msd_uli")
1212
+ def _D_msd_uli(**streams):
1213
+ r"""
1214
+ ULI 'MSD' instantaneous diffusion estimator:
1215
+ D = (3/4) * ( (dX - dX_minus) ⊗ (dX - dX_minus) ) / dt^3
1216
+ Returns (N, d, d).
1217
+
1218
+ .. physics:: MSD diffusion estimator (underdamped)
1219
+ :label: D-msd-underdamped
1220
+ :category: Estimator
1221
+
1222
+ .. math::
1223
+
1224
+ \hat D_{\text{MSD}}^{\text{ULI}}(t)
1225
+ = \frac{3}{4}\,
1226
+ \frac{(\mathrm{d}X_t - \mathrm{d}X_{t-1})
1227
+ \otimes (\mathrm{d}X_t - \mathrm{d}X_{t-1})}
1228
+ {\mathrm{d}t^3}
1229
+ """
1230
+ dX = streams["dX"]
1231
+ dXm = streams["dX_minus"]
1232
+ ddX = dX - dXm
1233
+ dt = streams["dt"][..., None, None]
1234
+ return 0.75 * jnp.einsum("im,in->imn", ddX, ddX) / (dt**3)
1235
+
1236
+
1237
+ _D_msd_uli._requires = frozenset({"dX", "dX_minus", "dt"}) # type: ignore[attr-defined]
1238
+
1239
+
1240
+ @timeop(name="D_weaknoise_uli")
1241
+ def _D_weaknoise_uli(**streams):
1242
+ r"""
1243
+ ULI 'WeakNoise' estimator:
1244
+ D = (1/2) * ( (2 dX - dX_minus - dX_plus) ⊗ (2 dX - dX_minus - dX_plus) ) / dt^3
1245
+ Returns (N, d, d).
1246
+
1247
+ .. physics:: Weak-noise diffusion estimator (underdamped)
1248
+ :label: D-weaknoise-underdamped
1249
+ :category: Estimator
1250
+
1251
+ .. math::
1252
+
1253
+ \hat D_{\text{WN}}^{\text{ULI}}(t)
1254
+ = \frac{1}{2}\,
1255
+ \frac{(2\,\mathrm{d}X_t - \mathrm{d}X_{t-1} - \mathrm{d}X_{t+1})
1256
+ \otimes (\cdots)}{\mathrm{d}t^3}
1257
+ """
1258
+ dX = streams["dX"]
1259
+ dXm = streams["dX_minus"]
1260
+ dXp = streams["dX_plus"]
1261
+ u = 2.0 * dX - dXm - dXp
1262
+ dt = streams["dt"][..., None, None]
1263
+ return 0.5 * jnp.einsum("im,in->imn", u, u) / (dt**3)
1264
+
1265
+
1266
+ _D_weaknoise_uli._requires = frozenset({"dX", "dX_minus", "dX_plus", "dt"}) # type: ignore[attr-defined]
1267
+
1268
+
1269
+ @timeop(name="D_noisy_uli")
1270
+ def _D_noisy_uli(**streams):
1271
+ r"""
1272
+ ULI 'noisy' diffusion estimator:
1273
+ D = (3 / (11 dt^3)) * sym( -a + b + c - 3d + e + f )
1274
+ with:
1275
+ a = dX ⊗ dX
1276
+ b = dX_minus ⊗ dX_minus
1277
+ c = dX_plus ⊗ dX_plus
1278
+ d = dX_plus ⊗ dX_minus
1279
+ e = dX ⊗ dX_plus
1280
+ f = dX ⊗ dX_minus
1281
+ Returns (N, d, d).
1282
+
1283
+ .. physics:: Noisy diffusion estimator (underdamped)
1284
+ :label: D-noisy-underdamped
1285
+ :category: Estimator
1286
+
1287
+ .. math::
1288
+
1289
+ \hat D_{\text{noisy}}^{\text{ULI}}(t)
1290
+ = \frac{3}{11\,\mathrm{d}t^3}\,\operatorname{sym}\!
1291
+ \bigl[-a + b + c - 3d + e + f\bigr]
1292
+
1293
+ where :math:`a = \mathrm{d}X_t\otimes\mathrm{d}X_t`,
1294
+ :math:`b = \mathrm{d}X_{t-1}\otimes\mathrm{d}X_{t-1}`, etc.
1295
+ Optimally handles both signal and localization noise.
1296
+ """
1297
+ dX = streams["dX"]
1298
+ dXm = streams["dX_minus"]
1299
+ dXp = streams["dX_plus"]
1300
+
1301
+ a = jnp.einsum("im,in->imn", dX, dX)
1302
+ b = jnp.einsum("im,in->imn", dXm, dXm)
1303
+ c = jnp.einsum("im,in->imn", dXp, dXp)
1304
+ d = jnp.einsum("im,in->imn", dXp, dXm)
1305
+ e = jnp.einsum("im,in->imn", dX, dXp)
1306
+ f = jnp.einsum("im,in->imn", dX, dXm)
1307
+
1308
+ M = (-1.0) * a + (1.0) * b + (1.0) * c + (-3.0) * d + (1.0) * e + (1.0) * f
1309
+ M = _symmetrize_imn(M)
1310
+
1311
+ dt = streams["dt"][..., None, None]
1312
+ return (3.0 / 11.0) * M / (dt**3)
1313
+
1314
+
1315
+ _D_noisy_uli._requires = frozenset({"dX", "dX_minus", "dX_plus", "dt"}) # type: ignore[attr-defined]
1316
+
1317
+
1318
+ @timeop(name="Lambda_uli")
1319
+ def _Lambda_uli(**streams):
1320
+ r"""
1321
+ ULI 'Lambda' instantaneous measurement-noise estimator:
1322
+ Λ = sym( 10a + b + c + 8d - 10e - 10f ) / 44
1323
+ with the same (a..f) definitions as in _D_noisy_uli.
1324
+ Returns (N, d, d).
1325
+
1326
+ .. physics:: Measurement noise estimator (underdamped)
1327
+ :label: Lambda-underdamped
1328
+ :category: Estimator
1329
+
1330
+ .. math::
1331
+
1332
+ \hat\Lambda^{\text{ULI}}
1333
+ = \frac{1}{44}\,\operatorname{sym}\!
1334
+ \bigl[10a + b + c + 8d - 10e - 10f\bigr]
1335
+
1336
+ Same displacement products :math:`(a\ldots f)` as the noisy
1337
+ diffusion estimator. Extracts localization noise for
1338
+ underdamped systems.
1339
+ """
1340
+ dX = streams["dX"]
1341
+ dXm = streams["dX_minus"]
1342
+ dXp = streams["dX_plus"]
1343
+
1344
+ a = jnp.einsum("im,in->imn", dX, dX)
1345
+ b = jnp.einsum("im,in->imn", dXm, dXm)
1346
+ c = jnp.einsum("im,in->imn", dXp, dXp)
1347
+ d = jnp.einsum("im,in->imn", dXp, dXm)
1348
+ e = jnp.einsum("im,in->imn", dX, dXp)
1349
+ f = jnp.einsum("im,in->imn", dX, dXm)
1350
+
1351
+ M = (10.0) * a + (1.0) * b + (1.0) * c + (8.0) * d + (-10.0) * e + (-10.0) * f
1352
+ return _symmetrize_imn(M) / 44.0
1353
+
1354
+
1355
+ _Lambda_uli._requires = frozenset({"dX", "dX_minus", "dX_plus"}) # type: ignore[attr-defined]