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
|
@@ -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]
|