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,141 @@
|
|
|
1
|
+
"""Bounded native-placement discovery and closed Pipeline options."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from sklearn import get_config
|
|
7
|
+
from sklearn.base import BaseEstimator
|
|
8
|
+
from sklearn.pipeline import Pipeline
|
|
9
|
+
|
|
10
|
+
from mlbundle._pipeline_contract import OPTIONS, valid_step_names
|
|
11
|
+
from mlbundle.errors import InvalidModelError, LimitExceededError, UnsupportedModelError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def objects(root: Any):
|
|
15
|
+
pending = [root]
|
|
16
|
+
seen = set()
|
|
17
|
+
visits = 0
|
|
18
|
+
while pending:
|
|
19
|
+
value = pending.pop()
|
|
20
|
+
visits += 1
|
|
21
|
+
if visits > 100_000:
|
|
22
|
+
raise LimitExceededError("Pipeline state work exceeds the limit.")
|
|
23
|
+
if id(value) in seen:
|
|
24
|
+
continue
|
|
25
|
+
seen.add(id(value))
|
|
26
|
+
if isinstance(value, dict):
|
|
27
|
+
children = value.values()
|
|
28
|
+
elif isinstance(value, (list, tuple)):
|
|
29
|
+
children = value
|
|
30
|
+
elif isinstance(value, np.ndarray) and value.dtype.kind == "O":
|
|
31
|
+
children = value.flat
|
|
32
|
+
else:
|
|
33
|
+
yield value
|
|
34
|
+
children = vars(value).values() if isinstance(value, BaseEstimator) else ()
|
|
35
|
+
if len(pending) + len(children) > 100_000:
|
|
36
|
+
raise LimitExceededError("Pipeline state work exceeds the limit.")
|
|
37
|
+
pending.extend(children)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def native_types(root: Any) -> tuple[str, ...]:
|
|
41
|
+
return tuple(
|
|
42
|
+
sorted(
|
|
43
|
+
{
|
|
44
|
+
f"{type(value).__module__}.{type(value).__name__}"
|
|
45
|
+
for value in objects(root)
|
|
46
|
+
if {base.__module__.split(".")[0] for base in type(value).__mro__}
|
|
47
|
+
& {"xgboost", "catboost"}
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def composite_candidate(model: Any) -> bool:
|
|
54
|
+
found = native_types(model)
|
|
55
|
+
if not found:
|
|
56
|
+
return False
|
|
57
|
+
if (
|
|
58
|
+
isinstance(model, Pipeline)
|
|
59
|
+
and model.steps
|
|
60
|
+
and {base.__module__.split(".")[0] for base in type(model.steps[-1][1]).__mro__}
|
|
61
|
+
& {"xgboost", "catboost"}
|
|
62
|
+
):
|
|
63
|
+
if any(native_types(step) for _, step in model.steps[:-1]):
|
|
64
|
+
reject_placement(found)
|
|
65
|
+
validate_options(model)
|
|
66
|
+
return True
|
|
67
|
+
reject_placement(found)
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def reject_placement(types: tuple[str, ...]) -> None:
|
|
72
|
+
raise UnsupportedModelError(
|
|
73
|
+
types,
|
|
74
|
+
reason=(
|
|
75
|
+
"Native estimators are supported only as the last step of the outermost Pipeline. "
|
|
76
|
+
+ str(UnsupportedModelError(types))
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate_options(model: Any) -> None:
|
|
82
|
+
if (
|
|
83
|
+
type(model) is not Pipeline
|
|
84
|
+
or not isinstance(model.steps, (list, tuple))
|
|
85
|
+
or not 1 <= len(model.steps) <= 128
|
|
86
|
+
):
|
|
87
|
+
raise UnsupportedModelError(
|
|
88
|
+
reason="Composite models require an exact outer Pipeline with 1 to 128 steps."
|
|
89
|
+
)
|
|
90
|
+
if "_metadata_request" in vars(model) or model.transform_input is not None:
|
|
91
|
+
raise UnsupportedModelError(
|
|
92
|
+
reason="Composite Pipeline transform_input and metadata routing are unsupported."
|
|
93
|
+
)
|
|
94
|
+
if set(vars(model)) != OPTIONS:
|
|
95
|
+
raise InvalidModelError("Unknown outer Pipeline state.")
|
|
96
|
+
if any(
|
|
97
|
+
not isinstance(item, (list, tuple)) or len(item) != 2 for item in model.steps
|
|
98
|
+
):
|
|
99
|
+
raise UnsupportedModelError(
|
|
100
|
+
reason="Pipeline step names must be unique bounded sklearn names."
|
|
101
|
+
)
|
|
102
|
+
names = [name for name, _ in model.steps]
|
|
103
|
+
if any(
|
|
104
|
+
type(name) is not str or not 1 <= len(name) <= 256 for name in names
|
|
105
|
+
) or not valid_step_names(names):
|
|
106
|
+
raise UnsupportedModelError(
|
|
107
|
+
reason="Pipeline step names must be unique bounded sklearn names."
|
|
108
|
+
)
|
|
109
|
+
if model.memory is not None:
|
|
110
|
+
raise UnsupportedModelError(
|
|
111
|
+
reason="Composite Pipeline memory must be None; caching is unsupported."
|
|
112
|
+
)
|
|
113
|
+
if type(model.verbose) is not bool:
|
|
114
|
+
raise UnsupportedModelError(
|
|
115
|
+
reason="Composite Pipeline verbose must be a boolean."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def validate_components(model: Pipeline, *, exporting: bool = False) -> None:
|
|
120
|
+
validate_options(model)
|
|
121
|
+
if exporting and get_config()["transform_output"] != "default":
|
|
122
|
+
raise UnsupportedModelError(
|
|
123
|
+
reason="Composite export requires default global transform_output; use per-step set_output."
|
|
124
|
+
)
|
|
125
|
+
for value in objects(model):
|
|
126
|
+
if isinstance(value, BaseEstimator):
|
|
127
|
+
if "_metadata_request" in vars(value):
|
|
128
|
+
raise UnsupportedModelError(
|
|
129
|
+
reason="Composite Pipeline transform_input and metadata routing are unsupported."
|
|
130
|
+
)
|
|
131
|
+
config = vars(value).get("_sklearn_output_config", {})
|
|
132
|
+
if (
|
|
133
|
+
not isinstance(config, dict)
|
|
134
|
+
or set(config) - {"transform"}
|
|
135
|
+
or config.get("transform", "default") not in ("default", "pandas")
|
|
136
|
+
):
|
|
137
|
+
raise UnsupportedModelError(
|
|
138
|
+
reason="Composite transformers support only default or pandas set_output."
|
|
139
|
+
)
|
|
140
|
+
if isinstance(value, Pipeline) and value is not model:
|
|
141
|
+
validate_options(value)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Bounded polynomial names without sklearn's dense powers_ allocation."""
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from itertools import combinations, combinations_with_replacement
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from mlbundle.errors import InvalidModelError, LimitExceededError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def polynomial_names(model: Any, names: list[str]) -> list[str]:
|
|
11
|
+
# Count term visits before constructing tuples; unlike powers_, work follows
|
|
12
|
+
# emitted monomials, not output width multiplied by original feature width.
|
|
13
|
+
maximum = (
|
|
14
|
+
min(model._max_degree, len(names))
|
|
15
|
+
if model.interaction_only
|
|
16
|
+
else model._max_degree
|
|
17
|
+
)
|
|
18
|
+
if model.n_output_features_ * max(1, maximum) > 100_000:
|
|
19
|
+
raise LimitExceededError("Transformer feature-name work exceeds the limit.")
|
|
20
|
+
choose = combinations if model.interaction_only else combinations_with_replacement
|
|
21
|
+
result = ["1"] if model.include_bias else []
|
|
22
|
+
for degree in range(max(1, model._min_degree), maximum + 1):
|
|
23
|
+
for term in choose(range(len(names)), degree):
|
|
24
|
+
parts = [
|
|
25
|
+
names[index] + ("" if power == 1 else f"^{power}")
|
|
26
|
+
for index, power in sorted(Counter(term).items())
|
|
27
|
+
]
|
|
28
|
+
if sum(map(len, parts)) + len(parts) - 1 > 1024:
|
|
29
|
+
raise InvalidModelError("Invalid transformed feature names.")
|
|
30
|
+
result.append(" ".join(parts))
|
|
31
|
+
return result
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Tabular prediction with explicit output dimensions and probability validation."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from sklearn.compose import ColumnTransformer
|
|
8
|
+
from sklearn.pipeline import FeatureUnion, Pipeline
|
|
9
|
+
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, TargetEncoder
|
|
10
|
+
|
|
11
|
+
from mlbundle._prediction_input import tabular
|
|
12
|
+
from mlbundle.adapters import ProbabilityOutput, RegressionOutput
|
|
13
|
+
from mlbundle.claims import ModelClaims
|
|
14
|
+
from mlbundle.contract import ModelKind
|
|
15
|
+
from mlbundle.errors import InvalidModelError, MLBundleError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _string_inputs(model: Any) -> bool:
|
|
19
|
+
pending = [model]
|
|
20
|
+
seen: set[int] = set()
|
|
21
|
+
while pending:
|
|
22
|
+
value = pending.pop()
|
|
23
|
+
if id(value) in seen:
|
|
24
|
+
continue
|
|
25
|
+
seen.add(id(value))
|
|
26
|
+
if isinstance(
|
|
27
|
+
value, (OneHotEncoder, OrdinalEncoder, TargetEncoder)
|
|
28
|
+
) and hasattr(value, "categories_"):
|
|
29
|
+
return True
|
|
30
|
+
if isinstance(value, Pipeline):
|
|
31
|
+
pending.extend(step for _, step in value.steps)
|
|
32
|
+
elif isinstance(value, ColumnTransformer):
|
|
33
|
+
pending.extend(step for _, step, _ in getattr(value, "transformers_", []))
|
|
34
|
+
elif isinstance(value, FeatureUnion):
|
|
35
|
+
pending.extend(step for _, step in value.transformer_list)
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Predictor:
|
|
40
|
+
def __init__(self, model: Any, claims: ModelClaims) -> None:
|
|
41
|
+
self.model = model
|
|
42
|
+
self.claims = claims
|
|
43
|
+
self.strings = isinstance(self.model, Pipeline) and _string_inputs(self.model)
|
|
44
|
+
|
|
45
|
+
def _data(self, data: object) -> tuple[object, int]:
|
|
46
|
+
return tabular(data, self.claims.n_features, strings=self.strings)
|
|
47
|
+
|
|
48
|
+
def _call(self, data: object, *, probabilities: bool) -> Any:
|
|
49
|
+
# Positional arrays/lists remain supported for named models; sklearn
|
|
50
|
+
# still enforces DataFrame name/order when those names are supplied.
|
|
51
|
+
try:
|
|
52
|
+
return self._native_call(data, probabilities=probabilities)
|
|
53
|
+
except MLBundleError:
|
|
54
|
+
raise
|
|
55
|
+
except Exception:
|
|
56
|
+
raise InvalidModelError("Sklearn prediction failed.") from None
|
|
57
|
+
|
|
58
|
+
def _native_call(self, data: object, *, probabilities: bool) -> Any:
|
|
59
|
+
with warnings.catch_warnings():
|
|
60
|
+
warnings.filterwarnings(
|
|
61
|
+
"ignore",
|
|
62
|
+
message="X does not have valid feature names, but .*",
|
|
63
|
+
category=UserWarning,
|
|
64
|
+
)
|
|
65
|
+
warnings.filterwarnings(
|
|
66
|
+
"ignore", message="X has feature names, but .*", category=UserWarning
|
|
67
|
+
)
|
|
68
|
+
raw = (
|
|
69
|
+
self.model.predict_proba(data)
|
|
70
|
+
if probabilities
|
|
71
|
+
else self.model.predict(data)
|
|
72
|
+
)
|
|
73
|
+
if probabilities:
|
|
74
|
+
return (
|
|
75
|
+
[np.asarray(item, dtype=float) for item in raw]
|
|
76
|
+
if isinstance(raw, list)
|
|
77
|
+
else np.asarray(raw, dtype=float)
|
|
78
|
+
)
|
|
79
|
+
return np.asarray(raw, dtype=float)
|
|
80
|
+
|
|
81
|
+
def predict(self, data: object) -> RegressionOutput:
|
|
82
|
+
if self.claims.kind != ModelKind.REGRESSOR:
|
|
83
|
+
raise ValueError("predict requires a regressor.")
|
|
84
|
+
data, rows = self._data(data)
|
|
85
|
+
result = np.asarray(self._call(data, probabilities=False), dtype=float)
|
|
86
|
+
expected = (
|
|
87
|
+
(rows,) if self.claims.n_outputs == 1 else (rows, self.claims.n_outputs)
|
|
88
|
+
)
|
|
89
|
+
if self.claims.n_outputs == 1 and result.shape == (rows, 1):
|
|
90
|
+
result = result[:, 0]
|
|
91
|
+
if result.shape != expected or not np.isfinite(result).all():
|
|
92
|
+
raise InvalidModelError(
|
|
93
|
+
"Invalid regression prediction dimensions or values."
|
|
94
|
+
)
|
|
95
|
+
return cast(RegressionOutput, result.tolist())
|
|
96
|
+
|
|
97
|
+
def predict_proba(self, data: object) -> ProbabilityOutput:
|
|
98
|
+
if self.claims.kind != ModelKind.CLASSIFIER:
|
|
99
|
+
raise ValueError("predict_proba requires a classifier.")
|
|
100
|
+
data, rows = self._data(data)
|
|
101
|
+
raw = self._call(data, probabilities=True)
|
|
102
|
+
matrices = raw if isinstance(raw, list) else [raw]
|
|
103
|
+
if not isinstance(matrices, list) or len(matrices) != self.claims.n_outputs:
|
|
104
|
+
raise InvalidModelError("Invalid probability output count.")
|
|
105
|
+
result: list[list[list[float]]] = []
|
|
106
|
+
for matrix, classes in zip(matrices, self.claims.n_classes or [], strict=True):
|
|
107
|
+
values = np.asarray(matrix, dtype=float)
|
|
108
|
+
if (
|
|
109
|
+
values.shape != (rows, classes)
|
|
110
|
+
or not np.isfinite(values).all()
|
|
111
|
+
or np.any(values < 0)
|
|
112
|
+
or np.any(values > 1)
|
|
113
|
+
or not np.allclose(values.sum(axis=1), 1.0, rtol=1e-7, atol=1e-8)
|
|
114
|
+
):
|
|
115
|
+
raise InvalidModelError(
|
|
116
|
+
"Invalid probability dimensions, values or row sums."
|
|
117
|
+
)
|
|
118
|
+
result.append(values.tolist())
|
|
119
|
+
return result[0] if self.claims.n_outputs == 1 else result
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Readable composition facts supplement complete fitted-state commitments."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import skops.io as sio
|
|
7
|
+
from sklearn.base import BaseEstimator
|
|
8
|
+
from sklearn.dummy import DummyClassifier, DummyRegressor
|
|
9
|
+
|
|
10
|
+
from .parameters import parameters
|
|
11
|
+
from .payload import validate_payload
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def prediction_state(model: Any) -> dict[str, Any]:
|
|
15
|
+
state = {}
|
|
16
|
+
if hasattr(model, "estimators_"):
|
|
17
|
+
estimators = np.asarray(model.estimators_, dtype=object)
|
|
18
|
+
state["estimator_labels"] = [
|
|
19
|
+
getattr(estimator, "classes_", None) for estimator in estimators.flat
|
|
20
|
+
]
|
|
21
|
+
for attribute in (
|
|
22
|
+
"estimator_weights_",
|
|
23
|
+
"estimators_features_",
|
|
24
|
+
"_x_mean",
|
|
25
|
+
"_x_std",
|
|
26
|
+
):
|
|
27
|
+
if hasattr(model, attribute):
|
|
28
|
+
state[attribute] = getattr(model, attribute)
|
|
29
|
+
loss = getattr(model, "_loss", getattr(model, "_base_loss", None))
|
|
30
|
+
if loss is not None:
|
|
31
|
+
state["loss"] = {
|
|
32
|
+
"class": type(loss).__name__,
|
|
33
|
+
"link": type(loss.link).__name__,
|
|
34
|
+
"n_classes": loss.n_classes,
|
|
35
|
+
}
|
|
36
|
+
if hasattr(model, "init_"):
|
|
37
|
+
initializer = model.init_
|
|
38
|
+
if isinstance(initializer, BaseEstimator):
|
|
39
|
+
state["initializer"] = {
|
|
40
|
+
"class": type(initializer).__name__,
|
|
41
|
+
"parameters": parameters(initializer),
|
|
42
|
+
"state_sha256": validate_payload(sio.dumps(initializer)),
|
|
43
|
+
}
|
|
44
|
+
if isinstance(initializer, (DummyClassifier, DummyRegressor)):
|
|
45
|
+
state["initializer"]["values"] = {
|
|
46
|
+
name: getattr(initializer, name)
|
|
47
|
+
for name in (
|
|
48
|
+
"classes_",
|
|
49
|
+
"class_prior_",
|
|
50
|
+
"constant_",
|
|
51
|
+
"n_classes_",
|
|
52
|
+
"n_outputs_",
|
|
53
|
+
)
|
|
54
|
+
if hasattr(initializer, name)
|
|
55
|
+
}
|
|
56
|
+
else:
|
|
57
|
+
state["initializer"] = initializer
|
|
58
|
+
state["learning_rate"] = model.learning_rate
|
|
59
|
+
if hasattr(model, "_probA"):
|
|
60
|
+
state["probability_calibration"] = {"A": model._probA, "B": model._probB}
|
|
61
|
+
return state
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Bound skops' recursive audit work before it builds or audits shared nodes."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mlbundle.canonical import canonical_dumps
|
|
6
|
+
from mlbundle.errors import InvalidModelError
|
|
7
|
+
|
|
8
|
+
MAX_NODES = 100_000
|
|
9
|
+
MAX_REFERENCES = 100_000
|
|
10
|
+
MAX_VISITS = 100_000
|
|
11
|
+
MAX_DEPTH = 32
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _without_ids(value: Any) -> Any:
|
|
15
|
+
if isinstance(value, dict):
|
|
16
|
+
return {
|
|
17
|
+
key: _without_ids(child) for key, child in value.items() if key != "__id__"
|
|
18
|
+
}
|
|
19
|
+
if isinstance(value, list):
|
|
20
|
+
return [_without_ids(child) for child in value]
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class References:
|
|
25
|
+
def __init__(self, schema: Any) -> None:
|
|
26
|
+
self.nodes: list[dict[str, Any]] = []
|
|
27
|
+
self.memo: dict[int, dict[str, Any]] = {}
|
|
28
|
+
pending = [schema]
|
|
29
|
+
self.reference_count = 0
|
|
30
|
+
while pending:
|
|
31
|
+
value = pending.pop()
|
|
32
|
+
if isinstance(value, dict):
|
|
33
|
+
if "__loader__" in value:
|
|
34
|
+
self.nodes.append(value)
|
|
35
|
+
if len(self.nodes) > MAX_NODES:
|
|
36
|
+
raise InvalidModelError("Skops node count exceeds the limit.")
|
|
37
|
+
self._record(value)
|
|
38
|
+
pending.extend(reversed(list(value.values())))
|
|
39
|
+
elif isinstance(value, list):
|
|
40
|
+
pending.extend(reversed(value))
|
|
41
|
+
self._work(schema)
|
|
42
|
+
|
|
43
|
+
def _record(self, value: dict[str, Any]) -> None:
|
|
44
|
+
identity = value.get("__id__")
|
|
45
|
+
if identity is not None and (type(identity) is not int or identity <= 0):
|
|
46
|
+
raise InvalidModelError("Invalid skops object identity.")
|
|
47
|
+
if value["__loader__"] == "CachedNode":
|
|
48
|
+
self.reference_count += 1
|
|
49
|
+
if self.reference_count > MAX_REFERENCES:
|
|
50
|
+
raise InvalidModelError("Skops reference count exceeds the limit.")
|
|
51
|
+
elif identity is not None:
|
|
52
|
+
previous = self.memo.get(identity)
|
|
53
|
+
if previous is not None and (
|
|
54
|
+
previous["__loader__"] != value["__loader__"]
|
|
55
|
+
or (
|
|
56
|
+
value["__loader__"] in ("JsonNode", "NdArrayNode")
|
|
57
|
+
and canonical_dumps(_without_ids(previous))
|
|
58
|
+
!= canonical_dumps(_without_ids(value))
|
|
59
|
+
)
|
|
60
|
+
):
|
|
61
|
+
raise InvalidModelError("Conflicting skops object references.")
|
|
62
|
+
self.memo.setdefault(identity, value)
|
|
63
|
+
|
|
64
|
+
def resolve(self, value: Any) -> dict[str, Any]:
|
|
65
|
+
if not isinstance(value, dict):
|
|
66
|
+
raise InvalidModelError("Invalid skops node reference.")
|
|
67
|
+
if value.get("__loader__") == "CachedNode":
|
|
68
|
+
identity = value.get("__id__")
|
|
69
|
+
if type(identity) is not int:
|
|
70
|
+
raise InvalidModelError("Invalid skops object identity.")
|
|
71
|
+
target = self.memo.get(identity)
|
|
72
|
+
if target is None:
|
|
73
|
+
raise InvalidModelError("Unknown skops object reference.")
|
|
74
|
+
if (target.get("__module__"), target.get("__class__")) != (
|
|
75
|
+
value.get("__module__"),
|
|
76
|
+
value.get("__class__"),
|
|
77
|
+
):
|
|
78
|
+
raise InvalidModelError("Conflicting skops reference type.")
|
|
79
|
+
return target
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
def _work(self, schema: Any) -> None:
|
|
83
|
+
memo: dict[int, tuple[int, int]] = {}
|
|
84
|
+
active: set[int] = set()
|
|
85
|
+
|
|
86
|
+
def cost(value):
|
|
87
|
+
if not isinstance(value, (dict, list)):
|
|
88
|
+
return 0, 0
|
|
89
|
+
value = self.resolve(value) if isinstance(value, dict) else value
|
|
90
|
+
identity = id(value)
|
|
91
|
+
if identity in active:
|
|
92
|
+
raise InvalidModelError("Cyclic skops object references.")
|
|
93
|
+
if identity in memo:
|
|
94
|
+
return memo[identity]
|
|
95
|
+
active.add(identity)
|
|
96
|
+
visits, depth = 1, 1
|
|
97
|
+
children = value.values() if isinstance(value, dict) else value
|
|
98
|
+
for child in children:
|
|
99
|
+
child_visits, child_depth = cost(child)
|
|
100
|
+
visits += child_visits
|
|
101
|
+
depth = max(depth, child_depth + 1)
|
|
102
|
+
if visits > MAX_VISITS or depth > MAX_DEPTH:
|
|
103
|
+
raise InvalidModelError(
|
|
104
|
+
"Skops expanded node visits or depth exceed the limit."
|
|
105
|
+
)
|
|
106
|
+
active.remove(identity)
|
|
107
|
+
memo[identity] = visits, depth
|
|
108
|
+
return visits, depth
|
|
109
|
+
|
|
110
|
+
cost(schema)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""One owned skops byte boundary for estimators and transformer components."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
import skops.io as sio
|
|
6
|
+
|
|
7
|
+
from mlbundle.errors import InvalidModelError, MLBundleError, UnsupportedModelError
|
|
8
|
+
|
|
9
|
+
from .payload import validate_payload
|
|
10
|
+
from .trusted import TRUSTED_TYPES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def audit_types(data: bytes) -> None:
|
|
14
|
+
unsupported = tuple(
|
|
15
|
+
sorted(set(sio.get_untrusted_types(data=data)) - set(TRUSTED_TYPES))
|
|
16
|
+
)
|
|
17
|
+
if unsupported:
|
|
18
|
+
raise UnsupportedModelError(unsupported)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SkopsIO:
|
|
22
|
+
"""Reuse checked snapshots inside one operation; never across public calls."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
validate: Callable[[object], None],
|
|
27
|
+
payload: Callable[[bytes], str] = validate_payload,
|
|
28
|
+
) -> None:
|
|
29
|
+
self.validate = validate
|
|
30
|
+
self.payload = payload
|
|
31
|
+
self._states: list[tuple[object, str]] = []
|
|
32
|
+
self._payload: bytes | None = None
|
|
33
|
+
self._digest: str | None = None
|
|
34
|
+
|
|
35
|
+
def state(self, model: object) -> str:
|
|
36
|
+
for native, digest in self._states:
|
|
37
|
+
if native is model:
|
|
38
|
+
return digest
|
|
39
|
+
return self.payload(sio.dumps(model))
|
|
40
|
+
|
|
41
|
+
def serialize(self, model: object) -> bytes:
|
|
42
|
+
try:
|
|
43
|
+
self._states = []
|
|
44
|
+
self._payload, self._digest = None, None
|
|
45
|
+
data = sio.dumps(model)
|
|
46
|
+
try:
|
|
47
|
+
digest = self.payload(data)
|
|
48
|
+
except UnsupportedModelError:
|
|
49
|
+
audit_types(data)
|
|
50
|
+
raise
|
|
51
|
+
audit_types(data)
|
|
52
|
+
self.validate(model)
|
|
53
|
+
self._states = [(model, digest)]
|
|
54
|
+
self._payload, self._digest = data, digest
|
|
55
|
+
return data
|
|
56
|
+
except MLBundleError:
|
|
57
|
+
raise
|
|
58
|
+
except Exception:
|
|
59
|
+
raise UnsupportedModelError(
|
|
60
|
+
reason="Model cannot be serialized safely with skops."
|
|
61
|
+
) from None
|
|
62
|
+
|
|
63
|
+
def deserialize(self, data: bytes) -> object:
|
|
64
|
+
try:
|
|
65
|
+
expected = self._digest if data is self._payload else self.payload(data)
|
|
66
|
+
self._payload, self._digest = None, None
|
|
67
|
+
audit_types(data)
|
|
68
|
+
model = sio.loads(data, trusted=list(TRUSTED_TYPES))
|
|
69
|
+
self.validate(model)
|
|
70
|
+
actual = self.payload(sio.dumps(model))
|
|
71
|
+
if expected != actual:
|
|
72
|
+
raise InvalidModelError(
|
|
73
|
+
"Skops state does not survive native construction."
|
|
74
|
+
)
|
|
75
|
+
self._states = [*self._states[-1:], (model, actual)]
|
|
76
|
+
return model
|
|
77
|
+
except MLBundleError:
|
|
78
|
+
raise
|
|
79
|
+
except Exception:
|
|
80
|
+
raise InvalidModelError("Invalid skops model payload.") from None
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Closed sklearn state keys and a content digest independent of skops object IDs."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from typing import Any
|
|
8
|
+
from zipfile import ZipFile
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from mlbundle.canonical import canonical_dumps
|
|
13
|
+
from mlbundle.errors import InvalidModelError, LimitExceededError, UnsupportedModelError
|
|
14
|
+
from mlbundle.limits import MANIFEST_BYTES
|
|
15
|
+
|
|
16
|
+
FIELDS = json.loads(files(__package__).joinpath("state_fields.json").read_text())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _buffer_digest(data: bytes) -> str:
|
|
20
|
+
if data.startswith(b"PK"):
|
|
21
|
+
with ZipFile(BytesIO(data)) as archive:
|
|
22
|
+
record = {
|
|
23
|
+
name: _buffer_digest(archive.read(name)) for name in archive.namelist()
|
|
24
|
+
}
|
|
25
|
+
return hashlib.sha256(canonical_dumps(record)).hexdigest()
|
|
26
|
+
array = np.load(BytesIO(data), allow_pickle=False)
|
|
27
|
+
if array.dtype.names:
|
|
28
|
+
# Native Tree records have uninitialized padding; only named fields
|
|
29
|
+
# participate in inference and survive the native round trip.
|
|
30
|
+
record = {name: _numeric_digest(array[name]) for name in array.dtype.names}
|
|
31
|
+
return hashlib.sha256(canonical_dumps(record)).hexdigest()
|
|
32
|
+
return _numeric_digest(array)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _numeric_digest(array: np.ndarray) -> str:
|
|
36
|
+
if array.dtype.kind == "f" and np.isnan(array).any():
|
|
37
|
+
array = array.copy()
|
|
38
|
+
array[np.isnan(array)] = np.nan
|
|
39
|
+
digest = hashlib.sha256(canonical_dumps([array.dtype.str, list(array.shape)]))
|
|
40
|
+
digest.update(np.ascontiguousarray(array).tobytes())
|
|
41
|
+
return digest.hexdigest()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _hash(domain: bytes, value: Any) -> str:
|
|
45
|
+
if domain == b"scalar:" or (isinstance(value, dict) and "$float" in value):
|
|
46
|
+
encoded = canonical_dumps(value)
|
|
47
|
+
else:
|
|
48
|
+
# These records contain only checked string keys and fixed-width hex digests.
|
|
49
|
+
size = (
|
|
50
|
+
sum(len(key) + 70 for key in value)
|
|
51
|
+
if isinstance(value, dict)
|
|
52
|
+
else len(value) * 67
|
|
53
|
+
if isinstance(value, list)
|
|
54
|
+
else 66
|
|
55
|
+
) + 1
|
|
56
|
+
if size > MANIFEST_BYTES:
|
|
57
|
+
raise LimitExceededError("JSON exceeds the byte limit.")
|
|
58
|
+
encoded = json.dumps(
|
|
59
|
+
value,
|
|
60
|
+
sort_keys=True,
|
|
61
|
+
ensure_ascii=False,
|
|
62
|
+
allow_nan=False,
|
|
63
|
+
separators=(",", ":"),
|
|
64
|
+
).encode("utf-8")
|
|
65
|
+
if len(encoded) > MANIFEST_BYTES:
|
|
66
|
+
raise LimitExceededError("JSON exceeds the byte limit.")
|
|
67
|
+
return hashlib.sha256(domain + encoded).hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_state(nodes: list[dict[str, Any]], resolve) -> None:
|
|
71
|
+
for node in nodes:
|
|
72
|
+
if node.get("__loader__") != "ObjectNode":
|
|
73
|
+
continue
|
|
74
|
+
name = node.get("__module__", "") + "." + node.get("__class__", "")
|
|
75
|
+
if name not in FIELDS:
|
|
76
|
+
raise UnsupportedModelError((name,))
|
|
77
|
+
state = resolve(node.get("content"))
|
|
78
|
+
if state.get("__loader__") == "JsonNode" and state.get("content") == "null":
|
|
79
|
+
continue
|
|
80
|
+
if state.get("__loader__") != "DictNode":
|
|
81
|
+
raise InvalidModelError("Invalid sklearn object state.")
|
|
82
|
+
attrs = state.get("content")
|
|
83
|
+
if not isinstance(attrs, dict) or set(attrs) - set(FIELDS[name]):
|
|
84
|
+
raise InvalidModelError("Unknown sklearn estimator state.")
|
|
85
|
+
# DictNode otherwise silently truncates attributes through zip().
|
|
86
|
+
keys = resolve(state.get("key_types"))
|
|
87
|
+
if keys.get("__loader__") != "ListNode" or len(keys["content"]) != len(attrs):
|
|
88
|
+
raise InvalidModelError("Invalid sklearn state key types.")
|
|
89
|
+
for raw_key in keys["content"]:
|
|
90
|
+
key = resolve(raw_key)
|
|
91
|
+
if key.get("__loader__") != "TypeNode" or (
|
|
92
|
+
key.get("__module__"),
|
|
93
|
+
key.get("__class__"),
|
|
94
|
+
) != ("builtins", "str"):
|
|
95
|
+
raise InvalidModelError("Sklearn state keys must be strings.")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _memoized_hash(cache, key, domain: bytes, value) -> str:
|
|
99
|
+
if key not in cache:
|
|
100
|
+
cache[key] = _hash(domain, value)
|
|
101
|
+
return cache[key]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _record_key(value):
|
|
105
|
+
if isinstance(value, dict):
|
|
106
|
+
return tuple(sorted(value.items()))
|
|
107
|
+
return tuple(value) if isinstance(value, list) else value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def state_digest(schema: Any, archive, resolve) -> str:
|
|
111
|
+
"""Merkle hash all state and buffers, without expanding shared references."""
|
|
112
|
+
memo: dict[int, str] = {}
|
|
113
|
+
buffers: dict[str, str] = {}
|
|
114
|
+
scalars: dict[tuple[type, Any], str] = {}
|
|
115
|
+
records: dict[tuple, str] = {}
|
|
116
|
+
|
|
117
|
+
def record(domain: bytes, value) -> str:
|
|
118
|
+
key = (domain, _record_key(value))
|
|
119
|
+
return _memoized_hash(records, key, domain, value)
|
|
120
|
+
|
|
121
|
+
def digest(value: Any) -> str:
|
|
122
|
+
if isinstance(value, dict):
|
|
123
|
+
node = resolve(value) if "__loader__" in value else value
|
|
124
|
+
if id(node) in memo:
|
|
125
|
+
return memo[id(node)]
|
|
126
|
+
content = {}
|
|
127
|
+
for key, child in node.items():
|
|
128
|
+
if key == "__id__":
|
|
129
|
+
continue
|
|
130
|
+
if key == "file":
|
|
131
|
+
if child not in buffers:
|
|
132
|
+
buffers[child] = _buffer_digest(archive.read(child))
|
|
133
|
+
content[key] = record(b"file:", buffers[child])
|
|
134
|
+
else:
|
|
135
|
+
content[key] = digest(child)
|
|
136
|
+
result = record(b"dict:", content)
|
|
137
|
+
memo[id(node)] = result
|
|
138
|
+
return result
|
|
139
|
+
if isinstance(value, list):
|
|
140
|
+
return record(b"list:", [digest(child) for child in value])
|
|
141
|
+
key = (type(value), value.hex() if type(value) is float else value)
|
|
142
|
+
return _memoized_hash(scalars, key, b"scalar:", value)
|
|
143
|
+
|
|
144
|
+
return digest(schema)
|