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