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,307 @@
1
+ """
2
+ SFI.inference.sparse.hillclimb — Stochastic hill-climbing selection
3
+ ====================================================================
4
+
5
+ Implements the search strategy described in Gerardos & Ronceray (2025):
6
+ starting from an initial model, accept random single-parameter
7
+ additions or removals if they increase the chosen information
8
+ criterion, until convergence. Multiple independent chains are run in
9
+ parallel, one per cardinality *k* ∈ {0, …, max_k} (including the null
10
+ and full models), each starting from a uniformly random support of
11
+ size *k*.
12
+
13
+ This is complementary to the deterministic strategies (greedy, beam):
14
+
15
+ * **Greedy / beam** explore a systematic path and may get trapped in a
16
+ single monotonic trajectory through the lattice.
17
+ * **Hill-climbing** performs a stochastic local search at every
18
+ complexity level, which can escape greedy traps and explore broader
19
+ neighbourhoods of the combinatorial lattice.
20
+
21
+ References
22
+ ----------
23
+ * Gerardos, A. & Ronceray, P. (2025). "Principled model selection for
24
+ stochastic dynamics." (describes the algorithm in the main text.)
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import math
31
+ import time
32
+ from typing import Optional
33
+
34
+ import jax.numpy as jnp
35
+ import numpy as np
36
+
37
+ from .base import SparsityStrategy
38
+ from .result import SparsityResult
39
+ from .scorer import SparseScorer
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ # ------------------------------------------------------------------
45
+ # Inline IC penalties (no SparsityResult needed during the search)
46
+ # ------------------------------------------------------------------
47
+
48
+
49
+ def _ic_penalty(
50
+ name: str,
51
+ k: int,
52
+ *,
53
+ n0: int,
54
+ p_param: float,
55
+ tau: Optional[float],
56
+ gamma: float,
57
+ total_info: float,
58
+ ) -> float:
59
+ """Return the penalty term for a given information criterion.
60
+
61
+ The score is ``info - penalty``, so a *larger* penalty means more
62
+ pruning.
63
+ """
64
+ name = name.upper()
65
+ if name == "AIC":
66
+ return float(k)
67
+ if name == "BIC":
68
+ if tau is None:
69
+ raise ValueError("BIC requires 'tau' (total trajectory time).")
70
+ return 0.5 * k * math.log(tau)
71
+ if name == "EBIC":
72
+ if tau is None:
73
+ raise ValueError("EBIC requires 'tau' (total trajectory time).")
74
+ lc = math.lgamma(n0 + 1) - math.lgamma(k + 1) - math.lgamma(n0 - k + 1)
75
+ return 0.5 * k * math.log(tau) + 2.0 * gamma * lc
76
+ if name == "PASTIS":
77
+ return k * math.log(n0 / p_param)
78
+ if name == "SIC":
79
+ if total_info <= 0:
80
+ return float("inf")
81
+ return k * math.log(total_info)
82
+ raise ValueError(f"Unknown criterion {name!r}")
83
+
84
+
85
+ # ------------------------------------------------------------------
86
+ # Single chain
87
+ # ------------------------------------------------------------------
88
+
89
+
90
+ def _run_chain(
91
+ scorer: SparseScorer,
92
+ init_support: np.ndarray,
93
+ *,
94
+ ic_name: str,
95
+ n0: int,
96
+ max_k: int,
97
+ p_param: float,
98
+ tau: Optional[float],
99
+ gamma: float,
100
+ total_info: float,
101
+ patience: int,
102
+ rng: np.random.Generator,
103
+ ) -> dict:
104
+ """Run one stochastic hill-climbing chain.
105
+
106
+ Returns
107
+ -------
108
+ trace : dict[int, (info, support, coeffs)]
109
+ Best raw information gain seen per cardinality across every
110
+ proposal evaluated by the chain (accepted *or* rejected). This
111
+ is the slice of the chain's work that contributes to the global
112
+ Pareto front.
113
+ """
114
+ current = set(int(j) for j in init_support)
115
+ all_indices = set(range(n0))
116
+ trace: dict = {}
117
+
118
+ def _evaluate(support_set: set) -> tuple[float, float, Optional[jnp.ndarray]]:
119
+ """Return ``(ic_score, raw_info, coeffs)``."""
120
+ k = len(support_set)
121
+ penalty = _ic_penalty(
122
+ ic_name, k,
123
+ n0=n0, p_param=p_param, tau=tau, gamma=gamma, total_info=total_info,
124
+ )
125
+ if k == 0:
126
+ return -penalty, 0.0, None
127
+ B = jnp.array(sorted(support_set), dtype=jnp.int32)
128
+ info, coeffs = scorer.info_and_coeffs(B)
129
+ info_f = float(info)
130
+ return info_f - penalty, info_f, coeffs
131
+
132
+ def _record_trace(support_set: set, info: float, coeffs) -> None:
133
+ k = len(support_set)
134
+ entry = trace.get(k)
135
+ if entry is None or info > entry[0]:
136
+ trace[k] = (info, sorted(support_set), coeffs)
137
+
138
+ best_ic, raw_info, coeffs = _evaluate(current)
139
+ _record_trace(current, raw_info, coeffs)
140
+ fails = 0
141
+
142
+ while fails < patience:
143
+ # Decide on a random move: add or remove
144
+ can_add = len(current) < max_k and len(current) < n0
145
+ can_remove = len(current) > 0
146
+ if can_add and can_remove:
147
+ do_add = bool(rng.integers(2))
148
+ elif can_add:
149
+ do_add = True
150
+ elif can_remove:
151
+ do_add = False
152
+ else:
153
+ break # stuck at empty with max_k=0
154
+
155
+ if do_add:
156
+ candidates = list(all_indices - current)
157
+ j = int(rng.choice(candidates))
158
+ proposal = current | {j}
159
+ else:
160
+ j = int(rng.choice(list(current)))
161
+ proposal = current - {j}
162
+
163
+ ic_proposal, info_proposal, coeffs_proposal = _evaluate(proposal)
164
+ _record_trace(proposal, info_proposal, coeffs_proposal)
165
+ if ic_proposal > best_ic:
166
+ current = proposal
167
+ best_ic = ic_proposal
168
+ fails = 0
169
+ else:
170
+ fails += 1
171
+
172
+ return trace
173
+
174
+
175
+ # ------------------------------------------------------------------
176
+ # Strategy class
177
+ # ------------------------------------------------------------------
178
+
179
+
180
+ class HillClimbStrategy(SparsityStrategy):
181
+ """Stochastic hill-climbing model selection.
182
+
183
+ For each cardinality *k* ∈ {0, …, max_k}, a chain starts from a
184
+ random support of size *k* and accepts random add/remove moves that
185
+ improve the chosen information criterion, stopping after *patience*
186
+ consecutive failures. The best-per-*k* results form the Pareto
187
+ front returned as a :class:`SparsityResult`.
188
+
189
+ This is the search algorithm described in Gerardos & Ronceray (2025),
190
+ §"Model selection", which recommends parallel searches from null,
191
+ full, and random starting points.
192
+
193
+ Parameters
194
+ ----------
195
+ ic : str, default ``"PASTIS"``
196
+ Information criterion used as the acceptance objective.
197
+ One of ``"AIC"``, ``"BIC"``, ``"EBIC"``, ``"PASTIS"``,
198
+ ``"SIC"``.
199
+ p_param : float, default 1e-3
200
+ PASTIS significance level :math:`p_0`.
201
+ tau : float or None
202
+ Total trajectory time (required for BIC / EBIC).
203
+ gamma : float, default 0.5
204
+ EBIC tuning parameter (:math:`\\gamma \\in [0,1]`).
205
+ patience : int, default 200
206
+ Stop a chain after this many consecutive rejected moves.
207
+ seed : int or None
208
+ Random seed for reproducibility.
209
+ report_time : bool, default False
210
+ Log elapsed wall-clock time when done.
211
+ """
212
+
213
+ name = "hillclimb"
214
+
215
+ def __init__(
216
+ self,
217
+ *,
218
+ ic: str = "PASTIS",
219
+ p_param: float = 1e-3,
220
+ tau: Optional[float] = None,
221
+ gamma: float = 0.5,
222
+ patience: int = 200,
223
+ seed: Optional[int] = None,
224
+ report_time: bool = False,
225
+ ):
226
+ self.ic = ic.upper()
227
+ self.p_param = p_param
228
+ self.tau = tau
229
+ self.gamma = gamma
230
+ self.patience = patience
231
+ self.seed = seed
232
+ self.report_time = report_time
233
+
234
+ # -----------------------------------------------------------------
235
+ def run(self, scorer: SparseScorer, *, max_k: int, **_kwargs) -> SparsityResult:
236
+ t0 = time.perf_counter()
237
+ n0 = scorer.p
238
+ max_k = min(max_k, n0)
239
+ rng = np.random.default_rng(self.seed)
240
+ total_info = float(scorer.total_info)
241
+
242
+ best_info = [-np.inf] * (max_k + 1)
243
+ best_support = [[] for _ in range(max_k + 1)]
244
+ best_coeffs = [None] * (max_k + 1)
245
+
246
+ # Null model (k=0) always has info=0
247
+ best_info[0] = 0.0
248
+
249
+ def _record(k: int, info: float, support: list, coeffs):
250
+ if 0 <= k <= max_k and info > best_info[k]:
251
+ best_info[k] = info
252
+ best_support[k] = list(support)
253
+ best_coeffs[k] = coeffs
254
+
255
+ # ------ Launch one chain per cardinality k --------------------
256
+ for k in range(0, max_k + 1):
257
+ # Random starting support of size k
258
+ if k == 0:
259
+ init = np.array([], dtype=np.int32)
260
+ elif k >= n0:
261
+ init = np.arange(n0, dtype=np.int32)
262
+ else:
263
+ init = np.sort(rng.choice(n0, size=k, replace=False))
264
+
265
+ trace = _run_chain(
266
+ scorer,
267
+ init,
268
+ ic_name=self.ic,
269
+ n0=n0,
270
+ max_k=max_k,
271
+ p_param=self.p_param,
272
+ tau=self.tau,
273
+ gamma=self.gamma,
274
+ total_info=total_info,
275
+ patience=self.patience,
276
+ rng=rng,
277
+ )
278
+ # Fold every cardinality the chain explored into the global
279
+ # Pareto front, not only its final landing point.
280
+ for k_state, (info, support, coeffs) in trace.items():
281
+ _record(k_state, info, support, coeffs)
282
+
283
+ logger.debug(
284
+ "Hill-climb chain k₀=%d → %d cardinalities recorded.",
285
+ k,
286
+ len(trace),
287
+ )
288
+
289
+ if self.report_time:
290
+ dt = time.perf_counter() - t0
291
+ logger.info(
292
+ "Hill-climb (%s, patience=%d) done in %.2fs.",
293
+ self.ic,
294
+ self.patience,
295
+ dt,
296
+ )
297
+
298
+ return SparsityResult(
299
+ p=n0,
300
+ total_info=total_info,
301
+ method="hillclimb",
302
+ best_info_by_k=best_info,
303
+ best_support_by_k=best_support,
304
+ best_coeffs_by_k=best_coeffs,
305
+ second_info_by_k=[-np.inf] * (max_k + 1),
306
+ second_support_by_k=[[] for _ in range(max_k + 1)],
307
+ )
@@ -0,0 +1,178 @@
1
+ """
2
+ SFI.inference.sparse.lasso — ℓ₁-penalised regression (LASSO)
3
+ =============================================================
4
+
5
+ Solve the :math:`\\ell_1`-regularised normal-equations problem:
6
+
7
+ .. math::
8
+
9
+ \\hat C = \\arg\\min_C \\;
10
+ \\tfrac{1}{2}\\,C^\\top G\\,C \\;-\\; M^\\top C
11
+ \\;+\\; \\alpha\\,\\|C\\|_1
12
+
13
+ using proximal coordinate descent. No access to the raw design matrix
14
+ :math:`\\Phi` is needed — only ``(M, G)`` — preserving the clean
15
+ decoupling from the data pipeline.
16
+
17
+ Sweeping over a regularisation path of :math:`\\alpha` values produces
18
+ supports at varying sparsity levels, from which a Pareto front is
19
+ assembled.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import time
26
+
27
+ import jax.numpy as jnp
28
+ import numpy as np
29
+
30
+ from .base import SparsityStrategy
31
+ from .result import SparsityResult
32
+ from .scorer import SparseScorer
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ def _soft_threshold(x: float, lam: float) -> float:
38
+ """Scalar soft-thresholding operator."""
39
+ if x > lam:
40
+ return x - lam
41
+ if x < -lam:
42
+ return x + lam
43
+ return 0.0
44
+
45
+
46
+ class LassoStrategy(SparsityStrategy):
47
+ r"""Coordinate-descent LASSO on the normal equations.
48
+
49
+ Parameters
50
+ ----------
51
+ alpha : float or None
52
+ Fixed regularisation strength. If *None* (default), an
53
+ automatic log-spaced path from :math:`\alpha_{\max}` (where the
54
+ solution is entirely zero) down to :math:`10^{-4}\,\alpha_{\max}`
55
+ is constructed.
56
+ n_alphas : int, default 50
57
+ Number of :math:`\alpha` values in the automatic path.
58
+ max_iter : int, default 1000
59
+ Maximum coordinate-descent iterations per :math:`\alpha`.
60
+ tol : float, default 1e-7
61
+ Convergence tolerance (max absolute change in any coefficient).
62
+ report_time : bool, default False
63
+ Log elapsed wall-clock time when done.
64
+ """
65
+
66
+ name = "lasso"
67
+
68
+ def __init__(
69
+ self,
70
+ *,
71
+ alpha: float | None = None,
72
+ n_alphas: int = 50,
73
+ max_iter: int = 1000,
74
+ tol: float = 1e-7,
75
+ report_time: bool = False,
76
+ ):
77
+ self.alpha = alpha
78
+ self.n_alphas = n_alphas
79
+ self.max_iter = max_iter
80
+ self.tol = tol
81
+ self.report_time = report_time
82
+
83
+ # -----------------------------------------------------------------
84
+ def _coordinate_descent(self, G: np.ndarray, M: np.ndarray, alpha: float, C_init: np.ndarray) -> np.ndarray:
85
+ """Run coordinate descent for one alpha. Pure numpy, no JAX."""
86
+ p = len(M)
87
+ C = C_init.copy()
88
+ for _ in range(self.max_iter):
89
+ max_delta = 0.0
90
+ for j in range(p):
91
+ # Partial residual for coordinate j
92
+ r_j = float(M[j] - G[j] @ C + G[j, j] * C[j])
93
+ G_jj = float(G[j, j])
94
+ if G_jj < 1e-30:
95
+ new_val = 0.0
96
+ else:
97
+ new_val = _soft_threshold(r_j, alpha) / G_jj
98
+ delta = abs(new_val - C[j])
99
+ if delta > max_delta:
100
+ max_delta = delta
101
+ C[j] = new_val
102
+ if max_delta < self.tol:
103
+ break
104
+ return C
105
+
106
+ # -----------------------------------------------------------------
107
+ def run(self, scorer: SparseScorer, *, max_k: int, **_kwargs) -> SparsityResult:
108
+ t0 = time.perf_counter()
109
+ p = scorer.p
110
+ max_k = min(max_k, p)
111
+
112
+ # Convert to numpy for the coordinate descent loop
113
+ G_np = np.asarray(scorer.G)
114
+ M_np = np.asarray(scorer.M)
115
+
116
+ # alpha_max: smallest alpha that zeros everything out
117
+ alpha_max = float(np.max(np.abs(M_np)))
118
+
119
+ if self.alpha is not None:
120
+ alphas = [self.alpha]
121
+ else:
122
+ alphas = np.logspace(
123
+ np.log10(alpha_max),
124
+ np.log10(alpha_max * 1e-4),
125
+ self.n_alphas,
126
+ ).tolist()
127
+
128
+ best_info = [-np.inf] * (max_k + 1)
129
+ best_support = [[] for _ in range(max_k + 1)]
130
+ best_coeffs = [None] * (max_k + 1)
131
+
132
+ # Null model
133
+ best_info[0] = 0.0
134
+
135
+ # Warm-start: start from zeros for the largest alpha
136
+ C_warm = np.zeros(p)
137
+
138
+ for alpha_val in alphas:
139
+ C_warm = self._coordinate_descent(G_np, M_np, alpha_val, C_warm)
140
+
141
+ # Identify nonzero support
142
+ support = [j for j in range(p) if abs(C_warm[j]) > 1e-14]
143
+ k = len(support)
144
+
145
+ if k > max_k or k == 0:
146
+ continue
147
+
148
+ # Re-solve exactly on the LASSO support (de-biased LASSO)
149
+ B = jnp.array(support, dtype=jnp.int32)
150
+ info, coeffs = scorer.info_and_coeffs(B)
151
+
152
+ if float(info) > best_info[k]:
153
+ best_info[k] = float(info)
154
+ best_support[k] = support
155
+ best_coeffs[k] = coeffs
156
+
157
+ # Also record the full model if within max_k
158
+ if p <= max_k:
159
+ full_info = float(scorer.total_info)
160
+ if full_info > best_info[p]:
161
+ best_info[p] = full_info
162
+ best_support[p] = list(range(p))
163
+ best_coeffs[p] = scorer.total_C
164
+
165
+ if self.report_time:
166
+ dt = time.perf_counter() - t0
167
+ logger.info("LASSO done in %.2fs (%d alphas).", dt, len(alphas))
168
+
169
+ return SparsityResult(
170
+ p=scorer.p,
171
+ total_info=float(scorer.total_info),
172
+ method=self.name,
173
+ best_info_by_k=best_info,
174
+ best_support_by_k=best_support,
175
+ best_coeffs_by_k=best_coeffs,
176
+ second_info_by_k=[-np.inf] * (max_k + 1),
177
+ second_support_by_k=[[] for _ in range(max_k + 1)],
178
+ )
@@ -0,0 +1,78 @@
1
+ """
2
+ SFI.inference.sparse.metrics — Benchmark helpers
3
+ =================================================
4
+
5
+ Standalone functions for comparing inferred supports / coefficients
6
+ against ground truth. Useful for benchmarking and papers but not
7
+ required for normal inference.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Dict, List
13
+
14
+ import jax.numpy as jnp
15
+
16
+ Array = jnp.ndarray
17
+
18
+
19
+ def overlap_metrics(true_support: List[int], pred_support: List[int]) -> Dict:
20
+ """Compare predicted support to the ground truth.
21
+
22
+ Parameters
23
+ ----------
24
+ true_support, pred_support : list[int]
25
+ Indices of the true and predicted active basis functions.
26
+
27
+ Returns
28
+ -------
29
+ dict
30
+ Keys: ``TP``, ``FP``, ``FN``, ``prec``, ``rec``, ``exact``.
31
+ """
32
+ true_set, pred_set = set(true_support), set(pred_support)
33
+ tp = len(true_set & pred_set)
34
+ fp = len(pred_set - true_set)
35
+ fn = len(true_set - pred_set)
36
+ return dict(
37
+ TP=tp,
38
+ FP=fp,
39
+ FN=fn,
40
+ prec=tp / (tp + fp) if tp + fp else 0.0,
41
+ rec=tp / (tp + fn) if tp + fn else 0.0,
42
+ exact=(fp == 0 and fn == 0),
43
+ )
44
+
45
+
46
+ def predictive_nmse(
47
+ Phi_test: Array,
48
+ true_support: List[int],
49
+ true_coeffs,
50
+ inferred_support: List[int],
51
+ inferred_coeffs,
52
+ ) -> float:
53
+ """Normalised mean-squared error on a held-out design matrix.
54
+
55
+ Parameters
56
+ ----------
57
+ Phi_test : (n_test, p) Array
58
+ Design matrix evaluated on test data.
59
+ true_support : list[int]
60
+ Ground-truth active indices.
61
+ true_coeffs : array-like
62
+ Ground-truth coefficient vector (length ``len(true_support)``).
63
+ inferred_support : list[int]
64
+ Inferred active indices.
65
+ inferred_coeffs : array-like
66
+ Inferred coefficient vector (length ``len(inferred_support)``).
67
+
68
+ Returns
69
+ -------
70
+ float
71
+ :math:`\\|\\hat y - y\\|^2 / \\|y\\|^2`.
72
+ """
73
+ if len(inferred_support) == 0:
74
+ return 1.0
75
+ true_signal = Phi_test[:, jnp.array(true_support)] @ jnp.array(true_coeffs)
76
+ pred_signal = Phi_test[:, jnp.array(inferred_support)] @ jnp.array(inferred_coeffs)
77
+ residual = true_signal - pred_signal
78
+ return float(jnp.sum(residual**2) / jnp.sum(true_signal**2))