mlbundle 0.1.0a1__py3-none-any.whl

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