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/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
|