butchc 0.6.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.
butchc/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """
2
+ BUTChC: Bayesian Update Tree Chained Conditionally
3
+
4
+ A dependency-free, probabilistic black-box hyperparameter optimiser with
5
+ hierarchical/conditional search spaces, KDE-modelled continuous parameters,
6
+ log and integer scales, warm starting, optional batch/parallel evaluation and
7
+ convergence tracking.
8
+
9
+ ``butchc.interop`` converts to and from ConfigSpace and JSON. It is not
10
+ imported here, so ConfigSpace never becomes a dependency of the core package.
11
+ """
12
+
13
+ from ._prune import prune, prune_report
14
+ from ._tree import reservoir_summary
15
+ from ._utils import KDE_RESERVOIR_SIZE
16
+ from ._validate import SearchSpaceError
17
+ from .optimizer import BUTChC_optimize
18
+
19
+ __all__ = [
20
+ "BUTChC_optimize",
21
+ "KDE_RESERVOIR_SIZE",
22
+ "SearchSpaceError",
23
+ "prune",
24
+ "prune_report",
25
+ "reservoir_summary",
26
+ ]
27
+ __version__ = "0.6.0"
butchc/_parallel.py ADDED
@@ -0,0 +1,91 @@
1
+ """Evaluating a batch of configurations, optionally in parallel.
2
+
3
+ BUTChC never creates a process or thread pool. It accepts an ``executor`` you
4
+ supply and dispatches through it. That keeps the package dependency-free, keeps
5
+ process lifecycle out of a library that has no business owning it, and leaves
6
+ the picklability decision with the person who wrote the objective — which is
7
+ the only person who can make it.
8
+
9
+ Any object satisfying one of these works:
10
+
11
+ - ``.map(fn, iterable)`` returning results **in input order** —
12
+ ``concurrent.futures`` executors, ``multiprocessing.Pool``, ``joblib``
13
+ wrappers, ``dask.distributed.Client``, ``mpi4py.futures``.
14
+ - ``.submit(fn, arg)`` returning futures with ``.result()`` — used when there
15
+ is no ``map``.
16
+
17
+ Order preservation is load-bearing. Updates are applied in *sample* order so a
18
+ seeded run is reproducible regardless of which evaluation finishes first; an
19
+ executor that returned results as they completed would silently break that.
20
+ """
21
+
22
+ import os
23
+ from functools import partial
24
+
25
+
26
+ def resolve_batch(batch):
27
+ """Turn the user-facing ``batch`` argument into a concrete size.
28
+
29
+ ``-1`` means one configuration per available CPU. ``os.cpu_count()`` can
30
+ return ``None`` on exotic platforms, in which case the sequential default
31
+ is the safe reading.
32
+ """
33
+ if batch == -1:
34
+ return os.cpu_count() or 1
35
+ return batch
36
+
37
+
38
+ def evaluate_batch(objective, configs, executor, kwargs):
39
+ """Evaluate ``configs``, returning results in the same order.
40
+
41
+ A single configuration, or no executor, is evaluated inline — there is
42
+ nothing to gain from dispatching one call, and a batch without an executor
43
+ is a deliberate mode (see ``batch`` in the API docs) rather than an error.
44
+ """
45
+ if executor is None or len(configs) == 1:
46
+ return [objective(config, **kwargs) for config in configs]
47
+
48
+ call = partial(objective, **kwargs) if kwargs else objective
49
+
50
+ try:
51
+ if hasattr(executor, "map"):
52
+ return list(executor.map(call, configs))
53
+ if hasattr(executor, "submit"):
54
+ futures = [executor.submit(call, config) for config in configs]
55
+ return [f.result() for f in futures]
56
+ except Exception as exc:
57
+ raise _explain(exc) from exc
58
+
59
+ raise TypeError(
60
+ f"executor must provide 'map' or 'submit'; "
61
+ f"{type(executor).__name__} provides neither"
62
+ )
63
+
64
+
65
+ def _explain(exc):
66
+ """Attach the diagnosis that a raw pickling error does not carry.
67
+
68
+ Process pools transmit the objective by pickle, and the objectives people
69
+ write are very often closures over training data or functions defined in
70
+ ``__main__`` at an interactive prompt. Neither pickles. The failure surfaces
71
+ far from its cause, so name the cause.
72
+ """
73
+ text = str(exc).lower()
74
+ picklish = (
75
+ isinstance(exc, (AttributeError, TypeError))
76
+ and ("pickle" in text or "local object" in text or "lambda" in text)
77
+ ) or type(exc).__name__ in ("PicklingError", "MaybeEncodingError")
78
+
79
+ if not picklish:
80
+ return exc
81
+
82
+ return RuntimeError(
83
+ "the objective could not be sent to the executor. A process pool "
84
+ "transmits the objective by pickle, so lambdas, closures and functions "
85
+ "defined interactively cannot be used with one. Either define the "
86
+ "objective at module level and pass its data via **kwargs, or use a "
87
+ "ThreadPoolExecutor — threads share memory and pickle nothing, and an "
88
+ "objective that spends its time in NumPy, scikit-learn, PyTorch or a "
89
+ "subprocess releases the GIL and parallelises fine on threads. "
90
+ f"Original error: {exc}"
91
+ )
butchc/_prune.py ADDED
@@ -0,0 +1,381 @@
1
+ """Reduce a search space using what a finished run learned about it.
2
+
3
+ The use case is a handoff. BUTChC's clearest margins on conditional problems
4
+ come from deciding *which branch* — `Branch trap` and `Categorical mix` — while
5
+ on `Nested pipeline`, where a branch must be tuned well once chosen, it only
6
+ ties TPE. Spending a fraction of the budget on BUTChC to throw away the
7
+ branches that do not matter, then handing the survivors to a second run or to
8
+ another optimiser, plays to that split.
9
+
10
+ The output is an ordinary search space: the same dicts BUTChC accepts, so it
11
+ can be fed back into `BUTChC_optimize`, or through `butchc.interop` into
12
+ ConfigSpace, Optuna, SMAC or anything else. Pass `return_tree=True` to also get
13
+ a probability tree consistent with that smaller space, for warm-starting a
14
+ follow-up BUTChC run — see `return_tree` below for why the tree from the
15
+ original run cannot be reused directly.
16
+
17
+ What this is not
18
+ ----------------
19
+
20
+ It is not a confidence statement. A branch's probability says where the model
21
+ would spend the next trial, which is not the same as a claim that the branch
22
+ contains no good configurations. `Branch trap` exists in the benchmark suite
23
+ precisely because a branch can have a poor average and an excellent best, and
24
+ that is the shape most likely to be destroyed by careless pruning. Three
25
+ defaults exist to make that harder:
26
+
27
+ - `protect_best` keeps whichever branch produced the best configuration seen,
28
+ whatever its probability. Deleting the branch holding your best result is
29
+ the one unrecoverable mistake here, so it is on by default.
30
+ - `keep_min` keeps at least two choices per node, so a node cannot collapse to
31
+ a single option on the evidence of a short run.
32
+ - `threshold` is a fraction of uniform, so a choice must be well behind the
33
+ even split before it is removed rather than merely behind.
34
+
35
+ Prune early in a run and none of that saves you: probabilities move a great
36
+ deal in the first trials, and the commitment exponent has barely ramped. Give
37
+ BUTChC enough budget for the tree to mean something — as a rule of thumb the
38
+ same budget you would consider too small to trust the answer from.
39
+ """
40
+
41
+ import math
42
+
43
+ from ._tree import initialize_prob_tree, reservoir_summary, to_internal, _subspace_size
44
+ from ._utils import GRID_SCORE
45
+ from ._validate import (
46
+ SearchSpaceError,
47
+ check_tree_matches_searchspace,
48
+ validate_searchspace,
49
+ )
50
+
51
+ #: A choice is a candidate for removal below this fraction of the uniform
52
+ #: probability for its node — 1/3 of uniform is 0.083 on a four-way choice and
53
+ #: 0.017 on a twenty-way one. Expressed relative to uniform rather than as a
54
+ #: bare probability because an absolute cut means different things at different
55
+ #: arities: 0.05 is near-abandonment on a three-way node and above average on a
56
+ #: thirty-way one. The value itself is a judgement call from a handful of
57
+ #: examples, not a tuned constant; `prune_report` exists so you can see what it
58
+ #: did before trusting it.
59
+ DEFAULT_THRESHOLD = 1.0 / 3.0
60
+
61
+ #: Choices kept per categorical node regardless of probability.
62
+ DEFAULT_KEEP_MIN = 2
63
+
64
+
65
+ def prune(searchspace, result, threshold=DEFAULT_THRESHOLD,
66
+ keep_min=DEFAULT_KEEP_MIN, protect_best=True, narrow=None,
67
+ return_tree=False):
68
+ """Return a smaller search space, keeping the branches worth searching.
69
+
70
+ Args:
71
+ searchspace: The space the run used, in BUTChC's format.
72
+ result: The dict returned by `BUTChC_optimize`. Its `prob_tree`
73
+ supplies the probabilities and `best_params` the branch
74
+ to protect.
75
+ threshold: Fraction of uniform probability below which a choice is
76
+ dropped. On a four-way node uniform is 0.25, so the
77
+ default of 1/3 drops anything at or below 0.083.
78
+ `0.0` drops nothing; `1.0` drops everything below
79
+ average.
80
+ keep_min: Minimum choices to keep per categorical node, taken in
81
+ probability order. Raise it to prune more timidly.
82
+ protect_best: Keep the choices taken by `result['best_params']` even
83
+ where probability says otherwise.
84
+ narrow: If set, also narrow continuous bounds to
85
+ `mean ± narrow * std` of the learned distribution,
86
+ clipped to the original bounds. `2.0` is a reasonable
87
+ starting point. Off by default, because unlike a dropped
88
+ branch a narrowed bound can exclude the optimum without
89
+ leaving any trace that it did.
90
+ return_tree: If True, also return a probability tree consistent with
91
+ the pruned space, suitable as `start_prob_tree` for a
92
+ follow-up run. `result['prob_tree']` itself is *not*
93
+ usable there: it still carries every dropped choice and
94
+ the original, wider continuous bounds, and
95
+ `BUTChC_optimize` requires an exact match between a
96
+ warm-start tree and the space it is given. The returned tree
97
+ keeps the learned statistics for every surviving choice
98
+ and archive point, so the follow-up run resumes rather
99
+ than restarting; dropped choices and narrowed-away
100
+ archive points are simply not there to resume from.
101
+
102
+ Returns:
103
+ The pruned search space. The input is not modified. If `return_tree`
104
+ is set, a `(searchspace, prob_tree)` tuple instead.
105
+
106
+ Raises:
107
+ SearchSpaceError: If `result` is not a `BUTChC_optimize` result, if
108
+ `keep_min < 1`, or if `narrow <= 0`.
109
+
110
+ Example:
111
+ >>> first = BUTChC_optimize(space, objective, 200, seed=0)
112
+ >>> smaller, tree = prune(space, first, return_tree=True)
113
+ >>> second = BUTChC_optimize(smaller, objective, 200, seed=1,
114
+ ... start_prob_tree=tree)
115
+ """
116
+ if not isinstance(result, dict) or "prob_tree" not in result:
117
+ raise SearchSpaceError(
118
+ "result must be the dict returned by BUTChC_optimize; "
119
+ "got something without a 'prob_tree' key"
120
+ )
121
+ if keep_min < 1:
122
+ raise SearchSpaceError(f"keep_min must be at least 1, got {keep_min}")
123
+ if narrow is not None and narrow <= 0:
124
+ raise SearchSpaceError(f"narrow must be positive, got {narrow}")
125
+
126
+ best = result.get("best_params") or {} if protect_best else {}
127
+ pruned, pruned_tree = _prune_level(searchspace, result["prob_tree"], best,
128
+ threshold, keep_min, narrow)
129
+ validate_searchspace(pruned)
130
+ if not return_tree:
131
+ return pruned
132
+
133
+ # A self-check, not a formality: if the tree-construction logic above ever
134
+ # drifts out of sync with the space-construction logic, this turns that
135
+ # into an immediate, loud error here rather than a confusing one deep
136
+ # inside a follow-up BUTChC_optimize call that a caller would have no
137
+ # reason to connect back to prune().
138
+ check_tree_matches_searchspace(pruned_tree, pruned)
139
+ return pruned, pruned_tree
140
+
141
+
142
+ def _prune_level(space, tree, best, threshold, keep_min, narrow):
143
+ out_space, out_tree = {}, {}
144
+ for key, spec in space.items():
145
+ node = tree.get(key)
146
+ if node is None:
147
+ # A parameter the tree never saw: keep it untouched rather than
148
+ # silently dropping something the objective may require. There is
149
+ # no learned data to carry forward either, so the matching tree
150
+ # node is simply a fresh one for this parameter alone.
151
+ out_space[key] = _copy_spec(spec)
152
+ out_tree[key] = initialize_prob_tree({key: spec})[key]
153
+ continue
154
+
155
+ if "values" in spec:
156
+ out_space[key], out_tree[key] = _prune_categorical(
157
+ key, spec, node, best, threshold, keep_min, narrow)
158
+ else:
159
+ out_space[key], out_tree[key] = _narrow_continuous(spec, node, narrow)
160
+ return out_space, out_tree
161
+
162
+
163
+ def _prune_categorical(key, spec, node, best, threshold, keep_min, narrow):
164
+ prob = node.get("prob", {})
165
+ values = [v for v in spec["values"] if v in prob] or list(spec["values"])
166
+ ranked = sorted(values, key=lambda v: prob.get(v, 0.0), reverse=True)
167
+
168
+ uniform = 1.0 / len(spec["values"]) if spec["values"] else 0.0
169
+ cut = threshold * uniform
170
+ keep = {v for v in ranked if prob.get(v, 0.0) > cut}
171
+ keep |= set(ranked[:max(keep_min, 1)])
172
+ if best.get(key) in spec["values"]:
173
+ keep.add(best[key])
174
+
175
+ kept = [v for v in spec["values"] if v in keep]
176
+ if not kept: # pragma: no cover - keep_min >= 1 makes this unreachable
177
+ raise SearchSpaceError(f"{key}: pruning removed every choice")
178
+
179
+ out_spec = _copy_spec(spec)
180
+ out_spec["values"] = kept
181
+
182
+ # A prior over dropped choices no longer sums to anything meaningful, and
183
+ # renormalising it would invent a belief the run did not express. Warm
184
+ # starting instead carries the learned distribution across explicitly —
185
+ # see `prune`'s `return_tree`.
186
+ out_spec.pop("prior", None)
187
+ out_spec.pop("prior_strength", None)
188
+
189
+ sub_spaces = spec.get("next_level")
190
+ out_next_level = {}
191
+ if sub_spaces:
192
+ sub_trees = node.get("next_level", {})
193
+ next_level = {}
194
+ for choice in kept:
195
+ if choice not in sub_spaces:
196
+ continue
197
+ sub_space, sub_tree = _prune_level(
198
+ sub_spaces[choice], sub_trees.get(choice, {}),
199
+ best, threshold, keep_min, narrow)
200
+ next_level[choice] = sub_space
201
+ out_next_level[choice] = sub_tree
202
+ if next_level:
203
+ out_spec["next_level"] = next_level
204
+
205
+ out_node = _pruned_categorical_node(node, kept, out_spec.get("next_level"),
206
+ out_next_level)
207
+ return out_spec, out_node
208
+
209
+
210
+ def _pruned_categorical_node(node, kept, pruned_next_level, next_level_trees):
211
+ """Build a categorical tree node restricted to `kept`, renormalised.
212
+
213
+ `counts` and `visits` are the learned evidence for the choices that
214
+ survive, carried across unchanged — that evidence is the entire point of
215
+ warm starting rather than restarting. `prob` is renormalised over the
216
+ survivors so it still sums to 1; nothing here re-derives it from `counts`
217
+ via `_recompute_prob`, because the surviving choices' relative standing is
218
+ exactly what pruning decided to trust.
219
+ """
220
+ counts = {k: node.get("counts", {}).get(k, 0.0) for k in kept}
221
+ visits = {k: node.get("visits", {}).get(k, 0.0) for k in kept}
222
+ raw_prob = {k: node.get("prob", {}).get(k, 0.0) for k in kept}
223
+ total = sum(raw_prob.values())
224
+ prob = ({k: v / total for k, v in raw_prob.items()} if total > 0
225
+ else {k: 1.0 / len(kept) for k in kept})
226
+
227
+ out_node = {
228
+ "counts": counts,
229
+ "visits": visits,
230
+ "prob": prob,
231
+ # Recomputed from the *pruned* next_level, not copied from the
232
+ # original node: sub_size governs how slowly this node commits, in
233
+ # proportion to how much sub-space its choices still open, and a
234
+ # dropped branch can change that. Carrying the old, larger value
235
+ # across would commit slower than the pruned space actually needs to.
236
+ "sub_size": (max((_subspace_size(sub) for sub in pruned_next_level.values()),
237
+ default=0) if pruned_next_level else 0),
238
+ # Kept apart from counts/visits for the same reason a fresh tree keeps
239
+ # them apart (see _tree.py): strength 0 makes _apply_prior a no-op, so
240
+ # the node is scored on carried-over evidence alone, not on a prior
241
+ # the pruned space no longer states.
242
+ "prior": dict(prob),
243
+ "prior_strength": 0.0,
244
+ }
245
+ if next_level_trees:
246
+ out_node["next_level"] = next_level_trees
247
+ return out_node
248
+
249
+
250
+ def _narrow_continuous(spec, node, narrow):
251
+ out = _copy_spec(spec)
252
+ if narrow is None or "reservoir" not in node or not node["reservoir"]:
253
+ return out, _copied_continuous_node(node)
254
+
255
+ summary = reservoir_summary(node)
256
+ lo = max(spec["min"], summary["mean"] - narrow * summary["std"])
257
+ hi = min(spec["max"], summary["mean"] + narrow * summary["std"])
258
+
259
+ if spec.get("int"):
260
+ # Widen to whole numbers so the range still holds integers, then clamp
261
+ # again: rounding outwards can otherwise push a bound past the original
262
+ # one, which would hand the next optimiser a space the user never
263
+ # authorised. A range too tight to contain two integers is left alone.
264
+ lo = max(math.floor(lo), math.ceil(spec["min"]))
265
+ hi = min(math.ceil(hi), math.floor(spec["max"]))
266
+ if hi - lo < 1:
267
+ return out, _copied_continuous_node(node)
268
+ if spec.get("log"):
269
+ lo = max(lo, 1e-300)
270
+
271
+ # A collapsed or inverted range means the archive concentrated below the
272
+ # resolution of the bounds. Keeping the original is the safe reading:
273
+ # there is nothing left to narrow towards.
274
+ if not lo < hi:
275
+ return out, _copied_continuous_node(node)
276
+
277
+ out["min"], out["max"] = lo, hi
278
+ return out, _narrowed_continuous_node(node, lo, hi)
279
+
280
+
281
+ def _copied_continuous_node(node):
282
+ """Deep-copy a continuous tree node's mutable fields.
283
+
284
+ Used wherever `_narrow_continuous` leaves the space untouched (`narrow`
285
+ unset, or the computed range collapsed): the original node already
286
+ matches an untouched space exactly, so nothing about it needs to change.
287
+ """
288
+ out = dict(node)
289
+ out["reservoir"] = list(node["reservoir"])
290
+ out["scores"] = list(node["scores"])
291
+ out["weights"] = list(node["weights"])
292
+ return out
293
+
294
+
295
+ def _narrowed_continuous_node(node, lo_ext, hi_ext):
296
+ """Build a continuous tree node matching a narrowed `[lo_ext, hi_ext]`.
297
+
298
+ Archive points outside the narrowed range are dropped and the remaining
299
+ weights renormalised — conditioning the learned distribution on the
300
+ region that survived is what warm starting into a narrower space should
301
+ mean, rather than inventing a new bandwidth for the discarded mass.
302
+ """
303
+ out_node = dict(node)
304
+ out_node["ext_min"], out_node["ext_max"] = lo_ext, hi_ext
305
+
306
+ lo_int, hi_int = to_internal(node, lo_ext), to_internal(node, hi_ext)
307
+ if node.get("int"):
308
+ # Mirrors the half-unit widening initialize_prob_tree applies: an
309
+ # internal range of exactly [lo, hi] gives the endpoint integers half
310
+ # the mass of an interior one under rounding.
311
+ lo_int -= 0.5
312
+ hi_int += 0.5
313
+ out_node["min"], out_node["max"] = lo_int, hi_int
314
+
315
+ survivors = [(r, s, w) for r, s, w in
316
+ zip(node["reservoir"], node["scores"], node["weights"])
317
+ if lo_int <= r <= hi_int]
318
+ if not survivors:
319
+ # A heavy-tailed or multimodal archive can leave nothing inside even
320
+ # a moderate narrowing. Reseeding one point at the centre keeps the
321
+ # node valid rather than handing back an empty, unusable reservoir;
322
+ # the next run's own trials repopulate it from there.
323
+ centre = (lo_int + hi_int) / 2.0
324
+ survivors = [(centre, GRID_SCORE, 1.0)]
325
+
326
+ reservoir, scores, weights = (list(v) for v in zip(*survivors))
327
+ total_w = sum(weights)
328
+ out_node["reservoir"] = reservoir
329
+ out_node["scores"] = scores
330
+ out_node["weights"] = ([w / total_w for w in weights] if total_w > 0
331
+ else [1.0 / len(weights)] * len(weights))
332
+ return out_node
333
+
334
+
335
+ def _copy_spec(spec):
336
+ """Deep-copy a spec node without importing copy for the nested case."""
337
+ out = {}
338
+ for key, value in spec.items():
339
+ if key == "next_level":
340
+ out[key] = {c: {k: _copy_spec(v) for k, v in sub.items()}
341
+ for c, sub in value.items()}
342
+ elif isinstance(value, dict):
343
+ out[key] = dict(value)
344
+ elif isinstance(value, list):
345
+ out[key] = list(value)
346
+ else:
347
+ out[key] = value
348
+ return out
349
+
350
+
351
+ def prune_report(searchspace, pruned):
352
+ """Describe what `prune` removed, for printing before you trust it.
353
+
354
+ Returns:
355
+ A list of human-readable lines. Empty when nothing changed.
356
+ """
357
+ lines = []
358
+ _report_level(searchspace, pruned, "", lines)
359
+ return lines
360
+
361
+
362
+ def _report_level(before, after, prefix, lines):
363
+ for key, spec in before.items():
364
+ name = prefix + key
365
+ new = after.get(key)
366
+ if new is None:
367
+ lines.append(f"{name}: removed")
368
+ continue
369
+ if "values" in spec:
370
+ dropped = [v for v in spec["values"] if v not in new["values"]]
371
+ if dropped:
372
+ lines.append(f"{name}: dropped {dropped}, kept {new['values']}")
373
+ for choice, sub in spec.get("next_level", {}).items():
374
+ if choice in new.get("next_level", {}):
375
+ _report_level(sub, new["next_level"][choice],
376
+ f"{name}={choice}:", lines)
377
+ elif (spec.get("min"), spec.get("max")) != (new.get("min"), new.get("max")):
378
+ lines.append(
379
+ f"{name}: narrowed [{spec['min']:.6g}, {spec['max']:.6g}] "
380
+ f"-> [{new['min']:.6g}, {new['max']:.6g}]"
381
+ )
butchc/_sampling.py ADDED
@@ -0,0 +1,108 @@
1
+ """Drawing configurations from the probability tree.
2
+
3
+ Two properties of the sampler are load-bearing:
4
+
5
+ 1. **Reflection, not clipping.** Gaussian jitter that is clipped to
6
+ ``[min, max]`` deposits an atom of probability on each bound and biases
7
+ the search toward the edges of every interval. Reflecting the excess back
8
+ inside preserves the density shape.
9
+
10
+ 2. **A structural trace.** Sampling returns both the flat config the user
11
+ sees and a trace mirroring the tree's shape. Updates walk the trace rather
12
+ than matching parameter names against the flat config, so a name appearing
13
+ in two branches can never update the wrong node.
14
+ """
15
+
16
+ import math
17
+
18
+ from ._tree import snap_internal, to_external
19
+ from ._utils import silverman_bandwidth, softmax, reflect
20
+
21
+
22
+ def sample_node(node, temp, rng, explore=0.0):
23
+ """
24
+ Draw a single value from a probability-tree node.
25
+
26
+ With probability ``explore`` the draw ignores the learned distribution and
27
+ samples uniformly from the node's support.
28
+
29
+ The floor is *per node*, not per configuration: a config in which every one
30
+ of ``d`` nodes explored has probability ``explore ** d``, which at the
31
+ default and ``d = 10`` is 1e-10. So this bounds how far any single
32
+ parameter's marginal can collapse; it does not make the optimiser a
33
+ superset of random search in high dimensions.
34
+
35
+ Args:
36
+ node: A node dict from the probability tree.
37
+ temp: Softmax temperature for categorical nodes (> 0).
38
+ rng: A ``random.Random`` instance.
39
+ explore: Probability of a uniform draw instead of a modelled one.
40
+
41
+ Returns:
42
+ ``(external_value, internal_value)``. For categorical nodes the two
43
+ are identical; for continuous nodes the internal value is what the
44
+ update rule records.
45
+
46
+ Raises:
47
+ ValueError: If the node has neither 'prob' nor 'reservoir'.
48
+ """
49
+ exploring = explore > 0.0 and rng.random() < explore
50
+
51
+ if "prob" in node:
52
+ keys = list(node["prob"])
53
+ if exploring:
54
+ choice = rng.choice(keys)
55
+ else:
56
+ log_probs = [math.log(max(node["prob"][k], 1e-12)) for k in keys]
57
+ choice = rng.choices(keys, weights=softmax(log_probs, temp), k=1)[0]
58
+ return choice, choice
59
+
60
+ if "reservoir" in node:
61
+ lo, hi = node["min"], node["max"]
62
+ if exploring:
63
+ x = rng.uniform(lo, hi)
64
+ else:
65
+ centre = rng.choices(node["reservoir"], weights=node["weights"], k=1)[0]
66
+ h = silverman_bandwidth(node["reservoir"], node["weights"], hi - lo)
67
+ x = reflect(centre + rng.gauss(0.0, h), lo, hi)
68
+ x = snap_internal(node, x)
69
+ x = min(max(x, lo), hi)
70
+ return to_external(node, x), x
71
+
72
+ raise ValueError(
73
+ f"Malformed node — expected 'prob' (categorical) or 'reservoir' "
74
+ f"(continuous). Got keys: {sorted(map(str, node))}"
75
+ )
76
+
77
+
78
+ def traverse_sample(tree, temp, rng, explore=0.0):
79
+ """
80
+ Recursively sample a full configuration from the probability tree.
81
+
82
+ A categorical node carrying a ``next_level`` opens exactly one sub-tree —
83
+ the one matching its sampled choice — so only parameters relevant to that
84
+ branch appear in the config.
85
+
86
+ Returns:
87
+ ``(config, trace)``. ``config`` is the flat ``{name: value}`` dict
88
+ passed to the objective. ``trace`` mirrors the tree structure as
89
+ ``{name: {'internal': v, 'sub': trace_or_None}}`` and drives updates.
90
+ """
91
+ config = {}
92
+ trace = {}
93
+
94
+ for key, node in tree.items():
95
+ external, internal = sample_node(node, temp, rng, explore)
96
+ config[key] = external
97
+ entry = {"internal": internal, "sub": None}
98
+
99
+ if "next_level" in node and external in node["next_level"]:
100
+ sub_config, sub_trace = traverse_sample(
101
+ node["next_level"][external], temp, rng, explore
102
+ )
103
+ config.update(sub_config)
104
+ entry["sub"] = sub_trace
105
+
106
+ trace[key] = entry
107
+
108
+ return config, trace