morphtree 2.1.0__py3-none-win_amd64.whl

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