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,158 @@
|
|
|
1
|
+
"""Composite orchestration; each byte member belongs to its existing loader."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import JsonValue
|
|
7
|
+
from sklearn.pipeline import Pipeline
|
|
8
|
+
|
|
9
|
+
from mlbundle._native_io import NativeExport, prepare_native
|
|
10
|
+
from mlbundle._pipeline_contract import PipelineInfo
|
|
11
|
+
from mlbundle._sklearn import transformers
|
|
12
|
+
from mlbundle._sklearn.pipeline_scope import validate_components
|
|
13
|
+
from mlbundle.claims import ModelClaims, verify_claims, verify_structure
|
|
14
|
+
from mlbundle.contract import Library, MemberName, SerializationFormat
|
|
15
|
+
from mlbundle.declarations import ExportDeclarations
|
|
16
|
+
from mlbundle.errors import LimitExceededError, UnsupportedModelError
|
|
17
|
+
from mlbundle.limits import ArchiveLimits
|
|
18
|
+
from mlbundle.registry import get_adapter
|
|
19
|
+
|
|
20
|
+
from .claims import extract, outer_claims, rebuild, structure, verify
|
|
21
|
+
from .prediction import Predictor
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class CompositeExport:
|
|
26
|
+
leaf: NativeExport
|
|
27
|
+
transformer_adapter: transformers.Adapter
|
|
28
|
+
prefix: Pipeline | None
|
|
29
|
+
restored_prefix: Pipeline | None
|
|
30
|
+
step_names: list[str]
|
|
31
|
+
verbose: bool
|
|
32
|
+
claims: ModelClaims
|
|
33
|
+
members: dict[MemberName, bytes]
|
|
34
|
+
library: Library = Library.SKLEARN
|
|
35
|
+
serialization: SerializationFormat = SerializationFormat.PIPELINE
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def structure(self) -> dict[str, JsonValue] | None:
|
|
39
|
+
return self.leaf.structure
|
|
40
|
+
|
|
41
|
+
def finish(self, names: list[str]) -> tuple[PipelineInfo, dict[str, JsonValue]]:
|
|
42
|
+
expected = extract(
|
|
43
|
+
self.prefix,
|
|
44
|
+
self.leaf.claims,
|
|
45
|
+
self.leaf.library,
|
|
46
|
+
self.step_names,
|
|
47
|
+
self.verbose,
|
|
48
|
+
names=names,
|
|
49
|
+
transformer_adapter=self.transformer_adapter,
|
|
50
|
+
)
|
|
51
|
+
actual_leaf = self.leaf.adapter.extract_claims(self.leaf.restored)
|
|
52
|
+
actual_model = rebuild(
|
|
53
|
+
self.restored_prefix, self.leaf.restored, self.step_names, self.verbose
|
|
54
|
+
)
|
|
55
|
+
validate_components(actual_model)
|
|
56
|
+
actual_claims = outer_claims(self.restored_prefix, actual_leaf, self.verbose)
|
|
57
|
+
verify_claims(self.claims, actual_claims)
|
|
58
|
+
actual = extract(
|
|
59
|
+
self.restored_prefix,
|
|
60
|
+
actual_leaf,
|
|
61
|
+
self.leaf.library,
|
|
62
|
+
self.step_names,
|
|
63
|
+
self.verbose,
|
|
64
|
+
names=names,
|
|
65
|
+
transformer_adapter=self.transformer_adapter,
|
|
66
|
+
)
|
|
67
|
+
verify(expected, actual)
|
|
68
|
+
original = structure(self.leaf.structure, expected)
|
|
69
|
+
encoded = verify_structure(
|
|
70
|
+
original,
|
|
71
|
+
structure(self.leaf.adapter.extract_structure(self.leaf.restored), actual),
|
|
72
|
+
)
|
|
73
|
+
self.members[MemberName.STRUCTURE] = encoded
|
|
74
|
+
return expected, original
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def prepare(
|
|
78
|
+
model: Any, declarations: ExportDeclarations, limits: ArchiveLimits
|
|
79
|
+
) -> CompositeExport:
|
|
80
|
+
validate_components(model, exporting=True)
|
|
81
|
+
leaf_model = model.steps[-1][1]
|
|
82
|
+
roots = {base.__module__.split(".")[0] for base in type(leaf_model).__mro__}
|
|
83
|
+
library = Library.XGBOOST if "xgboost" in roots else Library.CATBOOST
|
|
84
|
+
adapter = get_adapter(library)
|
|
85
|
+
if not adapter.recognizes(leaf_model):
|
|
86
|
+
raise UnsupportedModelError(
|
|
87
|
+
reason="Composite final estimator must be a supported XGBoost or CatBoost wrapper."
|
|
88
|
+
)
|
|
89
|
+
leaf = prepare_native(leaf_model, adapter, declarations, limits)
|
|
90
|
+
prefix = restored = None
|
|
91
|
+
transformer_adapter = transformers.Adapter()
|
|
92
|
+
members = dict(leaf.members)
|
|
93
|
+
if len(model.steps) > 1:
|
|
94
|
+
prefix = Pipeline(
|
|
95
|
+
list(model.steps[:-1]),
|
|
96
|
+
memory=None,
|
|
97
|
+
verbose=model.verbose,
|
|
98
|
+
transform_input=None,
|
|
99
|
+
)
|
|
100
|
+
data = transformer_adapter.serialize(prefix)
|
|
101
|
+
if len(data) > limits.member_bytes(MemberName.SKOPS):
|
|
102
|
+
raise LimitExceededError("Model member size limit exceeded.")
|
|
103
|
+
members[MemberName.SKOPS] = data
|
|
104
|
+
restored = transformer_adapter.deserialize(data)
|
|
105
|
+
claims = outer_claims(prefix, leaf.claims, model.verbose)
|
|
106
|
+
return CompositeExport(
|
|
107
|
+
leaf,
|
|
108
|
+
transformer_adapter,
|
|
109
|
+
prefix,
|
|
110
|
+
restored,
|
|
111
|
+
[name for name, _ in model.steps],
|
|
112
|
+
model.verbose,
|
|
113
|
+
claims,
|
|
114
|
+
members,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_components(
|
|
119
|
+
recipe: PipelineInfo,
|
|
120
|
+
members: dict[MemberName, bytes],
|
|
121
|
+
names: list[str],
|
|
122
|
+
verbose: bool,
|
|
123
|
+
) -> tuple[Pipeline, Predictor, ModelClaims, dict[str, JsonValue]]:
|
|
124
|
+
adapter = get_adapter(recipe.estimator.library)
|
|
125
|
+
transformer_adapter = transformers.Adapter()
|
|
126
|
+
leaf = adapter.deserialize(members[adapter.model_member])
|
|
127
|
+
prefix = (
|
|
128
|
+
None
|
|
129
|
+
if recipe.transformers is None
|
|
130
|
+
else transformer_adapter.deserialize(members[MemberName.SKOPS])
|
|
131
|
+
)
|
|
132
|
+
native_claims = adapter.extract_claims(leaf)
|
|
133
|
+
model = rebuild(prefix, leaf, recipe.step_names, verbose)
|
|
134
|
+
validate_components(model)
|
|
135
|
+
actual = extract(
|
|
136
|
+
prefix,
|
|
137
|
+
native_claims,
|
|
138
|
+
adapter.library,
|
|
139
|
+
recipe.step_names,
|
|
140
|
+
verbose,
|
|
141
|
+
names=names,
|
|
142
|
+
transformer_adapter=transformer_adapter,
|
|
143
|
+
)
|
|
144
|
+
verify(recipe, actual)
|
|
145
|
+
claims = outer_claims(prefix, native_claims, verbose)
|
|
146
|
+
extracted = structure(adapter.extract_structure(leaf), actual)
|
|
147
|
+
settings = native_claims.hyperparameters.get("prediction")
|
|
148
|
+
categorical = (
|
|
149
|
+
adapter.library == Library.XGBOOST
|
|
150
|
+
and isinstance(settings, dict)
|
|
151
|
+
and settings.get("enable_categorical") is True
|
|
152
|
+
)
|
|
153
|
+
return (
|
|
154
|
+
model,
|
|
155
|
+
Predictor(prefix, adapter.predictor(leaf), claims, categorical=categorical),
|
|
156
|
+
claims,
|
|
157
|
+
extracted,
|
|
158
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Transform original inputs, then delegate to the cached native predictor."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from sklearn import config_context
|
|
6
|
+
from sklearn.pipeline import Pipeline
|
|
7
|
+
|
|
8
|
+
from mlbundle._prediction_input import tabular
|
|
9
|
+
from mlbundle._sklearn.prediction import _string_inputs
|
|
10
|
+
from mlbundle.adapters import (
|
|
11
|
+
Predictor as NativePredictor,
|
|
12
|
+
ProbabilityOutput,
|
|
13
|
+
RegressionOutput,
|
|
14
|
+
)
|
|
15
|
+
from mlbundle.claims import ModelClaims
|
|
16
|
+
from mlbundle.contract import ModelKind
|
|
17
|
+
from mlbundle.errors import InvalidModelError, MLBundleError, PredictionInputError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Predictor:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
prefix: Pipeline | None,
|
|
24
|
+
native: NativePredictor,
|
|
25
|
+
claims: ModelClaims,
|
|
26
|
+
*,
|
|
27
|
+
categorical: bool = False,
|
|
28
|
+
) -> None:
|
|
29
|
+
self.prefix, self.native, self.claims = prefix, native, claims
|
|
30
|
+
self.strings = prefix is not None and _string_inputs(prefix)
|
|
31
|
+
self.categorical = categorical
|
|
32
|
+
|
|
33
|
+
def _transform(self, data: Any) -> Any:
|
|
34
|
+
# A single-step composite preserves the leaf's categorical admission.
|
|
35
|
+
if self.prefix is None:
|
|
36
|
+
return data
|
|
37
|
+
data, rows = tabular(
|
|
38
|
+
data,
|
|
39
|
+
self.claims.n_features,
|
|
40
|
+
strings=self.strings,
|
|
41
|
+
categorical=self.categorical,
|
|
42
|
+
)
|
|
43
|
+
names = getattr(data, "columns", None)
|
|
44
|
+
if (
|
|
45
|
+
names is not None
|
|
46
|
+
and self.claims.feature_names is not None
|
|
47
|
+
and list(names) != self.claims.feature_names
|
|
48
|
+
):
|
|
49
|
+
raise PredictionInputError(
|
|
50
|
+
"Prediction columns must match model feature names and order."
|
|
51
|
+
)
|
|
52
|
+
try:
|
|
53
|
+
with config_context(
|
|
54
|
+
transform_output="default", enable_metadata_routing=False
|
|
55
|
+
):
|
|
56
|
+
result = self.prefix.transform(data)
|
|
57
|
+
if len(result.shape) != 2 or result.shape[0] != rows:
|
|
58
|
+
raise InvalidModelError(
|
|
59
|
+
"Composite transformation must preserve prediction rows."
|
|
60
|
+
)
|
|
61
|
+
return result
|
|
62
|
+
except MLBundleError:
|
|
63
|
+
raise
|
|
64
|
+
except Exception:
|
|
65
|
+
raise InvalidModelError(
|
|
66
|
+
"Composite Pipeline transformation failed."
|
|
67
|
+
) from None
|
|
68
|
+
|
|
69
|
+
def predict(self, data: object) -> RegressionOutput:
|
|
70
|
+
if self.claims.kind != ModelKind.REGRESSOR:
|
|
71
|
+
raise ValueError("predict requires a regressor.")
|
|
72
|
+
return self.native.predict(self._transform(data))
|
|
73
|
+
|
|
74
|
+
def predict_proba(self, data: object) -> ProbabilityOutput:
|
|
75
|
+
if self.claims.kind != ModelKind.CLASSIFIER:
|
|
76
|
+
raise ValueError("predict_proba requires a classifier.")
|
|
77
|
+
return self.native.predict_proba(self._transform(data))
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Shared container names, member sets and resource checks."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from os import PathLike
|
|
5
|
+
from typing import BinaryIO
|
|
6
|
+
|
|
7
|
+
from .contract import MODEL_MEMBERS, MemberName
|
|
8
|
+
from .errors import InvalidContainerError, LimitExceededError
|
|
9
|
+
from .limits import ArchiveLimits
|
|
10
|
+
from .manifest import Manifest
|
|
11
|
+
|
|
12
|
+
BundleSource = str | PathLike[str] | bytes | BinaryIO
|
|
13
|
+
CHUNK_BYTES = 64 * 1024
|
|
14
|
+
MEMBER_ORDER = (
|
|
15
|
+
MemberName.MANIFEST,
|
|
16
|
+
MemberName.SKOPS,
|
|
17
|
+
MemberName.UBJSON,
|
|
18
|
+
MemberName.CBM,
|
|
19
|
+
MemberName.ONNX,
|
|
20
|
+
MemberName.SCHEMA,
|
|
21
|
+
MemberName.STRUCTURE,
|
|
22
|
+
MemberName.SAMPLES,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def effective_limits(limits: ArchiveLimits | None) -> ArchiveLimits:
|
|
27
|
+
# Revalidate even model_copy/model_construct values; ceilings are mandatory.
|
|
28
|
+
return ArchiveLimits.model_validate((limits or ArchiveLimits()).model_dump())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def member_name(raw: bytes | str) -> MemberName:
|
|
32
|
+
try:
|
|
33
|
+
return MemberName(raw if isinstance(raw, str) else raw.decode("ascii"))
|
|
34
|
+
except (UnicodeError, ValueError):
|
|
35
|
+
raise InvalidContainerError("Unknown or unsafe member name.") from None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check_members(
|
|
39
|
+
names: Iterable[MemberName], manifest: Manifest | None = None
|
|
40
|
+
) -> None:
|
|
41
|
+
present = set(names)
|
|
42
|
+
if MemberName.MANIFEST not in present:
|
|
43
|
+
raise InvalidContainerError("Missing manifest.json.")
|
|
44
|
+
models = {pair[1] for pair in MODEL_MEMBERS.values()}
|
|
45
|
+
selected = present & models
|
|
46
|
+
if len(selected) != 1 and selected not in (
|
|
47
|
+
{MemberName.SKOPS, MemberName.UBJSON},
|
|
48
|
+
{MemberName.SKOPS, MemberName.CBM},
|
|
49
|
+
):
|
|
50
|
+
raise InvalidContainerError("Invalid model member combination.")
|
|
51
|
+
if manifest is not None and present != {MemberName.MANIFEST, *manifest.integrity}:
|
|
52
|
+
raise InvalidContainerError("Members disagree with manifest references.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_sizes(
|
|
56
|
+
sizes: Iterable[tuple[MemberName, int, int]], limits: ArchiveLimits
|
|
57
|
+
) -> None:
|
|
58
|
+
compressed = uncompressed = count = 0
|
|
59
|
+
for name, packed, unpacked in sizes:
|
|
60
|
+
count += 1
|
|
61
|
+
compressed += packed
|
|
62
|
+
uncompressed += unpacked
|
|
63
|
+
if packed > limits.max_compressed_bytes or unpacked > limits.member_bytes(name):
|
|
64
|
+
raise LimitExceededError(f"Member size limit exceeded: {name.value}.")
|
|
65
|
+
if unpacked > limits.max_compression_ratio * packed:
|
|
66
|
+
raise LimitExceededError(f"Compression ratio limit exceeded: {name.value}.")
|
|
67
|
+
if count > limits.max_members:
|
|
68
|
+
raise LimitExceededError("Member count limit exceeded.")
|
|
69
|
+
if (
|
|
70
|
+
compressed > limits.max_compressed_bytes
|
|
71
|
+
or uncompressed > limits.max_total_bytes
|
|
72
|
+
):
|
|
73
|
+
raise LimitExceededError("Total member size limit exceeded.")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Manifest construction shared by standalone and composite export."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from pydantic import JsonValue
|
|
9
|
+
|
|
10
|
+
from .claims import ModelClaims
|
|
11
|
+
from .contract import (
|
|
12
|
+
FORMAT_NAME,
|
|
13
|
+
FORMAT_VERSION,
|
|
14
|
+
FeatureOrigin,
|
|
15
|
+
Library,
|
|
16
|
+
MemberName,
|
|
17
|
+
SerializationFormat,
|
|
18
|
+
)
|
|
19
|
+
from .environment import capture_environment
|
|
20
|
+
from .training import TrainingDeclaration
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def manifest_parts(
|
|
24
|
+
claims: ModelClaims,
|
|
25
|
+
library: Library,
|
|
26
|
+
serialization: SerializationFormat,
|
|
27
|
+
*,
|
|
28
|
+
structure: dict[str, JsonValue] | None,
|
|
29
|
+
feature_names: Sequence[str] | None,
|
|
30
|
+
training: TrainingDeclaration | None,
|
|
31
|
+
target_name: str | None,
|
|
32
|
+
metadata: Mapping[str, JsonValue] | None,
|
|
33
|
+
) -> dict[str, object]:
|
|
34
|
+
names = claims.feature_names
|
|
35
|
+
origin = FeatureOrigin.MODEL
|
|
36
|
+
if names is None:
|
|
37
|
+
origin = (
|
|
38
|
+
FeatureOrigin.DECLARED
|
|
39
|
+
if feature_names is not None
|
|
40
|
+
else FeatureOrigin.GENERATED
|
|
41
|
+
)
|
|
42
|
+
names = (
|
|
43
|
+
list(feature_names)
|
|
44
|
+
if feature_names is not None
|
|
45
|
+
else [f"feature_{index}" for index in range(1, claims.n_features + 1)]
|
|
46
|
+
)
|
|
47
|
+
if training is not None:
|
|
48
|
+
names, origin = training.names, FeatureOrigin.DATAFRAME
|
|
49
|
+
model = claims.model_dump(
|
|
50
|
+
mode="json", exclude={"feature_names", "feature_dtypes", "class_labels", "onnx"}
|
|
51
|
+
)
|
|
52
|
+
model.update(library=library.value, serialization=serialization.value)
|
|
53
|
+
return {
|
|
54
|
+
"format_name": FORMAT_NAME,
|
|
55
|
+
"format_version": FORMAT_VERSION,
|
|
56
|
+
"bundle_id": str(uuid4()),
|
|
57
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
58
|
+
"writer_version": version("mlbundle"),
|
|
59
|
+
"model": model,
|
|
60
|
+
"features": {
|
|
61
|
+
"names": names,
|
|
62
|
+
"dtypes": [None] * claims.n_features
|
|
63
|
+
if training is None
|
|
64
|
+
else training.dtypes,
|
|
65
|
+
"model_dtypes": claims.feature_dtypes,
|
|
66
|
+
"origin": origin.value,
|
|
67
|
+
},
|
|
68
|
+
"target": {"class_labels": claims.class_labels, "name": target_name},
|
|
69
|
+
"environment": capture_environment(onnx=claims.onnx).model_dump(mode="json"),
|
|
70
|
+
"references": {
|
|
71
|
+
"structure": None if structure is None else MemberName.STRUCTURE.value,
|
|
72
|
+
"schema": None if training is None else MemberName.SCHEMA.value,
|
|
73
|
+
"samples": None
|
|
74
|
+
if training is None or training.samples is None
|
|
75
|
+
else MemberName.SAMPLES.value,
|
|
76
|
+
},
|
|
77
|
+
"user_metadata": {} if metadata is None else dict(metadata),
|
|
78
|
+
}
|
mlbundle/_native_io.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Shared native preparation and full round-trip verification."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from pydantic import JsonValue
|
|
6
|
+
|
|
7
|
+
from .adapters import Adapter
|
|
8
|
+
from .claims import ModelClaims, verify_claims, verify_structure
|
|
9
|
+
from .contract import MODEL_MEMBERS, Library, MemberName, SerializationFormat
|
|
10
|
+
from .declarations import ExportDeclarations, resolve_labels
|
|
11
|
+
from .errors import LimitExceededError
|
|
12
|
+
from .limits import ArchiveLimits
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class NativeExport:
|
|
17
|
+
adapter: Adapter
|
|
18
|
+
model: object
|
|
19
|
+
restored: object
|
|
20
|
+
claims: ModelClaims
|
|
21
|
+
structure: dict[str, JsonValue] | None
|
|
22
|
+
members: dict[MemberName, bytes]
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def library(self) -> Library:
|
|
26
|
+
return self.adapter.library
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def serialization(self) -> SerializationFormat:
|
|
30
|
+
return MODEL_MEMBERS[self.library][0]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def prepare_native(
|
|
34
|
+
model: object,
|
|
35
|
+
adapter: Adapter,
|
|
36
|
+
declarations: ExportDeclarations,
|
|
37
|
+
limits: ArchiveLimits,
|
|
38
|
+
) -> NativeExport:
|
|
39
|
+
model = adapter.prepare_export(model, declarations)
|
|
40
|
+
data = adapter.serialize(model)
|
|
41
|
+
if len(data) > limits.member_bytes(adapter.model_member):
|
|
42
|
+
raise LimitExceededError("Model member size limit exceeded.")
|
|
43
|
+
claims = resolve_labels(adapter.extract_claims(model), declarations)
|
|
44
|
+
structure = adapter.extract_structure(model)
|
|
45
|
+
restored = adapter.deserialize(data)
|
|
46
|
+
verify_claims(
|
|
47
|
+
claims, resolve_labels(adapter.extract_claims(restored), declarations)
|
|
48
|
+
)
|
|
49
|
+
structure_bytes = verify_structure(structure, adapter.extract_structure(restored))
|
|
50
|
+
members = {adapter.model_member: data}
|
|
51
|
+
if structure is not None:
|
|
52
|
+
members[MemberName.STRUCTURE] = structure_bytes
|
|
53
|
+
return NativeExport(adapter, model, restored, claims, structure, members)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Original ONNX bytes with a validated CPU session; optional imports stay here."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from os import PathLike
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import onnx
|
|
9
|
+
from pydantic import JsonValue, ValidationError
|
|
10
|
+
|
|
11
|
+
from mlbundle.claims import ModelClaims
|
|
12
|
+
from mlbundle.contract import Library, MemberName
|
|
13
|
+
from mlbundle.declarations import ExportDeclarations, resolve_labels
|
|
14
|
+
from mlbundle.errors import InvalidModelError, LimitExceededError, MLBundleError
|
|
15
|
+
from mlbundle.limits import ArchiveLimits
|
|
16
|
+
|
|
17
|
+
from .description import Description, describe
|
|
18
|
+
from .prediction import Predictor
|
|
19
|
+
from .session import create_session
|
|
20
|
+
from .validation import parse_model
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class OnnxModel:
|
|
25
|
+
"""Validated native inference session and the exact bytes that created it."""
|
|
26
|
+
|
|
27
|
+
data: bytes
|
|
28
|
+
session: Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _description(data: bytes) -> Description:
|
|
32
|
+
try:
|
|
33
|
+
return describe(parse_model(data))
|
|
34
|
+
except MLBundleError:
|
|
35
|
+
raise
|
|
36
|
+
except ValidationError as error:
|
|
37
|
+
failure = error.errors(include_input=False, include_url=False)[0]
|
|
38
|
+
# Disclose closed field names, never author-supplied keys or values.
|
|
39
|
+
fields = {"producer_name", "producer_version", "feature_names", "class_labels"}
|
|
40
|
+
field = next((part for part in failure["loc"] if part in fields), None)
|
|
41
|
+
if field is not None and failure["type"] == "string_too_long":
|
|
42
|
+
limit = failure.get("ctx", {})["max_length"]
|
|
43
|
+
raise InvalidModelError(
|
|
44
|
+
f"ONNX {field} exceeds the {limit}-character limit."
|
|
45
|
+
) from None
|
|
46
|
+
raise InvalidModelError("Invalid ONNX model claims.") from None
|
|
47
|
+
except Exception:
|
|
48
|
+
raise InvalidModelError("Invalid ONNX model claims.") from None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Adapter:
|
|
52
|
+
library = Library.ONNX
|
|
53
|
+
model_member = MemberName.ONNX
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
self._data: bytes | None = None
|
|
57
|
+
self._validated: Description | None = None
|
|
58
|
+
|
|
59
|
+
def _describe(self, data: bytes) -> Description:
|
|
60
|
+
# An adapter belongs to one export/load, never a global cache or bundle.
|
|
61
|
+
if data is not self._data or self._validated is None:
|
|
62
|
+
description = _description(data)
|
|
63
|
+
self._data, self._validated = data, description
|
|
64
|
+
return self._validated
|
|
65
|
+
|
|
66
|
+
def recognizes(self, model: object) -> bool:
|
|
67
|
+
return isinstance(model, (bytes, onnx.ModelProto, OnnxModel)) or (
|
|
68
|
+
isinstance(model, (str, PathLike)) and Path(model).suffix.lower() == ".onnx"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def prepare_export(self, model: object, declarations: ExportDeclarations) -> object:
|
|
72
|
+
try:
|
|
73
|
+
if isinstance(model, OnnxModel):
|
|
74
|
+
data = model.data
|
|
75
|
+
elif isinstance(model, bytes):
|
|
76
|
+
data = model
|
|
77
|
+
elif isinstance(model, onnx.ModelProto):
|
|
78
|
+
if model.ByteSize() > ArchiveLimits().max_member_bytes:
|
|
79
|
+
raise LimitExceededError("ONNX model exceeds the byte limit.")
|
|
80
|
+
data = model.SerializeToString()
|
|
81
|
+
else:
|
|
82
|
+
path = Path(model) # type: ignore[arg-type]
|
|
83
|
+
cap = ArchiveLimits().max_member_bytes
|
|
84
|
+
if path.stat().st_size > cap:
|
|
85
|
+
raise LimitExceededError("ONNX model exceeds the byte limit.")
|
|
86
|
+
with path.open("rb") as stream:
|
|
87
|
+
data = stream.read(cap + 1)
|
|
88
|
+
description = self._describe(data)
|
|
89
|
+
resolve_labels(description.claims, declarations)
|
|
90
|
+
return self.deserialize(data)
|
|
91
|
+
except MLBundleError:
|
|
92
|
+
raise
|
|
93
|
+
except Exception:
|
|
94
|
+
raise InvalidModelError("Cannot read ONNX model input.") from None
|
|
95
|
+
|
|
96
|
+
def serialize(self, model: Any) -> bytes:
|
|
97
|
+
return model.data
|
|
98
|
+
|
|
99
|
+
def deserialize(self, data: bytes) -> OnnxModel:
|
|
100
|
+
description = self._describe(data)
|
|
101
|
+
try:
|
|
102
|
+
return OnnxModel(data, create_session(data, description))
|
|
103
|
+
except MLBundleError:
|
|
104
|
+
raise
|
|
105
|
+
except Exception:
|
|
106
|
+
raise InvalidModelError("ONNX CPU session creation failed.") from None
|
|
107
|
+
|
|
108
|
+
def extract_claims(self, model: Any) -> ModelClaims:
|
|
109
|
+
return self._describe(model.data).claims
|
|
110
|
+
|
|
111
|
+
def extract_structure(self, model: object) -> dict[str, JsonValue] | None: # noqa: ARG002
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def predictor(self, model: Any) -> Predictor:
|
|
115
|
+
return Predictor(model.session, self._describe(model.data))
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Bounded C regex scans of repeated wire scalars; no protobuf objects allocated."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
# Match exactly the same uint64 encodings accepted by the scalar walker,
|
|
9
|
+
# including overlong encodings; byte ten may contain only bit zero.
|
|
10
|
+
VARINT = rb"(?:[\x00-\x7f]|[\x80-\xff]{1,8}[\x00-\x7f]|[\x80-\xff]{9}[\x00-\x01])"
|
|
11
|
+
CHUNK_BYTES = 64 * 1024
|
|
12
|
+
PACKED = re.compile(rb"(?:" + VARINT + rb")+")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@lru_cache(maxsize=128)
|
|
16
|
+
def _pattern(tag: bytes, wire: int, value: bytes):
|
|
17
|
+
scalar = VARINT if wire == 0 else rb".{%d}" % (8 if wire == 1 else 4)
|
|
18
|
+
unit = re.escape(tag) + scalar if wire != 2 else re.escape(value)
|
|
19
|
+
return re.compile(rb"(?:" + unit + rb")+", re.DOTALL)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _varint_chunk(data: bytes, start: int, end: int, tag: bytes):
|
|
23
|
+
values = np.frombuffer(data, dtype=np.uint8, count=end - start, offset=start)
|
|
24
|
+
terminals = np.flatnonzero(values < 128)
|
|
25
|
+
count = len(terminals) // 2
|
|
26
|
+
if not count:
|
|
27
|
+
return start, 0
|
|
28
|
+
pairs = terminals[: count * 2].reshape(-1, 2)
|
|
29
|
+
starts = np.concatenate(([0], pairs[:-1, 1] + 1))
|
|
30
|
+
valid = pairs[:, 0] == starts + len(tag) - 1
|
|
31
|
+
for offset, byte in enumerate(tag):
|
|
32
|
+
# Clipping is only for malformed, too-short tags; their offset check fails.
|
|
33
|
+
valid &= values[np.minimum(starts + offset, len(values) - 1)] == byte
|
|
34
|
+
widths = pairs[:, 1] - pairs[:, 0]
|
|
35
|
+
valid &= (widths <= 10) & ((widths != 10) | (values[pairs[:, 1]] <= 1))
|
|
36
|
+
invalid = np.flatnonzero(~valid)
|
|
37
|
+
count = int(invalid[0]) if len(invalid) else count
|
|
38
|
+
return start + int(pairs[count - 1, 1]) + 1 if count else start, count
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _fixed_chunk(data: bytes, start: int, end: int, tag: bytes, wire: int):
|
|
42
|
+
size = len(tag) + (8 if wire == 1 else 4)
|
|
43
|
+
count = (end - start) // size
|
|
44
|
+
values = np.frombuffer(
|
|
45
|
+
data, dtype=np.uint8, count=count * size, offset=start
|
|
46
|
+
).reshape(-1, size)
|
|
47
|
+
valid = np.all(values[:, : len(tag)] == np.frombuffer(tag, dtype=np.uint8), axis=1)
|
|
48
|
+
invalid = np.flatnonzero(~valid)
|
|
49
|
+
count = int(invalid[0]) if len(invalid) else count
|
|
50
|
+
return start + count * size, count
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def repeated(
|
|
54
|
+
data: bytes, start: int, end: int, *, value_start: int, stop: int, wire: int
|
|
55
|
+
):
|
|
56
|
+
"""Include the already validated first field, then scan equivalent wire units."""
|
|
57
|
+
if wire == 2 and stop - start > 4096:
|
|
58
|
+
return stop, 1
|
|
59
|
+
tag = data[start:value_start]
|
|
60
|
+
pattern = _pattern(tag, wire, data[start:stop] if wire == 2 else b"")
|
|
61
|
+
probe = pattern.match(data, start, min(end, start + 64))
|
|
62
|
+
vectorize = wire != 2 and probe is not None and probe.end() - start >= 32
|
|
63
|
+
chunk_bytes = 128 if vectorize else CHUNK_BYTES
|
|
64
|
+
count, position = 0, start
|
|
65
|
+
while position < end and data.startswith(tag, position, end):
|
|
66
|
+
chunk_end = min(end, position + chunk_bytes)
|
|
67
|
+
if vectorize:
|
|
68
|
+
next_position, units = (
|
|
69
|
+
_varint_chunk(data, position, chunk_end, tag)
|
|
70
|
+
if wire == 0
|
|
71
|
+
else _fixed_chunk(data, position, chunk_end, tag, wire)
|
|
72
|
+
)
|
|
73
|
+
# Grow only after proportional progress; short alternating fields
|
|
74
|
+
# never speculate over a full 64 KiB window.
|
|
75
|
+
if next_position - position >= (chunk_end - position) // 2 and units:
|
|
76
|
+
count += units
|
|
77
|
+
position = next_position
|
|
78
|
+
chunk_bytes = min(CHUNK_BYTES, 2 * chunk_bytes)
|
|
79
|
+
continue
|
|
80
|
+
match = pattern.match(data, position, chunk_end)
|
|
81
|
+
if match is None:
|
|
82
|
+
break
|
|
83
|
+
next_position = match.end()
|
|
84
|
+
if wire == 0:
|
|
85
|
+
# Every field has one terminating tag byte and one scalar byte.
|
|
86
|
+
values = np.frombuffer(
|
|
87
|
+
data, dtype=np.uint8, count=next_position - position, offset=position
|
|
88
|
+
)
|
|
89
|
+
count += int(np.count_nonzero(values < 128)) // 2
|
|
90
|
+
else:
|
|
91
|
+
size = stop - start if wire == 2 else len(tag) + (8 if wire == 1 else 4)
|
|
92
|
+
count += (next_position - position) // size
|
|
93
|
+
position = next_position
|
|
94
|
+
return position, count
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def packed_varints(data: bytes, position: int, end: int):
|
|
98
|
+
match = PACKED.match(data, position, min(end, position + CHUNK_BYTES))
|
|
99
|
+
if match is None:
|
|
100
|
+
return position, 0
|
|
101
|
+
stop = match.end()
|
|
102
|
+
values = np.frombuffer(data, dtype=np.uint8, count=stop - position, offset=position)
|
|
103
|
+
return stop, int(np.count_nonzero(values < 128))
|