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.
Files changed (91) hide show
  1. mlbundle/__init__.py +82 -0
  2. mlbundle/_base.py +19 -0
  3. mlbundle/_canonical_work.py +67 -0
  4. mlbundle/_catboost/__init__.py +159 -0
  5. mlbundle/_catboost/budget.py +22 -0
  6. mlbundle/_catboost/flatbuffer.py +104 -0
  7. mlbundle/_catboost/native.py +244 -0
  8. mlbundle/_catboost/prediction.py +67 -0
  9. mlbundle/_catboost/scope.py +109 -0
  10. mlbundle/_catboost/state.py +62 -0
  11. mlbundle/_composite/__init__.py +1 -0
  12. mlbundle/_composite/claims.py +133 -0
  13. mlbundle/_composite/io.py +158 -0
  14. mlbundle/_composite/prediction.py +77 -0
  15. mlbundle/_container_rules.py +73 -0
  16. mlbundle/_export_manifest.py +78 -0
  17. mlbundle/_native_io.py +53 -0
  18. mlbundle/_onnx/__init__.py +115 -0
  19. mlbundle/_onnx/_wire_runs.py +103 -0
  20. mlbundle/_onnx/description.py +252 -0
  21. mlbundle/_onnx/prediction.py +136 -0
  22. mlbundle/_onnx/protobuf.py +111 -0
  23. mlbundle/_onnx/session.py +87 -0
  24. mlbundle/_onnx/signatures.py +63 -0
  25. mlbundle/_onnx/validation.py +164 -0
  26. mlbundle/_pipeline_contract.py +104 -0
  27. mlbundle/_prediction_input.py +63 -0
  28. mlbundle/_schema_fields.py +68 -0
  29. mlbundle/_sklearn/__init__.py +183 -0
  30. mlbundle/_sklearn/_width_checks.py +59 -0
  31. mlbundle/_sklearn/archive.py +32 -0
  32. mlbundle/_sklearn/arrays.py +150 -0
  33. mlbundle/_sklearn/column_width.py +198 -0
  34. mlbundle/_sklearn/consistency.py +127 -0
  35. mlbundle/_sklearn/dtypes.py +41 -0
  36. mlbundle/_sklearn/encoder_width.py +123 -0
  37. mlbundle/_sklearn/fitted.py +113 -0
  38. mlbundle/_sklearn/labels.py +52 -0
  39. mlbundle/_sklearn/nodes.py +75 -0
  40. mlbundle/_sklearn/parameters.py +94 -0
  41. mlbundle/_sklearn/payload.py +234 -0
  42. mlbundle/_sklearn/pipeline_scope.py +141 -0
  43. mlbundle/_sklearn/polynomial_names.py +31 -0
  44. mlbundle/_sklearn/prediction.py +119 -0
  45. mlbundle/_sklearn/prediction_state.py +61 -0
  46. mlbundle/_sklearn/references.py +110 -0
  47. mlbundle/_sklearn/serialization.py +80 -0
  48. mlbundle/_sklearn/state.py +144 -0
  49. mlbundle/_sklearn/state_fields.json +228 -0
  50. mlbundle/_sklearn/structure.py +265 -0
  51. mlbundle/_sklearn/transform_names.py +111 -0
  52. mlbundle/_sklearn/transform_width.py +270 -0
  53. mlbundle/_sklearn/transformers.py +69 -0
  54. mlbundle/_sklearn/trusted.py +89 -0
  55. mlbundle/_xgboost/__init__.py +262 -0
  56. mlbundle/_xgboost/arrays.py +47 -0
  57. mlbundle/_xgboost/categories.py +109 -0
  58. mlbundle/_xgboost/native.py +193 -0
  59. mlbundle/_xgboost/prediction.py +59 -0
  60. mlbundle/_xgboost/snapshots.py +41 -0
  61. mlbundle/_xgboost/state.py +147 -0
  62. mlbundle/_xgboost/structure.py +47 -0
  63. mlbundle/_xgboost/ubjson.py +119 -0
  64. mlbundle/_zip_directory.py +173 -0
  65. mlbundle/_zip_members.py +116 -0
  66. mlbundle/adapters.py +33 -0
  67. mlbundle/canonical.py +290 -0
  68. mlbundle/claims.py +124 -0
  69. mlbundle/cli.py +193 -0
  70. mlbundle/container.py +93 -0
  71. mlbundle/contract.py +55 -0
  72. mlbundle/declarations.py +68 -0
  73. mlbundle/environment.py +85 -0
  74. mlbundle/errors.py +92 -0
  75. mlbundle/inspection.py +30 -0
  76. mlbundle/integrity.py +30 -0
  77. mlbundle/limits.py +38 -0
  78. mlbundle/manifest.py +222 -0
  79. mlbundle/model_io.py +151 -0
  80. mlbundle/py.typed +0 -0
  81. mlbundle/registry.py +103 -0
  82. mlbundle/results.py +68 -0
  83. mlbundle/samples.py +71 -0
  84. mlbundle/schema.py +145 -0
  85. mlbundle/training.py +104 -0
  86. mlbundle/writing.py +256 -0
  87. mlbundle-0.1.0a1.dist-info/METADATA +347 -0
  88. mlbundle-0.1.0a1.dist-info/RECORD +91 -0
  89. mlbundle-0.1.0a1.dist-info/WHEEL +4 -0
  90. mlbundle-0.1.0a1.dist-info/entry_points.txt +3 -0
  91. 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
+ )