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,150 @@
|
|
|
1
|
+
"""Check declared numeric and sparse storage against real bytes before allocation."""
|
|
2
|
+
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
from math import prod
|
|
5
|
+
from zipfile import ZipFile
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from mlbundle.errors import InvalidModelError
|
|
10
|
+
from mlbundle.limits import ArchiveLimits
|
|
11
|
+
|
|
12
|
+
from .archive import bounded_zip
|
|
13
|
+
|
|
14
|
+
MAX_RANK = 32
|
|
15
|
+
MAX_FILES = 100_000
|
|
16
|
+
BYTE_CAP = ArchiveLimits().max_member_bytes
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def array_header(
|
|
20
|
+
archive: ZipFile, name: str, size: int
|
|
21
|
+
) -> tuple[tuple[int, ...], np.dtype]:
|
|
22
|
+
with archive.open(name) as stream:
|
|
23
|
+
version = np.lib.format.read_magic(stream)
|
|
24
|
+
if version == (1, 0):
|
|
25
|
+
shape, _, dtype = np.lib.format.read_array_header_1_0(stream)
|
|
26
|
+
elif version == (2, 0):
|
|
27
|
+
shape, _, dtype = np.lib.format.read_array_header_2_0(stream)
|
|
28
|
+
else:
|
|
29
|
+
raise InvalidModelError("Unsupported skops numeric array header.")
|
|
30
|
+
if (
|
|
31
|
+
dtype.hasobject
|
|
32
|
+
or dtype.itemsize == 0
|
|
33
|
+
or len(shape) > MAX_RANK
|
|
34
|
+
or any(not 0 <= dimension <= BYTE_CAP for dimension in shape)
|
|
35
|
+
or prod(shape) * dtype.itemsize != size - stream.tell()
|
|
36
|
+
):
|
|
37
|
+
raise InvalidModelError("Invalid skops numeric array dimensions.")
|
|
38
|
+
expected = size - stream.tell()
|
|
39
|
+
actual = 0
|
|
40
|
+
while chunk := stream.read(min(64 * 1024, expected - actual + 1)):
|
|
41
|
+
actual += len(chunk)
|
|
42
|
+
if actual > expected:
|
|
43
|
+
raise InvalidModelError("Invalid skops numeric array stored size.")
|
|
44
|
+
if actual != expected:
|
|
45
|
+
raise InvalidModelError("Invalid skops numeric array stored size.")
|
|
46
|
+
return shape, dtype
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sparse_headers(data: bytes, max_bytes: int = BYTE_CAP) -> int:
|
|
50
|
+
with bounded_zip(data, 9) as archive:
|
|
51
|
+
entries = archive.infolist()
|
|
52
|
+
names = {entry.filename for entry in entries}
|
|
53
|
+
if (
|
|
54
|
+
len(names) != len(entries)
|
|
55
|
+
or not {"format.npy", "shape.npy", "data.npy"} <= names
|
|
56
|
+
or names
|
|
57
|
+
- {
|
|
58
|
+
"format.npy",
|
|
59
|
+
"shape.npy",
|
|
60
|
+
"data.npy",
|
|
61
|
+
"indices.npy",
|
|
62
|
+
"indptr.npy",
|
|
63
|
+
"row.npy",
|
|
64
|
+
"col.npy",
|
|
65
|
+
"offsets.npy",
|
|
66
|
+
"_is_array.npy",
|
|
67
|
+
}
|
|
68
|
+
or sum(entry.file_size for entry in entries) > max_bytes
|
|
69
|
+
):
|
|
70
|
+
raise InvalidModelError("Invalid skops sparse archive.")
|
|
71
|
+
headers = {}
|
|
72
|
+
for entry in entries:
|
|
73
|
+
if (
|
|
74
|
+
entry.flag_bits & 1
|
|
75
|
+
or entry.compress_type not in (0, 8)
|
|
76
|
+
or entry.file_size > BYTE_CAP
|
|
77
|
+
or entry.file_size > 200 * entry.compress_size
|
|
78
|
+
):
|
|
79
|
+
raise InvalidModelError("Unsafe skops sparse member bounds.")
|
|
80
|
+
headers[entry.filename] = array_header(
|
|
81
|
+
archive, entry.filename, entry.file_size
|
|
82
|
+
)
|
|
83
|
+
if headers["shape.npy"][0] != (2,) or headers["format.npy"][0] != ():
|
|
84
|
+
raise InvalidModelError("Invalid skops sparse shape or format.")
|
|
85
|
+
shape = np.load(BytesIO(archive.read("shape.npy")), allow_pickle=False)
|
|
86
|
+
if (
|
|
87
|
+
shape.dtype.kind not in "iu"
|
|
88
|
+
or any(not 0 <= dim <= BYTE_CAP for dim in shape)
|
|
89
|
+
or prod(int(dim) for dim in shape) * headers["data.npy"][1].itemsize
|
|
90
|
+
> BYTE_CAP
|
|
91
|
+
):
|
|
92
|
+
raise InvalidModelError("Unsafe skops sparse declared shape.")
|
|
93
|
+
# All buffers have already passed exact stored-byte checks. Loading them
|
|
94
|
+
# here cannot allocate in proportion to an unsupported header declaration.
|
|
95
|
+
values = {
|
|
96
|
+
name[:-4]: np.load(BytesIO(archive.read(name)), allow_pickle=False)
|
|
97
|
+
for name in names
|
|
98
|
+
}
|
|
99
|
+
_sparse_indices(values, shape)
|
|
100
|
+
return sum(entry.file_size for entry in entries)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _sparse_indices(values, shape) -> None:
|
|
104
|
+
kind = values["format"].item()
|
|
105
|
+
rows, columns = (int(dim) for dim in shape)
|
|
106
|
+
if kind in (b"csr", b"csc", b"bsr"):
|
|
107
|
+
indices, pointers = values["indices"], values["indptr"]
|
|
108
|
+
blocks = len(values["data"])
|
|
109
|
+
major, minor = (columns, rows) if kind == b"csc" else (rows, columns)
|
|
110
|
+
if kind == b"bsr":
|
|
111
|
+
block_shape = values["data"].shape
|
|
112
|
+
if len(block_shape) != 3 or min(block_shape[1:]) < 1:
|
|
113
|
+
raise InvalidModelError("Invalid skops sparse block shape.")
|
|
114
|
+
major //= block_shape[1]
|
|
115
|
+
minor //= block_shape[2]
|
|
116
|
+
if (
|
|
117
|
+
pointers.dtype.kind not in "iu"
|
|
118
|
+
or indices.dtype.kind not in "iu"
|
|
119
|
+
or pointers.shape != (major + 1,)
|
|
120
|
+
or indices.shape != (blocks,)
|
|
121
|
+
or pointers[0] != 0
|
|
122
|
+
or pointers[-1] != blocks
|
|
123
|
+
or np.any(pointers[1:] < pointers[:-1])
|
|
124
|
+
or np.any(pointers < 0)
|
|
125
|
+
or np.any(indices < 0)
|
|
126
|
+
or np.any(indices >= minor)
|
|
127
|
+
):
|
|
128
|
+
raise InvalidModelError("Invalid skops sparse index storage.")
|
|
129
|
+
elif kind == b"coo":
|
|
130
|
+
for name, width in (("row", rows), ("col", columns)):
|
|
131
|
+
index = values[name]
|
|
132
|
+
if (
|
|
133
|
+
index.dtype.kind not in "iu"
|
|
134
|
+
or index.shape != values["data"].shape
|
|
135
|
+
or np.any(index < 0)
|
|
136
|
+
or np.any(index >= width)
|
|
137
|
+
):
|
|
138
|
+
raise InvalidModelError("Invalid skops sparse coordinate storage.")
|
|
139
|
+
elif kind == b"dia":
|
|
140
|
+
offsets = values["offsets"]
|
|
141
|
+
if (
|
|
142
|
+
offsets.dtype.kind not in "iu"
|
|
143
|
+
or offsets.ndim != 1
|
|
144
|
+
or values["data"].ndim != 2
|
|
145
|
+
or len(offsets) != len(values["data"])
|
|
146
|
+
or len(set(offsets.tolist())) != len(offsets)
|
|
147
|
+
):
|
|
148
|
+
raise InvalidModelError("Invalid skops sparse diagonal storage.")
|
|
149
|
+
else:
|
|
150
|
+
raise InvalidModelError("Unsupported skops sparse format.")
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Cross-check ColumnTransformer selectors, fitted clones and output slices."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from sklearn.preprocessing import FunctionTransformer
|
|
8
|
+
|
|
9
|
+
from mlbundle.canonical import canonical_dumps
|
|
10
|
+
from mlbundle.errors import InvalidModelError
|
|
11
|
+
|
|
12
|
+
from ._width_checks import width
|
|
13
|
+
from .parameters import parameters
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def require(condition: bool, message: str) -> None:
|
|
17
|
+
if not condition:
|
|
18
|
+
raise InvalidModelError(message)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def selector(value: Any, count: int, names: list[str] | None) -> list[int]:
|
|
22
|
+
message = "Invalid ColumnTransformer input mapping."
|
|
23
|
+
|
|
24
|
+
def position(item: Any) -> int:
|
|
25
|
+
if type(item) in (str, np.str_):
|
|
26
|
+
require(names is not None and item in names, message)
|
|
27
|
+
return (names or []).index(item)
|
|
28
|
+
require(
|
|
29
|
+
isinstance(item, (int, np.integer))
|
|
30
|
+
and not isinstance(item, (bool, np.bool_)),
|
|
31
|
+
message,
|
|
32
|
+
)
|
|
33
|
+
result = int(item)
|
|
34
|
+
result = result + count if result < 0 else result
|
|
35
|
+
require(0 <= result < count, message)
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
if isinstance(value, slice):
|
|
39
|
+
if type(value.start) is str or type(value.stop) is str:
|
|
40
|
+
require(value.step is None and names is not None, message)
|
|
41
|
+
start = 0 if value.start is None else position(value.start)
|
|
42
|
+
stop = count if value.stop is None else position(value.stop) + 1
|
|
43
|
+
return list(range(start, stop))
|
|
44
|
+
require(
|
|
45
|
+
all(
|
|
46
|
+
item is None or type(item) is int
|
|
47
|
+
for item in (value.start, value.stop, value.step)
|
|
48
|
+
)
|
|
49
|
+
and value.step != 0,
|
|
50
|
+
message,
|
|
51
|
+
)
|
|
52
|
+
return list(range(count))[value]
|
|
53
|
+
if type(value) in (str, np.str_) or isinstance(value, (int, np.integer)):
|
|
54
|
+
return [position(value)]
|
|
55
|
+
require(isinstance(value, (list, tuple, np.ndarray)), message)
|
|
56
|
+
array = np.asarray(value)
|
|
57
|
+
require(array.ndim == 1 and len(array) <= 100_000, message)
|
|
58
|
+
if array.dtype.kind == "b":
|
|
59
|
+
require(len(array) == count, message)
|
|
60
|
+
return np.flatnonzero(array).tolist()
|
|
61
|
+
return [position(item) for item in value]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def same_transformer(declared: Any, fitted: Any) -> bool:
|
|
65
|
+
if type(declared) is str:
|
|
66
|
+
if declared == "drop":
|
|
67
|
+
return type(fitted) is str and fitted == "drop"
|
|
68
|
+
return (
|
|
69
|
+
declared == "passthrough"
|
|
70
|
+
and type(fitted) is FunctionTransformer
|
|
71
|
+
and fitted.func is None
|
|
72
|
+
and fitted.inverse_func is None
|
|
73
|
+
and fitted.feature_names_out == "one-to-one"
|
|
74
|
+
and fitted.validate is False
|
|
75
|
+
)
|
|
76
|
+
return type(declared) is type(fitted) and canonical_dumps(
|
|
77
|
+
parameters(declared)
|
|
78
|
+
) == canonical_dumps(parameters(fitted))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def column_width(
|
|
82
|
+
model: Any, count: int, depth: int, output_width: Callable[..., int]
|
|
83
|
+
) -> int:
|
|
84
|
+
|
|
85
|
+
mapping_error = "Invalid ColumnTransformer input mapping."
|
|
86
|
+
clone_error = "ColumnTransformer declared and fitted transformers disagree."
|
|
87
|
+
output_error = "Invalid ColumnTransformer output mapping."
|
|
88
|
+
declared = model.transformers
|
|
89
|
+
fitted = model.transformers_
|
|
90
|
+
require(
|
|
91
|
+
isinstance(declared, (list, tuple))
|
|
92
|
+
and isinstance(fitted, list)
|
|
93
|
+
and len(declared) <= 128
|
|
94
|
+
and len(model._columns) == len(declared),
|
|
95
|
+
mapping_error,
|
|
96
|
+
)
|
|
97
|
+
require(
|
|
98
|
+
all(
|
|
99
|
+
isinstance(item, (tuple, list)) and len(item) == 3
|
|
100
|
+
for item in [*declared, *fitted]
|
|
101
|
+
),
|
|
102
|
+
mapping_error,
|
|
103
|
+
)
|
|
104
|
+
declared_names = [item[0] for item in declared]
|
|
105
|
+
require(
|
|
106
|
+
all(
|
|
107
|
+
type(name) is str
|
|
108
|
+
and 1 <= len(name) <= 256
|
|
109
|
+
and name != "remainder"
|
|
110
|
+
and "__" not in name
|
|
111
|
+
for name in declared_names
|
|
112
|
+
)
|
|
113
|
+
and len(set(declared_names)) == len(declared_names),
|
|
114
|
+
mapping_error,
|
|
115
|
+
)
|
|
116
|
+
names = getattr(model, "feature_names_in_", None)
|
|
117
|
+
names = None if names is None else list(names)
|
|
118
|
+
mapping = model._transformer_to_input_indices
|
|
119
|
+
require(
|
|
120
|
+
isinstance(mapping, dict) and set(mapping) == {*declared_names, "remainder"},
|
|
121
|
+
mapping_error,
|
|
122
|
+
)
|
|
123
|
+
for selected in mapping.values():
|
|
124
|
+
require(
|
|
125
|
+
isinstance(selected, list)
|
|
126
|
+
and all(type(i) is int and 0 <= i < count for i in selected),
|
|
127
|
+
mapping_error,
|
|
128
|
+
)
|
|
129
|
+
used = set()
|
|
130
|
+
for (name, _, columns), cached in zip(declared, model._columns, strict=True):
|
|
131
|
+
resolved = selector(columns, count, names)
|
|
132
|
+
require(
|
|
133
|
+
resolved == selector(cached, count, names) and resolved == mapping[name],
|
|
134
|
+
mapping_error,
|
|
135
|
+
)
|
|
136
|
+
used.update(resolved)
|
|
137
|
+
remaining = sorted(set(range(count)) - used)
|
|
138
|
+
require(mapping["remainder"] == remaining, mapping_error)
|
|
139
|
+
remainder = model._remainder
|
|
140
|
+
require(
|
|
141
|
+
isinstance(remainder, tuple)
|
|
142
|
+
and len(remainder) == 3
|
|
143
|
+
and remainder[0] == "remainder"
|
|
144
|
+
and selector(remainder[2], count, names) == remaining,
|
|
145
|
+
mapping_error,
|
|
146
|
+
)
|
|
147
|
+
left, right = model.remainder, remainder[1]
|
|
148
|
+
require(
|
|
149
|
+
type(left) is type(right)
|
|
150
|
+
and (
|
|
151
|
+
canonical_dumps(left) == canonical_dumps(right)
|
|
152
|
+
if type(left) is str
|
|
153
|
+
else canonical_dumps(parameters(left)) == canonical_dumps(parameters(right))
|
|
154
|
+
),
|
|
155
|
+
clone_error,
|
|
156
|
+
)
|
|
157
|
+
expected = [*declared, *((remainder,) if remaining else ())]
|
|
158
|
+
require(len(fitted) == len(expected), clone_error)
|
|
159
|
+
require(type(model.sparse_output_) is bool, output_error)
|
|
160
|
+
outputs = model.output_indices_
|
|
161
|
+
require(
|
|
162
|
+
isinstance(outputs, dict) and set(outputs) == {*declared_names, "remainder"},
|
|
163
|
+
output_error,
|
|
164
|
+
)
|
|
165
|
+
total = 0
|
|
166
|
+
for (name, original, columns), (actual_name, transformer, actual_columns) in zip(
|
|
167
|
+
expected, fitted, strict=True
|
|
168
|
+
):
|
|
169
|
+
require(
|
|
170
|
+
name == actual_name
|
|
171
|
+
and selector(columns, count, names)
|
|
172
|
+
== selector(actual_columns, count, names),
|
|
173
|
+
mapping_error,
|
|
174
|
+
)
|
|
175
|
+
selected = mapping[name]
|
|
176
|
+
if not selected:
|
|
177
|
+
require(
|
|
178
|
+
type(original) is type(transformer)
|
|
179
|
+
or same_transformer(original, transformer),
|
|
180
|
+
clone_error,
|
|
181
|
+
)
|
|
182
|
+
require(outputs[name] == slice(0, 0), output_error)
|
|
183
|
+
continue
|
|
184
|
+
require(same_transformer(original, transformer), clone_error)
|
|
185
|
+
if type(transformer) is str and transformer == "drop":
|
|
186
|
+
require(outputs[name] == slice(0, 0), output_error)
|
|
187
|
+
continue
|
|
188
|
+
require(
|
|
189
|
+
not isinstance(columns, (str, np.str_, int, np.integer))
|
|
190
|
+
and not isinstance(actual_columns, (str, np.str_, int, np.integer)),
|
|
191
|
+
mapping_error,
|
|
192
|
+
)
|
|
193
|
+
result = output_width(transformer, len(selected), depth=depth + 1)
|
|
194
|
+
require(outputs[name] == slice(total, total + result), output_error)
|
|
195
|
+
total = width(total + result)
|
|
196
|
+
if not remaining:
|
|
197
|
+
require(outputs["remainder"] == slice(0, 0), output_error)
|
|
198
|
+
return width(total)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Check alternate inference state against the estimator's displayed parameters."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.sparse import issparse
|
|
7
|
+
from sklearn.ensemble import GradientBoostingClassifier, GradientBoostingRegressor
|
|
8
|
+
from sklearn.ensemble._forest import ForestClassifier, ForestRegressor
|
|
9
|
+
from sklearn.linear_model._glm.glm import _GeneralizedLinearRegressor
|
|
10
|
+
from sklearn.svm._base import BaseLibSVM
|
|
11
|
+
|
|
12
|
+
from mlbundle.errors import InvalidModelError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_consistency(model: Any) -> None:
|
|
16
|
+
if isinstance(
|
|
17
|
+
model, (GradientBoostingClassifier, GradientBoostingRegressor)
|
|
18
|
+
) and hasattr(model, "estimators_"):
|
|
19
|
+
columns = (
|
|
20
|
+
model.n_classes_
|
|
21
|
+
if isinstance(model, GradientBoostingClassifier) and model.n_classes_ > 2
|
|
22
|
+
else 1
|
|
23
|
+
)
|
|
24
|
+
trees = model.estimators_
|
|
25
|
+
if (
|
|
26
|
+
not isinstance(trees, np.ndarray)
|
|
27
|
+
or trees.ndim != 2
|
|
28
|
+
or trees.shape != (model.n_estimators_, columns)
|
|
29
|
+
or model.n_trees_per_iteration_ != columns
|
|
30
|
+
):
|
|
31
|
+
raise InvalidModelError("Invalid gradient boosting stage arrangement.")
|
|
32
|
+
expected = model._get_loss(None)
|
|
33
|
+
_loss(model._loss, expected)
|
|
34
|
+
_initializer(model)
|
|
35
|
+
if isinstance(model, (ForestClassifier, ForestRegressor)) and hasattr(
|
|
36
|
+
model, "estimators_"
|
|
37
|
+
):
|
|
38
|
+
for estimator in model.estimators_:
|
|
39
|
+
if estimator.n_outputs_ != model.n_outputs_ or (
|
|
40
|
+
isinstance(model, ForestClassifier)
|
|
41
|
+
and not np.array_equal(
|
|
42
|
+
np.atleast_1d(estimator.n_classes_), np.atleast_1d(model.n_classes_)
|
|
43
|
+
)
|
|
44
|
+
):
|
|
45
|
+
raise InvalidModelError(
|
|
46
|
+
"Forest and constituent output counts disagree."
|
|
47
|
+
)
|
|
48
|
+
if isinstance(model, _GeneralizedLinearRegressor) and hasattr(model, "_base_loss"):
|
|
49
|
+
_loss(model._base_loss, model._get_loss())
|
|
50
|
+
if isinstance(model, BaseLibSVM) and hasattr(model, "_dual_coef_"):
|
|
51
|
+
_svm(model)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _loss(actual: Any, expected: Any) -> None:
|
|
55
|
+
if (
|
|
56
|
+
type(actual) is not type(expected)
|
|
57
|
+
or type(actual.link) is not type(expected.link)
|
|
58
|
+
or getattr(actual, "n_classes", None) != getattr(expected, "n_classes", None)
|
|
59
|
+
):
|
|
60
|
+
raise InvalidModelError("Fitted loss and declared parameters disagree.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _initializer(model: Any) -> None:
|
|
64
|
+
initializer = model.init_
|
|
65
|
+
if (
|
|
66
|
+
hasattr(initializer, "n_features_in_")
|
|
67
|
+
and initializer.n_features_in_ != model.n_features_in_
|
|
68
|
+
):
|
|
69
|
+
raise InvalidModelError("Gradient boosting initializer input width disagrees.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _same(left: Any, right: Any, sign: int) -> bool:
|
|
73
|
+
if issparse(left) or issparse(right):
|
|
74
|
+
return (
|
|
75
|
+
issparse(left)
|
|
76
|
+
and issparse(right)
|
|
77
|
+
and left.format == right.format
|
|
78
|
+
and left.shape == right.shape
|
|
79
|
+
and np.array_equal(left.indices, right.indices)
|
|
80
|
+
and np.array_equal(left.indptr, right.indptr)
|
|
81
|
+
and np.array_equal(left.data, sign * right.data)
|
|
82
|
+
)
|
|
83
|
+
return np.array_equal(left, sign * right)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _svm(model: Any) -> None:
|
|
87
|
+
if (
|
|
88
|
+
type(model._sparse) is not bool
|
|
89
|
+
or issparse(model.support_vectors_) != model._sparse
|
|
90
|
+
or issparse(model._dual_coef_) != model._sparse
|
|
91
|
+
):
|
|
92
|
+
raise InvalidModelError("SVM sparse flag and fitted storage disagree.")
|
|
93
|
+
classes = len(model.classes_) if hasattr(model, "classes_") else None
|
|
94
|
+
count = len(model.support_)
|
|
95
|
+
supports = np.asarray(model._n_support)
|
|
96
|
+
rows = 1 if classes is None else classes - 1
|
|
97
|
+
intercepts = 1 if classes is None else classes * (classes - 1) // 2
|
|
98
|
+
if (
|
|
99
|
+
supports.dtype.kind not in "iu"
|
|
100
|
+
or supports.shape != ((2,) if classes is None else (classes,))
|
|
101
|
+
or np.any(supports < 0)
|
|
102
|
+
or np.any(supports > count)
|
|
103
|
+
or int(supports.sum()) != count * (2 if classes is None else 1)
|
|
104
|
+
or model._dual_coef_.shape != (rows, count)
|
|
105
|
+
or model._intercept_.shape != (intercepts,)
|
|
106
|
+
or model.support_.dtype.kind not in "iu"
|
|
107
|
+
or np.any(model.support_ < 0)
|
|
108
|
+
or np.any(model.support_ >= model.shape_fit_[0])
|
|
109
|
+
or model.shape_fit_[1] != model.n_features_in_
|
|
110
|
+
):
|
|
111
|
+
raise InvalidModelError("Invalid SVM fitted dimensions or support indices.")
|
|
112
|
+
if model.kernel != "precomputed" and model.support_vectors_.shape != (
|
|
113
|
+
count,
|
|
114
|
+
model.n_features_in_,
|
|
115
|
+
):
|
|
116
|
+
raise InvalidModelError("Invalid SVM support-vector dimensions.")
|
|
117
|
+
if (
|
|
118
|
+
classes is not None
|
|
119
|
+
and (model._probA.size or callable(getattr(model, "predict_proba", None)))
|
|
120
|
+
and (model._probA.shape != (intercepts,) or model._probB.shape != (intercepts,))
|
|
121
|
+
):
|
|
122
|
+
raise InvalidModelError("Invalid SVM probability calibration dimensions.")
|
|
123
|
+
sign = -1 if model._impl in ("c_svc", "nu_svc") and len(model.classes_) == 2 else 1
|
|
124
|
+
if not _same(model.dual_coef_, model._dual_coef_, sign) or not np.array_equal(
|
|
125
|
+
model.intercept_, sign * model._intercept_
|
|
126
|
+
):
|
|
127
|
+
raise InvalidModelError("SVM public and inference coefficients disagree.")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Validate skops' empty-array dtype representation before construction."""
|
|
2
|
+
|
|
3
|
+
from mlbundle.canonical import canonical_loads
|
|
4
|
+
from mlbundle.errors import InvalidModelError
|
|
5
|
+
|
|
6
|
+
from .arrays import array_header
|
|
7
|
+
from .trusted import dtype_identifier
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_dtypes(nodes, resolve, archive) -> None:
|
|
11
|
+
for node in nodes:
|
|
12
|
+
if node.get("__loader__") != "DTypeNode":
|
|
13
|
+
continue
|
|
14
|
+
content = resolve(node.get("content"))
|
|
15
|
+
if content.get("__loader__") != "NdArrayNode" or (
|
|
16
|
+
content.get("__module__"),
|
|
17
|
+
content.get("__class__"),
|
|
18
|
+
) != ("numpy", "ndarray"):
|
|
19
|
+
raise InvalidModelError("Invalid skops dtype storage.")
|
|
20
|
+
if content.get("type") == "numpy":
|
|
21
|
+
name = content["file"]
|
|
22
|
+
shape, dtype = array_header(archive, name, archive.getinfo(name).file_size)
|
|
23
|
+
dtype_identifier(dtype)
|
|
24
|
+
if shape != (0,):
|
|
25
|
+
raise InvalidModelError("Skops dtype storage must be empty and scalar.")
|
|
26
|
+
elif content.get("type") == "json":
|
|
27
|
+
# Only the canonical empty object array emitted by dtype_get_state.
|
|
28
|
+
shape = resolve(content.get("shape"))
|
|
29
|
+
if shape.get("__loader__") != "TupleNode" or len(shape["content"]) != 1:
|
|
30
|
+
raise InvalidModelError("Invalid skops object dtype shape.")
|
|
31
|
+
size = resolve(shape["content"][0])
|
|
32
|
+
if (
|
|
33
|
+
size.get("__loader__") != "JsonNode"
|
|
34
|
+
or size.get("is_json") is not True
|
|
35
|
+
or canonical_loads(size["content"].encode()) != 0
|
|
36
|
+
or size["content"] != "0"
|
|
37
|
+
or content.get("content") != []
|
|
38
|
+
):
|
|
39
|
+
raise InvalidModelError("Skops object dtype storage must be empty.")
|
|
40
|
+
else:
|
|
41
|
+
raise InvalidModelError("Invalid skops dtype storage kind.")
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Validate fitted category/drop/grouping dimensions before deriving width."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder
|
|
7
|
+
|
|
8
|
+
from mlbundle.canonical import canonical_dumps
|
|
9
|
+
|
|
10
|
+
from ._width_checks import dimensions, indices, width
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def encoder_width(model: Any, count: int) -> int:
|
|
14
|
+
categories = model.categories_
|
|
15
|
+
dimensions(isinstance(categories, list) and len(categories) == count)
|
|
16
|
+
lengths = []
|
|
17
|
+
for values in categories:
|
|
18
|
+
dimensions(type(values) is np.ndarray)
|
|
19
|
+
array = np.asarray(values)
|
|
20
|
+
dimensions(array.ndim == 1 and 1 <= len(array) <= 100_000)
|
|
21
|
+
encoded = [canonical_dumps(item) for item in array]
|
|
22
|
+
dimensions(len(set(encoded)) == len(encoded))
|
|
23
|
+
lengths.append(len(array))
|
|
24
|
+
enabled = model._infrequent_enabled
|
|
25
|
+
dimensions(
|
|
26
|
+
type(enabled) is bool
|
|
27
|
+
and enabled
|
|
28
|
+
== (model.min_frequency is not None or model.max_categories is not None)
|
|
29
|
+
)
|
|
30
|
+
missing = model._missing_indices if type(model) is OrdinalEncoder else {}
|
|
31
|
+
dimensions(
|
|
32
|
+
isinstance(missing, dict)
|
|
33
|
+
and all(type(i) is int and 0 <= i < count for i in missing)
|
|
34
|
+
)
|
|
35
|
+
for i, index in missing.items():
|
|
36
|
+
dimensions(width(index, zero=True) == lengths[i] - 1)
|
|
37
|
+
dimensions(bool(np.asarray(categories[i][-1] != categories[i][-1])))
|
|
38
|
+
grouped = grouped_counts(model, lengths, missing, count)
|
|
39
|
+
if type(model) is not OneHotEncoder:
|
|
40
|
+
return count
|
|
41
|
+
dropped_counts(model, lengths, grouped, count)
|
|
42
|
+
cached = model._n_features_outs
|
|
43
|
+
dimensions(isinstance(cached, list) and len(cached) == count)
|
|
44
|
+
dimensions([width(item, zero=True) for item in cached] == grouped)
|
|
45
|
+
return width(sum(grouped))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def grouped_counts(
|
|
49
|
+
model: Any, lengths: list[int], missing: dict, count: int
|
|
50
|
+
) -> list[int]:
|
|
51
|
+
grouped = lengths.copy()
|
|
52
|
+
enabled = model._infrequent_enabled
|
|
53
|
+
if enabled:
|
|
54
|
+
infrequent = model._infrequent_indices
|
|
55
|
+
mappings = model._default_to_infrequent_mappings
|
|
56
|
+
dimensions(
|
|
57
|
+
isinstance(infrequent, list)
|
|
58
|
+
and isinstance(mappings, list)
|
|
59
|
+
and len(infrequent) == len(mappings) == count
|
|
60
|
+
)
|
|
61
|
+
for i, (selection, mapping) in enumerate(
|
|
62
|
+
zip(infrequent, mappings, strict=True)
|
|
63
|
+
):
|
|
64
|
+
if selection is None:
|
|
65
|
+
dimensions(mapping is None)
|
|
66
|
+
continue
|
|
67
|
+
size = lengths[i] - int(i in missing)
|
|
68
|
+
selected = indices(selection, size)
|
|
69
|
+
dimensions(bool(selected) and selected == sorted(selected))
|
|
70
|
+
grouped[i] -= len(selected) - 1
|
|
71
|
+
array = np.asarray(mapping)
|
|
72
|
+
dimensions(array.shape == (size,) and array.dtype.kind in "iu")
|
|
73
|
+
expected = np.empty(size, dtype=int)
|
|
74
|
+
frequent = sorted(set(range(size)) - set(selected))
|
|
75
|
+
expected[frequent] = np.arange(len(frequent))
|
|
76
|
+
expected[selected] = len(frequent)
|
|
77
|
+
dimensions(np.array_equal(array, expected))
|
|
78
|
+
return grouped
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def dropped_counts(
|
|
82
|
+
model: Any, lengths: list[int], grouped: list[int], count: int
|
|
83
|
+
) -> None:
|
|
84
|
+
enabled = model._infrequent_enabled
|
|
85
|
+
drop = model.drop_idx_
|
|
86
|
+
after = model._drop_idx_after_grouping
|
|
87
|
+
dimensions((drop is None) == (after is None) == (model.drop is None))
|
|
88
|
+
if drop is not None:
|
|
89
|
+
dimensions(
|
|
90
|
+
type(drop) is np.ndarray
|
|
91
|
+
and type(after) is np.ndarray
|
|
92
|
+
and drop.dtype.kind == after.dtype.kind == "O"
|
|
93
|
+
)
|
|
94
|
+
dimensions(np.asarray(drop).shape == np.asarray(after).shape == (count,))
|
|
95
|
+
for i, (raw_original, raw_transformed) in enumerate(
|
|
96
|
+
zip(drop, np.asarray(after), strict=True)
|
|
97
|
+
):
|
|
98
|
+
dimensions((raw_original is None) == (raw_transformed is None))
|
|
99
|
+
if raw_original is None:
|
|
100
|
+
dimensions(model.drop == "if_binary" and grouped[i] != 2)
|
|
101
|
+
continue
|
|
102
|
+
original = width(raw_original, zero=True)
|
|
103
|
+
transformed = width(raw_transformed, zero=True)
|
|
104
|
+
dimensions(original < lengths[i] and transformed < grouped[i])
|
|
105
|
+
mapping = model._default_to_infrequent_mappings[i] if enabled else None
|
|
106
|
+
dimensions(
|
|
107
|
+
transformed == (original if mapping is None else int(mapping[original]))
|
|
108
|
+
)
|
|
109
|
+
if type(model.drop) is str:
|
|
110
|
+
dimensions(
|
|
111
|
+
transformed == 0
|
|
112
|
+
and (
|
|
113
|
+
model.drop == "first"
|
|
114
|
+
or (model.drop == "if_binary" and grouped[i] == 2)
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
dimensions(np.asarray(model.drop).shape == (count,))
|
|
119
|
+
dimensions(
|
|
120
|
+
canonical_dumps(np.asarray(model.drop)[i])
|
|
121
|
+
== canonical_dumps(model.categories_[i][original])
|
|
122
|
+
)
|
|
123
|
+
grouped[i] -= 1
|