StochasticForceInference 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- SFI/__init__.py +64 -0
- SFI/bases/__init__.py +85 -0
- SFI/bases/constants.py +492 -0
- SFI/bases/linear.py +325 -0
- SFI/bases/monomials.py +218 -0
- SFI/bases/pairs.py +998 -0
- SFI/bases/spde.py +1537 -0
- SFI/diagnostics/__init__.py +60 -0
- SFI/diagnostics/assess.py +87 -0
- SFI/diagnostics/dynamics_order.py +621 -0
- SFI/diagnostics/plotting.py +226 -0
- SFI/diagnostics/report.py +238 -0
- SFI/diagnostics/residual_tests.py +395 -0
- SFI/diagnostics/residuals.py +688 -0
- SFI/inference/__init__.py +58 -0
- SFI/inference/base.py +1460 -0
- SFI/inference/optimizers.py +200 -0
- SFI/inference/overdamped.py +1214 -0
- SFI/inference/parametric_core/__init__.py +34 -0
- SFI/inference/parametric_core/covariance.py +232 -0
- SFI/inference/parametric_core/driver.py +272 -0
- SFI/inference/parametric_core/flow.py +149 -0
- SFI/inference/parametric_core/flow_multi.py +362 -0
- SFI/inference/parametric_core/flow_ud.py +168 -0
- SFI/inference/parametric_core/jacobians.py +540 -0
- SFI/inference/parametric_core/objective.py +286 -0
- SFI/inference/parametric_core/objective_ud.py +253 -0
- SFI/inference/parametric_core/precision.py +229 -0
- SFI/inference/parametric_core/solve.py +763 -0
- SFI/inference/result.py +362 -0
- SFI/inference/serialization.py +245 -0
- SFI/inference/sparse/__init__.py +67 -0
- SFI/inference/sparse/base.py +43 -0
- SFI/inference/sparse/beam.py +303 -0
- SFI/inference/sparse/greedy.py +151 -0
- SFI/inference/sparse/hillclimb.py +307 -0
- SFI/inference/sparse/lasso.py +178 -0
- SFI/inference/sparse/metrics.py +78 -0
- SFI/inference/sparse/result.py +278 -0
- SFI/inference/sparse/scorer.py +323 -0
- SFI/inference/sparse/stlsq.py +165 -0
- SFI/inference/sparsity.py +40 -0
- SFI/inference/underdamped.py +1355 -0
- SFI/integrate/__init__.py +38 -0
- SFI/integrate/api.py +920 -0
- SFI/integrate/integrand.py +402 -0
- SFI/integrate/rk4.py +156 -0
- SFI/integrate/timeops.py +174 -0
- SFI/langevin/__init__.py +29 -0
- SFI/langevin/base.py +863 -0
- SFI/langevin/chunked.py +225 -0
- SFI/langevin/noise.py +446 -0
- SFI/langevin/overdamped.py +560 -0
- SFI/langevin/underdamped.py +448 -0
- SFI/statefunc/__init__.py +49 -0
- SFI/statefunc/basis.py +87 -0
- SFI/statefunc/core/runtime.py +47 -0
- SFI/statefunc/factory.py +346 -0
- SFI/statefunc/interactor.py +90 -0
- SFI/statefunc/layout/__init__.py +29 -0
- SFI/statefunc/layout/_base.py +186 -0
- SFI/statefunc/layout/_eval_compiler.py +453 -0
- SFI/statefunc/layout/_fd_atoms.py +196 -0
- SFI/statefunc/layout/_grid.py +878 -0
- SFI/statefunc/layout/_sectors.py +166 -0
- SFI/statefunc/memhint.py +222 -0
- SFI/statefunc/nodes/__init__.py +56 -0
- SFI/statefunc/nodes/base.py +318 -0
- SFI/statefunc/nodes/contract.py +422 -0
- SFI/statefunc/nodes/interactions/__init__.py +23 -0
- SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
- SFI/statefunc/nodes/interactions/prepare.py +161 -0
- SFI/statefunc/nodes/interactions/specs.py +362 -0
- SFI/statefunc/nodes/interactions/stencils.py +718 -0
- SFI/statefunc/nodes/leaf.py +530 -0
- SFI/statefunc/nodes/ops/__init__.py +27 -0
- SFI/statefunc/nodes/ops/concat.py +28 -0
- SFI/statefunc/nodes/ops/derivative.py +447 -0
- SFI/statefunc/nodes/ops/einsum.py +120 -0
- SFI/statefunc/nodes/ops/linear.py +153 -0
- SFI/statefunc/nodes/ops/mapn.py +138 -0
- SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
- SFI/statefunc/nodes/ops/slice.py +83 -0
- SFI/statefunc/params.py +309 -0
- SFI/statefunc/psf.py +112 -0
- SFI/statefunc/sf.py +104 -0
- SFI/statefunc/stateexpr.py +1243 -0
- SFI/statefunc/structexpr.py +1003 -0
- SFI/trajectory/__init__.py +31 -0
- SFI/trajectory/collection.py +1122 -0
- SFI/trajectory/dataset.py +1164 -0
- SFI/trajectory/degrade.py +1108 -0
- SFI/trajectory/io.py +1027 -0
- SFI/trajectory/reserved_extras.py +129 -0
- SFI/utils/__init__.py +17 -0
- SFI/utils/formatting.py +308 -0
- SFI/utils/maths.py +185 -0
- SFI/utils/neighbors.py +162 -0
- SFI/utils/plotting.py +1936 -0
- stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
- stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
- stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
- stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
- stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
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)
|