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/result.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# SFI/inference/result.py
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Optional, Sequence, Tuple, Union
|
|
7
|
+
|
|
8
|
+
import jax
|
|
9
|
+
import jax.numpy as jnp
|
|
10
|
+
import numpy as np
|
|
11
|
+
from scipy.stats import norm as _norm
|
|
12
|
+
|
|
13
|
+
from SFI.statefunc.psf import PSF
|
|
14
|
+
|
|
15
|
+
# Import the bound state-function + PSF (for vectorization helpers)
|
|
16
|
+
from SFI.statefunc.sf import SF
|
|
17
|
+
|
|
18
|
+
# =====================================================================
|
|
19
|
+
# Module-level utility: CI for 1D kernel profiles
|
|
20
|
+
# =====================================================================
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def kernel_predict_ci(
|
|
24
|
+
r_eval: Union[np.ndarray, jnp.ndarray],
|
|
25
|
+
kernels: Sequence[Tuple[callable, str]],
|
|
26
|
+
coeffs: Union[np.ndarray, jnp.ndarray],
|
|
27
|
+
cov_block: Union[np.ndarray, jnp.ndarray],
|
|
28
|
+
*,
|
|
29
|
+
alpha: float = 0.95,
|
|
30
|
+
) -> Dict[str, np.ndarray]:
|
|
31
|
+
r"""Confidence interval for a 1D kernel profile.
|
|
32
|
+
|
|
33
|
+
For a reconstructed kernel profile
|
|
34
|
+
|
|
35
|
+
.. math::
|
|
36
|
+
|
|
37
|
+
k(r) = \sum_{\alpha} c_\alpha \, \phi_\alpha(r),
|
|
38
|
+
|
|
39
|
+
the variance at each :math:`r` is
|
|
40
|
+
|
|
41
|
+
.. math::
|
|
42
|
+
|
|
43
|
+
\operatorname{Var}[k(r)]
|
|
44
|
+
= \boldsymbol{\phi}(r)^\top \, \Sigma_c \, \boldsymbol{\phi}(r),
|
|
45
|
+
|
|
46
|
+
where :math:`\Sigma_c` is the covariance sub-block for the
|
|
47
|
+
coefficients of this basis group.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
r_eval : array_like, shape ``(R,)``
|
|
52
|
+
Radial grid on which to evaluate the kernel.
|
|
53
|
+
kernels : list of ``(callable, label)``
|
|
54
|
+
Kernel basis functions, e.g. from
|
|
55
|
+
:func:`~SFI.bases.pairs.exp_poly_kernels`.
|
|
56
|
+
Each callable maps ``r -> phi(r)``.
|
|
57
|
+
coeffs : array_like, shape ``(K,)``
|
|
58
|
+
Inferred coefficients for this basis block.
|
|
59
|
+
cov_block : array_like, shape ``(K, K)``
|
|
60
|
+
Covariance sub-block for these coefficients
|
|
61
|
+
(from ``inf.force_coefficients_covariance[i0:i1, i0:i1]``
|
|
62
|
+
after calling ``inf.compute_force_error()``).
|
|
63
|
+
alpha : float
|
|
64
|
+
Confidence level (default 0.95 for 95 % CI).
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
dict with keys:
|
|
69
|
+
|
|
70
|
+
- **r** — the input radial grid (as numpy array).
|
|
71
|
+
- **mean** — kernel profile ``coeffs @ phi(r)``.
|
|
72
|
+
- **std** — pointwise standard deviation.
|
|
73
|
+
- **lower**, **upper** — symmetric CI bounds.
|
|
74
|
+
- **phi** — basis matrix ``(K, R)`` (useful for further analysis).
|
|
75
|
+
"""
|
|
76
|
+
r_arr = np.asarray(r_eval)
|
|
77
|
+
r_jax = jnp.asarray(r_arr)
|
|
78
|
+
coeffs = np.asarray(coeffs)
|
|
79
|
+
cov_block = np.asarray(cov_block)
|
|
80
|
+
|
|
81
|
+
# Build basis matrix: phi[k, r_idx] = phi_k(r_idx)
|
|
82
|
+
phi = np.array([np.asarray(fn(r_jax)) for fn, _ in kernels]) # (K, R)
|
|
83
|
+
|
|
84
|
+
mean = coeffs @ phi # (R,)
|
|
85
|
+
# Var[k(r)] = phi(r)^T Sigma phi(r) — vectorised over r
|
|
86
|
+
# phi_cov = Sigma @ phi → (K, R)
|
|
87
|
+
phi_cov = cov_block @ phi # (K, R)
|
|
88
|
+
var = np.einsum("kr,kr->r", phi, phi_cov) # (R,)
|
|
89
|
+
std = np.sqrt(np.maximum(var, 0.0))
|
|
90
|
+
|
|
91
|
+
z = float(_norm.ppf((1.0 + alpha) / 2.0))
|
|
92
|
+
return dict(
|
|
93
|
+
r=r_arr,
|
|
94
|
+
mean=mean,
|
|
95
|
+
std=std,
|
|
96
|
+
lower=mean - z * std,
|
|
97
|
+
upper=mean + z * std,
|
|
98
|
+
phi=phi,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class InferenceResultSF(SF):
|
|
103
|
+
"""
|
|
104
|
+
A fitted, callable state function that *is* an SF and carries
|
|
105
|
+
parameter covariance + metadata for downstream uncertainty handling.
|
|
106
|
+
|
|
107
|
+
Notes
|
|
108
|
+
-----
|
|
109
|
+
- `param_cov` is the covariance of the *flattened* parameter vector
|
|
110
|
+
defined by the underlying PSF template order (see PSF.flatten_params).
|
|
111
|
+
- Covariance estimation is handled upstream (in the inferer).
|
|
112
|
+
- Call :meth:`predict_var` / :meth:`predict_ci` for pointwise uncertainty.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
# Extra fields beyond SF:
|
|
116
|
+
_psf_ref: PSF # reference PSF (from the parent SF)
|
|
117
|
+
param_cov: Optional[jnp.ndarray] # Σ_θ in the PSF template's vector order
|
|
118
|
+
meta: Dict[str, Any] # free-form: dt, A_hat, G/M, modes, sizes…
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
sf: SF,
|
|
123
|
+
*,
|
|
124
|
+
param_cov: Optional[jnp.ndarray] = None,
|
|
125
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
126
|
+
):
|
|
127
|
+
# Initialize SF with the original psf + params; keep drop_features consistent
|
|
128
|
+
super().__init__(sf._psf, sf.params, drop_features=sf.drop_features)
|
|
129
|
+
object.__setattr__(self, "_psf_ref", sf._psf)
|
|
130
|
+
object.__setattr__(self, "param_cov", param_cov)
|
|
131
|
+
object.__setattr__(self, "meta", {} if meta is None else dict(meta))
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------
|
|
134
|
+
# Convenience: parameter vectorization consistent with the PSF template
|
|
135
|
+
# ---------------------------------------------------------------------
|
|
136
|
+
def flatten_params(self) -> jnp.ndarray:
|
|
137
|
+
"""Return θ̂ as a 1D vector using the PSF template order."""
|
|
138
|
+
return self._psf_ref.flatten_params(self.params)
|
|
139
|
+
|
|
140
|
+
def materialize_params(self, vec: jnp.ndarray) -> dict[str, jax.Array]:
|
|
141
|
+
"""Inverse of `flatten_params`: make a param dict from a vector."""
|
|
142
|
+
return self._psf_ref.unflatten_params(vec)
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
# Internal: Jacobian evaluation
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
def _jacobian(
|
|
148
|
+
self,
|
|
149
|
+
x: jnp.ndarray,
|
|
150
|
+
*,
|
|
151
|
+
extras: Optional[Dict[str, Any]] = None,
|
|
152
|
+
mask: Optional[jnp.ndarray] = None,
|
|
153
|
+
) -> jnp.ndarray:
|
|
154
|
+
r"""Evaluate ∂F/∂θ at ``x`` using the underlying PSF's ``d_theta()``.
|
|
155
|
+
|
|
156
|
+
Returns
|
|
157
|
+
-------
|
|
158
|
+
J : jnp.ndarray, shape ``(…, *rank_shape, n_params)``
|
|
159
|
+
Jacobian of the model output w.r.t. the *full* flattened
|
|
160
|
+
parameter vector. For a rank-1 force ``F : (N, d)`` this is
|
|
161
|
+
``(N, d, p)``; for a rank-2 diffusion ``D : (N, d, d)`` it is
|
|
162
|
+
``(N, d, d, p)``.
|
|
163
|
+
"""
|
|
164
|
+
J_psf = self._psf_ref.d_theta() # a new PSF object
|
|
165
|
+
J_raw = J_psf(x, params=self.params, extras=extras, mask=mask)
|
|
166
|
+
# J_raw shape: (..., *rank_shape, n_features_fused)
|
|
167
|
+
# n_features_fused = n_features_child × n_params
|
|
168
|
+
# For CoeffNode n_features_child is 1, so fused == n_params.
|
|
169
|
+
# In general, we know the total n_params from the template.
|
|
170
|
+
n_params = int(self._psf_ref.template.size)
|
|
171
|
+
# Unfuse last axis: (..., *rank_shape, n_features_child, n_params)
|
|
172
|
+
# then sum over the feature axis (contraction already done for CoeffNode,
|
|
173
|
+
# but for safety reshape → n_feat_child × n_params → sum over n_feat_child).
|
|
174
|
+
n_fused = J_raw.shape[-1]
|
|
175
|
+
n_feat_child = n_fused // n_params if n_params > 0 else 1
|
|
176
|
+
if n_feat_child == 1:
|
|
177
|
+
# Common case: CoeffNode — fused axis is already (n_params,)
|
|
178
|
+
return J_raw
|
|
179
|
+
# Rare case: multi-feature PSFs — reshape and sum
|
|
180
|
+
batch_rank_shape = J_raw.shape[:-1]
|
|
181
|
+
J_raw = J_raw.reshape(*batch_rank_shape, n_feat_child, n_params)
|
|
182
|
+
return J_raw.sum(axis=-2)
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
# Uncertainty interface
|
|
186
|
+
# ------------------------------------------------------------------
|
|
187
|
+
def _check_param_cov(self):
|
|
188
|
+
"""Raise if param_cov is not available."""
|
|
189
|
+
if self.param_cov is None:
|
|
190
|
+
raise RuntimeError(
|
|
191
|
+
"Parameter covariance is not available on this result. "
|
|
192
|
+
"Call compute_force_error() (or compute_diffusion_error()) "
|
|
193
|
+
"on the inferer first, or use an optimizer that provides "
|
|
194
|
+
"the Hessian (e.g. L-BFGS-B instead of Adam)."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def predict_var(
|
|
198
|
+
self,
|
|
199
|
+
x: jnp.ndarray,
|
|
200
|
+
*,
|
|
201
|
+
extras: Optional[Dict[str, Any]] = None,
|
|
202
|
+
mask: Optional[jnp.ndarray] = None,
|
|
203
|
+
) -> jnp.ndarray:
|
|
204
|
+
r"""Pointwise predictive variance via the delta method.
|
|
205
|
+
|
|
206
|
+
.. math::
|
|
207
|
+
|
|
208
|
+
\operatorname{Var}\!\bigl[F_i(x)\bigr]
|
|
209
|
+
\approx \bigl(J_\theta(x)\,\Sigma_\theta\,J_\theta(x)^\top\bigr)_{ii}
|
|
210
|
+
|
|
211
|
+
For **linear** models (basis expansion) this is **exact**,
|
|
212
|
+
not an approximation.
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
x : array, shape ``(N, dim)``
|
|
217
|
+
Query points.
|
|
218
|
+
extras : dict, optional
|
|
219
|
+
Extra arguments forwarded to the underlying state function
|
|
220
|
+
(e.g. ``{"box": box}`` for periodic boundary conditions).
|
|
221
|
+
mask : array, optional
|
|
222
|
+
Boolean mask forwarded to evaluation.
|
|
223
|
+
|
|
224
|
+
Returns
|
|
225
|
+
-------
|
|
226
|
+
var : jnp.ndarray
|
|
227
|
+
Per-component variance. Shape matches the model output rank:
|
|
228
|
+
``(N, d)`` for a rank-1 (force) model, ``(N, d, d)`` for rank-2
|
|
229
|
+
(diffusion tensor).
|
|
230
|
+
"""
|
|
231
|
+
self._check_param_cov()
|
|
232
|
+
J = self._jacobian(x, extras=extras, mask=mask) # (..., *rank, p)
|
|
233
|
+
S = jnp.asarray(self.param_cov) # (p, p)
|
|
234
|
+
# Var_i = J_i @ S @ J_i^T (diagonal only)
|
|
235
|
+
# Efficient: (J @ S) elementwise-* J, sum over last axis
|
|
236
|
+
JS = jnp.einsum("...p,pq->...q", J, S)
|
|
237
|
+
return jnp.einsum("...p,...p->...", JS, J)
|
|
238
|
+
|
|
239
|
+
def predict_cov(
|
|
240
|
+
self,
|
|
241
|
+
x: jnp.ndarray,
|
|
242
|
+
*,
|
|
243
|
+
extras: Optional[Dict[str, Any]] = None,
|
|
244
|
+
mask: Optional[jnp.ndarray] = None,
|
|
245
|
+
) -> jnp.ndarray:
|
|
246
|
+
r"""Full pointwise covariance matrix via the delta method.
|
|
247
|
+
|
|
248
|
+
.. math::
|
|
249
|
+
|
|
250
|
+
\Sigma_F(x) = J_\theta(x)\,\Sigma_\theta\,J_\theta(x)^\top
|
|
251
|
+
|
|
252
|
+
Parameters
|
|
253
|
+
----------
|
|
254
|
+
x : array, shape ``(N, dim)``
|
|
255
|
+
extras, mask : forwarded to the underlying state function.
|
|
256
|
+
|
|
257
|
+
Returns
|
|
258
|
+
-------
|
|
259
|
+
cov : jnp.ndarray
|
|
260
|
+
For rank-1 models: shape ``(N, d, d)``.
|
|
261
|
+
For rank-2 models: shape ``(N, d, d, d, d)`` (rarely needed).
|
|
262
|
+
"""
|
|
263
|
+
self._check_param_cov()
|
|
264
|
+
J = self._jacobian(x, extras=extras, mask=mask) # (..., r, p)
|
|
265
|
+
S = jnp.asarray(self.param_cov) # (p, p)
|
|
266
|
+
return jnp.einsum("...ip,pq,...jq->...ij", J, S, J)
|
|
267
|
+
|
|
268
|
+
def predict_ci(
|
|
269
|
+
self,
|
|
270
|
+
x: jnp.ndarray,
|
|
271
|
+
*,
|
|
272
|
+
alpha: float = 0.95,
|
|
273
|
+
extras: Optional[Dict[str, Any]] = None,
|
|
274
|
+
mask: Optional[jnp.ndarray] = None,
|
|
275
|
+
) -> Dict[str, jnp.ndarray]:
|
|
276
|
+
r"""Pointwise confidence intervals via the delta method.
|
|
277
|
+
|
|
278
|
+
Parameters
|
|
279
|
+
----------
|
|
280
|
+
x : array, shape ``(N, dim)``
|
|
281
|
+
alpha : float
|
|
282
|
+
Confidence level (default 0.95 for 95 % CI).
|
|
283
|
+
extras, mask : forwarded to the underlying state function.
|
|
284
|
+
|
|
285
|
+
Returns
|
|
286
|
+
-------
|
|
287
|
+
dict with keys:
|
|
288
|
+
|
|
289
|
+
- **mean** — model prediction ``F̂(x)``.
|
|
290
|
+
- **std** — pointwise standard deviation ``√Var[F(x)]``.
|
|
291
|
+
- **lower**, **upper** — symmetric CI bounds
|
|
292
|
+
``F̂ ± z_{α/2} · std``.
|
|
293
|
+
"""
|
|
294
|
+
mean = self(x, extras=extras, mask=mask)
|
|
295
|
+
var = self.predict_var(x, extras=extras, mask=mask)
|
|
296
|
+
std = jnp.sqrt(jnp.maximum(var, 0.0))
|
|
297
|
+
z = float(_norm.ppf((1.0 + alpha) / 2.0))
|
|
298
|
+
return dict(mean=mean, std=std, lower=mean - z * std, upper=mean + z * std)
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------------
|
|
301
|
+
# Pretty representation
|
|
302
|
+
# ---------------------------------------------------------------------
|
|
303
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
304
|
+
kind = self.meta.get("kind", "unknown")
|
|
305
|
+
n_params = int(self.flatten_params().size)
|
|
306
|
+
has_cov = self.param_cov is not None
|
|
307
|
+
labels = self.meta.get("basis_labels", None)
|
|
308
|
+
n_basis = len(labels) if labels else self.meta.get("basis_features", "?")
|
|
309
|
+
return f"InferenceResultSF({kind}, basis={n_basis}, params={n_params}, cov={'yes' if has_cov else 'no'})"
|
|
310
|
+
|
|
311
|
+
def summary(self) -> str:
|
|
312
|
+
"""Formatted coefficient table (if labels and coefficients are available)."""
|
|
313
|
+
import numpy as np
|
|
314
|
+
|
|
315
|
+
from SFI.utils.formatting import model_summary
|
|
316
|
+
|
|
317
|
+
labels = self.meta.get("basis_labels", None)
|
|
318
|
+
theta = np.asarray(self.flatten_params())
|
|
319
|
+
kind = self.meta.get("kind", "model")
|
|
320
|
+
|
|
321
|
+
if labels is None:
|
|
322
|
+
labels = [f"b{j}" for j in range(theta.size)]
|
|
323
|
+
|
|
324
|
+
return model_summary(
|
|
325
|
+
labels,
|
|
326
|
+
theta,
|
|
327
|
+
title=f"{kind.capitalize()} Coefficient Table",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# ---------------------------------------------------------------------
|
|
331
|
+
# Persistence (equinox-based model serialization)
|
|
332
|
+
# ---------------------------------------------------------------------
|
|
333
|
+
def save(self, path) -> "Path":
|
|
334
|
+
"""Save this fitted model to ``<path>.eqx`` + ``<path>.meta.json``.
|
|
335
|
+
|
|
336
|
+
The saved files can be reloaded with :meth:`load`, provided
|
|
337
|
+
the user supplies a *template* built from the same PSF/Basis.
|
|
338
|
+
|
|
339
|
+
See :func:`SFI.inference.serialization.save_model`.
|
|
340
|
+
"""
|
|
341
|
+
from SFI.inference.serialization import save_model
|
|
342
|
+
|
|
343
|
+
return save_model(self, path)
|
|
344
|
+
|
|
345
|
+
@classmethod
|
|
346
|
+
def load(cls, path, template: "InferenceResultSF") -> "InferenceResultSF":
|
|
347
|
+
"""Reload a model saved by :meth:`save`.
|
|
348
|
+
|
|
349
|
+
Parameters
|
|
350
|
+
----------
|
|
351
|
+
path : str or Path
|
|
352
|
+
Base path (without extension).
|
|
353
|
+
template : InferenceResultSF
|
|
354
|
+
Skeleton with the same tree structure (same PSF + dummy params).
|
|
355
|
+
|
|
356
|
+
See Also
|
|
357
|
+
--------
|
|
358
|
+
SFI.inference.serialization.load_model
|
|
359
|
+
"""
|
|
360
|
+
from SFI.inference.serialization import load_model
|
|
361
|
+
|
|
362
|
+
return load_model(path, template)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# SFI/inference/serialization.py
|
|
2
|
+
"""
|
|
3
|
+
Save / load helpers for inference results.
|
|
4
|
+
|
|
5
|
+
Two complementary routes:
|
|
6
|
+
|
|
7
|
+
1. **Lightweight** (`save_results` / `load_results`):
|
|
8
|
+
Saves the scalar + array summary produced by ``report_dict()`` to a
|
|
9
|
+
NumPy ``.npz`` archive with a JSON sidecar. Useful for archival and
|
|
10
|
+
cross-language interop. Does **not** preserve the callable model.
|
|
11
|
+
|
|
12
|
+
2. **Equinox model** (`save` / `load` on ``InferenceResultSF``):
|
|
13
|
+
Serializes the full JAX pytree (leaf arrays + metadata) so the fitted
|
|
14
|
+
model can be reloaded and called for prediction.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING, Any, Dict
|
|
23
|
+
|
|
24
|
+
import jax.numpy as jnp
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from .result import InferenceResultSF
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# =====================================================================
|
|
34
|
+
# 1. Lightweight save/load (report_dict → .npz + .json)
|
|
35
|
+
# =====================================================================
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _split_for_npz(d: dict) -> tuple[dict, dict]:
|
|
39
|
+
"""Split a dict into (arrays, scalars) suitable for np.savez / JSON."""
|
|
40
|
+
arrays: dict = {}
|
|
41
|
+
scalars: dict = {}
|
|
42
|
+
for k, v in d.items():
|
|
43
|
+
if isinstance(v, np.ndarray):
|
|
44
|
+
arrays[k] = v
|
|
45
|
+
elif isinstance(v, dict):
|
|
46
|
+
# Recurse one level (e.g. metadata)
|
|
47
|
+
sub_a, sub_s = _split_for_npz(v)
|
|
48
|
+
for sk, sv in sub_a.items():
|
|
49
|
+
arrays[f"{k}.{sk}"] = sv
|
|
50
|
+
scalars[k] = sub_s
|
|
51
|
+
else:
|
|
52
|
+
scalars[k] = v
|
|
53
|
+
return arrays, scalars
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _merge_from_npz(arrays: dict, scalars: dict) -> dict:
|
|
57
|
+
"""Inverse of _split_for_npz."""
|
|
58
|
+
out: dict = {}
|
|
59
|
+
# First, add scalars
|
|
60
|
+
for k, v in scalars.items():
|
|
61
|
+
out[k] = v
|
|
62
|
+
# Then inject arrays back, re-nesting dotted keys
|
|
63
|
+
for k, v in arrays.items():
|
|
64
|
+
if "." in k:
|
|
65
|
+
parent, child = k.split(".", 1)
|
|
66
|
+
out.setdefault(parent, {})
|
|
67
|
+
if isinstance(out[parent], dict):
|
|
68
|
+
out[parent][child] = v
|
|
69
|
+
else:
|
|
70
|
+
out[k] = v
|
|
71
|
+
else:
|
|
72
|
+
out[k] = v
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def save_results(inferer, path: str | Path) -> Path:
|
|
77
|
+
"""Save ``inferer.report_dict()`` to ``<path>.npz`` + ``<path>.json``.
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
inferer : BaseLangevinInference
|
|
82
|
+
Any inference object that exposes ``report_dict()``.
|
|
83
|
+
path : str or Path
|
|
84
|
+
Base path (without extension). Two files are created:
|
|
85
|
+
``<path>.npz`` for arrays, ``<path>.json`` for scalars/metadata.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
Path
|
|
90
|
+
The base path used.
|
|
91
|
+
"""
|
|
92
|
+
path = Path(path)
|
|
93
|
+
d = inferer.report_dict()
|
|
94
|
+
arrays, scalars = _split_for_npz(d)
|
|
95
|
+
|
|
96
|
+
np.savez(str(path.with_suffix(".npz")), **arrays)
|
|
97
|
+
|
|
98
|
+
# JSON-safe conversion
|
|
99
|
+
def _jsonable(obj):
|
|
100
|
+
if isinstance(obj, (np.floating, float)):
|
|
101
|
+
return float(obj)
|
|
102
|
+
if isinstance(obj, (np.integer, int)):
|
|
103
|
+
return int(obj)
|
|
104
|
+
if isinstance(obj, np.ndarray):
|
|
105
|
+
return obj.tolist()
|
|
106
|
+
if isinstance(obj, (list, tuple)):
|
|
107
|
+
return [_jsonable(x) for x in obj]
|
|
108
|
+
if isinstance(obj, dict):
|
|
109
|
+
return {k: _jsonable(v) for k, v in obj.items()}
|
|
110
|
+
return obj
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
from importlib.metadata import version as _pkg_version
|
|
114
|
+
|
|
115
|
+
sfi_version = _pkg_version("StochasticForceInference")
|
|
116
|
+
except Exception:
|
|
117
|
+
sfi_version = "unknown"
|
|
118
|
+
scalars["_sfi_version"] = sfi_version
|
|
119
|
+
|
|
120
|
+
with open(str(path.with_suffix(".json")), "w") as f:
|
|
121
|
+
json.dump(_jsonable(scalars), f, indent=2)
|
|
122
|
+
|
|
123
|
+
logger.info("Results saved to %s.{npz,json}", path)
|
|
124
|
+
return path
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_results(path: str | Path) -> dict:
|
|
128
|
+
"""Reload a dict saved by :func:`save_results`.
|
|
129
|
+
|
|
130
|
+
Parameters
|
|
131
|
+
----------
|
|
132
|
+
path : str or Path
|
|
133
|
+
Base path (without extension).
|
|
134
|
+
|
|
135
|
+
Returns
|
|
136
|
+
-------
|
|
137
|
+
dict
|
|
138
|
+
The merged dictionary of arrays and scalars.
|
|
139
|
+
"""
|
|
140
|
+
path = Path(path)
|
|
141
|
+
npz = np.load(str(path.with_suffix(".npz")), allow_pickle=False)
|
|
142
|
+
arrays = dict(npz)
|
|
143
|
+
|
|
144
|
+
with open(str(path.with_suffix(".json"))) as f:
|
|
145
|
+
scalars = json.load(f)
|
|
146
|
+
|
|
147
|
+
return _merge_from_npz(arrays, scalars)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# =====================================================================
|
|
151
|
+
# 2. Model save/load (InferenceResultSF → .params.npz + .meta.json)
|
|
152
|
+
# =====================================================================
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def save_model(result_sf, path: str | Path) -> Path:
|
|
156
|
+
"""Serialize an ``InferenceResultSF`` (params array + metadata).
|
|
157
|
+
|
|
158
|
+
Creates ``<path>.params.npz`` (the fitted parameter arrays) and
|
|
159
|
+
``<path>.meta.json`` (param_cov + non-array metadata).
|
|
160
|
+
|
|
161
|
+
The PSF/Basis structure is **not** saved — it must be supplied again
|
|
162
|
+
as a ``template`` when calling :func:`load_model`.
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
result_sf : InferenceResultSF
|
|
167
|
+
The fitted model to save.
|
|
168
|
+
path : str or Path
|
|
169
|
+
Base path (without extension).
|
|
170
|
+
|
|
171
|
+
Returns
|
|
172
|
+
-------
|
|
173
|
+
Path
|
|
174
|
+
The base path used.
|
|
175
|
+
"""
|
|
176
|
+
path = Path(path)
|
|
177
|
+
|
|
178
|
+
# Save parameter arrays (the only dynamic content)
|
|
179
|
+
params_np = {k: np.asarray(v) for k, v in result_sf.params.items()}
|
|
180
|
+
np.savez(str(path.with_suffix(".params.npz")), **params_np)
|
|
181
|
+
|
|
182
|
+
# Persist param_cov and meta in JSON sidecar
|
|
183
|
+
sidecar: Dict[str, Any] = {}
|
|
184
|
+
if result_sf.meta:
|
|
185
|
+
safe_meta: Dict[str, Any] = {}
|
|
186
|
+
for k, v in result_sf.meta.items():
|
|
187
|
+
if isinstance(v, (jnp.ndarray, np.ndarray)):
|
|
188
|
+
safe_meta[k] = np.asarray(v).tolist()
|
|
189
|
+
elif isinstance(v, dict):
|
|
190
|
+
safe_meta[k] = {
|
|
191
|
+
kk: np.asarray(vv).tolist() if isinstance(vv, (jnp.ndarray, np.ndarray)) else vv
|
|
192
|
+
for kk, vv in v.items()
|
|
193
|
+
}
|
|
194
|
+
else:
|
|
195
|
+
safe_meta[k] = v
|
|
196
|
+
sidecar["meta"] = safe_meta
|
|
197
|
+
if result_sf.param_cov is not None:
|
|
198
|
+
sidecar["param_cov"] = np.asarray(result_sf.param_cov).tolist()
|
|
199
|
+
|
|
200
|
+
with open(str(path.with_suffix(".meta.json")), "w") as f:
|
|
201
|
+
json.dump(sidecar, f, indent=2)
|
|
202
|
+
|
|
203
|
+
logger.info("Model saved to %s.{params.npz,meta.json}", path)
|
|
204
|
+
return path
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def load_model(path: str | Path, template) -> "InferenceResultSF":
|
|
208
|
+
"""Reload an ``InferenceResultSF`` from files written by :func:`save_model`.
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
path : str or Path
|
|
213
|
+
Base path (without extension).
|
|
214
|
+
template : InferenceResultSF
|
|
215
|
+
A *skeleton* instance providing the PSF / Basis structure.
|
|
216
|
+
Typically built from the same PSF / Basis used at training time:
|
|
217
|
+
``template = InferenceResultSF(SF(psf, dummy_params))``.
|
|
218
|
+
|
|
219
|
+
Returns
|
|
220
|
+
-------
|
|
221
|
+
InferenceResultSF
|
|
222
|
+
"""
|
|
223
|
+
from SFI.statefunc import SF
|
|
224
|
+
|
|
225
|
+
from .result import InferenceResultSF
|
|
226
|
+
|
|
227
|
+
path = Path(path)
|
|
228
|
+
npz = np.load(str(path.with_suffix(".params.npz")))
|
|
229
|
+
params = {k: jnp.array(npz[k]) for k in npz.files}
|
|
230
|
+
|
|
231
|
+
sf = SF(template._psf_ref, params, drop_features=template.drop_features)
|
|
232
|
+
loaded = InferenceResultSF(sf)
|
|
233
|
+
|
|
234
|
+
meta_path = path.with_suffix(".meta.json")
|
|
235
|
+
if meta_path.exists():
|
|
236
|
+
with open(str(meta_path)) as f:
|
|
237
|
+
sidecar = json.load(f)
|
|
238
|
+
meta = sidecar.get("meta", {})
|
|
239
|
+
param_cov_list = sidecar.get("param_cov", None)
|
|
240
|
+
param_cov = jnp.array(param_cov_list) if param_cov_list is not None else None
|
|
241
|
+
object.__setattr__(loaded, "meta", meta)
|
|
242
|
+
object.__setattr__(loaded, "param_cov", param_cov)
|
|
243
|
+
|
|
244
|
+
logger.info("Model loaded from %s", path)
|
|
245
|
+
return loaded
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SFI.inference.sparse — Pluggable sparse model selection.
|
|
3
|
+
=========================================================
|
|
4
|
+
|
|
5
|
+
This sub-package separates the *scoring* of candidate supports from the
|
|
6
|
+
*search strategy* used to navigate the combinatorial lattice.
|
|
7
|
+
|
|
8
|
+
Public API
|
|
9
|
+
----------
|
|
10
|
+
SparseScorer
|
|
11
|
+
Owns the ``(M, G)`` normal-equations data. Scores any support *B* by
|
|
12
|
+
solving the restricted linear system and computing the information gain.
|
|
13
|
+
|
|
14
|
+
SparsityResult
|
|
15
|
+
Container returned by every strategy. Holds the Pareto front
|
|
16
|
+
(best info / support / coefficients per cardinality *k*) and provides
|
|
17
|
+
information-criterion selection (AIC, BIC, EBIC, PASTIS, SIC).
|
|
18
|
+
|
|
19
|
+
SparsityStrategy
|
|
20
|
+
Abstract base class for search algorithms. Subclasses must implement
|
|
21
|
+
``run(scorer, *, max_k, **kwargs) -> SparsityResult``.
|
|
22
|
+
|
|
23
|
+
Concrete strategies
|
|
24
|
+
~~~~~~~~~~~~~~~~~~~
|
|
25
|
+
BeamSearchStrategy
|
|
26
|
+
Bidirectional beam search (the original PASTIS algorithm).
|
|
27
|
+
GreedyStepwiseStrategy
|
|
28
|
+
Forward, backward, or bidirectional stepwise selection.
|
|
29
|
+
HillClimbStrategy
|
|
30
|
+
Stochastic hill-climbing with random add/remove moves
|
|
31
|
+
(Gerardos & Ronceray, 2025).
|
|
32
|
+
STLSQStrategy
|
|
33
|
+
Sequential Thresholded Least Squares (à la SINDy).
|
|
34
|
+
LassoStrategy
|
|
35
|
+
Coordinate-descent :math:`\\ell_1`-penalised regression on the normal
|
|
36
|
+
equations.
|
|
37
|
+
|
|
38
|
+
Benchmark helpers
|
|
39
|
+
~~~~~~~~~~~~~~~~~
|
|
40
|
+
overlap_metrics
|
|
41
|
+
Compare predicted vs true support (TP / FP / FN / precision / recall).
|
|
42
|
+
predictive_nmse
|
|
43
|
+
Normalised mean-squared error on a held-out design matrix.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from .base import SparsityStrategy
|
|
47
|
+
from .beam import BeamSearchStrategy
|
|
48
|
+
from .greedy import GreedyStepwiseStrategy
|
|
49
|
+
from .hillclimb import HillClimbStrategy
|
|
50
|
+
from .lasso import LassoStrategy
|
|
51
|
+
from .metrics import overlap_metrics, predictive_nmse
|
|
52
|
+
from .result import SparsityResult
|
|
53
|
+
from .scorer import SparseScorer
|
|
54
|
+
from .stlsq import STLSQStrategy
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"SparseScorer",
|
|
58
|
+
"SparsityResult",
|
|
59
|
+
"SparsityStrategy",
|
|
60
|
+
"BeamSearchStrategy",
|
|
61
|
+
"GreedyStepwiseStrategy",
|
|
62
|
+
"HillClimbStrategy",
|
|
63
|
+
"STLSQStrategy",
|
|
64
|
+
"LassoStrategy",
|
|
65
|
+
"overlap_metrics",
|
|
66
|
+
"predictive_nmse",
|
|
67
|
+
]
|