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/mutations.py ADDED
@@ -0,0 +1,205 @@
1
+ """Error-guided structural programs, novelty memory and local plateau protocol."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ import math
6
+ import time
7
+ import random
8
+
9
+ from .config import POLICIES
10
+ from .diagnostics import ErrorProfiler
11
+ from .metrics import logloss, paired_improvement
12
+ from .control import RunControl, SearchInterrupted
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Mutation:
17
+ genome: object
18
+ gene: str
19
+ reason: str
20
+ relevance: float
21
+
22
+
23
+ def propose_mutations(genome, profile):
24
+ strengths = {item["name"]: item["strength"] * item["confidence"] for item in profile["diagnoses"]}
25
+ mapping = {
26
+ "growth_policy": ("INTERACTION_LIKE_ERROR", "SMALL_UNSTABLE_REGION"),
27
+ "binning_policy": ("LOCAL_ERROR_CONCENTRATION", "SIGNED_BIAS"),
28
+ "missing_policy": ("MISSING_RELATED_ERROR",),
29
+ "leaf_policy": ("HIGH_UNCERTAINTY", "SIGNED_BIAS", "NOISE_LIKE"),
30
+ "regularization_policy": ("SMALL_UNSTABLE_REGION", "NOISE_LIKE"),
31
+ "sample_policy": ("PERSISTENT_ERROR", "IMBALANCE_RELATED_ERROR"),
32
+ "error_policy": ("PERSISTENT_ERROR", "IMBALANCE_RELATED_ERROR"),
33
+ "local_policy": ("LOCAL_ERROR_CONCENTRATION", "MISSING_RELATED_ERROR"),
34
+ "update_policy": ("NOISE_LIKE", "HIGH_RESIDUAL_VARIANCE"),
35
+ }
36
+ proposals = []
37
+ for gene, values in POLICIES.items():
38
+ diagnosis = max(mapping[gene], key=lambda key: strengths.get(key, 0))
39
+ relevance = 0.02 + strengths.get(diagnosis, 0)
40
+ for value in values:
41
+ if value != getattr(genome, gene):
42
+ proposals.append(Mutation(genome.mutate(**{gene: value}), gene,
43
+ f"Hypothesis {diagnosis}: change {gene} to {value}", relevance))
44
+ programs = [
45
+ ("OBJECTIVE_GAIN",),
46
+ ("OBJECTIVE_GAIN", "BALANCE", "MUL"),
47
+ ("OBJECTIVE_GAIN", "HESSIAN_BALANCE", "MUL"),
48
+ ("OBJECTIVE_GAIN", "RESIDUAL_VARIANCE_GAIN", "MUL_SLOT:0", "ADD"),
49
+ ("OBJECTIVE_GAIN", "UNCERTAINTY_GAIN", "MUL_SLOT:1", "ADD"),
50
+ ]
51
+ # Construct new bounded expressions from the incumbent, rather than only selecting templates.
52
+ for signal, slot in (("RESIDUAL_VARIANCE_GAIN", 0), ("UNCERTAINTY_GAIN", 1)):
53
+ if signal not in genome.split_program and len(genome.split_program) + 3 <= 15:
54
+ programs.append(genome.split_program + (signal, f"MUL_SLOT:{slot}", "ADD"))
55
+ if "BALANCE" not in genome.split_program and len(genome.split_program) + 2 <= 15:
56
+ programs.append(genome.split_program + ("BALANCE", "MUL"))
57
+ for program in programs:
58
+ if program != genome.split_program:
59
+ reason = "HIGH_RESIDUAL_VARIANCE" if "RESIDUAL_VARIANCE_GAIN" in program else "HIGH_UNCERTAINTY"
60
+ proposals.append(Mutation(genome.mutate(split_program=program), "split_program",
61
+ f"Hypothesis {reason}: construct bounded split program", 0.03 + strengths.get(reason, 0)))
62
+ unique = {p.genome.fingerprint(): p for p in proposals}
63
+ return sorted(unique.values(), key=lambda p: (-p.relevance, p.gene, p.genome.fingerprint()))
64
+
65
+
66
+ class StructuralSearch:
67
+ """A plateau is relative to the fully evaluated current neighborhood and fixed numbers."""
68
+ def __init__(self, config):
69
+ self.config = config
70
+ self.control = RunControl(timeout=config.search_timeout)
71
+
72
+ def run(self, initial, evaluate, X_train, y_train, X_search, y_search, X_guard, y_guard):
73
+ incumbent = initial
74
+ base_guard = logloss(y_guard, incumbent.predict_proba(X_guard))
75
+ history, profiles, seen = [], [], {}
76
+ final_coverage = {}
77
+ stalled = 0
78
+ stop_reason = "budget_exhausted"
79
+ plateau = False
80
+ for round_index in range(1, self.config.structural_rounds + 1):
81
+ try:
82
+ self.control.check()
83
+ except SearchInterrupted as exc:
84
+ stop_reason = exc.reason
85
+ break
86
+ genome = incumbent.genome
87
+ parent = genome.fingerprint()
88
+ old_search_p = incumbent.predict_proba(X_search)
89
+ old_search = logloss(y_search, old_search_p)
90
+ old_guard = logloss(y_guard, incumbent.predict_proba(X_guard))
91
+ if parent not in seen:
92
+ profile = ErrorProfiler().profile(X_train, y_train, incumbent.memory_, X_search, y_search, old_search_p)
93
+ proposals = propose_mutations(genome, profile)
94
+ if not self.config.diagnostic_guidance:
95
+ random.Random(str(self.config.seed) + parent).shuffle(proposals)
96
+ excluded = []
97
+ if incumbent.params.row_sample_count(len(y_train)) == len(y_train):
98
+ active = []
99
+ for proposal in proposals:
100
+ only_sampling = proposal.genome.mutate(sample_policy=genome.sample_policy) == genome
101
+ if proposal.gene == "sample_policy" and only_sampling:
102
+ excluded.append({"gene": proposal.gene, "fingerprint": proposal.genome.fingerprint(),
103
+ "reason": "all_training_rows_selected_at_fixed_parameters"})
104
+ else:
105
+ active.append(proposal)
106
+ proposals = active
107
+ seen[parent] = {"profile": profile, "proposals": proposals, "excluded": excluded,
108
+ "full": set(), "screened": set()}
109
+ profiles.append({"fingerprint": parent, "excluded_inactive_mutations": excluded, **profile})
110
+ state = seen[parent]
111
+ proposals = state["proposals"]
112
+ untried = [p for p in proposals if p.genome.fingerprint() not in state["full"] | state["screened"]]
113
+ # Screened candidates are deferred, not declared rejected. All must be fully checked for a plateau.
114
+ deferred = [p for p in proposals if p.genome.fingerprint() in state["screened"]]
115
+ queue = untried if untried else deferred
116
+ width = self.config.structural_mutants
117
+ if state["profile"]["confidence"] < 0.5:
118
+ width = min(width, 2)
119
+ candidates = queue[:width]
120
+ if not candidates:
121
+ plateau, stop_reason = True, "verified_local_plateau"
122
+ final_coverage = {"available": len(proposals), "fully_evaluated": len(state["full"]), "screened_pending": 0,
123
+ "excluded_inactive": state["excluded"]}
124
+ break
125
+ winner = None
126
+ for proposal in candidates:
127
+ if self.config.structural_evaluations is not None and len(history) >= self.config.structural_evaluations:
128
+ break
129
+ candidate_start = time.perf_counter()
130
+ child_fp = proposal.genome.fingerprint()
131
+ is_confirmation = child_fp in state["screened"]
132
+ probe_step = max(2, int(self.config.probe_fraction * incumbent.params.n_estimators))
133
+ can_probe = not is_confirmation and self.config.probe_fraction > 0 and probe_step < incumbent.params.n_estimators
134
+ did_screen = False
135
+
136
+ def probe(iteration, loss):
137
+ nonlocal did_screen
138
+ self.control.check()
139
+ if can_probe and iteration == probe_step and len(incumbent.search_curve_) >= iteration:
140
+ if loss > incumbent.search_curve_[iteration - 1] + self.config.probe_margin:
141
+ did_screen = True
142
+ return False
143
+ return True
144
+
145
+ try:
146
+ self.control.check()
147
+ child = evaluate(proposal.genome, incumbent.params, callback=probe)
148
+ except SearchInterrupted as exc:
149
+ stop_reason = exc.reason
150
+ history.append({'round': round_index, 'parent_fingerprint': parent, 'child_fingerprint': child_fp,
151
+ 'gene': proposal.gene, 'reason': proposal.reason, 'stage': 'interrupted',
152
+ 'decision': exc.reason, 'accepted': False,
153
+ 'cost_seconds': time.perf_counter() - candidate_start})
154
+ break
155
+ record = {"round": round_index, "parent_fingerprint": parent, "child_fingerprint": child_fp,
156
+ "gene": proposal.gene, "reason": proposal.reason, "relevance": proposal.relevance,
157
+ "stage": "confirmation" if is_confirmation else "full", "accepted": False,
158
+ "params_unchanged": child.params == initial.params}
159
+ record['cost_seconds'] = time.perf_counter() - candidate_start
160
+ record['trees_trained'] = len(child.trees) if hasattr(child, 'trees') else None
161
+ if did_screen:
162
+ state["screened"].add(child_fp)
163
+ record.update(stage="cheap_probe", search_loss=child.search_curve_[-1], guard_loss=None,
164
+ decision="deferred_for_full_check")
165
+ history.append(record)
166
+ continue
167
+ state["screened"].discard(child_fp)
168
+ state["full"].add(child_fp)
169
+ child_p = child.predict_proba(X_search)
170
+ search_loss = logloss(y_search, child_p)
171
+ guard_loss = logloss(y_guard, child.predict_proba(X_guard))
172
+ improvement, lower, se = paired_improvement(y_search, old_search_p, child_p, self.config.confidence_z)
173
+ penalty = self.config.complexity_penalty * max(0, proposal.genome.complexity() - genome.complexity())
174
+ threshold = self.config.min_morph_improvement + penalty
175
+ qualifies = lower > threshold and guard_loss <= min(old_guard + self.config.guard_tolerance,
176
+ base_guard + self.config.global_guard_tolerance)
177
+ record.update(search_loss=search_loss, guard_loss=guard_loss, search_delta=improvement,
178
+ lower_gain=lower, gain_standard_error=se, guard_delta=old_guard - guard_loss,
179
+ decision="eligible_not_selected" if qualifies else "rejected")
180
+ history.append(record)
181
+ if qualifies and (winner is None or search_loss + penalty < winner[0]):
182
+ winner = (search_loss + penalty, child, record)
183
+ if winner:
184
+ incumbent = winner[1]
185
+ winner[2].update(accepted=True, decision="committed")
186
+ stalled = 0
187
+ else:
188
+ stalled += 1
189
+ final_coverage = {"available": len(proposals), "fully_evaluated": len(state["full"]),
190
+ "screened_pending": len(state["screened"]), "excluded_inactive": state["excluded"]}
191
+ if stop_reason in ('cancelled', 'timeout'):
192
+ break
193
+ exhausted = len(state["full"]) == len(proposals) and not state["screened"]
194
+ required_stalls = min(self.config.structural_patience, max(1, math.ceil(len(proposals) / width)))
195
+ if winner is None and exhausted and stalled >= required_stalls:
196
+ plateau, stop_reason = True, "verified_local_plateau"
197
+ break
198
+ if self.config.structural_evaluations is not None and len(history) >= self.config.structural_evaluations:
199
+ break
200
+ if not plateau and history and history[-1]["parent_fingerprint"] != incumbent.genome.fingerprint():
201
+ final_coverage = {"available": None, "fully_evaluated": 0, "screened_pending": 0,
202
+ "note": "new incumbent has not yet been explored"}
203
+ return incumbent, {"stop_reason": stop_reason, "plateau_reached": plateau, "history": history,
204
+ "profiles": profiles, "coverage": final_coverage,
205
+ "scope": "active one-step neighborhood; reported no-op exclusions; fixed HyperParams; current TRAIN/SEARCH/GUARD"}
@@ -0,0 +1,79 @@
1
+ """Integrated native execution with unchanged public estimator signatures."""
2
+ from contextlib import contextmanager
3
+ from contextvars import ContextVar
4
+ import time
5
+ from .scalable_backend import ScalableBackend
6
+ from ._native.buffers import UnsupportedBuffers
7
+
8
+ _ACTIVE=ContextVar('morphtree_native_lab_backend',default=None);_PYTHON=object()
9
+
10
+
11
+ class NativeSession:
12
+ def __init__(self,*,histogram_only=False):
13
+ from ._native.loader import get_runtime,runtime_info
14
+ self.kernel=get_runtime();self.info=runtime_info();self.binary_sha256=self.info['binary_sha256'];self.histogram_only=histogram_only
15
+ self.calls=0;self.fallback_calls=0;self.indices_copy_bytes=0;self.seconds=0.;self.traversal_calls=0;self.small_batch_python_calls=0;self.traversal_fallback_calls=0
16
+ def create(self,nominal_sizes):return (NativeHistogramBackend if self.histogram_only else NativeBackend)(self,nominal_sizes)
17
+ def report(self):
18
+ return dict(backend='MORPHTREE_INTEGRATED_NATIVE_ABI1',binary_sha256=self.binary_sha256,
19
+ histogram_calls=self.calls,fallback_calls=self.fallback_calls,
20
+ indices_copy_bytes=self.indices_copy_bytes,histogram_seconds_including_bridge=self.seconds,
21
+ traversal_calls=self.traversal_calls,small_batch_python_calls=self.small_batch_python_calls,
22
+ traversal_fallback_calls=self.traversal_fallback_calls,
23
+ scope='Current process execution telemetry,not restored historical fit cost',runtime={**self.info,'cache':self.kernel.trees.report()})
24
+
25
+
26
+ class NativeHistogramBackend(ScalableBackend):
27
+ name='morphtree-native-histogram'
28
+ def __init__(self,session,nominal_sizes=None):super().__init__(nominal_sizes);self.session=session
29
+ def histogram(self,column,indices,g,h,r,u,size):
30
+ tick=time.perf_counter()
31
+ try:
32
+ result,copied=self.session.kernel.histogram(column,indices,g,h,r,u,size)
33
+ except UnsupportedBuffers:
34
+ self.session.fallback_calls+=1
35
+ return super().histogram(column,indices,g,h,r,u,size)
36
+ # Native rejection/error is not a fallback:it must stop qualification.
37
+ self.session.calls+=1;self.session.seconds+=time.perf_counter()-tick
38
+ self.session.indices_copy_bytes+=copied
39
+ return result
40
+
41
+
42
+ class NativeBackend(NativeHistogramBackend):
43
+ name='morphtree-integrated-native'
44
+ def predict_tree(self,nodes,columns):
45
+ # INTEGRATED013:Python wins at1/8/32rows,C wins from128rows.
46
+ # Equivalent execution route only;no row limit or sampling.
47
+ if columns and len(columns[0])<128:
48
+ self.session.small_batch_python_calls+=1;return ScalableBackend.predict_tree(self,nodes,columns)
49
+ try:result=self.session.kernel.predict_tree(nodes,columns)
50
+ except UnsupportedBuffers:
51
+ self.session.traversal_fallback_calls+=1;return ScalableBackend.predict_tree(self,nodes,columns)
52
+ self.session.traversal_calls+=1;return result
53
+
54
+ def bind_backend():
55
+ """One provider captured per fit/load/export;verified native use is automatic."""
56
+ active=_ACTIVE.get()
57
+ if active is _PYTHON:return ScalableBackend
58
+ return (NativeSession() if active is None else active).create
59
+
60
+
61
+ @contextmanager
62
+ def _scope(value):
63
+ if _ACTIVE.get() is not None:raise RuntimeError('Nested native backend scopes are not supported')
64
+ token=_ACTIVE.set(value)
65
+ try:yield value
66
+ finally:_ACTIVE.reset(token)
67
+
68
+ @contextmanager
69
+ def python_backend_scope():
70
+ """Explicit reference route for qualification,not a public estimator knob."""
71
+ with _scope(_PYTHON):yield
72
+
73
+ @contextmanager
74
+ def native_histogram_scope():
75
+ with _scope(NativeSession(histogram_only=True)) as value:yield value
76
+
77
+ @contextmanager
78
+ def native_backend_scope():
79
+ with _scope(NativeSession()) as value:yield value
@@ -0,0 +1,180 @@
1
+ """NumPy kernels only. Training still uses the native MorphTree tree builder."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ import math
6
+ from .backend import PythonBackend
7
+ from .metrics import sigmoid
8
+
9
+ _FLOAT64_EPS = float(np.finfo(np.float64).eps)
10
+
11
+
12
+ class NumpyBackend(PythonBackend):
13
+ name = "numpy"
14
+
15
+ def __init__(self):
16
+ self.split_calls = 0
17
+ self.canonical_split_fallbacks = 0
18
+
19
+ def bin(self, binner, X):
20
+ matrix = np.asarray(X, dtype=np.float64)
21
+ out = np.empty((len(binner.thresholds), len(X)), dtype=np.uint8 if binner.max_bins <= 255 else np.uint16)
22
+ for j, cuts in enumerate(binner.thresholds):
23
+ values = matrix[:, j]
24
+ out[j] = np.where(np.isnan(values), 0, np.searchsorted(cuts, values, side="right") + 1)
25
+ return out
26
+
27
+ def gradients(self, raw, y):
28
+ values = np.asarray(raw, dtype=np.float64)
29
+ probabilities = np.fromiter((sigmoid(float(v)) for v in values), dtype=np.float64, count=len(values))
30
+ return probabilities.tolist(), probabilities - np.asarray(y), np.maximum(probabilities * (1 - probabilities), 1e-12)
31
+
32
+ def probabilities(self, raw):
33
+ return [sigmoid(float(v)) for v in raw]
34
+
35
+ def regression_gradients(self, raw, y):
36
+ values = np.asarray(raw, dtype=np.float64)
37
+ return values.tolist(), values - np.asarray(y, dtype=np.float64), np.ones(len(y), dtype=np.float64)
38
+
39
+ def multiclass_raw(self, prior, n):
40
+ return np.tile(np.asarray(prior, dtype=np.float64), (n, 1))
41
+
42
+ def multiclass_gradient_column(self, probabilities, y, column, strategy):
43
+ values = np.fromiter((row[column] for row in probabilities), dtype=np.float64, count=len(y))
44
+ factor = 2. if strategy == 'softmax' else 1.
45
+ return values - (np.asarray(y) == column), np.maximum(factor*values*(1-values), 1e-12)
46
+
47
+ def multiclass_update(self, raw, column, step, prediction):
48
+ raw[:, column] += step * np.asarray(prediction)
49
+
50
+ def node_statistics(self, Xb, ids, g, h, u, features, need_missing, need_uncertainty):
51
+ indices = np.asarray(ids, dtype=np.intp)
52
+ missing = float(np.mean(np.any(Xb[np.ix_(features, indices)] == 0, axis=0))) if need_missing else 0.0
53
+ # Gather in C, then keep the reference fsum on the same ordered floats.
54
+ uncertainty = math.fsum(np.asarray(u)[indices].tolist()) / max(len(ids), 1) if need_uncertainty else 0.0
55
+ return math.fsum(np.asarray(g)[indices].tolist()), math.fsum(np.asarray(h)[indices].tolist()), uncertainty, missing
56
+
57
+ def histogram(self, column, indices, g, h, r, u, size):
58
+ ids = np.asarray(indices, dtype=np.intp)
59
+ bins = column[ids]
60
+ return np.vstack((np.bincount(bins, minlength=size),
61
+ *(np.bincount(bins, weights=np.asarray(values)[ids], minlength=size) for values in (g, h, r, u))))
62
+
63
+ def subtract(self, parent, child):
64
+ out = parent - child
65
+ out[0] = np.maximum(out[0], 0)
66
+ out[2] = np.maximum(out[2], 0)
67
+ return out
68
+
69
+ def cumulative(self, histogram):
70
+ return np.cumsum(histogram[:, 1:], axis=1)
71
+
72
+ def partition(self, column, ids, cut, missing_left):
73
+ indices = np.asarray(ids, dtype=np.intp)
74
+ bins = column[indices]
75
+ mask = np.where(bins == 0, missing_left, bins <= cut)
76
+ return indices[mask].tolist(), indices[~mask].tolist()
77
+
78
+ def add_update(self, raw, step, prediction):
79
+ return np.asarray(raw, dtype=np.float64) + step * prediction
80
+
81
+ def predict_tree(self, nodes, Xb):
82
+ # Tiny batches spend more time allocating vector queues than traversing.
83
+ # The reference walk preserves missing routes and cycle bounds exactly.
84
+ if Xb.shape[1] <= 16:
85
+ return np.asarray(PythonBackend.predict_tree(self, nodes, Xb), dtype=np.float64)
86
+ out = np.empty(Xb.shape[1], dtype=np.float64)
87
+ queue = [(0, np.arange(Xb.shape[1]))]
88
+ visits = 0
89
+ while queue:
90
+ index, rows = queue.pop()
91
+ visits += 1
92
+ if visits > len(nodes):
93
+ raise ValueError("Invalid cyclic tree")
94
+ if not len(rows):
95
+ continue
96
+ node = nodes[index]
97
+ if node.is_leaf:
98
+ out[rows] = node.value
99
+ continue
100
+ bins = Xb[node.feature, rows]
101
+ mask = np.where(bins == 0, node.missing_left, bins <= node.threshold_bin)
102
+ if mask.any():
103
+ queue.append((node.left, rows[mask]))
104
+ if (~mask).any():
105
+ queue.append((node.right, rows[~mask]))
106
+ return out
107
+
108
+ def fast_split(self, hist, total, depth, genome, params):
109
+ self.split_calls += 1
110
+ directions = np.asarray((True, False) if genome.missing_policy == "learned" else (genome.missing_policy == "left",))
111
+ if len(directions) == 2 and not hist[:, 0].any():
112
+ # Both routes are exactly identical; retain the core's first tie.
113
+ directions = directions[:1]
114
+ cumulative = np.column_stack((np.zeros(5), np.cumsum(hist[:, 1:], axis=1)))
115
+ left = cumulative[:, :, None] + hist[:, 0, None, None] * directions[None, None, :]
116
+ right = np.asarray(total)[:, None, None] - left
117
+ ln, lg, lh, lr, lu = left
118
+ rn, rg, rh, rr, ru = right
119
+ n, G, H, R, U = total
120
+ valid = (ln >= params.min_samples_leaf) & (rn >= params.min_samples_leaf) & (lh >= params.min_hessian) & (rh >= params.min_hessian)
121
+ if not valid.any():
122
+ return None
123
+ factor = params.depth_reg_strength if genome.regularization_policy == "depth_scaled" else 0.0
124
+ l2_child = params.l2 * (1 + factor * (depth + 1))
125
+ l2_parent = params.l2 * (1 + factor * depth)
126
+ score_left = np.maximum(np.abs(lg) - params.l1, 0) ** 2 / np.maximum(lh + l2_child, 1e-12)
127
+ score_right = np.maximum(np.abs(rg) - params.l1, 0) ** 2 / np.maximum(rh + l2_child, 1e-12)
128
+ parent = max(abs(G) - params.l1, 0) ** 2 / max(H + l2_parent, 1e-12)
129
+ # Programs usually consume one signal; do not allocate unused arrays.
130
+ program = genome.split_program
131
+ signals = {"OBJECTIVE_GAIN": 0.5 * (score_left + score_right - parent)}
132
+ if 'RESIDUAL_VARIANCE_GAIN' in program or 'UNCERTAINTY_GAIN' in program:
133
+ safe_ln, safe_rn = np.maximum(ln, 1), np.maximum(rn, 1)
134
+ if 'RESIDUAL_VARIANCE_GAIN' in program:
135
+ signals['RESIDUAL_VARIANCE_GAIN'] = np.maximum(0, lr * lr / safe_ln + rr * rr / safe_rn - R * R / n)
136
+ if 'BALANCE' in program:
137
+ signals['BALANCE'] = 2 * np.minimum(ln, rn) / n
138
+ if 'HESSIAN_BALANCE' in program:
139
+ signals['HESSIAN_BALANCE'] = 2 * np.minimum(lh, rh) / max(H, 1e-12)
140
+ if 'UNCERTAINTY_GAIN' in program:
141
+ signals['UNCERTAINTY_GAIN'] = (lu / safe_ln - ru / safe_rn) ** 2 * ln * rn / n
142
+ stack = []
143
+ for token in genome.split_program:
144
+ if token in signals:
145
+ stack.append(signals[token])
146
+ elif token.startswith("MUL_SLOT:"):
147
+ stack[-1] = stack[-1] * getattr(params, f"signal_slot_{token[-1]}")
148
+ else:
149
+ b, a = stack.pop(), stack.pop()
150
+ if token == "ADD":
151
+ stack.append(a + b)
152
+ elif token == "MUL":
153
+ stack.append(a * b)
154
+ elif token == "MIN":
155
+ stack.append(np.minimum(a, b))
156
+ else:
157
+ stack.append(np.maximum(a, b))
158
+ finite_valid = valid & np.isfinite(stack[0])
159
+ if not finite_valid.any():
160
+ return None
161
+ maximum = float(np.max(stack[0][finite_valid]))
162
+ # Conservative error envelope: near admission or a rounding boundary,
163
+ # the core evaluates the SAME histogram with canonical scalar operations.
164
+ # This does not change min_gain or the probability tolerance.
165
+ scale = max(1., abs(parent), float(score_left.max()), float(score_right.max()), abs(maximum))
166
+ error = 64 * _FLOAT64_EPS * scale
167
+ contenders = stack[0][finite_valid & (stack[0] >= maximum - 1e-12 - error)]
168
+ rounding_ambiguous = len(contenders) > 1 and float(contenders.min()) != maximum and np.any(
169
+ np.abs((contenders * 1e12) % 1 - .5) * 1e-12 <= error)
170
+ if abs(maximum - params.min_gain) <= error or rounding_ambiguous:
171
+ self.canonical_split_fallbacks += 1
172
+ return NotImplemented
173
+ gains = np.where(finite_valid & (stack[0] > params.min_gain), stack[0], -np.inf)
174
+ # Same 12-decimal score ordering and first-in-order tie rule as the core.
175
+ flat = int(np.argmax(np.round(gains, 12)))
176
+ cut, direction_index = np.unravel_index(flat, gains.shape)
177
+ gain = float(gains[cut, direction_index])
178
+ if gain <= params.min_gain:
179
+ return None
180
+ return gain, int(cut), bool(directions[direction_index])
@@ -0,0 +1,154 @@
1
+ """Optional Optuna TPE, pruning, conditional search and context-checked resume."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import asdict, replace
5
+ import math
6
+
7
+ from .config import VERSION, canonical_hash
8
+ from .tuning import bounded_parameter, decide_tuned_candidate, prediction_loss, tunable_parameters, tuning_record
9
+ from .control import RunControl, SearchInterrupted
10
+
11
+
12
+ def sampler_options(config):
13
+ # Pin options whose defaults differ between Optuna 4 and 5.
14
+ return dict(n_startup_trials=config.optuna_startup_trials, multivariate=False,
15
+ constant_liar=False, n_ei_candidates=24)
16
+
17
+
18
+ def study_context(base, config, dataset, base_guard_limit):
19
+ import optuna
20
+ return canonical_hash({'dataset': dataset, 'genome': base.genome.to_dict(),
21
+ 'parameters': asdict(base.params), 'seed': config.seed,
22
+ 'early_stopping': config.early_stopping_rounds, 'version': VERSION,
23
+ 'metric': 'regression_rmse_v1' if getattr(base, 'objective', 'binary_logloss') == 'squared_error' else 'binary_logloss_v1',
24
+ 'search_space': {'version': 1, 'active': tunable_parameters(base.genome)},
25
+ 'guard_tolerance': config.guard_tolerance, 'global_guard_tolerance': config.global_guard_tolerance,
26
+ 'base_guard_limit': base_guard_limit, 'sampler': {'name': 'TPE', **sampler_options(config)},
27
+ 'optuna_major': int(optuna.__version__.split('.')[0])})
28
+
29
+
30
+ class OptunaTuner:
31
+ def __init__(self):
32
+ self.study = None
33
+ self.context = None
34
+
35
+ def optimize(self, base, evaluate, X_search, y_search, X_guard, y_guard, config, base_guard_limit):
36
+ try:
37
+ import optuna
38
+ except ImportError as exc:
39
+ raise ImportError("Optuna is optional: install morphtree[optuna] or choose tuner='none'") from exc
40
+ before = prediction_loss(base, X_search, y_search)
41
+ guard_before = prediction_loss(base, X_guard, y_guard)
42
+ record = tuning_record(base.genome, before, guard_before)
43
+ record["backend"] = "optuna"
44
+ # Includes the runtime version, exact TRAIN/SEARCH contents, genome, baseline numbers and seed.
45
+ context = study_context(base, config, self.context, base_guard_limit)
46
+ control = RunControl(getattr(self, 'cancel', None), config.tune_timeout)
47
+ storage = None
48
+ try:
49
+ if config.optuna_storage is not None:
50
+ from sqlalchemy.pool import NullPool
51
+ storage = optuna.storages.RDBStorage(config.optuna_storage, engine_kwargs={"poolclass": NullPool})
52
+ self.study = optuna.create_study(direction="minimize", sampler=optuna.samplers.TPESampler(seed=config.seed, **sampler_options(config)),
53
+ pruner=optuna.pruners.MedianPruner(n_startup_trials=3, n_warmup_steps=5),
54
+ storage=storage, study_name=config.optuna_study_name or "MorphTree-" + context[:20],
55
+ load_if_exists=config.optuna_storage is not None)
56
+ except Exception:
57
+ if storage is not None:
58
+ storage.remove_session()
59
+ raise RuntimeError('Unable to initialize Optuna storage or study; check private runtime settings') from None
60
+ stored_context = self.study.user_attrs.get("morphtree_context")
61
+ if stored_context is not None and stored_context != context:
62
+ if storage is not None:
63
+ storage.remove_session()
64
+ raise ValueError("Optuna study context mismatch: do not mix runtime versions, datasets, genomes or "
65
+ "baseline parameters; use a new study name")
66
+ if self.study.trials and stored_context is None:
67
+ if storage is not None:
68
+ storage.remove_session()
69
+ raise ValueError("Refusing to reuse an existing study without MorphTree context")
70
+ self.study.set_user_attr("morphtree_context", context)
71
+ self.study.set_user_attr("structural_fingerprint", base.genome.fingerprint())
72
+ active = tunable_parameters(base.genome)
73
+
74
+ def objective(trial):
75
+ control.check()
76
+ values = {}
77
+ for name in active:
78
+ initial = getattr(base.params, name)
79
+ if name in ("n_estimators", "max_leaves", "min_samples_leaf"):
80
+ low = bounded_parameter(name, initial * 0.5)
81
+ high = bounded_parameter(name, initial * 1.6)
82
+ values[name] = trial.suggest_int(name, low, max(low, high))
83
+ else:
84
+ low = bounded_parameter(name, max(initial, 0.01) * 0.5)
85
+ high = bounded_parameter(name, max(initial, 0.01) * 1.8)
86
+ values[name] = trial.suggest_float(name, low, max(low, high), log=low > 0)
87
+ params = replace(base.params, **values)
88
+
89
+ def callback(iteration, loss):
90
+ control.check()
91
+ trial.report(loss, iteration)
92
+ if trial.should_prune():
93
+ raise optuna.TrialPruned()
94
+ return True
95
+
96
+ candidate = evaluate(base.genome, params, callback=callback, early_stopping_rounds=config.early_stopping_rounds)
97
+ if len(candidate.trees) < params.n_estimators:
98
+ params = replace(params, n_estimators=len(candidate.trees))
99
+ candidate = evaluate(base.genome, params, callback=lambda iteration, loss: control.check())
100
+ if candidate.genome.fingerprint() != record["frozen_fingerprint"]:
101
+ raise RuntimeError("Optuna attempted to change the frozen genome")
102
+ trial.set_user_attr("effective_parameters", asdict(params))
103
+ loss = prediction_loss(candidate, X_search, y_search)
104
+ guard_loss = prediction_loss(candidate, X_guard, y_guard)
105
+ if not math.isfinite(loss) or not math.isfinite(guard_loss):
106
+ raise ArithmeticError('Nonfinite trial metric')
107
+ feasible = guard_loss <= min(guard_before + config.guard_tolerance,
108
+ base_guard_limit + config.global_guard_tolerance)
109
+ trial.set_user_attr('guard_loss', guard_loss)
110
+ trial.set_user_attr('feasible', feasible)
111
+ return loss
112
+
113
+ first_new_trial = len(self.study.trials)
114
+ if first_new_trial:
115
+ # RNG state is not persisted by Optuna. Avoid replaying identical startup proposals after reopening.
116
+ self.study.sampler = optuna.samplers.TPESampler(seed=(config.seed + first_new_trial) % (2**32), **sampler_options(config))
117
+ try:
118
+ self.study.optimize(objective, n_trials=config.tune_trials, timeout=config.tune_timeout, n_jobs=1,
119
+ show_progress_bar=False, catch=(ArithmeticError, ValueError))
120
+ control.check()
121
+ except SearchInterrupted as exc:
122
+ record['stop_reason'] = exc.reason
123
+ complete = [trial for trial in self.study.trials if trial.state == optuna.trial.TrialState.COMPLETE
124
+ and trial.value is not None and math.isfinite(trial.value) and "effective_parameters" in trial.user_attrs]
125
+ feasible = [trial for trial in complete if trial.user_attrs.get('feasible') is True]
126
+ record["trials"] = [{"index": trial.number, "state": trial.state.name, "search_loss": trial.value,
127
+ "parameters": trial.params, 'guard_loss': trial.user_attrs.get('guard_loss'),
128
+ 'feasible': trial.user_attrs.get('feasible', False)} for trial in self.study.trials]
129
+ record["new_trials"] = len(self.study.trials) - first_new_trial
130
+ record['sampler'] = {'name': 'TPE', **sampler_options(config)}
131
+ record['sampling_note'] = 'Startup uses random sampling; requested trials do not prove adaptive execution'
132
+ record["study_context"] = context
133
+ record["completed_trials"] = len(complete)
134
+ record['feasible_trials'] = len(feasible)
135
+ if feasible and 'stop_reason' not in record:
136
+ best_trial = min(feasible, key=lambda trial: trial.value)
137
+ try:
138
+ control.check()
139
+ candidate = evaluate(base.genome, replace(base.params, **best_trial.user_attrs["effective_parameters"]),
140
+ callback=lambda iteration, loss: control.check())
141
+ except SearchInterrupted as exc:
142
+ record['stop_reason'] = exc.reason
143
+ candidate = base
144
+ else:
145
+ candidate = base
146
+ record["reason"] = "no_completed_trials"
147
+ selected, record = decide_tuned_candidate(base, candidate, record, X_search, y_search, X_guard, y_guard, config, base_guard_limit)
148
+ if not complete:
149
+ record.update(completed=False, accepted=False, reason="no_completed_trials")
150
+ elif not feasible:
151
+ record.update(completed=True, accepted=False, reason='no_feasible_trials')
152
+ if storage is not None:
153
+ storage.remove_session()
154
+ return selected, record