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 +27 -0
- butchc/_parallel.py +91 -0
- butchc/_prune.py +381 -0
- butchc/_sampling.py +108 -0
- butchc/_tree.py +319 -0
- butchc/_update.py +288 -0
- butchc/_utils.py +207 -0
- butchc/_validate.py +440 -0
- butchc/interop/__init__.py +39 -0
- butchc/interop/_configspace.py +413 -0
- butchc/interop/_json.py +57 -0
- butchc/optimizer.py +386 -0
- butchc-0.6.0.dist-info/METADATA +424 -0
- butchc-0.6.0.dist-info/RECORD +17 -0
- butchc-0.6.0.dist-info/WHEEL +5 -0
- butchc-0.6.0.dist-info/licenses/LICENSE +21 -0
- butchc-0.6.0.dist-info/top_level.txt +1 -0
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
|