morphtree 2.1.0__py3-none-win_amd64.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.
- morphtree/__init__.py +28 -0
- morphtree/__main__.py +3 -0
- morphtree/_experimental/__init__.py +1 -0
- morphtree/_experimental/admission.py +364 -0
- morphtree/_experimental/base.py +215 -0
- morphtree/_experimental/component_cache_v2.py +263 -0
- morphtree/_experimental/evolution_report.py +307 -0
- morphtree/_experimental/execution_v2.py +697 -0
- morphtree/_experimental/facade.py +289 -0
- morphtree/_experimental/integrated.py +748 -0
- morphtree/_experimental/learning.py +418 -0
- morphtree/_experimental/manifest.json +204 -0
- morphtree/_experimental/numeric_fixture.py +21 -0
- morphtree/_experimental/parameter_contract.py +38 -0
- morphtree/_experimental/postfit.py +51 -0
- morphtree/_experimental/preprocessing.py +133 -0
- morphtree/_experimental/processes.py +191 -0
- morphtree/_experimental/producer.py +542 -0
- morphtree/_experimental/product.py +156 -0
- morphtree/_experimental/product_cli.py +43 -0
- morphtree/_experimental/roundtrip_v3.py +311 -0
- morphtree/_experimental/runtime_io.py +65 -0
- morphtree/_experimental/sampling.py +26 -0
- morphtree/_experimental/support.py +281 -0
- morphtree/_experimental/target.py +59 -0
- morphtree/_experimental/training.py +53 -0
- morphtree/_experimental/worker.py +682 -0
- morphtree/_installed.py +17 -0
- morphtree/_native/__init__.py +1 -0
- morphtree/_native/bin/win_amd64/morphtree_kernels.dll +0 -0
- morphtree/_native/buffers.py +82 -0
- morphtree/_native/loader.py +39 -0
- morphtree/_native/manifest.json +29 -0
- morphtree/_native/runtime.py +48 -0
- morphtree/_native/src/histogram.c +61 -0
- morphtree/_native/src/traversal.c +51 -0
- morphtree/api.py +388 -0
- morphtree/api_compat.py +65 -0
- morphtree/backend.py +162 -0
- morphtree/booster.py +252 -0
- morphtree/cli.py +52 -0
- morphtree/config.py +262 -0
- morphtree/control.py +20 -0
- morphtree/data.py +300 -0
- morphtree/diagnostics.py +158 -0
- morphtree/estimators.py +156 -0
- morphtree/examples.py +25 -0
- morphtree/exporting.py +224 -0
- morphtree/legacy_artifacts.py +32 -0
- morphtree/metrics.py +156 -0
- morphtree/model.py +1010 -0
- morphtree/mutations.py +205 -0
- morphtree/native_lab_backend.py +79 -0
- morphtree/numpy_backend.py +180 -0
- morphtree/optuna_tuner.py +154 -0
- morphtree/persistence.py +414 -0
- morphtree/prediction_output.py +35 -0
- morphtree/prepared_bins.py +82 -0
- morphtree/public.py +106 -0
- morphtree/resource_plan.py +170 -0
- morphtree/scalable.py +781 -0
- morphtree/scalable_backend.py +74 -0
- morphtree/scalable_checkpoint.py +129 -0
- morphtree/scalable_data.py +255 -0
- morphtree/scalable_receipts.py +92 -0
- morphtree/scalable_target.py +68 -0
- morphtree/scalable_variants.py +133 -0
- morphtree/sklearn_view.py +79 -0
- morphtree/source_markers.py +25 -0
- morphtree/species.py +47 -0
- morphtree/temporary.py +65 -0
- morphtree/training_fragment.py +130 -0
- morphtree/tree.py +231 -0
- morphtree/tuning.py +150 -0
- morphtree-2.1.0.dist-info/METADATA +212 -0
- morphtree-2.1.0.dist-info/RECORD +79 -0
- morphtree-2.1.0.dist-info/WHEEL +4 -0
- morphtree-2.1.0.dist-info/entry_points.txt +2 -0
- morphtree-2.1.0.dist-info/licenses/LICENSE +21 -0
morphtree/tree.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Histogram GBDT tree, explicit missing splits and histogram subtraction."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import asdict, dataclass, replace
|
|
5
|
+
import heapq
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
from .backend import PythonBackend
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_program(program, signals, slots):
|
|
12
|
+
"""Small bounded stack interpreter. No eval, exec or code compilation."""
|
|
13
|
+
stack = []
|
|
14
|
+
for token in program:
|
|
15
|
+
if token in signals:
|
|
16
|
+
stack.append(signals[token])
|
|
17
|
+
elif token.startswith("MUL_SLOT:"):
|
|
18
|
+
stack[-1] *= slots[int(token[-1])]
|
|
19
|
+
else:
|
|
20
|
+
right, left = stack.pop(), stack.pop()
|
|
21
|
+
if token == "ADD":
|
|
22
|
+
stack.append(left + right)
|
|
23
|
+
elif token == "MUL":
|
|
24
|
+
stack.append(left * right)
|
|
25
|
+
elif token == "MIN":
|
|
26
|
+
stack.append(min(left, right))
|
|
27
|
+
elif token == "MAX":
|
|
28
|
+
stack.append(max(left, right))
|
|
29
|
+
else:
|
|
30
|
+
raise ValueError("Unknown program instruction")
|
|
31
|
+
return stack[0]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def soft_threshold(g, l1):
|
|
35
|
+
return math.copysign(max(abs(g) - l1, 0.0), g)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class TreeNode:
|
|
40
|
+
is_leaf: bool = True
|
|
41
|
+
value: float = 0.0
|
|
42
|
+
feature: int = -1
|
|
43
|
+
threshold_bin: int = -1
|
|
44
|
+
missing_left: bool = True
|
|
45
|
+
left: int = -1
|
|
46
|
+
right: int = -1
|
|
47
|
+
depth: int = 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HistogramTree:
|
|
51
|
+
def __init__(self, genome, params, backend=None):
|
|
52
|
+
self.genome, self.params = genome, params
|
|
53
|
+
self.backend = backend or PythonBackend()
|
|
54
|
+
self.nodes = ()
|
|
55
|
+
self.root_count = 0
|
|
56
|
+
self.histogram_builds = 0
|
|
57
|
+
self.histogram_subtractions = 0
|
|
58
|
+
|
|
59
|
+
def effective_l2(self, depth):
|
|
60
|
+
factor = 1 + self.params.depth_reg_strength * depth if self.genome.regularization_policy == "depth_scaled" else 1
|
|
61
|
+
return self.params.l2 * factor
|
|
62
|
+
|
|
63
|
+
def score(self, g, h, depth):
|
|
64
|
+
thresholded = soft_threshold(g, self.params.l1)
|
|
65
|
+
return thresholded * thresholded / max(h + self.effective_l2(depth), 1e-12)
|
|
66
|
+
|
|
67
|
+
def leaf_value(self, g, h, count, depth, uncertainty, missing_ratio):
|
|
68
|
+
value = -soft_threshold(g, self.params.l1) / max(h + self.effective_l2(depth), 1e-12)
|
|
69
|
+
if self.genome.leaf_policy == "smoothed":
|
|
70
|
+
value *= count / (count + self.params.path_smooth)
|
|
71
|
+
elif self.genome.leaf_policy == "clipped":
|
|
72
|
+
value = max(-self.params.max_delta_step, min(self.params.max_delta_step, value))
|
|
73
|
+
elif self.genome.leaf_policy == "uncertainty":
|
|
74
|
+
value /= 1 + self.params.gate_strength * uncertainty
|
|
75
|
+
gate = (self.genome.local_policy == "low_support" and count < max(8, self.root_count / 16))
|
|
76
|
+
gate = gate or (self.genome.local_policy == "missing_heavy" and missing_ratio >= 0.5)
|
|
77
|
+
if gate:
|
|
78
|
+
value /= 1 + self.params.gate_strength
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
def _histograms(self, Xb, ids, g, h, r, u, features, sizes):
|
|
82
|
+
self.histogram_builds += len(features)
|
|
83
|
+
return {f: self.backend.histogram(Xb[f], ids, g, h, r, u, sizes[f]) for f in features}
|
|
84
|
+
|
|
85
|
+
def _best_split(self, histograms, depth, features):
|
|
86
|
+
best = None
|
|
87
|
+
if depth >= self.params.max_depth:
|
|
88
|
+
return None
|
|
89
|
+
directions = (True, False) if self.genome.missing_policy == "learned" else (self.genome.missing_policy == "left",)
|
|
90
|
+
for feature in features:
|
|
91
|
+
hist = histograms[feature]
|
|
92
|
+
total = [math.fsum(row) for row in hist]
|
|
93
|
+
n, G, H, R, U = total
|
|
94
|
+
if n < 2 * self.params.min_samples_leaf:
|
|
95
|
+
continue
|
|
96
|
+
fast = self.backend.fast_split(hist, total, depth, self.genome, self.params)
|
|
97
|
+
if fast is NotImplemented and self.genome.split_program == ("OBJECTIVE_GAIN",):
|
|
98
|
+
# NumPy's ambiguous-gain fallback keeps canonical arithmetic.
|
|
99
|
+
fast = PythonBackend().fast_split(hist, total, depth, self.genome, self.params)
|
|
100
|
+
if fast is not NotImplemented:
|
|
101
|
+
if fast is not None and (best is None or round(fast[0], 12) > round(best[0], 12)):
|
|
102
|
+
best = (fast[0], feature, fast[1], fast[2])
|
|
103
|
+
continue
|
|
104
|
+
# Python float arithmetic is the admission/tie reference, including
|
|
105
|
+
# the narrowly requested NumPy fallback for ambiguous gains.
|
|
106
|
+
hist = [[float(v) for v in row] for row in hist]
|
|
107
|
+
# Without missing observations both routes have identical statistics.
|
|
108
|
+
# Preserve the historical first tie (missing_left=True), as NumPy does.
|
|
109
|
+
feature_directions = directions[:1] if len(directions) == 2 and not any(row[0] for row in hist) else directions
|
|
110
|
+
cumulative = PythonBackend().cumulative(hist)
|
|
111
|
+
parent_score = self.score(G, H, depth)
|
|
112
|
+
# Cut 0 isolates missing values. Final cut isolates observed from missing.
|
|
113
|
+
for cut in range(len(hist[0])):
|
|
114
|
+
observed = [row[cut - 1] if cut else 0.0 for row in cumulative]
|
|
115
|
+
for missing_left in feature_directions:
|
|
116
|
+
left = [v + (hist[k][0] if missing_left else 0.0) for k, v in enumerate(observed)]
|
|
117
|
+
ln, lg, lh, lr, lu = left
|
|
118
|
+
rn, rg, rh, rr, ru = [v - vleft for v, vleft in zip(total, left)]
|
|
119
|
+
if min(ln, rn) < self.params.min_samples_leaf or min(lh, rh) < self.params.min_hessian:
|
|
120
|
+
continue
|
|
121
|
+
objective = 0.5 * (self.score(lg, lh, depth + 1) + self.score(rg, rh, depth + 1) - parent_score)
|
|
122
|
+
if self.genome.split_program == ("OBJECTIVE_GAIN",):
|
|
123
|
+
gain = objective
|
|
124
|
+
else:
|
|
125
|
+
signals = {"OBJECTIVE_GAIN": objective,
|
|
126
|
+
"RESIDUAL_VARIANCE_GAIN": max(0.0, lr * lr / ln + rr * rr / rn - R * R / n),
|
|
127
|
+
"BALANCE": 2 * min(ln, rn) / n,
|
|
128
|
+
"HESSIAN_BALANCE": 2 * min(lh, rh) / max(H, 1e-12),
|
|
129
|
+
"UNCERTAINTY_GAIN": (lu / ln - ru / rn) ** 2 * ln * rn / n}
|
|
130
|
+
gain = run_program(self.genome.split_program, signals, (self.params.signal_slot_0, self.params.signal_slot_1))
|
|
131
|
+
if math.isfinite(gain) and gain > self.params.min_gain and (best is None or round(gain, 12) > round(best[0], 12)):
|
|
132
|
+
best = (gain, feature, cut, missing_left)
|
|
133
|
+
return best
|
|
134
|
+
|
|
135
|
+
def _lookahead_split(self, Xb, ids, g, h, r, u, features, sizes, depth):
|
|
136
|
+
"""Bounded two-level interaction probe; used only when greedy splitting stalls."""
|
|
137
|
+
if depth + 1 >= self.params.max_depth or len(ids) < 4 * self.params.min_samples_leaf:
|
|
138
|
+
return None
|
|
139
|
+
best = None
|
|
140
|
+
directions = (True, False) if self.genome.missing_policy == "learned" else (self.genome.missing_policy == "left",)
|
|
141
|
+
for feature in features[:4]:
|
|
142
|
+
observed = sorted({int(Xb[feature][i]) for i in ids if Xb[feature][i] > 0})
|
|
143
|
+
if not observed:
|
|
144
|
+
continue
|
|
145
|
+
cuts = sorted({observed[min(len(observed) - 1, k * len(observed) // 4)] for k in range(4)})
|
|
146
|
+
for cut in cuts:
|
|
147
|
+
for missing_left in directions:
|
|
148
|
+
left, right = self.backend.partition(Xb[feature], ids, cut, missing_left)
|
|
149
|
+
if min(len(left), len(right)) < self.params.min_samples_leaf:
|
|
150
|
+
continue
|
|
151
|
+
future_gain = 0.0
|
|
152
|
+
for child in (left, right):
|
|
153
|
+
if len(child) < 2 * self.params.min_samples_leaf:
|
|
154
|
+
continue
|
|
155
|
+
hist = self._histograms(Xb, child, g, h, r, u, features, sizes)
|
|
156
|
+
split = self._best_split(hist, depth + 1, features)
|
|
157
|
+
if split is not None:
|
|
158
|
+
future_gain += split[0]
|
|
159
|
+
G = math.fsum(g[i] for i in ids)
|
|
160
|
+
H = math.fsum(h[i] for i in ids)
|
|
161
|
+
lg = math.fsum(g[i] for i in left)
|
|
162
|
+
lh = math.fsum(h[i] for i in left)
|
|
163
|
+
rh = H - lh
|
|
164
|
+
if min(lh, rh) < self.params.min_hessian:
|
|
165
|
+
continue
|
|
166
|
+
immediate = 0.5 * (self.score(lg, lh, depth + 1) + self.score(G - lg, rh, depth + 1) - self.score(G, H, depth))
|
|
167
|
+
gain = immediate + future_gain
|
|
168
|
+
if gain > self.params.min_gain and (best is None or round(gain, 12) > round(best[0], 12)):
|
|
169
|
+
best = (gain, feature, cut, missing_left)
|
|
170
|
+
return best
|
|
171
|
+
|
|
172
|
+
def fit(self, Xb, g, h, r, u, rng, *, indices=None, sizes=None):
|
|
173
|
+
n_features = len(Xb)
|
|
174
|
+
features = sorted(rng.sample(range(n_features), max(1, math.ceil(n_features * self.params.feature_fraction))))
|
|
175
|
+
ids = list(range(len(g))) if indices is None else list(indices)
|
|
176
|
+
self.root_count = len(ids)
|
|
177
|
+
sizes = sizes or [max(column, default=0) + 1 for column in Xb]
|
|
178
|
+
hist = self._histograms(Xb, ids, g, h, r, u, features, sizes)
|
|
179
|
+
|
|
180
|
+
def make_node(node_ids, depth):
|
|
181
|
+
count = len(node_ids)
|
|
182
|
+
G, H, uncertainty, missing = self.backend.node_statistics(
|
|
183
|
+
Xb, node_ids, g, h, u, features, self.genome.local_policy == "missing_heavy", self.genome.leaf_policy == "uncertainty")
|
|
184
|
+
val = self.leaf_value(G, H, count, depth, uncertainty, missing)
|
|
185
|
+
return TreeNode(value=val, depth=depth)
|
|
186
|
+
|
|
187
|
+
nodes = [make_node(ids, 0)]
|
|
188
|
+
queue, serial = [], 0
|
|
189
|
+
|
|
190
|
+
def enqueue(node_id, node_ids, node_hist):
|
|
191
|
+
nonlocal serial
|
|
192
|
+
if len(nodes) >= 2 * self.params.max_leaves - 1:
|
|
193
|
+
return
|
|
194
|
+
split = self._best_split(node_hist, nodes[node_id].depth, features)
|
|
195
|
+
if split is None and self.genome.growth_policy == "lookahead":
|
|
196
|
+
split = self._lookahead_split(Xb, node_ids, g, h, r, u, features, sizes, nodes[node_id].depth)
|
|
197
|
+
if split is None:
|
|
198
|
+
return
|
|
199
|
+
priority = nodes[node_id].depth if self.genome.growth_policy == "depthwise" else -round(split[0], 12)
|
|
200
|
+
heapq.heappush(queue, (priority, serial, node_id, node_ids, node_hist, split))
|
|
201
|
+
serial += 1
|
|
202
|
+
|
|
203
|
+
enqueue(0, ids, hist)
|
|
204
|
+
leaves = 1
|
|
205
|
+
while queue and leaves < self.params.max_leaves:
|
|
206
|
+
_, _, node_id, node_ids, parent_hist, split = heapq.heappop(queue)
|
|
207
|
+
_, feature, cut, missing_left = split
|
|
208
|
+
left_ids, right_ids = self.backend.partition(Xb[feature], node_ids, cut, missing_left)
|
|
209
|
+
left_id, right_id = len(nodes), len(nodes) + 1
|
|
210
|
+
depth = nodes[node_id].depth + 1
|
|
211
|
+
nodes.extend((make_node(left_ids, depth), make_node(right_ids, depth)))
|
|
212
|
+
nodes[node_id] = replace(nodes[node_id], is_leaf=False, feature=feature, threshold_bin=cut,
|
|
213
|
+
missing_left=bool(missing_left), left=left_id, right=right_id)
|
|
214
|
+
leaves += 1
|
|
215
|
+
if leaves >= self.params.max_leaves:
|
|
216
|
+
break
|
|
217
|
+
small_ids = left_ids if len(left_ids) <= len(right_ids) else right_ids
|
|
218
|
+
small_hist = self._histograms(Xb, small_ids, g, h, r, u, features, sizes)
|
|
219
|
+
large_hist = {f: self.backend.subtract(parent_hist[f], small_hist[f]) for f in features}
|
|
220
|
+
self.histogram_subtractions += len(features)
|
|
221
|
+
left_hist, right_hist = (small_hist, large_hist) if small_ids is left_ids else (large_hist, small_hist)
|
|
222
|
+
enqueue(left_id, left_ids, left_hist)
|
|
223
|
+
enqueue(right_id, right_ids, right_hist)
|
|
224
|
+
self.nodes = tuple(nodes)
|
|
225
|
+
return self
|
|
226
|
+
|
|
227
|
+
def predict_binned(self, Xb):
|
|
228
|
+
return self.backend.predict_tree(self.nodes, Xb)
|
|
229
|
+
|
|
230
|
+
def to_dict(self):
|
|
231
|
+
return {"nodes": [asdict(node) for node in self.nodes]}
|
morphtree/tuning.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Small deterministic, conditional native tuner; never changes structural genes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import asdict, replace
|
|
5
|
+
import time
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
from .metrics import logloss, rmse
|
|
9
|
+
from .control import RunControl, SearchInterrupted
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
TUNABLE_ORDER = ("learning_rate", "l2", "min_samples_leaf", "max_leaves", "n_estimators", "feature_fraction", "row_fraction",
|
|
13
|
+
"path_smooth", "max_delta_step", "depth_reg_strength", "error_weight", "update_decay", "gate_strength",
|
|
14
|
+
"signal_slot_0", "signal_slot_1")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def tunable_parameters(genome):
|
|
18
|
+
active = set(genome.active_parameters())
|
|
19
|
+
return tuple(name for name in TUNABLE_ORDER if name in active)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def bounded_parameter(name, value):
|
|
23
|
+
integer_limits = {"min_samples_leaf": (1, 10**9), "max_leaves": (2, 4096), "n_estimators": (1, 4096)}
|
|
24
|
+
if name in integer_limits:
|
|
25
|
+
low, high = integer_limits[name]
|
|
26
|
+
return min(high, max(low, int(round(value))))
|
|
27
|
+
if name in ("feature_fraction", "row_fraction", "learning_rate"):
|
|
28
|
+
return min(1.0, max(0.001, value))
|
|
29
|
+
if name.startswith("signal_slot_"):
|
|
30
|
+
return min(10.0, max(0.0, value))
|
|
31
|
+
return max(1e-6, min(1e6, value))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def tuning_record(genome, base_search, base_guard):
|
|
35
|
+
return {"attempted": True, "backend": "native", "completed": False, "accepted": False,
|
|
36
|
+
"reason": "no_completed_trials", "trials": [], "active_parameters": list(genome.active_parameters()),
|
|
37
|
+
"tunable_parameters": list(tunable_parameters(genome)), "frozen_fingerprint": genome.fingerprint(),
|
|
38
|
+
"search_loss_before": base_search, "guard_loss_before": base_guard}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def prediction_loss(model, X, y):
|
|
42
|
+
if getattr(model, 'objective', 'binary_logloss') == 'squared_error':
|
|
43
|
+
return rmse(y, model.predict_raw(X))
|
|
44
|
+
return logloss(y, model.predict_proba(X))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def decide_tuned_candidate(base, candidate, record, X_search, y_search, X_guard, y_guard, config, base_guard_limit):
|
|
48
|
+
record["completed"] = bool(record["trials"])
|
|
49
|
+
record["selected_parameters"] = asdict(candidate.params)
|
|
50
|
+
new_search = prediction_loss(candidate, X_search, y_search)
|
|
51
|
+
new_guard = prediction_loss(candidate, X_guard, y_guard)
|
|
52
|
+
record.update(search_loss_after=new_search, guard_loss_after=new_guard)
|
|
53
|
+
floor = 0.0 if getattr(base, 'objective', 'binary_logloss') == 'squared_error' else 1e-12
|
|
54
|
+
passes = math.isfinite(new_search) and math.isfinite(new_guard) and new_search < record["search_loss_before"] - floor and new_guard <= min(
|
|
55
|
+
record["guard_loss_before"] + config.guard_tolerance, base_guard_limit + config.global_guard_tolerance)
|
|
56
|
+
if passes:
|
|
57
|
+
record.update(accepted=True, reason="accepted")
|
|
58
|
+
result = candidate
|
|
59
|
+
else:
|
|
60
|
+
record["reason"] = "rejected_or_no_search_gain" if record["completed"] else "no_completed_trials"
|
|
61
|
+
result = base
|
|
62
|
+
record["effective_parameters"] = asdict(result.params)
|
|
63
|
+
assert result.genome.fingerprint() == record["frozen_fingerprint"]
|
|
64
|
+
return result, record
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MorphTuner:
|
|
68
|
+
"""Coarse coordinate probes, sensitivity ranking and local refinement."""
|
|
69
|
+
def optimize(self, base, evaluate, X_search, y_search, X_guard, y_guard, config, base_guard_limit):
|
|
70
|
+
start = time.monotonic()
|
|
71
|
+
before = logloss(y_search, base.predict_proba(X_search))
|
|
72
|
+
guard = logloss(y_guard, base.predict_proba(X_guard))
|
|
73
|
+
record = tuning_record(base.genome, before, guard)
|
|
74
|
+
control = RunControl(getattr(self, 'cancel', None), config.tune_timeout)
|
|
75
|
+
best, best_loss = base, before
|
|
76
|
+
tried, sensitivity = set(), {}
|
|
77
|
+
stale = 0
|
|
78
|
+
proposal_list = []
|
|
79
|
+
for name in tunable_parameters(base.genome):
|
|
80
|
+
value = getattr(base.params, name)
|
|
81
|
+
if value == 0:
|
|
82
|
+
value = 0.1
|
|
83
|
+
for factor in (0.7, 1.3):
|
|
84
|
+
proposal_list.append((name, bounded_parameter(name, value * factor), "coarse"))
|
|
85
|
+
# Reserve about half the budget for sensitivity-guided refinements.
|
|
86
|
+
coarse_budget = max(2, (config.tune_trials + 1) // 2)
|
|
87
|
+
for trial_index in range(config.tune_trials):
|
|
88
|
+
try:
|
|
89
|
+
control.check()
|
|
90
|
+
except SearchInterrupted as exc:
|
|
91
|
+
record['stop_reason'] = exc.reason
|
|
92
|
+
break
|
|
93
|
+
if config.tune_timeout and time.monotonic() - start >= config.tune_timeout:
|
|
94
|
+
record["stop_reason"] = "timeout"
|
|
95
|
+
break
|
|
96
|
+
if trial_index < coarse_budget and proposal_list:
|
|
97
|
+
name, value, phase = proposal_list.pop(0)
|
|
98
|
+
else:
|
|
99
|
+
if sensitivity:
|
|
100
|
+
ranked = sorted(sensitivity, key=lambda k: (-sensitivity[k], k))
|
|
101
|
+
name = ranked[((trial_index - coarse_budget) // 2) % len(ranked)]
|
|
102
|
+
else:
|
|
103
|
+
name = tunable_parameters(base.genome)[(trial_index // 2) % len(tunable_parameters(base.genome))]
|
|
104
|
+
factor = 0.85 if trial_index % 2 == 0 else 1.15
|
|
105
|
+
value, phase = bounded_parameter(name, max(getattr(best.params, name), 1e-6) * factor), "local"
|
|
106
|
+
params = replace(best.params, **{name: value})
|
|
107
|
+
key = tuple(asdict(params).items())
|
|
108
|
+
if key in tried or params == best.params:
|
|
109
|
+
continue
|
|
110
|
+
tried.add(key)
|
|
111
|
+
def callback(iteration, loss):
|
|
112
|
+
control.check()
|
|
113
|
+
return True
|
|
114
|
+
try:
|
|
115
|
+
candidate = evaluate(base.genome, params, callback=callback, early_stopping_rounds=config.early_stopping_rounds)
|
|
116
|
+
except SearchInterrupted as exc:
|
|
117
|
+
record['stop_reason'] = exc.reason
|
|
118
|
+
break
|
|
119
|
+
except (ArithmeticError, ValueError) as exc:
|
|
120
|
+
record['trials'].append({'index': trial_index, 'state': 'FAIL', 'reason': type(exc).__name__})
|
|
121
|
+
continue
|
|
122
|
+
# n_estimators is a numerical parameter: choosing a retained prefix is allowed only here.
|
|
123
|
+
if len(candidate.trees) < params.n_estimators:
|
|
124
|
+
params = replace(params, n_estimators=len(candidate.trees))
|
|
125
|
+
try:
|
|
126
|
+
candidate = evaluate(base.genome, params, callback=callback)
|
|
127
|
+
except SearchInterrupted as exc:
|
|
128
|
+
record['stop_reason'] = exc.reason
|
|
129
|
+
break
|
|
130
|
+
assert candidate.genome.fingerprint() == record["frozen_fingerprint"]
|
|
131
|
+
loss = logloss(y_search, candidate.predict_proba(X_search))
|
|
132
|
+
candidate_guard = logloss(y_guard, candidate.predict_proba(X_guard))
|
|
133
|
+
feasible = math.isfinite(loss) and math.isfinite(candidate_guard) and candidate_guard <= min(
|
|
134
|
+
logloss(y_guard, best.predict_proba(X_guard)) + config.guard_tolerance,
|
|
135
|
+
guard + config.guard_tolerance, base_guard_limit + config.global_guard_tolerance)
|
|
136
|
+
gain = best_loss - loss
|
|
137
|
+
sensitivity[name] = max(sensitivity.get(name, 0.0), abs(gain))
|
|
138
|
+
record["trials"].append({"index": trial_index, "phase": phase, "changed_parameter": name,
|
|
139
|
+
"value": value, "search_loss": loss, "guard_loss": candidate_guard,
|
|
140
|
+
"feasible": feasible, "parameters": asdict(candidate.params)})
|
|
141
|
+
if feasible and loss < best_loss - 1e-12:
|
|
142
|
+
best, best_loss, stale = candidate, loss, 0
|
|
143
|
+
else:
|
|
144
|
+
stale += 1
|
|
145
|
+
if stale >= config.tune_patience and trial_index + 1 >= coarse_budget:
|
|
146
|
+
record["stop_reason"] = "local_patience"
|
|
147
|
+
break
|
|
148
|
+
record.setdefault("stop_reason", "budget_exhausted")
|
|
149
|
+
record["sensitivity"] = sensitivity
|
|
150
|
+
return decide_tuned_candidate(base, best, record, X_search, y_search, X_guard, y_guard, config, base_guard_limit)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: morphtree
|
|
3
|
+
Version: 2.1.0
|
|
4
|
+
Summary: Adaptive tree learning for tabular data with diagnostic structural evolution
|
|
5
|
+
Project-URL: Homepage, https://github.com/lucalullo/morph-tree
|
|
6
|
+
Project-URL: Source, https://github.com/lucalullo/morph-tree
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: <3.15,>=3.12
|
|
10
|
+
Requires-Dist: numpy<3,>=1.26.0
|
|
11
|
+
Requires-Dist: optuna==5.0.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# MorphTree
|
|
15
|
+
|
|
16
|
+
**Adaptive tree learning for tabular data, with structural evolution and a simple Python API.**
|
|
17
|
+
|
|
18
|
+
MorphTree 2.1.0 supports binary classification, multiclass classification and single-target regression. It screens applicable tree families, selects a parent, diagnoses training errors and evaluates structural changes to the executable learning program. A descendant replaces its parent only when the selection criteria are satisfied; otherwise the parent is retained.
|
|
19
|
+
|
|
20
|
+
Automatic preprocessing, structural evolution, final numerical optimization and model persistence are available through `fit()` and `predict()`.
|
|
21
|
+
|
|
22
|
+
Discovering a learning program involves more work than fitting a single fixed model: MorphTree compares families, diagnoses errors, evaluates descendants and tunes the retained program. This search happens during `fit()`. A saved model can then predict without repeating it, while an exported frozen program can train new weights without rerunning the search, within the advanced API's data and runtime requirements. These are different forms of reuse; their costs and predictive quality depend on the workload.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install morphtree
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This command selects the latest compatible release available on PyPI. Compatible prebuilt wheels include the native components and require no C compiler. See the [installation guide](https://github.com/lucalullo/morph-tree/blob/main/docs/INSTALLATION.md) for supported environments, local wheel installation and source-build requirements.
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
These independent examples require the optional scikit-learn package: `pip install scikit-learn`.
|
|
35
|
+
|
|
36
|
+
### Binary classification
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from sklearn.datasets import load_breast_cancer
|
|
40
|
+
from sklearn.model_selection import train_test_split
|
|
41
|
+
from morphtree import MorphTreeClassifier
|
|
42
|
+
|
|
43
|
+
X, y = load_breast_cancer(return_X_y=True)
|
|
44
|
+
X_train, X_test, y_train, y_test = train_test_split(
|
|
45
|
+
X, y, test_size=0.2, random_state=42, stratify=y
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
model = MorphTreeClassifier(random_state=42)
|
|
49
|
+
model.fit(X_train, y_train)
|
|
50
|
+
|
|
51
|
+
predictions = model.predict(X_test)
|
|
52
|
+
probabilities = model.predict_proba(X_test)
|
|
53
|
+
print("Test accuracy:", model.score(X_test, y_test))
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Multiclass classification
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from sklearn.datasets import load_iris
|
|
60
|
+
from sklearn.model_selection import train_test_split
|
|
61
|
+
from morphtree import MorphTreeClassifier
|
|
62
|
+
|
|
63
|
+
X, y = load_iris(return_X_y=True)
|
|
64
|
+
X_train, X_test, y_train, y_test = train_test_split(
|
|
65
|
+
X, y, test_size=0.2, random_state=42, stratify=y
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
model = MorphTreeClassifier(random_state=42)
|
|
69
|
+
model.fit(X_train, y_train)
|
|
70
|
+
|
|
71
|
+
predictions = model.predict(X_test)
|
|
72
|
+
probabilities = model.predict_proba(X_test)
|
|
73
|
+
print("Test accuracy:", model.score(X_test, y_test))
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Regression
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from sklearn.datasets import load_diabetes
|
|
80
|
+
from sklearn.model_selection import train_test_split
|
|
81
|
+
from morphtree import MorphTreeRegressor
|
|
82
|
+
|
|
83
|
+
X, y = load_diabetes(return_X_y=True)
|
|
84
|
+
X_train, X_test, y_train, y_test = train_test_split(
|
|
85
|
+
X, y, test_size=0.2, random_state=42
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
model = MorphTreeRegressor(random_state=42)
|
|
89
|
+
model.fit(X_train, y_train)
|
|
90
|
+
|
|
91
|
+
predictions = model.predict(X_test)
|
|
92
|
+
print("Test R2:", model.score(X_test, y_test))
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`predict()` returns a NumPy array of shape `(n_samples,)`. Classification `predict_proba()` returns a numeric NumPy array of shape `(n_samples, n_classes)`; column `j` follows `classes_[j]`. Probabilities are finite, lie in `[0, 1]` and sum to one within floating-point tolerance. NumPy slicing such as `probabilities[:, 1]` works directly; select the column for your intended class.
|
|
96
|
+
|
|
97
|
+
Classifier `score()` reports accuracy; regressor `score()` reports R². Each example evaluates on its held-out TEST split.
|
|
98
|
+
|
|
99
|
+
## Structural evolution
|
|
100
|
+
|
|
101
|
+
MorphTree evaluates changes to its learning program as well as numerical parameters:
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
Preprocess data and plan resources
|
|
105
|
+
-> Screen applicable tree families and select one parent
|
|
106
|
+
-> Diagnose errors from out-of-fold training evidence
|
|
107
|
+
-> Propose and train structural descendants
|
|
108
|
+
-> Evaluate on internal SEARCH data; accept or roll back
|
|
109
|
+
-> Continue from the retained parent when the budget permits
|
|
110
|
+
-> Freeze the learning program
|
|
111
|
+
-> Run final Optuna numerical tuning and refit
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The current finite mutation grammar changes split-objective weighting and tree-growth policy. Mutations execute during descendant training; they are not merely annotations. Accepted descendants can become parents for subsequent proposals. Evolution never forces acceptance, and a completed fit may retain its initial parent after rejecting every proposal.
|
|
115
|
+
|
|
116
|
+
Adaptive evolution and final Optuna tuning are enabled by default. `mode="baseline"` provides a control without structural evolution; `use_optuna=False` disables the final numerical search. These options change the scope of fitting. See the [API reference](https://github.com/lucalullo/morph-tree/blob/main/docs/API.md) and [architecture](https://github.com/lucalullo/morph-tree/blob/main/docs/ARCHITECTURE.md).
|
|
117
|
+
|
|
118
|
+
## Data and resources
|
|
119
|
+
|
|
120
|
+
The public estimators accept supported NumPy arrays, pandas DataFrames and row-based tabular input. Preprocessing handles numerical and categorical columns, including supported missing-value representations. Preserve the training feature schema when predicting. Missing-value and category handling are learned from the permitted training partition.
|
|
121
|
+
|
|
122
|
+
Prediction uses internal batching, preserving row order. Training does not silently discard observations to pass resource admission. Memory, time, feature cardinality and model complexity still determine whether a workload can complete. See [data and resource limits](https://github.com/lucalullo/morph-tree/blob/main/docs/PUBLIC_API_LIMITS.md) for input contracts and qualified workloads.
|
|
123
|
+
|
|
124
|
+
The Python learning program remains mutable. Compiled C kernels accelerate supported numerical operations and are selected automatically; normal use requires no backend configuration. Python retains the evolutionary controller and operations outside those kernels.
|
|
125
|
+
|
|
126
|
+
## Capacity and defaults
|
|
127
|
+
|
|
128
|
+
The default constructor is the recommended starting point. The initial screening/evolution capacity is two rounds; with `capacity=None`, the final numerical search uses a resource-dependent domain. The selected final round count is therefore not necessarily two. Automatic trial count currently resolves to two Optuna trials.
|
|
129
|
+
|
|
130
|
+
An explicit eight-round profile is available through the same API:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
eight_round_classifier = MorphTreeClassifier(
|
|
134
|
+
random_state=42, capacity={"n_estimators": 8}
|
|
135
|
+
)
|
|
136
|
+
eight_round_regressor = MorphTreeRegressor(
|
|
137
|
+
random_state=42, capacity={"n_estimators": 8}
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
This fixes the round capacity for the profile; it is not guaranteed to exceed the final capacity selected by the automatic policy. More rounds can improve accuracy and increase work, but can also worsen generalization. The [capacity comparison](https://github.com/lucalullo/morph-tree/blob/main/docs/CAPACITY.md) reports results for 2, 4 and 8 rounds across six datasets and explains why the defaults remain unchanged.
|
|
142
|
+
|
|
143
|
+
Other controls include `max_mutations`, `n_trials`, `time_budget`, `memory_budget`, `n_jobs` and `mode`. Their semantics and resource limits are documented in the [API reference](https://github.com/lucalullo/morph-tree/blob/main/docs/API.md).
|
|
144
|
+
|
|
145
|
+
## Inspect, save and reuse
|
|
146
|
+
|
|
147
|
+
After fitting the binary classifier above:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
print(model.get_algorithm_program())
|
|
151
|
+
print(model.get_recipe())
|
|
152
|
+
print(model.get_mutation_history())
|
|
153
|
+
print(model.get_search_report())
|
|
154
|
+
print(model.cost_report())
|
|
155
|
+
|
|
156
|
+
model.save("morphtree-model.json")
|
|
157
|
+
restored = MorphTreeClassifier.load("morphtree-model.json")
|
|
158
|
+
restored_predictions = restored.predict(X_test)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`get_algorithm_program()` describes the retained family's executable learning rules. `get_recipe()` also records its numerical parameters, fitted preprocessing and class ordering; inspecting a recipe does not train a model. `save()` and `load()` preserve the learned weights for inference on new observations with the same feature schema, without repeating discovery. Regression uses the same `save()` method and `MorphTreeRegressor.load()`.
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
model.export_standalone("predictor.py", output_format="numpy")
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The standalone predictor includes the fitted weights and performs inference without importing MorphTree; this explicit NumPy format requires NumPy. The default export format remains dependency-free Python lists.
|
|
168
|
+
|
|
169
|
+
To export the learning program for a separate training operation:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
model.export_algorithm("training_program.py")
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Exporting does not train. The generated `train_encoded()` function trains new weights for the frozen family, rules and numerical parameters, without repeating family screening, OOF diagnosis, mutations or Optuna. It requires the exact MorphTree runtime used for export, input encoded with the exported preprocessing, compatible task/class labels in compact numeric buffers and an explicit TRAIN identity. It is an advanced training fragment, not a raw-data `fit(X, y)` replacement or a standalone predictor. The [API reference](https://github.com/lucalullo/morph-tree/blob/main/docs/API.md) documents its encoded-data example and integrity requirements, alongside reports, genealogy and model variants.
|
|
176
|
+
|
|
177
|
+
Calling the ordinary estimator's `fit()` again runs its configured learning process; loading a model does not turn `fit()` into frozen-program training. Evaluate a reused program on fresh held-out data before relying on it for a new problem: avoiding another search does not guarantee better accuracy or a particular speedup.
|
|
178
|
+
|
|
179
|
+
## Supported environments
|
|
180
|
+
|
|
181
|
+
The verified runtime scope is CPython 3.12–3.14 on Windows x86-64 and Ubuntu 24.04 WSL2 x86-64. Linux wheel ABI auditing is separate from full runtime qualification on other distributions. Python 3.15, macOS and other architectures are outside the established scope.
|
|
182
|
+
|
|
183
|
+
Optuna and NumPy are core dependencies. pandas and scikit-learn are optional integrations. NumPy predictions support standard slicing, vectorized operations and compatible scikit-learn metrics; this does not imply support for every scikit-learn estimator protocol.
|
|
184
|
+
|
|
185
|
+
## Evidence and limitations
|
|
186
|
+
|
|
187
|
+
Predictive quality, training cost and memory use depend on the dataset, family, capacity and search budget. Local checks cover native dispatch, the three tasks, persistence, exported inference and structural decision handling within the [qualification scope](https://github.com/lucalullo/morph-tree/blob/main/docs/QUALIFICATION.md).
|
|
188
|
+
|
|
189
|
+
- Accepted mutations do not by themselves prove better generalization; controlled comparisons are needed to isolate their contribution.
|
|
190
|
+
- An independent predictive benefit from structural evolution has not been established across diverse real-world benchmarks.
|
|
191
|
+
- Screening, OOF diagnostics, descendants and Optuna add work beyond a single fixed model fit.
|
|
192
|
+
- Kernel acceleration does not imply an equivalent speedup for the complete pipeline.
|
|
193
|
+
- Capacity comparisons show dataset-specific gains and regressions, not a universally optimal setting.
|
|
194
|
+
|
|
195
|
+
Measured results and their limitations are recorded in the [comparison report](https://github.com/lucalullo/morph-tree/blob/main/docs/COMPARISON.md), [capacity study](https://github.com/lucalullo/morph-tree/blob/main/docs/CAPACITY.md) and [benchmark methodology](https://github.com/lucalullo/morph-tree/blob/main/BENCHMARKS.md).
|
|
196
|
+
|
|
197
|
+
## Documentation
|
|
198
|
+
|
|
199
|
+
- [Installation](https://github.com/lucalullo/morph-tree/blob/main/docs/INSTALLATION.md)
|
|
200
|
+
- [API reference](https://github.com/lucalullo/morph-tree/blob/main/docs/API.md)
|
|
201
|
+
- [Architecture](https://github.com/lucalullo/morph-tree/blob/main/docs/ARCHITECTURE.md)
|
|
202
|
+
- [Examples](https://github.com/lucalullo/morph-tree/tree/main/examples/)
|
|
203
|
+
- [Qualification](https://github.com/lucalullo/morph-tree/blob/main/docs/QUALIFICATION.md)
|
|
204
|
+
- [Contributing](https://github.com/lucalullo/morph-tree/blob/main/CONTRIBUTING.md)
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
MorphTree is created by Luca Lullo and released under the [MIT License](https://github.com/lucalullo/morph-tree/blob/main/LICENSE).
|
|
209
|
+
|
|
210
|
+
## Author
|
|
211
|
+
|
|
212
|
+
Created by [Luca Lullo](https://github.com/lucalullo).
|