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.
Files changed (79) hide show
  1. morphtree/__init__.py +28 -0
  2. morphtree/__main__.py +3 -0
  3. morphtree/_experimental/__init__.py +1 -0
  4. morphtree/_experimental/admission.py +364 -0
  5. morphtree/_experimental/base.py +215 -0
  6. morphtree/_experimental/component_cache_v2.py +263 -0
  7. morphtree/_experimental/evolution_report.py +307 -0
  8. morphtree/_experimental/execution_v2.py +697 -0
  9. morphtree/_experimental/facade.py +289 -0
  10. morphtree/_experimental/integrated.py +748 -0
  11. morphtree/_experimental/learning.py +418 -0
  12. morphtree/_experimental/manifest.json +204 -0
  13. morphtree/_experimental/numeric_fixture.py +21 -0
  14. morphtree/_experimental/parameter_contract.py +38 -0
  15. morphtree/_experimental/postfit.py +51 -0
  16. morphtree/_experimental/preprocessing.py +133 -0
  17. morphtree/_experimental/processes.py +191 -0
  18. morphtree/_experimental/producer.py +542 -0
  19. morphtree/_experimental/product.py +156 -0
  20. morphtree/_experimental/product_cli.py +43 -0
  21. morphtree/_experimental/roundtrip_v3.py +311 -0
  22. morphtree/_experimental/runtime_io.py +65 -0
  23. morphtree/_experimental/sampling.py +26 -0
  24. morphtree/_experimental/support.py +281 -0
  25. morphtree/_experimental/target.py +59 -0
  26. morphtree/_experimental/training.py +53 -0
  27. morphtree/_experimental/worker.py +682 -0
  28. morphtree/_installed.py +17 -0
  29. morphtree/_native/__init__.py +1 -0
  30. morphtree/_native/bin/win_amd64/morphtree_kernels.dll +0 -0
  31. morphtree/_native/buffers.py +82 -0
  32. morphtree/_native/loader.py +39 -0
  33. morphtree/_native/manifest.json +29 -0
  34. morphtree/_native/runtime.py +48 -0
  35. morphtree/_native/src/histogram.c +61 -0
  36. morphtree/_native/src/traversal.c +51 -0
  37. morphtree/api.py +388 -0
  38. morphtree/api_compat.py +65 -0
  39. morphtree/backend.py +162 -0
  40. morphtree/booster.py +252 -0
  41. morphtree/cli.py +52 -0
  42. morphtree/config.py +262 -0
  43. morphtree/control.py +20 -0
  44. morphtree/data.py +300 -0
  45. morphtree/diagnostics.py +158 -0
  46. morphtree/estimators.py +156 -0
  47. morphtree/examples.py +25 -0
  48. morphtree/exporting.py +224 -0
  49. morphtree/legacy_artifacts.py +32 -0
  50. morphtree/metrics.py +156 -0
  51. morphtree/model.py +1010 -0
  52. morphtree/mutations.py +205 -0
  53. morphtree/native_lab_backend.py +79 -0
  54. morphtree/numpy_backend.py +180 -0
  55. morphtree/optuna_tuner.py +154 -0
  56. morphtree/persistence.py +414 -0
  57. morphtree/prediction_output.py +35 -0
  58. morphtree/prepared_bins.py +82 -0
  59. morphtree/public.py +106 -0
  60. morphtree/resource_plan.py +170 -0
  61. morphtree/scalable.py +781 -0
  62. morphtree/scalable_backend.py +74 -0
  63. morphtree/scalable_checkpoint.py +129 -0
  64. morphtree/scalable_data.py +255 -0
  65. morphtree/scalable_receipts.py +92 -0
  66. morphtree/scalable_target.py +68 -0
  67. morphtree/scalable_variants.py +133 -0
  68. morphtree/sklearn_view.py +79 -0
  69. morphtree/source_markers.py +25 -0
  70. morphtree/species.py +47 -0
  71. morphtree/temporary.py +65 -0
  72. morphtree/training_fragment.py +130 -0
  73. morphtree/tree.py +231 -0
  74. morphtree/tuning.py +150 -0
  75. morphtree-2.1.0.dist-info/METADATA +212 -0
  76. morphtree-2.1.0.dist-info/RECORD +79 -0
  77. morphtree-2.1.0.dist-info/WHEEL +4 -0
  78. morphtree-2.1.0.dist-info/entry_points.txt +2 -0
  79. 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).