mlbundle 0.1.0a1__py3-none-any.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.
- mlbundle/__init__.py +82 -0
- mlbundle/_base.py +19 -0
- mlbundle/_canonical_work.py +67 -0
- mlbundle/_catboost/__init__.py +159 -0
- mlbundle/_catboost/budget.py +22 -0
- mlbundle/_catboost/flatbuffer.py +104 -0
- mlbundle/_catboost/native.py +244 -0
- mlbundle/_catboost/prediction.py +67 -0
- mlbundle/_catboost/scope.py +109 -0
- mlbundle/_catboost/state.py +62 -0
- mlbundle/_composite/__init__.py +1 -0
- mlbundle/_composite/claims.py +133 -0
- mlbundle/_composite/io.py +158 -0
- mlbundle/_composite/prediction.py +77 -0
- mlbundle/_container_rules.py +73 -0
- mlbundle/_export_manifest.py +78 -0
- mlbundle/_native_io.py +53 -0
- mlbundle/_onnx/__init__.py +115 -0
- mlbundle/_onnx/_wire_runs.py +103 -0
- mlbundle/_onnx/description.py +252 -0
- mlbundle/_onnx/prediction.py +136 -0
- mlbundle/_onnx/protobuf.py +111 -0
- mlbundle/_onnx/session.py +87 -0
- mlbundle/_onnx/signatures.py +63 -0
- mlbundle/_onnx/validation.py +164 -0
- mlbundle/_pipeline_contract.py +104 -0
- mlbundle/_prediction_input.py +63 -0
- mlbundle/_schema_fields.py +68 -0
- mlbundle/_sklearn/__init__.py +183 -0
- mlbundle/_sklearn/_width_checks.py +59 -0
- mlbundle/_sklearn/archive.py +32 -0
- mlbundle/_sklearn/arrays.py +150 -0
- mlbundle/_sklearn/column_width.py +198 -0
- mlbundle/_sklearn/consistency.py +127 -0
- mlbundle/_sklearn/dtypes.py +41 -0
- mlbundle/_sklearn/encoder_width.py +123 -0
- mlbundle/_sklearn/fitted.py +113 -0
- mlbundle/_sklearn/labels.py +52 -0
- mlbundle/_sklearn/nodes.py +75 -0
- mlbundle/_sklearn/parameters.py +94 -0
- mlbundle/_sklearn/payload.py +234 -0
- mlbundle/_sklearn/pipeline_scope.py +141 -0
- mlbundle/_sklearn/polynomial_names.py +31 -0
- mlbundle/_sklearn/prediction.py +119 -0
- mlbundle/_sklearn/prediction_state.py +61 -0
- mlbundle/_sklearn/references.py +110 -0
- mlbundle/_sklearn/serialization.py +80 -0
- mlbundle/_sklearn/state.py +144 -0
- mlbundle/_sklearn/state_fields.json +228 -0
- mlbundle/_sklearn/structure.py +265 -0
- mlbundle/_sklearn/transform_names.py +111 -0
- mlbundle/_sklearn/transform_width.py +270 -0
- mlbundle/_sklearn/transformers.py +69 -0
- mlbundle/_sklearn/trusted.py +89 -0
- mlbundle/_xgboost/__init__.py +262 -0
- mlbundle/_xgboost/arrays.py +47 -0
- mlbundle/_xgboost/categories.py +109 -0
- mlbundle/_xgboost/native.py +193 -0
- mlbundle/_xgboost/prediction.py +59 -0
- mlbundle/_xgboost/snapshots.py +41 -0
- mlbundle/_xgboost/state.py +147 -0
- mlbundle/_xgboost/structure.py +47 -0
- mlbundle/_xgboost/ubjson.py +119 -0
- mlbundle/_zip_directory.py +173 -0
- mlbundle/_zip_members.py +116 -0
- mlbundle/adapters.py +33 -0
- mlbundle/canonical.py +290 -0
- mlbundle/claims.py +124 -0
- mlbundle/cli.py +193 -0
- mlbundle/container.py +93 -0
- mlbundle/contract.py +55 -0
- mlbundle/declarations.py +68 -0
- mlbundle/environment.py +85 -0
- mlbundle/errors.py +92 -0
- mlbundle/inspection.py +30 -0
- mlbundle/integrity.py +30 -0
- mlbundle/limits.py +38 -0
- mlbundle/manifest.py +222 -0
- mlbundle/model_io.py +151 -0
- mlbundle/py.typed +0 -0
- mlbundle/registry.py +103 -0
- mlbundle/results.py +68 -0
- mlbundle/samples.py +71 -0
- mlbundle/schema.py +145 -0
- mlbundle/training.py +104 -0
- mlbundle/writing.py +256 -0
- mlbundle-0.1.0a1.dist-info/METADATA +347 -0
- mlbundle-0.1.0a1.dist-info/RECORD +91 -0
- mlbundle-0.1.0a1.dist-info/WHEEL +4 -0
- mlbundle-0.1.0a1.dist-info/entry_points.txt +3 -0
- mlbundle-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Numeric CBM preflight before CatBoost's native zero-copy loader sees bytes."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import struct
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from mlbundle.canonical import canonical_loads
|
|
11
|
+
from mlbundle.errors import InvalidModelError
|
|
12
|
+
from mlbundle.limits import (
|
|
13
|
+
HYPERPARAMETER_BYTES,
|
|
14
|
+
MANIFEST_BYTES,
|
|
15
|
+
MAX_CLASSES,
|
|
16
|
+
MAX_FEATURES,
|
|
17
|
+
ArchiveLimits,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from .budget import structure_budget
|
|
21
|
+
from .flatbuffer import Buffer, Table
|
|
22
|
+
from .scope import CLASSIFIERS, MAX_DEPTH, MAX_TREES, REGRESSORS
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Snapshot:
|
|
27
|
+
native: dict[str, Any]
|
|
28
|
+
metadata: dict[str, str]
|
|
29
|
+
loss: str
|
|
30
|
+
width: int
|
|
31
|
+
dimension: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _features(tree: Table) -> list[dict[str, Any]]:
|
|
35
|
+
records = []
|
|
36
|
+
remaining = MAX_TREES * MAX_DEPTH
|
|
37
|
+
for feature in tree.tables(5, 6, MAX_FEATURES):
|
|
38
|
+
has_nans = feature.scalar(0, "B")
|
|
39
|
+
index, flat = (feature.scalar(i, "i", -1) for i in (1, 2))
|
|
40
|
+
treatment = feature.scalar(5, "b")
|
|
41
|
+
borders = feature.vector(3, "<f4", MAX_TREES * MAX_DEPTH)
|
|
42
|
+
remaining -= len(borders)
|
|
43
|
+
if (
|
|
44
|
+
remaining < 0
|
|
45
|
+
or index != len(records)
|
|
46
|
+
or flat != len(records)
|
|
47
|
+
or has_nans not in (0, 1)
|
|
48
|
+
or treatment not in (0, 1, 2)
|
|
49
|
+
or not 0 <= index < MAX_FEATURES
|
|
50
|
+
or not 0 <= flat < MAX_FEATURES
|
|
51
|
+
or not np.isfinite(borders).all()
|
|
52
|
+
or np.any(borders[1:] <= borders[:-1])
|
|
53
|
+
):
|
|
54
|
+
raise InvalidModelError("Invalid CatBoost numeric feature record.")
|
|
55
|
+
name = feature.string(4, 4096)
|
|
56
|
+
if len(name) > 1024:
|
|
57
|
+
raise InvalidModelError("CatBoost feature name exceeds the limit.")
|
|
58
|
+
records.append(
|
|
59
|
+
{
|
|
60
|
+
"index": index,
|
|
61
|
+
"flat_index": flat,
|
|
62
|
+
"name": name,
|
|
63
|
+
"has_nans": bool(has_nans),
|
|
64
|
+
"nan_value_treatment": treatment,
|
|
65
|
+
"borders": borders,
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
if (
|
|
69
|
+
not records
|
|
70
|
+
or [r["index"] for r in records] != list(range(len(records)))
|
|
71
|
+
or [r["flat_index"] for r in records] != list(range(len(records)))
|
|
72
|
+
):
|
|
73
|
+
raise InvalidModelError("Invalid CatBoost numeric feature order.")
|
|
74
|
+
return records
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _metadata(core: Table) -> dict[str, str]:
|
|
78
|
+
result = {}
|
|
79
|
+
total = 0
|
|
80
|
+
for item in core.tables(2, 2, 4096):
|
|
81
|
+
key, value = item.string(0, 1024), item.string(1, MANIFEST_BYTES)
|
|
82
|
+
total += len(key.encode()) + len(value.encode())
|
|
83
|
+
if not key or key in result or total > MANIFEST_BYTES:
|
|
84
|
+
raise InvalidModelError("Invalid or oversized CatBoost metadata.")
|
|
85
|
+
result[key] = value
|
|
86
|
+
for key in ("training", "output_options"):
|
|
87
|
+
if key in result:
|
|
88
|
+
canonical_loads(result[key].encode(), max_bytes=MANIFEST_BYTES)
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _classes(metadata: dict[str, str], count: int) -> None:
|
|
93
|
+
classes = canonical_loads(
|
|
94
|
+
metadata["class_params"].encode(), max_bytes=HYPERPARAMETER_BYTES
|
|
95
|
+
)
|
|
96
|
+
if not isinstance(classes, dict):
|
|
97
|
+
raise InvalidModelError("Invalid CatBoost native class record.")
|
|
98
|
+
mapping, names = classes["class_to_label"], classes["class_names"]
|
|
99
|
+
if (
|
|
100
|
+
not isinstance(mapping, list)
|
|
101
|
+
or len(mapping) != count
|
|
102
|
+
or not isinstance(names, list)
|
|
103
|
+
or len(names) not in (0, count)
|
|
104
|
+
):
|
|
105
|
+
raise InvalidModelError("Invalid CatBoost native class count.")
|
|
106
|
+
declared = classes["classes_count"]
|
|
107
|
+
if type(declared) is not int or declared not in (0, count):
|
|
108
|
+
raise InvalidModelError("Invalid CatBoost declared class count.")
|
|
109
|
+
if classes.get("class_label_type") not in (
|
|
110
|
+
"Integer",
|
|
111
|
+
"Float",
|
|
112
|
+
"String",
|
|
113
|
+
"Boolean",
|
|
114
|
+
) or any(
|
|
115
|
+
type(label) not in (int, float) or not math.isfinite(cast(float, label))
|
|
116
|
+
for label in mapping
|
|
117
|
+
):
|
|
118
|
+
raise InvalidModelError("Invalid CatBoost native class mapping.")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _loss(metadata: dict[str, str], dimension: int) -> str:
|
|
122
|
+
params = canonical_loads(
|
|
123
|
+
metadata["params"].encode(), max_bytes=HYPERPARAMETER_BYTES, max_depth=16
|
|
124
|
+
)
|
|
125
|
+
if not isinstance(params, dict):
|
|
126
|
+
raise InvalidModelError("Invalid CatBoost native parameters.")
|
|
127
|
+
params = cast(dict[str, Any], params)
|
|
128
|
+
loss = params["loss_function"]["type"]
|
|
129
|
+
if (
|
|
130
|
+
loss not in CLASSIFIERS | REGRESSORS
|
|
131
|
+
or params["tree_learner_options"]["grow_policy"] != "SymmetricTree"
|
|
132
|
+
):
|
|
133
|
+
raise InvalidModelError("Unsupported CatBoost native loss or tree policy.")
|
|
134
|
+
if loss == "MultiClass":
|
|
135
|
+
if not 2 <= dimension <= MAX_CLASSES:
|
|
136
|
+
raise InvalidModelError("Invalid CatBoost multiclass dimension.")
|
|
137
|
+
elif dimension != 1:
|
|
138
|
+
raise InvalidModelError("CatBoost requires a single target.")
|
|
139
|
+
for metric in [
|
|
140
|
+
params["metrics"]["eval_metric"],
|
|
141
|
+
*params["metrics"]["custom_metrics"],
|
|
142
|
+
]:
|
|
143
|
+
if metric["type"].startswith("Python"):
|
|
144
|
+
raise InvalidModelError("Custom Python CatBoost metrics are unsupported.")
|
|
145
|
+
if loss in CLASSIFIERS:
|
|
146
|
+
_classes(metadata, dimension if loss == "MultiClass" else 2)
|
|
147
|
+
return loss
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _trees(tree: Table) -> dict[str, Any]:
|
|
151
|
+
dimension = tree.scalar(0, "i")
|
|
152
|
+
if not 1 <= dimension <= MAX_CLASSES:
|
|
153
|
+
raise InvalidModelError("Invalid CatBoost output dimension.")
|
|
154
|
+
depths = tree.vector(2, "<i4", MAX_TREES)
|
|
155
|
+
offsets = tree.vector(3, "<i4", MAX_TREES)
|
|
156
|
+
splits = tree.vector(1, "<i4", MAX_TREES * MAX_DEPTH)
|
|
157
|
+
if (
|
|
158
|
+
not len(depths)
|
|
159
|
+
or np.any(depths < 0)
|
|
160
|
+
or np.any(depths > MAX_DEPTH)
|
|
161
|
+
or len(offsets) != len(depths)
|
|
162
|
+
):
|
|
163
|
+
raise InvalidModelError("Invalid CatBoost tree count or depth.")
|
|
164
|
+
expected = np.concatenate(([0], np.cumsum(depths[:-1], dtype=np.int64)))
|
|
165
|
+
if not np.array_equal(offsets, expected) or len(splits) != int(depths.sum()):
|
|
166
|
+
raise InvalidModelError("Invalid CatBoost tree split offsets.")
|
|
167
|
+
leaves = int(np.left_shift(np.int64(1), depths).sum())
|
|
168
|
+
values = tree.vector(8, "<f8", ArchiveLimits().max_member_bytes // 8)
|
|
169
|
+
weights = tree.vector(9, "<f8", ArchiveLimits().max_member_bytes // 8)
|
|
170
|
+
if (
|
|
171
|
+
len(values) != leaves * dimension
|
|
172
|
+
or len(weights) != leaves
|
|
173
|
+
or not np.isfinite(values).all()
|
|
174
|
+
or not np.isfinite(weights).all()
|
|
175
|
+
or np.any(weights < 0)
|
|
176
|
+
):
|
|
177
|
+
raise InvalidModelError("Invalid CatBoost leaf values or weights.")
|
|
178
|
+
features = _features(tree)
|
|
179
|
+
border_count = sum(len(feature["borders"]) for feature in features)
|
|
180
|
+
if np.any(splits < 0) or np.any(splits >= border_count):
|
|
181
|
+
raise InvalidModelError("Invalid CatBoost split feature indices.")
|
|
182
|
+
scale, legacy_bias = tree.scalar(14, "d", 1.0), tree.scalar(15, "d", 0.0)
|
|
183
|
+
bias = tree.vector(16, "<f8", MAX_CLASSES)
|
|
184
|
+
if (
|
|
185
|
+
not math.isfinite(scale)
|
|
186
|
+
or not math.isfinite(legacy_bias)
|
|
187
|
+
or len(bias) not in (0, dimension)
|
|
188
|
+
or not np.isfinite(bias).all()
|
|
189
|
+
):
|
|
190
|
+
raise InvalidModelError("Invalid CatBoost scale or bias.")
|
|
191
|
+
if len(bias) and legacy_bias != 0.0:
|
|
192
|
+
raise InvalidModelError("Ambiguous CatBoost bias records.")
|
|
193
|
+
repacked = tree.vector(17, "<u4", MAX_TREES * MAX_DEPTH)
|
|
194
|
+
if len(repacked) not in (0, len(splits)):
|
|
195
|
+
raise InvalidModelError("Invalid CatBoost repacked split count.")
|
|
196
|
+
return {
|
|
197
|
+
"approx_dimension": dimension,
|
|
198
|
+
"tree_depths": depths,
|
|
199
|
+
"tree_split_offsets": offsets,
|
|
200
|
+
"tree_splits": splits,
|
|
201
|
+
"leaf_values": values,
|
|
202
|
+
"leaf_weights": weights,
|
|
203
|
+
"float_features": features,
|
|
204
|
+
"scale": scale,
|
|
205
|
+
"bias": bias if len(bias) else np.full(dimension, legacy_bias),
|
|
206
|
+
"repacked_bins": repacked,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def decode(data: bytes) -> Snapshot:
|
|
211
|
+
if (
|
|
212
|
+
type(data) is not bytes
|
|
213
|
+
or len(data) > ArchiveLimits().max_member_bytes
|
|
214
|
+
or len(data) < 12
|
|
215
|
+
or data[:4] != b"CBM1"
|
|
216
|
+
):
|
|
217
|
+
raise InvalidModelError("Expected a bounded native CatBoost CBM model.")
|
|
218
|
+
size = struct.unpack_from("<I", data, 4)[0]
|
|
219
|
+
if size != len(data) - 8:
|
|
220
|
+
raise InvalidModelError(
|
|
221
|
+
"Truncated CBM core or unsupported CatBoost model parts."
|
|
222
|
+
)
|
|
223
|
+
buffer = Buffer(memoryview(data)[8:])
|
|
224
|
+
core = buffer.table(buffer.indirect(0), 4)
|
|
225
|
+
if core.string(0, 256) != "FlabuffersModel_v1" or len(core.vector(3, "<u4", 0)):
|
|
226
|
+
raise InvalidModelError("Unsupported CatBoost core version or model parts.")
|
|
227
|
+
tree = core.child(1, 19)
|
|
228
|
+
# No CTR parser, feature calcer, or non-oblivious traversal reaches native code.
|
|
229
|
+
for field in (4, 6, 7, 10, 11, 12, 13, 18):
|
|
230
|
+
if len(tree.vector(field, "<u4", 0)):
|
|
231
|
+
raise InvalidModelError(
|
|
232
|
+
"Unsupported CatBoost feature or non-symmetric tree state."
|
|
233
|
+
)
|
|
234
|
+
native = _trees(tree)
|
|
235
|
+
structure_budget(native)
|
|
236
|
+
metadata = _metadata(core)
|
|
237
|
+
loss = _loss(metadata, native["approx_dimension"])
|
|
238
|
+
return Snapshot(
|
|
239
|
+
native,
|
|
240
|
+
metadata,
|
|
241
|
+
loss,
|
|
242
|
+
len(native["float_features"]),
|
|
243
|
+
native["approx_dimension"],
|
|
244
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Numeric CatBoost input and output validation without native exception contents."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from mlbundle._prediction_input import tabular
|
|
8
|
+
from mlbundle.adapters import ProbabilityOutput, RegressionOutput
|
|
9
|
+
from mlbundle.claims import ModelClaims
|
|
10
|
+
from mlbundle.contract import ModelKind
|
|
11
|
+
from mlbundle.errors import InvalidModelError, MLBundleError, PredictionInputError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Predictor:
|
|
15
|
+
def __init__(self, model: Any, claims: ModelClaims) -> None:
|
|
16
|
+
self.model, self.claims = model, claims
|
|
17
|
+
|
|
18
|
+
def _call(self, data: object, *, probabilities: bool) -> tuple[np.ndarray, int]:
|
|
19
|
+
try:
|
|
20
|
+
inputs, rows = tabular(data, self.claims.n_features)
|
|
21
|
+
if rows < 1:
|
|
22
|
+
raise PredictionInputError(
|
|
23
|
+
"CatBoost prediction requires at least one row."
|
|
24
|
+
)
|
|
25
|
+
if (
|
|
26
|
+
hasattr(inputs, "columns")
|
|
27
|
+
and list(inputs.columns) != self.claims.feature_names
|
|
28
|
+
):
|
|
29
|
+
raise PredictionInputError(
|
|
30
|
+
"CatBoost prediction columns must match native feature order."
|
|
31
|
+
)
|
|
32
|
+
raw = (
|
|
33
|
+
self.model.predict_proba(inputs, thread_count=1)
|
|
34
|
+
if probabilities
|
|
35
|
+
else self.model.predict(inputs, thread_count=1)
|
|
36
|
+
)
|
|
37
|
+
return np.asarray(raw, dtype=float), rows
|
|
38
|
+
except MLBundleError:
|
|
39
|
+
raise
|
|
40
|
+
except Exception:
|
|
41
|
+
raise InvalidModelError("CatBoost prediction failed.") from None
|
|
42
|
+
|
|
43
|
+
def predict(self, data: object) -> RegressionOutput:
|
|
44
|
+
if self.claims.kind != ModelKind.REGRESSOR:
|
|
45
|
+
raise ValueError("predict requires a regressor.")
|
|
46
|
+
values, rows = self._call(data, probabilities=False)
|
|
47
|
+
if values.shape != (rows,) or not np.isfinite(values).all():
|
|
48
|
+
raise InvalidModelError(
|
|
49
|
+
"Invalid regression prediction dimensions or values."
|
|
50
|
+
)
|
|
51
|
+
return cast(RegressionOutput, values.tolist())
|
|
52
|
+
|
|
53
|
+
def predict_proba(self, data: object) -> ProbabilityOutput:
|
|
54
|
+
if self.claims.kind != ModelKind.CLASSIFIER:
|
|
55
|
+
raise ValueError("predict_proba requires a classifier.")
|
|
56
|
+
values, rows = self._call(data, probabilities=True)
|
|
57
|
+
if (
|
|
58
|
+
values.shape != (rows, (self.claims.n_classes or [0])[0])
|
|
59
|
+
or not np.isfinite(values).all()
|
|
60
|
+
or np.any(values < 0)
|
|
61
|
+
or np.any(values > 1)
|
|
62
|
+
or not np.allclose(values.sum(axis=1), 1.0, rtol=1e-6, atol=1e-7)
|
|
63
|
+
):
|
|
64
|
+
raise InvalidModelError(
|
|
65
|
+
"Invalid probability dimensions, values or row sums."
|
|
66
|
+
)
|
|
67
|
+
return cast(ProbabilityOutput, values.tolist())
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Exact wrappers, verified losses and numeric symmetric trees only."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import catboost as cb
|
|
6
|
+
|
|
7
|
+
from mlbundle.canonical import canonical_dumps
|
|
8
|
+
from mlbundle.errors import InvalidJSONError, UnsupportedModelError
|
|
9
|
+
from mlbundle.limits import HYPERPARAMETER_BYTES
|
|
10
|
+
|
|
11
|
+
CLASSIFIERS = frozenset({"Logloss", "CrossEntropy", "MultiClass"})
|
|
12
|
+
REGRESSORS = frozenset({"RMSE", "MAE", "Quantile", "Huber", "Poisson", "MAPE"})
|
|
13
|
+
CLASSES = (cb.CatBoostClassifier, cb.CatBoostRegressor)
|
|
14
|
+
NATIVE_CLASS = type(vars(cb.CatBoostClassifier())["_object"])
|
|
15
|
+
MAX_TREES = 100_000
|
|
16
|
+
MAX_DEPTH = 16
|
|
17
|
+
WRAPPER_FIELDS = frozenset(
|
|
18
|
+
{
|
|
19
|
+
"_init_params",
|
|
20
|
+
"_canonized_params",
|
|
21
|
+
"_object",
|
|
22
|
+
"_is_fitted_",
|
|
23
|
+
"_random_seed",
|
|
24
|
+
"_learning_rate",
|
|
25
|
+
"_tree_count",
|
|
26
|
+
"_n_features_in",
|
|
27
|
+
"_prediction_values_change",
|
|
28
|
+
"_loss_value_change",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parameters(params: dict[str, Any]) -> None:
|
|
34
|
+
for key in ("loss_function", "objective", "eval_metric"):
|
|
35
|
+
if params.get(key) is not None and not isinstance(params[key], str):
|
|
36
|
+
raise UnsupportedModelError(
|
|
37
|
+
reason="Custom Python CatBoost objectives and metrics are unsupported."
|
|
38
|
+
)
|
|
39
|
+
metrics = params.get("custom_metric", [])
|
|
40
|
+
if isinstance(metrics, str):
|
|
41
|
+
metrics = [metrics]
|
|
42
|
+
if metrics is not None and any(not isinstance(metric, str) for metric in metrics):
|
|
43
|
+
raise UnsupportedModelError(
|
|
44
|
+
reason="Custom Python CatBoost objectives and metrics are unsupported."
|
|
45
|
+
)
|
|
46
|
+
try:
|
|
47
|
+
canonical_dumps(params, max_bytes=HYPERPARAMETER_BYTES)
|
|
48
|
+
except InvalidJSONError:
|
|
49
|
+
raise UnsupportedModelError(
|
|
50
|
+
reason="CatBoost parameters must contain only JSON values."
|
|
51
|
+
) from None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _wrapper(model: Any, loss: str) -> None:
|
|
55
|
+
cached = model._canonized_params
|
|
56
|
+
params = model.get_params() if cached is None else cached
|
|
57
|
+
if not isinstance(params, dict):
|
|
58
|
+
raise UnsupportedModelError(reason="Invalid CatBoost wrapper parameter cache.")
|
|
59
|
+
_parameters(params)
|
|
60
|
+
if type(model) is cb.CatBoostRegressor:
|
|
61
|
+
declared = params.get("loss_function", params.get("objective"))
|
|
62
|
+
mode = "RawFormulaVal"
|
|
63
|
+
if isinstance(declared, str):
|
|
64
|
+
if declared.startswith(("Poisson", "Tweedie")):
|
|
65
|
+
mode = "Exponent"
|
|
66
|
+
elif declared == "RMSEWithUncertainty":
|
|
67
|
+
mode = "RMSEWithUncertainty"
|
|
68
|
+
if mode != ("Exponent" if loss == "Poisson" else "RawFormulaVal"):
|
|
69
|
+
raise UnsupportedModelError(
|
|
70
|
+
reason="CatBoost wrapper default prediction does not survive CBM loading."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate(model: Any) -> None:
|
|
75
|
+
if type(model) not in CLASSES:
|
|
76
|
+
raise UnsupportedModelError(
|
|
77
|
+
reason="Only exact CatBoostClassifier and CatBoostRegressor are supported."
|
|
78
|
+
)
|
|
79
|
+
if set(vars(model)) - WRAPPER_FIELDS:
|
|
80
|
+
raise UnsupportedModelError(
|
|
81
|
+
reason="Unknown CatBoost wrapper attributes are unsupported."
|
|
82
|
+
)
|
|
83
|
+
if type(vars(model).get("_object")) is not NATIVE_CLASS:
|
|
84
|
+
raise UnsupportedModelError(
|
|
85
|
+
reason="Only the native CatBoost model object is supported."
|
|
86
|
+
)
|
|
87
|
+
if not model.is_fitted():
|
|
88
|
+
raise UnsupportedModelError(reason="CatBoost estimator must be fitted.")
|
|
89
|
+
_parameters(model.get_params())
|
|
90
|
+
if model.get_cat_feature_indices():
|
|
91
|
+
raise UnsupportedModelError(
|
|
92
|
+
reason="CatBoost categorical features are unsupported."
|
|
93
|
+
)
|
|
94
|
+
if model.get_text_feature_indices() or model.get_embedding_feature_indices():
|
|
95
|
+
raise UnsupportedModelError(
|
|
96
|
+
reason="CatBoost text and embedding features are unsupported."
|
|
97
|
+
)
|
|
98
|
+
native = model.get_all_params()
|
|
99
|
+
loss = native["loss_function"].split(":", 1)[0]
|
|
100
|
+
allowed = CLASSIFIERS if type(model) is cb.CatBoostClassifier else REGRESSORS
|
|
101
|
+
if loss not in allowed:
|
|
102
|
+
raise UnsupportedModelError(
|
|
103
|
+
reason="Unsupported CatBoost loss; only closed single-target losses are supported."
|
|
104
|
+
)
|
|
105
|
+
if native.get("grow_policy") != "SymmetricTree":
|
|
106
|
+
raise UnsupportedModelError(
|
|
107
|
+
reason="Only CatBoost SymmetricTree is supported; Depthwise/Lossguide are unsupported."
|
|
108
|
+
)
|
|
109
|
+
_wrapper(model, loss)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Recomputed native snapshots and commitments independent of CBM buffer layout."""
|
|
2
|
+
|
|
3
|
+
from hashlib import sha256
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from mlbundle.canonical import canonical_dumps
|
|
9
|
+
from mlbundle.errors import InvalidModelError
|
|
10
|
+
from mlbundle.limits import MANIFEST_BYTES
|
|
11
|
+
|
|
12
|
+
from .native import Snapshot, decode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def snapshot(model: Any) -> Snapshot:
|
|
16
|
+
# CatBoost has no public in-memory save. This audited native CBM serializer
|
|
17
|
+
# lets extraction recompute loaded state without reader-side disk writes.
|
|
18
|
+
return decode(model._serialize_model())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def commitment(state: Snapshot) -> str:
|
|
22
|
+
def digest(value: Any) -> str:
|
|
23
|
+
if isinstance(value, np.ndarray):
|
|
24
|
+
header = canonical_dumps([value.dtype.str, list(value.shape)])
|
|
25
|
+
result = sha256(b"array:")
|
|
26
|
+
result.update(header)
|
|
27
|
+
result.update(memoryview(value))
|
|
28
|
+
return result.hexdigest()
|
|
29
|
+
if isinstance(value, dict):
|
|
30
|
+
record = {key: digest(child) for key, child in value.items()}
|
|
31
|
+
return sha256(b"dict:" + canonical_dumps(record)).hexdigest()
|
|
32
|
+
if isinstance(value, list):
|
|
33
|
+
return sha256(
|
|
34
|
+
b"list:" + canonical_dumps([digest(child) for child in value])
|
|
35
|
+
).hexdigest()
|
|
36
|
+
return sha256(
|
|
37
|
+
b"scalar:" + canonical_dumps(value, max_bytes=MANIFEST_BYTES)
|
|
38
|
+
).hexdigest()
|
|
39
|
+
|
|
40
|
+
return digest({"native_model": state.native, "metadata": state.metadata})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def json_value(value: Any) -> Any:
|
|
44
|
+
if isinstance(value, np.ndarray):
|
|
45
|
+
return value.tolist()
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
return {key: json_value(child) for key, child in value.items()}
|
|
48
|
+
if isinstance(value, list):
|
|
49
|
+
return [json_value(child) for child in value]
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def feature_importances(model: Any, width: int) -> list[float]:
|
|
54
|
+
values = np.asarray(
|
|
55
|
+
model.copy().get_feature_importance(
|
|
56
|
+
type="PredictionValuesChange", thread_count=1
|
|
57
|
+
),
|
|
58
|
+
dtype=float,
|
|
59
|
+
)
|
|
60
|
+
if values.shape != (width,) or not np.isfinite(values).all() or np.any(values < 0):
|
|
61
|
+
raise InvalidModelError("Invalid CatBoost feature importances.")
|
|
62
|
+
return values.tolist()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Separate checked components, closed sklearn reconstruction."""
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""One composite extraction for export and load."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import JsonValue
|
|
6
|
+
from sklearn.pipeline import Pipeline
|
|
7
|
+
|
|
8
|
+
from mlbundle._pipeline_contract import (
|
|
9
|
+
NativeComponentInfo,
|
|
10
|
+
PipelineInfo,
|
|
11
|
+
TransformedFeatures,
|
|
12
|
+
)
|
|
13
|
+
from mlbundle._sklearn import transformers
|
|
14
|
+
from mlbundle._sklearn.pipeline_scope import validate_components
|
|
15
|
+
from mlbundle._sklearn.transform_names import input_boundary, output_names
|
|
16
|
+
from mlbundle._sklearn.transform_width import output_width
|
|
17
|
+
from mlbundle.canonical import canonical_dumps
|
|
18
|
+
from mlbundle.claims import ModelClaims
|
|
19
|
+
from mlbundle.contract import MODEL_MEMBERS, Library
|
|
20
|
+
from mlbundle.errors import ClaimMismatchError, InvalidModelError, MLBundleError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def outer_claims(
|
|
24
|
+
prefix: Pipeline | None, leaf: ModelClaims, verbose: bool
|
|
25
|
+
) -> ModelClaims:
|
|
26
|
+
count, names = input_boundary(prefix, leaf)
|
|
27
|
+
return ModelClaims(
|
|
28
|
+
estimator_class="Pipeline",
|
|
29
|
+
kind=leaf.kind,
|
|
30
|
+
hyperparameters={"memory": None, "verbose": verbose, "transform_input": None},
|
|
31
|
+
n_features=count,
|
|
32
|
+
n_classes=leaf.n_classes,
|
|
33
|
+
n_outputs=leaf.n_outputs,
|
|
34
|
+
feature_names=names,
|
|
35
|
+
feature_dtypes=leaf.feature_dtypes if prefix is None else None,
|
|
36
|
+
class_labels=leaf.class_labels,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def extract(
|
|
41
|
+
prefix: Pipeline | None,
|
|
42
|
+
leaf: ModelClaims,
|
|
43
|
+
library: Library,
|
|
44
|
+
step_names: list[str],
|
|
45
|
+
verbose: bool,
|
|
46
|
+
*,
|
|
47
|
+
names: list[str],
|
|
48
|
+
transformer_adapter: transformers.Adapter,
|
|
49
|
+
) -> PipelineInfo:
|
|
50
|
+
try:
|
|
51
|
+
if library not in (Library.XGBOOST, Library.CATBOOST):
|
|
52
|
+
raise InvalidModelError("Invalid composite Pipeline state.")
|
|
53
|
+
outer = outer_claims(prefix, leaf, verbose)
|
|
54
|
+
count = (
|
|
55
|
+
outer.n_features
|
|
56
|
+
if prefix is None
|
|
57
|
+
else output_width(prefix, outer.n_features)
|
|
58
|
+
)
|
|
59
|
+
if count != leaf.n_features:
|
|
60
|
+
raise InvalidModelError(
|
|
61
|
+
"Transformer output width disagrees with final estimator."
|
|
62
|
+
)
|
|
63
|
+
transformed = names if prefix is None else output_names(prefix, names)
|
|
64
|
+
origin = "input" if prefix is None else "transformer"
|
|
65
|
+
if transformed is None:
|
|
66
|
+
transformed = leaf.feature_names
|
|
67
|
+
origin = "model"
|
|
68
|
+
if transformed is None:
|
|
69
|
+
transformed = [f"transformed_feature_{i}" for i in range(1, count + 1)]
|
|
70
|
+
origin = "generated"
|
|
71
|
+
if len(transformed) != count:
|
|
72
|
+
raise InvalidModelError("Transformed names and width disagree.")
|
|
73
|
+
if prefix is not None:
|
|
74
|
+
validate_components(prefix)
|
|
75
|
+
return PipelineInfo(
|
|
76
|
+
step_names=step_names,
|
|
77
|
+
transformers=None if prefix is None else transformer_adapter.claims(prefix),
|
|
78
|
+
estimator=NativeComponentInfo(
|
|
79
|
+
library=library,
|
|
80
|
+
serialization=MODEL_MEMBERS[library][0],
|
|
81
|
+
member=MODEL_MEMBERS[library][1],
|
|
82
|
+
claims=leaf,
|
|
83
|
+
),
|
|
84
|
+
transformed_features=TransformedFeatures(
|
|
85
|
+
n_features=count, names=transformed, origin=origin
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
except MLBundleError:
|
|
89
|
+
raise
|
|
90
|
+
except Exception:
|
|
91
|
+
raise InvalidModelError("Invalid composite Pipeline state.") from None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def verify(expected: PipelineInfo, actual: PipelineInfo) -> None:
|
|
95
|
+
for field in ("step_names", "transformers", "transformed_features"):
|
|
96
|
+
if canonical_dumps(expected.model_dump(mode="json")[field]) != canonical_dumps(
|
|
97
|
+
actual.model_dump(mode="json")[field]
|
|
98
|
+
):
|
|
99
|
+
raise ClaimMismatchError("pipeline." + field)
|
|
100
|
+
left = expected.estimator.model_dump(mode="json")
|
|
101
|
+
right = actual.estimator.model_dump(mode="json")
|
|
102
|
+
for field, value in left["claims"].items():
|
|
103
|
+
if canonical_dumps(value) != canonical_dumps(right["claims"][field]):
|
|
104
|
+
raise ClaimMismatchError("pipeline.estimator." + field)
|
|
105
|
+
if canonical_dumps(left) != canonical_dumps(right):
|
|
106
|
+
raise ClaimMismatchError("pipeline.estimator")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def structure(
|
|
110
|
+
native: dict[str, JsonValue] | None, recipe: PipelineInfo
|
|
111
|
+
) -> dict[str, JsonValue]:
|
|
112
|
+
if native is None:
|
|
113
|
+
raise InvalidModelError("Invalid composite Pipeline state.")
|
|
114
|
+
return {
|
|
115
|
+
**native,
|
|
116
|
+
"pipeline": {
|
|
117
|
+
"step_name": recipe.step_names[-1],
|
|
118
|
+
"features": recipe.transformed_features.model_dump(mode="json"),
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def rebuild(
|
|
124
|
+
prefix: Pipeline | None, leaf: Any, step_names: list[str], verbose: bool
|
|
125
|
+
) -> Pipeline:
|
|
126
|
+
if prefix is not None and [name for name, _ in prefix.steps] != step_names[:-1]:
|
|
127
|
+
raise ClaimMismatchError("pipeline.transformers")
|
|
128
|
+
return Pipeline(
|
|
129
|
+
[*([] if prefix is None else prefix.steps), (step_names[-1], leaf)],
|
|
130
|
+
memory=None,
|
|
131
|
+
verbose=verbose,
|
|
132
|
+
transform_input=None,
|
|
133
|
+
)
|