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,252 @@
|
|
|
1
|
+
"""Native graph signatures, output contracts and original label claims."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from pydantic import JsonValue
|
|
7
|
+
|
|
8
|
+
from mlbundle.canonical import canonical_dumps
|
|
9
|
+
from mlbundle.claims import ClassLabels, ModelClaims
|
|
10
|
+
from mlbundle.contract import ModelKind
|
|
11
|
+
from mlbundle.environment import OnnxInfo, Opset
|
|
12
|
+
from mlbundle.errors import InvalidModelError, LimitExceededError
|
|
13
|
+
from mlbundle.limits import HYPERPARAMETER_BYTES, MAX_CLASSES
|
|
14
|
+
|
|
15
|
+
from .signatures import _batch, _inputs, signature
|
|
16
|
+
|
|
17
|
+
LABEL_OPS = frozenset(
|
|
18
|
+
{"ZipMap", "LinearClassifier", "TreeEnsembleClassifier", "SVMClassifier"}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Description:
|
|
24
|
+
claims: ModelClaims
|
|
25
|
+
inputs: tuple[dict[str, Any], ...]
|
|
26
|
+
output: str
|
|
27
|
+
maps: bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ancestors(graph: Any, output: str) -> list[Any]:
|
|
31
|
+
producers = {name: node for node in graph.node for name in node.output if name}
|
|
32
|
+
pending, seen, result = [output], set(), []
|
|
33
|
+
while pending:
|
|
34
|
+
name = pending.pop()
|
|
35
|
+
if name in seen:
|
|
36
|
+
continue
|
|
37
|
+
seen.add(name)
|
|
38
|
+
node = producers.get(name)
|
|
39
|
+
if node is not None:
|
|
40
|
+
result.append(node)
|
|
41
|
+
pending.extend(node.input)
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _labels(nodes: list[Any]) -> ClassLabels | None:
|
|
46
|
+
found = []
|
|
47
|
+
for node in nodes:
|
|
48
|
+
if node.domain != "ai.onnx.ml" or node.op_type not in LABEL_OPS:
|
|
49
|
+
continue
|
|
50
|
+
for attr in node.attribute:
|
|
51
|
+
if attr.name == "classlabels_strings":
|
|
52
|
+
found.append([value.decode("utf-8") for value in attr.strings])
|
|
53
|
+
elif attr.name in ("classlabels_ints", "classlabels_int64s"):
|
|
54
|
+
found.append(list(attr.ints))
|
|
55
|
+
if not found:
|
|
56
|
+
return None
|
|
57
|
+
if any(
|
|
58
|
+
canonical_dumps(labels) != canonical_dumps(found[0]) for labels in found[1:]
|
|
59
|
+
):
|
|
60
|
+
raise InvalidModelError("ONNX native label records disagree.")
|
|
61
|
+
return cast(ClassLabels, [found[0]])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _native_labels(graph: Any, output: str) -> ClassLabels | None:
|
|
65
|
+
producers = {name: node for node in graph.node for name in node.output if name}
|
|
66
|
+
original = output
|
|
67
|
+
for _ in range(len(graph.node)):
|
|
68
|
+
node = producers.get(output)
|
|
69
|
+
if node is None:
|
|
70
|
+
return None
|
|
71
|
+
if node.domain == "ai.onnx.ml" and node.op_type in LABEL_OPS:
|
|
72
|
+
if node.op_type == "ZipMap" or (
|
|
73
|
+
len(node.output) > 1 and output == node.output[1]
|
|
74
|
+
):
|
|
75
|
+
return _labels([node])
|
|
76
|
+
break
|
|
77
|
+
if (
|
|
78
|
+
node.domain in ("", "ai.onnx")
|
|
79
|
+
and node.op_type in ("Identity", "Cast")
|
|
80
|
+
and len(node.input) == 1
|
|
81
|
+
):
|
|
82
|
+
output = node.input[0]
|
|
83
|
+
else:
|
|
84
|
+
break
|
|
85
|
+
if any(
|
|
86
|
+
node.domain == "ai.onnx.ml" and node.op_type in LABEL_OPS
|
|
87
|
+
for node in _ancestors(graph, original)
|
|
88
|
+
):
|
|
89
|
+
raise InvalidModelError(
|
|
90
|
+
"Unsupported transformation of ONNX labeled probabilities."
|
|
91
|
+
)
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _softmax(graph: Any, output: str) -> bool:
|
|
96
|
+
producers = {name: node for node in graph.node for name in node.output if name}
|
|
97
|
+
for _ in range(len(graph.node)):
|
|
98
|
+
node = producers.get(output)
|
|
99
|
+
if node is None or node.domain not in ("", "ai.onnx"):
|
|
100
|
+
return False
|
|
101
|
+
if node.op_type == "Softmax":
|
|
102
|
+
axis = next((attr.i for attr in node.attribute if attr.name == "axis"), -1)
|
|
103
|
+
return axis in (1, -1)
|
|
104
|
+
if node.op_type not in ("Identity", "Cast") or len(node.input) != 1:
|
|
105
|
+
return False
|
|
106
|
+
output = node.input[0]
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _probability(value: dict[str, Any]) -> bool:
|
|
111
|
+
if value["dtype"].startswith("sequence<map<"):
|
|
112
|
+
return True
|
|
113
|
+
shape = value["shape"]
|
|
114
|
+
return (
|
|
115
|
+
value["dtype"] in ("float32", "float64")
|
|
116
|
+
and shape is not None
|
|
117
|
+
and len(shape) == 2
|
|
118
|
+
and _batch(shape)
|
|
119
|
+
and (not isinstance(shape[1], int) or 1 <= shape[1] <= MAX_CLASSES)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _label_output(value: dict[str, Any]) -> bool:
|
|
124
|
+
shape = value["shape"]
|
|
125
|
+
return (
|
|
126
|
+
value["dtype"] in ("int64", "string")
|
|
127
|
+
and shape is not None
|
|
128
|
+
and _batch(shape)
|
|
129
|
+
and (len(shape) == 1 or (len(shape) == 2 and shape[1] == 1))
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _regression_width(graph: Any, output: str) -> None:
|
|
134
|
+
producers = {name: node for node in graph.node for name in node.output if name}
|
|
135
|
+
for _ in range(len(graph.node)):
|
|
136
|
+
node = producers.get(output)
|
|
137
|
+
if node is None:
|
|
138
|
+
return
|
|
139
|
+
if node.domain == "ai.onnx.ml" and node.op_type in (
|
|
140
|
+
"LinearRegressor",
|
|
141
|
+
"TreeEnsembleRegressor",
|
|
142
|
+
):
|
|
143
|
+
attr_name = "targets" if node.op_type == "LinearRegressor" else "n_targets"
|
|
144
|
+
width = next(
|
|
145
|
+
(attr.i for attr in node.attribute if attr.name == attr_name), 1
|
|
146
|
+
)
|
|
147
|
+
if width != 1:
|
|
148
|
+
raise InvalidModelError(
|
|
149
|
+
"ONNX native regression operator must expose one target."
|
|
150
|
+
)
|
|
151
|
+
return
|
|
152
|
+
if (
|
|
153
|
+
node.domain not in ("", "ai.onnx")
|
|
154
|
+
or node.op_type not in ("Identity", "Cast")
|
|
155
|
+
or len(node.input) != 1
|
|
156
|
+
):
|
|
157
|
+
return
|
|
158
|
+
output = node.input[0]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _outputs(
|
|
162
|
+
graph: Any, outputs: list[dict[str, Any]]
|
|
163
|
+
) -> tuple[ModelKind, dict[str, Any], ClassLabels | None, int | None]:
|
|
164
|
+
if len(outputs) == 2:
|
|
165
|
+
candidates = [
|
|
166
|
+
value
|
|
167
|
+
for index, value in enumerate(outputs)
|
|
168
|
+
if _probability(value) and _label_output(outputs[1 - index])
|
|
169
|
+
]
|
|
170
|
+
elif len(outputs) == 1:
|
|
171
|
+
value = outputs[0]
|
|
172
|
+
evidence = _native_labels(graph, value["name"]) is not None or _softmax(
|
|
173
|
+
graph, value["name"]
|
|
174
|
+
)
|
|
175
|
+
candidates = [value] if _probability(value) and evidence else []
|
|
176
|
+
else:
|
|
177
|
+
candidates = []
|
|
178
|
+
if len(candidates) == 1:
|
|
179
|
+
value = candidates[0]
|
|
180
|
+
labels = _native_labels(graph, value["name"])
|
|
181
|
+
count = value["shape"][1] if value["shape"] is not None else None
|
|
182
|
+
if not isinstance(count, int):
|
|
183
|
+
count = len(labels[0]) if labels else None
|
|
184
|
+
if count is None or (labels is not None and len(labels[0]) != count):
|
|
185
|
+
raise InvalidModelError(
|
|
186
|
+
"ONNX probability width and native labels disagree."
|
|
187
|
+
)
|
|
188
|
+
return ModelKind.CLASSIFIER, value, labels, count
|
|
189
|
+
if len(outputs) == 1:
|
|
190
|
+
value = outputs[0]
|
|
191
|
+
shape = value["shape"]
|
|
192
|
+
if (
|
|
193
|
+
value["dtype"] in ("float32", "float64")
|
|
194
|
+
and shape is not None
|
|
195
|
+
and _batch(shape)
|
|
196
|
+
and (len(shape) == 1 or (len(shape) == 2 and shape[1] == 1))
|
|
197
|
+
):
|
|
198
|
+
_regression_width(graph, value["name"])
|
|
199
|
+
return ModelKind.REGRESSOR, value, None, None
|
|
200
|
+
raise InvalidModelError(
|
|
201
|
+
"ONNX kind or probability/regression output layout is indeterminate."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def describe(model: Any) -> Description:
|
|
206
|
+
graph = model.graph
|
|
207
|
+
if not 1 <= len(graph.output) <= 2:
|
|
208
|
+
raise InvalidModelError(
|
|
209
|
+
"ONNX requires one regression or probability output, optionally with labels."
|
|
210
|
+
)
|
|
211
|
+
inputs = _inputs(graph)
|
|
212
|
+
outputs = [signature(value) for value in graph.output]
|
|
213
|
+
kind, output, labels, count = _outputs(graph, outputs)
|
|
214
|
+
width = sum(value["shape"][1] for value in inputs)
|
|
215
|
+
metadata = {item.key: item.value for item in model.metadata_props}
|
|
216
|
+
if len(metadata) != len(model.metadata_props):
|
|
217
|
+
raise InvalidModelError("Duplicate ONNX metadata keys.")
|
|
218
|
+
projection = {
|
|
219
|
+
"inputs": list(inputs),
|
|
220
|
+
"outputs": outputs,
|
|
221
|
+
"metadata_props": metadata,
|
|
222
|
+
}
|
|
223
|
+
try:
|
|
224
|
+
canonical_dumps(projection, max_bytes=HYPERPARAMETER_BYTES, max_depth=16)
|
|
225
|
+
except LimitExceededError:
|
|
226
|
+
raise LimitExceededError(
|
|
227
|
+
"ONNX hyperparameters (inputs/outputs/metadata_props) exceed the "
|
|
228
|
+
f"{HYPERPARAMETER_BYTES}-byte or depth-16 limit."
|
|
229
|
+
) from None
|
|
230
|
+
claims = ModelClaims(
|
|
231
|
+
estimator_class="ONNX",
|
|
232
|
+
kind=kind,
|
|
233
|
+
hyperparameters=cast(dict[str, JsonValue], projection),
|
|
234
|
+
n_features=width,
|
|
235
|
+
n_classes=None if count is None else [count],
|
|
236
|
+
n_outputs=1,
|
|
237
|
+
feature_names=[value["name"] for value in inputs] if len(inputs) > 1 else None,
|
|
238
|
+
feature_dtypes=[
|
|
239
|
+
value["dtype"] for value in inputs for _ in range(value["shape"][1])
|
|
240
|
+
],
|
|
241
|
+
class_labels=labels,
|
|
242
|
+
onnx=OnnxInfo(
|
|
243
|
+
opsets=[
|
|
244
|
+
Opset(domain=item.domain, version=item.version)
|
|
245
|
+
for item in sorted(model.opset_import, key=lambda item: item.domain)
|
|
246
|
+
],
|
|
247
|
+
ir_version=model.ir_version,
|
|
248
|
+
producer_name=model.producer_name,
|
|
249
|
+
producer_version=model.producer_version,
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
return Description(claims, inputs, output["name"], output["shape"] is None)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Numeric tabular feeds and validated tensor/ZipMap outputs, entirely in memory."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from mlbundle.adapters import ProbabilityOutput, RegressionOutput
|
|
10
|
+
from mlbundle.contract import ModelKind
|
|
11
|
+
from mlbundle.errors import InvalidModelError, PredictionInputError
|
|
12
|
+
|
|
13
|
+
from .description import Description
|
|
14
|
+
|
|
15
|
+
_InputShapeError = PredictionInputError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _array(data: Any, dtype: str) -> np.ndarray:
|
|
19
|
+
raw = np.asarray(data, dtype=object)
|
|
20
|
+
if np.dtype(dtype).kind in "iu":
|
|
21
|
+
limits = np.iinfo(dtype)
|
|
22
|
+
for value in raw.flat:
|
|
23
|
+
if (
|
|
24
|
+
isinstance(value, (bool, np.bool_))
|
|
25
|
+
or not isinstance(value, (int, float, np.integer, np.floating))
|
|
26
|
+
or not math.isfinite(value)
|
|
27
|
+
or int(value) != value
|
|
28
|
+
or not limits.min <= int(value) <= limits.max
|
|
29
|
+
):
|
|
30
|
+
raise InvalidModelError(
|
|
31
|
+
"ONNX integer inputs require whole numbers within the dtype range."
|
|
32
|
+
)
|
|
33
|
+
elif any(
|
|
34
|
+
isinstance(value, (bool, np.bool_))
|
|
35
|
+
or not isinstance(value, (int, float, np.integer, np.floating))
|
|
36
|
+
for value in raw.flat
|
|
37
|
+
):
|
|
38
|
+
raise InvalidModelError("ONNX inputs require finite numeric values.")
|
|
39
|
+
with warnings.catch_warnings():
|
|
40
|
+
warnings.simplefilter("error", RuntimeWarning)
|
|
41
|
+
values = np.asarray(raw, dtype=dtype)
|
|
42
|
+
if not np.isfinite(values).all():
|
|
43
|
+
raise InvalidModelError("ONNX inputs require finite numeric values.")
|
|
44
|
+
return values
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Predictor:
|
|
48
|
+
def __init__(self, session: Any, description: Description) -> None:
|
|
49
|
+
self.session = session
|
|
50
|
+
self.description = description
|
|
51
|
+
self.claims = description.claims
|
|
52
|
+
|
|
53
|
+
def _feeds(self, data: object) -> tuple[dict[str, np.ndarray], int]:
|
|
54
|
+
frame = None
|
|
55
|
+
if any(
|
|
56
|
+
base.__module__.split(".")[0] == "pandas" for base in type(data).__mro__
|
|
57
|
+
):
|
|
58
|
+
import pandas as pd # noqa: PLC0415
|
|
59
|
+
|
|
60
|
+
if isinstance(data, pd.DataFrame):
|
|
61
|
+
frame = data
|
|
62
|
+
values = np.asarray(data, dtype=object)
|
|
63
|
+
if values.ndim != 2 or values.shape[1] != self.claims.n_features:
|
|
64
|
+
raise _InputShapeError(
|
|
65
|
+
"Prediction input must have the model's feature width."
|
|
66
|
+
)
|
|
67
|
+
rows = values.shape[0]
|
|
68
|
+
feeds = {}
|
|
69
|
+
for index, value in enumerate(self.description.inputs):
|
|
70
|
+
shape = value["shape"]
|
|
71
|
+
if isinstance(shape[0], int) and rows != shape[0]:
|
|
72
|
+
raise _InputShapeError(
|
|
73
|
+
"Prediction row count must match the ONNX static batch."
|
|
74
|
+
)
|
|
75
|
+
if len(self.description.inputs) == 1:
|
|
76
|
+
column = values
|
|
77
|
+
elif frame is not None:
|
|
78
|
+
if not frame.columns.is_unique or value["name"] not in frame.columns:
|
|
79
|
+
raise InvalidModelError(
|
|
80
|
+
"ONNX DataFrame columns must match model input names."
|
|
81
|
+
)
|
|
82
|
+
column = frame[[value["name"]]].to_numpy(dtype=object)
|
|
83
|
+
else:
|
|
84
|
+
column = values[:, index : index + 1]
|
|
85
|
+
feeds[value["name"]] = _array(column, value["dtype"])
|
|
86
|
+
return feeds, rows
|
|
87
|
+
|
|
88
|
+
def _run(self, data: object) -> tuple[Any, int]:
|
|
89
|
+
try:
|
|
90
|
+
feeds, rows = self._feeds(data)
|
|
91
|
+
return self.session.run([self.description.output], feeds)[0], rows
|
|
92
|
+
except (InvalidModelError, _InputShapeError):
|
|
93
|
+
raise
|
|
94
|
+
except Exception:
|
|
95
|
+
raise InvalidModelError(
|
|
96
|
+
"ONNX prediction failed or input values are invalid."
|
|
97
|
+
) from None
|
|
98
|
+
|
|
99
|
+
def predict(self, data: object) -> RegressionOutput:
|
|
100
|
+
if self.claims.kind != ModelKind.REGRESSOR:
|
|
101
|
+
raise ValueError("predict requires a regressor.")
|
|
102
|
+
raw, rows = self._run(data)
|
|
103
|
+
try:
|
|
104
|
+
values = np.asarray(raw, dtype=float)
|
|
105
|
+
if values.shape == (rows, 1):
|
|
106
|
+
values = values[:, 0]
|
|
107
|
+
if values.shape != (rows,) or not np.isfinite(values).all():
|
|
108
|
+
raise ValueError
|
|
109
|
+
return cast(RegressionOutput, values.tolist())
|
|
110
|
+
except Exception:
|
|
111
|
+
raise InvalidModelError("Invalid ONNX regression output.") from None
|
|
112
|
+
|
|
113
|
+
def predict_proba(self, data: object) -> ProbabilityOutput:
|
|
114
|
+
if self.claims.kind != ModelKind.CLASSIFIER:
|
|
115
|
+
raise ValueError("predict_proba requires a classifier.")
|
|
116
|
+
raw, rows = self._run(data)
|
|
117
|
+
try:
|
|
118
|
+
if self.description.maps:
|
|
119
|
+
labels = (self.claims.class_labels or [[]])[0]
|
|
120
|
+
if not isinstance(raw, list) or any(
|
|
121
|
+
not isinstance(row, dict) or set(row) != set(labels) for row in raw
|
|
122
|
+
):
|
|
123
|
+
raise ValueError
|
|
124
|
+
raw = [[row[label] for label in labels] for row in raw]
|
|
125
|
+
values = np.asarray(raw, dtype=float)
|
|
126
|
+
if (
|
|
127
|
+
values.shape != (rows, (self.claims.n_classes or [0])[0])
|
|
128
|
+
or not np.isfinite(values).all()
|
|
129
|
+
or np.any(values < 0)
|
|
130
|
+
or np.any(values > 1)
|
|
131
|
+
or not np.allclose(values.sum(axis=1), 1.0, rtol=1e-6, atol=1e-5)
|
|
132
|
+
):
|
|
133
|
+
raise ValueError
|
|
134
|
+
return cast(ProbabilityOutput, values.tolist())
|
|
135
|
+
except Exception:
|
|
136
|
+
raise InvalidModelError("Invalid ONNX probability output.") from None
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Wire bounds before protobuf constructs objects; no tensor decoding or file I/O."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mlbundle.errors import InvalidModelError, LimitExceededError
|
|
6
|
+
from mlbundle.limits import ArchiveLimits
|
|
7
|
+
|
|
8
|
+
from ._wire_runs import packed_varints, repeated
|
|
9
|
+
|
|
10
|
+
# Each field/header or packed scalar consumes at least one distinct wire byte.
|
|
11
|
+
MAX_FIELDS = ArchiveLimits().max_member_bytes
|
|
12
|
+
MAX_DEPTH = 32
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _varint(data: bytes, position: int, end: int) -> tuple[int, int]:
|
|
16
|
+
value = 0
|
|
17
|
+
for shift in range(0, 70, 7):
|
|
18
|
+
if position >= end:
|
|
19
|
+
raise InvalidModelError("Truncated ONNX protobuf.")
|
|
20
|
+
byte = data[position]
|
|
21
|
+
position += 1
|
|
22
|
+
value |= (byte & 127) << shift
|
|
23
|
+
if byte < 128:
|
|
24
|
+
if shift == 63 and byte > 1:
|
|
25
|
+
break
|
|
26
|
+
return value, position
|
|
27
|
+
raise InvalidModelError("Invalid ONNX protobuf integer.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_wire(data: bytes, descriptor: Any) -> None:
|
|
31
|
+
if not data:
|
|
32
|
+
raise InvalidModelError("Empty ONNX model.")
|
|
33
|
+
if len(data) > ArchiveLimits().max_member_bytes:
|
|
34
|
+
raise LimitExceededError("ONNX model exceeds the byte limit.")
|
|
35
|
+
budget = [0]
|
|
36
|
+
_message(data, 0, len(data), descriptor, depth=0, budget=budget)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _message(data, position, end, descriptor, *, depth, budget):
|
|
40
|
+
if depth > MAX_DEPTH:
|
|
41
|
+
raise LimitExceededError("ONNX protobuf nesting exceeds the limit.")
|
|
42
|
+
seen = set()
|
|
43
|
+
while position < end:
|
|
44
|
+
budget[0] += 1
|
|
45
|
+
if budget[0] > MAX_FIELDS:
|
|
46
|
+
raise LimitExceededError("ONNX protobuf field count exceeds the limit.")
|
|
47
|
+
start = position
|
|
48
|
+
tag, position = _varint(data, position, end)
|
|
49
|
+
number, wire = tag >> 3, tag & 7
|
|
50
|
+
field = descriptor.fields_by_number.get(number)
|
|
51
|
+
if field is None or (not field.is_repeated and number in seen):
|
|
52
|
+
raise InvalidModelError("Unknown or duplicate ONNX protobuf field.")
|
|
53
|
+
seen.add(number)
|
|
54
|
+
value_start = position
|
|
55
|
+
position = _field(data, position, end, field, wire, depth=depth, budget=budget)
|
|
56
|
+
if field.is_repeated and (wire != 2 or field.type in (9, 12)):
|
|
57
|
+
position, count = repeated(
|
|
58
|
+
data, start, end, value_start=value_start, stop=position, wire=wire
|
|
59
|
+
)
|
|
60
|
+
budget[0] += count - 1
|
|
61
|
+
if budget[0] > MAX_FIELDS:
|
|
62
|
+
raise LimitExceededError("ONNX protobuf field count exceeds the limit.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _field(data, position, end, field, wire, *, depth, budget):
|
|
66
|
+
expected = (
|
|
67
|
+
2
|
|
68
|
+
if field.type in (9, 11, 12)
|
|
69
|
+
else {1: 1, 2: 5, 6: 1, 7: 5, 15: 5, 16: 1}.get(field.type, 0)
|
|
70
|
+
)
|
|
71
|
+
packed = field.is_repeated and expected != 2 and wire == 2
|
|
72
|
+
if wire != expected and not packed:
|
|
73
|
+
raise InvalidModelError("Invalid ONNX protobuf wire type.")
|
|
74
|
+
if wire == 0:
|
|
75
|
+
return _varint(data, position, end)[1]
|
|
76
|
+
if wire in (1, 5):
|
|
77
|
+
stop = position + (8 if wire == 1 else 4)
|
|
78
|
+
else:
|
|
79
|
+
size, position = _varint(data, position, end)
|
|
80
|
+
stop = position + size
|
|
81
|
+
if stop > end:
|
|
82
|
+
raise InvalidModelError("Truncated ONNX protobuf field.")
|
|
83
|
+
if field.type == 11:
|
|
84
|
+
_message(
|
|
85
|
+
data, position, stop, field.message_type, depth=depth + 1, budget=budget
|
|
86
|
+
)
|
|
87
|
+
elif packed:
|
|
88
|
+
_packed(data, position, stop, expected, budget)
|
|
89
|
+
return stop
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _packed(data, position, end, wire, budget):
|
|
93
|
+
if wire in (1, 5):
|
|
94
|
+
size = 8 if wire == 1 else 4
|
|
95
|
+
if (end - position) % size:
|
|
96
|
+
raise InvalidModelError("Invalid packed ONNX protobuf field.")
|
|
97
|
+
budget[0] += (end - position) // size
|
|
98
|
+
else:
|
|
99
|
+
while position < end:
|
|
100
|
+
stop, count = packed_varints(data, position, end)
|
|
101
|
+
if not count:
|
|
102
|
+
stop = _varint(data, position, end)[1]
|
|
103
|
+
count = 1
|
|
104
|
+
position = stop
|
|
105
|
+
budget[0] += count
|
|
106
|
+
if budget[0] > MAX_FIELDS:
|
|
107
|
+
raise LimitExceededError(
|
|
108
|
+
"ONNX protobuf scalar count exceeds the limit."
|
|
109
|
+
)
|
|
110
|
+
if budget[0] > MAX_FIELDS:
|
|
111
|
+
raise LimitExceededError("ONNX protobuf scalar count exceeds the limit.")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Fixed runtime settings and agreement with its inferred input/output metadata."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
import onnxruntime as ort
|
|
6
|
+
|
|
7
|
+
from mlbundle.errors import InvalidModelError
|
|
8
|
+
|
|
9
|
+
from .description import Description
|
|
10
|
+
|
|
11
|
+
ORT_TYPES = {
|
|
12
|
+
"float32": "tensor(float)",
|
|
13
|
+
"float64": "tensor(double)",
|
|
14
|
+
"int32": "tensor(int32)",
|
|
15
|
+
"int64": "tensor(int64)",
|
|
16
|
+
"string": "tensor(string)",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _agree(expected: dict[str, Any], actual: Any) -> None:
|
|
21
|
+
dtype = expected["dtype"]
|
|
22
|
+
if dtype.startswith("sequence<map<"):
|
|
23
|
+
dtype = dtype.removeprefix("sequence<map<").removesuffix(">>")
|
|
24
|
+
key, value = dtype.split(",")
|
|
25
|
+
native_type = f"seq(map({key},{ORT_TYPES[value]}))"
|
|
26
|
+
else:
|
|
27
|
+
native_type = ORT_TYPES[dtype]
|
|
28
|
+
if expected["name"] != actual.name or native_type != actual.type:
|
|
29
|
+
raise InvalidModelError("ONNX graph and runtime input/output types disagree.")
|
|
30
|
+
shape = expected["shape"]
|
|
31
|
+
if shape is not None and (
|
|
32
|
+
actual.shape is None
|
|
33
|
+
or len(shape) != len(actual.shape)
|
|
34
|
+
or any(
|
|
35
|
+
isinstance(a, int) and a != b
|
|
36
|
+
for a, b in zip(shape, actual.shape, strict=True)
|
|
37
|
+
)
|
|
38
|
+
):
|
|
39
|
+
raise InvalidModelError(
|
|
40
|
+
"ONNX graph and runtime input/output dimensions disagree."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_session(data: bytes, description: Description) -> Any:
|
|
45
|
+
options = ort.SessionOptions()
|
|
46
|
+
options.intra_op_num_threads = 1
|
|
47
|
+
options.inter_op_num_threads = 1
|
|
48
|
+
options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
|
|
49
|
+
options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL
|
|
50
|
+
options.enable_cpu_mem_arena = False
|
|
51
|
+
options.enable_mem_pattern = False
|
|
52
|
+
options.enable_profiling = False
|
|
53
|
+
options.optimized_model_filepath = ""
|
|
54
|
+
options.log_severity_level = 4
|
|
55
|
+
options.add_session_config_entry("session.load_model_format", "ONNX")
|
|
56
|
+
options.add_session_config_entry("session.disable_prepacking", "1")
|
|
57
|
+
session = ort.InferenceSession(
|
|
58
|
+
data,
|
|
59
|
+
sess_options=options,
|
|
60
|
+
providers=["CPUExecutionProvider"],
|
|
61
|
+
read_config_from_model=False,
|
|
62
|
+
)
|
|
63
|
+
inputs, outputs = session.get_inputs(), session.get_outputs()
|
|
64
|
+
expected_outputs = cast(
|
|
65
|
+
list[dict[str, Any]], description.claims.hyperparameters["outputs"]
|
|
66
|
+
)
|
|
67
|
+
if len(inputs) != len(description.inputs) or len(outputs) != len(expected_outputs):
|
|
68
|
+
raise InvalidModelError("ONNX graph and runtime input/output counts disagree.")
|
|
69
|
+
for expected, actual in zip(description.inputs, inputs, strict=True):
|
|
70
|
+
_agree(expected, actual)
|
|
71
|
+
for expected, actual in zip(expected_outputs, outputs, strict=True):
|
|
72
|
+
_agree(expected, actual)
|
|
73
|
+
if actual.name == description.output and expected["shape"] is not None:
|
|
74
|
+
width = (
|
|
75
|
+
1
|
|
76
|
+
if description.claims.n_classes is None
|
|
77
|
+
else description.claims.n_classes[0]
|
|
78
|
+
)
|
|
79
|
+
if (
|
|
80
|
+
len(actual.shape) == 2
|
|
81
|
+
and isinstance(actual.shape[1], int)
|
|
82
|
+
and actual.shape[1] != width
|
|
83
|
+
):
|
|
84
|
+
raise InvalidModelError(
|
|
85
|
+
"ONNX runtime prediction width disagrees with native claims."
|
|
86
|
+
)
|
|
87
|
+
return session
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Supported tabular graph signatures and feature input layouts."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mlbundle.errors import InvalidModelError
|
|
6
|
+
from mlbundle.limits import MAX_FEATURES
|
|
7
|
+
|
|
8
|
+
DTYPES = {1: "float32", 11: "float64", 6: "int32", 7: "int64"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def signature(value: Any) -> dict[str, Any]:
|
|
12
|
+
typ = value.type
|
|
13
|
+
if typ.HasField("tensor_type"):
|
|
14
|
+
tensor = typ.tensor_type
|
|
15
|
+
if not tensor.HasField("shape"):
|
|
16
|
+
raise InvalidModelError("ONNX input/output ranks must be declared.")
|
|
17
|
+
dtype = {**DTYPES, 8: "string"}.get(tensor.elem_type)
|
|
18
|
+
if dtype is None:
|
|
19
|
+
raise InvalidModelError("Unsupported ONNX input/output dtype.")
|
|
20
|
+
shape = [
|
|
21
|
+
dim.dim_value if dim.HasField("dim_value") else dim.dim_param or None
|
|
22
|
+
for dim in tensor.shape.dim
|
|
23
|
+
]
|
|
24
|
+
return {"name": value.name, "dtype": dtype, "shape": shape}
|
|
25
|
+
if typ.HasField("sequence_type"):
|
|
26
|
+
element = typ.sequence_type.elem_type
|
|
27
|
+
if element.HasField("map_type"):
|
|
28
|
+
mapping = element.map_type
|
|
29
|
+
dtype = DTYPES.get(mapping.value_type.tensor_type.elem_type)
|
|
30
|
+
if mapping.key_type in (7, 8) and dtype in ("float32", "float64"):
|
|
31
|
+
key = "int64" if mapping.key_type == 7 else "string"
|
|
32
|
+
return {
|
|
33
|
+
"name": value.name,
|
|
34
|
+
"dtype": f"sequence<map<{key},{dtype}>>",
|
|
35
|
+
"shape": None,
|
|
36
|
+
}
|
|
37
|
+
raise InvalidModelError("Unsupported ONNX input/output layout.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _batch(shape: list[Any]) -> bool:
|
|
41
|
+
return bool(shape) and (not isinstance(shape[0], int) or shape[0] == 1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _inputs(graph: Any) -> tuple[dict[str, Any], ...]:
|
|
45
|
+
inputs = tuple(signature(value) for value in graph.input)
|
|
46
|
+
for value in inputs:
|
|
47
|
+
shape = value["shape"]
|
|
48
|
+
if (
|
|
49
|
+
value["dtype"] not in DTYPES.values()
|
|
50
|
+
or shape is None
|
|
51
|
+
or len(shape) != 2
|
|
52
|
+
or not _batch(shape)
|
|
53
|
+
or not isinstance(shape[1], int)
|
|
54
|
+
or not 1 <= shape[1] <= MAX_FEATURES
|
|
55
|
+
):
|
|
56
|
+
raise InvalidModelError(
|
|
57
|
+
"ONNX inputs require numeric matrices with fixed feature width and dynamic or one-row batches."
|
|
58
|
+
)
|
|
59
|
+
if len(inputs) > 1 and shape[1] != 1:
|
|
60
|
+
raise InvalidModelError(
|
|
61
|
+
"Multiple ONNX inputs require one column per input."
|
|
62
|
+
)
|
|
63
|
+
return inputs
|