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,278 @@
1
+ """
2
+ SFI.inference.sparse.result — Sparsity result container
3
+ =======================================================
4
+
5
+ :class:`SparsityResult` is the return type of every search strategy.
6
+ It stores the Pareto front (best info / support / coefficients per
7
+ cardinality *k*) and provides information-criterion selection.
8
+
9
+ Supported information criteria
10
+ ------------------------------
11
+ * **AIC** — Akaike (1974), penalty *k*.
12
+ * **BIC** — Schwarz (1978), penalty (k/2) ln τ. Uses the
13
+ continuous-time formulation of Gerardos & Ronceray (2025).
14
+ * **EBIC** — Chen & Chen (2008), BIC + 2 γ ln C(n₀, k).
15
+ * **PASTIS** — Gerardos & Ronceray (2025), penalty k ln(n₀/p₀).
16
+ * **SIC** — Secret Information Criterion (unpublished, Ronceray),
17
+ penalty k ln(I_total).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import math
24
+ from dataclasses import dataclass, field
25
+ from typing import Dict, List, Optional, Tuple
26
+
27
+ import jax.numpy as jnp
28
+ import numpy as np
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ Array = jnp.ndarray
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class SparsityResult:
37
+ """Frozen container for the output of a sparsity search.
38
+
39
+ Attributes
40
+ ----------
41
+ p : int
42
+ Total number of candidate basis functions.
43
+ total_info : float
44
+ Information gain of the full (dense) model.
45
+ method : str
46
+ Name of the strategy that produced this result (e.g.
47
+ ``"beam"``, ``"greedy"``, ``"stlsq"``, ``"lasso"``).
48
+ best_info_by_k : list[float]
49
+ ``best_info_by_k[k]`` is the highest information gain found
50
+ among all explored supports of cardinality *k*. Unexplored
51
+ cardinalities are ``-inf``.
52
+ best_support_by_k : list[list[int]]
53
+ The support achieving ``best_info_by_k[k]``.
54
+ best_coeffs_by_k : list[Array | None]
55
+ The corresponding coefficient vector.
56
+ second_info_by_k : list[float]
57
+ Second-best information gain per *k* (for robustness
58
+ diagnostics). May be all ``-inf`` if the strategy does not
59
+ track runner-ups.
60
+ second_support_by_k : list[list[int]]
61
+ Support achieving the second-best info per *k*.
62
+ """
63
+
64
+ p: int
65
+ total_info: float
66
+ method: str
67
+
68
+ best_info_by_k: list = field(default_factory=list)
69
+ best_support_by_k: list = field(default_factory=list)
70
+ best_coeffs_by_k: list = field(default_factory=list)
71
+
72
+ second_info_by_k: list = field(default_factory=list)
73
+ second_support_by_k: list = field(default_factory=list)
74
+
75
+ # -----------------------------------------------------------------
76
+ # Information-criterion selection
77
+ # -----------------------------------------------------------------
78
+ def select_by_ic(
79
+ self,
80
+ name: str,
81
+ *,
82
+ p_param: float = 1e-3,
83
+ tau: Optional[float] = None,
84
+ gamma: float = 0.5,
85
+ ) -> Tuple[int, List[int], float, Optional[Array]]:
86
+ r"""Return the support that maximises a given information criterion.
87
+
88
+ .. physics:: Information criteria for sparse model selection
89
+ :label: information-criteria
90
+ :category: Model selection
91
+
92
+ .. math::
93
+
94
+ \text{AIC}(k) &= \mathcal{I}(k) - k \\
95
+ \text{BIC}(k) &= \mathcal{I}(k) - \tfrac{1}{2}\,k\,\ln\tau \\
96
+ \text{EBIC}(k) &= \text{BIC}(k) - 2\gamma\,\ln\binom{n_0}{k} \\
97
+ \text{PASTIS}(k) &= \mathcal{I}(k) - k\,\ln(n_0 / p_0) \\
98
+ \text{SIC}(k) &= \mathcal{I}(k) - k\,\ln(\mathcal{I}_{\text{total}})
99
+
100
+ where :math:`\mathcal{I}(k)` is the log-likelihood gain with
101
+ *k* basis terms out of :math:`n_0` candidates, :math:`\tau`
102
+ is the total trajectory time, :math:`p_0` is the PASTIS
103
+ significance level, and :math:`\gamma \in [0,1]` controls EBIC
104
+ stringency.
105
+
106
+ References
107
+ ----------
108
+ * **AIC** — Akaike, H. (1974). "A new look at the statistical
109
+ model identification." *IEEE Trans. Automat. Control*, 19(6),
110
+ 716–723.
111
+ * **BIC** — Schwarz, G. (1978). "Estimating the dimension of a
112
+ model." *Ann. Statist.*, 6(2), 461–464. The continuous-time
113
+ formulation :math:`\tfrac{k}{2}\ln\tau` follows from the
114
+ Laplace approximation of the SDE marginal likelihood
115
+ (Gerardos & Ronceray, 2025).
116
+ * **EBIC** — Chen, J. & Chen, Z. (2008). "Extended Bayesian
117
+ information criteria for model selection with large model
118
+ spaces." *Biometrika*, 95(3), 759–771.
119
+ * **PASTIS** — Gerardos, A. & Ronceray, P. (2025).
120
+ "Principled model selection for stochastic dynamics."
121
+ * **SIC** — Unpublished (Ronceray).
122
+
123
+ Parameters
124
+ ----------
125
+ name : ``"AIC"`` | ``"BIC"`` | ``"EBIC"`` | ``"PASTIS"`` | ``"SIC"``
126
+ Information criterion to maximise.
127
+ p_param : float, default 1e-3
128
+ Significance level :math:`p_0` for the PASTIS penalty.
129
+ tau : float or None
130
+ Total trajectory time. **Required** for BIC and EBIC.
131
+ gamma : float, default 0.5
132
+ EBIC tuning parameter (:math:`\gamma \in [0,1]`). Only used
133
+ when *name* is ``"EBIC"``.
134
+
135
+ Returns
136
+ -------
137
+ k_star : int
138
+ Selected model size.
139
+ support : list[int]
140
+ Basis-function indices of the chosen model.
141
+ score : float
142
+ Value of the information criterion at ``k_star``.
143
+ coeffs : Array or None
144
+ Coefficient vector for the selected support.
145
+ """
146
+ name = name.upper()
147
+ n0 = self.p
148
+ total_info = self.total_info
149
+
150
+ # Validate tau for criteria that need it
151
+ if name in ("BIC", "EBIC") and tau is None:
152
+ raise ValueError(
153
+ f"Criterion {name!r} requires the total trajectory time 'tau'. Pass tau=<float> to select_by_ic()."
154
+ )
155
+
156
+ def _log_comb(n: int, k: int) -> float:
157
+ """log C(n, k) via lgamma — exact for integer args."""
158
+ if k < 0 or k > n:
159
+ return 0.0
160
+ return math.lgamma(n + 1) - math.lgamma(k + 1) - math.lgamma(n - k + 1)
161
+
162
+ def _score(k: int, info: float) -> float:
163
+ if info == -np.inf:
164
+ return -np.inf
165
+ if name == "AIC":
166
+ return info - k
167
+ if name == "BIC":
168
+ return info - 0.5 * k * math.log(tau)
169
+ if name == "EBIC":
170
+ return info - 0.5 * k * math.log(tau) - 2.0 * gamma * _log_comb(n0, k)
171
+ if name == "PASTIS":
172
+ return info - k * math.log(n0 / p_param)
173
+ if name == "SIC":
174
+ return info - k * math.log(total_info)
175
+ raise ValueError(f"Unknown criterion {name!r}")
176
+
177
+ scores = [_score(k, info) for k, info in enumerate(self.best_info_by_k)]
178
+ k_star = int(np.argmax(scores))
179
+ logger.info(
180
+ "Criterion %s selected a model with %d terms out of %d.",
181
+ name,
182
+ k_star,
183
+ self.p,
184
+ )
185
+ return (
186
+ k_star,
187
+ self.best_support_by_k[k_star],
188
+ scores[k_star],
189
+ self.best_coeffs_by_k[k_star],
190
+ )
191
+
192
+ # -----------------------------------------------------------------
193
+ # Convenience: all ICs at once
194
+ # -----------------------------------------------------------------
195
+ def all_ic(
196
+ self,
197
+ *,
198
+ p_param: float = 1e-3,
199
+ tau: Optional[float] = None,
200
+ gamma: float = 0.5,
201
+ true_support: Optional[List[int]] = None,
202
+ true_coeffs: Optional[List[float]] = None,
203
+ Phi_test: Optional[Array] = None,
204
+ verbose: bool = True,
205
+ ) -> Dict[str, Dict]:
206
+ """Compute all information criteria and optionally compare to ground truth.
207
+
208
+ Parameters
209
+ ----------
210
+ p_param : float
211
+ PASTIS significance level.
212
+ tau : float or None
213
+ Total trajectory time. If provided, BIC and EBIC are
214
+ included; otherwise they are skipped.
215
+ gamma : float, default 0.5
216
+ EBIC tuning parameter.
217
+ true_support, true_coeffs : optional
218
+ Ground-truth support and coefficients for overlap metrics.
219
+ Phi_test : optional Array
220
+ Held-out design matrix for predictive NMSE.
221
+ verbose : bool
222
+ If *True*, log a summary table at INFO level.
223
+
224
+ Returns
225
+ -------
226
+ dict
227
+ Keyed by IC name, each value is a dict with ``k``,
228
+ ``support``, ``score``, ``coeffs``, and optionally overlap
229
+ and predictive-NMSE entries.
230
+ """
231
+ from .metrics import overlap_metrics, predictive_nmse
232
+
233
+ # Build list of criteria — BIC/EBIC only when tau is available
234
+ ic_names = ["AIC"]
235
+ if tau is not None:
236
+ ic_names += ["BIC", "EBIC"]
237
+ ic_names += ["PASTIS", "SIC"]
238
+
239
+ summary: Dict[str, Dict] = {}
240
+ for ic_name in ic_names:
241
+ k, support, score, coeffs = self.select_by_ic(
242
+ ic_name,
243
+ p_param=p_param,
244
+ tau=tau,
245
+ gamma=gamma,
246
+ )
247
+ entry: dict = dict(k=k, support=support, score=float(score), coeffs=coeffs)
248
+ if true_support is not None:
249
+ entry.update(overlap_metrics(true_support, support))
250
+ if Phi_test is not None and true_coeffs is not None:
251
+ entry["predictive_NMSE"] = predictive_nmse(Phi_test, true_support, true_coeffs, support, coeffs)
252
+ summary[ic_name] = entry
253
+
254
+ if verbose:
255
+ has_overlap = any("exact" in e for e in summary.values())
256
+ has_nmse = any("predictive_NMSE" in e for e in summary.values())
257
+
258
+ hdr_parts = [f"{'IC':<8}", f"{'k*':>3}", f"{'score':>10}"]
259
+ if has_overlap:
260
+ hdr_parts.append(f"{'TP/FP/FN':>15}")
261
+ hdr_parts.append(f"{'exact':>10}")
262
+ if has_nmse:
263
+ hdr_parts.append(f"{'pred NMSE':>10}")
264
+ hdr_parts.append("support")
265
+
266
+ lines = ["=== Information-criterion summary ===", " ".join(hdr_parts)]
267
+ for ic_name, entry in summary.items():
268
+ row_parts = [f"{ic_name:<8}", f"{entry['k']:>3}", f"{entry['score']:10.2f}"]
269
+ if has_overlap:
270
+ row_parts.append(f"{entry['TP']}/{entry['FP']}/{entry['FN']:>9}")
271
+ row_parts.append(f"{str(entry['exact']):>10}")
272
+ if has_nmse:
273
+ row_parts.append(f"{entry['predictive_NMSE']:10.4f}")
274
+ row_parts.append(str(entry["support"]))
275
+ lines.append(" ".join(row_parts))
276
+ logger.info("\n".join(lines))
277
+
278
+ return summary
@@ -0,0 +1,323 @@
1
+ """
2
+ SFI.inference.sparse.scorer — Normal-equations scorer
3
+ =====================================================
4
+
5
+ The :class:`SparseScorer` owns the pre-computed moment vector **M** and
6
+ Gram matrix **G** and provides efficient (JIT / vmap) evaluation of the
7
+ log-likelihood gain for any candidate support :math:`B`.
8
+
9
+ It is *stateless* with respect to the search: no Pareto-front data lives
10
+ here. Every strategy receives a scorer and calls its methods.
11
+
12
+ Performance notes
13
+ ~~~~~~~~~~~~~~~~~
14
+ * Symmetry of **G** is detected at construction time. When symmetric
15
+ PSD, the restricted solve uses Cholesky (``assume_a="pos"``),
16
+ which is ~2× faster and more stable than the general LU path.
17
+ * The solve is fully JIT-compatible (no Python-level ``try``/``except``).
18
+ Singular/rank-deficient cases are handled via ``jnp.linalg.lstsq``.
19
+ * ``vmap_info`` avoids double-JIT: the vmapped kernel is compiled
20
+ once per support size *k* as a standalone pure function.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from functools import partial
27
+ from typing import Tuple
28
+
29
+ import jax
30
+ import jax.numpy as jnp
31
+ import jax.scipy.linalg as jsp_la
32
+ import numpy as np
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ Array = jnp.ndarray
37
+
38
+
39
+ # =====================================================================
40
+ # Pure-function kernels (no class reference ⇒ clean JIT, clean vmap)
41
+ # =====================================================================
42
+
43
+
44
+ def _solve_sym(A_norm: Array, b_norm: Array) -> Array:
45
+ """Cholesky-based solve for a symmetric PSD system (normalised)."""
46
+ return jsp_la.solve(A_norm, b_norm, assume_a="pos")
47
+
48
+
49
+ def _solve_gen(A_norm: Array, b_norm: Array) -> Array:
50
+ """General LU solve for a non-symmetric system (normalised)."""
51
+ return jsp_la.solve(A_norm, b_norm, assume_a="gen")
52
+
53
+
54
+ def _solve_lstsq(A_norm: Array, b_norm: Array) -> Array:
55
+ """Least-squares fallback (always JIT-safe)."""
56
+ x, _, _, _ = jnp.linalg.lstsq(A_norm, b_norm, rcond=None)
57
+ return x
58
+
59
+
60
+ def _info_kernel_sym(M: Array, G: Array, B: Array, tol: float) -> Tuple[Array, Array]:
61
+ """Score one support — symmetric-PSD fast path."""
62
+ B_idx = jnp.asarray(B, dtype=jnp.int32)
63
+ M_B = M[B_idx]
64
+ G_BB = G[jnp.ix_(B_idx, B_idx)]
65
+
66
+ # Diagonal preconditioning
67
+ diag_clipped = jnp.clip(jnp.diag(G_BB), min=tol)
68
+ d = jnp.sqrt(diag_clipped)
69
+ D_inv = 1.0 / d
70
+ A_norm = (G_BB * D_inv[:, None]) * D_inv[None, :]
71
+ b_norm = M_B / d
72
+
73
+ # Cholesky-based solve; lstsq fallback via lax.cond on det
74
+ # We use a simple heuristic: if reciprocal condition number is
75
+ # reasonable, use Cholesky; otherwise lstsq.
76
+ # For JIT compatibility we always compute both paths and select.
77
+ x_chol = _solve_sym(A_norm, b_norm)
78
+ # If Cholesky produced NaN/Inf (silent failure on non-PSD input),
79
+ # fall back to least-squares.
80
+ chol_ok = jnp.all(jnp.isfinite(x_chol))
81
+ x_norm = jax.lax.cond(
82
+ chol_ok,
83
+ lambda _: x_chol,
84
+ lambda _: _solve_lstsq(A_norm, b_norm),
85
+ None,
86
+ )
87
+
88
+ C_B = x_norm / d
89
+ Q_B = C_B @ M_B
90
+ info = 0.5 * Q_B
91
+ return info, C_B
92
+
93
+
94
+ def _info_kernel_gen(M: Array, G: Array, B: Array, tol: float) -> Tuple[Array, Array]:
95
+ """Score one support — general (non-symmetric) G."""
96
+ B_idx = jnp.asarray(B, dtype=jnp.int32)
97
+ M_B = M[B_idx]
98
+ G_BB = G[jnp.ix_(B_idx, B_idx)]
99
+
100
+ diag_clipped = jnp.clip(jnp.diag(G_BB), min=tol)
101
+ d = jnp.sqrt(diag_clipped)
102
+ D_inv = 1.0 / d
103
+ A_norm = (G_BB * D_inv[:, None]) * D_inv[None, :]
104
+ b_norm = M_B / d
105
+
106
+ x_gen = _solve_gen(A_norm, b_norm)
107
+ gen_ok = jnp.all(jnp.isfinite(x_gen))
108
+ x_norm = jax.lax.cond(
109
+ gen_ok,
110
+ lambda _: x_gen,
111
+ lambda _: _solve_lstsq(A_norm, b_norm),
112
+ None,
113
+ )
114
+
115
+ C_B = x_norm / d
116
+ Q_B = C_B @ M_B
117
+ info = 0.5 * Q_B
118
+ return info, C_B
119
+
120
+
121
+ def _info_kernel_residual_sym(M: Array, G: Array, B: Array, tol: float, norm_X2: float, n: int) -> Tuple[Array, Array]:
122
+ """Symmetric-PSD path with residual-based information gain."""
123
+ info_raw, C_B = _info_kernel_sym(M, G, B, tol)
124
+ Q_B = 2.0 * info_raw # undo the 0.5 factor
125
+ RSS_B = norm_X2 - Q_B
126
+ info = 0.5 * n * jnp.log(norm_X2 / RSS_B)
127
+ return info, C_B
128
+
129
+
130
+ def _info_kernel_residual_gen(M: Array, G: Array, B: Array, tol: float, norm_X2: float, n: int) -> Tuple[Array, Array]:
131
+ """General path with residual-based information gain."""
132
+ info_raw, C_B = _info_kernel_gen(M, G, B, tol)
133
+ Q_B = 2.0 * info_raw
134
+ RSS_B = norm_X2 - Q_B
135
+ info = 0.5 * n * jnp.log(norm_X2 / RSS_B)
136
+ return info, C_B
137
+
138
+
139
+ # =====================================================================
140
+ # Shared JIT / vmap caches (keyed by scorer config, NOT instance)
141
+ # =====================================================================
142
+ # By keeping M and G as call-time arguments instead of partial-bound,
143
+ # all SparseScorer instances with the same (is_sym, use_residuals, tol,
144
+ # norm_X2, n) share compiled XLA code. This avoids per-instance
145
+ # recompilation — a major speed-up when running many scorers with the
146
+ # same p (e.g. benchmarks, cross-validation, bootstrapping).
147
+
148
+ _single_jit_cache: dict[tuple, callable] = {}
149
+ _vmap_jit_cache: dict[tuple, callable] = {}
150
+
151
+
152
+ def _get_single_jit(is_sym: bool, use_residuals: bool, tol: float, norm_X2: float = 0.0, n: int = 1):
153
+ key = (is_sym, use_residuals, tol, norm_X2, n)
154
+ if key not in _single_jit_cache:
155
+ kernel = _pick_kernel(is_sym, use_residuals, tol, norm_X2, n)
156
+ _single_jit_cache[key] = jax.jit(kernel)
157
+ return _single_jit_cache[key]
158
+
159
+
160
+ def _get_vmap_jit(k: int, is_sym: bool, use_residuals: bool, tol: float, norm_X2: float = 0.0, n: int = 1):
161
+ key = (k, is_sym, use_residuals, tol, norm_X2, n)
162
+ if key not in _vmap_jit_cache:
163
+ kernel = _pick_kernel(is_sym, use_residuals, tol, norm_X2, n)
164
+ _vmap_jit_cache[key] = jax.jit(jax.vmap(kernel, in_axes=(None, None, 0), out_axes=(0, 0)))
165
+ return _vmap_jit_cache[key]
166
+
167
+
168
+ def _pick_kernel(is_sym, use_residuals, tol, norm_X2, n):
169
+ """Return a partial-bound kernel (M, G, B) -> (info, C_B)."""
170
+ if use_residuals:
171
+ if is_sym:
172
+ return partial(_info_kernel_residual_sym, tol=tol, norm_X2=norm_X2, n=n)
173
+ return partial(_info_kernel_residual_gen, tol=tol, norm_X2=norm_X2, n=n)
174
+ if is_sym:
175
+ return partial(_info_kernel_sym, tol=tol)
176
+ return partial(_info_kernel_gen, tol=tol)
177
+
178
+
179
+ # =====================================================================
180
+ # SparseScorer
181
+ # =====================================================================
182
+
183
+
184
+ class SparseScorer:
185
+ r"""Score candidate supports by solving the restricted normal equations.
186
+
187
+ Parameters
188
+ ----------
189
+ M : (p,) Array
190
+ Pre-computed moment vector (cross-moments between data and
191
+ basis functions).
192
+ G : (p, p) Array
193
+ Normal-equations matrix. May be non-symmetric (e.g. when using
194
+ Itô-shift moment estimators). Symmetry is detected automatically
195
+ and a Cholesky fast-path is used when possible.
196
+ norm_X2 : float, default 0.0
197
+ Sum of squared observations. Only used when
198
+ ``use_residuals=True``.
199
+ n : int, default 1
200
+ Sample count prefactor used in the residual-based information gain
201
+ :math:`\tfrac{1}{2} n\,\log(\lVert X\rVert^2 / \mathrm{RSS})`.
202
+ Only used when ``use_residuals=True``.
203
+ pinv_tol : float, default 1e-8
204
+ Tolerance for the diagonal preconditioning floor.
205
+ use_residuals : bool, default False
206
+ If *True*, the information gain is computed via the residual
207
+ sum-of-squares expression instead of the explicit quadratic form.
208
+ """
209
+
210
+ def __init__(
211
+ self,
212
+ *,
213
+ M: Array,
214
+ G: Array,
215
+ norm_X2: float = 0.0,
216
+ n: int = 1,
217
+ pinv_tol: float = 1e-8,
218
+ use_residuals: bool = False,
219
+ ):
220
+ self.M = jnp.asarray(M)
221
+ self.G = jnp.asarray(G)
222
+ self.p: int = int(self.M.shape[0])
223
+ self.pinv_tol = pinv_tol
224
+ self.norm_X2 = norm_X2
225
+ self.n = n
226
+ self.use_residuals = use_residuals
227
+
228
+ # --- detect symmetry of G (once, on host) --------------------
229
+ G_np = np.asarray(self.G)
230
+ self.G_is_symmetric: bool = bool(np.allclose(G_np, G_np.T, atol=1e-12 * (np.abs(G_np).max() + 1e-30)))
231
+ if self.G_is_symmetric:
232
+ logger.debug("SparseScorer: G is symmetric → Cholesky fast path.")
233
+ else:
234
+ logger.debug("SparseScorer: G is non-symmetric → general LU path.")
235
+
236
+ # --- select the right kernel ---------------------------------
237
+ # Use shared module-level JIT cache so all instances with the
238
+ # same config share compiled XLA code.
239
+ self._jit_kernel = _get_single_jit(
240
+ self.G_is_symmetric,
241
+ self.use_residuals,
242
+ self.pinv_tol,
243
+ self.norm_X2,
244
+ self.n,
245
+ )
246
+
247
+ # Pre-compute the full (dense) solution.
248
+ self.total_info, self.total_C = self.info_and_coeffs(jnp.arange(self.p))
249
+
250
+ # -----------------------------------------------------------------
251
+ # Score a single support B
252
+ # -----------------------------------------------------------------
253
+ def info_and_coeffs(self, B: Array) -> Tuple[Array, Array]:
254
+ r"""Solve :math:`G_{BB}\,C_B = M_B` and return the information gain.
255
+
256
+ Parameters
257
+ ----------
258
+ B : (k,) int Array
259
+ Indices of the active basis functions (the *support*).
260
+
261
+ Returns
262
+ -------
263
+ info : scalar Array
264
+ :math:`\tfrac{1}{2}\,C_B^\top M_B` (or the RSS variant when
265
+ ``use_residuals=True``).
266
+ C_B : (k,) Array
267
+ Maximum-likelihood coefficients for the restricted support.
268
+ """
269
+ if B.size == 0:
270
+ return jnp.array(0.0), jnp.zeros(0, dtype=self.M.dtype)
271
+ return self._jit_kernel(self.M, self.G, B)
272
+
273
+ # -----------------------------------------------------------------
274
+ # Batched evaluation
275
+ # -----------------------------------------------------------------
276
+
277
+ @staticmethod
278
+ def _pad_to_pow2(n: int) -> int:
279
+ """Round *n* up to next power of 2 (min 1)."""
280
+ if n <= 1:
281
+ return 1
282
+ return 1 << (n - 1).bit_length()
283
+
284
+ def vmap_info(self, batch: Array) -> Tuple[Array, Array]:
285
+ """Score a batch of supports of the *same* cardinality.
286
+
287
+ The batch is padded to the next power-of-2 length so that
288
+ JAX's compilation cache stays bounded (≤ ~12 unique shapes per
289
+ support size *k* instead of one per distinct batch count).
290
+
291
+ Parameters
292
+ ----------
293
+ batch : (n_supports, k) int Array
294
+ Each row is a sorted support of length *k*.
295
+
296
+ Returns
297
+ -------
298
+ infos : (n_supports,) Array
299
+ coeffs : (n_supports, k) Array
300
+ """
301
+ n_real = batch.shape[0]
302
+ k = int(batch.shape[1])
303
+
304
+ n_padded = self._pad_to_pow2(n_real)
305
+
306
+ if n_padded > n_real:
307
+ # Pad with copies of the first row (valid support — avoids
308
+ # pathological zeros). Results from padded rows are discarded.
309
+ pad_rows = jnp.broadcast_to(batch[0:1], (n_padded - n_real, k))
310
+ batch = jnp.concatenate([batch, pad_rows], axis=0)
311
+
312
+ fn = _get_vmap_jit(
313
+ k,
314
+ self.G_is_symmetric,
315
+ self.use_residuals,
316
+ self.pinv_tol,
317
+ self.norm_X2,
318
+ self.n,
319
+ )
320
+ infos, coeffs = fn(self.M, self.G, batch)
321
+
322
+ # Slice off padding
323
+ return infos[:n_real], coeffs[:n_real]