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,113 @@
|
|
|
1
|
+
"""Fitted dimensions that inference uses must agree with displayed counts."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from sklearn.dummy import DummyRegressor
|
|
7
|
+
from sklearn.neural_network import MLPClassifier, MLPRegressor
|
|
8
|
+
from sklearn.svm._base import BaseLibSVM
|
|
9
|
+
|
|
10
|
+
from mlbundle.errors import InvalidModelError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_fitted(model: Any, width: int | None) -> None:
|
|
14
|
+
_statistics(model, width)
|
|
15
|
+
_dummy(model)
|
|
16
|
+
if width is None:
|
|
17
|
+
return
|
|
18
|
+
if hasattr(model, "_fit_X") and hasattr(model, "_y"):
|
|
19
|
+
rows = model._fit_X.shape[0]
|
|
20
|
+
if (
|
|
21
|
+
model._fit_X.shape[1] != width
|
|
22
|
+
or len(model._y) != rows
|
|
23
|
+
or getattr(model, "n_samples_fit_", rows) != rows
|
|
24
|
+
):
|
|
25
|
+
raise InvalidModelError("Neighbor fitted dimensions disagree.")
|
|
26
|
+
if isinstance(model, (MLPClassifier, MLPRegressor)) and hasattr(model, "coefs_"):
|
|
27
|
+
_mlp(model, width)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _mlp(model: Any, width: int) -> None:
|
|
31
|
+
sizes = [width, *np.atleast_1d(model.hidden_layer_sizes).tolist()]
|
|
32
|
+
sizes.append(model.n_outputs_)
|
|
33
|
+
if (
|
|
34
|
+
model.n_layers_ != len(sizes)
|
|
35
|
+
or len(model.coefs_) != len(sizes) - 1
|
|
36
|
+
or len(model.intercepts_) != len(model.coefs_)
|
|
37
|
+
or any(
|
|
38
|
+
coef.shape != (left, right) or intercept.shape != (right,)
|
|
39
|
+
for coef, intercept, left, right in zip(
|
|
40
|
+
model.coefs_, model.intercepts_, sizes, sizes[1:], strict=False
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
):
|
|
44
|
+
raise InvalidModelError("MLP fitted layer dimensions disagree.")
|
|
45
|
+
if isinstance(model, MLPClassifier):
|
|
46
|
+
classes = len(model.classes_)
|
|
47
|
+
if (
|
|
48
|
+
classes < 2
|
|
49
|
+
or model.n_outputs_ != (1 if classes <= 2 else classes)
|
|
50
|
+
or model.out_activation_ != ("logistic" if classes <= 2 else "softmax")
|
|
51
|
+
):
|
|
52
|
+
raise InvalidModelError("MLP class count and output activation disagree.")
|
|
53
|
+
elif model.out_activation_ != ("exp" if model.loss == "poisson" else "identity"):
|
|
54
|
+
raise InvalidModelError("MLP regression loss and output activation disagree.")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _statistics(model: Any, width: int | None) -> None:
|
|
58
|
+
if not hasattr(model, "classes_") or isinstance(model.classes_, list):
|
|
59
|
+
return
|
|
60
|
+
classes = len(model.classes_)
|
|
61
|
+
if width is not None and hasattr(model, "coef_"):
|
|
62
|
+
_coefficient(model, classes, width)
|
|
63
|
+
for name in ("theta_", "var_", "means_", "centroids_"):
|
|
64
|
+
if (
|
|
65
|
+
width is not None
|
|
66
|
+
and hasattr(model, name)
|
|
67
|
+
and np.asarray(getattr(model, name)).shape
|
|
68
|
+
!= (
|
|
69
|
+
classes,
|
|
70
|
+
width,
|
|
71
|
+
)
|
|
72
|
+
):
|
|
73
|
+
raise InvalidModelError(
|
|
74
|
+
"Classifier fitted statistics and dimensions disagree."
|
|
75
|
+
)
|
|
76
|
+
for name in ("class_prior_", "class_log_prior_", "class_count_"):
|
|
77
|
+
if hasattr(model, name) and np.asarray(getattr(model, name)).shape != (
|
|
78
|
+
classes,
|
|
79
|
+
):
|
|
80
|
+
raise InvalidModelError(
|
|
81
|
+
"Classifier fitted priors and class counts disagree."
|
|
82
|
+
)
|
|
83
|
+
if hasattr(model, "feature_log_prob_"):
|
|
84
|
+
values = model.feature_log_prob_
|
|
85
|
+
shapes = (
|
|
86
|
+
[np.asarray(value).shape for value in values]
|
|
87
|
+
if isinstance(values, list)
|
|
88
|
+
else [np.asarray(values).shape]
|
|
89
|
+
)
|
|
90
|
+
if any(len(shape) != 2 or shape[0] != classes for shape in shapes):
|
|
91
|
+
raise InvalidModelError("Classifier likelihoods and class counts disagree.")
|
|
92
|
+
if (isinstance(values, list) and len(values) != width) or (
|
|
93
|
+
not isinstance(values, list) and shapes[0][1] != width
|
|
94
|
+
):
|
|
95
|
+
raise InvalidModelError(
|
|
96
|
+
"Classifier likelihoods and feature counts disagree."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _coefficient(model: Any, classes: int, width: int) -> None:
|
|
101
|
+
if isinstance(model, BaseLibSVM):
|
|
102
|
+
return
|
|
103
|
+
if np.asarray(model.coef_).shape != (1 if classes <= 2 else classes, width):
|
|
104
|
+
raise InvalidModelError("Classifier coefficients and class counts disagree.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _dummy(model: Any) -> None:
|
|
108
|
+
if (
|
|
109
|
+
isinstance(model, DummyRegressor)
|
|
110
|
+
and hasattr(model, "constant_")
|
|
111
|
+
and np.asarray(model.constant_).shape != (1, model.n_outputs_)
|
|
112
|
+
):
|
|
113
|
+
raise InvalidModelError("Dummy regressor constant and output counts disagree.")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""One label/count check for every fitted classifier, including nested models."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from pydantic import ConfigDict, TypeAdapter
|
|
7
|
+
from sklearn.neural_network import MLPClassifier
|
|
8
|
+
|
|
9
|
+
from mlbundle.canonical import canonical_dumps, canonical_value
|
|
10
|
+
from mlbundle.claims import ClassLabels, validate_label_sets
|
|
11
|
+
from mlbundle.errors import InvalidModelError
|
|
12
|
+
|
|
13
|
+
# The pinned pre-commit Pyright predates Annotated aliases as TypeForm arguments.
|
|
14
|
+
_LABELS = TypeAdapter(
|
|
15
|
+
cast(Any, ClassLabels), config=ConfigDict(strict=True, allow_inf_nan=False)
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_classes(model: Any) -> None:
|
|
20
|
+
classes = model.classes_ if isinstance(model.classes_, list) else [model.classes_]
|
|
21
|
+
if not classes or any(np.asarray(labels).ndim != 1 for labels in classes):
|
|
22
|
+
raise InvalidModelError("Invalid classifier label dimensions.")
|
|
23
|
+
counts = np.asarray([len(labels) for labels in classes])
|
|
24
|
+
labels = _LABELS.validate_python(canonical_value(classes))
|
|
25
|
+
validate_label_sets(labels)
|
|
26
|
+
binarizer = getattr(model, "_label_binarizer", None)
|
|
27
|
+
if binarizer is not None and canonical_dumps(model.classes_) != canonical_dumps(
|
|
28
|
+
binarizer.classes_
|
|
29
|
+
):
|
|
30
|
+
raise InvalidModelError("Classifier and label binarizer classes disagree.")
|
|
31
|
+
_outputs(model, len(classes))
|
|
32
|
+
if hasattr(model, "n_classes_"):
|
|
33
|
+
native = np.asarray(model.n_classes_)
|
|
34
|
+
if (
|
|
35
|
+
native.dtype.kind not in "iu"
|
|
36
|
+
or native.ndim != (0 if len(classes) == 1 else 1)
|
|
37
|
+
or not np.array_equal(np.atleast_1d(native), counts)
|
|
38
|
+
):
|
|
39
|
+
raise InvalidModelError(
|
|
40
|
+
"Classifier labels and native class counts disagree."
|
|
41
|
+
)
|
|
42
|
+
if hasattr(model, "tree_") and not np.array_equal(model.tree_.n_classes, counts):
|
|
43
|
+
raise InvalidModelError("Classifier labels and tree class counts disagree.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _outputs(model: Any, count: int) -> None:
|
|
47
|
+
if (
|
|
48
|
+
not isinstance(model, MLPClassifier)
|
|
49
|
+
and hasattr(model, "n_outputs_")
|
|
50
|
+
and model.n_outputs_ != count
|
|
51
|
+
):
|
|
52
|
+
raise InvalidModelError("Classifier labels and native output counts disagree.")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Closed skops node layouts: no unused state, loaders or hidden child objects."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mlbundle.errors import InvalidModelError, UnsupportedModelError
|
|
6
|
+
|
|
7
|
+
from .trusted import FUNCTIONS, OPTIMIZER_TYPES, SCALAR_NAMES
|
|
8
|
+
|
|
9
|
+
FIELDS = {
|
|
10
|
+
"ObjectNode": {"content"},
|
|
11
|
+
"TreeNode": {"content", "__reduce__"},
|
|
12
|
+
"LossNode": {"content", "__reduce__"},
|
|
13
|
+
"DictNode": {"content", "key_types"},
|
|
14
|
+
"ListNode": {"content"},
|
|
15
|
+
"TupleNode": {"content"},
|
|
16
|
+
"SetNode": {"content"},
|
|
17
|
+
"JsonNode": {"content", "is_json"},
|
|
18
|
+
"TypeNode": set(),
|
|
19
|
+
"FunctionNode": set(),
|
|
20
|
+
"CachedNode": set(),
|
|
21
|
+
"NdArrayNode": {"type", "file", "content", "shape"},
|
|
22
|
+
"SparseMatrixNode": {"type", "file"},
|
|
23
|
+
"RandomStateNode": {"content"},
|
|
24
|
+
"RandomGeneratorNode": {"content"},
|
|
25
|
+
"MaskedArrayNode": {"content"},
|
|
26
|
+
"DTypeNode": {"content"},
|
|
27
|
+
"SliceNode": {"content"},
|
|
28
|
+
"ConstructorFromReduceNode": {"content"},
|
|
29
|
+
"BytesNode": {"file"},
|
|
30
|
+
"BytearrayNode": {"file"},
|
|
31
|
+
}
|
|
32
|
+
COMMON = {"__class__", "__module__", "__loader__", "__id__"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_nodes(nodes: list[dict[str, Any]], root: Any) -> None:
|
|
36
|
+
for node in nodes:
|
|
37
|
+
loader = node["__loader__"]
|
|
38
|
+
name = node.get("__module__", "") + "." + node.get("__class__", "")
|
|
39
|
+
_special_loader(node, name, loader)
|
|
40
|
+
if name in OPTIMIZER_TYPES and loader not in ("ObjectNode", "CachedNode"):
|
|
41
|
+
raise InvalidModelError("Invalid skops optimizer loader.")
|
|
42
|
+
if (
|
|
43
|
+
node.get("__module__") == "sklearn.tree._tree"
|
|
44
|
+
and node.get("__class__") == "Tree"
|
|
45
|
+
and loader not in ("TreeNode", "CachedNode")
|
|
46
|
+
):
|
|
47
|
+
raise InvalidModelError("Invalid skops native tree loader.")
|
|
48
|
+
if loader not in FIELDS:
|
|
49
|
+
raise UnsupportedModelError((name,))
|
|
50
|
+
allowed = (
|
|
51
|
+
COMMON
|
|
52
|
+
| FIELDS[loader]
|
|
53
|
+
| ({"protocol", "_skops_version"} if node is root else set())
|
|
54
|
+
)
|
|
55
|
+
if set(node) - allowed:
|
|
56
|
+
raise InvalidModelError("Unknown skops node state.")
|
|
57
|
+
if loader in ("TreeNode", "LossNode") and set(node["__reduce__"]) != {"args"}:
|
|
58
|
+
raise InvalidModelError("Unknown skops constructor state.")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _special_loader(node, name, loader) -> None:
|
|
62
|
+
if name in FUNCTIONS and loader not in ("FunctionNode", "CachedNode"):
|
|
63
|
+
raise InvalidModelError("Invalid skops function loader.")
|
|
64
|
+
if loader == "FunctionNode" and name not in FUNCTIONS:
|
|
65
|
+
raise UnsupportedModelError((name,))
|
|
66
|
+
if name == "numpy.dtype" and loader not in ("DTypeNode", "CachedNode"):
|
|
67
|
+
raise InvalidModelError("Invalid skops dtype loader.")
|
|
68
|
+
if loader == "DTypeNode" and name != "numpy.dtype":
|
|
69
|
+
raise InvalidModelError("Invalid skops dtype identity.")
|
|
70
|
+
if (
|
|
71
|
+
loader == "TypeNode"
|
|
72
|
+
and node.get("__module__") == "numpy"
|
|
73
|
+
and name not in SCALAR_NAMES
|
|
74
|
+
):
|
|
75
|
+
raise UnsupportedModelError((name,))
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Deterministic sklearn parameters; these records never select loading code."""
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from pydantic import JsonValue
|
|
7
|
+
from sklearn.base import BaseEstimator
|
|
8
|
+
|
|
9
|
+
from mlbundle.canonical import canonical_value
|
|
10
|
+
from mlbundle.errors import LimitExceededError, UnsupportedModelError
|
|
11
|
+
from mlbundle.limits import HYPERPARAMETER_BYTES
|
|
12
|
+
|
|
13
|
+
from .trusted import dtype_identifier, reference_identifier
|
|
14
|
+
|
|
15
|
+
MAX_VISITS = 100_000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parameter(
|
|
19
|
+
value: object,
|
|
20
|
+
ancestors: frozenset[int],
|
|
21
|
+
budget: list[int] | None = None,
|
|
22
|
+
*,
|
|
23
|
+
estimator_ref: bool = False,
|
|
24
|
+
) -> JsonValue:
|
|
25
|
+
budget = [0] if budget is None else budget
|
|
26
|
+
budget[0] += 1
|
|
27
|
+
if budget[0] > MAX_VISITS or len(ancestors) > 16:
|
|
28
|
+
raise LimitExceededError("Sklearn parameter work exceeds the limit.")
|
|
29
|
+
if id(value) in ancestors:
|
|
30
|
+
raise UnsupportedModelError(reason="Cyclic hyperparameters are unsupported.")
|
|
31
|
+
path = ancestors | {id(value)}
|
|
32
|
+
if isinstance(value, BaseEstimator):
|
|
33
|
+
if not type(value).__module__.startswith("sklearn."):
|
|
34
|
+
raise UnsupportedModelError(
|
|
35
|
+
(f"{type(value).__module__}.{type(value).__name__}",)
|
|
36
|
+
)
|
|
37
|
+
return _estimator(value, path, budget, estimator_ref)
|
|
38
|
+
if isinstance(value, np.random.RandomState):
|
|
39
|
+
return {"$random_state": canonical_value(value.get_state())}
|
|
40
|
+
if isinstance(value, dict):
|
|
41
|
+
if any(type(key) is not str for key in value):
|
|
42
|
+
raise UnsupportedModelError(reason="Hyperparameter keys must be strings.")
|
|
43
|
+
return {key: _parameter(item, path, budget) for key, item in value.items()}
|
|
44
|
+
if isinstance(value, (list, tuple)):
|
|
45
|
+
return [
|
|
46
|
+
_parameter(item, path, budget, estimator_ref=estimator_ref)
|
|
47
|
+
for item in value
|
|
48
|
+
]
|
|
49
|
+
return _leaf(value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _leaf(value: object) -> JsonValue:
|
|
53
|
+
if isinstance(value, np.dtype):
|
|
54
|
+
return {"$dtype": dtype_identifier(value)}
|
|
55
|
+
if callable(value):
|
|
56
|
+
identifier = reference_identifier(value)
|
|
57
|
+
if identifier is not None:
|
|
58
|
+
return {"$type" if isinstance(value, type) else "$function": identifier}
|
|
59
|
+
raise UnsupportedModelError(
|
|
60
|
+
reason="Callable and type hyperparameters are unsupported."
|
|
61
|
+
)
|
|
62
|
+
return canonical_value(value, max_bytes=HYPERPARAMETER_BYTES, max_depth=16)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _estimator(value, ancestors, budget, reference) -> JsonValue:
|
|
66
|
+
# Named deep keys own step parameters; constructor lists retain routing/order.
|
|
67
|
+
if reference:
|
|
68
|
+
return {"$estimator_ref": type(value).__name__}
|
|
69
|
+
return {
|
|
70
|
+
"$estimator": {
|
|
71
|
+
"class": type(value).__name__,
|
|
72
|
+
"params": parameters(value, ancestors, budget),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parameters(
|
|
78
|
+
model: BaseEstimator,
|
|
79
|
+
ancestors: frozenset[int] = frozenset(),
|
|
80
|
+
budget: list[int] | None = None,
|
|
81
|
+
) -> dict[str, JsonValue]:
|
|
82
|
+
budget = [0] if budget is None else budget
|
|
83
|
+
return cast(
|
|
84
|
+
dict[str, JsonValue],
|
|
85
|
+
{
|
|
86
|
+
key: _parameter(
|
|
87
|
+
value,
|
|
88
|
+
ancestors,
|
|
89
|
+
budget,
|
|
90
|
+
estimator_ref=key in ("steps", "transformers", "transformer_list"),
|
|
91
|
+
)
|
|
92
|
+
for key, value in model.get_params(deep=True).items()
|
|
93
|
+
},
|
|
94
|
+
)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Bound the nested skops ZIP before skops reads schema or numeric buffers."""
|
|
2
|
+
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from pydantic import JsonValue
|
|
8
|
+
|
|
9
|
+
from mlbundle.canonical import canonical_loads
|
|
10
|
+
from mlbundle.errors import InvalidModelError
|
|
11
|
+
from mlbundle.limits import (
|
|
12
|
+
MANIFEST_BYTES,
|
|
13
|
+
MAX_CLASSES,
|
|
14
|
+
MAX_FEATURES,
|
|
15
|
+
MAX_OUTPUTS,
|
|
16
|
+
ArchiveLimits,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .archive import bounded_zip
|
|
20
|
+
from .arrays import MAX_FILES, array_header, sparse_headers
|
|
21
|
+
from .dtypes import validate_dtypes
|
|
22
|
+
from .nodes import validate_nodes
|
|
23
|
+
from .references import References
|
|
24
|
+
from .state import state_digest, validate_state
|
|
25
|
+
|
|
26
|
+
BUFFER_BYTES = ArchiveLimits().max_member_bytes
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_payload(data: bytes) -> str:
|
|
30
|
+
limits = ArchiveLimits()
|
|
31
|
+
with bounded_zip(data, MAX_FILES) as archive:
|
|
32
|
+
entries = archive.infolist()
|
|
33
|
+
names = [entry.filename for entry in entries]
|
|
34
|
+
if (
|
|
35
|
+
len(names) > MAX_FILES
|
|
36
|
+
or len(names) != len(set(names))
|
|
37
|
+
or "schema.json" not in names
|
|
38
|
+
or any("/" in name or "\\" in name or ".." in name for name in names)
|
|
39
|
+
or sum(entry.file_size for entry in entries) > limits.max_member_bytes
|
|
40
|
+
):
|
|
41
|
+
raise InvalidModelError("Invalid nested skops archive.")
|
|
42
|
+
schema_entry = archive.getinfo("schema.json")
|
|
43
|
+
if schema_entry.file_size > MANIFEST_BYTES:
|
|
44
|
+
raise InvalidModelError("Skops schema exceeds the byte limit.")
|
|
45
|
+
for entry in entries:
|
|
46
|
+
if (
|
|
47
|
+
entry.flag_bits & 1
|
|
48
|
+
or entry.compress_type not in (ZIP_STORED, ZIP_DEFLATED)
|
|
49
|
+
or entry.file_size > limits.max_member_bytes
|
|
50
|
+
or entry.file_size > limits.max_compression_ratio * entry.compress_size
|
|
51
|
+
):
|
|
52
|
+
raise InvalidModelError("Unsafe nested skops member bounds.")
|
|
53
|
+
schema = canonical_loads(archive.read(schema_entry))
|
|
54
|
+
references = References(schema)
|
|
55
|
+
validate_nodes(references.nodes, schema)
|
|
56
|
+
validate_state(references.nodes, references.resolve)
|
|
57
|
+
arrays = {name for name in names if name.endswith(".npy")} | _array_references(
|
|
58
|
+
schema
|
|
59
|
+
)
|
|
60
|
+
sparse = {
|
|
61
|
+
node["file"]
|
|
62
|
+
for node in references.nodes
|
|
63
|
+
if node.get("__loader__") == "SparseMatrixNode"
|
|
64
|
+
}
|
|
65
|
+
remaining = limits.max_member_bytes - sum(
|
|
66
|
+
archive.getinfo(name).file_size for name in arrays if name in names
|
|
67
|
+
)
|
|
68
|
+
sparse_sizes = {}
|
|
69
|
+
for name in sparse:
|
|
70
|
+
sparse_sizes[name] = sparse_headers(archive.read(name), remaining)
|
|
71
|
+
remaining -= sparse_sizes[name]
|
|
72
|
+
_buffer_budget(references, archive, sparse_sizes)
|
|
73
|
+
headers = {}
|
|
74
|
+
for name in arrays:
|
|
75
|
+
if name not in names:
|
|
76
|
+
raise InvalidModelError("Missing skops numeric array member.")
|
|
77
|
+
headers[name] = _array_header(
|
|
78
|
+
archive, name, archive.getinfo(name).file_size
|
|
79
|
+
)
|
|
80
|
+
validate_dtypes(references.nodes, references.resolve, archive)
|
|
81
|
+
_tree_constructors(references, archive, headers)
|
|
82
|
+
files = _array_references(schema) | sparse | {"schema.json"}
|
|
83
|
+
if set(names) != files:
|
|
84
|
+
raise InvalidModelError("Unreferenced skops member.")
|
|
85
|
+
return state_digest(schema, archive, references.resolve)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _array_references(schema: JsonValue) -> set[str]:
|
|
89
|
+
arrays: set[str] = set()
|
|
90
|
+
pending = [schema]
|
|
91
|
+
while pending:
|
|
92
|
+
node = pending.pop()
|
|
93
|
+
if isinstance(node, dict):
|
|
94
|
+
if node.get("__loader__") == "NdArrayNode" and "file" in node:
|
|
95
|
+
name = node["file"]
|
|
96
|
+
if not isinstance(name, str):
|
|
97
|
+
raise InvalidModelError("Invalid skops numeric array reference.")
|
|
98
|
+
arrays.add(name)
|
|
99
|
+
pending.extend(node.values())
|
|
100
|
+
elif isinstance(node, list):
|
|
101
|
+
pending.extend(node)
|
|
102
|
+
return arrays
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _buffer_budget(
|
|
106
|
+
references: References, archive: ZipFile, sparse: dict[str, int]
|
|
107
|
+
) -> None:
|
|
108
|
+
seen: set[int] = set()
|
|
109
|
+
total = 0
|
|
110
|
+
for node in references.nodes:
|
|
111
|
+
if (
|
|
112
|
+
node.get("__loader__") not in ("NdArrayNode", "SparseMatrixNode")
|
|
113
|
+
or "file" not in node
|
|
114
|
+
):
|
|
115
|
+
continue
|
|
116
|
+
identity = node.get("__id__", id(node))
|
|
117
|
+
if identity in seen:
|
|
118
|
+
continue
|
|
119
|
+
seen.add(identity)
|
|
120
|
+
total += sparse.get(node["file"], archive.getinfo(node["file"]).file_size)
|
|
121
|
+
if total > BUFFER_BYTES:
|
|
122
|
+
raise InvalidModelError("Skops materialized buffer bytes exceed the limit.")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_array_header = array_header
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _tree_constructors(references: References, archive: ZipFile, headers) -> None:
|
|
129
|
+
seen: set[int] = set()
|
|
130
|
+
for node in references.nodes:
|
|
131
|
+
identity = node.get("__id__", id(node))
|
|
132
|
+
if node.get("__loader__") == "TreeNode" and identity not in seen:
|
|
133
|
+
seen.add(identity)
|
|
134
|
+
reduced = node.get("__reduce__")
|
|
135
|
+
if not isinstance(reduced, dict):
|
|
136
|
+
raise InvalidModelError("Invalid skops tree constructor.")
|
|
137
|
+
args = references.resolve(reduced.get("args"))
|
|
138
|
+
state = references.resolve(node.get("content"))
|
|
139
|
+
_tree_constructor(
|
|
140
|
+
{**node, "__reduce__": {"args": args}, "content": state},
|
|
141
|
+
references.memo,
|
|
142
|
+
archive,
|
|
143
|
+
headers,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _resolve(
|
|
148
|
+
value: JsonValue, memo: dict[int, dict[str, JsonValue]]
|
|
149
|
+
) -> dict[str, JsonValue]:
|
|
150
|
+
if not isinstance(value, dict):
|
|
151
|
+
raise InvalidModelError("Invalid skops tree constructor schema.")
|
|
152
|
+
identity = value.get("__id__")
|
|
153
|
+
if type(identity) is int and identity in memo:
|
|
154
|
+
return memo[identity]
|
|
155
|
+
if value.get("__loader__") == "CachedNode":
|
|
156
|
+
raise InvalidModelError("Invalid skops tree constructor reference.")
|
|
157
|
+
return value
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _integer(value: JsonValue, memo: dict[int, dict[str, JsonValue]]) -> int:
|
|
161
|
+
node = _resolve(value, memo)
|
|
162
|
+
content = node.get("content")
|
|
163
|
+
if (
|
|
164
|
+
node.get("__loader__") != "JsonNode"
|
|
165
|
+
or node.get("is_json") is not True
|
|
166
|
+
or not isinstance(content, str)
|
|
167
|
+
):
|
|
168
|
+
raise InvalidModelError("Invalid skops tree constructor integer.")
|
|
169
|
+
result = canonical_loads(content.encode())
|
|
170
|
+
if type(result) is not int:
|
|
171
|
+
raise InvalidModelError("Invalid skops tree constructor integer.")
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _numeric_header(
|
|
176
|
+
value: JsonValue, memo: dict[int, dict[str, JsonValue]], headers
|
|
177
|
+
) -> tuple[str, tuple[int, ...], np.dtype]:
|
|
178
|
+
node = _resolve(value, memo)
|
|
179
|
+
name = node.get("file")
|
|
180
|
+
if (
|
|
181
|
+
node.get("__loader__") != "NdArrayNode"
|
|
182
|
+
or node.get("type") != "numpy"
|
|
183
|
+
or not isinstance(name, str)
|
|
184
|
+
):
|
|
185
|
+
raise InvalidModelError("Invalid skops tree numeric storage.")
|
|
186
|
+
shape, dtype = headers[name]
|
|
187
|
+
return name, shape, dtype
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _tree_constructor(
|
|
191
|
+
node: dict[str, JsonValue],
|
|
192
|
+
memo: dict[int, dict[str, JsonValue]],
|
|
193
|
+
archive: ZipFile,
|
|
194
|
+
headers,
|
|
195
|
+
) -> None:
|
|
196
|
+
reduced = node.get("__reduce__")
|
|
197
|
+
arguments = reduced.get("args") if isinstance(reduced, dict) else None
|
|
198
|
+
if not isinstance(arguments, dict):
|
|
199
|
+
raise InvalidModelError("Invalid skops tree constructor arguments.")
|
|
200
|
+
args = arguments.get("content")
|
|
201
|
+
if (
|
|
202
|
+
arguments.get("__loader__") != "TupleNode"
|
|
203
|
+
or not isinstance(args, list)
|
|
204
|
+
or len(args) != 3
|
|
205
|
+
):
|
|
206
|
+
raise InvalidModelError("Invalid skops tree constructor arguments.")
|
|
207
|
+
width, outputs = _integer(args[0], memo), _integer(args[2], memo)
|
|
208
|
+
if not 1 <= width <= MAX_FEATURES or not 1 <= outputs <= MAX_OUTPUTS:
|
|
209
|
+
raise InvalidModelError("Unsafe skops tree constructor dimensions.")
|
|
210
|
+
name, shape, dtype = _numeric_header(args[1], memo, headers)
|
|
211
|
+
if shape != (outputs,) or dtype != np.dtype(np.intp):
|
|
212
|
+
raise InvalidModelError("Invalid skops tree class-count dimensions.")
|
|
213
|
+
classes = np.load(BytesIO(archive.read(name)), allow_pickle=False)
|
|
214
|
+
if np.any(classes < 1) or np.any(classes > MAX_CLASSES):
|
|
215
|
+
raise InvalidModelError("Unsafe skops tree class counts.")
|
|
216
|
+
state = node.get("content")
|
|
217
|
+
if not isinstance(state, dict):
|
|
218
|
+
raise InvalidModelError("Invalid skops tree state.")
|
|
219
|
+
attributes = state.get("content")
|
|
220
|
+
if not isinstance(attributes, dict):
|
|
221
|
+
raise InvalidModelError("Invalid skops tree state.")
|
|
222
|
+
if set(attributes) != {"node_count", "max_depth", "nodes", "values"}:
|
|
223
|
+
raise InvalidModelError("Unknown skops tree state.")
|
|
224
|
+
count = _integer(attributes.get("node_count"), memo)
|
|
225
|
+
depth = _integer(attributes.get("max_depth"), memo)
|
|
226
|
+
_, nodes_shape, _ = _numeric_header(attributes.get("nodes"), memo, headers)
|
|
227
|
+
_, values_shape, _ = _numeric_header(attributes.get("values"), memo, headers)
|
|
228
|
+
if (
|
|
229
|
+
count < 1
|
|
230
|
+
or not 0 <= depth < count
|
|
231
|
+
or nodes_shape != (count,)
|
|
232
|
+
or values_shape != (count, outputs, int(max(classes)))
|
|
233
|
+
):
|
|
234
|
+
raise InvalidModelError("Invalid skops tree storage dimensions.")
|