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/model.py
ADDED
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
"""Native Base classifier; optional numeric tuning and experimental discovery."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import asdict, dataclass, fields, replace
|
|
5
|
+
import copy
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import platform
|
|
10
|
+
import struct
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from .backend import PythonBackend
|
|
14
|
+
from .booster import BinaryHistogramBooster, TrainingCache
|
|
15
|
+
from .config import AlgorithmGenome, HyperParams, MorphTreeConfig, SPEC_VERSION, VERSION, validate_task_genome, decode_task_genome, TASK_GROWTH_FORMATS
|
|
16
|
+
from .data import get_feature_names, split_indices, regression_split_indices, subset, validate_names, validate_X, validate_xy, validate_y, validate_regression_y, validate_multiclass_y
|
|
17
|
+
from .metrics import accuracy, logloss, regression_metrics, r2_score, multiclass_metrics
|
|
18
|
+
from .persistence import LoadLimits, decode_booster, decode_multiclass_booster, decode_species, integer, read_json, require_keys, write_json
|
|
19
|
+
from .species import AlgorithmSpecies
|
|
20
|
+
from .control import RunControl, SearchInterrupted
|
|
21
|
+
from .public import public_config, imported_report, imported_regression_report, imported_multiclass_report, recipe_settings, RECIPE_FIELDS
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class FitReport:
|
|
26
|
+
data: dict
|
|
27
|
+
|
|
28
|
+
def to_dict(self):
|
|
29
|
+
return copy.deepcopy(self.data)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def dataset_fingerprint(splits, *, task='binary'):
|
|
33
|
+
digest = hashlib.sha256()
|
|
34
|
+
if task == 'regression':
|
|
35
|
+
digest.update(b'MorphTree:regression:float64:v1')
|
|
36
|
+
elif task != 'binary':
|
|
37
|
+
raise ValueError('Unsupported fingerprint task')
|
|
38
|
+
for X, y in splits:
|
|
39
|
+
digest.update(struct.pack("!QQ", len(X), len(X[0])))
|
|
40
|
+
for row, label in zip(X, y):
|
|
41
|
+
for value in row:
|
|
42
|
+
digest.update(b"missing!" if math.isnan(value) else struct.pack("!d", value))
|
|
43
|
+
digest.update(struct.pack('!d', label) if task == 'regression' else bytes([label]))
|
|
44
|
+
return digest.hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MorphTreeClassifier:
|
|
48
|
+
"""Binary dense-numeric classifier. The learner is always MorphTree itself."""
|
|
49
|
+
def __init__(self, *, config=None, genome=None, params=None, backend='pure', tuner=None):
|
|
50
|
+
self.config = MorphTreeConfig() if config is None else config
|
|
51
|
+
if tuner is not None:
|
|
52
|
+
self.config = replace(self.config, tuner=tuner)
|
|
53
|
+
if backend not in ('pure', 'numpy'):
|
|
54
|
+
raise ValueError("backend must be pure or numpy")
|
|
55
|
+
self.backend = backend
|
|
56
|
+
self.base_genome = AlgorithmGenome() if genome is None else genome
|
|
57
|
+
self.base_params = HyperParams() if params is None else params
|
|
58
|
+
if not isinstance(self.config, MorphTreeConfig) or not isinstance(self.base_genome, AlgorithmGenome) or not isinstance(self.base_params, HyperParams):
|
|
59
|
+
raise ValueError("config, genome and params must use their corresponding MorphTree types")
|
|
60
|
+
self._validate_backend_config()
|
|
61
|
+
self.model_ = None
|
|
62
|
+
self.genome_, self.params_, self.report_ = None, None, None
|
|
63
|
+
self.n_features_in_, self.feature_names_in_ = None, None
|
|
64
|
+
self._algorithm_locked = False
|
|
65
|
+
self._training_partition = None
|
|
66
|
+
self.optuna_study_ = None
|
|
67
|
+
self.classes_ = (0, 1)
|
|
68
|
+
|
|
69
|
+
def _validate_backend_config(self):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
def _make_backend(self):
|
|
73
|
+
if self.backend == 'numpy':
|
|
74
|
+
from .numpy_backend import NumpyBackend
|
|
75
|
+
return NumpyBackend()
|
|
76
|
+
return PythonBackend()
|
|
77
|
+
|
|
78
|
+
def _make_tuner(self):
|
|
79
|
+
if self.config.tuner == 'optuna':
|
|
80
|
+
from .optuna_tuner import OptunaTuner
|
|
81
|
+
return OptunaTuner()
|
|
82
|
+
from .tuning import MorphTuner
|
|
83
|
+
return MorphTuner()
|
|
84
|
+
|
|
85
|
+
def fit(self, X, y, *, search=None, guard=None, feature_names=None, cancel=None):
|
|
86
|
+
start = time.perf_counter()
|
|
87
|
+
control = RunControl(cancel, self.config.search_timeout)
|
|
88
|
+
tuning_enabled = self.config.enable_tuning and self.config.tuner != 'none'
|
|
89
|
+
inferred = get_feature_names(X)
|
|
90
|
+
if feature_names is not None:
|
|
91
|
+
validate_names(feature_names, len(feature_names))
|
|
92
|
+
if inferred is not None and list(feature_names) != inferred:
|
|
93
|
+
raise ValueError("Explicit feature_names must match training DataFrame order")
|
|
94
|
+
names = inferred if feature_names is None else list(feature_names)
|
|
95
|
+
X, y = validate_xy(X, y)
|
|
96
|
+
width = len(X[0])
|
|
97
|
+
validate_names(names, width)
|
|
98
|
+
if width > LoadLimits().max_features:
|
|
99
|
+
raise ValueError("Feature count exceeds the supported model-format limit")
|
|
100
|
+
if (search is None) != (guard is None):
|
|
101
|
+
raise ValueError("Provide both SEARCH and GUARD, or neither")
|
|
102
|
+
if search is None and self._training_partition == 'explicit':
|
|
103
|
+
raise ValueError('This learning recipe requires explicit SEARCH and GUARD splits')
|
|
104
|
+
if search is None and (self._training_partition == 'automatic' or self.config.enable_morphing or tuning_enabled):
|
|
105
|
+
train_ids, search_ids, guard_ids = split_indices(y, self.config.search_fraction, self.config.guard_fraction, self.config.seed)
|
|
106
|
+
train, search_data, guard_data = [subset(X, y, ids) for ids in (train_ids, search_ids, guard_ids)]
|
|
107
|
+
split_kind = "automatic_stratified_disjoint"
|
|
108
|
+
full_X, full_y = X, y
|
|
109
|
+
elif search is not None:
|
|
110
|
+
if not isinstance(search, (tuple, list)) or len(search) != 2 or not isinstance(guard, (tuple, list)) or len(guard) != 2:
|
|
111
|
+
raise ValueError("SEARCH and GUARD must be (X, y) pairs")
|
|
112
|
+
train = (X, y)
|
|
113
|
+
search_data = validate_xy(*search, width, names)
|
|
114
|
+
guard_data = validate_xy(*guard, width, names)
|
|
115
|
+
full_X, full_y = X + search_data[0] + guard_data[0], y + search_data[1] + guard_data[1]
|
|
116
|
+
split_kind = "explicit_caller_responsible_for_disjointness"
|
|
117
|
+
else:
|
|
118
|
+
# Pure baseline needs no automatic data split. These losses are training diagnostics only.
|
|
119
|
+
train = search_data = guard_data = (X, y)
|
|
120
|
+
full_X, full_y = X, y
|
|
121
|
+
split_kind = "baseline_no_selection_metrics_are_training_only"
|
|
122
|
+
X_train, y_train = train
|
|
123
|
+
X_search, y_search = search_data
|
|
124
|
+
X_guard, y_guard = guard_data
|
|
125
|
+
backend = self._make_backend()
|
|
126
|
+
cache = TrainingCache(X_train, X_search, backend)
|
|
127
|
+
|
|
128
|
+
def evaluate(genome, params, *, callback=None, early_stopping_rounds=0):
|
|
129
|
+
return BinaryHistogramBooster(genome, params, self.config.seed, backend, memory_enabled=self.config.memory_enabled,
|
|
130
|
+
collect_diagnostics=self.config.enable_morphing and not self._algorithm_locked).fit(
|
|
131
|
+
X_train, y_train, eval_set=search_data, callback=callback, cache=cache,
|
|
132
|
+
early_stopping_rounds=early_stopping_rounds)
|
|
133
|
+
|
|
134
|
+
base_start = time.perf_counter()
|
|
135
|
+
def base_callback(iteration, loss):
|
|
136
|
+
if cancel is not None and cancel():
|
|
137
|
+
raise SearchInterrupted('cancelled')
|
|
138
|
+
return True
|
|
139
|
+
try:
|
|
140
|
+
incumbent = evaluate(self.base_genome, self.base_params,
|
|
141
|
+
callback=base_callback if not self.config.enable_morphing and not tuning_enabled else None)
|
|
142
|
+
except SearchInterrupted as exc:
|
|
143
|
+
raise RuntimeError('Base training cancelled; fitted state was not changed') from exc
|
|
144
|
+
base_time = time.perf_counter() - base_start
|
|
145
|
+
base_search = logloss(y_search, incumbent.predict_proba(X_search))
|
|
146
|
+
base_guard = logloss(y_guard, incumbent.predict_proba(X_guard))
|
|
147
|
+
discovery_start = time.perf_counter()
|
|
148
|
+
if self.config.enable_morphing and not self._algorithm_locked:
|
|
149
|
+
from .mutations import StructuralSearch
|
|
150
|
+
searcher = StructuralSearch(self.config)
|
|
151
|
+
searcher.control = control
|
|
152
|
+
incumbent, structural = searcher.run(
|
|
153
|
+
incumbent, evaluate, X_train, y_train, X_search, y_search, X_guard, y_guard)
|
|
154
|
+
else:
|
|
155
|
+
structural = {"stop_reason": "frozen_species_reused" if self._algorithm_locked else "morphing_disabled",
|
|
156
|
+
"plateau_reached": False, "history": [], "profiles": [], "coverage": {},
|
|
157
|
+
"scope": "not searched"}
|
|
158
|
+
discovery_time = time.perf_counter() - discovery_start
|
|
159
|
+
structure_search = logloss(y_search, incumbent.predict_proba(X_search))
|
|
160
|
+
structure_guard = logloss(y_guard, incumbent.predict_proba(X_guard))
|
|
161
|
+
frozen = incumbent.genome.fingerprint()
|
|
162
|
+
tuning_start = time.perf_counter()
|
|
163
|
+
budget_freeze = self.config.freeze_on_budget and structural['stop_reason'] == 'budget_exhausted'
|
|
164
|
+
fixed_base = not self.config.enable_morphing
|
|
165
|
+
can_tune = structural["plateau_reached"] or self._algorithm_locked or budget_freeze or fixed_base
|
|
166
|
+
freeze_reason = ('budget_limited_freeze' if budget_freeze else 'explicit_freeze' if self._algorithm_locked
|
|
167
|
+
else 'fixed_configuration' if fixed_base else structural['stop_reason'])
|
|
168
|
+
tuning = {"attempted": False, "completed": False, "accepted": False, "trials": [],
|
|
169
|
+
"reason": "disabled" if not tuning_enabled else "no_verified_plateau", "frozen_fingerprint": frozen}
|
|
170
|
+
tuner = None
|
|
171
|
+
if tuning_enabled and can_tune:
|
|
172
|
+
tuner = self._make_tuner()
|
|
173
|
+
tuner.cancel = cancel
|
|
174
|
+
if hasattr(tuner, "context"):
|
|
175
|
+
tuner.context = {'data': dataset_fingerprint((train, search_data, guard_data)), 'schema': names,
|
|
176
|
+
'backend': self.backend, 'memory_enabled': self.config.memory_enabled}
|
|
177
|
+
incumbent, tuning = tuner.optimize(incumbent, evaluate, X_search, y_search, X_guard, y_guard, self.config, base_guard)
|
|
178
|
+
tuning_time = time.perf_counter() - tuning_start
|
|
179
|
+
if incumbent.genome.fingerprint() != frozen:
|
|
180
|
+
raise RuntimeError("Structural fingerprint changed after freeze")
|
|
181
|
+
final_search = logloss(y_search, incumbent.predict_proba(X_search))
|
|
182
|
+
final_guard = logloss(y_guard, incumbent.predict_proba(X_guard))
|
|
183
|
+
params, genome = incumbent.params, incumbent.genome
|
|
184
|
+
refit_start = time.perf_counter()
|
|
185
|
+
final_model = incumbent
|
|
186
|
+
cancelled = structural['stop_reason'] == 'cancelled' or tuning.get('stop_reason') == 'cancelled'
|
|
187
|
+
did_refit = self.config.refit_full and not cancelled and split_kind != "baseline_no_selection_metrics_are_training_only"
|
|
188
|
+
if did_refit:
|
|
189
|
+
refit_control = RunControl(cancel)
|
|
190
|
+
def refit_callback(iteration, loss):
|
|
191
|
+
refit_control.check()
|
|
192
|
+
return True
|
|
193
|
+
refit_control.check()
|
|
194
|
+
final_model = BinaryHistogramBooster(genome, params, self.config.seed, backend, memory_enabled=self.config.memory_enabled).fit(
|
|
195
|
+
full_X, full_y, callback=refit_callback)
|
|
196
|
+
refit_time = time.perf_counter() - refit_start
|
|
197
|
+
report = {
|
|
198
|
+
"version": VERSION, "spec_version": SPEC_VERSION, "python": platform.python_version(), "backend": backend.name,
|
|
199
|
+
"config": public_config(self.config), "stop_reason": structural["stop_reason"], "plateau_reached": structural["plateau_reached"],
|
|
200
|
+
"freeze_reason": freeze_reason,
|
|
201
|
+
"plateau_scope": structural["scope"], "coverage": structural["coverage"], "base_genome": self.base_genome.to_dict(),
|
|
202
|
+
"final_genome": genome.to_dict(), "final_fingerprint": frozen, "base_parameters": asdict(self.base_params),
|
|
203
|
+
"final_parameters": asdict(params), "active_parameters": list(genome.active_parameters()),
|
|
204
|
+
"base_search_loss": base_search, "base_guard_loss": base_guard, "structure_search_loss": structure_search,
|
|
205
|
+
"structure_guard_loss": structure_guard, "final_search_loss": final_search, "final_guard_loss": final_guard,
|
|
206
|
+
"structural_gain": base_search - structure_search, "tuning_gain": structure_search - final_search,
|
|
207
|
+
"morph_history": structural["history"], "error_profiles": structural["profiles"], "tuning": tuning,
|
|
208
|
+
"split": {"kind": split_kind, "train": len(X_train), "search": len(X_search), "guard": len(X_guard),
|
|
209
|
+
"sealed": "not accepted by fit; evaluate externally", "refit_rows": len(full_X) if did_refit else len(X_train)},
|
|
210
|
+
"feature_schema": {"count": width, "names": names}, "refit_full": did_refit,
|
|
211
|
+
"structural_sampling": {"row_fraction": self.base_params.row_fraction, "train_rows": len(y_train),
|
|
212
|
+
"sampled_rows": self.base_params.row_sample_count(len(y_train)),
|
|
213
|
+
"policy_active": self.base_params.row_sample_count(len(y_train)) < len(y_train),
|
|
214
|
+
"scope": "fixed throughout discovery; numerical tuning only after freeze"},
|
|
215
|
+
"metrics_scope": ("TRAIN diagnostics only; no validation performed" if split_kind == "baseline_no_selection_metrics_are_training_only"
|
|
216
|
+
else "before refit; SEARCH/GUARD are selection sets, not independent test results"),
|
|
217
|
+
"timings_seconds": {"base": base_time, "discovery": discovery_time, "tuning": tuning_time,
|
|
218
|
+
"refit": refit_time, "total_fit": time.perf_counter() - start},
|
|
219
|
+
}
|
|
220
|
+
# Commit fitted state only after the entire operation succeeds.
|
|
221
|
+
recipe = {name: getattr(self.config, name) for name in RECIPE_FIELDS}
|
|
222
|
+
recipe['refit_full'] = did_refit
|
|
223
|
+
recipe['partition'] = 'automatic' if split_kind.startswith('automatic') else ('explicit' if search is not None else 'all')
|
|
224
|
+
algorithm = AlgorithmSpecies(genome, params, {'learning_recipe': recipe})
|
|
225
|
+
self.model_, self.genome_, self.params_, self.report_ = final_model, genome, params, FitReport(report)
|
|
226
|
+
self.n_features_in_, self.feature_names_in_ = width, names
|
|
227
|
+
self.optuna_study_ = getattr(tuner, "study", None)
|
|
228
|
+
self.algorithm_ = algorithm
|
|
229
|
+
self.morph_history_ = copy.deepcopy(structural["history"])
|
|
230
|
+
return self
|
|
231
|
+
|
|
232
|
+
def _require_fitted(self):
|
|
233
|
+
if self.model_ is None:
|
|
234
|
+
raise RuntimeError("MorphTreeClassifier is not fitted")
|
|
235
|
+
|
|
236
|
+
def predict_proba(self, X):
|
|
237
|
+
self._require_fitted()
|
|
238
|
+
rows = validate_X(X, self.n_features_in_, self.feature_names_in_, allow_empty=True)
|
|
239
|
+
probabilities = self.model_.predict_proba(rows) if rows else []
|
|
240
|
+
return [[1 - p, p] for p in probabilities]
|
|
241
|
+
|
|
242
|
+
def predict(self, X):
|
|
243
|
+
return [int(row[1] >= 0.5) for row in self.predict_proba(X)]
|
|
244
|
+
|
|
245
|
+
def score(self, X, y):
|
|
246
|
+
probabilities = [row[1] for row in self.predict_proba(X)]
|
|
247
|
+
return accuracy(validate_y(y, len(probabilities)), probabilities)
|
|
248
|
+
|
|
249
|
+
def report(self):
|
|
250
|
+
self._require_fitted()
|
|
251
|
+
return self.report_.to_dict()
|
|
252
|
+
|
|
253
|
+
def inspect(self):
|
|
254
|
+
return self.report()
|
|
255
|
+
|
|
256
|
+
def inspect_algorithm(self):
|
|
257
|
+
self._require_fitted()
|
|
258
|
+
return self.algorithm_.to_dict()
|
|
259
|
+
|
|
260
|
+
def save_algorithm(self, path):
|
|
261
|
+
self._require_fitted()
|
|
262
|
+
self.algorithm_.save(path)
|
|
263
|
+
|
|
264
|
+
def save_model(self, path):
|
|
265
|
+
self._require_fitted()
|
|
266
|
+
write_json(path, {"format": "MorphTreeModel", "spec_version": SPEC_VERSION, "producer_version": VERSION,
|
|
267
|
+
"algorithm": self.algorithm_.to_dict(), "n_features": self.n_features_in_,
|
|
268
|
+
"feature_names": self.feature_names_in_, "booster": self.model_.to_dict(), "report": self.report()})
|
|
269
|
+
|
|
270
|
+
save = save_model
|
|
271
|
+
|
|
272
|
+
@classmethod
|
|
273
|
+
def load_model(cls, path, *, limits=None, backend='pure'):
|
|
274
|
+
return cls._from_payload(read_json(path, limits), limits=limits, backend=backend)
|
|
275
|
+
|
|
276
|
+
@classmethod
|
|
277
|
+
def _from_payload(cls, data, *, limits=None, backend='pure'):
|
|
278
|
+
limits = limits or LoadLimits()
|
|
279
|
+
require_keys(data, ("format", "spec_version", "producer_version", "algorithm", "n_features", "feature_names", "booster", "report"))
|
|
280
|
+
if data["format"] != "MorphTreeModel" or type(data["spec_version"]) is not int or data["spec_version"] != SPEC_VERSION:
|
|
281
|
+
raise ValueError("Unsupported model format/version")
|
|
282
|
+
genome, params, metadata = decode_species(data["algorithm"])
|
|
283
|
+
recipe = metadata.get('learning_recipe')
|
|
284
|
+
settings = recipe_settings(recipe)
|
|
285
|
+
if recipe is None:
|
|
286
|
+
settings = replace(settings, memory_enabled=True) # Legacy artifacts predate explicit recipes.
|
|
287
|
+
obj = cls(config=replace(settings, enable_morphing=False, enable_tuning=False), genome=genome, params=params, backend=backend)
|
|
288
|
+
count = integer(data["n_features"], "n_features", 1, limits.max_features)
|
|
289
|
+
validate_names(data["feature_names"], count)
|
|
290
|
+
obj.model_ = decode_booster(data["booster"], genome, params, obj._make_backend(), limits)
|
|
291
|
+
if obj.model_.n_features_ != count:
|
|
292
|
+
raise ValueError("Model and booster dimensions disagree")
|
|
293
|
+
if not isinstance(data["report"], dict):
|
|
294
|
+
raise ValueError("Model report must be an object")
|
|
295
|
+
obj.n_features_in_, obj.feature_names_in_ = count, data["feature_names"]
|
|
296
|
+
obj.genome_, obj.params_, obj.report_ = genome, params, FitReport(imported_report(data["report"]))
|
|
297
|
+
obj.algorithm_ = AlgorithmSpecies(genome, params, metadata)
|
|
298
|
+
obj.morph_history_ = []
|
|
299
|
+
obj._algorithm_locked = True
|
|
300
|
+
obj._training_partition = recipe['partition'] if recipe else None
|
|
301
|
+
return obj
|
|
302
|
+
|
|
303
|
+
load = load_model
|
|
304
|
+
|
|
305
|
+
@classmethod
|
|
306
|
+
def from_algorithm(cls, algorithm, *, retune=False, config=None, backend='pure', tuner=None):
|
|
307
|
+
species = algorithm if isinstance(algorithm, AlgorithmSpecies) else AlgorithmSpecies.load(algorithm)
|
|
308
|
+
if type(retune) is not bool:
|
|
309
|
+
raise ValueError("retune must be a bool")
|
|
310
|
+
recipe = species.metadata.get('learning_recipe')
|
|
311
|
+
settings = replace(recipe_settings(recipe, config), enable_morphing=False, enable_tuning=retune)
|
|
312
|
+
if recipe is None and config is None:
|
|
313
|
+
settings = replace(settings, memory_enabled=True)
|
|
314
|
+
if retune and (tuner or settings.tuner) == 'none':
|
|
315
|
+
raise ValueError("retune=True requires an explicit native or optuna tuner")
|
|
316
|
+
obj = cls(config=settings, genome=species.genome, params=species.parameters, backend=backend, tuner=tuner)
|
|
317
|
+
obj._algorithm_locked = True
|
|
318
|
+
obj._training_partition = recipe['partition'] if recipe else None
|
|
319
|
+
return obj
|
|
320
|
+
|
|
321
|
+
@classmethod
|
|
322
|
+
def discover(cls, X, y, *, config=None, params=None, genome=None, search=None, guard=None, backend='pure', tuner=None):
|
|
323
|
+
"""Explicit experimental discovery; no generalization advantage is promised."""
|
|
324
|
+
settings = replace(config or MorphTreeConfig(), enable_morphing=True, refit_full=False)
|
|
325
|
+
model = cls(config=settings, params=params, genome=genome, backend=backend, tuner=tuner).fit(X, y, search=search, guard=guard)
|
|
326
|
+
return AlgorithmSpecies(model.genome_, model.params_, {**model.algorithm_.metadata, "discovery_report": model.report()})
|
|
327
|
+
|
|
328
|
+
def export_algorithm(self, path):
|
|
329
|
+
self._require_fitted()
|
|
330
|
+
self.algorithm_.export_python(path)
|
|
331
|
+
|
|
332
|
+
def export_python(self, path):
|
|
333
|
+
self._require_fitted()
|
|
334
|
+
from .exporting import export_predictor
|
|
335
|
+
export_predictor(self, path)
|
|
336
|
+
|
|
337
|
+
def export_bundle(self, path):
|
|
338
|
+
self._require_fitted()
|
|
339
|
+
from .exporting import export_bundle
|
|
340
|
+
export_bundle(self, path)
|
|
341
|
+
|
|
342
|
+
def save_report(self, path):
|
|
343
|
+
from .exporting import report_markdown
|
|
344
|
+
from .persistence import atomic_text
|
|
345
|
+
if str(path).lower().endswith(".md"):
|
|
346
|
+
atomic_text(path, report_markdown(self.report()))
|
|
347
|
+
else:
|
|
348
|
+
write_json(path, self.report())
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class MorphTreeRegressor:
|
|
352
|
+
"""Fixed native Base with squared error; no classification/morphing wrapper.
|
|
353
|
+
|
|
354
|
+
This first development increment has no numeric tuner or structural discovery.
|
|
355
|
+
Validation or explicit Optuna TRAIN/SEARCH/GUARD selects numeric values only.
|
|
356
|
+
"""
|
|
357
|
+
def __init__(self, *, config=None, params=None, backend='pure', tuner=None, genome=None):
|
|
358
|
+
config = MorphTreeConfig() if config is None else config
|
|
359
|
+
params = HyperParams() if params is None else params
|
|
360
|
+
genome = AlgorithmGenome() if genome is None else genome
|
|
361
|
+
if not isinstance(config, MorphTreeConfig) or not isinstance(params, HyperParams) or not isinstance(genome, AlgorithmGenome):
|
|
362
|
+
raise ValueError('config, params and genome must use their MorphTree types')
|
|
363
|
+
if tuner is not None:
|
|
364
|
+
config = replace(config, tuner=tuner)
|
|
365
|
+
if backend not in ('pure', 'numpy'):
|
|
366
|
+
raise ValueError('backend must be pure or numpy')
|
|
367
|
+
validate_task_genome('regression',genome)
|
|
368
|
+
if config.enable_morphing or config.memory_enabled or config.diagnostic_guidance:
|
|
369
|
+
raise ValueError('Regression currently supports fixed Base without morphing, memory or guidance')
|
|
370
|
+
if config.tuner not in ('none', 'optuna'):
|
|
371
|
+
raise ValueError('Regression supports tuner=none or explicit tuner=optuna')
|
|
372
|
+
if config.search_timeout is not None:
|
|
373
|
+
raise ValueError('Regression search_timeout is not supported; no structural search runs')
|
|
374
|
+
self.config, self.base_params, self.base_genome = config, params, genome
|
|
375
|
+
self.backend, self.task = backend, 'regression'
|
|
376
|
+
self.model_, self.params_, self.genome_, self.report_ = None, None, None, None
|
|
377
|
+
self.n_features_in_, self.feature_names_in_ = None, None
|
|
378
|
+
self.optuna_study_ = None
|
|
379
|
+
|
|
380
|
+
def _make_backend(self):
|
|
381
|
+
if self.backend == 'numpy':
|
|
382
|
+
from .numpy_backend import NumpyBackend
|
|
383
|
+
return NumpyBackend()
|
|
384
|
+
return PythonBackend()
|
|
385
|
+
|
|
386
|
+
def fit(self, X, y, *, validation=None, search=None, guard=None, feature_names=None, cancel=None):
|
|
387
|
+
start = time.perf_counter()
|
|
388
|
+
control = RunControl(cancel)
|
|
389
|
+
inferred = get_feature_names(X)
|
|
390
|
+
if feature_names is not None:
|
|
391
|
+
validate_names(feature_names, len(feature_names))
|
|
392
|
+
if inferred is not None and list(feature_names) != inferred:
|
|
393
|
+
raise ValueError('Explicit feature_names must match training DataFrame order')
|
|
394
|
+
names = inferred if feature_names is None else list(feature_names)
|
|
395
|
+
rows = validate_X(X)
|
|
396
|
+
targets = validate_regression_y(y, len(rows))
|
|
397
|
+
width = len(rows[0])
|
|
398
|
+
validate_names(names, width)
|
|
399
|
+
limits = LoadLimits()
|
|
400
|
+
if width > limits.max_features:
|
|
401
|
+
raise ValueError('Feature count exceeds the supported model-format limit')
|
|
402
|
+
tuning_enabled = self.config.enable_tuning and self.config.tuner == 'optuna'
|
|
403
|
+
if (search is None) != (guard is None):
|
|
404
|
+
raise ValueError('Provide both SEARCH and GUARD, or neither')
|
|
405
|
+
if validation is not None and (tuning_enabled or search is not None):
|
|
406
|
+
raise ValueError('validation is exclusive with tuning/SEARCH/GUARD; never supply final test data')
|
|
407
|
+
def selection_pair(pair, label):
|
|
408
|
+
if not isinstance(pair, (tuple, list)) or len(pair) != 2:
|
|
409
|
+
raise ValueError(label + ' must be an (X, y) pair; never supply final test data')
|
|
410
|
+
values = validate_X(pair[0], width, names)
|
|
411
|
+
return values, validate_regression_y(pair[1], len(values))
|
|
412
|
+
|
|
413
|
+
full_rows, full_targets = rows, targets
|
|
414
|
+
search_data = guard_data = None
|
|
415
|
+
if search is not None:
|
|
416
|
+
search_data, guard_data = selection_pair(search, 'SEARCH'), selection_pair(guard, 'GUARD')
|
|
417
|
+
full_rows = rows + search_data[0] + guard_data[0]
|
|
418
|
+
full_targets = targets + search_data[1] + guard_data[1]
|
|
419
|
+
split_kind = 'explicit_caller_responsible_for_disjointness'
|
|
420
|
+
elif tuning_enabled:
|
|
421
|
+
partitions = regression_split_indices(len(rows), self.config.search_fraction, self.config.guard_fraction, self.config.seed)
|
|
422
|
+
(rows, targets), search_data, guard_data = [subset(rows, targets, ids) for ids in partitions]
|
|
423
|
+
split_kind = 'automatic_random_disjoint'
|
|
424
|
+
selected_validation = search_data
|
|
425
|
+
if validation is not None:
|
|
426
|
+
selected_validation = selection_pair(validation, 'validation')
|
|
427
|
+
full_rows, full_targets = rows + selected_validation[0], targets + selected_validation[1]
|
|
428
|
+
backend = self._make_backend()
|
|
429
|
+
cache = TrainingCache(rows, selected_validation[0], backend) if selected_validation else None
|
|
430
|
+
def callback(iteration, loss):
|
|
431
|
+
control.check()
|
|
432
|
+
return True
|
|
433
|
+
def evaluate(genome, params, *, callback=callback, early_stopping_rounds=0):
|
|
434
|
+
return BinaryHistogramBooster(genome, params, self.config.seed, backend,
|
|
435
|
+
memory_enabled=False, objective='squared_error').fit(rows, targets, eval_set=selected_validation,
|
|
436
|
+
early_stopping_rounds=early_stopping_rounds, callback=callback, cache=cache)
|
|
437
|
+
tuning, study = {'attempted': False, 'reason': 'disabled'}, None
|
|
438
|
+
try:
|
|
439
|
+
control.check()
|
|
440
|
+
# GUARD compares against an immutable full-budget TRAIN Base, never a SEARCH-selected prefix.
|
|
441
|
+
base = evaluate(self.base_genome, self.base_params,
|
|
442
|
+
early_stopping_rounds=self.config.early_stopping_rounds if validation is not None else 0)
|
|
443
|
+
selected = base
|
|
444
|
+
if tuning_enabled:
|
|
445
|
+
from .optuna_tuner import OptunaTuner
|
|
446
|
+
from .metrics import rmse
|
|
447
|
+
tuner = OptunaTuner()
|
|
448
|
+
tuner.cancel = cancel
|
|
449
|
+
tuner.context = {'data': dataset_fingerprint(((rows, targets), search_data, guard_data), task='regression'),
|
|
450
|
+
'schema': names, 'backend': self.backend, 'task': 'regression', 'objective': 'squared_error',
|
|
451
|
+
'partition': split_kind, 'memory_enabled': False}
|
|
452
|
+
selected, tuning = tuner.optimize(base, evaluate, *search_data, *guard_data, self.config,
|
|
453
|
+
rmse(guard_data[1], base.predict_raw(guard_data[0])))
|
|
454
|
+
study = tuner.study
|
|
455
|
+
params = replace(selected.params, n_estimators=len(selected.trees))
|
|
456
|
+
train_metrics = regression_metrics(targets, selected.predict_raw(rows))
|
|
457
|
+
validation_metrics = (regression_metrics(selected_validation[1], selected.predict_raw(selected_validation[0]))
|
|
458
|
+
if selected_validation else None)
|
|
459
|
+
guard_metrics = regression_metrics(guard_data[1], selected.predict_raw(guard_data[0])) if guard_data else None
|
|
460
|
+
final = selected
|
|
461
|
+
interrupted = tuning.get('stop_reason') in ('cancelled', 'timeout')
|
|
462
|
+
did_refit = bool(selected_validation and self.config.refit_full and not interrupted)
|
|
463
|
+
if did_refit:
|
|
464
|
+
control.check()
|
|
465
|
+
final = BinaryHistogramBooster(self.base_genome, params, self.config.seed, backend,
|
|
466
|
+
memory_enabled=False, objective='squared_error').fit(
|
|
467
|
+
full_rows, full_targets, callback=callback)
|
|
468
|
+
final.params = params
|
|
469
|
+
# Check representation budgets without allocating a second decoded ensemble.
|
|
470
|
+
if sum(len(t.nodes) for t in final.trees) > limits.max_nodes or sum(map(len, final.binner.thresholds)) > limits.max_thresholds:
|
|
471
|
+
raise ValueError('Fitted regression model exceeds default format budgets')
|
|
472
|
+
if abs(final.base_score) > 1e100 or any(not math.isfinite(node.value) or abs(node.value) > 1e100
|
|
473
|
+
for tree in final.trees for node in tree.nodes):
|
|
474
|
+
raise ArithmeticError('Regression state exceeds the supported finite model range')
|
|
475
|
+
report = {'version': VERSION, 'spec_version': SPEC_VERSION, 'task': 'regression', 'objective': 'squared_error',
|
|
476
|
+
'backend': backend.name, 'config': public_config(self.config), 'freeze_reason': 'fixed_configuration',
|
|
477
|
+
'stop_reason': tuning.get('stop_reason', 'numeric_tuning_complete' if tuning_enabled else
|
|
478
|
+
'early_stopping' if selected.stopped_early_ else 'fixed_base_complete'),
|
|
479
|
+
'base_parameters': asdict(self.base_params), 'final_parameters': asdict(params),
|
|
480
|
+
'selected_trees': len(selected.trees), 'validation_rmse_curve': list(selected.search_curve_),
|
|
481
|
+
'train_metrics': train_metrics, 'validation_metrics': validation_metrics, 'refit_full': did_refit,
|
|
482
|
+
'split': {'kind': 'explicit_validation' if selected_validation else 'all_train', 'train': len(rows),
|
|
483
|
+
'validation': len(selected_validation[1]) if selected_validation else 0,
|
|
484
|
+
'refit_rows': len(rows) + len(selected_validation[1]) if did_refit else len(rows)},
|
|
485
|
+
'metrics_scope': 'TRAIN/validation before optional refit; selection metrics, not an independent test'
|
|
486
|
+
if selected_validation else 'TRAIN diagnostics only; no validation performed',
|
|
487
|
+
'timings_seconds': {'total_fit': time.perf_counter() - start}}
|
|
488
|
+
if search_data:
|
|
489
|
+
report.update(tuning=tuning, search_metrics=validation_metrics, guard_metrics=guard_metrics,
|
|
490
|
+
metric='rmse', frozen_fingerprint=self.base_genome.fingerprint(),
|
|
491
|
+
base_search_rmse=regression_metrics(search_data[1], base.predict_raw(search_data[0]))['rmse'],
|
|
492
|
+
base_guard_rmse=regression_metrics(guard_data[1], base.predict_raw(guard_data[0]))['rmse'],
|
|
493
|
+
metrics_scope='TRAIN/SEARCH/GUARD before optional refit; selection diagnostics, not independent test',
|
|
494
|
+
split={'kind': split_kind, 'train': len(targets), 'search': len(search_data[1]),
|
|
495
|
+
'guard': len(guard_data[1]), 'refit_rows': len(full_targets) if did_refit else len(targets)})
|
|
496
|
+
except SearchInterrupted as exc:
|
|
497
|
+
raise RuntimeError('Regression fit cancelled; fitted state was not changed') from exc
|
|
498
|
+
# No partially fitted state escapes validation, cancellation, refit or metrics failures.
|
|
499
|
+
self.model_, self.params_, self.genome_, self.report_ = final, params, self.base_genome, FitReport(report)
|
|
500
|
+
self.n_features_in_, self.feature_names_in_ = width, names
|
|
501
|
+
self.optuna_study_ = study
|
|
502
|
+
return self
|
|
503
|
+
|
|
504
|
+
def _require_fitted(self):
|
|
505
|
+
if self.model_ is None:
|
|
506
|
+
raise RuntimeError('MorphTreeRegressor is not fitted')
|
|
507
|
+
|
|
508
|
+
def predict(self, X):
|
|
509
|
+
self._require_fitted()
|
|
510
|
+
rows = validate_X(X, self.n_features_in_, self.feature_names_in_, allow_empty=True)
|
|
511
|
+
prediction = [float(v) for v in self.model_.predict_raw(rows)] if rows else []
|
|
512
|
+
if any(not math.isfinite(v) for v in prediction):
|
|
513
|
+
raise ArithmeticError('Regression prediction outside finite floating-point range')
|
|
514
|
+
return prediction
|
|
515
|
+
|
|
516
|
+
def score(self, X, y):
|
|
517
|
+
return r2_score(y, self.predict(X))
|
|
518
|
+
|
|
519
|
+
def report(self):
|
|
520
|
+
self._require_fitted()
|
|
521
|
+
return self.report_.to_dict()
|
|
522
|
+
|
|
523
|
+
def inspect(self):
|
|
524
|
+
return self.report()
|
|
525
|
+
|
|
526
|
+
def inspect_algorithm(self):
|
|
527
|
+
self._require_fitted()
|
|
528
|
+
return {'task': 'regression', 'objective': 'squared_error', 'genome': self.genome_.to_dict(),
|
|
529
|
+
'parameters': asdict(self.params_)}
|
|
530
|
+
|
|
531
|
+
def _model_payload(self, *, include_report=True):
|
|
532
|
+
self._require_fitted()
|
|
533
|
+
return {**({'genome':self.genome_.to_dict()} if self.genome_!=AlgorithmGenome() else {}), 'format': TASK_GROWTH_FORMATS['regression'] if self.genome_!=AlgorithmGenome() else 'MorphTreeRegressionModel', 'spec_version': SPEC_VERSION, 'producer_version': VERSION,
|
|
534
|
+
'task': 'regression', 'objective': 'squared_error', 'parameters': asdict(self.params_),
|
|
535
|
+
'n_features': self.n_features_in_, 'feature_names': self.feature_names_in_,
|
|
536
|
+
'booster': self.model_.to_dict(), 'report': self.report() if include_report else {}}
|
|
537
|
+
|
|
538
|
+
def save_model(self, path):
|
|
539
|
+
write_json(path, self._model_payload())
|
|
540
|
+
|
|
541
|
+
save = save_model
|
|
542
|
+
|
|
543
|
+
@classmethod
|
|
544
|
+
def load_model(cls, path, *, limits=None, backend='pure'):
|
|
545
|
+
return cls._from_payload(read_json(path, limits), limits=limits, backend=backend)
|
|
546
|
+
|
|
547
|
+
@classmethod
|
|
548
|
+
def _from_payload(cls, data, *, limits=None, backend='pure'):
|
|
549
|
+
limits = limits or LoadLimits()
|
|
550
|
+
genome=decode_task_genome('regression',data)
|
|
551
|
+
require_keys({k:v for k,v in data.items() if k!='genome'}, ('format', 'spec_version', 'producer_version', 'task', 'objective', 'parameters',
|
|
552
|
+
'n_features', 'feature_names', 'booster', 'report'))
|
|
553
|
+
if (data['format'] not in ('MorphTreeRegressionModel',TASK_GROWTH_FORMATS['regression']) or type(data['spec_version']) is not int
|
|
554
|
+
or data['spec_version'] != SPEC_VERSION or data['task'] != 'regression' or data['objective'] != 'squared_error'):
|
|
555
|
+
raise ValueError('Unsupported regression model format/task/objective/version')
|
|
556
|
+
require_keys(data['parameters'], [field.name for field in fields(HyperParams)])
|
|
557
|
+
params = HyperParams(**data['parameters'])
|
|
558
|
+
obj = cls(params=params, backend=backend, genome=genome)
|
|
559
|
+
count = integer(data['n_features'], 'n_features', 1, limits.max_features)
|
|
560
|
+
validate_names(data['feature_names'], count)
|
|
561
|
+
obj.model_ = decode_booster(data['booster'], genome, params, obj._make_backend(), limits,
|
|
562
|
+
objective='squared_error')
|
|
563
|
+
if obj.model_.n_features_ != count or not isinstance(data['report'], dict):
|
|
564
|
+
raise ValueError('Invalid regression dimensions/report')
|
|
565
|
+
obj.params_, obj.genome_, obj.report_ = params, genome, FitReport(imported_regression_report(data['report']))
|
|
566
|
+
obj.n_features_in_ = count
|
|
567
|
+
obj.feature_names_in_ = list(data['feature_names']) if data['feature_names'] is not None else None
|
|
568
|
+
return obj
|
|
569
|
+
|
|
570
|
+
load = load_model
|
|
571
|
+
|
|
572
|
+
def export_python(self, path):
|
|
573
|
+
self._require_fitted()
|
|
574
|
+
from .exporting import export_predictor
|
|
575
|
+
export_predictor(self, path)
|
|
576
|
+
|
|
577
|
+
def export_bundle(self, path):
|
|
578
|
+
self._require_fitted()
|
|
579
|
+
from .exporting import export_bundle
|
|
580
|
+
export_bundle(self, path)
|
|
581
|
+
|
|
582
|
+
def save_report(self, path):
|
|
583
|
+
from .persistence import atomic_text
|
|
584
|
+
report = self.report()
|
|
585
|
+
if str(path).lower().endswith('.md'):
|
|
586
|
+
text = '# MorphTree regression report\n\nSelection diagnostics; never independent test evidence.\n\n'
|
|
587
|
+
text += '```json\n' + json.dumps(report, indent=2, allow_nan=False) + '\n```\n'
|
|
588
|
+
atomic_text(path, text)
|
|
589
|
+
else:
|
|
590
|
+
write_json(path, report)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
class MorphTreeMulticlassClassifier:
|
|
594
|
+
"""Fixed native multiclass Base. Explicit softmax or normalized OvR, no search."""
|
|
595
|
+
def __init__(self, *, config=None, params=None, backend='pure', strategy='ovr', genome=None):
|
|
596
|
+
config = MorphTreeConfig() if config is None else config
|
|
597
|
+
params = HyperParams() if params is None else params
|
|
598
|
+
genome=validate_task_genome('multiclass',AlgorithmGenome() if genome is None else genome)
|
|
599
|
+
self.base_genome=genome;self.genome_=None
|
|
600
|
+
if not isinstance(config, MorphTreeConfig) or not isinstance(params, HyperParams):
|
|
601
|
+
raise ValueError('config and params must use their MorphTree types')
|
|
602
|
+
if backend not in ('pure', 'numpy') or strategy not in ('softmax', 'ovr'):
|
|
603
|
+
raise ValueError('backend must be pure/numpy and strategy softmax/ovr')
|
|
604
|
+
if (config.enable_morphing or config.memory_enabled or config.diagnostic_guidance or config.tuner != 'none'
|
|
605
|
+
or config.search_timeout is not None or config.tune_timeout is not None):
|
|
606
|
+
raise ValueError('Multiclass currently supports fixed Base without tuning, morphing, memory or guidance')
|
|
607
|
+
self.config, self.base_params, self.backend, self.strategy = config, params, backend, strategy
|
|
608
|
+
self.task = 'multiclass'
|
|
609
|
+
self.model_, self.params_, self.report_, self.classes_ = None, None, None, None
|
|
610
|
+
self.n_features_in_, self.feature_names_in_ = None, None
|
|
611
|
+
|
|
612
|
+
def _make_backend(self):
|
|
613
|
+
if self.backend == 'numpy':
|
|
614
|
+
from .numpy_backend import NumpyBackend
|
|
615
|
+
return NumpyBackend()
|
|
616
|
+
return PythonBackend()
|
|
617
|
+
|
|
618
|
+
def fit(self, X, y, *, validation=None, feature_names=None, cancel=None):
|
|
619
|
+
start, control = time.perf_counter(), RunControl(cancel)
|
|
620
|
+
names = get_feature_names(X)
|
|
621
|
+
if feature_names is not None:
|
|
622
|
+
validate_names(feature_names, len(feature_names))
|
|
623
|
+
if names is not None and list(feature_names) != names:
|
|
624
|
+
raise ValueError('Explicit feature_names must match training DataFrame order')
|
|
625
|
+
names = list(feature_names)
|
|
626
|
+
rows = validate_X(X)
|
|
627
|
+
targets, classes = validate_multiclass_y(y, len(rows))
|
|
628
|
+
width, k, limits = len(rows[0]), len(classes), LoadLimits()
|
|
629
|
+
validate_names(names, width)
|
|
630
|
+
if width > limits.max_features or k*self.base_params.n_estimators > limits.max_trees:
|
|
631
|
+
raise ValueError('Multiclass feature/aggregate tree budget exceeded; reduce rounds or classes')
|
|
632
|
+
selected_validation = None
|
|
633
|
+
if validation is not None:
|
|
634
|
+
if not isinstance(validation, (tuple, list)) or len(validation) != 2:
|
|
635
|
+
raise ValueError('validation must be an (X, y) selection pair; never pass final test data')
|
|
636
|
+
vx = validate_X(validation[0], width, names)
|
|
637
|
+
vy, _ = validate_multiclass_y(validation[1], len(vx), classes)
|
|
638
|
+
selected_validation = vx, vy
|
|
639
|
+
backend = self._make_backend()
|
|
640
|
+
def callback(iteration, loss):
|
|
641
|
+
control.check()
|
|
642
|
+
return True
|
|
643
|
+
def make(params):
|
|
644
|
+
return BinaryHistogramBooster(self.base_genome, params, self.config.seed, backend,
|
|
645
|
+
memory_enabled=False, objective=self.strategy, n_classes=k)
|
|
646
|
+
try:
|
|
647
|
+
control.check()
|
|
648
|
+
selected = make(self.base_params).fit(rows, targets, eval_set=selected_validation,
|
|
649
|
+
early_stopping_rounds=self.config.early_stopping_rounds, callback=callback)
|
|
650
|
+
rounds = len(selected.trees)//k
|
|
651
|
+
params = replace(self.base_params, n_estimators=rounds)
|
|
652
|
+
train_metrics = multiclass_metrics(targets, selected.predict_proba(rows))
|
|
653
|
+
validation_metrics = (multiclass_metrics(selected_validation[1], selected.predict_proba(selected_validation[0]))
|
|
654
|
+
if selected_validation else None)
|
|
655
|
+
did_refit = bool(selected_validation and self.config.refit_full)
|
|
656
|
+
final = selected
|
|
657
|
+
if did_refit:
|
|
658
|
+
final = make(params).fit(rows+selected_validation[0], targets+selected_validation[1], callback=callback)
|
|
659
|
+
final.params = params
|
|
660
|
+
if (sum(len(tree.nodes) for tree in final.trees) > limits.max_nodes
|
|
661
|
+
or sum(map(len, final.binner.thresholds)) > limits.max_thresholds):
|
|
662
|
+
raise ValueError('Fitted multiclass model exceeds default format budgets')
|
|
663
|
+
if any(not math.isfinite(node.value) or abs(node.value)>1e100 for tree in final.trees for node in tree.nodes):
|
|
664
|
+
raise ArithmeticError('Multiclass state exceeds finite format range')
|
|
665
|
+
report = {'version': VERSION, 'spec_version': SPEC_VERSION, 'task': 'multiclass', 'strategy': self.strategy,
|
|
666
|
+
'backend': backend.name, 'classes': list(classes), 'config': public_config(self.config),
|
|
667
|
+
'freeze_reason': 'fixed_configuration', 'stop_reason': 'early_stopping' if selected.stopped_early_ else 'fixed_base_complete',
|
|
668
|
+
'base_parameters': asdict(self.base_params), 'final_parameters': asdict(params), 'selected_rounds': rounds,
|
|
669
|
+
'tree_count': len(final.trees), 'validation_logloss_curve': list(selected.search_curve_),
|
|
670
|
+
'train_metrics': train_metrics, 'validation_metrics': validation_metrics, 'refit_full': did_refit,
|
|
671
|
+
'hessian': 'diagonal_upper_bound_2p(1-p)' if self.strategy=='softmax' else 'independent_binary_p(1-p)',
|
|
672
|
+
'split': {'kind': 'explicit_validation' if selected_validation else 'all_train', 'train': len(rows),
|
|
673
|
+
'validation': len(selected_validation[1]) if selected_validation else 0,
|
|
674
|
+
'refit_rows': len(rows)+len(selected_validation[1]) if did_refit else len(rows)},
|
|
675
|
+
'metrics_scope': 'TRAIN/validation before optional refit; selection diagnostics, not independent test',
|
|
676
|
+
'timings_seconds': {'total_fit': time.perf_counter()-start}}
|
|
677
|
+
except SearchInterrupted as exc:
|
|
678
|
+
raise RuntimeError('Multiclass fit cancelled; fitted state was not changed') from exc
|
|
679
|
+
self.model_, self.params_, self.report_, self.classes_ = final, params, FitReport(report), classes
|
|
680
|
+
self.genome_=self.base_genome
|
|
681
|
+
self.n_features_in_, self.feature_names_in_ = width, names
|
|
682
|
+
return self
|
|
683
|
+
|
|
684
|
+
def _require_fitted(self):
|
|
685
|
+
if self.model_ is None:
|
|
686
|
+
raise RuntimeError('MorphTreeMulticlassClassifier is not fitted')
|
|
687
|
+
|
|
688
|
+
def predict_proba(self, X):
|
|
689
|
+
self._require_fitted()
|
|
690
|
+
rows = validate_X(X, self.n_features_in_, self.feature_names_in_, allow_empty=True)
|
|
691
|
+
return self.model_.predict_proba(rows) if rows else []
|
|
692
|
+
|
|
693
|
+
def predict(self, X):
|
|
694
|
+
return [self.classes_[max(range(len(row)), key=row.__getitem__)] for row in self.predict_proba(X)]
|
|
695
|
+
|
|
696
|
+
def score(self, X, y):
|
|
697
|
+
probabilities = self.predict_proba(X)
|
|
698
|
+
labels, _ = validate_multiclass_y(y, len(probabilities), self.classes_)
|
|
699
|
+
return multiclass_metrics(labels, probabilities)['accuracy']
|
|
700
|
+
|
|
701
|
+
def report(self):
|
|
702
|
+
self._require_fitted()
|
|
703
|
+
return self.report_.to_dict()
|
|
704
|
+
|
|
705
|
+
def inspect(self):
|
|
706
|
+
return self.report()
|
|
707
|
+
|
|
708
|
+
def inspect_algorithm(self):
|
|
709
|
+
self._require_fitted()
|
|
710
|
+
return {'task': self.task, 'strategy': self.strategy, 'classes': list(self.classes_),
|
|
711
|
+
'genome': self.genome_.to_dict(), 'parameters': asdict(self.params_)}
|
|
712
|
+
|
|
713
|
+
def _model_payload(self, *, include_report=True):
|
|
714
|
+
self._require_fitted()
|
|
715
|
+
return {**({'genome':self.genome_.to_dict()} if self.genome_!=AlgorithmGenome() else {}), 'format': TASK_GROWTH_FORMATS['multiclass'] if self.genome_!=AlgorithmGenome() else 'MorphTreeMulticlassModel', 'spec_version': SPEC_VERSION, 'producer_version': VERSION,
|
|
716
|
+
'task': 'multiclass', 'strategy': self.strategy, 'classes': list(self.classes_), 'parameters': asdict(self.params_),
|
|
717
|
+
'n_features': self.n_features_in_, 'feature_names': self.feature_names_in_, 'booster': self.model_.to_dict(),
|
|
718
|
+
'report': self.report() if include_report else {}}
|
|
719
|
+
|
|
720
|
+
def save_model(self, path):
|
|
721
|
+
write_json(path, self._model_payload())
|
|
722
|
+
|
|
723
|
+
save = save_model
|
|
724
|
+
|
|
725
|
+
@classmethod
|
|
726
|
+
def load_model(cls, path, *, limits=None, backend='pure'):
|
|
727
|
+
return cls._from_payload(read_json(path, limits), limits=limits, backend=backend)
|
|
728
|
+
|
|
729
|
+
@classmethod
|
|
730
|
+
def _from_payload(cls, data, *, limits=None, backend='pure'):
|
|
731
|
+
limits = limits or LoadLimits()
|
|
732
|
+
genome=decode_task_genome('multiclass',data)
|
|
733
|
+
require_keys({k:v for k,v in data.items() if k!='genome'}, ('format', 'spec_version', 'producer_version', 'task', 'strategy', 'classes', 'parameters',
|
|
734
|
+
'n_features', 'feature_names', 'booster', 'report'))
|
|
735
|
+
if (data['format'] not in ('MorphTreeMulticlassModel',TASK_GROWTH_FORMATS['multiclass']) or type(data['spec_version']) is not int
|
|
736
|
+
or data['spec_version'] != SPEC_VERSION or data['task'] != 'multiclass'):
|
|
737
|
+
raise ValueError('Unsupported multiclass format/version/task')
|
|
738
|
+
vocabulary = data['classes']
|
|
739
|
+
if not isinstance(vocabulary, list):
|
|
740
|
+
raise ValueError('classes must be a sorted list')
|
|
741
|
+
_, classes = validate_multiclass_y(vocabulary, len(vocabulary))
|
|
742
|
+
if list(classes) != vocabulary:
|
|
743
|
+
raise ValueError('classes must be sorted and unique')
|
|
744
|
+
require_keys(data['parameters'], [field.name for field in fields(HyperParams)])
|
|
745
|
+
params = HyperParams(**data['parameters'])
|
|
746
|
+
obj = cls(params=params, backend=backend, strategy=data['strategy'], genome=genome)
|
|
747
|
+
count = integer(data['n_features'], 'n_features', 1, limits.max_features)
|
|
748
|
+
validate_names(data['feature_names'], count)
|
|
749
|
+
booster = decode_multiclass_booster(data['booster'], params, obj._make_backend(), len(classes), obj.strategy, limits, genome=genome)
|
|
750
|
+
if booster.n_features_ != count or not isinstance(data['report'], dict):
|
|
751
|
+
raise ValueError('Invalid multiclass dimensions/report')
|
|
752
|
+
obj.model_, obj.params_, obj.classes_ = booster, params, classes
|
|
753
|
+
obj.genome_=genome
|
|
754
|
+
obj.report_ = FitReport(imported_multiclass_report(data['report']))
|
|
755
|
+
obj.n_features_in_ = count
|
|
756
|
+
obj.feature_names_in_ = list(data['feature_names']) if data['feature_names'] is not None else None
|
|
757
|
+
return obj
|
|
758
|
+
|
|
759
|
+
load = load_model
|
|
760
|
+
|
|
761
|
+
def export_python(self, path):
|
|
762
|
+
self._require_fitted()
|
|
763
|
+
from .exporting import export_predictor
|
|
764
|
+
export_predictor(self, path)
|
|
765
|
+
|
|
766
|
+
def export_bundle(self, path):
|
|
767
|
+
self._require_fitted()
|
|
768
|
+
from .exporting import export_bundle
|
|
769
|
+
export_bundle(self, path)
|
|
770
|
+
|
|
771
|
+
def save_report(self, path):
|
|
772
|
+
from .persistence import atomic_text
|
|
773
|
+
if str(path).lower().endswith('.md'):
|
|
774
|
+
atomic_text(path, '# MorphTree multiclass report\n\nSelection diagnostics, not independent test evidence.\n\n```json\n'
|
|
775
|
+
+json.dumps(self.report(), indent=2, allow_nan=False)+'\n```\n')
|
|
776
|
+
else:
|
|
777
|
+
write_json(path, self.report())
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
class AutoMorphTree:
|
|
781
|
+
"""Native task dispatch with explicit bounded numeric selection; fixed Base by default."""
|
|
782
|
+
def __init__(self, *, task='auto', mode='fast', config=None, params=None, backend='pure',
|
|
783
|
+
max_models=4, time_budget=30., candidate_params=None):
|
|
784
|
+
if task not in ('auto', 'binary', 'multiclass', 'regression') or mode not in ('fast', 'numeric'):
|
|
785
|
+
raise ValueError('AutoMorphTree currently supports task auto/binary/multiclass/regression and mode=fast/numeric')
|
|
786
|
+
config = MorphTreeConfig() if config is None else config
|
|
787
|
+
params = HyperParams() if params is None else params
|
|
788
|
+
if not isinstance(config, MorphTreeConfig) or not isinstance(params, HyperParams):
|
|
789
|
+
raise ValueError('config and params must use their MorphTree types')
|
|
790
|
+
if backend not in ('pure', 'numpy'):
|
|
791
|
+
raise ValueError('backend must be pure or numpy')
|
|
792
|
+
if config.tuner != 'none' or config.enable_morphing or config.memory_enabled or config.diagnostic_guidance:
|
|
793
|
+
raise ValueError('AutoMorphTree uses a fixed structure without implicit Optuna, morphing, memory or guidance')
|
|
794
|
+
if type(max_models) is not int or not 1 <= max_models <= 16:
|
|
795
|
+
raise ValueError('max_models must be an integer in [1, 16]')
|
|
796
|
+
if type(time_budget) not in (int, float) or not math.isfinite(time_budget) or time_budget <= 0:
|
|
797
|
+
raise ValueError('time_budget must be positive and finite')
|
|
798
|
+
if candidate_params is not None and (not isinstance(candidate_params, (list, tuple))
|
|
799
|
+
or len(candidate_params) > 15 or any(not isinstance(p, HyperParams) for p in candidate_params)):
|
|
800
|
+
raise ValueError('candidate_params must contain at most 15 HyperParams')
|
|
801
|
+
if mode == 'fast' and (candidate_params is not None or max_models != 4 or time_budget != 30.):
|
|
802
|
+
raise ValueError('Numeric budgets/portfolio require mode=numeric')
|
|
803
|
+
if mode == 'numeric' and (config.search_timeout is not None or config.tune_timeout is not None):
|
|
804
|
+
raise ValueError('Use time_budget for numeric Auto; separate search/tune timeouts are unsupported')
|
|
805
|
+
self.max_models, self.time_budget = max_models, float(time_budget)
|
|
806
|
+
self.candidate_params = None if candidate_params is None else tuple(candidate_params)
|
|
807
|
+
self.selection_report_ = None
|
|
808
|
+
self.task, self.mode, self.config, self.params, self.backend = task, mode, config, params, backend
|
|
809
|
+
self.model_, self.selected_task_ = None, None
|
|
810
|
+
|
|
811
|
+
def fit(self, X, y, **kwargs):
|
|
812
|
+
if hasattr(y, 'tolist'):
|
|
813
|
+
y = y.tolist()
|
|
814
|
+
if isinstance(y, (str, bytes)):
|
|
815
|
+
raise ValueError('y must be a one-dimensional target sequence')
|
|
816
|
+
try:
|
|
817
|
+
values = list(y)
|
|
818
|
+
except TypeError as exc:
|
|
819
|
+
raise ValueError('y must be a one-dimensional target sequence') from exc
|
|
820
|
+
task = self.task
|
|
821
|
+
if task == 'auto':
|
|
822
|
+
if values and all(type(v) is str for v in values):
|
|
823
|
+
task = 'multiclass'
|
|
824
|
+
elif values and all(type(v) is int or type(v) is float and math.isfinite(v) for v in values):
|
|
825
|
+
if set(values) == {0, 1}:
|
|
826
|
+
task = 'binary'
|
|
827
|
+
elif any(type(v) is float and not v.is_integer() for v in values):
|
|
828
|
+
task = 'regression'
|
|
829
|
+
else:
|
|
830
|
+
raise ValueError('Integer-valued targets are ambiguous; set task=regression or task=multiclass explicitly')
|
|
831
|
+
else:
|
|
832
|
+
raise ValueError('Cannot infer a task safely; provide valid targets and an explicit task')
|
|
833
|
+
learner = {'binary': MorphTreeClassifier, 'multiclass': MorphTreeMulticlassClassifier, 'regression': MorphTreeRegressor}[task]
|
|
834
|
+
selection = None
|
|
835
|
+
if self.mode == 'numeric':
|
|
836
|
+
candidate, selection = self._fit_numeric(learner, task, X, values, **kwargs)
|
|
837
|
+
else:
|
|
838
|
+
candidate = learner(config=self.config, params=self.params, backend=self.backend).fit(X, values, **kwargs)
|
|
839
|
+
self.model_, self.selected_task_, self.selection_report_ = candidate, task, selection
|
|
840
|
+
return self
|
|
841
|
+
|
|
842
|
+
def _fit_numeric(self, learner, task, X, y, *, search=None, guard=None, feature_names=None, cancel=None):
|
|
843
|
+
if search is None or guard is None:
|
|
844
|
+
raise ValueError('Numeric Auto requires explicit disjoint TRAIN, SEARCH and GUARD; never pass final test data')
|
|
845
|
+
names = get_feature_names(X)
|
|
846
|
+
if feature_names is not None:
|
|
847
|
+
validate_names(feature_names, len(feature_names))
|
|
848
|
+
if names is not None and list(feature_names) != names:
|
|
849
|
+
raise ValueError('Explicit feature_names must match training DataFrame order')
|
|
850
|
+
names = list(feature_names)
|
|
851
|
+
rows = validate_X(X)
|
|
852
|
+
validate_names(names, len(rows[0]))
|
|
853
|
+
if task == 'multiclass':
|
|
854
|
+
_, vocabulary = validate_multiclass_y(y, len(rows))
|
|
855
|
+
targets = list(y)
|
|
856
|
+
else:
|
|
857
|
+
vocabulary = None
|
|
858
|
+
targets = validate_regression_y(y, len(rows)) if task == 'regression' else validate_y(y, len(rows), both_classes=True)
|
|
859
|
+
def pair(value):
|
|
860
|
+
if not isinstance(value, (tuple, list)) or len(value) != 2:
|
|
861
|
+
raise ValueError('Selection data must be an (X, y) pair')
|
|
862
|
+
vx = validate_X(value[0], len(rows[0]), names)
|
|
863
|
+
if task == 'multiclass':
|
|
864
|
+
vy, _ = validate_multiclass_y(value[1], len(vx), vocabulary)
|
|
865
|
+
else:
|
|
866
|
+
vy = validate_regression_y(value[1], len(vx)) if task == 'regression' else validate_y(value[1], len(vx), both_classes=True)
|
|
867
|
+
return vx, vy
|
|
868
|
+
sx, sy = pair(search); gx, gy = pair(guard)
|
|
869
|
+
# This finite portfolio is fixed before observing SEARCH/GUARD losses.
|
|
870
|
+
k = len(vocabulary) if vocabulary else 1
|
|
871
|
+
rounds = min(4096 // k, 2 * self.params.n_estimators)
|
|
872
|
+
support = min(self.params.min_samples_leaf, max(2, len(rows) // 40))
|
|
873
|
+
candidates = list(self.candidate_params) if self.candidate_params is not None else [
|
|
874
|
+
replace(self.params, n_estimators=rounds),
|
|
875
|
+
replace(self.params, learning_rate=min(1., 2*self.params.learning_rate),
|
|
876
|
+
max_leaves=min(4096, 2*self.params.max_leaves), min_samples_leaf=support),
|
|
877
|
+
replace(self.params, l2=min(1e6, max(1., 4*self.params.l2)),
|
|
878
|
+
min_samples_leaf=max(self.params.min_samples_leaf, max(2, len(rows)//20)))]
|
|
879
|
+
unique = []
|
|
880
|
+
for params in candidates:
|
|
881
|
+
if params != self.params and params not in unique:
|
|
882
|
+
unique.append(params)
|
|
883
|
+
portfolio = [self.params] + unique[:self.max_models-1]
|
|
884
|
+
if any(k*p.n_estimators > 4096 for p in portfolio):
|
|
885
|
+
raise ValueError('Numeric candidate exceeds aggregate tree budget')
|
|
886
|
+
if cancel is not None and not callable(cancel):
|
|
887
|
+
raise ValueError('cancel must be callable or None')
|
|
888
|
+
start = time.monotonic()
|
|
889
|
+
deadline = start + self.time_budget
|
|
890
|
+
cancelled = False
|
|
891
|
+
def interrupt():
|
|
892
|
+
nonlocal cancelled
|
|
893
|
+
if cancel is not None and cancel():
|
|
894
|
+
cancelled = True
|
|
895
|
+
return True
|
|
896
|
+
return time.monotonic() >= deadline
|
|
897
|
+
cfg = replace(self.config, refit_full=False, early_stopping_rounds=0)
|
|
898
|
+
records = []
|
|
899
|
+
best = None
|
|
900
|
+
base_guard = best_guard = best_search = None
|
|
901
|
+
selected_index = 0
|
|
902
|
+
stop = 'portfolio_exhausted'
|
|
903
|
+
attempted = 0
|
|
904
|
+
def loss(model, vx, vy):
|
|
905
|
+
if task == 'regression':
|
|
906
|
+
return regression_metrics(vy, model.predict(vx))['rmse']
|
|
907
|
+
if task == 'binary':
|
|
908
|
+
return logloss(vy, [row[1] for row in model.predict_proba(vx)])
|
|
909
|
+
return multiclass_metrics(vy, model.predict_proba(vx))['logloss']
|
|
910
|
+
for index, params in enumerate(portfolio):
|
|
911
|
+
if interrupt():
|
|
912
|
+
stop = 'cancelled' if cancelled else 'time_budget'
|
|
913
|
+
break
|
|
914
|
+
attempted += 1
|
|
915
|
+
begun = time.monotonic()
|
|
916
|
+
try:
|
|
917
|
+
candidate = learner(config=cfg, params=params, backend=self.backend).fit(rows, targets, feature_names=names, cancel=interrupt)
|
|
918
|
+
search_loss = loss(candidate, sx, sy)
|
|
919
|
+
guard_loss = loss(candidate, gx, gy)
|
|
920
|
+
except RuntimeError as exc:
|
|
921
|
+
if not isinstance(exc.__cause__, SearchInterrupted):
|
|
922
|
+
raise
|
|
923
|
+
stop = 'cancelled' if cancelled else 'time_budget'
|
|
924
|
+
records.append({'index':index, 'parameters':asdict(params), 'status':'INTERRUPTED', 'fit_seconds':time.monotonic()-begun})
|
|
925
|
+
break
|
|
926
|
+
if index == 0:
|
|
927
|
+
accepted = True
|
|
928
|
+
base_guard = guard_loss
|
|
929
|
+
else:
|
|
930
|
+
accepted = (search_loss < best_search and guard_loss <= best_guard + cfg.guard_tolerance
|
|
931
|
+
and guard_loss <= base_guard + cfg.global_guard_tolerance)
|
|
932
|
+
records.append({'index':index, 'parameters':asdict(params), 'status':'COMPLETE', 'accepted':accepted,
|
|
933
|
+
'search_loss':search_loss, 'guard_loss':guard_loss, 'fit_seconds':time.monotonic()-begun})
|
|
934
|
+
if accepted:
|
|
935
|
+
best, best_search, best_guard, selected_index = candidate, search_loss, guard_loss, index
|
|
936
|
+
if cancelled:
|
|
937
|
+
raise RuntimeError('Auto fit cancelled; fitted state was not changed')
|
|
938
|
+
if best is None:
|
|
939
|
+
raise RuntimeError('Time budget could not complete Base; fitted state was not changed')
|
|
940
|
+
# Refit is explicit in config and charged separately. A timeout retains the
|
|
941
|
+
# complete TRAIN winner; cancellation preserves the previous Auto fit.
|
|
942
|
+
refit = False
|
|
943
|
+
refit_status = 'disabled'
|
|
944
|
+
if self.config.refit_full:
|
|
945
|
+
refit_status = 'budget_exhausted'
|
|
946
|
+
if not interrupt():
|
|
947
|
+
attempted += 1
|
|
948
|
+
labels = ([vocabulary[v] for v in sy+gy] if vocabulary else sy+gy)
|
|
949
|
+
try:
|
|
950
|
+
full = learner(config=cfg, params=best.params_, backend=self.backend).fit(
|
|
951
|
+
rows+sx+gx, targets+labels, feature_names=names, cancel=interrupt)
|
|
952
|
+
best, refit, refit_status = full, True, 'complete'
|
|
953
|
+
except RuntimeError as exc:
|
|
954
|
+
if not isinstance(exc.__cause__, SearchInterrupted):raise
|
|
955
|
+
refit_status = 'interrupted'
|
|
956
|
+
if cancelled:
|
|
957
|
+
raise RuntimeError('Auto fit cancelled; fitted state was not changed')
|
|
958
|
+
result = {'configuration_selection':'guarded_numeric_portfolio', 'best_configuration_claim':False,
|
|
959
|
+
'selected_index':selected_index, 'fallback_to_base':selected_index==0,
|
|
960
|
+
'metric':'rmse' if task=='regression' else 'logloss', 'base_guard_loss':base_guard,
|
|
961
|
+
'selected_search_loss':best_search, 'selected_guard_loss':best_guard,
|
|
962
|
+
'attempted_fits':attempted, 'completed_selection_fits':sum(r['status']=='COMPLETE' for r in records),
|
|
963
|
+
'max_models':self.max_models, 'time_budget_seconds':self.time_budget, 'elapsed_seconds':time.monotonic()-start,
|
|
964
|
+
'budget_kind':'cooperative at complete boosting rounds; validation/metrics and allocations are not hard bounded',
|
|
965
|
+
'stop_reason':stop, 'refit_full':refit, 'refit_status':refit_status,
|
|
966
|
+
'partition':{'kind':'explicit_caller_responsible_for_disjointness','train':len(rows),'search':len(sx),'guard':len(gx)},
|
|
967
|
+
'candidates':records, 'structure':'immutable fixed Base', 'metrics_scope':'selection diagnostics before optional refit'}
|
|
968
|
+
return best, result
|
|
969
|
+
|
|
970
|
+
def report(self):
|
|
971
|
+
if self.model_ is None:
|
|
972
|
+
raise RuntimeError('AutoMorphTree is not fitted')
|
|
973
|
+
result = self.model_.report()
|
|
974
|
+
result['auto'] = {'selected_task': self.selected_task_, 'rule': 'explicit' if self.task != 'auto' else 'conservative_target_type',
|
|
975
|
+
'configuration_selection': 'fixed_base_no_search', 'best_configuration_claim': False}
|
|
976
|
+
if self.selection_report_ is not None:
|
|
977
|
+
result['auto'].update(copy.deepcopy(self.selection_report_))
|
|
978
|
+
return result
|
|
979
|
+
|
|
980
|
+
def save_report(self, path):
|
|
981
|
+
if self.selection_report_ is None:
|
|
982
|
+
if self.model_ is None:
|
|
983
|
+
raise RuntimeError('AutoMorphTree is not fitted')
|
|
984
|
+
return self.model_.save_report(path)
|
|
985
|
+
if str(path).lower().endswith('.md'):
|
|
986
|
+
from .persistence import atomic_text
|
|
987
|
+
atomic_text(path, '# AutoMorphTree numeric selection report\n\nSelection diagnostics, not independent test evidence.\n\n```json\n'
|
|
988
|
+
+json.dumps(self.report(), indent=2, allow_nan=False)+'\n```\n')
|
|
989
|
+
else:
|
|
990
|
+
write_json(path, self.report())
|
|
991
|
+
|
|
992
|
+
def __getattr__(self, name):
|
|
993
|
+
# Delegate only the public native interface; artifacts keep their native format.
|
|
994
|
+
allowed = {'predict', 'predict_proba', 'score', 'save_model', 'save', 'export_python', 'export_bundle',
|
|
995
|
+
'save_report', 'inspect_algorithm', 'classes_', 'params_', 'n_features_in_', 'feature_names_in_'}
|
|
996
|
+
model = self.__dict__.get('model_')
|
|
997
|
+
if name in allowed and model is not None:
|
|
998
|
+
return getattr(model, name)
|
|
999
|
+
raise AttributeError(name)
|
|
1000
|
+
|
|
1001
|
+
def inspect(self):
|
|
1002
|
+
return self.report()
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def load_model(path, *, limits=None, backend='pure'):
|
|
1006
|
+
"""Bounded dispatch on an explicit format; never infer a task from target values."""
|
|
1007
|
+
data = read_json(path, limits)
|
|
1008
|
+
cls = {'MorphTreeRegressionModel': MorphTreeRegressor, 'MorphTreeMulticlassModel': MorphTreeMulticlassClassifier}.get(
|
|
1009
|
+
data.get('format'), MorphTreeClassifier)
|
|
1010
|
+
return cls._from_payload(data, limits=limits, backend=backend)
|