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,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SFI.inference.sparse.base — Strategy protocol
|
|
3
|
+
==============================================
|
|
4
|
+
|
|
5
|
+
Every sparse-selection algorithm subclasses :class:`SparsityStrategy`
|
|
6
|
+
and implements :meth:`run`, which receives a :class:`SparseScorer` and
|
|
7
|
+
returns a :class:`SparsityResult`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
|
|
14
|
+
from .result import SparsityResult
|
|
15
|
+
from .scorer import SparseScorer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SparsityStrategy(ABC):
|
|
19
|
+
"""Abstract base class for sparsity search strategies.
|
|
20
|
+
|
|
21
|
+
Subclasses must implement :meth:`run`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
#: Short identifier used in :attr:`SparsityResult.method`.
|
|
25
|
+
name: str = "base"
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def run(self, scorer: SparseScorer, *, max_k: int, **kwargs) -> SparsityResult:
|
|
29
|
+
"""Execute the search and return a :class:`SparsityResult`.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
scorer : SparseScorer
|
|
34
|
+
Provides ``info_and_coeffs`` / ``vmap_info`` for evaluating
|
|
35
|
+
candidate supports.
|
|
36
|
+
max_k : int
|
|
37
|
+
Maximum model size to explore.
|
|
38
|
+
|
|
39
|
+
Returns
|
|
40
|
+
-------
|
|
41
|
+
SparsityResult
|
|
42
|
+
"""
|
|
43
|
+
...
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SFI.inference.sparse.beam — Bidirectional beam search
|
|
3
|
+
=====================================================
|
|
4
|
+
|
|
5
|
+
The :class:`BeamSearchStrategy` explores the support lattice by
|
|
6
|
+
expanding every ±1 neighbour of every support in the current frontier
|
|
7
|
+
and retaining only the ``beam_width`` best models per cardinality *k*.
|
|
8
|
+
|
|
9
|
+
This is the original algorithm described in the PASTIS paper.
|
|
10
|
+
|
|
11
|
+
Performance notes
|
|
12
|
+
~~~~~~~~~~~~~~~~~
|
|
13
|
+
* Child generation uses pure Python integer lists — no per-child JAX
|
|
14
|
+
array allocations.
|
|
15
|
+
* Each child is scored individually via ``info_and_coeffs`` (the
|
|
16
|
+
single-support JIT kernel) rather than batched through
|
|
17
|
+
``scorer.vmap_info``. On CPU this is the faster path here because
|
|
18
|
+
beam children come in many distinct cardinalities, and the vmap
|
|
19
|
+
cache would otherwise compile O(max_k × unique_batch_sizes) shapes;
|
|
20
|
+
per-child scoring caps the cache at O(max_k) entries.
|
|
21
|
+
* Frontier capping uses ``heapq.nlargest`` instead of full sort.
|
|
22
|
+
* Skyline update only touches cardinalities modified in the current
|
|
23
|
+
generation.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import collections
|
|
29
|
+
import heapq
|
|
30
|
+
import itertools
|
|
31
|
+
import logging
|
|
32
|
+
import time
|
|
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
|
+
class BeamSearchStrategy(SparsityStrategy):
|
|
45
|
+
"""Bidirectional beam search over the support lattice.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
beam_width : int, default 20
|
|
50
|
+
Maximum number of candidate models retained per cardinality.
|
|
51
|
+
aic_patience : int, default 2
|
|
52
|
+
Stop early when AIC has strictly declined for this many
|
|
53
|
+
consecutive *closed* cardinality levels.
|
|
54
|
+
report_time : bool, default False
|
|
55
|
+
If *True*, log elapsed time and number of explored supports.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
name = "beam"
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
beam_width: int = 20,
|
|
64
|
+
aic_patience: int = 2,
|
|
65
|
+
report_time: bool = False,
|
|
66
|
+
):
|
|
67
|
+
self.beam_width = beam_width
|
|
68
|
+
self.aic_patience = aic_patience
|
|
69
|
+
self.report_time = report_time
|
|
70
|
+
|
|
71
|
+
# -----------------------------------------------------------------
|
|
72
|
+
def run(
|
|
73
|
+
self,
|
|
74
|
+
scorer: SparseScorer,
|
|
75
|
+
*,
|
|
76
|
+
max_k: int,
|
|
77
|
+
init_supports: list[tuple[int, ...]] | None = None,
|
|
78
|
+
**_kwargs,
|
|
79
|
+
) -> SparsityResult:
|
|
80
|
+
"""Execute the beam search.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
scorer : SparseScorer
|
|
85
|
+
max_k : int
|
|
86
|
+
Maximum model size to consider.
|
|
87
|
+
init_supports : list of tuples of int, optional
|
|
88
|
+
Seed supports to inject into the initial frontier. Each
|
|
89
|
+
entry is a tuple of basis-function indices. Useful for
|
|
90
|
+
seeding the search with a known good model (e.g. the true
|
|
91
|
+
support) so that the Pareto front is guaranteed to include
|
|
92
|
+
it.
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
SparsityResult
|
|
97
|
+
"""
|
|
98
|
+
t0 = time.perf_counter()
|
|
99
|
+
p = scorer.p
|
|
100
|
+
beam_width = self.beam_width
|
|
101
|
+
|
|
102
|
+
# ---- skyline arrays ------------------------------------------
|
|
103
|
+
best_info = [-np.inf] * (max_k + 1)
|
|
104
|
+
best_support = [[] for _ in range(max_k + 1)]
|
|
105
|
+
best_coeffs = [None] * (max_k + 1)
|
|
106
|
+
|
|
107
|
+
# ---- per-k heaps (min-heap keyed by info) --------------------
|
|
108
|
+
beam = [[] for _ in range(max_k + 1)]
|
|
109
|
+
uid = itertools.count()
|
|
110
|
+
|
|
111
|
+
def heap_item(info, state):
|
|
112
|
+
return (info, next(uid), state)
|
|
113
|
+
|
|
114
|
+
# ---- visited set (tuples of Python ints) ---------------------
|
|
115
|
+
visited: set[tuple[int, ...]] = set()
|
|
116
|
+
|
|
117
|
+
def push_into_beam(state: dict) -> bool:
|
|
118
|
+
k = len(state["B"])
|
|
119
|
+
h = beam[k]
|
|
120
|
+
if len(h) < beam_width:
|
|
121
|
+
heapq.heappush(h, heap_item(state["info"], state))
|
|
122
|
+
return True
|
|
123
|
+
if state["info"] > h[0][0]:
|
|
124
|
+
heapq.heappushpop(h, heap_item(state["info"], state))
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
# ---- initialise with empty support ---------------------------
|
|
129
|
+
null = dict(B=(), info=0.0, coeffs=None)
|
|
130
|
+
heapq.heappush(beam[0], heap_item(0.0, null))
|
|
131
|
+
visited.add(())
|
|
132
|
+
best_info[0] = 0.0
|
|
133
|
+
|
|
134
|
+
frontier = [collections.deque([null])] + [collections.deque() for _ in range(max_k)]
|
|
135
|
+
|
|
136
|
+
# ---- seed with user-supplied supports ------------------------
|
|
137
|
+
if init_supports is not None:
|
|
138
|
+
n_seeded = 0
|
|
139
|
+
for B_raw in init_supports:
|
|
140
|
+
B = tuple(sorted(int(i) for i in B_raw))
|
|
141
|
+
k = len(B)
|
|
142
|
+
if k == 0 or k > max_k or B in visited:
|
|
143
|
+
continue
|
|
144
|
+
visited.add(B)
|
|
145
|
+
B_arr = jnp.array(B, dtype=jnp.int32)
|
|
146
|
+
info_val, coeffs_val = scorer.info_and_coeffs(B_arr)
|
|
147
|
+
info_f = float(info_val)
|
|
148
|
+
st = dict(B=B, info=info_f, coeffs=coeffs_val)
|
|
149
|
+
push_into_beam(st)
|
|
150
|
+
frontier[k].append(st)
|
|
151
|
+
if info_f > best_info[k]:
|
|
152
|
+
best_info[k] = info_f
|
|
153
|
+
best_support[k] = list(B)
|
|
154
|
+
best_coeffs[k] = coeffs_val
|
|
155
|
+
n_seeded += 1
|
|
156
|
+
logger.info("Seeded beam with %d user-supplied supports.", n_seeded)
|
|
157
|
+
|
|
158
|
+
best_aic_by_k = [-np.inf] * (max_k + 1)
|
|
159
|
+
all_indices = set(range(p))
|
|
160
|
+
|
|
161
|
+
# ---- main loop -----------------------------------------------
|
|
162
|
+
generation = 0
|
|
163
|
+
logger.info(
|
|
164
|
+
"Beam search: max_k=%d, beam_width=%d, p=%d.",
|
|
165
|
+
max_k,
|
|
166
|
+
beam_width,
|
|
167
|
+
p,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
while any(frontier[k] for k in range(max_k + 1)):
|
|
171
|
+
new_frontier = [collections.deque() for _ in range(max_k + 1)]
|
|
172
|
+
|
|
173
|
+
# Build child batches per target size j (pure Python ints)
|
|
174
|
+
child_batches: list[list[tuple[int, ...]]] = [[] for _ in range(max_k + 1)]
|
|
175
|
+
|
|
176
|
+
for k, parents in enumerate(frontier):
|
|
177
|
+
if not parents:
|
|
178
|
+
continue
|
|
179
|
+
for st in parents:
|
|
180
|
+
B_set = set(st["B"])
|
|
181
|
+
|
|
182
|
+
# --- ADD children (size k+1) -----------------------
|
|
183
|
+
if k < max_k and k < p:
|
|
184
|
+
remaining = all_indices - B_set
|
|
185
|
+
B_sorted = sorted(st["B"])
|
|
186
|
+
for j in remaining:
|
|
187
|
+
# sorted insert via bisect would be faster,
|
|
188
|
+
# but for small k (<100) sorted() is fine
|
|
189
|
+
child = tuple(sorted(B_sorted + [j]))
|
|
190
|
+
if child not in visited:
|
|
191
|
+
visited.add(child)
|
|
192
|
+
child_batches[k + 1].append(child)
|
|
193
|
+
|
|
194
|
+
# --- DROP children (size k-1) ----------------------
|
|
195
|
+
if k > 0:
|
|
196
|
+
B_list = sorted(st["B"])
|
|
197
|
+
for pos in range(k):
|
|
198
|
+
child = tuple(B_list[:pos] + B_list[pos + 1 :])
|
|
199
|
+
if child not in visited:
|
|
200
|
+
visited.add(child)
|
|
201
|
+
child_batches[k - 1].append(child)
|
|
202
|
+
|
|
203
|
+
# Track which cardinalities were touched
|
|
204
|
+
touched_ks: set[int] = set()
|
|
205
|
+
|
|
206
|
+
# Score each child individually via the single-support JIT
|
|
207
|
+
# kernel; see the module docstring for why this beats vmap
|
|
208
|
+
# for beam search.
|
|
209
|
+
for j in range(max_k + 1):
|
|
210
|
+
batch = child_batches[j]
|
|
211
|
+
if not batch:
|
|
212
|
+
continue
|
|
213
|
+
touched_ks.add(j)
|
|
214
|
+
for child in batch:
|
|
215
|
+
info_jax, coeff_jax = scorer.info_and_coeffs(
|
|
216
|
+
jnp.array(child, dtype=jnp.int32),
|
|
217
|
+
)
|
|
218
|
+
info_i = float(info_jax)
|
|
219
|
+
coeff_i = np.asarray(coeff_jax)
|
|
220
|
+
st = dict(B=child, info=info_i, coeffs=coeff_i)
|
|
221
|
+
inserted = push_into_beam(st)
|
|
222
|
+
if inserted:
|
|
223
|
+
new_frontier[j].append(st)
|
|
224
|
+
|
|
225
|
+
# Cap new frontier to beam_width per k (heapq.nlargest)
|
|
226
|
+
for j in range(max_k + 1):
|
|
227
|
+
if len(new_frontier[j]) > beam_width:
|
|
228
|
+
top = heapq.nlargest(beam_width, new_frontier[j], key=lambda s: s["info"])
|
|
229
|
+
new_frontier[j] = collections.deque(top)
|
|
230
|
+
|
|
231
|
+
# Update skyline — only touched cardinalities
|
|
232
|
+
for kk in touched_ks:
|
|
233
|
+
h = beam[kk]
|
|
234
|
+
if not h:
|
|
235
|
+
continue
|
|
236
|
+
best_info_val, _, best_state = max(h)
|
|
237
|
+
if best_info_val > best_info[kk]:
|
|
238
|
+
best_info[kk] = best_info_val
|
|
239
|
+
best_support[kk] = list(best_state["B"])
|
|
240
|
+
best_coeffs[kk] = best_state["coeffs"]
|
|
241
|
+
best_aic_by_k[kk] = max(best_aic_by_k[kk], best_info_val - kk)
|
|
242
|
+
|
|
243
|
+
# AIC early-stop
|
|
244
|
+
k_star = -1
|
|
245
|
+
for i in range(max_k + 1):
|
|
246
|
+
if new_frontier[i]:
|
|
247
|
+
break
|
|
248
|
+
k_star = i
|
|
249
|
+
|
|
250
|
+
if k_star >= self.aic_patience:
|
|
251
|
+
window = range(k_star - self.aic_patience + 1, k_star + 1)
|
|
252
|
+
aic_vals = [best_aic_by_k[i] for i in window]
|
|
253
|
+
strictly_down = all(aic_vals[i] < aic_vals[i - 1] - 1e-9 for i in range(1, len(aic_vals)))
|
|
254
|
+
if strictly_down:
|
|
255
|
+
logger.info("Early stop: AIC declined on closed window %s.", list(window))
|
|
256
|
+
break
|
|
257
|
+
|
|
258
|
+
if any(len(q) > 0 for q in new_frontier):
|
|
259
|
+
tot_front = sum(len(q) for q in new_frontier)
|
|
260
|
+
knonzero = [i for i, q in enumerate(new_frontier) if q]
|
|
261
|
+
kmin, kmax_val = knonzero[0], knonzero[-1]
|
|
262
|
+
logger.info(
|
|
263
|
+
"Generation %d — %d visited — max info=%.4f [frontier %d, k=%d–%d]",
|
|
264
|
+
generation,
|
|
265
|
+
len(visited),
|
|
266
|
+
max(best_info),
|
|
267
|
+
tot_front,
|
|
268
|
+
kmin,
|
|
269
|
+
kmax_val,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
frontier = new_frontier
|
|
273
|
+
generation += 1
|
|
274
|
+
|
|
275
|
+
# ---- second-best extraction ----------------------------------
|
|
276
|
+
second_info = [-np.inf] * (max_k + 1)
|
|
277
|
+
second_support = [[] for _ in range(max_k + 1)]
|
|
278
|
+
|
|
279
|
+
for k, h in enumerate(beam):
|
|
280
|
+
if len(h) >= 2:
|
|
281
|
+
best_two = heapq.nlargest(2, h, key=lambda t: t[0])
|
|
282
|
+
s_info, _, s_state = best_two[1]
|
|
283
|
+
second_info[k] = s_info
|
|
284
|
+
second_support[k] = list(s_state["B"])
|
|
285
|
+
|
|
286
|
+
if self.report_time:
|
|
287
|
+
dt = time.perf_counter() - t0
|
|
288
|
+
logger.info(
|
|
289
|
+
"Beam search done in %.2fs (%d supports explored).",
|
|
290
|
+
dt,
|
|
291
|
+
len(visited),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
return SparsityResult(
|
|
295
|
+
p=scorer.p,
|
|
296
|
+
total_info=float(scorer.total_info),
|
|
297
|
+
method=self.name,
|
|
298
|
+
best_info_by_k=best_info,
|
|
299
|
+
best_support_by_k=best_support,
|
|
300
|
+
best_coeffs_by_k=best_coeffs,
|
|
301
|
+
second_info_by_k=second_info,
|
|
302
|
+
second_support_by_k=second_support,
|
|
303
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SFI.inference.sparse.greedy — Stepwise greedy selection
|
|
3
|
+
=======================================================
|
|
4
|
+
|
|
5
|
+
Classic forward / backward / bidirectional stepwise search.
|
|
6
|
+
|
|
7
|
+
* **Forward**: at each step, add the feature that maximises the
|
|
8
|
+
information gain.
|
|
9
|
+
* **Backward**: start from the full model and drop the least useful
|
|
10
|
+
feature.
|
|
11
|
+
* **Bidirectional**: alternate one forward step then one backward step,
|
|
12
|
+
keeping whichever direction improves the score.
|
|
13
|
+
|
|
14
|
+
The algorithm naturally produces exactly one support per cardinality
|
|
15
|
+
(forward path) or a monotonic path from full to empty (backward),
|
|
16
|
+
yielding a clean Pareto front.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
import jax.numpy as jnp
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from .base import SparsityStrategy
|
|
28
|
+
from .result import SparsityResult
|
|
29
|
+
from .scorer import SparseScorer
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class GreedyStepwiseStrategy(SparsityStrategy):
|
|
35
|
+
"""Forward / backward / bidirectional stepwise selection.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
direction : ``"forward"`` | ``"backward"`` | ``"both"``, default ``"forward"``
|
|
40
|
+
Which direction(s) to search.
|
|
41
|
+
|
|
42
|
+
* ``"forward"``: start empty, add one feature at a time.
|
|
43
|
+
* ``"backward"``: start full, drop one feature at a time.
|
|
44
|
+
* ``"both"``: run both directions and merge the Pareto fronts.
|
|
45
|
+
report_time : bool, default False
|
|
46
|
+
Log elapsed wall-clock time when done.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
name = "greedy"
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
direction: str = "forward",
|
|
55
|
+
report_time: bool = False,
|
|
56
|
+
):
|
|
57
|
+
if direction not in ("forward", "backward", "both"):
|
|
58
|
+
raise ValueError(f"direction must be 'forward', 'backward', or 'both'; got {direction!r}")
|
|
59
|
+
self.direction = direction
|
|
60
|
+
self.report_time = report_time
|
|
61
|
+
|
|
62
|
+
# -----------------------------------------------------------------
|
|
63
|
+
def run(self, scorer: SparseScorer, *, max_k: int, **_kwargs) -> SparsityResult:
|
|
64
|
+
t0 = time.perf_counter()
|
|
65
|
+
p = scorer.p
|
|
66
|
+
max_k = min(max_k, p)
|
|
67
|
+
|
|
68
|
+
best_info = [-np.inf] * (max_k + 1)
|
|
69
|
+
best_support = [[] for _ in range(max_k + 1)]
|
|
70
|
+
best_coeffs = [None] * (max_k + 1)
|
|
71
|
+
|
|
72
|
+
# Null model
|
|
73
|
+
best_info[0] = 0.0
|
|
74
|
+
|
|
75
|
+
def _record(k, info, support, coeffs):
|
|
76
|
+
if info > best_info[k]:
|
|
77
|
+
best_info[k] = info
|
|
78
|
+
best_support[k] = list(map(int, support))
|
|
79
|
+
best_coeffs[k] = coeffs
|
|
80
|
+
|
|
81
|
+
# ----- Forward path -------------------------------------------
|
|
82
|
+
if self.direction in ("forward", "both"):
|
|
83
|
+
current = jnp.array([], dtype=jnp.int32)
|
|
84
|
+
remaining = set(range(p))
|
|
85
|
+
|
|
86
|
+
for step in range(1, max_k + 1):
|
|
87
|
+
# Try adding each remaining feature
|
|
88
|
+
candidates = sorted(remaining)
|
|
89
|
+
children = []
|
|
90
|
+
for j in candidates:
|
|
91
|
+
child = jnp.sort(jnp.concatenate([current, jnp.array([j], jnp.int32)]))
|
|
92
|
+
children.append(child)
|
|
93
|
+
|
|
94
|
+
batch = jnp.stack(children)
|
|
95
|
+
infos, coeffs = scorer.vmap_info(batch)
|
|
96
|
+
|
|
97
|
+
best_idx = int(jnp.argmax(infos))
|
|
98
|
+
best_j = candidates[best_idx]
|
|
99
|
+
current = jnp.sort(jnp.concatenate([current, jnp.array([best_j], jnp.int32)]))
|
|
100
|
+
remaining.remove(best_j)
|
|
101
|
+
|
|
102
|
+
_record(step, float(infos[best_idx]), current, coeffs[best_idx])
|
|
103
|
+
logger.debug(
|
|
104
|
+
"Forward step %d: added feature %d, info=%.4f",
|
|
105
|
+
step,
|
|
106
|
+
best_j,
|
|
107
|
+
float(infos[best_idx]),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# ----- Backward path ------------------------------------------
|
|
111
|
+
if self.direction in ("backward", "both"):
|
|
112
|
+
# Start from the full model; its info/coeffs are already cached
|
|
113
|
+
# on the scorer, so no extra solve is needed.
|
|
114
|
+
current = jnp.arange(p, dtype=jnp.int32)
|
|
115
|
+
info_cur, coeffs_cur = float(scorer.total_info), scorer.total_C
|
|
116
|
+
k = p
|
|
117
|
+
|
|
118
|
+
while k > 0:
|
|
119
|
+
if k <= max_k:
|
|
120
|
+
_record(k, info_cur, current, coeffs_cur)
|
|
121
|
+
if k == 1:
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
# Try dropping each feature and pick the drop with the
|
|
125
|
+
# largest information gain.
|
|
126
|
+
children = [jnp.delete(current, pos) for pos in range(k)]
|
|
127
|
+
batch = jnp.stack(children)
|
|
128
|
+
infos, coeffs = scorer.vmap_info(batch)
|
|
129
|
+
|
|
130
|
+
best_pos = int(jnp.argmax(infos))
|
|
131
|
+
current = children[best_pos]
|
|
132
|
+
info_cur = float(infos[best_pos])
|
|
133
|
+
coeffs_cur = coeffs[best_pos]
|
|
134
|
+
k -= 1
|
|
135
|
+
|
|
136
|
+
logger.debug("Backward step k=%d: info=%.4f", k, info_cur)
|
|
137
|
+
|
|
138
|
+
if self.report_time:
|
|
139
|
+
dt = time.perf_counter() - t0
|
|
140
|
+
logger.info("Greedy (%s) done in %.2fs.", self.direction, dt)
|
|
141
|
+
|
|
142
|
+
return SparsityResult(
|
|
143
|
+
p=scorer.p,
|
|
144
|
+
total_info=float(scorer.total_info),
|
|
145
|
+
method=f"greedy-{self.direction}",
|
|
146
|
+
best_info_by_k=best_info,
|
|
147
|
+
best_support_by_k=best_support,
|
|
148
|
+
best_coeffs_by_k=best_coeffs,
|
|
149
|
+
second_info_by_k=[-np.inf] * (max_k + 1),
|
|
150
|
+
second_support_by_k=[[] for _ in range(max_k + 1)],
|
|
151
|
+
)
|