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
mlbundle/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ """Pickle-free model bundles."""
2
+
3
+ from importlib.metadata import version as _version
4
+
5
+ from .claims import ModelClaims
6
+ from .contract import (
7
+ FORMAT_NAME,
8
+ FORMAT_VERSION,
9
+ FeatureOrigin,
10
+ Library,
11
+ MemberName,
12
+ ModelKind,
13
+ SerializationFormat,
14
+ )
15
+ from .environment import OnnxInfo, Opset, TrainingEnvironment
16
+ from .errors import (
17
+ AdapterNotImplementedError,
18
+ ClaimMismatchError,
19
+ IntegrityMismatchError,
20
+ InvalidContainerError,
21
+ InvalidJSONError,
22
+ InvalidManifestError,
23
+ InvalidModelError,
24
+ InvalidSamplesError,
25
+ InvalidSchemaError,
26
+ LimitExceededError,
27
+ MissingOptionalDependencyError,
28
+ MLBundleError,
29
+ PredictionInputError,
30
+ UnsupportedFormatVersionError,
31
+ UnsupportedModelError,
32
+ )
33
+ from .inspection import inspect
34
+ from .integrity import IntegrityRecord
35
+ from .limits import ArchiveLimits
36
+ from .manifest import FeatureInfo, Manifest, MemberReferences, ModelInfo, TargetInfo
37
+ from .model_io import export, load
38
+ from .results import LoadedBundle, MetadataView
39
+
40
+ __version__ = _version("mlbundle")
41
+
42
+ __all__ = [
43
+ "FORMAT_NAME",
44
+ "FORMAT_VERSION",
45
+ "AdapterNotImplementedError",
46
+ "ArchiveLimits",
47
+ "ClaimMismatchError",
48
+ "FeatureInfo",
49
+ "FeatureOrigin",
50
+ "IntegrityMismatchError",
51
+ "IntegrityRecord",
52
+ "InvalidContainerError",
53
+ "InvalidJSONError",
54
+ "InvalidManifestError",
55
+ "InvalidModelError",
56
+ "InvalidSamplesError",
57
+ "InvalidSchemaError",
58
+ "Library",
59
+ "LimitExceededError",
60
+ "LoadedBundle",
61
+ "MLBundleError",
62
+ "Manifest",
63
+ "MemberName",
64
+ "MemberReferences",
65
+ "MetadataView",
66
+ "MissingOptionalDependencyError",
67
+ "ModelClaims",
68
+ "ModelInfo",
69
+ "ModelKind",
70
+ "OnnxInfo",
71
+ "Opset",
72
+ "PredictionInputError",
73
+ "SerializationFormat",
74
+ "TargetInfo",
75
+ "TrainingEnvironment",
76
+ "UnsupportedFormatVersionError",
77
+ "UnsupportedModelError",
78
+ "__version__",
79
+ "export",
80
+ "inspect",
81
+ "load",
82
+ ]
mlbundle/_base.py ADDED
@@ -0,0 +1,19 @@
1
+ """Shared strict model configuration and bounded field types."""
2
+
3
+ from typing import Annotated
4
+
5
+ from pydantic import BaseModel, ConfigDict, StringConstraints
6
+
7
+ ShortString = Annotated[str, StringConstraints(min_length=1, max_length=256)]
8
+ Name = Annotated[str, StringConstraints(min_length=1, max_length=1024)]
9
+
10
+
11
+ class ContractModel(BaseModel):
12
+ model_config = ConfigDict(
13
+ strict=True,
14
+ extra="forbid",
15
+ frozen=True,
16
+ validate_default=True,
17
+ hide_input_in_errors=True,
18
+ allow_inf_nan=False,
19
+ )
@@ -0,0 +1,67 @@
1
+ """Linear preflight for the expanded size/depth of native shared JSON containers."""
2
+
3
+ from typing import Any
4
+
5
+ from .errors import LimitExceededError
6
+
7
+ _SCALARS = (str, int, float, bool, type(None))
8
+ _NUMBERS = {int, float, bool, type(None)}
9
+
10
+
11
+ def _sequence_cost(item):
12
+ # C map/set classify flat numeric arrays without Python calls per element.
13
+ if len(item) >= 64 and set(map(type, item)) <= _NUMBERS:
14
+ return 1 + 2 * len(item), ()
15
+ return 1, item
16
+
17
+
18
+ def _child_cost(child: Any, measure) -> tuple[int, int]:
19
+ if type(child) in _SCALARS:
20
+ return (len(child) + 2 if type(child) is str else 1), 0
21
+ return measure(child)
22
+
23
+
24
+ def _array_cost(value: Any) -> tuple[int, int, Any]:
25
+ return value.ndim, value.size, value.flat if value.dtype.kind == "O" else ()
26
+
27
+
28
+ def bound_work(value: Any, max_bytes: int, max_depth: int) -> None:
29
+ memo: dict[int, tuple[int, int]] = {}
30
+ active: set[int] = set()
31
+
32
+ def measure(item):
33
+ array = (
34
+ type(item).__module__.split(".")[0] == "numpy"
35
+ and type(item).__name__ == "ndarray"
36
+ )
37
+ if not isinstance(item, (dict, list, tuple)) and not array:
38
+ return (len(item) + 2 if isinstance(item, str) else 1), 0
39
+ identity = id(item)
40
+ if identity in active:
41
+ raise LimitExceededError("Cyclic JSON containers exceed the nesting limit.")
42
+ if identity in memo:
43
+ return memo[identity]
44
+ active.add(identity)
45
+ depth, size = 1, 1
46
+ if array:
47
+ depth, size, children = _array_cost(item)
48
+ elif isinstance(item, dict):
49
+ size += sum(len(key) + 3 for key in item if isinstance(key, str))
50
+ children = item.values()
51
+ else:
52
+ size, children = _sequence_cost(item)
53
+ if size > max_bytes or depth > max_depth:
54
+ raise LimitExceededError("Expanded JSON size or depth exceeds the limit.")
55
+ for child in children:
56
+ child_size, child_depth = _child_cost(child, measure)
57
+ size += child_size + 1
58
+ depth = max(depth, child_depth + 1)
59
+ if size > max_bytes or depth > max_depth:
60
+ raise LimitExceededError(
61
+ "Expanded JSON size or depth exceeds the limit."
62
+ )
63
+ active.remove(identity)
64
+ memo[identity] = size, depth
65
+ return size, depth
66
+
67
+ measure(value)
@@ -0,0 +1,159 @@
1
+ """Native CBM adapter; exact CatBoost wrappers and no reader-side file I/O."""
2
+
3
+ from pathlib import Path
4
+ from tempfile import TemporaryDirectory
5
+ from typing import Any, cast
6
+
7
+ import catboost as cb
8
+ from pydantic import JsonValue
9
+
10
+ from mlbundle.canonical import canonical_value
11
+ from mlbundle.claims import ClassLabels, ModelClaims
12
+ from mlbundle.contract import Library, MemberName, ModelKind
13
+ from mlbundle.declarations import ExportDeclarations
14
+ from mlbundle.errors import InvalidModelError, MLBundleError, UnsupportedModelError
15
+
16
+ from .native import decode
17
+ from .prediction import Predictor
18
+ from .scope import CLASSIFIERS, validate
19
+ from .state import commitment, feature_importances, json_value, snapshot
20
+
21
+
22
+ class Adapter:
23
+ library = Library.CATBOOST
24
+ model_member = MemberName.CBM
25
+
26
+ def recognizes(self, model: object) -> bool:
27
+ return isinstance(model, cb.CatBoost)
28
+
29
+ def prepare_export(self, model: Any, declarations: ExportDeclarations) -> object:
30
+ try:
31
+ validate(model)
32
+ if declarations.class_labels is not None:
33
+ raise UnsupportedModelError(
34
+ reason="CatBoost already carries class labels; class_labels is unsupported."
35
+ )
36
+ self.extract_claims(model)
37
+ return model
38
+ except MLBundleError:
39
+ raise
40
+ except Exception:
41
+ raise UnsupportedModelError(
42
+ reason="CatBoost export preparation failed."
43
+ ) from None
44
+
45
+ def serialize(self, model: Any) -> bytes:
46
+ try:
47
+ validate(model)
48
+ # Public save_model accepts paths only; export owns this temporary file.
49
+ with TemporaryDirectory(prefix="mlbundle-catboost-") as directory:
50
+ path = Path(directory) / "model.cbm"
51
+ model.save_model(str(path), format="cbm")
52
+ data = path.read_bytes()
53
+ decode(data)
54
+ return data
55
+ except MLBundleError:
56
+ raise
57
+ except Exception:
58
+ raise UnsupportedModelError(
59
+ reason="CatBoost CBM serialization failed."
60
+ ) from None
61
+
62
+ def deserialize(self, data: bytes) -> object:
63
+ try:
64
+ state = decode(data)
65
+ cls = (
66
+ cb.CatBoostClassifier
67
+ if state.loss in CLASSIFIERS
68
+ else cb.CatBoostRegressor
69
+ )
70
+ # blob loading does not restore _init_params. Poisson's default predict
71
+ # selects Exponent from that wrapper field, so bind the validated loss.
72
+ model: Any = cls(loss_function=state.loss)
73
+ model.load_model(blob=data)
74
+ model._n_features_in = state.width
75
+ actual = snapshot(model)
76
+ if commitment(state) != commitment(actual):
77
+ raise InvalidModelError(
78
+ "CatBoost state does not survive native construction."
79
+ )
80
+ validate(model)
81
+ return model
82
+ except MLBundleError:
83
+ raise
84
+ except Exception:
85
+ raise InvalidModelError("Invalid CatBoost CBM payload.") from None
86
+
87
+ def extract_claims(self, model: Any) -> ModelClaims:
88
+ try:
89
+ validate(model)
90
+ state = snapshot(model)
91
+ classifier = type(model) is cb.CatBoostClassifier
92
+ if (
93
+ classifier != (state.loss in CLASSIFIERS)
94
+ or model.n_features_in_ not in (0, state.width)
95
+ or model.tree_count_ != len(state.native["tree_depths"])
96
+ ):
97
+ raise InvalidModelError(
98
+ "CatBoost wrapper and native dimensions or kind disagree."
99
+ )
100
+ labels = (
101
+ cast(ClassLabels, canonical_value([model.classes_]))
102
+ if classifier
103
+ else None
104
+ )
105
+ if classifier and (
106
+ labels is None
107
+ or len(labels[0])
108
+ != (state.dimension if state.loss == "MultiClass" else 2)
109
+ ):
110
+ raise InvalidModelError(
111
+ "CatBoost classes and native dimensions disagree."
112
+ )
113
+ loss = model.get_params().get("loss_function")
114
+ if loss is not None and loss.split(":", 1)[0] != state.loss:
115
+ raise InvalidModelError("CatBoost wrapper and native loss disagree.")
116
+ return ModelClaims(
117
+ estimator_class=type(model).__name__,
118
+ kind=ModelKind.CLASSIFIER if classifier else ModelKind.REGRESSOR,
119
+ hyperparameters={
120
+ "parameters": canonical_value(model.get_all_params()),
121
+ "tree_count": model.tree_count_,
122
+ "best_iteration": model.get_best_iteration(),
123
+ "scale_and_bias": canonical_value(model.get_scale_and_bias()),
124
+ "probability_threshold": model.get_probability_threshold()
125
+ if classifier
126
+ else None,
127
+ },
128
+ state_sha256=commitment(state),
129
+ n_features=state.width,
130
+ n_classes=None if labels is None else [len(labels[0])],
131
+ n_outputs=1,
132
+ feature_names=model.feature_names_,
133
+ feature_dtypes=None,
134
+ class_labels=labels,
135
+ )
136
+ except MLBundleError:
137
+ raise
138
+ except Exception:
139
+ raise InvalidModelError("Invalid CatBoost model claims.") from None
140
+
141
+ def extract_structure(self, model: Any) -> dict[str, JsonValue]:
142
+ try:
143
+ validate(model)
144
+ state = snapshot(model)
145
+ return cast(
146
+ dict[str, JsonValue],
147
+ {
148
+ "family": "catboost",
149
+ "native_model": json_value(state.native),
150
+ "feature_importances": feature_importances(model, state.width),
151
+ },
152
+ )
153
+ except MLBundleError:
154
+ raise
155
+ except Exception:
156
+ raise InvalidModelError("Invalid CatBoost model structure.") from None
157
+
158
+ def predictor(self, model: object) -> Predictor:
159
+ return Predictor(model, self.extract_claims(model))
@@ -0,0 +1,22 @@
1
+ """Bound structure expansion before native parsing or allocating Python lists."""
2
+
3
+ from typing import Any
4
+
5
+ from mlbundle.errors import LimitExceededError
6
+ from mlbundle.limits import STRUCTURE_BYTES
7
+
8
+
9
+ def structure_budget(native: dict[str, Any]) -> None:
10
+ # Worst finite binary64 JSON token plus separator is 25 bytes. The bound
11
+ # intentionally rejects some compact-value models before list allocation.
12
+ size = 4096
13
+ for value in native.values():
14
+ if hasattr(value, "dtype"):
15
+ size += value.size * (25 if value.dtype.kind == "f" else 12)
16
+ for feature in native["float_features"]:
17
+ size += 256 + 6 * len(feature["name"].encode("utf-8"))
18
+ size += feature["borders"].size * 25
19
+ if size > STRUCTURE_BYTES:
20
+ raise LimitExceededError(
21
+ "CatBoost structure allocation budget exceeds the limit."
22
+ )
@@ -0,0 +1,104 @@
1
+ """Checked views of the closed CatBoost 1.2.10 CBM FlatBuffer schema."""
2
+
3
+ import struct
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+ from mlbundle.errors import InvalidModelError
9
+
10
+
11
+ class Buffer:
12
+ def __init__(self, data: memoryview) -> None:
13
+ self.data = data
14
+ self.work = 0
15
+
16
+ def span(self, position: int, size: int) -> memoryview:
17
+ if position < 0 or size < 0 or size > len(self.data) - position:
18
+ raise InvalidModelError("Truncated CatBoost CBM buffer.")
19
+ return self.data[position : position + size]
20
+
21
+ def number(self, position: int, fmt: str) -> Any:
22
+ return struct.unpack("<" + fmt, self.span(position, struct.calcsize(fmt)))[0]
23
+
24
+ def indirect(self, position: int) -> int:
25
+ offset = self.number(position, "I")
26
+ if offset < 4:
27
+ raise InvalidModelError("Invalid CatBoost CBM offset.")
28
+ target = position + offset
29
+ self.span(target, 4)
30
+ return target
31
+
32
+ def table(self, position: int, fields: int) -> "Table":
33
+ self.work += 1
34
+ if self.work > 200_000:
35
+ raise InvalidModelError("CatBoost CBM table work exceeds the limit.")
36
+ return Table(self, position, fields)
37
+
38
+ def string(self, position: int, cap: int) -> str:
39
+ start = self.indirect(position)
40
+ size = self.number(start, "I")
41
+ if size > cap or self.number(start + 4 + size, "B") != 0:
42
+ raise InvalidModelError("Invalid CatBoost CBM string length.")
43
+ return bytes(self.span(start + 4, size)).decode("utf-8")
44
+
45
+
46
+ class Table:
47
+ def __init__(self, buffer: Buffer, position: int, fields: int) -> None:
48
+ self.buffer, self.position = buffer, position
49
+ vtable = position - buffer.number(position, "i")
50
+ size = buffer.number(vtable, "H")
51
+ self.size = buffer.number(vtable + 2, "H")
52
+ if size < 4 or size % 2 or size > 4 + 2 * fields or self.size < 4:
53
+ raise InvalidModelError("Unknown or invalid CatBoost CBM table fields.")
54
+ buffer.span(position, self.size)
55
+ buffer.span(vtable, size)
56
+ self.offsets = [buffer.number(vtable + i, "H") for i in range(4, size, 2)]
57
+ if any(offset and not 4 <= offset < self.size for offset in self.offsets):
58
+ raise InvalidModelError("Invalid CatBoost CBM field offset.")
59
+
60
+ def field(self, index: int, size: int = 4) -> int | None:
61
+ offset = self.offsets[index] if index < len(self.offsets) else 0
62
+ if not offset:
63
+ return None
64
+ if size > self.size - offset:
65
+ raise InvalidModelError("Invalid CatBoost CBM field size.")
66
+ return self.position + offset
67
+
68
+ def scalar(self, index: int, fmt: str, default: Any = 0) -> Any:
69
+ position = self.field(index, struct.calcsize(fmt))
70
+ return default if position is None else self.buffer.number(position, fmt)
71
+
72
+ def string(self, index: int, cap: int) -> str:
73
+ position = self.field(index)
74
+ return "" if position is None else self.buffer.string(position, cap)
75
+
76
+ def child(self, index: int, fields: int) -> "Table":
77
+ position = self.field(index)
78
+ if position is None:
79
+ raise InvalidModelError("Missing CatBoost CBM table.")
80
+ return self.buffer.table(self.buffer.indirect(position), fields)
81
+
82
+ def vector(self, index: int, dtype: str, cap: int) -> np.ndarray:
83
+ position = self.field(index)
84
+ kind = np.dtype(dtype)
85
+ if position is None:
86
+ return np.empty(0, dtype=kind)
87
+ start = self.buffer.indirect(position)
88
+ count = self.buffer.number(start, "I")
89
+ if count > cap:
90
+ raise InvalidModelError("CatBoost CBM vector count exceeds the limit.")
91
+ return np.frombuffer(self.buffer.span(start + 4, count * kind.itemsize), kind)
92
+
93
+ def tables(self, index: int, fields: int, cap: int) -> list["Table"]:
94
+ offsets = self.vector(index, "<u4", cap)
95
+ if not len(offsets):
96
+ return []
97
+ position = self.field(index)
98
+ if position is None:
99
+ raise InvalidModelError("Missing CatBoost CBM vector.")
100
+ start = self.buffer.indirect(position) + 4
101
+ return [
102
+ self.buffer.table(self.buffer.indirect(start + 4 * i), fields)
103
+ for i in range(len(offsets))
104
+ ]