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/data.py ADDED
@@ -0,0 +1,300 @@
1
+ """Input contracts, deterministic splits and compact train-only binning."""
2
+ from __future__ import annotations
3
+
4
+ from array import array
5
+ from bisect import bisect_left, bisect_right
6
+ import csv
7
+ import math
8
+ from numbers import Real
9
+ import random
10
+
11
+ from .config import checked_int
12
+
13
+
14
+ def get_feature_names(X):
15
+ if not hasattr(X, "columns"):
16
+ return None
17
+ names = list(X.columns)
18
+ validate_names(names, len(names))
19
+ return names
20
+
21
+
22
+ def validate_names(names, count):
23
+ if names is not None:
24
+ if not isinstance(names, (list, tuple)) or len(names) != count:
25
+ raise ValueError("Feature names must match the number of columns")
26
+ if any(not isinstance(x, str) or not x for x in names) or len(set(names)) != len(names):
27
+ raise ValueError("Feature names must be unique nonempty strings")
28
+
29
+
30
+ def validate_X(X, n_features=None, expected_names=None, *, allow_empty=False):
31
+ names = get_feature_names(X)
32
+ if names is not None and expected_names is not None:
33
+ if set(names) != set(expected_names):
34
+ raise ValueError("Feature schema mismatch: missing or extra columns")
35
+ X = X.loc[:, list(expected_names)]
36
+ if hasattr(X, "to_numpy"):
37
+ X = X.to_numpy()
38
+ if hasattr(X, "ndim") and X.ndim != 2:
39
+ raise ValueError("X must have exactly two dimensions")
40
+ if n_features is not None and hasattr(X, "shape") and len(X.shape) == 2 and X.shape[1] != n_features:
41
+ raise ValueError(f"X must have exactly {n_features} features")
42
+ if hasattr(X, "tolist"):
43
+ X = X.tolist()
44
+ if isinstance(X, (str, bytes)):
45
+ raise ValueError("X must be a matrix of numeric rows")
46
+ try:
47
+ rows = list(X)
48
+ except TypeError as exc:
49
+ raise ValueError("X must be a matrix") from exc
50
+ if not rows:
51
+ if allow_empty and n_features is not None:
52
+ return []
53
+ raise ValueError("X must contain at least one row")
54
+ width = n_features
55
+ output = []
56
+ for row in rows:
57
+ if isinstance(row, (str, bytes)):
58
+ raise ValueError("Each row must be a numeric sequence")
59
+ try:
60
+ values = list(row)
61
+ except TypeError as exc:
62
+ raise ValueError("X must have exactly two dimensions") from exc
63
+ if width is None:
64
+ width = len(values)
65
+ if not width or len(values) != width:
66
+ raise ValueError(f"X must have exactly {width} features in each row")
67
+ converted = []
68
+ for value in values:
69
+ if value is None:
70
+ converted.append(math.nan)
71
+ elif isinstance(value, Real):
72
+ try:
73
+ number = float(value)
74
+ except (ValueError, OverflowError) as exc:
75
+ raise ValueError("Feature outside supported floating-point range") from exc
76
+ if math.isinf(number):
77
+ raise ValueError("X may contain missing values but not infinity")
78
+ converted.append(number)
79
+ else:
80
+ raise ValueError("X must contain numeric values, None or NaN")
81
+ output.append(converted)
82
+ return output
83
+
84
+
85
+ def validate_y(y, n, *, both_classes=False):
86
+ if hasattr(y, "ndim") and y.ndim != 1:
87
+ raise ValueError("y must be one-dimensional")
88
+ if hasattr(y, "tolist"):
89
+ y = y.tolist()
90
+ if isinstance(y, (str, bytes)):
91
+ raise ValueError("y must contain numeric 0/1 labels")
92
+ try:
93
+ values = list(y)
94
+ except TypeError as exc:
95
+ raise ValueError("y must be a one-dimensional sequence") from exc
96
+ if len(values) != n or not values:
97
+ raise ValueError("y must contain one label for every nonempty input row")
98
+ if any(not isinstance(v, Real) or not math.isfinite(v) or v not in (0, 1) for v in values):
99
+ raise ValueError("Only one-dimensional numeric 0/1 labels are supported")
100
+ out = [int(v) for v in values]
101
+ if both_classes and len(set(out)) != 2:
102
+ raise ValueError("Training and selection splits must each contain both classes")
103
+ return out
104
+
105
+
106
+ def validate_xy(X, y, n_features=None, expected_names=None, *, both_classes=True):
107
+ X = validate_X(X, n_features, expected_names)
108
+ return X, validate_y(y, len(X), both_classes=both_classes)
109
+
110
+
111
+ def validate_regression_y(y, n):
112
+ """Finite scalar targets; bound magnitude to the shared tree/format range."""
113
+ if hasattr(y, 'ndim') and y.ndim != 1:
114
+ raise ValueError('Regression y must be one-dimensional')
115
+ if hasattr(y, 'tolist'):
116
+ y = y.tolist()
117
+ if isinstance(y, (str, bytes)):
118
+ raise ValueError('Regression y must contain real numbers')
119
+ try:
120
+ values = list(y)
121
+ except TypeError as exc:
122
+ raise ValueError('Regression y must be a one-dimensional sequence') from exc
123
+ if len(values) != n or not values:
124
+ raise ValueError('Regression y must contain one target for every nonempty input row')
125
+ output = []
126
+ for value in values:
127
+ if isinstance(value, bool) or not isinstance(value, Real):
128
+ raise ValueError('Regression targets must be finite real numbers, excluding bool')
129
+ try:
130
+ number = float(value)
131
+ except (ValueError, OverflowError) as exc:
132
+ raise ValueError('Regression target outside floating-point range') from exc
133
+ if not math.isfinite(number) or abs(number) > 1e100:
134
+ raise ValueError('Regression targets must be finite with magnitude at most 1e100')
135
+ output.append(number)
136
+ return output
137
+
138
+
139
+ def validate_multiclass_y(y, n, classes=None):
140
+ """TRAIN-derived sorted classes: homogeneous strings or signed 64-bit ints."""
141
+ if hasattr(y, 'ndim') and y.ndim != 1:
142
+ raise ValueError('Multiclass y must be one-dimensional')
143
+ if hasattr(y, 'tolist'):
144
+ y = y.tolist()
145
+ if isinstance(y, (str, bytes)):
146
+ raise ValueError('Multiclass y must be a label sequence')
147
+ try:
148
+ values = list(y)
149
+ except TypeError as exc:
150
+ raise ValueError('Multiclass y must be a label sequence') from exc
151
+ if len(values) != n or not values:
152
+ raise ValueError('One label is required for every nonempty input row')
153
+ if not (all(type(v) is int and -(2**63) <= v < 2**63 for v in values)
154
+ or all(type(v) is str and 0 < len(v) <= 256 for v in values)):
155
+ raise ValueError('Classes must be homogeneous strings (1..256 chars) or signed 64-bit integers, excluding bool')
156
+ if classes is None:
157
+ classes = tuple(sorted(set(values)))
158
+ if not 2 <= len(classes) <= 100:
159
+ raise ValueError('Multiclass TRAIN requires 2..100 observed classes')
160
+ if type(values[0]) is not type(classes[0]):
161
+ raise ValueError('Label type does not match TRAIN classes')
162
+ lookup = {label: i for i, label in enumerate(classes)}
163
+ if any(value not in lookup for value in values):
164
+ raise ValueError('Label not observed in TRAIN; class vocabulary never learns from validation/test')
165
+ return [lookup[value] for value in values], tuple(classes)
166
+
167
+
168
+ def split_indices(y, search_fraction, guard_fraction, seed):
169
+ rng = random.Random(seed)
170
+ train, search, guard = [], [], []
171
+ for label in (0, 1):
172
+ ids = [i for i, value in enumerate(y) if value == label]
173
+ if len(ids) < 3:
174
+ raise ValueError("Automatic splitting requires at least 3 examples per class; provide explicit splits")
175
+ rng.shuffle(ids)
176
+ ns = max(1, round(len(ids) * search_fraction))
177
+ ng = max(1, round(len(ids) * guard_fraction))
178
+ while ns + ng >= len(ids):
179
+ if ns >= ng and ns > 1:
180
+ ns -= 1
181
+ elif ng > 1:
182
+ ng -= 1
183
+ else:
184
+ raise ValueError("Too few examples for three disjoint splits")
185
+ search.extend(ids[:ns])
186
+ guard.extend(ids[ns:ns + ng])
187
+ train.extend(ids[ns + ng:])
188
+ for ids in (train, search, guard):
189
+ rng.shuffle(ids)
190
+ return train, search, guard
191
+
192
+
193
+ def regression_split_indices(n, search_fraction, guard_fraction, seed):
194
+ """Target-independent, reproducible partition; every row belongs to one split."""
195
+ if n < 3:
196
+ raise ValueError('Regression tuning requires at least 3 rows; provide explicit SEARCH and GUARD')
197
+ ids = list(range(n))
198
+ random.Random(seed).shuffle(ids)
199
+ ns = max(1, round(n * search_fraction))
200
+ ng = max(1, round(n * guard_fraction))
201
+ while ns + ng >= n:
202
+ if ns >= ng and ns > 1:
203
+ ns -= 1
204
+ elif ng > 1:
205
+ ng -= 1
206
+ else:
207
+ raise ValueError('Too few rows for three disjoint regression splits')
208
+ return ids[ns + ng:], ids[:ns], ids[ns:ns + ng]
209
+
210
+
211
+ def subset(X, y, ids):
212
+ return [X[i] for i in ids], [y[i] for i in ids]
213
+
214
+
215
+ def stable_midpoint(a, b):
216
+ # Avoid overflow and keep the boundary strictly greater than a for bisect_right.
217
+ mid = a + (b - a) / 2 if (a >= 0) == (b >= 0) else a / 2 + b / 2
218
+ return b if mid <= a else mid
219
+
220
+
221
+ class Binner:
222
+ """Bin zero is missing; observed bins are 1..max_bins."""
223
+ def __init__(self, max_bins=31, policy="quantile"):
224
+ checked_int("max_bins", max_bins, 2, 4096)
225
+ if policy not in ("quantile", "uniform"):
226
+ raise ValueError("Unknown binning policy")
227
+ self.max_bins, self.policy = max_bins, policy
228
+ self.thresholds = None
229
+
230
+ def fit(self, X):
231
+ cuts = []
232
+ for j in range(len(X[0])):
233
+ nominal = getattr(X, '_nominal_sizes', {}).get(j)
234
+ if nominal is not None:
235
+ cuts.append(tuple(float(v) for v in range(nominal + 1)))
236
+ continue
237
+ column = X.column(j) if hasattr(X, '_compact_columns') else (row[j] for row in X)
238
+ values = sorted(v for v in column if not math.isnan(v))
239
+ # Only <=max_bins distinct values need enumeration. Otherwise the
240
+ # existing rank quantiles need the sorted observations and extrema,
241
+ # not a second O(n)-sized set/list or a second sort. Bisect skips
242
+ # duplicate runs, so low-cardinality columns do not incur a Python
243
+ # scan per observation. Preserve each first stable representative,
244
+ # including the sign of zero, as sorted(set(values)) did.
245
+ unique, position = [], 0
246
+ while position < len(values) and len(unique) <= self.max_bins:
247
+ value = values[position]
248
+ unique.append(value)
249
+ position = bisect_right(values, value, position + 1)
250
+ if len(unique) <= 1:
251
+ thresholds = []
252
+ elif len(unique) <= self.max_bins:
253
+ thresholds = [stable_midpoint(a, b) for a, b in zip(unique, unique[1:])]
254
+ elif self.policy == "uniform":
255
+ low, high = values[0], values[bisect_left(values, values[-1])]
256
+ thresholds = [low * (1 - k / self.max_bins) + high * (k / self.max_bins) for k in range(1, self.max_bins)]
257
+ else:
258
+ thresholds = [values[min(len(values) - 1, (k * len(values)) // self.max_bins)] for k in range(1, self.max_bins)]
259
+ cuts.append(tuple(sorted({v for v in thresholds if values[0] < v <= values[-1]})) if thresholds else ())
260
+ self.thresholds = tuple(cuts)
261
+ return self
262
+
263
+ def transform(self, X):
264
+ if self.thresholds is None:
265
+ raise RuntimeError("Binner is not fitted")
266
+ kind = "B" if self.max_bins <= 255 else "H"
267
+ return [array(kind, (0 if math.isnan(row[j]) else bisect_right(cuts, row[j]) + 1 for row in X))
268
+ for j, cuts in enumerate(self.thresholds)]
269
+
270
+ def to_dict(self):
271
+ return {"max_bins": self.max_bins, "policy": self.policy, "thresholds": [list(c) for c in self.thresholds]}
272
+
273
+
274
+ def read_csv(path, target=None, expected_names=None):
275
+ with open(path, encoding="utf-8-sig", newline="") as handle:
276
+ reader = csv.reader(handle)
277
+ try:
278
+ names = next(reader)
279
+ except StopIteration as exc:
280
+ raise ValueError("CSV is empty") from exc
281
+ validate_names(names, len(names))
282
+ if target is not None and target not in names:
283
+ raise ValueError(f"Target column {target!r} not found")
284
+ features = [c for c in names if c != target]
285
+ if expected_names is not None:
286
+ if set(features) != set(expected_names):
287
+ raise ValueError("CSV feature schema mismatch")
288
+ features = list(expected_names)
289
+ indices = [names.index(c) for c in features]
290
+ X, y = [], []
291
+ for line, row in enumerate(reader, 2):
292
+ if len(row) != len(names):
293
+ raise ValueError(f"Malformed CSV row {line}")
294
+ try:
295
+ X.append([float(row[j]) if row[j].strip().lower() not in ("", "nan", "none", "null") else None for j in indices])
296
+ if target is not None:
297
+ y.append(float(row[names.index(target)]))
298
+ except ValueError as exc:
299
+ raise ValueError(f"Nonnumeric CSV value at row {line}") from exc
300
+ return X, y if target is not None else None, features
@@ -0,0 +1,158 @@
1
+ """Persistent sample-error memory and bounded, explicitly heuristic diagnosis."""
2
+ from __future__ import annotations
3
+ from array import array
4
+ from dataclasses import dataclass
5
+ import math
6
+
7
+ class ErrorMemory:
8
+
9
+ def __init__(self, size, beta=0.9):
10
+ self.beta = beta
11
+ self.ema_error = array('d', [0.0]) * size
12
+ self.signed_residual_ema = array('d', [0.0]) * size
13
+ self.wrong_streak = array('I', [0]) * size
14
+ self.uncertainty = array('d', [0.0]) * size
15
+ self.hardness = array('d', [0.0]) * size
16
+ self.direction_consistency = array('d', [0.0]) * size
17
+ self.updates = 0
18
+
19
+ def update(self, y, p):
20
+ if len(y) != len(self.ema_error) or len(p) != len(y):
21
+ raise ValueError('ErrorMemory sample alignment mismatch')
22
+ self.updates += 1
23
+ correction = 1 - self.beta ** self.updates
24
+ for i, (label, probability) in enumerate(zip(y, p)):
25
+ residual = label - probability
26
+ self.ema_error[i] = self.beta * self.ema_error[i] + (1 - self.beta) * abs(residual)
27
+ self.signed_residual_ema[i] = self.beta * self.signed_residual_ema[i] + (1 - self.beta) * residual
28
+ self.wrong_streak[i] = min(2 ** 32 - 1, self.wrong_streak[i] + 1) if (probability >= 0.5) != label else 0
29
+ self.uncertainty[i] = 4 * probability * (1 - probability)
30
+ mean_error = self.ema_error[i] / max(correction, 1e-12)
31
+ self.direction_consistency[i] = abs(self.signed_residual_ema[i]) / max(self.ema_error[i], 1e-12)
32
+ self.hardness[i] = min(1.0, 0.7 * mean_error + 0.3 * min(self.wrong_streak[i] / 8, 1.0))
33
+
34
+ def summary(self):
35
+ n = max(1, len(self.ema_error))
36
+ return {'updates': self.updates, 'mean_error_ema': math.fsum(self.ema_error) / n, 'mean_hardness': math.fsum(self.hardness) / n, 'persistent_fraction': sum((v >= 3 for v in self.wrong_streak)) / n, 'mean_uncertainty': math.fsum(self.uncertainty) / n, 'mean_direction_consistency': math.fsum(self.direction_consistency) / n, 'max_wrong_streak': max(self.wrong_streak, default=0)}
37
+
38
+ @dataclass(frozen=True)
39
+ class Diagnosis:
40
+ name: str
41
+ strength: float
42
+ confidence: float
43
+ evidence: str
44
+
45
+ class ErrorProfiler:
46
+ """Diagnostics are hypotheses, not identified causes of prediction errors."""
47
+
48
+ def profile(self, X_train, y_train, memory, X_search, y_search, p_search):
49
+ if memory is None or len(memory.ema_error) != len(y_train):
50
+ raise ValueError('Profiler requires aligned persistent TRAIN error memory')
51
+ n = len(y_search)
52
+ residuals = [label - p for label, p in zip(y_search, p_search)]
53
+ absolute = [abs(r) for r in residuals]
54
+ mean = math.fsum(absolute) / n
55
+ bias = abs(math.fsum(residuals) / n)
56
+ variance = math.fsum((r * r for r in residuals)) / n - (math.fsum(residuals) / n) ** 2
57
+ memory_stats = memory.summary()
58
+ confidence = min(1.0, math.sqrt(n / 120))
59
+ groups = []
60
+ local_gap, missing_gap, small_gap = (0.0, 0.0, 0.0)
61
+ partition_bits = []
62
+ for j in range(len(X_train[0])):
63
+ present = sorted((row[j] for row in X_train if not math.isnan(row[j])))
64
+ threshold = 0.5 * present[(len(present) - 1) // 2] + 0.5 * present[len(present) // 2] if present else 0.0
65
+ low = [i for i, row in enumerate(X_search) if not math.isnan(row[j]) and row[j] <= threshold]
66
+ high = [i for i, row in enumerate(X_search) if not math.isnan(row[j]) and row[j] > threshold]
67
+ missing = [i for i, row in enumerate(X_search) if math.isnan(row[j])]
68
+ partition_bits.append([not math.isnan(row[j]) and row[j] > threshold for row in X_search])
69
+ for name, ids in (('low', low), ('high', high), ('missing', missing)):
70
+ if len(ids) < 3:
71
+ continue
72
+ group_error = math.fsum((absolute[i] for i in ids)) / len(ids)
73
+ gap = max(0.0, group_error - mean)
74
+ support = min(1.0, len(ids) / 20)
75
+ local_gap = max(local_gap, gap * support)
76
+ if name == 'missing':
77
+ missing_gap = max(missing_gap, gap * support)
78
+ if len(ids) < max(8, n * 0.1):
79
+ small_gap = max(small_gap, gap * support)
80
+ groups.append({'feature': j, 'region': name, 'count': len(ids), 'error': group_error, 'excess_error': gap})
81
+ class_errors = []
82
+ for label in (0, 1):
83
+ ids = [i for i, value in enumerate(y_search) if value == label]
84
+ class_errors.append(math.fsum((absolute[i] for i in ids)) / max(len(ids), 1))
85
+ imbalance = min(y_train.count(0), y_train.count(1)) / len(y_train)
86
+ interaction = 0.0
87
+ for a in range(min(4, len(partition_bits))):
88
+ for b in range(a + 1, min(4, len(partition_bits))):
89
+ for abit, bbit in ((False, False), (False, True), (True, False), (True, True)):
90
+ ids = [i for i in range(n) if partition_bits[a][i] == abit and partition_bits[b][i] == bbit]
91
+ if len(ids) >= 5:
92
+ signed = abs(math.fsum((residuals[i] for i in ids)) / len(ids))
93
+ interaction = max(interaction, max(0.0, signed - bias - local_gap) * min(1, len(ids) / 20))
94
+ strengths = {'PERSISTENT_ERROR': memory_stats['persistent_fraction'], 'HIGH_RESIDUAL_VARIANCE': min(1, variance * 4), 'SIGNED_BIAS': min(1, bias * 4), 'HIGH_UNCERTAINTY': memory_stats['mean_uncertainty'], 'LOCAL_ERROR_CONCENTRATION': min(1, local_gap * 4), 'SMALL_UNSTABLE_REGION': min(1, small_gap * 4), 'MISSING_RELATED_ERROR': min(1, missing_gap * 4), 'IMBALANCE_RELATED_ERROR': min(1, abs(class_errors[0] - class_errors[1]) * 3) if imbalance < 0.35 else 0.0, 'INTERACTION_LIKE_ERROR': min(1, interaction * 3), 'NOISE_LIKE': min(1, mean * 2) * max(0, 1 - local_gap * 5 - bias * 4 - interaction * 3), 'LOW_STRUCTURED_ERROR': max(0, 1 - max(local_gap * 4, missing_gap * 4, bias * 4, interaction * 3))}
95
+ diagnoses = [Diagnosis(name, float(strength), confidence, 'TRAIN persistent memory and SEARCH residual partitions') for name, strength in strengths.items()]
96
+ return {'diagnoses': [d.__dict__ for d in diagnoses], 'memory': memory_stats, 'regions': sorted(groups, key=lambda g: g['excess_error'], reverse=True)[:8], 'search_mean_absolute_residual': mean, 'search_signed_bias': bias, 'confidence': confidence, 'interpretation': 'heuristic hypotheses, not causal conclusions'}
97
+
98
+ def profile_task(self, X, y, prediction, task):
99
+ """Current-parent TRAIN-OOF residual hypotheses, not predictive utility.
100
+
101
+ Multiclass uses its true K-vector residuals, not binary training.
102
+ Regression stays continuous and is scaled using TRAIN targets only.
103
+ The bounded region probe follows the existing profiler's logic.
104
+ """
105
+ n = len(y)
106
+ if task not in ('multiclass', 'regression') or not n or len(X) != n or (len(prediction) != n):
107
+ raise ValueError('Aligned task-specific residual data required')
108
+ if task == 'regression':
109
+ if any((type(v) not in (int, float) or not math.isfinite(v) for v in list(y) + list(prediction))):
110
+ raise ValueError('Finite continuous targets/predictions required')
111
+ mean = math.fsum((v / n for v in y))
112
+ scale = math.hypot(*(v - mean for v in y)) / math.sqrt(n)
113
+ scale = scale if scale > 1e-12 else 1.0
114
+ vectors = [[(a - b) / scale for a, b in zip(y, prediction)]]
115
+ labels = [None]
116
+ else:
117
+ labels = sorted(set(y))
118
+ k = len(labels)
119
+ scale = 1.0
120
+ if labels != list(range(k)) or k < 2 or any((type(p) is not list or len(p) != k or any((type(v) not in (int, float) or not math.isfinite(v) or v < 0 for v in p)) or (abs(math.fsum(p) - 1) > 1e-12) for p in prediction)):
121
+ raise ValueError('Native contiguous K-class normalized probabilities required')
122
+ vectors = [[int(label == c) - p[c] for label, p in zip(y, prediction)] for c in labels]
123
+ probes = [self._task_residual_probe(X, r) for r in vectors]
124
+ names = ('INTERACTION_LIKE_ERROR', 'SMALL_UNSTABLE_REGION')
125
+ confidence = min(1.0, math.sqrt(n / 120.0))
126
+ return {'task': task, 'rows': n, 'target_scale_TRAIN_only': scale, 'diagnoses': [{'name': name, 'strength': max((p[name] for p in probes)), 'confidence': confidence, 'evidence': 'Current-parent TRAIN-OOF residual partitions'} for name in names], 'per_output': [{'class': c, 'probe': p} for c, p in zip(labels, probes)], 'policy_id': 'PRODUCT002_TASK_OOF_HEURISTIC_V1', 'fold_stability': 'UNKNOWN_NOT_ESTIMATED_FROM_ONE_STREAM', 'interpretation': 'Bounded residual associations; no causal or prospective utility claim'}
127
+
128
+ def _task_residual_probe(self, X, residual):
129
+ n = len(residual)
130
+ absolute = [abs(v) for v in residual]
131
+ if any((not math.isfinite(v) for v in absolute)):
132
+ raise ValueError('Residual overflow')
133
+ mean = math.fsum(absolute) / n
134
+ bias = abs(math.fsum(residual) / n)
135
+ local = small = interaction = 0.0
136
+ bits = []
137
+ for j in range(len(X[0])):
138
+ present = sorted((row[j] for row in X if not math.isnan(row[j])))
139
+ threshold = 0.5 * present[(len(present) - 1) // 2] + 0.5 * present[len(present) // 2] if present else 0.0
140
+ low = [i for i, row in enumerate(X) if not math.isnan(row[j]) and row[j] <= threshold]
141
+ high = [i for i, row in enumerate(X) if not math.isnan(row[j]) and row[j] > threshold]
142
+ missing = [i for i, row in enumerate(X) if math.isnan(row[j])]
143
+ bits.append([not math.isnan(row[j]) and row[j] > threshold for row in X])
144
+ for ids in (low, high, missing):
145
+ if len(ids) < 3:
146
+ continue
147
+ gap = max(0.0, math.fsum((absolute[i] for i in ids)) / len(ids) - mean) * min(1.0, len(ids) / 20.0)
148
+ local = max(local, gap)
149
+ if len(ids) < max(8, n * 0.1):
150
+ small = max(small, gap)
151
+ for a in range(min(4, len(bits))):
152
+ for b in range(a + 1, min(4, len(bits))):
153
+ for av, bv in ((False, False), (False, True), (True, False), (True, True)):
154
+ ids = [i for i in range(n) if bits[a][i] == av and bits[b][i] == bv]
155
+ if len(ids) >= 5:
156
+ signed = abs(math.fsum((residual[i] for i in ids)) / len(ids))
157
+ interaction = max(interaction, max(0.0, signed - bias - local) * min(1.0, len(ids) / 20.0))
158
+ return {'INTERACTION_LIKE_ERROR': min(1.0, interaction * 3.0), 'SMALL_UNSTABLE_REGION': min(1.0, small * 4.0), 'mean_absolute_residual': mean, 'absolute_signed_bias': bias, 'local_excess': local}
@@ -0,0 +1,156 @@
1
+ """Task-specific public estimators sharing the existing evolutionary engine."""
2
+ import copy
3
+
4
+ from .api import MorphTree
5
+
6
+
7
+ def _score_targets(y, length):
8
+ from ._experimental.preprocessing import _m26_scalar
9
+ values = y.tolist() if hasattr(y, 'tolist') else y
10
+ if not isinstance(values, (list, tuple)) or not values or len(values) != length:
11
+ raise ValueError('score requires nonempty aligned scalar targets')
12
+ values = [_m26_scalar(value) for value in values]
13
+ if any(value is None for value in values):
14
+ raise ValueError('Missing score targets are unsupported')
15
+ return values
16
+
17
+
18
+ class _TaskEstimator:
19
+ _task_kind = None
20
+ _fitted_attributes = frozenset(('task_', 'n_features_in_', 'feature_names_in_',
21
+ 'best_family_', 'best_params_', 'stop_reason_', 'last_attempt_'))
22
+
23
+ def __init__(self, *, random_state=None, verbosity=0, use_optuna=True,
24
+ max_mutations=2, time_budget='auto', n_trials='auto', n_jobs=1,
25
+ capacity=None, mode='adaptive', operators=None):
26
+ self._engine = MorphTree(task=self._task_kind, random_state=random_state,
27
+ verbosity=verbosity, use_optuna=use_optuna, max_mutations=max_mutations,
28
+ time_budget=time_budget, n_trials=n_trials, n_jobs=n_jobs,
29
+ capacity=capacity, mode=mode, operators=operators)
30
+
31
+ def get_params(self, deep=True):
32
+ engine = self._engine
33
+ return copy.deepcopy(dict(random_state=engine.random_state,
34
+ verbosity=engine.verbose, use_optuna=engine.tuning != 'none',
35
+ max_mutations=engine._max_mutations, time_budget=engine.time_budget,
36
+ n_trials=engine.n_trials, n_jobs=engine.n_jobs, capacity=engine.capacity,
37
+ mode=engine.mode,
38
+ operators=('growth_policy:depthwise',) if engine._research_growth else None))
39
+
40
+ def set_params(self, **values):
41
+ params = self.get_params()
42
+ if set(values) - set(params):
43
+ raise ValueError('Unknown estimator parameter: ' + ', '.join(sorted(set(values)-set(params))))
44
+ replacement = type(self)(**(params | values))
45
+ self._engine = replacement._engine
46
+ return self
47
+
48
+ def __getattr__(self, name):
49
+ # Forward only documented attributes; do not expose the internal engine
50
+ # as if the task facade itself were a raw native learner.
51
+ if name in self._fitted_attributes or (name == 'classes_' and self._task_kind == 'classification'):
52
+ return copy.deepcopy(getattr(self._engine, name))
53
+ if name in ('random_state', 'verbosity', 'use_optuna', 'max_mutations',
54
+ 'time_budget', 'n_trials', 'n_jobs', 'capacity', 'mode', 'operators'):
55
+ return self.get_params()[name]
56
+ raise AttributeError(name)
57
+
58
+ def fit(self, X, y):
59
+ # Input materialization, context/sealed-data guards, finite budgets and
60
+ # fit rollback remain in the same engine as the legacy MorphTree API.
61
+ self._engine.fit(X, y)
62
+ return self
63
+
64
+ def predict(self, X):
65
+ from .prediction_output import prediction_array
66
+ return prediction_array(self._engine.predict(X),regression=self._task_kind=='regression')
67
+
68
+ def save(self, path, *, variant='all'):
69
+ return self._engine.save(path, variant=variant)
70
+
71
+ @classmethod
72
+ def load(cls, path, *, sha256=None, workdir=None):
73
+ engine = MorphTree.load(path, sha256=sha256, workdir=workdir)
74
+ allowed = ('binary', 'multiclass') if cls._task_kind == 'classification' else ('regression',)
75
+ if engine.task_ not in allowed:
76
+ workspace = getattr(engine, '_loaded_workspace', None)
77
+ if workspace is not None:
78
+ workspace.cleanup()
79
+ raise ValueError('Saved model task is incompatible with ' + cls.__name__)
80
+ # Keep loaded weights, encoder, variants, recipe and evidence unchanged.
81
+ # A classifier refit may switch between binary and multiclass targets.
82
+ engine.task = cls._task_kind
83
+ obj = cls.__new__(cls)
84
+ obj._engine = engine
85
+ return obj
86
+
87
+ def report(self):
88
+ return self._engine.report()
89
+
90
+ def get_search_report(self):
91
+ return self._engine.get_search_report()
92
+
93
+ def get_algorithm_program(self):
94
+ return self._engine.get_algorithm_program()
95
+
96
+ def get_mutation_history(self):
97
+ return self._engine.get_mutation_history()
98
+
99
+ def get_recipe(self):
100
+ return self._engine.get_recipe()
101
+
102
+ def cost_report(self):
103
+ return self._engine.cost_report()
104
+
105
+ def available_variants(self):
106
+ return self._engine.available_variants()
107
+
108
+ def save_recipe(self, path, *, variant='selected'):
109
+ return self._engine.save_recipe(path, variant=variant)
110
+
111
+ def export_python(self, path, *, variant='selected',output_format='python'):
112
+ if output_format not in ('python','numpy'):raise ValueError('output_format must be python or numpy')
113
+ if output_format=='python':return self._engine.export_python(path,variant=variant)
114
+ from .prediction_output import numpy_export_source
115
+ from .persistence import atomic_text,read_regular_bytes
116
+ import hashlib
117
+ # Keep the historical public-output guards and owned work directory.
118
+ with self._engine._public_output(path):
119
+ self._engine.export_python(path,variant=variant)
120
+ source=read_regular_bytes(path,32*1024**2).decode('utf8')
121
+ source=numpy_export_source(source,self.classes_ if self._task_kind=='classification' else [],self._task_kind=='regression')
122
+ if len(source.encode('utf8'))>32*1024**2:raise MemoryError('Historical standalone source cap exceeded')
123
+ atomic_text(path,source)
124
+ return hashlib.sha256(source.encode('utf8')).hexdigest()
125
+
126
+ def export_standalone(self,path,*,output_format='python'):
127
+ return self.export_python(path,output_format=output_format)
128
+
129
+ def export_algorithm(self, path):
130
+ return self._engine.export_algorithm(path)
131
+
132
+
133
+ class MorphTreeClassifier(_TaskEstimator):
134
+ """Native evolutionary classification; resolves binary versus multiclass."""
135
+ _task_kind = 'classification'
136
+ _estimator_type = 'classifier'
137
+
138
+ def predict_proba(self, X):
139
+ from .prediction_output import probability_array
140
+ return probability_array(self._engine.predict_proba(X),len(self.classes_),n_samples=len(X))
141
+
142
+ def score(self, X, y):
143
+ prediction = self.predict(X)
144
+ targets = _score_targets(y, len(prediction))
145
+ return sum(a == (b.item() if hasattr(b,'item') else b) for a, b in zip(targets, prediction)) / len(targets)
146
+
147
+
148
+ class MorphTreeRegressor(_TaskEstimator):
149
+ """Native evolutionary regression with an explicitly numeric target."""
150
+ _task_kind = 'regression'
151
+ _estimator_type = 'regressor'
152
+
153
+ def score(self, X, y):
154
+ from .metrics import r2_score
155
+ prediction = self.predict(X)
156
+ return r2_score(_score_targets(y, len(prediction)), prediction)
morphtree/examples.py ADDED
@@ -0,0 +1,25 @@
1
+ """Normal user code, executed by an approved host in this qualification build.
2
+
3
+ No Session, checkpoint, campaign identifier or research directory is an API
4
+ argument. Current standalone training remains disabled until a run is approved.
5
+ The same function works for binary, multiclass and regression targets.
6
+ """
7
+ from morphtree import MorphTree
8
+
9
+
10
+ def train(X_train, y_train, *, task='auto', time_budget=300):
11
+ model=MorphTree(task=task,mode='baseline',random_state=42,
12
+ tuning='none',time_budget=time_budget)
13
+ model.fit(X_train,y_train)
14
+ return model
15
+
16
+
17
+ def save_and_predict(model, X_test, path, export_path):
18
+ model.save(path)
19
+ restored=MorphTree.load(path)
20
+ predictions=restored.predict(X_test)
21
+ probabilities=(restored.predict_proba(X_test)
22
+ if restored.task_!='regression' else None)
23
+ restored.export_python(export_path)
24
+ return {'predictions':predictions,'probabilities':probabilities,
25
+ 'recipe':restored.recipe(),'configuration':restored.get_params()}