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,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))
|