StochasticForceInference 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. SFI/__init__.py +64 -0
  2. SFI/bases/__init__.py +85 -0
  3. SFI/bases/constants.py +492 -0
  4. SFI/bases/linear.py +325 -0
  5. SFI/bases/monomials.py +218 -0
  6. SFI/bases/pairs.py +998 -0
  7. SFI/bases/spde.py +1537 -0
  8. SFI/diagnostics/__init__.py +60 -0
  9. SFI/diagnostics/assess.py +87 -0
  10. SFI/diagnostics/dynamics_order.py +621 -0
  11. SFI/diagnostics/plotting.py +226 -0
  12. SFI/diagnostics/report.py +238 -0
  13. SFI/diagnostics/residual_tests.py +395 -0
  14. SFI/diagnostics/residuals.py +688 -0
  15. SFI/inference/__init__.py +58 -0
  16. SFI/inference/base.py +1460 -0
  17. SFI/inference/optimizers.py +200 -0
  18. SFI/inference/overdamped.py +1214 -0
  19. SFI/inference/parametric_core/__init__.py +34 -0
  20. SFI/inference/parametric_core/covariance.py +232 -0
  21. SFI/inference/parametric_core/driver.py +272 -0
  22. SFI/inference/parametric_core/flow.py +149 -0
  23. SFI/inference/parametric_core/flow_multi.py +362 -0
  24. SFI/inference/parametric_core/flow_ud.py +168 -0
  25. SFI/inference/parametric_core/jacobians.py +540 -0
  26. SFI/inference/parametric_core/objective.py +286 -0
  27. SFI/inference/parametric_core/objective_ud.py +253 -0
  28. SFI/inference/parametric_core/precision.py +229 -0
  29. SFI/inference/parametric_core/solve.py +763 -0
  30. SFI/inference/result.py +362 -0
  31. SFI/inference/serialization.py +245 -0
  32. SFI/inference/sparse/__init__.py +67 -0
  33. SFI/inference/sparse/base.py +43 -0
  34. SFI/inference/sparse/beam.py +303 -0
  35. SFI/inference/sparse/greedy.py +151 -0
  36. SFI/inference/sparse/hillclimb.py +307 -0
  37. SFI/inference/sparse/lasso.py +178 -0
  38. SFI/inference/sparse/metrics.py +78 -0
  39. SFI/inference/sparse/result.py +278 -0
  40. SFI/inference/sparse/scorer.py +323 -0
  41. SFI/inference/sparse/stlsq.py +165 -0
  42. SFI/inference/sparsity.py +40 -0
  43. SFI/inference/underdamped.py +1355 -0
  44. SFI/integrate/__init__.py +38 -0
  45. SFI/integrate/api.py +920 -0
  46. SFI/integrate/integrand.py +402 -0
  47. SFI/integrate/rk4.py +156 -0
  48. SFI/integrate/timeops.py +174 -0
  49. SFI/langevin/__init__.py +29 -0
  50. SFI/langevin/base.py +863 -0
  51. SFI/langevin/chunked.py +225 -0
  52. SFI/langevin/noise.py +446 -0
  53. SFI/langevin/overdamped.py +560 -0
  54. SFI/langevin/underdamped.py +448 -0
  55. SFI/statefunc/__init__.py +49 -0
  56. SFI/statefunc/basis.py +87 -0
  57. SFI/statefunc/core/runtime.py +47 -0
  58. SFI/statefunc/factory.py +346 -0
  59. SFI/statefunc/interactor.py +90 -0
  60. SFI/statefunc/layout/__init__.py +29 -0
  61. SFI/statefunc/layout/_base.py +186 -0
  62. SFI/statefunc/layout/_eval_compiler.py +453 -0
  63. SFI/statefunc/layout/_fd_atoms.py +196 -0
  64. SFI/statefunc/layout/_grid.py +878 -0
  65. SFI/statefunc/layout/_sectors.py +166 -0
  66. SFI/statefunc/memhint.py +222 -0
  67. SFI/statefunc/nodes/__init__.py +56 -0
  68. SFI/statefunc/nodes/base.py +318 -0
  69. SFI/statefunc/nodes/contract.py +422 -0
  70. SFI/statefunc/nodes/interactions/__init__.py +23 -0
  71. SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
  72. SFI/statefunc/nodes/interactions/prepare.py +161 -0
  73. SFI/statefunc/nodes/interactions/specs.py +362 -0
  74. SFI/statefunc/nodes/interactions/stencils.py +718 -0
  75. SFI/statefunc/nodes/leaf.py +530 -0
  76. SFI/statefunc/nodes/ops/__init__.py +27 -0
  77. SFI/statefunc/nodes/ops/concat.py +28 -0
  78. SFI/statefunc/nodes/ops/derivative.py +447 -0
  79. SFI/statefunc/nodes/ops/einsum.py +120 -0
  80. SFI/statefunc/nodes/ops/linear.py +153 -0
  81. SFI/statefunc/nodes/ops/mapn.py +138 -0
  82. SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
  83. SFI/statefunc/nodes/ops/slice.py +83 -0
  84. SFI/statefunc/params.py +309 -0
  85. SFI/statefunc/psf.py +112 -0
  86. SFI/statefunc/sf.py +104 -0
  87. SFI/statefunc/stateexpr.py +1243 -0
  88. SFI/statefunc/structexpr.py +1003 -0
  89. SFI/trajectory/__init__.py +31 -0
  90. SFI/trajectory/collection.py +1122 -0
  91. SFI/trajectory/dataset.py +1164 -0
  92. SFI/trajectory/degrade.py +1108 -0
  93. SFI/trajectory/io.py +1027 -0
  94. SFI/trajectory/reserved_extras.py +129 -0
  95. SFI/utils/__init__.py +17 -0
  96. SFI/utils/formatting.py +308 -0
  97. SFI/utils/maths.py +185 -0
  98. SFI/utils/neighbors.py +162 -0
  99. SFI/utils/plotting.py +1936 -0
  100. stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
  101. stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
  102. stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
  103. stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
  104. stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ ]