morphtree 2.1.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- morphtree/__init__.py +28 -0
- morphtree/__main__.py +3 -0
- morphtree/_experimental/__init__.py +1 -0
- morphtree/_experimental/admission.py +364 -0
- morphtree/_experimental/base.py +215 -0
- morphtree/_experimental/component_cache_v2.py +263 -0
- morphtree/_experimental/evolution_report.py +307 -0
- morphtree/_experimental/execution_v2.py +697 -0
- morphtree/_experimental/facade.py +289 -0
- morphtree/_experimental/integrated.py +748 -0
- morphtree/_experimental/learning.py +418 -0
- morphtree/_experimental/manifest.json +204 -0
- morphtree/_experimental/numeric_fixture.py +21 -0
- morphtree/_experimental/parameter_contract.py +38 -0
- morphtree/_experimental/postfit.py +51 -0
- morphtree/_experimental/preprocessing.py +133 -0
- morphtree/_experimental/processes.py +191 -0
- morphtree/_experimental/producer.py +542 -0
- morphtree/_experimental/product.py +156 -0
- morphtree/_experimental/product_cli.py +43 -0
- morphtree/_experimental/roundtrip_v3.py +311 -0
- morphtree/_experimental/runtime_io.py +65 -0
- morphtree/_experimental/sampling.py +26 -0
- morphtree/_experimental/support.py +281 -0
- morphtree/_experimental/target.py +59 -0
- morphtree/_experimental/training.py +53 -0
- morphtree/_experimental/worker.py +682 -0
- morphtree/_installed.py +17 -0
- morphtree/_native/__init__.py +1 -0
- morphtree/_native/bin/win_amd64/morphtree_kernels.dll +0 -0
- morphtree/_native/buffers.py +82 -0
- morphtree/_native/loader.py +39 -0
- morphtree/_native/manifest.json +29 -0
- morphtree/_native/runtime.py +48 -0
- morphtree/_native/src/histogram.c +61 -0
- morphtree/_native/src/traversal.c +51 -0
- morphtree/api.py +388 -0
- morphtree/api_compat.py +65 -0
- morphtree/backend.py +162 -0
- morphtree/booster.py +252 -0
- morphtree/cli.py +52 -0
- morphtree/config.py +262 -0
- morphtree/control.py +20 -0
- morphtree/data.py +300 -0
- morphtree/diagnostics.py +158 -0
- morphtree/estimators.py +156 -0
- morphtree/examples.py +25 -0
- morphtree/exporting.py +224 -0
- morphtree/legacy_artifacts.py +32 -0
- morphtree/metrics.py +156 -0
- morphtree/model.py +1010 -0
- morphtree/mutations.py +205 -0
- morphtree/native_lab_backend.py +79 -0
- morphtree/numpy_backend.py +180 -0
- morphtree/optuna_tuner.py +154 -0
- morphtree/persistence.py +414 -0
- morphtree/prediction_output.py +35 -0
- morphtree/prepared_bins.py +82 -0
- morphtree/public.py +106 -0
- morphtree/resource_plan.py +170 -0
- morphtree/scalable.py +781 -0
- morphtree/scalable_backend.py +74 -0
- morphtree/scalable_checkpoint.py +129 -0
- morphtree/scalable_data.py +255 -0
- morphtree/scalable_receipts.py +92 -0
- morphtree/scalable_target.py +68 -0
- morphtree/scalable_variants.py +133 -0
- morphtree/sklearn_view.py +79 -0
- morphtree/source_markers.py +25 -0
- morphtree/species.py +47 -0
- morphtree/temporary.py +65 -0
- morphtree/training_fragment.py +130 -0
- morphtree/tree.py +231 -0
- morphtree/tuning.py +150 -0
- morphtree-2.1.0.dist-info/METADATA +212 -0
- morphtree-2.1.0.dist-info/RECORD +79 -0
- morphtree-2.1.0.dist-info/WHEEL +4 -0
- morphtree-2.1.0.dist-info/entry_points.txt +2 -0
- morphtree-2.1.0.dist-info/licenses/LICENSE +21 -0
morphtree/persistence.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Bounded JSON artifacts; strict graph validation, atomic writes, no pickle."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import asdict, dataclass, fields, replace
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import tempfile
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from .config import AlgorithmGenome, HyperParams, SPEC_VERSION, VERSION, validate_task_genome
|
|
14
|
+
from .data import Binner, validate_names
|
|
15
|
+
from .tree import HistogramTree, TreeNode
|
|
16
|
+
from .booster import BinaryHistogramBooster
|
|
17
|
+
from .public import learning_fingerprint, public_metadata, validate_recipe
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class LoadLimits:
|
|
22
|
+
max_bytes: int = 64 * 1024 * 1024
|
|
23
|
+
max_features: int = 4096
|
|
24
|
+
max_trees: int = 4096
|
|
25
|
+
max_nodes: int = 262144
|
|
26
|
+
max_thresholds: int = 1000000
|
|
27
|
+
max_json_depth: int = 256
|
|
28
|
+
|
|
29
|
+
def __post_init__(self):
|
|
30
|
+
for field in fields(self):
|
|
31
|
+
value = getattr(self, field.name)
|
|
32
|
+
if type(value) is not int or value < 1:
|
|
33
|
+
raise ValueError("Loader limits must be positive integers")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _unique_object(pairs):
|
|
37
|
+
result = {}
|
|
38
|
+
for key, value in pairs:
|
|
39
|
+
if key in result:
|
|
40
|
+
raise ValueError('Duplicate JSON key')
|
|
41
|
+
result[key] = value
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _reject_constant(value):
|
|
46
|
+
raise ValueError(f"Nonfinite JSON constant: {value}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def regular_file_size(path):
|
|
50
|
+
info = Path(path).stat()
|
|
51
|
+
if not stat.S_ISREG(info.st_mode):
|
|
52
|
+
raise ValueError('Model artifact must be a regular file')
|
|
53
|
+
return info.st_size
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def read_regular_bytes(path, max_bytes):
|
|
57
|
+
"""Read one bounded regular-file snapshot, never wait for a FIFO writer.
|
|
58
|
+
|
|
59
|
+
POSIX nonblocking open also covers a regular-path/FIFO replacement between
|
|
60
|
+
stat and open. Descriptor validation precedes reading on every platform.
|
|
61
|
+
Caller-supplied regular-file symlinks remain supported; checksum custody
|
|
62
|
+
and typed JSON validation still apply to the exact bytes returned.
|
|
63
|
+
"""
|
|
64
|
+
if type(max_bytes) is not int or max_bytes < 1:
|
|
65
|
+
raise ValueError('Positive immutable snapshot allowance required')
|
|
66
|
+
regular_file_size(path)
|
|
67
|
+
flags = os.O_RDONLY | getattr(os, 'O_BINARY', 0) | getattr(os, 'O_NONBLOCK', 0)
|
|
68
|
+
descriptor = os.open(path, flags)
|
|
69
|
+
try:
|
|
70
|
+
info = os.fstat(descriptor)
|
|
71
|
+
if not stat.S_ISREG(info.st_mode):
|
|
72
|
+
raise ValueError('Model artifact descriptor must be a regular file')
|
|
73
|
+
if info.st_size > max_bytes:
|
|
74
|
+
raise ValueError('Artifact exceeds max_bytes')
|
|
75
|
+
with os.fdopen(descriptor, 'rb') as handle:
|
|
76
|
+
descriptor = None
|
|
77
|
+
# The file ceiling is admission, not the buffer size. BufferedReader
|
|
78
|
+
# may reserve its requested length even for a tiny regular file.
|
|
79
|
+
# One extra byte detects growth without ceiling-sized allocation.
|
|
80
|
+
raw = handle.read(info.st_size + 1)
|
|
81
|
+
after = os.fstat(handle.fileno())
|
|
82
|
+
if len(raw) > max_bytes or after.st_size > max_bytes:
|
|
83
|
+
raise ValueError('Artifact exceeds max_bytes')
|
|
84
|
+
if (info.st_size, info.st_mtime_ns) != (after.st_size, after.st_mtime_ns) or len(raw) != info.st_size:
|
|
85
|
+
raise ValueError('Artifact changed while reading immutable snapshot')
|
|
86
|
+
return raw
|
|
87
|
+
finally:
|
|
88
|
+
if descriptor is not None:
|
|
89
|
+
os.close(descriptor)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_json(path, limits=None):
|
|
93
|
+
limits = limits or LoadLimits()
|
|
94
|
+
return decode_json_bytes(read_regular_bytes(path, limits.max_bytes), limits)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _admit_json_workspace(raw, limits, reserve):
|
|
98
|
+
"""Conservative topology forecast before stdlib parsing, constant space.
|
|
99
|
+
|
|
100
|
+
This is resource admission, not a replacement JSON parser or an absolute
|
|
101
|
+
process-memory ceiling. Syntax, duplicate keys and finite values still go
|
|
102
|
+
through the strict decoder below. Charges include pairs-hook and validation
|
|
103
|
+
workspace; dense containers must not hide behind a file-byte multiplier.
|
|
104
|
+
"""
|
|
105
|
+
estimate = 4096 + len(raw) * 12
|
|
106
|
+
if reserve is not None:
|
|
107
|
+
reserve(estimate, 'JSON decoding workspace before parsing')
|
|
108
|
+
depth = 0
|
|
109
|
+
quoted = escaped = atom = False
|
|
110
|
+
for position, byte in enumerate(raw):
|
|
111
|
+
if quoted:
|
|
112
|
+
estimate += 8
|
|
113
|
+
if escaped:
|
|
114
|
+
escaped = False
|
|
115
|
+
elif byte == 92:
|
|
116
|
+
escaped = True
|
|
117
|
+
elif byte == 34:
|
|
118
|
+
quoted = False
|
|
119
|
+
elif byte == 34:
|
|
120
|
+
if depth > limits.max_json_depth:
|
|
121
|
+
raise ValueError('JSON nesting depth exceeds supported limit')
|
|
122
|
+
estimate += 256
|
|
123
|
+
quoted = True
|
|
124
|
+
atom = False
|
|
125
|
+
elif byte in (123, 91):
|
|
126
|
+
if depth > limits.max_json_depth:
|
|
127
|
+
raise ValueError('JSON nesting depth exceeds supported limit')
|
|
128
|
+
depth += 1
|
|
129
|
+
estimate += 1024 if byte == 123 else 128
|
|
130
|
+
atom = False
|
|
131
|
+
elif byte in (125, 93):
|
|
132
|
+
depth -= 1
|
|
133
|
+
atom = False
|
|
134
|
+
elif byte in (44, 58):
|
|
135
|
+
estimate += 128
|
|
136
|
+
atom = False
|
|
137
|
+
elif byte in (9, 10, 13, 32):
|
|
138
|
+
atom = False
|
|
139
|
+
elif not atom:
|
|
140
|
+
if depth > limits.max_json_depth:
|
|
141
|
+
raise ValueError('JSON nesting depth exceeds supported limit')
|
|
142
|
+
estimate += 128
|
|
143
|
+
atom = True
|
|
144
|
+
if reserve is not None and position % 4096 == 0:
|
|
145
|
+
reserve(estimate, 'JSON topology workspace before parsing')
|
|
146
|
+
if reserve is not None:
|
|
147
|
+
reserve(estimate, 'JSON topology workspace before parsing')
|
|
148
|
+
return estimate
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def decode_json_bytes(raw, limits=None, *, reserve=None):
|
|
152
|
+
"""Validate one byte snapshot; checksum and decoder see identical bytes."""
|
|
153
|
+
limits = limits or LoadLimits()
|
|
154
|
+
if type(raw) is not bytes:
|
|
155
|
+
raise ValueError('Immutable JSON bytes required')
|
|
156
|
+
if len(raw) > limits.max_bytes:
|
|
157
|
+
raise ValueError("Artifact exceeds max_bytes")
|
|
158
|
+
_admit_json_workspace(raw, limits, reserve)
|
|
159
|
+
try:
|
|
160
|
+
value = json.loads(raw, object_pairs_hook=_unique_object, parse_constant=_reject_constant)
|
|
161
|
+
except (UnicodeError, json.JSONDecodeError, RecursionError) as exc:
|
|
162
|
+
raise ValueError("Malformed or excessively nested JSON artifact") from exc
|
|
163
|
+
if not isinstance(value, dict):
|
|
164
|
+
raise ValueError("Artifact must be a JSON object")
|
|
165
|
+
# Keep iterators on the current ancestry only: a wide array must not create
|
|
166
|
+
# a second width-sized list of (child,depth) tuples during validation.
|
|
167
|
+
pending = [(iter((value,)), 0)]
|
|
168
|
+
while pending:
|
|
169
|
+
children, depth = pending[-1]
|
|
170
|
+
try:
|
|
171
|
+
node = next(children)
|
|
172
|
+
except StopIteration:
|
|
173
|
+
pending.pop()
|
|
174
|
+
continue
|
|
175
|
+
if depth > limits.max_json_depth:
|
|
176
|
+
raise ValueError('JSON nesting depth exceeds supported limit')
|
|
177
|
+
if isinstance(node, dict):
|
|
178
|
+
pending.append((iter(node.values()), depth + 1))
|
|
179
|
+
elif isinstance(node, list):
|
|
180
|
+
pending.append((iter(node), depth + 1))
|
|
181
|
+
elif isinstance(node, float) and not math.isfinite(node):
|
|
182
|
+
raise ValueError("Nonfinite JSON number")
|
|
183
|
+
if reserve is not None:
|
|
184
|
+
reserve(0, 'JSON decoding completed')
|
|
185
|
+
return value
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def atomic_text(path, text):
|
|
189
|
+
path = Path(path)
|
|
190
|
+
# The directory must already exist. Never create arbitrary parent paths from artifact content.
|
|
191
|
+
# CPython's Windows mkstemp may retry PermissionError as a name collision
|
|
192
|
+
# when os.access reports writable. Do not spin through TMP_MAX candidates:
|
|
193
|
+
# one unpredictable O_EXCL name, and preserve the actual filesystem error.
|
|
194
|
+
temporary = path.parent / ('.morphtree-' + uuid.uuid4().hex + '.tmp')
|
|
195
|
+
if os.name == 'nt':
|
|
196
|
+
# A valid final path can have insufficient suffix room for a secure
|
|
197
|
+
# temporary name. Extend only the owned temporary OS path; leave the
|
|
198
|
+
# user destination semantics and ordinary load paths unchanged.
|
|
199
|
+
temporary = os.path.abspath(temporary)
|
|
200
|
+
if not temporary.startswith('\\\\?\\'):
|
|
201
|
+
temporary = '\\\\?\\UNC\\' + temporary[2:] if temporary.startswith('\\\\') else '\\\\?\\' + temporary
|
|
202
|
+
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, 'O_BINARY', 0) | getattr(os, 'O_NOFOLLOW', 0)
|
|
203
|
+
fd = os.open(temporary, flags, 0o600)
|
|
204
|
+
primary = None
|
|
205
|
+
try:
|
|
206
|
+
try:
|
|
207
|
+
handle = os.fdopen(fd, "w", encoding="utf-8", newline="\n")
|
|
208
|
+
except BaseException:
|
|
209
|
+
os.close(fd)
|
|
210
|
+
raise
|
|
211
|
+
with handle:
|
|
212
|
+
handle.write(text)
|
|
213
|
+
handle.flush()
|
|
214
|
+
os.fsync(handle.fileno())
|
|
215
|
+
os.replace(temporary, path)
|
|
216
|
+
except BaseException as error:
|
|
217
|
+
primary = error
|
|
218
|
+
raise
|
|
219
|
+
finally:
|
|
220
|
+
try:
|
|
221
|
+
os.unlink(temporary)
|
|
222
|
+
except FileNotFoundError:
|
|
223
|
+
pass
|
|
224
|
+
except OSError as cleanup_error:
|
|
225
|
+
if primary is None:
|
|
226
|
+
raise
|
|
227
|
+
BaseException.add_note(primary, 'Owned temporary cleanup also failed: ' + type(cleanup_error).__name__)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def write_json(path, data):
|
|
231
|
+
atomic_text(path, json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False) + "\n")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def require_keys(data, required, optional=()):
|
|
235
|
+
if not isinstance(data, dict) or not set(required) <= set(data) or set(data) - set(required) - set(optional):
|
|
236
|
+
raise ValueError("Unexpected or missing artifact fields")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def finite(value, name):
|
|
240
|
+
if type(value) not in (float, int):
|
|
241
|
+
raise ValueError(f"{name} must be finite")
|
|
242
|
+
try:
|
|
243
|
+
normalized = float(value)
|
|
244
|
+
except (OverflowError, ValueError) as exc:
|
|
245
|
+
raise ValueError(f"{name} must be representable as a finite float") from exc
|
|
246
|
+
if not math.isfinite(normalized):
|
|
247
|
+
raise ValueError(f"{name} must be finite")
|
|
248
|
+
return normalized
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def integer(value, name, low, high):
|
|
252
|
+
if type(value) is not int or not low <= value <= high:
|
|
253
|
+
raise ValueError(f"Invalid {name}")
|
|
254
|
+
return value
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def species_payload(genome, params, metadata=None):
|
|
258
|
+
metadata = public_metadata(metadata or {})
|
|
259
|
+
recipe = metadata.pop('learning_recipe', None)
|
|
260
|
+
payload = {"format": "MorphTreeSpecies", "spec_version": 2 if recipe is not None else 1, "producer_version": VERSION,
|
|
261
|
+
"task": "binary_classification", "capabilities": ["numeric_dense", "missing", "split_dsl_v1", "local_gates_v1"],
|
|
262
|
+
"genome": genome.to_dict(), "parameters": asdict(params), "structural_fingerprint": genome.fingerprint(),
|
|
263
|
+
"active_parameters": list(genome.active_parameters()), "metadata": metadata}
|
|
264
|
+
if recipe is not None:
|
|
265
|
+
payload.update(learning_recipe=recipe, learning_fingerprint=learning_fingerprint(genome, params, recipe))
|
|
266
|
+
return payload
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def decode_species(data):
|
|
270
|
+
require_keys(data, ("format", "spec_version", "producer_version", "task", "capabilities", "genome", "parameters",
|
|
271
|
+
"structural_fingerprint", "active_parameters", "metadata"), ('learning_recipe', 'learning_fingerprint'))
|
|
272
|
+
if data["format"] != "MorphTreeSpecies" or type(data["spec_version"]) is not int or data["spec_version"] not in (1, 2):
|
|
273
|
+
raise ValueError("Unsupported species format/version; 0.2 artifacts require retraining")
|
|
274
|
+
if data["task"] != "binary_classification":
|
|
275
|
+
raise ValueError("Unsupported task")
|
|
276
|
+
if data["capabilities"] != ["numeric_dense", "missing", "split_dsl_v1", "local_gates_v1"]:
|
|
277
|
+
raise ValueError("Unsupported species capabilities")
|
|
278
|
+
try:
|
|
279
|
+
require_keys(data["genome"], [field.name for field in fields(AlgorithmGenome)])
|
|
280
|
+
require_keys(data["parameters"], [field.name for field in fields(HyperParams)])
|
|
281
|
+
genome, params = AlgorithmGenome(**data["genome"]), HyperParams(**data["parameters"])
|
|
282
|
+
except (TypeError, KeyError) as exc:
|
|
283
|
+
raise ValueError("Invalid species genes/parameters") from exc
|
|
284
|
+
if data["structural_fingerprint"] != genome.fingerprint() or data["active_parameters"] != list(genome.active_parameters()):
|
|
285
|
+
raise ValueError("Species fingerprint or active parameters mismatch")
|
|
286
|
+
if not isinstance(data["metadata"], dict):
|
|
287
|
+
raise ValueError("Species metadata must be an object")
|
|
288
|
+
metadata = public_metadata(data['metadata'])
|
|
289
|
+
metadata.pop('learning_recipe', None)
|
|
290
|
+
if data['spec_version'] == 2:
|
|
291
|
+
recipe = validate_recipe(data.get('learning_recipe'))
|
|
292
|
+
if data.get('learning_fingerprint') != learning_fingerprint(genome, params, recipe):
|
|
293
|
+
raise ValueError('Learning fingerprint mismatch')
|
|
294
|
+
metadata['learning_recipe'] = recipe
|
|
295
|
+
elif 'learning_recipe' in data or 'learning_fingerprint' in data:
|
|
296
|
+
raise ValueError('Version 1 cannot contain version 2 learning fields')
|
|
297
|
+
return genome, params, metadata
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def decode_booster(data, genome, params, backend, limits=None, *, objective='binary_logloss', _aggregate_tree_count=None):
|
|
301
|
+
limits = limits or LoadLimits()
|
|
302
|
+
require_keys(data, ("base_score", "n_features", "binner", "steps", "trees"))
|
|
303
|
+
n_features = integer(data["n_features"], "n_features", 1, limits.max_features)
|
|
304
|
+
base_score = finite(data["base_score"], "base_score")
|
|
305
|
+
if objective not in ('binary_logloss', 'squared_error'):
|
|
306
|
+
raise ValueError('Unsupported booster objective')
|
|
307
|
+
if abs(base_score) > (1e100 if objective == 'squared_error' else 100):
|
|
308
|
+
raise ValueError("Implausible base score")
|
|
309
|
+
bd = data["binner"]
|
|
310
|
+
require_keys(bd, ("max_bins", "policy", "thresholds"))
|
|
311
|
+
if integer(bd["max_bins"], "max_bins", 2, 4096) != params.max_bins or bd["policy"] != genome.binning_policy:
|
|
312
|
+
raise ValueError("Binner/species mismatch")
|
|
313
|
+
cuts = bd["thresholds"]
|
|
314
|
+
if not isinstance(cuts, list) or len(cuts) != n_features:
|
|
315
|
+
raise ValueError("Binner feature count mismatch")
|
|
316
|
+
if any(not isinstance(row, list) for row in cuts) or sum(map(len, cuts)) > limits.max_thresholds:
|
|
317
|
+
raise ValueError("Threshold budget exceeded")
|
|
318
|
+
cuts = [[finite(v, "threshold") for v in row] for row in cuts]
|
|
319
|
+
for feature,row in enumerate(cuts):
|
|
320
|
+
nominal = getattr(backend, 'nominal_sizes', {}).get(feature)
|
|
321
|
+
if nominal is not None:
|
|
322
|
+
if row != list(range(nominal + 1)):
|
|
323
|
+
raise ValueError('Nominal thresholds must match the validated TRAIN vocabulary')
|
|
324
|
+
elif len(row) >= params.max_bins:
|
|
325
|
+
raise ValueError("Too many thresholds for max_bins")
|
|
326
|
+
if any(a >= b for a, b in zip(row, row[1:])):
|
|
327
|
+
raise ValueError("Binning thresholds must be strictly increasing")
|
|
328
|
+
trees, steps = data["trees"], data["steps"]
|
|
329
|
+
if not isinstance(trees, list) or not isinstance(steps, list) or not 1 <= len(trees) <= limits.max_trees:
|
|
330
|
+
raise ValueError("Invalid tree count")
|
|
331
|
+
tree_allowance=min(params.n_estimators,limits.max_trees) if _aggregate_tree_count is None else _aggregate_tree_count
|
|
332
|
+
if type(tree_allowance) is not int or not 1<=tree_allowance<=limits.max_trees:raise ValueError('Invalid aggregate tree allowance')
|
|
333
|
+
if len(trees) != len(steps) or len(trees) > tree_allowance:
|
|
334
|
+
raise ValueError("Tree/update count mismatch")
|
|
335
|
+
steps = [finite(step, "step") for step in steps]
|
|
336
|
+
for step in steps:
|
|
337
|
+
if not 0 < step <= 1:
|
|
338
|
+
raise ValueError("Invalid update weight")
|
|
339
|
+
decoded, total_nodes = [], 0
|
|
340
|
+
required_nodes = [field.name for field in fields(TreeNode)]
|
|
341
|
+
for td in trees:
|
|
342
|
+
require_keys(td, ("nodes",))
|
|
343
|
+
nodes = td["nodes"]
|
|
344
|
+
if not isinstance(nodes, list) or not nodes or len(nodes) > 2 * params.max_leaves - 1:
|
|
345
|
+
raise ValueError("Invalid tree size")
|
|
346
|
+
total_nodes += len(nodes)
|
|
347
|
+
if total_nodes > limits.max_nodes:
|
|
348
|
+
raise ValueError("Total node budget exceeded")
|
|
349
|
+
for node in nodes:
|
|
350
|
+
require_keys(node, required_nodes)
|
|
351
|
+
if type(node["is_leaf"]) is not bool or type(node["missing_left"]) is not bool:
|
|
352
|
+
raise ValueError("Node flags must be boolean")
|
|
353
|
+
if abs(finite(node["value"], "leaf value")) > 1e100:
|
|
354
|
+
raise ValueError("Leaf magnitude exceeds supported limit")
|
|
355
|
+
integer(node["depth"], "depth", 0, params.max_depth)
|
|
356
|
+
if node["is_leaf"]:
|
|
357
|
+
if any(type(node[k]) is not int or node[k] != -1 for k in ("feature", "threshold_bin", "left", "right")):
|
|
358
|
+
raise ValueError("Leaf must not contain split fields")
|
|
359
|
+
else:
|
|
360
|
+
f = integer(node["feature"], "feature", 0, n_features - 1)
|
|
361
|
+
integer(node["threshold_bin"], "threshold_bin", 0, len(cuts[f]) + 1)
|
|
362
|
+
integer(node["left"], "left child", 0, len(nodes) - 1)
|
|
363
|
+
integer(node["right"], "right child", 0, len(nodes) - 1)
|
|
364
|
+
if node["left"] == node["right"]:
|
|
365
|
+
raise ValueError("A split must have two distinct children")
|
|
366
|
+
visited, stack = set(), [(0, 0)]
|
|
367
|
+
while stack:
|
|
368
|
+
index, depth = stack.pop()
|
|
369
|
+
if index in visited:
|
|
370
|
+
raise ValueError("Cyclic tree or shared child")
|
|
371
|
+
visited.add(index)
|
|
372
|
+
node = nodes[index]
|
|
373
|
+
if node["depth"] != depth:
|
|
374
|
+
raise ValueError("Inconsistent tree depth")
|
|
375
|
+
if not node["is_leaf"]:
|
|
376
|
+
stack.extend(((node["left"], depth + 1), (node["right"], depth + 1)))
|
|
377
|
+
if len(visited) != len(nodes):
|
|
378
|
+
raise ValueError("Unreachable tree nodes")
|
|
379
|
+
tree = HistogramTree(genome, params, backend)
|
|
380
|
+
tree.nodes = tuple(TreeNode(**{**node, "value": finite(node["value"], "leaf value")}) for node in nodes)
|
|
381
|
+
decoded.append(tree)
|
|
382
|
+
booster = BinaryHistogramBooster(genome, params, backend=backend,
|
|
383
|
+
memory_enabled=objective != 'squared_error', objective=objective)
|
|
384
|
+
booster.base_score, booster.n_features_ = base_score, n_features
|
|
385
|
+
booster.binner = Binner(params.max_bins, genome.binning_policy)
|
|
386
|
+
booster.binner.thresholds = tuple(tuple(row) for row in cuts)
|
|
387
|
+
booster.trees, booster.steps = decoded, list(steps)
|
|
388
|
+
return booster
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def decode_multiclass_booster(data, params, backend, n_classes, strategy, limits=None, *, genome=None):
|
|
392
|
+
"""Reuse the scalar graph validator with a single aggregate tree/node budget."""
|
|
393
|
+
limits = limits or LoadLimits()
|
|
394
|
+
if type(n_classes) is not int or n_classes<2:raise ValueError('Typed multiclass dimension required')
|
|
395
|
+
require_keys(data, ('base_score', 'n_features', 'binner', 'steps', 'trees'))
|
|
396
|
+
priors = data['base_score']
|
|
397
|
+
if not isinstance(priors, list) or len(priors) != n_classes:
|
|
398
|
+
raise ValueError('Multiclass prior dimensions disagree')
|
|
399
|
+
priors = [finite(value, 'class prior score') for value in priors]
|
|
400
|
+
if any(abs(value) > 100 for value in priors):
|
|
401
|
+
raise ValueError('Implausible class prior score')
|
|
402
|
+
trees = data['trees']
|
|
403
|
+
if not isinstance(trees, list) or not trees or len(trees) % n_classes or len(trees) > limits.max_trees:
|
|
404
|
+
raise ValueError('Multiclass requires complete rounds within the aggregate tree budget')
|
|
405
|
+
if len(trees)//n_classes > params.n_estimators:
|
|
406
|
+
raise ValueError('Multiclass round count exceeds parameters')
|
|
407
|
+
# Validate once, not once per class (which would multiply the load budgets).
|
|
408
|
+
genome=validate_task_genome('multiclass',AlgorithmGenome() if genome is None else genome)
|
|
409
|
+
booster = decode_booster({**data, 'base_score': 0.}, genome,
|
|
410
|
+
params, backend, limits, objective='squared_error', _aggregate_tree_count=len(trees))
|
|
411
|
+
if any(step != params.learning_rate for step in booster.steps):
|
|
412
|
+
raise ValueError('Multiclass steps must match the declared fixed learning rate')
|
|
413
|
+
booster.params, booster.objective, booster.n_classes, booster.base_score = params, strategy, n_classes, priors
|
|
414
|
+
return booster
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Public NumPy output boundary; no learning, clipping or renormalization."""
|
|
2
|
+
|
|
3
|
+
def prediction_array(values, *, regression=False):
|
|
4
|
+
import numpy as np
|
|
5
|
+
result=np.asarray(values,dtype=np.float64 if regression else object if len(values) and isinstance(values[0],str) else None)
|
|
6
|
+
if result.ndim!=1:raise ValueError('One-dimensional scalar predictions required')
|
|
7
|
+
if not regression and any((v.item() if hasattr(v,'item') else v)!=old for v,old in zip(result,values)):
|
|
8
|
+
# Never round large/mixed integer class IDs to binary64 or stringify them.
|
|
9
|
+
result=np.asarray(values,dtype=object)
|
|
10
|
+
if regression and not np.isfinite(result).all():raise ValueError('Finite regression predictions required')
|
|
11
|
+
return result
|
|
12
|
+
|
|
13
|
+
def probability_array(values, n_classes, *, n_samples=None, check=None):
|
|
14
|
+
import numpy as np
|
|
15
|
+
result=np.asarray(values,dtype=np.float64)
|
|
16
|
+
if result.ndim!=2 or result.shape[1]!=n_classes or n_classes<2 or n_samples is not None and result.shape[0]!=n_samples:
|
|
17
|
+
raise ValueError('Probability shape must be (n_samples,n_classes)')
|
|
18
|
+
for start in range(0,len(result),4096):
|
|
19
|
+
if check:check()
|
|
20
|
+
block=result[start:start+4096]
|
|
21
|
+
if not np.isfinite(block).all() or np.any(block<0) or np.any(block>1):raise ValueError('Probabilities must be finite and in [0,1]')
|
|
22
|
+
if not np.allclose(block.sum(axis=1),1.,rtol=0.,atol=1e-10):raise ValueError('Probability rows must sum to one')
|
|
23
|
+
if check:check()
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
def numpy_export_source(source, classes, regression):
|
|
27
|
+
"""Wrap explicit NumPy exports while retaining the standalone list default."""
|
|
28
|
+
import inspect
|
|
29
|
+
extra='\n# Explicit NumPy output adapter; requires NumPy, not morphtree.\n'
|
|
30
|
+
extra+='OUTPUT_FORMAT = "numpy"\nclasses_ = '+repr(list(classes))+'\n'
|
|
31
|
+
extra+=inspect.getsource(prediction_array)+'\n'+inspect.getsource(probability_array)
|
|
32
|
+
extra+='\n_morphtree_python_predict = predict\ndef predict(X):\n return prediction_array(_morphtree_python_predict(X), regression='+repr(regression)+')\n'
|
|
33
|
+
if not regression:
|
|
34
|
+
extra+='\n_morphtree_python_predict_proba = predict_proba\ndef predict_proba(X):\n return probability_array(_morphtree_python_predict_proba(X), len(classes_), n_samples=len(X))\n'
|
|
35
|
+
return source+extra
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Bounded, call-local reuse of exact TRAIN preprocessing, never learner fits.
|
|
2
|
+
|
|
3
|
+
The facade supplies identities from its owned copied buffers. Tokens are bound
|
|
4
|
+
to one encoded input object. Source, ordered sample, labels, schema, encoder,
|
|
5
|
+
role, engine, bin policy and bin count all participate in the reuse key.
|
|
6
|
+
Entries are private and not persisted. The cap covers retained bins/thresholds
|
|
7
|
+
and conservative entry overhead, not all fit/process allocations.
|
|
8
|
+
"""
|
|
9
|
+
from array import array
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
import hashlib
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from .data import Binner
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
IDENTITIES=('source_sha256','schema_sha256','effective_rows_sha256',
|
|
18
|
+
'effective_labels_sha256','preprocessing_sha256','engine_sha256')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fingerprint(binner, bins):
|
|
22
|
+
h=hashlib.sha256()
|
|
23
|
+
h.update(binner.policy.encode('ascii'));h.update(binner.max_bins.to_bytes(8,'little'))
|
|
24
|
+
for cuts in binner.thresholds:
|
|
25
|
+
h.update(len(cuts).to_bytes(8,'little'));h.update(memoryview(array('d',cuts)))
|
|
26
|
+
for column in bins:
|
|
27
|
+
h.update(column.typecode.encode('ascii'));h.update(bytes((bool(getattr(column,'nominal',False)),)))
|
|
28
|
+
h.update(len(column).to_bytes(8,'little'));h.update(memoryview(column))
|
|
29
|
+
return h.hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def retained_bytes(binner,bins,key):
|
|
33
|
+
# Counting shared small integers more than once is deliberately conservative.
|
|
34
|
+
def size(v):return sys.getsizeof(v)+(sum(size(x) for x in v) if type(v) is tuple else 0)
|
|
35
|
+
return (2048+size(key)
|
|
36
|
+
+sys.getsizeof(binner)+sys.getsizeof(binner.__dict__)
|
|
37
|
+
+sys.getsizeof(binner.thresholds)
|
|
38
|
+
+sum(sys.getsizeof(cuts)+sum(sys.getsizeof(v) for v in cuts) for cuts in binner.thresholds)
|
|
39
|
+
+sys.getsizeof(bins)+sum(sys.getsizeof(c)+sys.getsizeof(c.__dict__) for c in bins))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BinCache:
|
|
43
|
+
def __init__(self,limit_bytes,check):
|
|
44
|
+
if type(limit_bytes) is not int or limit_bytes<0 or not callable(check):raise ValueError('Finite cache allowance/check')
|
|
45
|
+
self.limit_bytes=limit_bytes;self.check=check;self.entries=OrderedDict();self.bytes=0
|
|
46
|
+
self.counts=dict(hits=0,misses=0,bypassed=0,evictions=0,peak_retained_bytes=0,reused_rows=0,preparation_seconds=0.,integrity_seconds=0.)
|
|
47
|
+
def bind(self,X,identity,role):
|
|
48
|
+
if type(identity) is not dict or set(identity)!=set(IDENTITIES):raise ValueError('Exact owned-source identity')
|
|
49
|
+
if any(type(v) is not str or len(v)!=64 or any(c not in '0123456789abcdef' for c in v) for v in identity.values()):raise ValueError('SHA256 identity fields')
|
|
50
|
+
if role not in ('TRAIN','OOF_TRAIN','TRAIN_FULL'):raise ValueError('Learning role required; no SEARCH/EXTERNAL preparation')
|
|
51
|
+
if not getattr(X,'_compact_columns',False):raise ValueError('Owned compact encoded input required')
|
|
52
|
+
return _Bound(self,X,tuple(identity[n] for n in IDENTITIES)+(role,))
|
|
53
|
+
def _get(self,X,base,genome,params,backend):
|
|
54
|
+
self.check()
|
|
55
|
+
nominal=tuple(sorted(X._nominal_sizes.items()))
|
|
56
|
+
if nominal!=tuple(sorted(backend.nominal_sizes.items())):raise ValueError('Prepared bins/backend nominal schema mismatch')
|
|
57
|
+
key=base+(len(X),X.width,genome.binning_policy,params.max_bins,nominal)
|
|
58
|
+
if key in self.entries:
|
|
59
|
+
binner,bins,size,stamp=self.entries[key];tick=time.perf_counter()
|
|
60
|
+
if fingerprint(binner,bins)!=stamp:raise ValueError('Changed call-local prepared buffers')
|
|
61
|
+
self.counts['integrity_seconds']+=time.perf_counter()-tick
|
|
62
|
+
self.entries.move_to_end(key);self.counts['hits']+=1;self.counts['reused_rows']+=len(X)
|
|
63
|
+
self.check();return binner,bins
|
|
64
|
+
self.counts['misses']+=1;tick=time.perf_counter()
|
|
65
|
+
binner=Binner(params.max_bins,genome.binning_policy).fit(X);bins=backend.bin(binner,X)
|
|
66
|
+
self.counts['preparation_seconds']+=time.perf_counter()-tick;self.check()
|
|
67
|
+
size=retained_bytes(binner,bins,key)
|
|
68
|
+
if size>self.limit_bytes:
|
|
69
|
+
self.counts['bypassed']+=1;return binner,bins
|
|
70
|
+
while self.bytes+size>self.limit_bytes:
|
|
71
|
+
_,(_,_,removed,_)=self.entries.popitem(last=False);self.bytes-=removed;self.counts['evictions']+=1
|
|
72
|
+
self.entries[key]=(binner,bins,size,fingerprint(binner,bins));self.bytes+=size
|
|
73
|
+
self.counts['peak_retained_bytes']=max(self.bytes,self.counts['peak_retained_bytes']);self.check();return binner,bins
|
|
74
|
+
def report(self):return dict(limit_bytes=self.limit_bytes,retained_bytes=self.bytes,entries=len(self.entries),**self.counts)
|
|
75
|
+
def clear(self):self.entries.clear();self.bytes=0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Bound:
|
|
79
|
+
def __init__(self,owner,X,base):self.owner,self.X,self.base=owner,X,base
|
|
80
|
+
def get(self,X,genome,params,backend):
|
|
81
|
+
if X is not self.X:raise ValueError('Prepared-data token belongs to a different input object')
|
|
82
|
+
return self.owner._get(X,self.base,genome,params,backend)
|
morphtree/public.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Public artifact boundary. Runtime connection settings are never serializable."""
|
|
2
|
+
from dataclasses import asdict
|
|
3
|
+
import math
|
|
4
|
+
from .config import MorphTreeConfig, canonical_hash
|
|
5
|
+
|
|
6
|
+
PUBLIC_CONFIG_FIELDS = (
|
|
7
|
+
'seed', 'search_fraction', 'guard_fraction', 'enable_morphing', 'enable_tuning',
|
|
8
|
+
'structural_rounds', 'structural_mutants', 'structural_patience', 'structural_evaluations',
|
|
9
|
+
'search_timeout', 'freeze_on_budget', 'diagnostic_guidance', 'memory_enabled',
|
|
10
|
+
'min_morph_improvement', 'guard_tolerance', 'global_guard_tolerance', 'complexity_penalty',
|
|
11
|
+
'confidence_z', 'probe_fraction', 'probe_margin', 'tuner', 'tune_trials', 'tune_patience',
|
|
12
|
+
'tune_timeout', 'early_stopping_rounds', 'refit_full', 'optuna_startup_trials')
|
|
13
|
+
RECIPE_FIELDS = ('seed', 'memory_enabled', 'refit_full', 'search_fraction', 'guard_fraction')
|
|
14
|
+
|
|
15
|
+
def public_config(config):
|
|
16
|
+
return {name: getattr(config, name) for name in PUBLIC_CONFIG_FIELDS if hasattr(config, name)}
|
|
17
|
+
|
|
18
|
+
def validate_recipe(recipe):
|
|
19
|
+
if not isinstance(recipe, dict) or set(recipe) != set(RECIPE_FIELDS) | {'partition'}:
|
|
20
|
+
raise ValueError('Invalid learning recipe fields')
|
|
21
|
+
config = MorphTreeConfig(**{name: recipe[name] for name in RECIPE_FIELDS})
|
|
22
|
+
if recipe['partition'] not in ('automatic', 'explicit', 'all'):
|
|
23
|
+
raise ValueError('Invalid learning partition')
|
|
24
|
+
return {**{name: getattr(config, name) for name in RECIPE_FIELDS}, 'partition': recipe['partition']}
|
|
25
|
+
|
|
26
|
+
def recipe_settings(recipe, config=None):
|
|
27
|
+
if recipe is None:
|
|
28
|
+
return config or MorphTreeConfig()
|
|
29
|
+
recipe = validate_recipe(recipe)
|
|
30
|
+
if config is not None:
|
|
31
|
+
if config.memory_enabled != recipe['memory_enabled']:
|
|
32
|
+
raise ValueError('config.memory_enabled conflicts with the frozen learning recipe')
|
|
33
|
+
return config
|
|
34
|
+
return MorphTreeConfig(**{name: recipe[name] for name in RECIPE_FIELDS})
|
|
35
|
+
|
|
36
|
+
def learning_fingerprint(genome, params, recipe):
|
|
37
|
+
return canonical_hash({'genome': genome.to_dict(), 'parameters': asdict(params),
|
|
38
|
+
'learning_recipe': validate_recipe(recipe), 'semantics': 2})
|
|
39
|
+
|
|
40
|
+
def imported_report(data):
|
|
41
|
+
"""Imported annotations are untrusted. Preserve only typed, known numeric metrics.
|
|
42
|
+
|
|
43
|
+
Operational histories/free text are deliberately not carried across artifact boundaries.
|
|
44
|
+
Predictions and the explicit learning recipe are independent of this diagnostic subset.
|
|
45
|
+
"""
|
|
46
|
+
result = {}
|
|
47
|
+
for name in ('base_search_loss', 'base_guard_loss', 'structure_search_loss', 'structure_guard_loss',
|
|
48
|
+
'final_search_loss', 'final_guard_loss', 'structural_gain', 'tuning_gain'):
|
|
49
|
+
value = data.get(name)
|
|
50
|
+
if type(value) in (int, float):
|
|
51
|
+
try:
|
|
52
|
+
normalized = float(value)
|
|
53
|
+
except OverflowError:
|
|
54
|
+
continue
|
|
55
|
+
if math.isfinite(normalized):
|
|
56
|
+
result[name] = normalized
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
def public_metadata(metadata):
|
|
60
|
+
result = {}
|
|
61
|
+
if 'learning_recipe' in metadata:
|
|
62
|
+
result['learning_recipe'] = validate_recipe(metadata['learning_recipe'])
|
|
63
|
+
if isinstance(metadata.get('discovery_report'), dict):
|
|
64
|
+
result['discovery_report'] = imported_report(metadata['discovery_report'])
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def imported_regression_report(data):
|
|
69
|
+
"""Regression diagnostics only; never trust free-form operational annotations."""
|
|
70
|
+
result = {'task': 'regression', 'objective': 'squared_error', 'imported': True}
|
|
71
|
+
for group in ('train_metrics', 'validation_metrics'):
|
|
72
|
+
values = data.get(group)
|
|
73
|
+
if not isinstance(values, dict):
|
|
74
|
+
continue
|
|
75
|
+
kept = {}
|
|
76
|
+
for name in ('rmse', 'mae', 'r2'):
|
|
77
|
+
value = values.get(name)
|
|
78
|
+
if type(value) in (int, float):
|
|
79
|
+
try:
|
|
80
|
+
number = float(value)
|
|
81
|
+
except OverflowError:
|
|
82
|
+
continue
|
|
83
|
+
if math.isfinite(number) and (name == 'r2' or number >= 0):
|
|
84
|
+
kept[name] = number
|
|
85
|
+
result[group] = kept
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def imported_multiclass_report(data):
|
|
90
|
+
result = {'task': 'multiclass', 'imported': True}
|
|
91
|
+
for group in ('train_metrics', 'validation_metrics'):
|
|
92
|
+
values = data.get(group)
|
|
93
|
+
if not isinstance(values, dict):
|
|
94
|
+
continue
|
|
95
|
+
kept = {}
|
|
96
|
+
for name in ('logloss', 'accuracy', 'macro_f1'):
|
|
97
|
+
value = values.get(name)
|
|
98
|
+
if type(value) in (int, float):
|
|
99
|
+
try:
|
|
100
|
+
number = float(value)
|
|
101
|
+
except OverflowError:
|
|
102
|
+
continue
|
|
103
|
+
if math.isfinite(number) and 0 <= number and (name == 'logloss' or number <= 1):
|
|
104
|
+
kept[name] = number
|
|
105
|
+
result[group] = kept
|
|
106
|
+
return result
|