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
SFI/inference/base.py ADDED
@@ -0,0 +1,1460 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import logging
5
+ from abc import ABC, abstractmethod
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from SFI.inference.sparse import SparsityResult
11
+
12
+ import jax
13
+ import jax.numpy as jnp
14
+
15
+ from SFI.integrate.api import integrate
16
+ from SFI.integrate.integrand import (
17
+ ConstOperand,
18
+ ExprOperand,
19
+ Integrand,
20
+ Term,
21
+ TimeOperand,
22
+ )
23
+ from SFI.integrate.timeops import stream, timeop, velocity
24
+ from SFI.statefunc.nodes.interactions.prepare import (
25
+ prepare_collection_for_expr,
26
+ )
27
+ from SFI.utils.maths import stable_pinv
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class BaseLangevinInference(ABC):
33
+ """Stochastic Force Inference main class
34
+
35
+ This class provides tools for inferring force (drift) and
36
+ diffusion tensors from stochastic trajectory data based on
37
+ Langevin dynamics. It contains the shared logic for Overdamped and
38
+ Underdamped Langevin inference.
39
+
40
+ These subclasses must implement a handful of hooks that depend on
41
+ the physics (e.g. whether velocities are observed). The details of
42
+ the physics assumptions and definitions, as well as extensive doc
43
+ strings, are given in the headers of these classes.
44
+
45
+ Key Features
46
+ ------------
47
+
48
+ - **Force inference** — linear combination of basis functions
49
+ (``infer_force_linear`` with a ``Basis``, the canonical path) or
50
+ parametric families (``infer_force`` with a ``Basis`` or ``PSF``, the
51
+ single-step flow estimator with native (D, Λ) profiling).
52
+ - **Diffusion inference** — constant (``compute_diffusion_constant``) or
53
+ state-dependent via a linear basis (``infer_diffusion_linear``).
54
+ - **Sparsification** — pluggable strategies (beam search, greedy, STLSQ,
55
+ LASSO) with information-criterion selection (AIC, BIC, PASTIS).
56
+ - **Error estimation** — normalized mean-squared-error (NMSE) prediction
57
+ for force and diffusion.
58
+ - **Comparison** — evaluate inferred fields against known exact models
59
+ (``compare_to_exact``).
60
+ - **Simulation** — generate trajectories from inferred fields
61
+ (``simulate_bootstrapped_trajectory``).
62
+
63
+ Workflow
64
+ --------
65
+
66
+ 1. Initialize with a ``TrajectoryCollection`` holding the trajectory.
67
+ 2. Use the ``infer_*`` methods to infer force and diffusion fields.
68
+ 3. Optionally sparsify the results to mitigate overfitting.
69
+ 4. Optionally compute error estimates and/or compare with exact data.
70
+
71
+ Indices Convention
72
+ ------------------
73
+
74
+ The code uses ``jnp.einsum`` with a consistent index naming scheme:
75
+
76
+ - ``t`` — time index, 0..Ntimesteps-1.
77
+ - ``a, b, c...`` — basis-function indices, 0..Nfunctions-1.
78
+ - ``m, n, o...`` — state / spatial indices, 0..dim-1.
79
+ - ``i, j...`` — particle indices (size Nparticles, or 1 if there is no
80
+ particle structure).
81
+
82
+ These also serve as array-shape shorthands: e.g.
83
+ ``basis_linear : im -> iam`` means ``basis_linear`` takes an array of
84
+ shape (Nparticles, dim) and returns one of shape
85
+ (Nparticles, Nfunctions, dim).
86
+
87
+ Logging levels control output (configure via ``logging`` or
88
+ ``SFI.enable_logging()``):
89
+
90
+ - INFO -> inference steps, key results.
91
+ - DEBUG -> detailed computation progress.
92
+
93
+ """
94
+
95
+ def __init__(self, data, *, max_memory_gb=1.0, **kwargs):
96
+ """Initialize the inference object.
97
+
98
+ Parameters
99
+ ----------
100
+ data : TrajectoryCollection
101
+ The trajectory data to infer from.
102
+ max_memory_gb : float
103
+ Approximate memory budget for integration batches.
104
+
105
+ Raises
106
+ ------
107
+ TypeError
108
+ If ``data`` is not a ``TrajectoryCollection``.
109
+ """
110
+ from SFI.trajectory.collection import TrajectoryCollection
111
+
112
+ if not isinstance(data, TrajectoryCollection):
113
+ raise TypeError(
114
+ f"Expected a TrajectoryCollection, got {type(data).__name__}. "
115
+ f"Use TrajectoryCollection.from_dataset(ds) or "
116
+ f"TrajectoryCollection.from_arrays(X=..., dt=...) first."
117
+ )
118
+ self.data = data
119
+ self.max_memory_gb = max_memory_gb
120
+ self.metadata = {}
121
+
122
+ @property
123
+ def _chunk_target_bytes(self) -> int:
124
+ """Convert ``max_memory_gb`` to bytes for the integration engine."""
125
+ return max(1, int(self.max_memory_gb * 1024**3))
126
+
127
+ @contextlib.contextmanager
128
+ def _structural_scope(self, *exprs):
129
+ """Expose the structural (CSR/stencil) arrays required by ``exprs`` for the
130
+ duration of the block, without ever persisting them on the dataset.
131
+
132
+ On entry ``self.data`` is swapped for a transient collection carrying the
133
+ freshly-built structural arrays; on exit the original collection object is
134
+ restored. Nothing is left on the dataset, so a stale structural table can
135
+ never survive a later transform, and teardown is exception-safe (there is
136
+ no purge step to forget).
137
+ """
138
+ original = self.data
139
+ try:
140
+ exprs = tuple(e for e in exprs if e is not None)
141
+ if exprs:
142
+ self.data = prepare_collection_for_expr(original, *exprs)
143
+ yield
144
+ finally:
145
+ self.data = original
146
+
147
+ # ---- shared validation helpers ---------------------------------------- #
148
+
149
+ @staticmethod
150
+ def _validate_basis(basis, *, expected_rank: int, label: str = "basis"):
151
+ """Check that ``basis`` is a Basis with the correct rank.
152
+
153
+ Parameters
154
+ ----------
155
+ basis : object
156
+ Should be a ``Basis`` instance.
157
+ expected_rank : int
158
+ Required spatial rank (1 for force, 2 for diffusion).
159
+ label : str
160
+ Human-readable name for error messages.
161
+
162
+ Raises
163
+ ------
164
+ TypeError
165
+ If ``basis`` is not a ``Basis``.
166
+ ValueError
167
+ If the rank does not match ``expected_rank``.
168
+ """
169
+ from SFI.statefunc import Basis as _Basis
170
+
171
+ if not isinstance(basis, _Basis):
172
+ from SFI.statefunc import PSF as _PSF
173
+ from SFI.statefunc import SF as _SF
174
+
175
+ if isinstance(basis, (_PSF, _SF)):
176
+ raise TypeError(
177
+ f"`{label}` must be a Basis (deterministic dictionary), "
178
+ f"not a {type(basis).__name__}. "
179
+ f"For parametric inference with a PSF, use infer_force()."
180
+ )
181
+ raise TypeError(f"`{label}` must be a Basis object, got {type(basis).__name__}.")
182
+ if basis.rank != expected_rank:
183
+ raise ValueError(
184
+ f"`{label}` must have rank={expected_rank} "
185
+ f"(got rank={basis.rank}). "
186
+ + (
187
+ "Force bases should produce vectors (rank 1)."
188
+ if expected_rank == 1
189
+ else "Diffusion bases should produce matrices (rank 2)."
190
+ )
191
+ )
192
+
193
+ def sparsify_force(
194
+ self,
195
+ *,
196
+ criterion: str = "PASTIS",
197
+ p: float = 0.05,
198
+ method: str = "beam",
199
+ max_k: int | None = None,
200
+ **strategy_kwargs,
201
+ ) -> "SparsityResult":
202
+ """Sparsify the inferred force by selecting a subset of basis functions.
203
+
204
+ Builds a Pareto front of sparse models using the chosen
205
+ ``method``, then selects the model that maximises the given
206
+ information ``criterion``.
207
+
208
+ Parameters
209
+ ----------
210
+ criterion : ``"PASTIS"`` | ``"AIC"`` | ``"BIC"`` | ``"EBIC"`` | ``"SIC"``, default ``"PASTIS"``
211
+ Information criterion for model selection.
212
+ p : float, default 0.05
213
+ Prior-scale parameter :math:`p_0` for the PASTIS penalty.
214
+ method : str, default ``"beam"``
215
+ Search strategy. One of:
216
+
217
+ - ``"beam"`` — bidirectional beam search (PASTIS original).
218
+ Extra kwargs: ``beam_width`` (int, default 3),
219
+ ``aic_patience`` (int, default 2).
220
+ - ``"greedy"`` — forward stepwise selection.
221
+ Extra kwargs: ``direction`` (``"forward"`` | ``"backward"``
222
+ | ``"both"``, default ``"forward"``).
223
+ - ``"stlsq"`` — Sequential Thresholded Least Squares
224
+ (SINDy-style). Extra kwargs: ``threshold`` (float or None),
225
+ ``mode`` (``"relative"`` | ``"absolute"``),
226
+ ``n_thresholds`` (int).
227
+ - ``"lasso"`` — :math:`\\ell_1`-penalised coordinate descent.
228
+ Extra kwargs: ``alpha`` (float or None),
229
+ ``n_alphas`` (int).
230
+ - ``"hillclimb"`` — stochastic hill-climbing
231
+ (Gerardos & Ronceray, 2025). Extra kwargs: ``ic``,
232
+ ``patience`` (int), ``seed`` (int or None).
233
+ max_k : int or None
234
+ Maximum model size. Defaults to the full basis size.
235
+ **strategy_kwargs
236
+ Passed to the strategy constructor.
237
+
238
+ Returns
239
+ -------
240
+ SparsityResult
241
+ The full Pareto-front result, also stored as
242
+ ``self.force_sparsity_result``.
243
+ """
244
+ from SFI.inference.sparse import (
245
+ BeamSearchStrategy,
246
+ GreedyStepwiseStrategy,
247
+ HillClimbStrategy,
248
+ LassoStrategy,
249
+ STLSQStrategy,
250
+ )
251
+
252
+ scorer = self.force_scorer
253
+ if max_k is None:
254
+ max_k = scorer.p
255
+
256
+ # Build the strategy object
257
+ _strategies = {
258
+ "beam": BeamSearchStrategy,
259
+ "greedy": GreedyStepwiseStrategy,
260
+ "stlsq": STLSQStrategy,
261
+ "lasso": LassoStrategy,
262
+ "hillclimb": HillClimbStrategy,
263
+ }
264
+ key = method.lower()
265
+ if key not in _strategies:
266
+ raise ValueError(f"Unknown sparsity method {method!r}. Choose from {list(_strategies)}.")
267
+
268
+ # Default beam_width for beam search
269
+ if key == "beam":
270
+ strategy_kwargs.setdefault("beam_width", 3)
271
+ strategy_kwargs.setdefault("aic_patience", 2)
272
+ strategy_kwargs.setdefault("report_time", True)
273
+ elif key == "hillclimb":
274
+ # Hill-climb uses the selection criterion as its acceptance
275
+ # objective by default, so it stays consistent with the IC
276
+ # used at the final `select_by_ic` step.
277
+ strategy_kwargs.setdefault("ic", criterion)
278
+ strategy_kwargs.setdefault("p_param", p)
279
+
280
+ strategy = _strategies[key](**strategy_kwargs)
281
+ result: SparsityResult = strategy.run(scorer, max_k=max_k)
282
+
283
+ # Select the best model according to the information criterion.
284
+ # BIC/EBIC penalise by the total trajectory time tau (== Teff);
285
+ # supply it from the data so those criteria work out of the box.
286
+ tau = float(self.data.Teff({"dt"}))
287
+ k, support, score, coeffs = result.select_by_ic(criterion, p_param=p, tau=tau)
288
+ self._update_force_coefficients(coeffs, support)
289
+ self.force_sparsity_result = result
290
+ return result
291
+
292
+ ########################## ERROR ANALYSIS ###########################
293
+
294
+ def compute_force_error(self):
295
+ r"""
296
+ Estimate sampling error for force inference.
297
+
298
+ .. physics:: Force coefficient covariance & predicted error
299
+ :label: force-error-covariance
300
+ :category: Error analysis
301
+
302
+ .. math::
303
+
304
+ \operatorname{Cov}(C) = G^{-1},
305
+ \qquad
306
+ \mathbb{E}\!\left[\langle \delta F^\top A^{-1} \delta F \rangle\right]
307
+ = \operatorname{Tr}\!\left(G\,\operatorname{Cov}(C)\right),
308
+ \qquad
309
+ I_F = \tfrac{1}{2}\,C^\top M,
310
+ \qquad
311
+ \text{NMSE}_{F,\text{pred}}
312
+ = \frac{\operatorname{Tr}(G\cdot\operatorname{Cov}(C))}{C^\top M}
313
+ = \frac{\operatorname{Tr}(G\cdot\operatorname{Cov}(C))}{2 I_F}
314
+
315
+ Assumes the sampling error dominates; measurement noise and
316
+ discretization biases are not addressed.
317
+
318
+ This method evaluates the covariance of the inferred force coefficients, the standard error,
319
+ and computes the predicted normalized mean squared error (MSE) of the inferred force field. This analysis
320
+ assumes that the sampling error dominates, and measurement noise or discretization biases are
321
+ not explicitly addressed. It is common to OLI and ULI (by construction of the normal matrix G).
322
+
323
+ Updates:
324
+ self.force_coefficients_covariance (jnp.ndarray): Covariance matrix of the force coefficients.
325
+ self.force_coefficients_stderr (jnp.ndarray): Standard error for each force coefficient.
326
+ self.force_information (float): Estimated information content of the inferred force field.
327
+ self.force_predicted_MSE (float): Predicted normalized mean squared error of the inferred force field.
328
+
329
+ """
330
+ # Estimate the covariance of the force coefficients.
331
+ # force_G is the NLL Hessian for all force methods:
332
+ # - linear: G_ab = Σ_t dt b_a⊤ A⁻¹ b_b (GLS Gram = Onsager–Machlup Hessian)
333
+ # - parametric: G = Σ_t ψ_t⊤ Σ_t⁻¹ ψ_t (Gauss–Newton NLL Hessian)
334
+ # - nonlinear: G = H(NLL) from L-BFGS-B inverse Hessian
335
+ # In all cases Cov(C) = G⁻¹ exactly (Fisher information bound).
336
+ self.force_coefficients_covariance = self.force_G_pinv
337
+
338
+ # Calculate the standard error for each force coefficient
339
+ self.force_coefficients_stderr = jnp.einsum("aa->a", self.force_coefficients_covariance) ** 0.5
340
+
341
+ # Propagate covariance into the existing InferenceResultSF (if present)
342
+ if hasattr(self, "force_inferred") and self.force_inferred is not None:
343
+ object.__setattr__(self.force_inferred, "param_cov", self.force_coefficients_covariance)
344
+
345
+ # Compute time-integrated squared error of the force field
346
+ force_SSE = float(jnp.einsum("ab,ba", self.force_G, self.force_coefficients_covariance))
347
+
348
+ # Compute normalized MSE
349
+ if hasattr(self, "force_moments"):
350
+ self.force_information = float(0.5 * self.force_coefficients_full @ self.force_moments)
351
+ force_energy = 2.0 * self.force_information
352
+ elif hasattr(self, "force_optimization_results_nonlinear"):
353
+ self.force_information = -self.force_optimization_results_nonlinear["fun"]
354
+ # For parametric/nonlinear, force_information = -NLL_min; energy = 2*I
355
+ force_energy = 2.0 * self.force_information
356
+ else:
357
+ raise RuntimeError("Force information is unavailable. Run force inference before computing the error.")
358
+
359
+ # Guard against empty support (zero inferred force energy → null model, MSE undefined)
360
+ if force_energy == 0.0:
361
+ self.force_predicted_MSE = float("nan")
362
+ else:
363
+ self.force_predicted_MSE = float(force_SSE / force_energy)
364
+
365
+ def compute_diffusion_error(self):
366
+ r"""
367
+ Estimate sampling error for diffusion inference.
368
+
369
+ Mirrors :meth:`compute_force_error` for the diffusion field.
370
+ Uses the diffusion Gram matrix (normal matrix) and its inverse.
371
+
372
+ For linear diffusion inference the moments covariance is
373
+ proportional to the Gram matrix, giving
374
+ ``Cov(θ_D) = cov_factor * G_D⁻¹``.
375
+
376
+ .. note::
377
+
378
+ This error estimate is approximate. The diffusion inference is more
379
+ complex than the force inference: diffusion coefficients are inferred
380
+ from force residuals, a positive-definiteness constraint applies, and
381
+ the simple covariance formula ``Cov(θ_D) = cov_factor * G_D⁻¹`` may
382
+ not capture all sources of uncertainty. Treat the result as a rough
383
+ guide rather than a rigorous confidence interval.
384
+
385
+ Updates
386
+ -------
387
+ self.diffusion_coefficients_covariance : jnp.ndarray
388
+ Covariance matrix of the diffusion coefficients.
389
+ self.diffusion_coefficients_stderr : jnp.ndarray
390
+ Standard error for each diffusion coefficient.
391
+ self.diffusion_information : float
392
+ Estimated information content of the inferred diffusion field.
393
+ self.diffusion_predicted_MSE : float
394
+ Predicted normalized mean squared error.
395
+ """
396
+ if not hasattr(self, "diffusion_G_pinv") or self.diffusion_G_pinv is None:
397
+ raise RuntimeError(
398
+ "Diffusion Gram inverse is unavailable. Run infer_diffusion_linear() before computing the error."
399
+ )
400
+
401
+ diffusion_method = self.metadata.get("diffusion_method", "linear")
402
+ if diffusion_method in ("parametric_nll",):
403
+ # G is the NLL Hessian → Cov(θ_D) = G⁻¹
404
+ cov_factor = 1.0
405
+ else:
406
+ # Linear diffusion is OLS (not GLS): the local estimator D̂_t has
407
+ # chi-squared fluctuations with Var(D̂_t) ≈ 2D².
408
+ # Propagating through the MoM formula gives
409
+ # Cov(Ĉ_D) ≈ 2D²_eff · dt · G_D⁻¹
410
+ # where D_eff = Tr(D)/d is the average eigenvalue of the diffusion tensor
411
+ # and dt is the observation interval. This is inherently approximate;
412
+ # any fixed factor misses the state-dependence of D.
413
+ d = int(self.diffusion_average.shape[-1])
414
+ D_eff = float(jnp.trace(self.diffusion_average)) / d
415
+ dt_val = float(self.data.peek_row(require={"dt"})["dt"])
416
+ cov_factor = 2.0 * D_eff**2 * dt_val
417
+ self.diffusion_coefficients_covariance = cov_factor * self.diffusion_G_pinv
418
+ self.diffusion_coefficients_cov = self.diffusion_coefficients_covariance
419
+
420
+ self.diffusion_coefficients_stderr = jnp.einsum("aa->a", self.diffusion_coefficients_covariance) ** 0.5
421
+
422
+ # Propagate into the existing InferenceResultSF
423
+ if hasattr(self, "diffusion_inferred") and self.diffusion_inferred is not None:
424
+ object.__setattr__(
425
+ self.diffusion_inferred,
426
+ "param_cov",
427
+ self.diffusion_coefficients_covariance,
428
+ )
429
+
430
+ diffusion_SSE = float(jnp.einsum("ab,ba", self.diffusion_G, self.diffusion_coefficients_covariance))
431
+
432
+ if hasattr(self, "diffusion_moments"):
433
+ self.diffusion_information = float(0.5 * self.diffusion_coefficients_full @ self.diffusion_moments)
434
+ diffusion_energy = 2.0 * self.diffusion_information
435
+ else:
436
+ self.diffusion_information = float("nan")
437
+ diffusion_energy = 0.0
438
+
439
+ if diffusion_energy == 0.0:
440
+ self.diffusion_predicted_MSE = float("nan")
441
+ else:
442
+ self.diffusion_predicted_MSE = float(diffusion_SSE / diffusion_energy)
443
+
444
+ def diagnose(self, *, level: str = "standard", **kwargs):
445
+ """Run the consistency-check suite from :mod:`SFI.diagnostics`.
446
+
447
+ Convenience wrapper for :func:`SFI.diagnostics.assess`. See its
448
+ docstring for the available ``level`` presets.
449
+ """
450
+ from SFI.diagnostics import assess
451
+
452
+ return assess(self, level=level, **kwargs)
453
+
454
+ def holdout_score(self, data, *, require_error: bool = False) -> dict:
455
+ """Held-out NMSE of the fitted force on an independent collection.
456
+
457
+ A *side feature for data-abundant scenarios*: SFI estimates its
458
+ own accuracy from the training data (``force_predicted_MSE``)
459
+ and validates fits through the diagnostics suite, neither of
460
+ which costs any data. Reach for an explicit train/test split
461
+ (:meth:`TrajectoryCollection.split_time
462
+ <SFI.trajectory.TrajectoryCollection.split_time>`) only when
463
+ data is plentiful, or to confirm a suspected bias floor: a
464
+ ``ratio`` near 1 means the fit is sampling-limited, a ratio
465
+ ``≫ 1`` means a bias floor (often measurement noise — see the
466
+ noise-and-sampling guide).
467
+
468
+ The score is the residual-based normalised mean-square error of
469
+ ``force_inferred`` on ``data``, with the diffusion noise floor
470
+ subtracted (a bias detector, not a precision instrument: its
471
+ resolution is set by the χ² fluctuations of the residuals).
472
+
473
+ Parameters
474
+ ----------
475
+ data : TrajectoryCollection
476
+ Independent test data (e.g. the second half of
477
+ ``coll.split_time(0.8)``).
478
+ require_error : bool
479
+ If True, run :meth:`compute_force_error` first when the
480
+ predicted error is missing, so ``ratio`` is always defined.
481
+
482
+ Returns
483
+ -------
484
+ dict
485
+ ``{"holdout_NMSE", "predicted_NMSE", "ratio", "excess_z",
486
+ "n_obs"}``. Also stored as ``self.force_holdout_NMSE``.
487
+
488
+ Notes
489
+ -----
490
+ Bases that read *time-dependent* extras are not supported on the
491
+ held-out path (the residual builders pass extras unsliced).
492
+ """
493
+ from SFI.diagnostics.residual_tests import mse_consistency
494
+ from SFI.diagnostics.residuals import build_residuals
495
+
496
+ if require_error and getattr(self, "force_predicted_MSE", None) is None:
497
+ self.compute_force_error()
498
+
499
+ bundle = build_residuals(self, data=data)
500
+ out = mse_consistency(self, bundle)
501
+ result = {
502
+ "holdout_NMSE": out.get("realised_NMSE"),
503
+ "predicted_NMSE": out.get("predicted_NMSE"),
504
+ "ratio": out.get("ratio"),
505
+ "excess_z": out.get("excess_z"),
506
+ "n_obs": bundle.n_obs,
507
+ }
508
+ self.force_holdout_NMSE = result["holdout_NMSE"]
509
+ return result
510
+
511
+ def print_report(self):
512
+ """
513
+ Print a summary report of the inference results.
514
+
515
+ Provides insights into the inferred diffusion and force fields, along with error metrics
516
+ such as sampling error, trajectory length, discretization bias, and measurement noise.
517
+ """
518
+ print("\n --- StochasticForceInference Report --- ")
519
+
520
+ # Average diffusion tensor
521
+ print("Average diffusion tensor:\n", self.diffusion_average)
522
+
523
+ # Measurement noise tensor
524
+ print("Measurement noise tensor:\n", self.Lambda)
525
+
526
+ # Entropy production
527
+ if hasattr(self, "DeltaS"):
528
+ print(
529
+ "Entropy production: inferred/bootstrapped error:",
530
+ self.DeltaS,
531
+ self.error_DeltaS,
532
+ )
533
+
534
+ # Force inference metrics
535
+ if hasattr(self, "force_predicted_MSE"):
536
+ print("Force estimated information:", self.force_information)
537
+ print(
538
+ "Force: estimated normalized mean squared error (sampling only):",
539
+ self.force_predicted_MSE,
540
+ )
541
+ # To add: bias estimates
542
+
543
+ # Diffusion error metrics
544
+ if hasattr(self, "diffusion_predicted_MSE"):
545
+ print("Diffusion estimated information:", self.diffusion_information)
546
+ print(
547
+ "Diffusion: estimated normalized mean squared error (sampling only):",
548
+ self.diffusion_predicted_MSE,
549
+ )
550
+ # To add: bias estimates
551
+
552
+ # Exact-comparison NMSE (set by compare_to_exact)
553
+ if hasattr(self, "NMSE_force"):
554
+ print(f"Normalized MSE (force): {self.NMSE_force:.4f}")
555
+ if hasattr(self, "NMSE_diffusion"):
556
+ print(f"Normalized MSE (diffusion): {self.NMSE_diffusion:.4f}")
557
+
558
+ # Force coefficient table
559
+ if hasattr(self, "force_coefficients_full"):
560
+ print()
561
+ print(self.summary("force"))
562
+
563
+ def summary(self, field: str = "force") -> str:
564
+ """
565
+ Return a formatted coefficient table for the inferred model.
566
+
567
+ Parameters
568
+ ----------
569
+ field : ``"force"`` or ``"diffusion"``
570
+ Which inferred field to summarize.
571
+
572
+ Returns
573
+ -------
574
+ str
575
+ Multi-line table ready for ``print()``.
576
+ """
577
+ import numpy as np
578
+
579
+ from SFI.utils.formatting import model_summary
580
+
581
+ if field == "force":
582
+ labels = getattr(self, "force_basis_labels", None)
583
+ coeffs = getattr(self, "force_coefficients_full", None)
584
+ stderr = getattr(self, "force_coefficients_stderr", None)
585
+ support = getattr(self, "force_support", None)
586
+ title = "Force Coefficient Table"
587
+ elif field == "diffusion":
588
+ labels = getattr(self, "diffusion_basis_labels", None)
589
+ coeffs = getattr(self, "diffusion_coefficients_full", None)
590
+ stderr = getattr(self, "diffusion_coefficients_stderr", None)
591
+ support = getattr(self, "diffusion_support", None)
592
+ title = "Diffusion Coefficient Table"
593
+ else:
594
+ raise ValueError(f"Unknown field {field!r}; expected 'force' or 'diffusion'.")
595
+
596
+ if coeffs is None:
597
+ return f" (No {field} coefficients available.)"
598
+
599
+ coeffs = np.asarray(coeffs)
600
+ n = coeffs.shape[0]
601
+
602
+ auto_labels = labels is None
603
+ if auto_labels:
604
+ labels = [f"b{j}" for j in range(n)]
605
+
606
+ if stderr is not None:
607
+ stderr = np.asarray(stderr)
608
+
609
+ # support may be full or sparse
610
+ support_arr = None
611
+ if support is not None:
612
+ support_arr = np.asarray(support)
613
+ if support_arr.shape[0] == n:
614
+ support_arr = None # full support, do not highlight
615
+
616
+ return model_summary(
617
+ labels,
618
+ coeffs,
619
+ stderr=stderr,
620
+ support=support_arr,
621
+ title=title,
622
+ auto_labels=auto_labels,
623
+ )
624
+
625
+ def report_dict(self) -> dict:
626
+ """Return a structured summary of inference results as a dictionary.
627
+
628
+ This is the machine-readable counterpart of :meth:`print_report`.
629
+ All values are plain Python scalars or numpy arrays (no JAX arrays).
630
+
631
+ Returns
632
+ -------
633
+ dict
634
+ Keys include ``"diffusion_average"``, ``"Lambda"``,
635
+ ``"force_information"``, ``"force_predicted_MSE"``,
636
+ ``"NMSE_force"``, ``"NMSE_diffusion"``, and others
637
+ when available. Missing quantities are omitted.
638
+ """
639
+ import numpy as np
640
+
641
+ d: dict = {}
642
+ d["metadata"] = dict(self.metadata)
643
+
644
+ if hasattr(self, "diffusion_average"):
645
+ d["diffusion_average"] = np.asarray(self.diffusion_average)
646
+ if hasattr(self, "Lambda"):
647
+ d["Lambda"] = np.asarray(self.Lambda)
648
+
649
+ if hasattr(self, "DeltaS"):
650
+ d["DeltaS"] = float(self.DeltaS)
651
+ d["error_DeltaS"] = float(self.error_DeltaS)
652
+
653
+ if hasattr(self, "force_coefficients"):
654
+ d["force_coefficients"] = np.asarray(self.force_coefficients)
655
+ if hasattr(self, "force_coefficients_full"):
656
+ d["force_coefficients_full"] = np.asarray(self.force_coefficients_full)
657
+ if hasattr(self, "force_support"):
658
+ d["force_support"] = np.asarray(self.force_support)
659
+ if hasattr(self, "force_coefficients_stderr"):
660
+ d["force_coefficients_stderr"] = np.asarray(self.force_coefficients_stderr)
661
+ if hasattr(self, "force_information"):
662
+ d["force_information"] = float(self.force_information)
663
+ if hasattr(self, "force_predicted_MSE"):
664
+ d["force_predicted_MSE"] = float(self.force_predicted_MSE)
665
+ if hasattr(self, "NMSE_force"):
666
+ d["NMSE_force"] = float(self.NMSE_force)
667
+ if hasattr(self, "MSE_force"):
668
+ d["MSE_force"] = float(self.MSE_force)
669
+
670
+ if hasattr(self, "diffusion_coefficients"):
671
+ d["diffusion_coefficients"] = np.asarray(self.diffusion_coefficients)
672
+ if hasattr(self, "diffusion_information"):
673
+ d["diffusion_information"] = float(self.diffusion_information)
674
+ if hasattr(self, "diffusion_predicted_MSE"):
675
+ d["diffusion_predicted_MSE"] = float(self.diffusion_predicted_MSE)
676
+ if hasattr(self, "NMSE_diffusion"):
677
+ d["NMSE_diffusion"] = float(self.NMSE_diffusion)
678
+ if hasattr(self, "MSE_diffusion"):
679
+ d["MSE_diffusion"] = float(self.MSE_diffusion)
680
+
681
+ return d
682
+
683
+ def compare_to_exact(
684
+ self,
685
+ *,
686
+ model_exact=None,
687
+ data_exact=None,
688
+ force_exact=None,
689
+ diffusion_exact=None, # callable | float | (d,d) array
690
+ maxpoints: int = 1000,
691
+ ) -> None:
692
+ r"""
693
+ Compare inferred vs exact using dt-weighted time means via the integrate() API.
694
+
695
+ This function evaluates the inferred force/diffusion against "exact" references
696
+ on a (possibly exact/synthetic) dataset. It updates:
697
+
698
+ self.MSE_force / self.NMSE_force
699
+ self.MSE_diffusion / self.NMSE_diffusion
700
+
701
+ Inputs: exact references
702
+ ~~~~~~~~~~~~~~~~~~~~~~~~
703
+ You can provide exact references in two ways:
704
+
705
+ 1) Preferred: `model_exact`
706
+ A model object (from SFI.langevin submodule) exposing:
707
+
708
+ - model_exact.force_sf : exact force/drift (SF/StateExpr-like)
709
+ - model_exact.diffusion_sf : exact diffusion (SF/StateExpr-like)
710
+ OR a constant (float or (d,d) matrix) via ``model_exact.D``
711
+
712
+ 2) Explicit: `force_exact`, `diffusion_exact`
713
+ - `force_exact`: SF/StateExpr-like callable returning (N, d)
714
+ - `diffusion_exact`:
715
+
716
+ * callable returning (N, d, d), OR
717
+ * float meaning σ·I, OR
718
+ * (d,d) matrix constant diffusion.
719
+
720
+ These are used if `model_exact` is not provided. If `model_exact` is provided,
721
+ its members take precedence unless they are missing, in which case the explicit
722
+ arguments can be used as fallback.
723
+
724
+ Velocity provisioning (underdamped)
725
+ -----------------------------------
726
+ If an evaluated expression advertises `needs_v=True`, this routine supplies:
727
+
728
+ v := dX/dt (secant velocity from the data stream)
729
+
730
+ i.e. it uses `velocity("dX", "dt")` as the `v=...` keyword argument. This works for both
731
+ exact and inferred expressions and keeps underdamped comparisons possible even when
732
+ the dataset only stores positions.
733
+
734
+ Metrics
735
+ -------
736
+ Force:
737
+ e = Fe - Fh
738
+ MSE_force = < e^T A_inv e >
739
+ NMSE_force = MSE_force / < Fh^T A_inv Fh >
740
+
741
+ Diffusion:
742
+ E = De - Dh
743
+ MSE_diffusion = < tr(A_inv E A_inv E) >
744
+
745
+ .. physics:: Normalized MSE metrics (force & diffusion)
746
+ :label: nmse-metrics
747
+ :category: Error analysis
748
+
749
+ .. math::
750
+
751
+ \text{NMSE}_F
752
+ = \frac{\langle (F_{\text{exact}} - \hat F)^\top A^{-1}
753
+ (F_{\text{exact}} - \hat F) \rangle}
754
+ {\langle \hat F^\top A^{-1} \hat F \rangle}
755
+
756
+ .. math::
757
+
758
+ \text{NMSE}_D
759
+ = \frac{\langle \operatorname{tr}(A^{-1} E\, A^{-1} E) \rangle}
760
+ {\langle \operatorname{tr}(A^{-1} \hat D\, A^{-1} \hat D) \rangle}
761
+
762
+ where :math:`E = D_{\text{exact}} - \hat D`.
763
+ NMSE_diffusion = MSE_diffusion / < tr(A_inv Dh A_inv Dh) >
764
+
765
+ where A_inv is `self.A_inv` (typically (2 D̄)^{-1} from the inferred constant diffusion normalization).
766
+
767
+ Subsampling
768
+ -----------
769
+ Uses a simple subsampling heuristic so that the total number of evaluated points is ~<= `maxpoints`,
770
+ accounting for the maximum number of particles.
771
+
772
+ Requirements
773
+ ------------
774
+ - `self.A_inv` must exist (run compute_diffusion_constant() or otherwise set A_inv).
775
+ - The dataset must provide streams `X`, `dt`, and if any evaluated expr needs v: `dX` as well.
776
+ """
777
+ data_exact = data_exact or self.data
778
+
779
+ # ---------- resolve exact references ----------
780
+ if model_exact is not None:
781
+ F_exact = (
782
+ getattr(model_exact, "force_sf", None)
783
+ if force_exact is None
784
+ else getattr(model_exact, "force_sf", force_exact)
785
+ )
786
+ D_exact = (
787
+ getattr(model_exact, "diffusion_sf", None)
788
+ if diffusion_exact is None
789
+ else getattr(model_exact, "diffusion_sf", diffusion_exact)
790
+ )
791
+ # Fall back to model_exact.D when the bound SF is absent (e.g. constant
792
+ # diffusion in OverdampedProcess sets _D_sf=None but stores the raw value
793
+ # in the .D field).
794
+ if D_exact is None:
795
+ D_exact = diffusion_exact if diffusion_exact is not None else getattr(model_exact, "D", None)
796
+ else:
797
+ F_exact = force_exact
798
+ D_exact = diffusion_exact
799
+
800
+ if not hasattr(self, "A_inv"):
801
+ raise RuntimeError("A_inv not available. Run compute_diffusion_constant() (or equivalent) first.")
802
+
803
+ nsteps = int(getattr(data_exact, "Nsteps", 0) or 0)
804
+ nmaxp = int(getattr(data_exact, "Nmaxparticles", 1) or 1)
805
+ subsampling = max(1, nsteps // max(1, maxpoints // max(1, nmaxp))) if nsteps else 1
806
+
807
+ logger.info("Comparing to exact data...")
808
+
809
+ A = ConstOperand(jnp.asarray(self.A_inv), alias="A")
810
+ d = int(jnp.asarray(self.A_inv).shape[0])
811
+
812
+ # Helper: provide v only if the expression wants it.
813
+ def _maybe_v(expr):
814
+ return velocity("dX", "dt") if bool(getattr(expr, "needs_v", False)) else None
815
+
816
+ # Helper: wrap constant diffusion into a TimeOperand returning (N,d,d)
817
+ def _const_diffusion_operand(Dconst, *, alias: str):
818
+ Dconst = jnp.asarray(Dconst)
819
+ if Dconst.ndim == 0:
820
+ # scalar σ -> σ I
821
+ sigma = float(Dconst)
822
+ Dmat = sigma * jnp.eye(d, dtype=jnp.asarray(self.A_inv).dtype)
823
+ elif Dconst.ndim == 2:
824
+ Dmat = Dconst
825
+ else:
826
+ raise TypeError("Constant diffusion must be a float (σ) or a (d,d) matrix.")
827
+
828
+ @timeop(name=f"D_const_{alias}")
829
+ def _Dconst(**streams):
830
+ N = streams["X"].shape[0]
831
+ return jnp.broadcast_to(Dmat[None, :, :], (N, Dmat.shape[0], Dmat.shape[1]))
832
+
833
+ _Dconst._requires = frozenset({"X"}) # type: ignore[attr-defined]
834
+
835
+ return TimeOperand(_Dconst, alias=alias)
836
+
837
+ # Helper: build diffusion operand (ExprOperand or TimeOperand) with alias control.
838
+ def _diffusion_operand(Dobj, *, alias: str):
839
+ # NoiseModel instances (e.g. ConservedNoise) are not callable exprs
840
+ # and cannot be converted to JAX arrays — return None so the caller
841
+ # skips the (point-wise) diffusion comparison for them.
842
+ from SFI.langevin.noise import NoiseModel
843
+
844
+ if isinstance(Dobj, NoiseModel):
845
+ return None
846
+ if callable(Dobj):
847
+ vD = _maybe_v(Dobj)
848
+ return ExprOperand(expr=Dobj, x=stream("X"), v=vD, alias=alias)
849
+ return _const_diffusion_operand(Dobj, alias=alias)
850
+
851
+ # ------------------------------ FORCE ------------------------------ #
852
+ if hasattr(self, "force_inferred") and (F_exact is not None):
853
+ if not callable(F_exact):
854
+ raise TypeError("Exact force must be callable (SF/StateExpr-like).")
855
+
856
+ Fh = getattr(self.force_inferred, "sf", self.force_inferred)
857
+
858
+ # Structural extras needed by expr graphs are built into a transient
859
+ # collection (never persisted). Only StateExpr-like objects have them;
860
+ # plain callables are skipped (the `root` attribute marks StateExprs).
861
+ force_exprs = [e for e in (F_exact, Fh) if hasattr(e, "root")]
862
+ if force_exprs:
863
+ data_exact = prepare_collection_for_expr(data_exact, *force_exprs)
864
+
865
+ Fe_op = ExprOperand(expr=F_exact, x=stream("X"), v=_maybe_v(F_exact), alias="Fe")
866
+ Fh_op = ExprOperand(expr=Fh, x=stream("X"), v=_maybe_v(Fh), alias="Fh")
867
+
868
+ # Numerator: ⟨(Fe−Fh)^T A (Fe−Fh)⟩
869
+ num_prog = Integrand(
870
+ exprs=[Fe_op, Fh_op],
871
+ consts=[A],
872
+ terms=[
873
+ Term(eq="im,mn,in->i", ops=("Fe", "A", "Fe"), scale=+1.0),
874
+ Term(eq="im,mn,in->i", ops=("Fe", "A", "Fh"), scale=-2.0),
875
+ Term(eq="im,mn,in->i", ops=("Fh", "A", "Fh"), scale=+1.0),
876
+ ],
877
+ )
878
+ # Denominator: ⟨Fh^T A Fh⟩
879
+ den_prog = Integrand(
880
+ exprs=[Fh_op],
881
+ consts=[A],
882
+ terms=[Term(eq="im,mn,in->i", ops=("Fh", "A", "Fh"))],
883
+ )
884
+
885
+ num = integrate(
886
+ data_exact,
887
+ num_prog,
888
+ reduce="mean",
889
+ reduce_over_particles=True,
890
+ subsampling=subsampling,
891
+ chunk_target_bytes=self._chunk_target_bytes,
892
+ )
893
+ den = integrate(
894
+ data_exact,
895
+ den_prog,
896
+ reduce="mean",
897
+ reduce_over_particles=True,
898
+ subsampling=subsampling,
899
+ chunk_target_bytes=self._chunk_target_bytes,
900
+ )
901
+
902
+ self.MSE_force = num
903
+ self.NMSE_force = num / (den + 1e-12)
904
+
905
+ logger.info("Normalized MSE (force): %s", self.NMSE_force)
906
+
907
+ # ------------------------------ DIFFUSION ------------------------------ #
908
+ if D_exact is not None:
909
+ # Inferred diffusion: prefer callable diffusion_inferred if available, else constant diffusion_average.
910
+ Dh_obj = None
911
+ if hasattr(self, "diffusion_inferred"):
912
+ Dh_candidate = getattr(self.diffusion_inferred, "sf", self.diffusion_inferred)
913
+ if callable(Dh_candidate):
914
+ Dh_obj = Dh_candidate
915
+
916
+ if Dh_obj is None:
917
+ if hasattr(self, "diffusion_average"):
918
+ Dh_obj = jnp.asarray(self.diffusion_average)
919
+ else:
920
+ raise RuntimeError("No inferred diffusion callable and no diffusion_average available.")
921
+
922
+ # Build operands (supports callable OR constant for both exact and inferred).
923
+ De_op = _diffusion_operand(D_exact, alias="De")
924
+ Dh_op = _diffusion_operand(Dh_obj, alias="Dh")
925
+
926
+ if De_op is None or Dh_op is None:
927
+ logger.info(
928
+ "Skipping diffusion NMSE: exact diffusion is a NoiseModel "
929
+ "(e.g. ConservedNoise) that cannot be compared point-wise."
930
+ )
931
+ else:
932
+ # Prepare structural extras only for callable exprs with node trees.
933
+ diff_exprs = []
934
+ if isinstance(De_op, ExprOperand) and hasattr(D_exact, "root"):
935
+ diff_exprs.append(D_exact)
936
+ if isinstance(Dh_op, ExprOperand) and hasattr(Dh_obj, "root"):
937
+ diff_exprs.append(Dh_obj)
938
+ if diff_exprs:
939
+ data_exact = prepare_collection_for_expr(data_exact, *diff_exprs)
940
+
941
+ exprs_num = [op for op in (De_op, Dh_op) if isinstance(op, ExprOperand)]
942
+ times_num = [op for op in (De_op, Dh_op) if isinstance(op, TimeOperand)]
943
+ exprs_den = [Dh_op] if isinstance(Dh_op, ExprOperand) else []
944
+ times_den = [Dh_op] if isinstance(Dh_op, TimeOperand) else []
945
+
946
+ # Numerator: ⟨ tr(A (De−Dh) A (De−Dh)) ⟩
947
+ # Expanded form with contractions using eq="imn,iop,no,pm->i".
948
+ num_prog = Integrand(
949
+ exprs=exprs_num,
950
+ times=times_num,
951
+ consts=[A],
952
+ terms=[
953
+ Term(eq="imn,iop,no,pm->i", ops=("De", "De", "A", "A"), scale=+1.0),
954
+ Term(eq="imn,iop,no,pm->i", ops=("De", "Dh", "A", "A"), scale=-2.0),
955
+ Term(eq="imn,iop,no,pm->i", ops=("Dh", "Dh", "A", "A"), scale=+1.0),
956
+ ],
957
+ )
958
+ den_prog = Integrand(
959
+ exprs=exprs_den,
960
+ times=times_den,
961
+ consts=[A],
962
+ terms=[Term(eq="imn,iop,no,pm->i", ops=("Dh", "Dh", "A", "A"))],
963
+ )
964
+
965
+ num = integrate(
966
+ data_exact,
967
+ num_prog,
968
+ reduce="mean",
969
+ reduce_over_particles=True,
970
+ subsampling=subsampling,
971
+ chunk_target_bytes=self._chunk_target_bytes,
972
+ )
973
+ den = integrate(
974
+ data_exact,
975
+ den_prog,
976
+ reduce="mean",
977
+ reduce_over_particles=True,
978
+ subsampling=subsampling,
979
+ chunk_target_bytes=self._chunk_target_bytes,
980
+ )
981
+
982
+ self.MSE_diffusion = num
983
+ self.NMSE_diffusion = num / (den + 1e-12)
984
+
985
+ logger.info("Normalized MSE (diffusion): %s", self.NMSE_diffusion)
986
+
987
+ # ------------------------------------------------------------------
988
+ # Exact-vs-inferred sample arrays + scatter (graphical comparison)
989
+ # ------------------------------------------------------------------
990
+ def _comparison_points(self, data=None, *, maxpoints: int = 2000, need_v: bool = False):
991
+ """Subsample ``(X_flat, V_flat|None, mask_flat)`` to ~``maxpoints`` points."""
992
+ import numpy as np
993
+
994
+ data = data if data is not None else self.data
995
+ t, X, M = data.to_arrays(dataset=0)
996
+ X = np.asarray(X)
997
+ M = np.asarray(M)
998
+ T, _, d = X.shape
999
+ stride = max(1, T // max(1, maxpoints // max(1, X.shape[1])))
1000
+ Xs, Ms = X[::stride], M[::stride]
1001
+ Vf = None
1002
+ if need_v:
1003
+ from SFI.utils.maths import fd_velocity
1004
+
1005
+ dt = np.diff(np.asarray(t, dtype=float))
1006
+ Vf = np.asarray(fd_velocity(X, dt))[::stride].reshape(-1, d)
1007
+ return Xs.reshape(-1, d), Vf, Ms.reshape(-1).astype(bool)
1008
+
1009
+ def _eval_on_points(self, field, Xf, Vf):
1010
+ """Evaluate a callable force/field on flat points, supplying ``v`` when needed."""
1011
+ import jax.numpy as jnp
1012
+ import numpy as np
1013
+
1014
+ fn = getattr(field, "sf", field)
1015
+ needs_v = bool(getattr(field, "needs_v", False))
1016
+ if needs_v and Vf is not None:
1017
+ out = fn(jnp.asarray(Xf), v=jnp.asarray(Vf))
1018
+ else:
1019
+ out = fn(jnp.asarray(Xf))
1020
+ return np.asarray(out)
1021
+
1022
+ def _eval_diffusion_on_points(self, Dobj, Xf, Vf):
1023
+ """Evaluate a diffusion field (callable) or broadcast a constant to ``(M, d, d)``."""
1024
+ import numpy as np
1025
+
1026
+ if callable(getattr(Dobj, "sf", Dobj)):
1027
+ return self._eval_on_points(Dobj, Xf, Vf)
1028
+ Dc = np.asarray(Dobj)
1029
+ d = Xf.shape[1]
1030
+ if Dc.ndim == 0:
1031
+ Dmat = float(Dc) * np.eye(d)
1032
+ elif Dc.ndim == 2:
1033
+ Dmat = Dc
1034
+ else:
1035
+ raise TypeError("Constant diffusion must be a scalar or a (d, d) matrix.")
1036
+ return np.broadcast_to(Dmat[None, :, :], (Xf.shape[0], d, d))
1037
+
1038
+ def force_comparison_arrays(self, *, model_exact=None, force_exact=None, data=None, maxpoints: int = 2000):
1039
+ """Return ``(F_exact, F_inferred)`` evaluated along the trajectory.
1040
+
1041
+ Evaluates the exact and inferred force on the (subsampled, masked)
1042
+ trajectory points, supplying finite-difference velocities for
1043
+ underdamped fields. Feeds :meth:`comparison_scatter`; also handy
1044
+ for custom diagnostics.
1045
+
1046
+ Parameters
1047
+ ----------
1048
+ model_exact :
1049
+ Object exposing ``force_sf`` (e.g. an ``OverdampedProcess``).
1050
+ force_exact :
1051
+ Explicit callable exact force (overrides ``model_exact``).
1052
+ data :
1053
+ Collection to evaluate on (default: the training data).
1054
+ maxpoints :
1055
+ Approximate number of points to evaluate.
1056
+
1057
+ Returns
1058
+ -------
1059
+ (F_exact, F_inferred) : tuple of ndarray, shape ``(n_points, d)``
1060
+ """
1061
+ F_exact = force_exact
1062
+ if F_exact is None and model_exact is not None:
1063
+ F_exact = getattr(model_exact, "force_sf", None)
1064
+ if F_exact is None or not callable(F_exact):
1065
+ raise ValueError(
1066
+ "force_comparison_arrays needs a callable exact force "
1067
+ "(model_exact.force_sf or force_exact=)."
1068
+ )
1069
+ if not hasattr(self, "force_inferred"):
1070
+ raise RuntimeError("No inferred force; run infer_force_linear / infer_force first.")
1071
+ need_v = bool(getattr(F_exact, "needs_v", False)) or bool(
1072
+ getattr(self.force_inferred, "needs_v", False)
1073
+ )
1074
+ Xf, Vf, mask = self._comparison_points(data, maxpoints=maxpoints, need_v=need_v)
1075
+ Fe = self._eval_on_points(F_exact, Xf, Vf)[mask]
1076
+ Fi = self._eval_on_points(self.force_inferred, Xf, Vf)[mask]
1077
+ return Fe, Fi
1078
+
1079
+ def diffusion_comparison_arrays(self, *, model_exact=None, diffusion_exact=None, data=None, maxpoints: int = 2000):
1080
+ """Return ``(D_exact, D_inferred)`` evaluated along the trajectory.
1081
+
1082
+ Like :meth:`force_comparison_arrays` but for the diffusion field;
1083
+ a constant exact/inferred diffusion is broadcast to ``(n_points,
1084
+ d, d)``.
1085
+ """
1086
+ import jax.numpy as jnp
1087
+
1088
+ D_exact = diffusion_exact
1089
+ if D_exact is None and model_exact is not None:
1090
+ D_exact = getattr(model_exact, "diffusion_sf", None)
1091
+ if D_exact is None:
1092
+ D_exact = getattr(model_exact, "D", None)
1093
+ if D_exact is None:
1094
+ raise ValueError(
1095
+ "diffusion_comparison_arrays needs an exact diffusion "
1096
+ "(model_exact or diffusion_exact=)."
1097
+ )
1098
+ D_inf = getattr(self, "diffusion_inferred", None)
1099
+ if D_inf is None or not callable(getattr(D_inf, "sf", D_inf)):
1100
+ if not hasattr(self, "diffusion_average"):
1101
+ raise RuntimeError("No inferred diffusion callable and no diffusion_average available.")
1102
+ D_inf = jnp.asarray(self.diffusion_average)
1103
+ need_v = bool(getattr(D_exact, "needs_v", False)) or bool(getattr(D_inf, "needs_v", False))
1104
+ Xf, Vf, mask = self._comparison_points(data, maxpoints=maxpoints, need_v=need_v)
1105
+ De = self._eval_diffusion_on_points(D_exact, Xf, Vf)[mask]
1106
+ Di = self._eval_diffusion_on_points(D_inf, Xf, Vf)[mask]
1107
+ return De, Di
1108
+
1109
+ def comparison_scatter(self, *, model_exact=None, field: str = "force", data=None, ax=None, maxpoints: int = 2000, **plot_kw):
1110
+ """Scatter inferred-vs-exact force (or diffusion) along the trajectory.
1111
+
1112
+ Evaluates both fields on the data with
1113
+ :meth:`force_comparison_arrays` / :meth:`diffusion_comparison_arrays`
1114
+ and renders them with
1115
+ :func:`SFI.utils.plotting.comparison_scatter` (identity line +
1116
+ Pearson ``r`` + MSE). Replaces hand-rolled exact-vs-inferred
1117
+ scatters in demos.
1118
+
1119
+ Parameters
1120
+ ----------
1121
+ model_exact :
1122
+ Object exposing ``force_sf`` / ``diffusion_sf`` / ``D``.
1123
+ field : {"force", "diffusion"}
1124
+ Which field to compare.
1125
+ data :
1126
+ Collection to evaluate on (default: training data).
1127
+ ax :
1128
+ Target axes (default: current axes).
1129
+ maxpoints :
1130
+ Approximate number of points to evaluate.
1131
+ **plot_kw :
1132
+ Forwarded to :func:`SFI.utils.plotting.comparison_scatter`.
1133
+
1134
+ Returns
1135
+ -------
1136
+ matplotlib.axes.Axes
1137
+ """
1138
+ import matplotlib.pyplot as plt
1139
+
1140
+ from SFI.utils import plotting
1141
+
1142
+ if field == "force":
1143
+ exact, inferred = self.force_comparison_arrays(model_exact=model_exact, data=data, maxpoints=maxpoints)
1144
+ elif field == "diffusion":
1145
+ exact, inferred = self.diffusion_comparison_arrays(model_exact=model_exact, data=data, maxpoints=maxpoints)
1146
+ else:
1147
+ raise ValueError(f"Unknown field {field!r}; expected 'force' or 'diffusion'.")
1148
+ if ax is not None:
1149
+ plt.sca(ax)
1150
+ plotting.comparison_scatter(exact, inferred, **plot_kw)
1151
+ return plt.gca()
1152
+
1153
+ def compare_params_to_exact(self, theta_true, *, psf=None) -> dict:
1154
+ """Compare inferred parametric coefficients to known ground truth.
1155
+
1156
+ For a model fitted with a parametric family, returns a per-parameter
1157
+ dict of absolute and relative error. ``theta_true`` may be a flat
1158
+ array (compared elementwise to ``force_coefficients_full``) or a
1159
+ ``{name: value}`` dict (unflattened from the fitted coefficients via
1160
+ ``psf.unflatten_params``, falling back to ``self.force_psf``).
1161
+
1162
+ Returns
1163
+ -------
1164
+ dict
1165
+ ``{name: {"true", "inferred", "abs_error", "rel_error"}}``; also
1166
+ stored as ``self.parameter_comparison``.
1167
+ """
1168
+ import numpy as np
1169
+
1170
+ theta_inf = np.asarray(self.force_coefficients_full).ravel()
1171
+ out: dict = {}
1172
+ if isinstance(theta_true, dict):
1173
+ psf = psf if psf is not None else getattr(self, "force_psf", None)
1174
+ inf_dict: dict = {}
1175
+ if psf is not None and hasattr(psf, "unflatten_params"):
1176
+ try:
1177
+ inf_dict = {k: np.asarray(v) for k, v in dict(psf.unflatten_params(theta_inf)).items()}
1178
+ except Exception:
1179
+ inf_dict = {}
1180
+ for name, tv in theta_true.items():
1181
+ tv = np.asarray(tv, dtype=float)
1182
+ iv = np.asarray(inf_dict.get(name, np.full(tv.shape, np.nan)), dtype=float)
1183
+ abs_err = float(np.sqrt(np.mean((iv - tv) ** 2))) if iv.shape == tv.shape else float("nan")
1184
+ rel = abs_err / (float(np.sqrt(np.mean(tv**2))) + 1e-12)
1185
+ out[name] = {"true": tv, "inferred": iv, "abs_error": abs_err, "rel_error": rel}
1186
+ else:
1187
+ tv = np.asarray(theta_true, dtype=float).ravel()
1188
+ iv = theta_inf[: tv.shape[0]]
1189
+ err = float(np.linalg.norm(iv - tv))
1190
+ out["theta"] = {
1191
+ "true": tv,
1192
+ "inferred": iv,
1193
+ "abs_error": err,
1194
+ "rel_error": err / (float(np.linalg.norm(tv)) + 1e-12),
1195
+ }
1196
+ self.parameter_comparison = out
1197
+ return out
1198
+
1199
+ def coeff_block(self, block, *, field: str = "force"):
1200
+ """Return the coefficient (and covariance) slice for a basis sub-block.
1201
+
1202
+ Compound bases (e.g. a multi-kernel or time-Fourier library) pack
1203
+ several conceptual blocks into one flat coefficient vector. This
1204
+ returns the slice for one block without hand-computed offsets.
1205
+
1206
+ Parameters
1207
+ ----------
1208
+ block :
1209
+ ``(start, stop)`` indices, a ``slice``, an ``int``, or a
1210
+ ``Basis`` (located by matching its labels as a contiguous run of
1211
+ the fitted basis labels).
1212
+ field : {"force", "diffusion"}
1213
+
1214
+ Returns
1215
+ -------
1216
+ (coeffs, cov) : tuple
1217
+ ``coeffs`` is the 1-D slice; ``cov`` is the matching covariance
1218
+ block (or ``None`` if no covariance is available).
1219
+ """
1220
+ import numpy as np
1221
+
1222
+ coeffs = np.asarray(getattr(self, f"{field}_coefficients_full"))
1223
+ cov = getattr(self, f"{field}_coefficients_covariance", None)
1224
+ cov = np.asarray(cov) if cov is not None else None
1225
+
1226
+ if isinstance(block, slice):
1227
+ i0 = block.start or 0
1228
+ i1 = block.stop if block.stop is not None else len(coeffs)
1229
+ elif isinstance(block, (tuple, list)) and len(block) == 2 and all(
1230
+ isinstance(v, (int, np.integer)) for v in block
1231
+ ):
1232
+ i0, i1 = int(block[0]), int(block[1])
1233
+ elif isinstance(block, (int, np.integer)):
1234
+ i0, i1 = int(block), int(block) + 1
1235
+ elif hasattr(block, "labels") and hasattr(block, "n_features"):
1236
+ full_labels = list(getattr(self, f"{field}_basis_labels", []) or [])
1237
+ block_labels = list(block.labels)
1238
+ nf = int(block.n_features)
1239
+ i0 = None
1240
+ for start in range(0, len(full_labels) - nf + 1):
1241
+ if full_labels[start : start + nf] == block_labels:
1242
+ i0 = start
1243
+ break
1244
+ if i0 is None:
1245
+ raise ValueError("Could not locate the basis block within the fitted basis labels.")
1246
+ i1 = i0 + nf
1247
+ else:
1248
+ raise TypeError("block must be (start, stop), a slice, an int, or a Basis.")
1249
+
1250
+ cslice = coeffs[i0:i1]
1251
+ covslice = cov[i0:i1, i0:i1] if cov is not None else None
1252
+ return cslice, covslice
1253
+
1254
+ def predict_time_profile(self, basis_block, t, *, field: str = "force", x=None):
1255
+ """Evaluate a (time-dependent) basis block's coefficient profile at ``t``.
1256
+
1257
+ Contracts the fitted coefficients of ``basis_block`` with the basis's
1258
+ own evaluation at times ``t`` (via the reserved ``time`` extra),
1259
+ returning the time profile — e.g. ``-k(t)`` for the ``x`` block of a
1260
+ time-Fourier trap. Avoids re-deriving the design matrix by hand.
1261
+ """
1262
+ import jax.numpy as jnp
1263
+ import numpy as np
1264
+
1265
+ coeffs, _ = self.coeff_block(basis_block, field=field)
1266
+ t = np.asarray(t)
1267
+ dim = int(getattr(basis_block, "dim", 1))
1268
+ x0 = np.zeros((t.shape[0], dim)) if x is None else np.broadcast_to(np.asarray(x), (t.shape[0], dim))
1269
+ duration = float(t.max() - t.min()) if t.size else 1.0
1270
+ D = np.asarray(
1271
+ basis_block(jnp.asarray(x0), extras={"time": jnp.asarray(t)[:, None], "duration": duration})
1272
+ ).reshape(t.shape[0], -1)
1273
+ return D @ np.asarray(coeffs)
1274
+
1275
+ #################################################################
1276
+ ################ BACKEND ############################
1277
+ #################################################################
1278
+
1279
+ def _update_force_coefficients(self, coeffs, support=None, jit_inferred=True):
1280
+ """Write or update force coefficients and rebuild ``force_inferred``.
1281
+
1282
+ Called after the initial linear solve and again after sparsification
1283
+ to set the active support and (re-)build the callable force field.
1284
+
1285
+ Parameters
1286
+ ----------
1287
+ coeffs : Array
1288
+ Coefficient vector for the active basis functions.
1289
+ support : array-like or None
1290
+ Indices of the active basis functions. ``None`` means all.
1291
+ jit_inferred : bool
1292
+ Whether to JIT-compile the resulting ``force_inferred``.
1293
+ """
1294
+ self.force_coefficients = coeffs
1295
+ if support is None:
1296
+ self.force_support = jnp.arange(self.force_scorer.p)
1297
+ else:
1298
+ self.force_support = jnp.array(support)
1299
+
1300
+ # Persist basis labels for downstream reporting (best-effort)
1301
+ if hasattr(self, "force_basis"):
1302
+ self.force_basis_labels = getattr(self.force_basis, "labels", None)
1303
+ elif not hasattr(self, "force_basis_labels"):
1304
+ self.force_basis_labels = None
1305
+ self.force_G = self.force_G_full[jnp.ix_(self.force_support, self.force_support)]
1306
+ self.force_G_pinv = stable_pinv(self.force_G)
1307
+ # Sparse coeffs on the complete basis:
1308
+ self.force_coefficients_full = jnp.zeros_like(self.force_moments)
1309
+ if len(self.force_support) > 0:
1310
+ self.force_coefficients_full = self.force_coefficients_full.at[self.force_support].set(coeffs)
1311
+ # Call the inferred-constructing subclass-specific hook:
1312
+ self._update_force_inferred()
1313
+
1314
+ def _update_diffusion_coefficients(self, coeffs, support=None, jit_inferred=True):
1315
+ """Write or update diffusion coefficients and rebuild ``diffusion_inferred``."""
1316
+ self.diffusion_coefficients = coeffs
1317
+ if support is None:
1318
+ self.diffusion_support = jnp.arange(self.diffusion_scorer.p)
1319
+ else:
1320
+ self.diffusion_support = jnp.array(support)
1321
+ self.diffusion_G = self.diffusion_G_full[jnp.ix_(self.diffusion_support, self.diffusion_support)]
1322
+ self.diffusion_G_pinv = stable_pinv(self.diffusion_G)
1323
+ # Sparse coeffs on the complete basis:
1324
+ self.diffusion_coefficients_full = jnp.zeros_like(self.diffusion_moments)
1325
+ self.diffusion_coefficients_full = self.diffusion_coefficients_full.at[self.diffusion_support].set(coeffs)
1326
+ # Call the inferred-constructing subclass-specific hook:
1327
+ self._update_diffusion_inferred()
1328
+
1329
+ def _detach_from_jax(self):
1330
+ """Convert all JAX arrays inside this object to NumPy arrays
1331
+ to prevent memory leaks. Use this before deleting this object,
1332
+ as the Jax traces might persist otherwise. Important when
1333
+ performing a large number of inference runs in the same run
1334
+ (e.g. for benchmarking the method over many
1335
+ parameters/trajectories).
1336
+
1337
+ """
1338
+ import gc
1339
+
1340
+ for attr_name in vars(self): # Loop through all attributes
1341
+ attr_value = getattr(self, attr_name)
1342
+
1343
+ if isinstance(attr_value, jnp.ndarray): # If it's a JAX array
1344
+ setattr(self, attr_name, jax.device_get(attr_value)) # Convert to NumPy
1345
+
1346
+ elif isinstance(attr_value, dict): # If it's a dictionary, check inside
1347
+ for key, value in attr_value.items():
1348
+ if isinstance(value, jnp.ndarray):
1349
+ attr_value[key] = jax.device_get(value)
1350
+
1351
+ elif isinstance(attr_value, list): # If it's a list, check each item
1352
+ for i in range(len(attr_value)):
1353
+ if isinstance(attr_value[i], jnp.ndarray):
1354
+ attr_value[i] = jax.device_get(attr_value[i])
1355
+
1356
+ # Clear any lingering references in JAX's cache
1357
+ jax.clear_caches()
1358
+ jax.device_get(jax.numpy.zeros(1)) # Forces JAX to clear buffers
1359
+ gc.collect()
1360
+
1361
+ # ---- simulation helpers ----------------------------------------------- #
1362
+
1363
+ def _find_finite_x0(self, also_dx: bool = False):
1364
+ """Return the first X row (and optionally dX) with no NaN fill values.
1365
+
1366
+ Masked datasets store NaN as a fill value at missing positions.
1367
+ Using such a row as a simulation initial condition propagates NaN
1368
+ through the whole trajectory. This helper scans the datasets to
1369
+ find the first time step where every element of X is finite.
1370
+
1371
+ Parameters
1372
+ ----------
1373
+ also_dx : bool
1374
+ If True, also return ``dX = X[t+1] - X[t]`` at the chosen step
1375
+ (both rows must be finite).
1376
+
1377
+ Returns
1378
+ -------
1379
+ x0 : Array
1380
+ First fully-finite position row.
1381
+ dX : Array, only when ``also_dx=True``
1382
+ Increment at that time step.
1383
+ """
1384
+ import numpy as np
1385
+
1386
+ for ds in self.data.datasets:
1387
+ X_np = np.asarray(ds.X)
1388
+ T = X_np.shape[0]
1389
+ flat = X_np.reshape(T, -1)
1390
+ rows_finite = np.all(np.isfinite(flat), axis=1)
1391
+ if also_dx:
1392
+ valid = np.where(rows_finite[:-1] & rows_finite[1:])[0]
1393
+ else:
1394
+ valid = np.where(rows_finite)[0]
1395
+ if len(valid) > 0:
1396
+ t0 = int(valid[0])
1397
+ x0 = jnp.asarray(X_np[t0])
1398
+ if also_dx:
1399
+ return x0, jnp.asarray(X_np[t0 + 1] - X_np[t0])
1400
+ return x0
1401
+
1402
+ # Fallback: peek_row + replace any remaining NaN with zeros.
1403
+ import warnings
1404
+
1405
+ x0 = jnp.asarray(self.data.peek_row(require={"X"})["X"])
1406
+ x0_clean = jnp.where(jnp.isfinite(x0), x0, jnp.zeros_like(x0))
1407
+ warnings.warn(
1408
+ "simulate_bootstrapped_trajectory: no fully-finite time step found in "
1409
+ "the dataset. NaN fill values in the initial condition have been replaced "
1410
+ "with 0.0.",
1411
+ UserWarning,
1412
+ stacklevel=3,
1413
+ )
1414
+ if also_dx:
1415
+ return x0_clean, jnp.zeros_like(x0_clean)
1416
+ return x0_clean
1417
+
1418
+ ### Subclass hooks ###
1419
+
1420
+ @abstractmethod
1421
+ def _force_G_matrix(self) -> jnp.ndarray: ...
1422
+
1423
+ @abstractmethod
1424
+ def _force_moments(self) -> jnp.ndarray: ...
1425
+
1426
+ @abstractmethod
1427
+ def _diffusion_G_matrix(self) -> jnp.ndarray: ...
1428
+
1429
+ @abstractmethod
1430
+ def _diffusion_moments(self) -> jnp.ndarray: ...
1431
+
1432
+ @abstractmethod
1433
+ def get_diffusion_timeop(self, method: str) -> TimeOperand: ...
1434
+
1435
+ @abstractmethod
1436
+ def _update_force_inferred(self) -> None: ...
1437
+
1438
+ @abstractmethod
1439
+ def _update_diffusion_inferred(self) -> None: ...
1440
+
1441
+ # ---- persistence ------------------------------------------------------ #
1442
+
1443
+ def save_results(self, path) -> "Path":
1444
+ """Save ``report_dict()`` to ``<path>.npz`` + ``<path>.json``.
1445
+
1446
+ See :func:`SFI.inference.serialization.save_results` for details.
1447
+ """
1448
+ from SFI.inference.serialization import save_results
1449
+
1450
+ return save_results(self, path)
1451
+
1452
+ @staticmethod
1453
+ def load_results(path) -> dict:
1454
+ """Reload a dict previously saved by :meth:`save_results`.
1455
+
1456
+ See :func:`SFI.inference.serialization.load_results` for details.
1457
+ """
1458
+ from SFI.inference.serialization import load_results
1459
+
1460
+ return load_results(path)