diff --git a/AGENTS.md b/AGENTS.md index c006fe9..f90c248 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,11 +218,11 @@ claims. `native_model` is an OnnxModel with original bytes and a CPU session. Kind is inferred from supported outputs; native labels are verified, absent labels use count-checked declarations. Structure is null. See the normative ONNX section linked from `docs/format.md` for exact layouts and resource bounds. -CatBoost supports exact numeric single-target symmetric classifiers/regressors, with native state commitments and in-memory loading. Sklearn pipelines containing XGBoost/CatBoost estimators are rejected; export the estimator directly. -The closed member list is: required `manifest.json`, exactly -one of `model.skops`, `model.ubj`, `model.cbm` or `model.onnx`, optional `schema.json`, and +CatBoost supports exact numeric single-target symmetric classifiers/regressors, with native state commitments and in-memory loading. Exact outer sklearn Pipelines ending in XGBoost/CatBoost use separately checked skops/native components; other native placements remain rejected. +The closed member list is: required `manifest.json`, one +native member, or declared `model.skops` + `model.ubj`/`model.cbm` pair, optional `schema.json`, and `structure.json` when the supported model has extractable structure. Optional -`samples.json` requires explicit user intent. No other members are allowed. +`samples.json` requires explicit user intent. The ceiling is six members; no others are allowed. The manifest must record format name/version, bundle id, UTC creation time and writer version; model library, estimator class, classifier/regressor kind, @@ -261,11 +261,12 @@ its kind/count inference remains ONNX-owned. Keep native behavior in adapters; f - `src/mlbundle/contract.py` — closed identifiers and native member/format pairing. - `src/mlbundle/limits.py` — shared archive ceilings and specific member caps. - `src/mlbundle/canonical.py` and `_canonical_work.py` — deterministic JSON and bounded decoding/expansion. -- `src/mlbundle/model_io.py` — library-independent public export/load, manifest construction and round-trip verification. +- `src/mlbundle/model_io.py`, `_native_io.py`, `_export_manifest.py` — public I/O, native round trips and manifest construction. +- `src/mlbundle/_composite/`, `_pipeline_contract.py` — strict recipe, separate I/O, closed reconstruction/claims/prediction and input/inner boundaries. Read `docs/format-pipeline.md` when changing composites. - `src/mlbundle/training.py` — lazy local DataFrame alignment and schema inference. - `src/mlbundle/schema.py` and `_schema_fields.py` — closed schema/report declarations and reader cross-checks without mlschema. - `src/mlbundle/samples.py` — explicit feature-row selection and bounded cell conversion. -- `src/mlbundle/_sklearn/` — closed dtype/type/function trust, skops loading, MLP state, parameter/claim extraction, native structure, nested payload bounds and fitted categorical prediction. +- `src/mlbundle/_sklearn/` — owned trust/skops/state/parameters/structure, transformer route and closed fitted-width/name proofs. Composite commitments/native structure bind the rebuilt Pipeline; all-sklearn structure stays null. - `src/mlbundle/_xgboost/` — closed wrappers/objectives, native UBJSON preflight/loading, label binding, recoverable config, complete tree structure and validated prediction. - `src/mlbundle/_catboost/` — closed numeric symmetric wrappers/losses, CBM preflight/in-memory loading, complete native state/structure and validated prediction. - `src/mlbundle/_onnx/` — bounded protobuf/graph validation, unchanged bytes, native signatures/labels, CPU sessions and tabular prediction. @@ -276,13 +277,12 @@ its kind/count inference remains ONNX-owned. Keep native behavior in adapters; f - `src/mlbundle/environment.py` — training declarations and distribution metadata capture. - `src/mlbundle/adapters.py` and `_prediction_input.py` — adapter/predictor protocols, output types and shared input checks. - `src/mlbundle/registry.py` — closed lazy resolution of `_sklearn`, `_xgboost`, `_catboost` and `_onnx` adapters. -- `src/mlbundle/results.py` — metadata-only and loaded reader result types. - `src/mlbundle/_container_rules.py` — shared source type, closed member sets/order and ceilings. - `src/mlbundle/_zip_directory.py` — bounded ZIP/ZIP64 directory and extra-field validation. - `src/mlbundle/_zip_members.py` — local headers/descriptors and bounded decompression with CRC. - `src/mlbundle/container.py` — source bounds, validated in-memory reading and integrity checks. - `src/mlbundle/writing.py` — integrity ownership, deterministic ZIP and atomic publication. -- `src/mlbundle/inspection.py` — public metadata-only inspection without adapter resolution. +- `src/mlbundle/inspection.py`, `results.py` — metadata-only inspection and result records without adapter resolution. - `docs/format.md` — version 1 wire specification; linked container, adapter/structure, ONNX and schema/sample sections are normative. - `tests/` — one file per feature, with success and error cases together. - `scripts/package_smoke.py` — fixture, isolated install and artifact-content checks. diff --git a/README.md b/README.md index 6f8f25d..f2dbd77 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,8 @@ from sklearn.tree import DecisionTreeClassifier from mlbundle import export, inspect, load rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] -model = DecisionTreeClassifier(random_state=7).fit( - rows, ["cold", "warm", "cold", "warm"] -) +model = DecisionTreeClassifier(random_state=7).fit(rows, ["cold", "warm"] * 2) export(model, "trained.mlbundle", feature_names=["temperature", "humidity"]) - bundle = load("trained.mlbundle") assert isinstance(bundle.native_model, DecisionTreeClassifier) assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() @@ -63,12 +60,7 @@ from mlbundle import export, load rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] model = XGBClassifier(n_estimators=3, max_depth=2, n_jobs=1).fit(rows, [0, 1, 0, 1]) -export( - model, - "xgboost.mlbundle", - feature_names=["temperature", "humidity"], - class_labels=[["cold", "warm"]], -) +export(model, "xgboost.mlbundle", class_labels=[["cold", "warm"]]) bundle = load("xgboost.mlbundle") assert isinstance(bundle.native_model, XGBClassifier) assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() @@ -76,10 +68,10 @@ assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() assert bundle.class_labels == [["cold", "warm"]] ``` -The adapter binds labels/settings to a native **copy**, leaving your model -unchanged. Load verifies that binding, not historical truth. Omitted labels use -encoded indices or an existing binding; native `predict` still returns indices. -Sklearn, CatBoost and regressors reject `class_labels=`. +Labels/settings bind to a native **copy**. Load verifies consistency, not history. +Omitted labels use encoded indices/an existing binding; native predict stays encoded. +Standalone sklearn, CatBoost and regressors reject `class_labels=`; composites +delegate labels to the final native adapter. Only recoverable native configuration is recorded; learning rate, max depth, seed and thread count do not survive native XGBoost I/O and are not reconstructed. @@ -103,14 +95,45 @@ assert bundle.class_labels == [["cold", "warm"]] assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() ``` -Export a pipeline's estimator as `export(pipeline.named_steps["est"], ...)`. -CatBoost pipelines, `class_labels=`, categorical/text/embedding features, +`class_labels=`, categorical/text/embedding features, Depthwise/Lossguide, custom Python objectives/metrics and other losses are rejected. Classifier losses: Logloss, CrossEntropy, MultiClass. Regressors: RMSE, MAE, Quantile, Huber, Poisson, MAPE. Complete trees/borders/NaN handling/importances are verified on load. [The CatBoost contract](docs/format-catboost.md) details wrapper recovery, native-parser limits and the private in-memory serializer. +## Composite pipelines + +Install `[sklearn,xgboost]` or `[sklearn,catboost]`. Export the fitted Pipeline +as it is; transformers use skops and the final estimator uses its native adapter: + +```python +from sklearn.pipeline import make_pipeline +from sklearn.preprocessing import StandardScaler +from xgboost import XGBClassifier +from mlbundle import export, load + +rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] +model = make_pipeline(StandardScaler(), XGBClassifier(n_estimators=3, n_jobs=1)) +model.fit(rows, [0, 1, 0, 1]) +export(model, "pipeline.mlbundle", class_labels=[["cold", "warm"]]) +bundle = load("pipeline.mlbundle") +assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() +assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() +assert bundle.class_labels == [["cold", "warm"]] +``` + +Load rebuilds a real Pipeline after verifying both parts. Bundle probability labels +use XGBoost's bound originals; native classes/predict stay encoded. CatBoost keeps +native labels. Prefixes include scalers, PCA, SimpleImputer (indicators/empty columns), +KBinsDiscretizer, PolynomialFeatures, univariate selectors, OneHotEncoder/OrdinalEncoder, +identity/closed NumPy FunctionTransformer, nested native-free Pipeline and ColumnTransformer. +Each needs a fitted, data-free width proof. Unknown widths, FeatureUnion/KNNImputer, +slice/callable/scalar selectors, caching, metadata routing and other native placements +remain rejected. Step names/outer options are declarations; trees address transformed +features, without original-feature attribution. Schema/dtypes/samples describe original +inputs; six members permit schema plus explicit samples. See [the pipeline contract](docs/format-pipeline.md). + ## Existing ONNX files Install the `onnx` extra. This example assumes your existing `trained.onnx` is @@ -148,11 +171,8 @@ from sklearn.tree import DecisionTreeClassifier from mlbundle import export, inspect, load training = pd.DataFrame( - { - "temperature": [0.0, 1.0, 0.1, 0.9], - "humidity": [1.0, 0.0, 0.9, 0.1], - "target": ["cold", "warm", "cold", "warm"], - } + [[0.0, 1.0, "cold"], [1.0, 0.0, "warm"]] * 2, + columns=["temperature", "humidity", "target"], ) features = training.drop(columns="target") model = DecisionTreeClassifier(random_state=7).fit(features, training["target"]) @@ -162,15 +182,8 @@ assert view.manifest.features.dtypes == ["float64", "float64"] assert view.schema["reports"][0]["labels"] == ["cold", "warm"] assert view.samples is None assert load("schema.mlbundle").schema == view.schema - # Samples are real data: explicitly include the first two feature rows. -export( - model, - "sampled.mlbundle", - training_data=training, - target_name="target", - sample_rows=2, -) +export(model, "sampled.mlbundle", training_data=features, sample_rows=2) assert inspect("sampled.mlbundle").samples == features.head(2).to_dict("records") ``` @@ -196,8 +209,6 @@ metadata or structure matches the model. `load` additionally deserializes throug the closed adapter and recomputes native claims and structure before returning the model and predictor. Export checks the same native round trip before writing. -After the sklearn example above, these commands work: - ```shell mlbundle inspect trained.mlbundle mlbundle inspect trained.mlbundle --json @@ -212,61 +223,45 @@ dependency rejection (`MLBundleError`), **2** usage/I/O failure. ## Supported models and limits -Support requires a faithful closed-adapter round trip; a catalog entry does not guarantee every fitted configuration exports. - - **Sklearn:** fitted classifiers exposing `predict_proba` and regressors with recoverable feature/output counts. Tested families include trees/ensembles, linear/regularized/robust/GLM models, SVMs, PLSRegression, naive Bayes, LDA/QDA, dense brute-force neighbors, NearestCentroid, MLP and MultiOutput/RegressorChain. - MLPClassifier/Regressor (adam, sgd, lbfgs) retain optimizer state, alone/in pipelines, with null structure and complete commitments; see [MLP tests](tests/test_sklearn_mlp.py). SimpleImputer/KNNImputer, scalers, KBinsDiscretizer, categorical encoders, univariate selectors and closed NumPy FunctionTransformer functions round-trip, including nested ColumnTransformer pipelines; see [the exact lists](docs/format-state.md). - Pipeline structure stays null because format 1 does not map transformed features. See + All-sklearn Pipeline structure stays null; composite trees expose the inner boundary. See [tested configurations](tests/test_sklearn_families.py) and [core round trips/rejections](tests/test_sklearn.py). - **Sklearn rejections:** custom estimators/subclasses, functions/types outside the closed lists (including lambdas and user functions), compound fitted dtypes, classifiers without - probabilities, multilabel/ClassifierChain layouts, XGBoost/CatBoost pipeline steps, + probabilities, multilabel/ClassifierChain layouts, unsupported native placements, and untrusted nested storage such as fitted sparse neighbor arrays. HistGradientBoosting uses untrusted `TreePredictor` storage and is rejected. - **Sklearn versions:** the closed class/attribute catalog is audited/tested against **1.9.1 with skops 0.16.0**, not every version allowed by `>=1.6`. - Unknown classes/attributes fail export/load. The committed sklearn version - must match at export/load; other matching-version configurations can work - when they satisfy the catalog. No permissive fallback or advertised broader - range. See [state ownership](docs/format-state.md). -- **XGBoost:** exact XGBClassifier/XGBRegressor, one target, native `gbtree` - storage (including XGBoost 3.4.1 `dart` dropout normalized to gbtree). - Classifiers: `binary:logistic`, `multi:softprob`. Regressors: - `reg:squarederror`, `reg:absoluteerror`, `reg:pseudohubererror`, - `reg:squaredlogerror`, `reg:logistic`, `count:poisson`, `reg:gamma`, - `reg:tweedie`. Raw boosters, RF wrappers, subclasses, gblinear, legacy dart - storage, ranking/survival/custom objectives and multiple targets are rejected. - At most 100,000 trees and depth 14; exact native state and complete dumps are - required. See [the XGBoost mapping](docs/format-adapters.md#implemented-xgboost-mapping). -- **ONNX:** rank-two numeric matrix or width-one inputs, fixed width, - float32/float64/int32/int64; dynamic or one-row batches. One target: float - regression `[batch]`/`[batch,1]` or tensor/ZipMap probabilities, optional labels. - Classification requires native graph evidence. Ambiguous/image/string layouts, - larger fixed batches, multiple targets, external tensors, custom domains/local - functions and training/runtime configuration fail before CPU sessions. - Tested with ONNX 1.23.0/Runtime 1.30.0; operators/opsets must pass both. - Large unpacked ensembles/ZipMap and multiline producer versions work. One wire - validation per export/load; field/scalar ceilings follow 512 MiB, producer strings - allow 65,536 characters. See [ONNX bounds](docs/format-onnx.md). - -Archive ceilings: **5 members**, **512 MiB** archive/compressed/member bytes, + Unknown state fails; export/load sklearn versions must match. Configurations + must satisfy the catalog and round trip; see [state ownership](docs/format-state.md). +- **XGBoost:** exact XGBClassifier/XGBRegressor, one target, native gbtree + (including XGBoost 3.4.1 dart normalized to gbtree). Closed probability/regression + objectives; raw boosters, RF wrappers, subclasses, ranking/survival/custom + objectives and unsupported storage are rejected. At most 100,000 trees/depth 14, + with complete state/dumps; see [the mapping](docs/format-adapters.md#implemented-xgboost-mapping). +- **ONNX:** fixed-width rank-two numeric inputs; one regression/probability target, + optional native labels and dynamic/one-row batches. Unsupported layouts, + external data/custom domains/functions and runtime/training configuration fail + before CPU sessions. Tested with ONNX 1.23.0/Runtime 1.30.0; see [ONNX bounds](docs/format-onnx.md). + +Archive ceilings: **6 members**, **512 MiB** archive/compressed/member bytes, **1 GiB** aggregate uncompressed bytes, ratio **200**, JSON depth **32**. Manifest: **8 MiB**; structure: **256 MiB**; schema/samples: **16 MiB each**. Features/classes: at most **100,000**; outputs: **1,024** (XGBoost/CatBoost/ONNX: one). -Callers may lower `ArchiveLimits`, never raise these ceilings. Native work has -additional [state](docs/format-state.md) and [ONNX](docs/format-onnx.md) bounds; -these byte budgets do not cap total process memory, prediction rows or CPU. +Callers may only lower `ArchiveLimits`. Native [state](docs/format-state.md)/[ONNX](docs/format-onnx.md) +bounds do not cap process memory, prediction rows or CPU. ## Bundle contents and security -Every bundle contains `manifest.json` and exactly one of `model.skops`, -`model.ubj`, `model.cbm`, `model.onnx`. Optional members are `schema.json`, `structure.json` +Every bundle contains `manifest.json` and one native member, or the declared +`model.skops` + `model.ubj`/`model.cbm` composite pair. Single-step composites use only the leaf. Optional members are `schema.json`, `structure.json` (required when the adapter extracts structure) and explicitly requested `samples.json`. No training DataFrame or arbitrary files are included. The manifest records identity/time/writer, model parameters/counts/state, diff --git a/SECURITY.md b/SECURITY.md index 300a081..93c422c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -101,9 +101,16 @@ make the native parser a sandbox. The installed skops load path builds nodes, audits them and only then constructs objects; NumPy buffers use `allow_pickle=False`. No pickle/joblib loading fallback is used. Skops imports `pickle` only to recognize an unrelated save-side exception; -this does not deserialize pickle bytes. Custom estimators/functions and XGBoost/CatBoost -estimators inside sklearn pipelines are rejected. CatBoost rejections name the unsupported -type and advise exporting the estimator directly; neither adapter unwraps pipelines. HistGradientBoosting's separate +this does not deserialize pickle bytes. Custom estimators/functions and unsupported +native placements are rejected. Exact outer Pipelines ending in an admitted native +wrapper split transformer skops bytes from UBJSON/CBM, retaining each adapter's +preflight, trust, state/claims and structure checks; no booster type enters skops. +Closed fitted-state width proofs never use data. Shared bounded names/parameters/ +commitments and inner tree coordinates are recomputed before closed Pipeline +reconstruction is returned. Cache/metadata routing/unknown outer state fail; +step names and outer options are declarations, not authenticated training history. +See [the composite contract](docs/format-pipeline.md). +HistGradientBoosting's separate `TreePredictor` storage is also excluded. Broadening the supplemental list requires source review and hostile-payload tests, never author approval or bundle metadata. @@ -111,8 +118,8 @@ Both export and load recompute the same model claims and structures and compare canonical bytes, preserving class-label types and order. Export additionally compares the original and reloaded native models before publication. Loaded results contain the native model for runtime explanations; mutating it invalidates the -relationship to the verified metadata. Pipeline structure is null because v1 -does not describe its input transformations. +relationship to the verified metadata. All-sklearn Pipeline structure stays null; +composites expose checked native trees in transformed feature coordinates. The closed sklearn class/attribute catalog is audited against 1.9.1 with skops 0.16.0. Other versions/configurations can be rejected for unknown state. The diff --git a/docs/format-adapters.md b/docs/format-adapters.md index 0b50d30..986a3be 100644 --- a/docs/format-adapters.md +++ b/docs/format-adapters.md @@ -4,6 +4,7 @@ Normative sections of [the format contract](format.md). ## `structure.json` +Composites add only the checked `pipeline` boundary sibling defined in [pipelines](format-pipeline.md); native indices address transformed features. Structure is never embedded in the manifest. It is a JSON object with required `family` and `feature_importances` fields, plus exactly the family fields below. `feature_importances` is null or an ordered numeric array of length `n_features`. @@ -40,7 +41,7 @@ ensemble. Unsupported or oversized mandatory structure fails export rather than being silently omitted. ONNX graph visualizations and additional structure families require a deliberate future contract change. -Sklearn `Pipeline` returns null structure in version 1. Its final estimator +All-scikit-learn `Pipeline` returns null structure in version 1. Its final estimator operates on transformed features; version 1 has no input transformation mapping, so no final-step tree or coefficient member is emitted. Claims, names and predictions still describe the complete pipeline; `model.state_sha256` also @@ -91,11 +92,13 @@ the exact score/NumPy function names in [the state contract](format-state.md). It adds no arbitrary class, callable or I/O constructor. Skops' own defaults remain part of the dependency trust boundary. Dumped bytes are audited with `get_untrusted_types`; every type outside the list is rejected -at export and independently at load. XGBoost estimators inside sklearn pipelines -are rejected (`xgboost.core.Booster`, `xgboost.sklearn.XGBClassifier` are untrusted). -CatBoost pipeline steps are also rejected, with unsupported types listed and -an instruction to export `pipeline.named_steps["est"]` directly. Native adapters -own standalone XGBoost and CatBoost loading; neither is implicitly unwrapped. +at export and independently at load. Exact outer Pipelines ending in a supported +XGBoost/CatBoost wrapper use the [composite route](format-pipeline.md): prefix-only +skops, native leaf bytes through the unchanged preflight/adapter, and closed Pipeline +reconstruction. Transformer parameters/state, data-free widths/names, leaf claims +and extended native structure are recomputed. Other native placements fail with +bounded unsupported types. Native adapters never receive skops-contained booster +state, and their trusted types stay excluded. All-sklearn pipelines stay unchanged. MLPClassifier/MLPRegressor support adam, sgd and lbfgs, alone or in a Pipeline. MLP structure is null; layer weights and complete fitted/optimizer state are bound by the manifest state commitment. Optimizers permit ObjectNode only diff --git a/docs/format-catboost.md b/docs/format-catboost.md index 9d2bd2f..fdba021 100644 --- a/docs/format-catboost.md +++ b/docs/format-catboost.md @@ -25,9 +25,10 @@ and independently before native load. Categorical CBM round trips work natively, but CTR storage has an unchecked native loader and needs its own preflight. There is no implicit input encoding or model conversion in this adapter. -A sklearn Pipeline containing CatBoost is unsupported. The skops trust list -does not admit CatBoost; export lists the unsupported types and instructs the -caller to export `pipeline.named_steps["est"]` directly. No implicit unwrapping. +An exact outer sklearn Pipeline may end in an admitted CatBoost wrapper via the +[composite representation](format-pipeline.md). The prefix uses checked skops bytes; +CBM passes through this adapter's unchanged preflight/claims/structure checks. +Other native placements are rejected; CatBoost remains outside skops trust. `class_labels=` is rejected even if equal: native `classes_` owns the labels. Class arrays retain native scalar types and probability-column order and are output-major, including `[[0, 1]]` for binary classification. Int and string @@ -36,7 +37,8 @@ binary/multiclass labels have tested exact round trips. ## Native I/O and wrapper recovery Library/serialization/member pairing is `catboost` / `cbm` / `model.cbm`. -There is exactly one native member per bundle; no pickle or skops fallback. +Standalone bundles have one native member; composites may also have a skops prefix. +CBM never goes through skops; there is no pickle fallback. Public `save_model(path, format="cbm")` writes in an export-only temporary directory that is removed before publication. Public saving has no in-memory API. Public `load_model(blob=data)` consumes bytes without reader-side disk I/O. diff --git a/docs/format-container.md b/docs/format-container.md index af98118..afdbb54 100644 --- a/docs/format-container.md +++ b/docs/format-container.md @@ -56,7 +56,7 @@ One MiB is 1,048,576 bytes. `ArchiveLimits` defines these defaults and ceilings: | Limit | Maximum | | --- | ---: | -| Member count, including manifest | 5 (minimum configured value 2) | +| Member count, including manifest | 6 (minimum configured value 2) | | Entire archive, including headers | 512 MiB | | Sum of compressed member sizes | 512 MiB | | Any uncompressed member | 512 MiB | @@ -79,6 +79,13 @@ Callers may lower archive limits, never raise the ceilings in version 1. Consumers still need process memory, CPU and prediction limits; a 1 GiB byte budget is not a guarantee that a parsed model uses at most 1 GiB of RAM. +Before manifest parsing, admit one model member or the two candidates +`model.skops` + `model.ubj`/`model.cbm`. Other combinations fail. After strict +manifest parsing, the declared standalone/composite recipe and actual members +must agree exactly; undeclared pairs fail. [Pipeline rules](format-pipeline.md) +own the one-step exception and required structure. Six members accommodate +manifest, two models, structure, schema and explicit samples. + ### Container functions and inspection The internal functions are `mlbundle.container.read_container(source, *, diff --git a/docs/format-pipeline.md b/docs/format-pipeline.md new file mode 100644 index 0000000..9fcf174 --- /dev/null +++ b/docs/format-pipeline.md @@ -0,0 +1,224 @@ +# Version 1 composite pipelines + +Normative section of [the format contract](format.md). This pre-release format +extension preserves reading of standalone bundles. Older readers reject composites. + +## Representation and declarations + +Only an exact outer `sklearn.pipeline.Pipeline` ending in a supported exact +XGBClassifier/XGBRegressor or CatBoostClassifier/CatBoostRegressor is composite. +All-scikit-learn pipelines retain their single skops member and null structure. +Every native adapter's standalone scope, preflight, claims and structure rules apply. +No native estimator or booster type is added to the skops trust list. + +`model.library` is `sklearn`, `serialization` is `pipeline`, `estimator_class` +is `Pipeline`, and `state_sha256` is null. Kind, class/output counts and target +labels equal the native component claims. `model.n_features` and manifest +features describe original inputs. Outer hyperparameters have exactly `memory` +(null), `verbose` (boolean), `transform_input` (null). ONNX fields are absent/null. + +`model.pipeline` is required exactly for this serialization. Its strict frozen +records reject unknown fields and coercions. The record contains: + +- `step_names`: 1–128 unique strings, 1–256 characters, without `__` or any + of `steps`, `memory`, `verbose`, `transform_input`. Names are data. +- `transformers`: null for one step; otherwise `member="model.skops"`, + `estimator_class="Pipeline"`, `hyperparameters` and non-null `state_sha256`. + Parameters use the existing sklearn projection, including `$estimator_ref` + in constructor collections and full records in named deep keys, with the + existing 4,096-key, 1 MiB and depth-16 bounds. +- `estimator`: `library` (`xgboost` or `catboost`), paired `serialization` and + `member`, plus the complete shared `ModelClaims` record under `claims`. + No ONNX fields or absent classifier labels are admitted. +- `transformed_features`: `n_features` (1–100,000), ordered unique `names` + (1–1,024 characters each), `origin` (`transformer`, `model`, `input`, `generated`). + Width equals the native component feature count. Generated names are exactly + `transformed_feature_1` through `transformed_feature_N`. + +Final step names and outer options are reconstruction declarations. Prefix +names/order/options are also verified against the loaded skops member. Rebuilding +declarations preserves them; it does not authenticate their historical values. +No manifest class name, parameter projection or step name selects loading code. + +Example (native claims and transformer parameters shortened for readability): + +```json +{ + "library":"sklearn", "serialization":"pipeline", "estimator_class":"Pipeline", + "kind":"classifier", "n_features":3, "n_classes":[2], "n_outputs":1, + "state_sha256":null, + "hyperparameters":{"memory":null,"verbose":false,"transform_input":null}, + "pipeline":{ + "step_names":["scale","est"], + "transformers":{ + "member":"model.skops", "estimator_class":"Pipeline", + "hyperparameters":{"steps":[["scale",{"$estimator_ref":"StandardScaler"}]]}, + "state_sha256":"506feb7a8fd45118e204d24081be7ec4d8b8998e73fa4581863741fb1852533f" + }, + "estimator":{"library":"xgboost","serialization":"ubjson","member":"model.ubj", + "claims":{"estimator_class":"XGBClassifier","n_features":3}}, + "transformed_features":{"n_features":3,"names":["a","b","c"],"origin":"transformer"} + } +} +``` + +## Members and component I/O + +Standalone bundles have exactly one of the four model members. A composite +with a prefix has exactly `model.skops` plus `model.ubj` or `model.cbm`. +A single-step composite has only its native member and null `transformers`. +Passthrough/None preceding steps still require the prefix member to preserve +the recipe. A pair without its declaration, a missing declared member, ONNX +pairs, two boosters or three model members fail. Manifest integrity covers +exactly the declared model members and referenced optional members. + +Composite `structure.json` is required. Schema and explicit samples are optional; +six members permit both with the two model members and manifest. Every other +archive ceiling is unchanged. Callers may lower member count and other limits. + +Export gates scope/options before serialization. The leaf adapter receives +`ExportDeclarations`, prepares its native copy where required, serializes and +deserializes bytes with its own preflight. Shared claims and native structure +must survive that round trip. The prefix is a transformer-only exact Pipeline: +the shared skops byte route applies the owned trust/catalog, payload/buffer +bounds and payload-versus-reserialized commitment comparison. It checks fitted +transformers and projects their parameters with the standalone owner. Each component +reuses verified snapshots within the synchronous operation, as defined in +[state verification](format-state.md); public calls never share that state. + +Shared composite extraction verifies fitted widths and names, recomputes the +recipe and outer claims for both original and restored components, and compares +canonical bytes. The reconstructed Pipeline and extended native structure must +survive the full comparison before atomic publication. Nothing fits a model, +transforms sample/dummy rows as verification, or changes the caller's pipeline. + +Load first validates container/manifest/integrity and decodes each JSON member +once. Schema/samples remain core-owned. The native leaf and optional prefix +load from bytes through their own checked routes. A constant imported Pipeline +class combines the loaded steps; no manifest-directed import or constructor is +used. The writer's extraction recomputes every component/outer claim and the +extended structure. Only exact canonical agreement permits a loaded result. + +## Fitted widths and feature names + +Widths derive from fitted state without data. Every effective transformer input +width agrees with the preceding output. Intermediate/final widths are positive +and bounded by 100,000; an empty ColumnTransformer branch contributes zero. +Nesting is at most 16. Input width/names come from the first effective fitted +transformer, or the native leaf for an identity-only prefix/single-step pipeline. + +The closed exact classes and rules are: + +| Transformer | Width rule and consistency checks | +| --- | --- | +| StandardScaler, MinMaxScaler, MaxAbsScaler, RobustScaler, Normalizer | Preserve width; fitted vectors/options and feature dimensions agree. | +| FunctionTransformer | Identity or the nine owned NumPy elementwise functions in [state](format-state.md); preserves width. Nonempty `kw_args`/`inv_kw_args` are excluded. | +| PCA | `components_.shape[0]` equals bounded `n_components_`; component input width and fitted mean/variance/singular-value vectors agree. | +| VarianceThreshold | Count true entries in the validated fitted support mask; variance vector has input width. | +| SelectKBest, SelectPercentile, SelectFpr, SelectFdr, SelectFwe, GenericUnivariateSelect | Count the checked support mask; fitted scores/p-values align; score functions remain the owned closed list. | +| SimpleImputer | Keep input width when `keep_empty_features`; otherwise count non-NaN fitted statistics, including dropped empty columns. Add the checked MissingIndicator feature count when requested. | +| KBinsDiscretizer | `ordinal` preserves width; onehot/onehot-dense sum fitted `n_bins_`. Edge lengths/order and the internal encoder counts/categories/sparsity agree. | +| PolynomialFeatures | Bounded combinatorial count from degree range, interaction-only and bias options; checked against both fitted output counts. No powers array expansion for width proof. | +| OneHotEncoder | Sum category counts after checked infrequent grouping and drop state; cross-check cached output counts, mapping lengths/indices, and original/grouped drop indices. | +| OrdinalEncoder | Preserve width; categories, missing indices and infrequent mappings align. | +| Pipeline, ColumnTransformer | Recursively compose/sum the closed rules. ColumnTransformer selectors, cached input indices, remainder complement, branch names/order, declared/fitted parameters, sparsity flag and output slices agree. | +| `"passthrough"`, None | Preserve width and recipe position. | + +Other transformers (including KNNImputer and FeatureUnion) have no composite +width rule. Existing skops/parameter restrictions also apply: callable selectors, +scalar/slice-valued constructor selectors and arbitrary function/type parameters have +no supported projection. Catalog admission alone does not guarantee export. + +Effective original names use the existing model/dataframe/declared/generated +rules. Checked `get_feature_names_out` methods propagate them; known elementwise +operations propagate directly. Otherwise use verified native leaf names, then +generated transformed names. Names must match width and remain unique/bounded. Polynomial names are generated +without dense powers_ arrays, with at most 100,000 term visits. ColumnTransformer +verbose_feature_names_out must be boolean; arbitrary format expansion is excluded. +Native names/dtypes stay separate in leaf claims: null XGBoost names or positional +CatBoost names are preserved. Native tree dumps are never renamed. This supplies +display coordinates, not inverse transformations or original-feature attribution. + +## Structure, prediction and inspection + +All native structure fields remain unchanged, with one sibling `pipeline`: + +```json +"pipeline": { + "step_name":"est", + "features":{"n_features":3,"names":["a","b","c"],"origin":"transformer"} +} +``` + +Family remains xgboost/catboost. Tree indices/importances address transformed +features. This extension is recomputed and allowed only for composites. +Stored structure bytes are compared with fresh canonical extraction through +the existing single-decode owner. Legacy all-sklearn structure remains null. + +`LoadedBundle.native_model` is a real Pipeline. Bundle input features/schema/ +dtypes/samples describe original inputs. The composite predictor checks original +rank/width and numeric cells, admitting strings only for supported fitted encoders; +it transforms once, checks unchanged row count and delegates to a cached validated +native predictor. Native input/output checks, probability tolerances and prediction +settings remain authoritative. Single-step prediction delegates directly, preserving +native categorical admission. Transform uses default global output and disabled +metadata routing while retaining explicit per-step default/pandas output. + +For XGBoost, bundle probability columns use bound original labels while the native +Pipeline's classes/predict remain encoded. CatBoost retains its native labels and +rejects `class_labels=`. Mutating a loaded native object invalidates verification. + +Inspect imports no model library and attests only metadata/integrity. CLI inspect +and verify display escaped/bounded steps, final estimator, input/transformed widths +and transformed names. Inspect retains its unverified-model notice; JSON includes +the complete typed recipe. Verify loads and checks both parts and structure. + +## Rejections + +Existing skops/native/schema/sample/resource messages retain their owners. +Recipe/pairing failures use `InvalidManifestError: Invalid mlbundle manifest.` +Candidate model sets use `InvalidContainerError: Invalid model member combination.` +Actual/reference disagreement remains `Members disagree with manifest references.` +Canonical disagreement uses ClaimMismatchError with ordinary outer fields, +`pipeline.transformers`, `pipeline.step_names`, `pipeline.estimator.`, +`pipeline.transformed_features`, or `structure`. Unsupported native placements +list sanitized unsupported types after the placement message below. + +The new scope, state and prediction messages are: + +- `Composite models require an exact outer Pipeline with 1 to 128 steps.` +- `Pipeline step names must be unique bounded sklearn names.` +- `Unknown outer Pipeline state.` +- `Composite Pipeline memory must be None; caching is unsupported.` +- `Composite Pipeline verbose must be a boolean.` +- `Composite Pipeline transform_input and metadata routing are unsupported.` +- `Composite export requires default global transform_output; use per-step set_output.` +- `Composite transformers support only default or pandas set_output.` +- `Native estimators are supported only as the last step of the outermost Pipeline.` +- `Composite final estimator must be a supported XGBoost or CatBoost wrapper.` +- `Transformer member must be a nonempty exact Pipeline.` +- `Transformer component contains a non-transformer.` +- `Composite transformers must be fitted.` +- `Composite input width cannot be recovered from fitted state.` +- `Transformer output width cannot be verified without data.` +- `Transformer nesting exceeds the limit.` +- `Transformer feature-name work exceeds the limit.` +- `Invalid transformer feature width.` +- `Transformer input widths disagree.` +- `Transformer fitted dimensions disagree.` +- `Invalid ColumnTransformer input mapping.` +- `Invalid ColumnTransformer output mapping.` +- `ColumnTransformer declared and fitted transformers disagree.` +- `Transformer output width disagrees with final estimator.` +- `Invalid transformed feature names.` +- `Transformed names and width disagree.` +- `Invalid composite Pipeline state.` (bounded unexpected-state wrapper) +- `Composite Pipeline transformation failed.` +- `Composite transformation must preserve prediction rows.` + +Native estimators in ColumnTransformer, FeatureUnion, stacking, voting, +calibration, search, TransformedTargetRegressor, nested pipelines or any nonfinal +position remain excluded. Caching, metadata requests, non-null transform_input, +unknown outer state and unverifiable fitted widths fail before publication. +Separate byte preflights do not sandbox native parsers or prediction. Host process, +memory/CPU/row limits and dependency maintenance remain necessary. diff --git a/docs/format-schema.md b/docs/format-schema.md index 89754b5..77957ba 100644 --- a/docs/format-schema.md +++ b/docs/format-schema.md @@ -141,8 +141,10 @@ arbitrary objects and unsupported NumPy values fail without stringification. Schema and samples each have the existing 16 MiB cap and JSON depth 32, further lowerable through `ArchiveLimits`. Sample rows are encoded incrementally within -the byte budget; no row is truncated. The five-member ceiling already permits -manifest, model, schema, structure and samples together. +the byte budget; no row is truncated. Six members permit a composite with +manifest, skops prefix, native leaf, structure, schema and explicit samples. +Composite schema, training dtypes and samples describe original inputs, never +the transformed boundary; target reports use the final adapter's verified labels. ## Error boundaries diff --git a/docs/format-state.md b/docs/format-state.md index daca194..258c043 100644 --- a/docs/format-state.md +++ b/docs/format-state.md @@ -108,7 +108,9 @@ values and fitted selector statistics participate in `state_sha256`. `state_sha256` is a lowercase SHA-256 digest. A sklearn structure object requires this field. Sklearn models with null structure, including Pipeline, instead put -the digest in `model.state_sha256`; CatBoost also requires a native commitment there. This optional +the digest in `model.state_sha256`; CatBoost also requires a native commitment there. +Composite prefix digests instead live in `model.pipeline.transformers.state_sha256`; +the outer digest is null, with native leaf claims/state and structure verified separately. This optional manifest field defaults to null, and is part of the verified claim projection. Missing commitments on an opaque sklearn model fail full load comparison. @@ -165,10 +167,11 @@ selection inferred from attribute suffixes. The explicit checks supplement it: | GLMs | Coefficients/intercepts plus loss/link are structure/composition. Fitted loss/link class and class count must agree with the parameter-derived loss. | | SVC / NuSVC / SVR / NuSVR | Kernel parameters, support data, native/private coefficients, sparse flag, effective gamma and calibration are committed. Linear coefficients and calibration are structure/composition when available. Public and inference dual coefficients/intercepts must agree, including sklearn's binary sign reversal. Support counts/indices, coefficient/calibration dimensions and sparse storage agree. | | Naive Bayes / QDA / neighbors / nearest centroid / MLP / semi-supervised / opaque meta-estimators | Their complete fitted statistics, data, class/output mappings, distance/activation state and nested models are committed in the manifest when structure is null. Statistics/likelihood/prior dimensions agree with classes/features; neighbor sample counts agree with fitted arrays. MLP layer sizes, output counts/activation and label-binarizer classes agree with public parameters/labels. Existing probability/count/trust constraints still apply. | -| Pipeline | All fitted steps, transformations, final estimator, order, names, classes and parameters are committed/claimed. Structure stays null; no transformed-feature visualization is inferred. | +| All-sklearn Pipeline | All fitted steps/order/names/parameters are committed; structure stays null. | +| Composite Pipeline | Prefix skops/state/parameters use the same owners as standalone sklearn. Leaf claims and complete native structure use its adapter. Shared data-free widths/names and checked tree-boundary record describe the reconstructed Pipeline; outer options/final name are declarations. See [pipelines](format-pipeline.md). | Unsupported custom types, functions/types outside these lists, HistGradientBoosting storage, -XGBoost/CatBoost pipeline steps, multilabel/chain probability layouts and untrusted nested internals remain +unsupported XGBoost/CatBoost placements, multilabel/chain probability layouts and untrusted nested internals remain rejections. For example, actual fitted `csr_matrix` neighbor state is outside the unchanged trust list; merely declaring its loader as the trusted base `spmatrix` does not make its state survive the native construction comparison. diff --git a/docs/format.md b/docs/format.md index 30e505b..e6f4502 100644 --- a/docs/format.md +++ b/docs/format.md @@ -13,29 +13,30 @@ The implemented ONNX mapping and validation rules are in [ONNX](format-onnx.md). The implemented CBM mapping and validation rules are in [CatBoost](format-catboost.md). The normative schema/declaration rules are in [Schema and samples](format-schema.md). [Prediction state and bounded work](format-state.md) is also normative. -These sections are part of this version 1 contract. +[Composite pipelines](format-pipeline.md) is normative for the split representation. ## Members | Member | Rule and contents | | --- | --- | | `manifest.json` | Required; the manifest described below. | -| `model.skops` | Exactly one model member; scikit-learn, skops format. | -| `model.ubj` | Exactly one model member; XGBoost native UBJSON format. | -| `model.cbm` | Exactly one model member; CatBoost native CBM binary format. | -| `model.onnx` | Exactly one model member; original, self-contained ONNX bytes, unchanged. | +| `model.skops` | Standalone model member; scikit-learn, skops format. | +| `model.ubj` | Standalone model member; XGBoost native UBJSON format. | +| `model.cbm` | Standalone model member; CatBoost native CBM binary format. | +| `model.onnx` | Standalone model member; original, self-contained ONNX bytes, unchanged. | | `schema.json` | Optional; exact object with `schema_version`, `fields` and `reports` from local inference. See [Schema and samples](format-schema.md). No training DataFrame. | | `structure.json` | Optional only for families without extractable structure; object as defined below. | | `samples.json` | Optional; array of row objects keyed by feature name, in declared feature order when consumed. Only explicit user intent may enable it. Values use the canonical JSON value rules. Samples are real user data. | +Model members are one standalone member, or the declared skops/UBJSON or skops/CBM +composite pair; single-step composites use only the leaf. See [pipelines](format-pipeline.md). Presence of schema, structure or samples must match both the manifest reference and an integrity record. There is no member for arbitrary user files. A training DataFrame must never be included. For samples, each row must contain exactly the declared feature names, without a target column or arbitrary extra columns; the byte/depth limits apply even when no rows are present. -For version 1, structure extraction is mandatory for supported scikit-learn -models exposing `tree_`, tree ensembles with constituent `tree_` estimators, +Version 1 requires structure for supported scikit-learn models exposing `tree_`, tree ensembles with constituent `tree_` estimators, models exposing `coef_`/`intercept_`, and models exposing `feature_importances_`; and for supported XGBoost and CatBoost tree boosters. A member is required whenever the adapter's extraction returns an object. Other supported sklearn families @@ -51,8 +52,7 @@ All objects representing typed records reject unknown keys. Types are strict: booleans do not stand for integers, strings do not stand for numbers and enum values are exact. JSON object keys are strings. Arrays are ordered. Null is distinct from an empty array/object. Unless marked optional, fields are required. -Optional fields can be omitted or null; writers emit defaults explicitly. -`user_metadata` may be omitted and defaults to `{}`. +Optional fields can be omitted/null; writers emit defaults. `user_metadata` defaults to `{}`. ### Identity @@ -65,13 +65,12 @@ Optional fields can be omitted or null; writers emit defaults explicitly. | `writer_version` | nonempty string, at most 256 characters | mlbundle package version that wrote the bundle. Must equal `environment.packages.mlbundle`. | Identity fields are declarations, not proof of provenance. - ### `model` | Field | Type | Meaning / verification | | --- | --- | --- | | `library` | `"sklearn"`, `"xgboost"`, `"catboost"`, `"onnx"` | Closed adapter identifier; validated against serialization/member pairing. | -| `serialization` | `"skops"`, `"ubjson"`, `"cbm"`, `"onnx"` | Must pair respectively with the four libraries and model members above. | +| `serialization` | `"skops"`, `"ubjson"`, `"cbm"`, `"onnx"`, `"pipeline"` | Standalone formats pair with their libraries; pipeline requires sklearn and a strict recipe. | | `estimator_class` | nonempty string, at most 256 characters | Verified class name (not a Python import path); ONNX uses the adapter's fixed `"ONNX"` identity. Never instantiate from this field. | | `kind` | `"classifier"`, `"regressor"` | Verified prediction kind. | | `hyperparameters` | object, at most 4,096 top-level keys | Verified JSON projection of the supported estimator's parameters. Sklearn uses `get_params(deep=True)`; other adapters use their recoverable native parameter representation. ONNX records graph signatures and metadata properties as defined in [ONNX](format-onnx.md), without inventing training settings. At most 1 MiB canonical bytes and depth 16. | @@ -79,6 +78,9 @@ Identity fields are declarations, not proof of provenance. | `n_classes` | null or array of integers 1–100,000 | Verified per-output class counts; classifier array length equals `n_outputs`, regressor value is null. | | `n_outputs` | integer 1–1,024 | Verified target output count. Probability columns are classes, not separate target outputs. | +`model.pipeline` defaults to null for old bundles and is required exactly for +`serialization="pipeline"`. Its records, cross-checks and declaration semantics +are specified in [Composite pipelines](format-pipeline.md); standalone writers omit this null extension to preserve existing wire bytes. Hyperparameter values preserve JSON scalars, nested containers and numeric tags. Do not stringify unsupported objects or call `repr` as a fallback. If an adapter supports nested estimators in parameters, their representation is an object diff --git a/pyproject.toml b/pyproject.toml index 531f487..9c15a97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -155,6 +155,8 @@ select = [ ignore = ["E501", "PLR0913", "PLR2004", "RET504"] [tool.ruff.lint.per-file-ignores] +"src/mlbundle/model_io.py" = ["PLC0415"] # Lazy optional adapter imports. +"src/mlbundle/registry.py" = ["PLC0415"] "tests/**/*.py" = ["ARG", "PLR2004", "PLR0124", "PLC0415", "S101", "DTZ"] # The base/artifact checks must run without importing the sklearn fixture producer. "scripts/package_smoke.py" = ["PLC0415"] diff --git a/scripts/package_smoke.py b/scripts/package_smoke.py index 441e6ca..d9c0201 100644 --- a/scripts/package_smoke.py +++ b/scripts/package_smoke.py @@ -69,6 +69,44 @@ def _base(path: Path) -> None: raise AssertionError("base installation unexpectedly loaded sklearn") assert not _OPTIONAL & sys.modules.keys() sys.stdout.write("base wheel: import/help/inspect OK; optional libraries absent\n") + composite = path.with_name(path.stem + "-composite.mlbundle") + if composite.exists(): + view = mlbundle.inspect(composite) + assert view.manifest.model.pipeline is not None + assert "Boundary: input=2, transformed=2" in _console("inspect", str(composite)) + assert json.loads( + _console("inspect", str(composite), "--json") + ) == view.manifest.model_dump(mode="json", by_alias=True) + try: + mlbundle.load(composite) + except mlbundle.MissingOptionalDependencyError as error: + assert error.extra == "sklearn" + else: + raise AssertionError("base installation unexpectedly loaded a composite") + assert not _OPTIONAL & sys.modules.keys() + sys.stdout.write( + "base wheel: composite inspect OK; bounded missing sklearn dependency\n" + ) + + +def _composite_fixture(path: Path) -> None: + from sklearn.pipeline import make_pipeline + from sklearn.preprocessing import StandardScaler + from xgboost import XGBClassifier + + import mlbundle + + rows = [[0, 1], [1, 0], [0, 2], [2, 0]] + model = make_pipeline( + StandardScaler(), XGBClassifier(n_estimators=2, n_jobs=1) + ).fit(rows, [0, 1, 0, 1]) + composite = path.with_name(path.stem + "-composite.mlbundle") + mlbundle.export(model, composite) + assert ( + mlbundle.load(composite).predict_proba(rows) + == model.predict_proba(rows).tolist() + ) + sys.stdout.write("composite fixture: separate skops/native members OK\n") def _contents(directory: Path) -> None: @@ -109,6 +147,8 @@ def main() -> None: absent = _OPTIONAL - {"sklearn", "skops", "numpy", "scipy"} assert all(find_spec(name) is None for name in absent) _sklearn(args.path) + if args.action == "create": + _composite_fixture(args.path) if __name__ == "__main__": diff --git a/src/mlbundle/_composite/__init__.py b/src/mlbundle/_composite/__init__.py new file mode 100644 index 0000000..63304e2 --- /dev/null +++ b/src/mlbundle/_composite/__init__.py @@ -0,0 +1 @@ +"""Separate checked components, closed sklearn reconstruction.""" diff --git a/src/mlbundle/_composite/claims.py b/src/mlbundle/_composite/claims.py new file mode 100644 index 0000000..0db6ec0 --- /dev/null +++ b/src/mlbundle/_composite/claims.py @@ -0,0 +1,133 @@ +"""One composite extraction for export and load.""" + +from typing import Any + +from pydantic import JsonValue +from sklearn.pipeline import Pipeline + +from mlbundle._pipeline_contract import ( + NativeComponentInfo, + PipelineInfo, + TransformedFeatures, +) +from mlbundle._sklearn import transformers +from mlbundle._sklearn.pipeline_scope import validate_components +from mlbundle._sklearn.transform_names import input_boundary, output_names +from mlbundle._sklearn.transform_width import output_width +from mlbundle.canonical import canonical_dumps +from mlbundle.claims import ModelClaims +from mlbundle.contract import MODEL_MEMBERS, Library +from mlbundle.errors import ClaimMismatchError, InvalidModelError, MLBundleError + + +def outer_claims( + prefix: Pipeline | None, leaf: ModelClaims, verbose: bool +) -> ModelClaims: + count, names = input_boundary(prefix, leaf) + return ModelClaims( + estimator_class="Pipeline", + kind=leaf.kind, + hyperparameters={"memory": None, "verbose": verbose, "transform_input": None}, + n_features=count, + n_classes=leaf.n_classes, + n_outputs=leaf.n_outputs, + feature_names=names, + feature_dtypes=leaf.feature_dtypes if prefix is None else None, + class_labels=leaf.class_labels, + ) + + +def extract( + prefix: Pipeline | None, + leaf: ModelClaims, + library: Library, + step_names: list[str], + verbose: bool, + *, + names: list[str], + transformer_adapter: transformers.Adapter, +) -> PipelineInfo: + try: + if library not in (Library.XGBOOST, Library.CATBOOST): + raise InvalidModelError("Invalid composite Pipeline state.") + outer = outer_claims(prefix, leaf, verbose) + count = ( + outer.n_features + if prefix is None + else output_width(prefix, outer.n_features) + ) + if count != leaf.n_features: + raise InvalidModelError( + "Transformer output width disagrees with final estimator." + ) + transformed = names if prefix is None else output_names(prefix, names) + origin = "input" if prefix is None else "transformer" + if transformed is None: + transformed = leaf.feature_names + origin = "model" + if transformed is None: + transformed = [f"transformed_feature_{i}" for i in range(1, count + 1)] + origin = "generated" + if len(transformed) != count: + raise InvalidModelError("Transformed names and width disagree.") + if prefix is not None: + validate_components(prefix) + return PipelineInfo( + step_names=step_names, + transformers=None if prefix is None else transformer_adapter.claims(prefix), + estimator=NativeComponentInfo( + library=library, + serialization=MODEL_MEMBERS[library][0], + member=MODEL_MEMBERS[library][1], + claims=leaf, + ), + transformed_features=TransformedFeatures( + n_features=count, names=transformed, origin=origin + ), + ) + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid composite Pipeline state.") from None + + +def verify(expected: PipelineInfo, actual: PipelineInfo) -> None: + for field in ("step_names", "transformers", "transformed_features"): + if canonical_dumps(expected.model_dump(mode="json")[field]) != canonical_dumps( + actual.model_dump(mode="json")[field] + ): + raise ClaimMismatchError("pipeline." + field) + left = expected.estimator.model_dump(mode="json") + right = actual.estimator.model_dump(mode="json") + for field, value in left["claims"].items(): + if canonical_dumps(value) != canonical_dumps(right["claims"][field]): + raise ClaimMismatchError("pipeline.estimator." + field) + if canonical_dumps(left) != canonical_dumps(right): + raise ClaimMismatchError("pipeline.estimator") + + +def structure( + native: dict[str, JsonValue] | None, recipe: PipelineInfo +) -> dict[str, JsonValue]: + if native is None: + raise InvalidModelError("Invalid composite Pipeline state.") + return { + **native, + "pipeline": { + "step_name": recipe.step_names[-1], + "features": recipe.transformed_features.model_dump(mode="json"), + }, + } + + +def rebuild( + prefix: Pipeline | None, leaf: Any, step_names: list[str], verbose: bool +) -> Pipeline: + if prefix is not None and [name for name, _ in prefix.steps] != step_names[:-1]: + raise ClaimMismatchError("pipeline.transformers") + return Pipeline( + [*([] if prefix is None else prefix.steps), (step_names[-1], leaf)], + memory=None, + verbose=verbose, + transform_input=None, + ) diff --git a/src/mlbundle/_composite/io.py b/src/mlbundle/_composite/io.py new file mode 100644 index 0000000..0b914a0 --- /dev/null +++ b/src/mlbundle/_composite/io.py @@ -0,0 +1,158 @@ +"""Composite orchestration; each byte member belongs to its existing loader.""" + +from dataclasses import dataclass +from typing import Any + +from pydantic import JsonValue +from sklearn.pipeline import Pipeline + +from mlbundle._native_io import NativeExport, prepare_native +from mlbundle._pipeline_contract import PipelineInfo +from mlbundle._sklearn import transformers +from mlbundle._sklearn.pipeline_scope import validate_components +from mlbundle.claims import ModelClaims, verify_claims, verify_structure +from mlbundle.contract import Library, MemberName, SerializationFormat +from mlbundle.declarations import ExportDeclarations +from mlbundle.errors import LimitExceededError, UnsupportedModelError +from mlbundle.limits import ArchiveLimits +from mlbundle.registry import get_adapter + +from .claims import extract, outer_claims, rebuild, structure, verify +from .prediction import Predictor + + +@dataclass(frozen=True) +class CompositeExport: + leaf: NativeExport + transformer_adapter: transformers.Adapter + prefix: Pipeline | None + restored_prefix: Pipeline | None + step_names: list[str] + verbose: bool + claims: ModelClaims + members: dict[MemberName, bytes] + library: Library = Library.SKLEARN + serialization: SerializationFormat = SerializationFormat.PIPELINE + + @property + def structure(self) -> dict[str, JsonValue] | None: + return self.leaf.structure + + def finish(self, names: list[str]) -> tuple[PipelineInfo, dict[str, JsonValue]]: + expected = extract( + self.prefix, + self.leaf.claims, + self.leaf.library, + self.step_names, + self.verbose, + names=names, + transformer_adapter=self.transformer_adapter, + ) + actual_leaf = self.leaf.adapter.extract_claims(self.leaf.restored) + actual_model = rebuild( + self.restored_prefix, self.leaf.restored, self.step_names, self.verbose + ) + validate_components(actual_model) + actual_claims = outer_claims(self.restored_prefix, actual_leaf, self.verbose) + verify_claims(self.claims, actual_claims) + actual = extract( + self.restored_prefix, + actual_leaf, + self.leaf.library, + self.step_names, + self.verbose, + names=names, + transformer_adapter=self.transformer_adapter, + ) + verify(expected, actual) + original = structure(self.leaf.structure, expected) + encoded = verify_structure( + original, + structure(self.leaf.adapter.extract_structure(self.leaf.restored), actual), + ) + self.members[MemberName.STRUCTURE] = encoded + return expected, original + + +def prepare( + model: Any, declarations: ExportDeclarations, limits: ArchiveLimits +) -> CompositeExport: + validate_components(model, exporting=True) + leaf_model = model.steps[-1][1] + roots = {base.__module__.split(".")[0] for base in type(leaf_model).__mro__} + library = Library.XGBOOST if "xgboost" in roots else Library.CATBOOST + adapter = get_adapter(library) + if not adapter.recognizes(leaf_model): + raise UnsupportedModelError( + reason="Composite final estimator must be a supported XGBoost or CatBoost wrapper." + ) + leaf = prepare_native(leaf_model, adapter, declarations, limits) + prefix = restored = None + transformer_adapter = transformers.Adapter() + members = dict(leaf.members) + if len(model.steps) > 1: + prefix = Pipeline( + list(model.steps[:-1]), + memory=None, + verbose=model.verbose, + transform_input=None, + ) + data = transformer_adapter.serialize(prefix) + if len(data) > limits.member_bytes(MemberName.SKOPS): + raise LimitExceededError("Model member size limit exceeded.") + members[MemberName.SKOPS] = data + restored = transformer_adapter.deserialize(data) + claims = outer_claims(prefix, leaf.claims, model.verbose) + return CompositeExport( + leaf, + transformer_adapter, + prefix, + restored, + [name for name, _ in model.steps], + model.verbose, + claims, + members, + ) + + +def load_components( + recipe: PipelineInfo, + members: dict[MemberName, bytes], + names: list[str], + verbose: bool, +) -> tuple[Pipeline, Predictor, ModelClaims, dict[str, JsonValue]]: + adapter = get_adapter(recipe.estimator.library) + transformer_adapter = transformers.Adapter() + leaf = adapter.deserialize(members[adapter.model_member]) + prefix = ( + None + if recipe.transformers is None + else transformer_adapter.deserialize(members[MemberName.SKOPS]) + ) + native_claims = adapter.extract_claims(leaf) + model = rebuild(prefix, leaf, recipe.step_names, verbose) + validate_components(model) + actual = extract( + prefix, + native_claims, + adapter.library, + recipe.step_names, + verbose, + names=names, + transformer_adapter=transformer_adapter, + ) + verify(recipe, actual) + claims = outer_claims(prefix, native_claims, verbose) + extracted = structure(adapter.extract_structure(leaf), actual) + settings = native_claims.hyperparameters.get("prediction") + categorical = ( + adapter.library == Library.XGBOOST + and isinstance(settings, dict) + and settings.get("enable_categorical") is True + ) + return ( + model, + Predictor(prefix, adapter.predictor(leaf), claims, categorical=categorical), + claims, + extracted, + ) diff --git a/src/mlbundle/_composite/prediction.py b/src/mlbundle/_composite/prediction.py new file mode 100644 index 0000000..ac7fe8d --- /dev/null +++ b/src/mlbundle/_composite/prediction.py @@ -0,0 +1,77 @@ +"""Transform original inputs, then delegate to the cached native predictor.""" + +from typing import Any + +from sklearn import config_context +from sklearn.pipeline import Pipeline + +from mlbundle._prediction_input import tabular +from mlbundle._sklearn.prediction import _string_inputs +from mlbundle.adapters import ( + Predictor as NativePredictor, + ProbabilityOutput, + RegressionOutput, +) +from mlbundle.claims import ModelClaims +from mlbundle.contract import ModelKind +from mlbundle.errors import InvalidModelError, MLBundleError, PredictionInputError + + +class Predictor: + def __init__( + self, + prefix: Pipeline | None, + native: NativePredictor, + claims: ModelClaims, + *, + categorical: bool = False, + ) -> None: + self.prefix, self.native, self.claims = prefix, native, claims + self.strings = prefix is not None and _string_inputs(prefix) + self.categorical = categorical + + def _transform(self, data: Any) -> Any: + # A single-step composite preserves the leaf's categorical admission. + if self.prefix is None: + return data + data, rows = tabular( + data, + self.claims.n_features, + strings=self.strings, + categorical=self.categorical, + ) + names = getattr(data, "columns", None) + if ( + names is not None + and self.claims.feature_names is not None + and list(names) != self.claims.feature_names + ): + raise PredictionInputError( + "Prediction columns must match model feature names and order." + ) + try: + with config_context( + transform_output="default", enable_metadata_routing=False + ): + result = self.prefix.transform(data) + if len(result.shape) != 2 or result.shape[0] != rows: + raise InvalidModelError( + "Composite transformation must preserve prediction rows." + ) + return result + except MLBundleError: + raise + except Exception: + raise InvalidModelError( + "Composite Pipeline transformation failed." + ) from None + + def predict(self, data: object) -> RegressionOutput: + if self.claims.kind != ModelKind.REGRESSOR: + raise ValueError("predict requires a regressor.") + return self.native.predict(self._transform(data)) + + def predict_proba(self, data: object) -> ProbabilityOutput: + if self.claims.kind != ModelKind.CLASSIFIER: + raise ValueError("predict_proba requires a classifier.") + return self.native.predict_proba(self._transform(data)) diff --git a/src/mlbundle/_container_rules.py b/src/mlbundle/_container_rules.py index a843b35..ed625ba 100644 --- a/src/mlbundle/_container_rules.py +++ b/src/mlbundle/_container_rules.py @@ -42,8 +42,12 @@ def check_members( if MemberName.MANIFEST not in present: raise InvalidContainerError("Missing manifest.json.") models = {pair[1] for pair in MODEL_MEMBERS.values()} - if len(present & models) != 1: - raise InvalidContainerError("Exactly one model member is required.") + selected = present & models + if len(selected) != 1 and selected not in ( + {MemberName.SKOPS, MemberName.UBJSON}, + {MemberName.SKOPS, MemberName.CBM}, + ): + raise InvalidContainerError("Invalid model member combination.") if manifest is not None and present != {MemberName.MANIFEST, *manifest.integrity}: raise InvalidContainerError("Members disagree with manifest references.") diff --git a/src/mlbundle/_export_manifest.py b/src/mlbundle/_export_manifest.py new file mode 100644 index 0000000..a2f610c --- /dev/null +++ b/src/mlbundle/_export_manifest.py @@ -0,0 +1,78 @@ +"""Manifest construction shared by standalone and composite export.""" + +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +from importlib.metadata import version +from uuid import uuid4 + +from pydantic import JsonValue + +from .claims import ModelClaims +from .contract import ( + FORMAT_NAME, + FORMAT_VERSION, + FeatureOrigin, + Library, + MemberName, + SerializationFormat, +) +from .environment import capture_environment +from .training import TrainingDeclaration + + +def manifest_parts( + claims: ModelClaims, + library: Library, + serialization: SerializationFormat, + *, + structure: dict[str, JsonValue] | None, + feature_names: Sequence[str] | None, + training: TrainingDeclaration | None, + target_name: str | None, + metadata: Mapping[str, JsonValue] | None, +) -> dict[str, object]: + names = claims.feature_names + origin = FeatureOrigin.MODEL + if names is None: + origin = ( + FeatureOrigin.DECLARED + if feature_names is not None + else FeatureOrigin.GENERATED + ) + names = ( + list(feature_names) + if feature_names is not None + else [f"feature_{index}" for index in range(1, claims.n_features + 1)] + ) + if training is not None: + names, origin = training.names, FeatureOrigin.DATAFRAME + model = claims.model_dump( + mode="json", exclude={"feature_names", "feature_dtypes", "class_labels", "onnx"} + ) + model.update(library=library.value, serialization=serialization.value) + return { + "format_name": FORMAT_NAME, + "format_version": FORMAT_VERSION, + "bundle_id": str(uuid4()), + "created_at": datetime.now(UTC).isoformat(), + "writer_version": version("mlbundle"), + "model": model, + "features": { + "names": names, + "dtypes": [None] * claims.n_features + if training is None + else training.dtypes, + "model_dtypes": claims.feature_dtypes, + "origin": origin.value, + }, + "target": {"class_labels": claims.class_labels, "name": target_name}, + "environment": capture_environment(onnx=claims.onnx).model_dump(mode="json"), + "references": { + "structure": None if structure is None else MemberName.STRUCTURE.value, + "schema": None if training is None else MemberName.SCHEMA.value, + "samples": None + if training is None or training.samples is None + else MemberName.SAMPLES.value, + }, + "user_metadata": {} if metadata is None else dict(metadata), + } diff --git a/src/mlbundle/_native_io.py b/src/mlbundle/_native_io.py new file mode 100644 index 0000000..64d2a6a --- /dev/null +++ b/src/mlbundle/_native_io.py @@ -0,0 +1,53 @@ +"""Shared native preparation and full round-trip verification.""" + +from dataclasses import dataclass + +from pydantic import JsonValue + +from .adapters import Adapter +from .claims import ModelClaims, verify_claims, verify_structure +from .contract import MODEL_MEMBERS, Library, MemberName, SerializationFormat +from .declarations import ExportDeclarations, resolve_labels +from .errors import LimitExceededError +from .limits import ArchiveLimits + + +@dataclass(frozen=True) +class NativeExport: + adapter: Adapter + model: object + restored: object + claims: ModelClaims + structure: dict[str, JsonValue] | None + members: dict[MemberName, bytes] + + @property + def library(self) -> Library: + return self.adapter.library + + @property + def serialization(self) -> SerializationFormat: + return MODEL_MEMBERS[self.library][0] + + +def prepare_native( + model: object, + adapter: Adapter, + declarations: ExportDeclarations, + limits: ArchiveLimits, +) -> NativeExport: + model = adapter.prepare_export(model, declarations) + data = adapter.serialize(model) + if len(data) > limits.member_bytes(adapter.model_member): + raise LimitExceededError("Model member size limit exceeded.") + claims = resolve_labels(adapter.extract_claims(model), declarations) + structure = adapter.extract_structure(model) + restored = adapter.deserialize(data) + verify_claims( + claims, resolve_labels(adapter.extract_claims(restored), declarations) + ) + structure_bytes = verify_structure(structure, adapter.extract_structure(restored)) + members = {adapter.model_member: data} + if structure is not None: + members[MemberName.STRUCTURE] = structure_bytes + return NativeExport(adapter, model, restored, claims, structure, members) diff --git a/src/mlbundle/_pipeline_contract.py b/src/mlbundle/_pipeline_contract.py new file mode 100644 index 0000000..c63d3a6 --- /dev/null +++ b/src/mlbundle/_pipeline_contract.py @@ -0,0 +1,104 @@ +"""Core-only composite records; declarations never select Python constructors.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import Field, JsonValue, field_validator, model_validator + +from ._base import ContractModel +from .canonical import canonical_dumps +from .claims import FeatureNames, ModelClaims, validate_labels +from .contract import MODEL_MEMBERS, Library, MemberName, SerializationFormat +from .limits import HYPERPARAMETER_BYTES, MAX_FEATURES + +StepName = Annotated[str, Field(min_length=1, max_length=256)] +Digest = Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] +OPTIONS = {"steps", "memory", "verbose", "transform_input"} + + +def valid_step_names(names: list[str]) -> bool: + return len(set(names)) == len(names) and all( + "__" not in name and name not in OPTIONS for name in names + ) + + +class TransformerInfo(ContractModel): + member: Literal["model.skops"] + estimator_class: Literal["Pipeline"] + hyperparameters: dict[str, JsonValue] = Field(max_length=4096) + state_sha256: Digest + + @field_validator("hyperparameters") + @classmethod + def bounded_parameters(cls, value: dict[str, JsonValue]) -> dict[str, JsonValue]: + canonical_dumps(value, max_bytes=HYPERPARAMETER_BYTES, max_depth=16) + return value + + +class NativeComponentInfo(ContractModel): + library: Literal[Library.XGBOOST, Library.CATBOOST] + serialization: SerializationFormat + member: MemberName + claims: ModelClaims + + @model_validator(mode="after") + def native_pairing(self) -> NativeComponentInfo: + if (self.serialization, self.member) != MODEL_MEMBERS[self.library]: + raise ValueError("Invalid native component pairing.") + classes = { + Library.XGBOOST: {"XGBClassifier", "XGBRegressor"}, + Library.CATBOOST: {"CatBoostClassifier", "CatBoostRegressor"}, + } + if self.claims.estimator_class not in classes[self.library]: + raise ValueError("Invalid native component class.") + if self.claims.onnx is not None: + raise ValueError("Composite components cannot declare ONNX fields.") + validate_labels(self.claims, self.claims.class_labels) + return self + + +class TransformedFeatures(ContractModel): + n_features: int = Field(gt=0, le=MAX_FEATURES) + names: FeatureNames + origin: Literal["transformer", "model", "input", "generated"] + + @model_validator(mode="after") + def aligned_names(self) -> TransformedFeatures: + if len(self.names) != self.n_features or len(set(self.names)) != len( + self.names + ): + raise ValueError("Invalid transformed names.") + if self.origin == "generated" and self.names != [ + f"transformed_feature_{i}" for i in range(1, self.n_features + 1) + ]: + raise ValueError("Invalid generated transformed names.") + return self + + +class PipelineInfo(ContractModel): + step_names: list[StepName] = Field(min_length=1, max_length=128) + transformers: TransformerInfo | None + estimator: NativeComponentInfo + transformed_features: TransformedFeatures + + @model_validator(mode="after") + def consistent_recipe(self) -> PipelineInfo: + if not valid_step_names(self.step_names): + raise ValueError("Invalid Pipeline names.") + if (len(self.step_names) > 1) != (self.transformers is not None): + raise ValueError("Transformer recipe and steps disagree.") + if self.transformed_features.n_features != self.estimator.claims.n_features: + raise ValueError("Composite boundary widths disagree.") + return self + + +def expected_model_members( + library: Library, pipeline: PipelineInfo | None +) -> set[MemberName]: + if pipeline is None: + return {MODEL_MEMBERS[library][1]} + members = {pipeline.estimator.member} + if pipeline.transformers is not None: + members.add(MemberName.SKOPS) + return members diff --git a/src/mlbundle/_sklearn/__init__.py b/src/mlbundle/_sklearn/__init__.py index 083413c..1e14fa5 100644 --- a/src/mlbundle/_sklearn/__init__.py +++ b/src/mlbundle/_sklearn/__init__.py @@ -3,7 +3,6 @@ from typing import Any, cast import numpy as np -import skops.io as sio from pydantic import JsonValue, ValidationError from sklearn.base import BaseEstimator, is_classifier, is_regressor from sklearn.exceptions import NotFittedError @@ -27,24 +26,9 @@ from .parameters import parameters from .payload import validate_payload from .prediction import Predictor +from .serialization import SkopsIO from .structure import extract_structure, has_structure, validate_tree_storage -from .trusted import TRUSTED_TYPES - - -def _untrusted(data: bytes) -> None: - unsupported = tuple( - sorted(set(sio.get_untrusted_types(data=data)) - set(TRUSTED_TYPES)) - ) - if unsupported: - if any(name.startswith("catboost.") for name in unsupported): - raise UnsupportedModelError( - unsupported, - reason=( - f"{UnsupportedModelError(unsupported)} " - 'Export the estimator directly: export(pipeline.named_steps["est"], ...).' - ), - ) - raise UnsupportedModelError(unsupported) +from .trusted import TRUSTED_TYPES as TRUSTED_TYPES def _outputs(model: Any, classifier: bool) -> tuple[int, ClassLabels | None]: @@ -96,16 +80,11 @@ class Adapter: model_member = MemberName.SKOPS def __init__(self) -> None: - # The registry creates a fresh adapter for every public export/load. - self._states: list[tuple[object, str]] = [] - self._payload: bytes | None = None - self._digest: str | None = None + # Each registry call creates fresh synchronous operation state. + self._io = SkopsIO(self._validate, validate_payload) def _state(self, model: object) -> str: - for native, digest in self._states: - if native is model: - return digest - return validate_payload(sio.dumps(model)) + return self._io.state(model) def recognizes(self, model: object) -> bool: return ( @@ -151,47 +130,10 @@ def _validate(self, model: Any) -> None: validate_tree_storage(model) def serialize(self, model: object) -> bytes: - try: - self._states = [] - self._payload, self._digest = None, None - data = sio.dumps(model) - try: - digest = validate_payload(data) - except UnsupportedModelError: - _untrusted(data) - raise - _untrusted(data) - self._validate(model) - self._states = [(model, digest)] - self._payload, self._digest = data, digest - return data - except MLBundleError: - raise - except Exception: - raise UnsupportedModelError( - reason="Model cannot be serialized safely with skops." - ) from None + return self._io.serialize(model) def deserialize(self, data: bytes) -> object: - try: - expected_state = ( - self._digest if data is self._payload else validate_payload(data) - ) - self._payload, self._digest = None, None - _untrusted(data) - model = sio.loads(data, trusted=list(TRUSTED_TYPES)) - self._validate(model) - actual_state = validate_payload(sio.dumps(model)) - if expected_state != actual_state: - raise InvalidModelError( - "Skops state does not survive native construction." - ) - self._states = [*self._states[-1:], (model, actual_state)] - return model - except MLBundleError: - raise - except Exception: - raise InvalidModelError("Invalid skops model payload.") from None + return self._io.deserialize(data) def extract_claims(self, model: Any) -> ModelClaims: try: diff --git a/src/mlbundle/_sklearn/_width_checks.py b/src/mlbundle/_sklearn/_width_checks.py new file mode 100644 index 0000000..b399063 --- /dev/null +++ b/src/mlbundle/_sklearn/_width_checks.py @@ -0,0 +1,59 @@ +"""Strict fitted dimensions shared by the closed width validators.""" + +from typing import Any + +import numpy as np + +from mlbundle.errors import InvalidModelError +from mlbundle.limits import MAX_FEATURES + + +def width(value: Any, *, zero: bool = False) -> int: + if ( + isinstance(value, (bool, np.bool_)) + or not isinstance(value, (int, np.integer)) + or not (0 if zero else 1) <= value <= MAX_FEATURES + ): + raise InvalidModelError("Invalid transformer feature width.") + return int(value) + + +def dimensions(condition: bool) -> None: + if not condition: + raise InvalidModelError("Transformer fitted dimensions disagree.") + + +def vector( + model: Any, name: str, count: int, *, required: bool = True +) -> np.ndarray | None: + value = getattr(model, name, None) + if value is None: + dimensions(not required) + return None + result = np.asarray(value) + dimensions(result.shape == (count,)) + return result + + +def input_width(model: Any, count: int) -> None: + native = getattr(model, "n_features_in_", None) + if native is not None and width(native) != count: + raise InvalidModelError("Transformer input widths disagree.") + names = getattr(model, "feature_names_in_", None) + if names is not None: + values = np.asarray(names) + dimensions( + values.shape == (count,) + and all(type(x) is str and 1 <= len(x) <= 1024 for x in values) + and len(set(values)) == count + ) + + +def indices(value: Any, count: int) -> list[int]: + array = np.asarray(value) + dimensions(array.ndim == 1 and array.dtype.kind in "iu" and len(array) <= count) + result = [int(item) for item in array] + dimensions( + all(0 <= item < count for item in result) and len(set(result)) == len(result) + ) + return result diff --git a/src/mlbundle/_sklearn/column_width.py b/src/mlbundle/_sklearn/column_width.py new file mode 100644 index 0000000..163cfb1 --- /dev/null +++ b/src/mlbundle/_sklearn/column_width.py @@ -0,0 +1,198 @@ +"""Cross-check ColumnTransformer selectors, fitted clones and output slices.""" + +from collections.abc import Callable +from typing import Any + +import numpy as np +from sklearn.preprocessing import FunctionTransformer + +from mlbundle.canonical import canonical_dumps +from mlbundle.errors import InvalidModelError + +from ._width_checks import width +from .parameters import parameters + + +def require(condition: bool, message: str) -> None: + if not condition: + raise InvalidModelError(message) + + +def selector(value: Any, count: int, names: list[str] | None) -> list[int]: + message = "Invalid ColumnTransformer input mapping." + + def position(item: Any) -> int: + if type(item) in (str, np.str_): + require(names is not None and item in names, message) + return (names or []).index(item) + require( + isinstance(item, (int, np.integer)) + and not isinstance(item, (bool, np.bool_)), + message, + ) + result = int(item) + result = result + count if result < 0 else result + require(0 <= result < count, message) + return result + + if isinstance(value, slice): + if type(value.start) is str or type(value.stop) is str: + require(value.step is None and names is not None, message) + start = 0 if value.start is None else position(value.start) + stop = count if value.stop is None else position(value.stop) + 1 + return list(range(start, stop)) + require( + all( + item is None or type(item) is int + for item in (value.start, value.stop, value.step) + ) + and value.step != 0, + message, + ) + return list(range(count))[value] + if type(value) in (str, np.str_) or isinstance(value, (int, np.integer)): + return [position(value)] + require(isinstance(value, (list, tuple, np.ndarray)), message) + array = np.asarray(value) + require(array.ndim == 1 and len(array) <= 100_000, message) + if array.dtype.kind == "b": + require(len(array) == count, message) + return np.flatnonzero(array).tolist() + return [position(item) for item in value] + + +def same_transformer(declared: Any, fitted: Any) -> bool: + if type(declared) is str: + if declared == "drop": + return type(fitted) is str and fitted == "drop" + return ( + declared == "passthrough" + and type(fitted) is FunctionTransformer + and fitted.func is None + and fitted.inverse_func is None + and fitted.feature_names_out == "one-to-one" + and fitted.validate is False + ) + return type(declared) is type(fitted) and canonical_dumps( + parameters(declared) + ) == canonical_dumps(parameters(fitted)) + + +def column_width( + model: Any, count: int, depth: int, output_width: Callable[..., int] +) -> int: + + mapping_error = "Invalid ColumnTransformer input mapping." + clone_error = "ColumnTransformer declared and fitted transformers disagree." + output_error = "Invalid ColumnTransformer output mapping." + declared = model.transformers + fitted = model.transformers_ + require( + isinstance(declared, (list, tuple)) + and isinstance(fitted, list) + and len(declared) <= 128 + and len(model._columns) == len(declared), + mapping_error, + ) + require( + all( + isinstance(item, (tuple, list)) and len(item) == 3 + for item in [*declared, *fitted] + ), + mapping_error, + ) + declared_names = [item[0] for item in declared] + require( + all( + type(name) is str + and 1 <= len(name) <= 256 + and name != "remainder" + and "__" not in name + for name in declared_names + ) + and len(set(declared_names)) == len(declared_names), + mapping_error, + ) + names = getattr(model, "feature_names_in_", None) + names = None if names is None else list(names) + mapping = model._transformer_to_input_indices + require( + isinstance(mapping, dict) and set(mapping) == {*declared_names, "remainder"}, + mapping_error, + ) + for selected in mapping.values(): + require( + isinstance(selected, list) + and all(type(i) is int and 0 <= i < count for i in selected), + mapping_error, + ) + used = set() + for (name, _, columns), cached in zip(declared, model._columns, strict=True): + resolved = selector(columns, count, names) + require( + resolved == selector(cached, count, names) and resolved == mapping[name], + mapping_error, + ) + used.update(resolved) + remaining = sorted(set(range(count)) - used) + require(mapping["remainder"] == remaining, mapping_error) + remainder = model._remainder + require( + isinstance(remainder, tuple) + and len(remainder) == 3 + and remainder[0] == "remainder" + and selector(remainder[2], count, names) == remaining, + mapping_error, + ) + left, right = model.remainder, remainder[1] + require( + type(left) is type(right) + and ( + canonical_dumps(left) == canonical_dumps(right) + if type(left) is str + else canonical_dumps(parameters(left)) == canonical_dumps(parameters(right)) + ), + clone_error, + ) + expected = [*declared, *((remainder,) if remaining else ())] + require(len(fitted) == len(expected), clone_error) + require(type(model.sparse_output_) is bool, output_error) + outputs = model.output_indices_ + require( + isinstance(outputs, dict) and set(outputs) == {*declared_names, "remainder"}, + output_error, + ) + total = 0 + for (name, original, columns), (actual_name, transformer, actual_columns) in zip( + expected, fitted, strict=True + ): + require( + name == actual_name + and selector(columns, count, names) + == selector(actual_columns, count, names), + mapping_error, + ) + selected = mapping[name] + if not selected: + require( + type(original) is type(transformer) + or same_transformer(original, transformer), + clone_error, + ) + require(outputs[name] == slice(0, 0), output_error) + continue + require(same_transformer(original, transformer), clone_error) + if type(transformer) is str and transformer == "drop": + require(outputs[name] == slice(0, 0), output_error) + continue + require( + not isinstance(columns, (str, np.str_, int, np.integer)) + and not isinstance(actual_columns, (str, np.str_, int, np.integer)), + mapping_error, + ) + result = output_width(transformer, len(selected), depth=depth + 1) + require(outputs[name] == slice(total, total + result), output_error) + total = width(total + result) + if not remaining: + require(outputs["remainder"] == slice(0, 0), output_error) + return width(total) diff --git a/src/mlbundle/_sklearn/encoder_width.py b/src/mlbundle/_sklearn/encoder_width.py new file mode 100644 index 0000000..cd8055b --- /dev/null +++ b/src/mlbundle/_sklearn/encoder_width.py @@ -0,0 +1,123 @@ +"""Validate fitted category/drop/grouping dimensions before deriving width.""" + +from typing import Any + +import numpy as np +from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder + +from mlbundle.canonical import canonical_dumps + +from ._width_checks import dimensions, indices, width + + +def encoder_width(model: Any, count: int) -> int: + categories = model.categories_ + dimensions(isinstance(categories, list) and len(categories) == count) + lengths = [] + for values in categories: + dimensions(type(values) is np.ndarray) + array = np.asarray(values) + dimensions(array.ndim == 1 and 1 <= len(array) <= 100_000) + encoded = [canonical_dumps(item) for item in array] + dimensions(len(set(encoded)) == len(encoded)) + lengths.append(len(array)) + enabled = model._infrequent_enabled + dimensions( + type(enabled) is bool + and enabled + == (model.min_frequency is not None or model.max_categories is not None) + ) + missing = model._missing_indices if type(model) is OrdinalEncoder else {} + dimensions( + isinstance(missing, dict) + and all(type(i) is int and 0 <= i < count for i in missing) + ) + for i, index in missing.items(): + dimensions(width(index, zero=True) == lengths[i] - 1) + dimensions(bool(np.asarray(categories[i][-1] != categories[i][-1]))) + grouped = grouped_counts(model, lengths, missing, count) + if type(model) is not OneHotEncoder: + return count + dropped_counts(model, lengths, grouped, count) + cached = model._n_features_outs + dimensions(isinstance(cached, list) and len(cached) == count) + dimensions([width(item, zero=True) for item in cached] == grouped) + return width(sum(grouped)) + + +def grouped_counts( + model: Any, lengths: list[int], missing: dict, count: int +) -> list[int]: + grouped = lengths.copy() + enabled = model._infrequent_enabled + if enabled: + infrequent = model._infrequent_indices + mappings = model._default_to_infrequent_mappings + dimensions( + isinstance(infrequent, list) + and isinstance(mappings, list) + and len(infrequent) == len(mappings) == count + ) + for i, (selection, mapping) in enumerate( + zip(infrequent, mappings, strict=True) + ): + if selection is None: + dimensions(mapping is None) + continue + size = lengths[i] - int(i in missing) + selected = indices(selection, size) + dimensions(bool(selected) and selected == sorted(selected)) + grouped[i] -= len(selected) - 1 + array = np.asarray(mapping) + dimensions(array.shape == (size,) and array.dtype.kind in "iu") + expected = np.empty(size, dtype=int) + frequent = sorted(set(range(size)) - set(selected)) + expected[frequent] = np.arange(len(frequent)) + expected[selected] = len(frequent) + dimensions(np.array_equal(array, expected)) + return grouped + + +def dropped_counts( + model: Any, lengths: list[int], grouped: list[int], count: int +) -> None: + enabled = model._infrequent_enabled + drop = model.drop_idx_ + after = model._drop_idx_after_grouping + dimensions((drop is None) == (after is None) == (model.drop is None)) + if drop is not None: + dimensions( + type(drop) is np.ndarray + and type(after) is np.ndarray + and drop.dtype.kind == after.dtype.kind == "O" + ) + dimensions(np.asarray(drop).shape == np.asarray(after).shape == (count,)) + for i, (raw_original, raw_transformed) in enumerate( + zip(drop, np.asarray(after), strict=True) + ): + dimensions((raw_original is None) == (raw_transformed is None)) + if raw_original is None: + dimensions(model.drop == "if_binary" and grouped[i] != 2) + continue + original = width(raw_original, zero=True) + transformed = width(raw_transformed, zero=True) + dimensions(original < lengths[i] and transformed < grouped[i]) + mapping = model._default_to_infrequent_mappings[i] if enabled else None + dimensions( + transformed == (original if mapping is None else int(mapping[original])) + ) + if type(model.drop) is str: + dimensions( + transformed == 0 + and ( + model.drop == "first" + or (model.drop == "if_binary" and grouped[i] == 2) + ) + ) + else: + dimensions(np.asarray(model.drop).shape == (count,)) + dimensions( + canonical_dumps(np.asarray(model.drop)[i]) + == canonical_dumps(model.categories_[i][original]) + ) + grouped[i] -= 1 diff --git a/src/mlbundle/_sklearn/pipeline_scope.py b/src/mlbundle/_sklearn/pipeline_scope.py new file mode 100644 index 0000000..9fc03bc --- /dev/null +++ b/src/mlbundle/_sklearn/pipeline_scope.py @@ -0,0 +1,141 @@ +"""Bounded native-placement discovery and closed Pipeline options.""" + +from typing import Any + +import numpy as np +from sklearn import get_config +from sklearn.base import BaseEstimator +from sklearn.pipeline import Pipeline + +from mlbundle._pipeline_contract import OPTIONS, valid_step_names +from mlbundle.errors import InvalidModelError, LimitExceededError, UnsupportedModelError + + +def objects(root: Any): + pending = [root] + seen = set() + visits = 0 + while pending: + value = pending.pop() + visits += 1 + if visits > 100_000: + raise LimitExceededError("Pipeline state work exceeds the limit.") + if id(value) in seen: + continue + seen.add(id(value)) + if isinstance(value, dict): + children = value.values() + elif isinstance(value, (list, tuple)): + children = value + elif isinstance(value, np.ndarray) and value.dtype.kind == "O": + children = value.flat + else: + yield value + children = vars(value).values() if isinstance(value, BaseEstimator) else () + if len(pending) + len(children) > 100_000: + raise LimitExceededError("Pipeline state work exceeds the limit.") + pending.extend(children) + + +def native_types(root: Any) -> tuple[str, ...]: + return tuple( + sorted( + { + f"{type(value).__module__}.{type(value).__name__}" + for value in objects(root) + if {base.__module__.split(".")[0] for base in type(value).__mro__} + & {"xgboost", "catboost"} + } + ) + ) + + +def composite_candidate(model: Any) -> bool: + found = native_types(model) + if not found: + return False + if ( + isinstance(model, Pipeline) + and model.steps + and {base.__module__.split(".")[0] for base in type(model.steps[-1][1]).__mro__} + & {"xgboost", "catboost"} + ): + if any(native_types(step) for _, step in model.steps[:-1]): + reject_placement(found) + validate_options(model) + return True + reject_placement(found) + return False + + +def reject_placement(types: tuple[str, ...]) -> None: + raise UnsupportedModelError( + types, + reason=( + "Native estimators are supported only as the last step of the outermost Pipeline. " + + str(UnsupportedModelError(types)) + ), + ) + + +def validate_options(model: Any) -> None: + if ( + type(model) is not Pipeline + or not isinstance(model.steps, (list, tuple)) + or not 1 <= len(model.steps) <= 128 + ): + raise UnsupportedModelError( + reason="Composite models require an exact outer Pipeline with 1 to 128 steps." + ) + if "_metadata_request" in vars(model) or model.transform_input is not None: + raise UnsupportedModelError( + reason="Composite Pipeline transform_input and metadata routing are unsupported." + ) + if set(vars(model)) != OPTIONS: + raise InvalidModelError("Unknown outer Pipeline state.") + if any( + not isinstance(item, (list, tuple)) or len(item) != 2 for item in model.steps + ): + raise UnsupportedModelError( + reason="Pipeline step names must be unique bounded sklearn names." + ) + names = [name for name, _ in model.steps] + if any( + type(name) is not str or not 1 <= len(name) <= 256 for name in names + ) or not valid_step_names(names): + raise UnsupportedModelError( + reason="Pipeline step names must be unique bounded sklearn names." + ) + if model.memory is not None: + raise UnsupportedModelError( + reason="Composite Pipeline memory must be None; caching is unsupported." + ) + if type(model.verbose) is not bool: + raise UnsupportedModelError( + reason="Composite Pipeline verbose must be a boolean." + ) + + +def validate_components(model: Pipeline, *, exporting: bool = False) -> None: + validate_options(model) + if exporting and get_config()["transform_output"] != "default": + raise UnsupportedModelError( + reason="Composite export requires default global transform_output; use per-step set_output." + ) + for value in objects(model): + if isinstance(value, BaseEstimator): + if "_metadata_request" in vars(value): + raise UnsupportedModelError( + reason="Composite Pipeline transform_input and metadata routing are unsupported." + ) + config = vars(value).get("_sklearn_output_config", {}) + if ( + not isinstance(config, dict) + or set(config) - {"transform"} + or config.get("transform", "default") not in ("default", "pandas") + ): + raise UnsupportedModelError( + reason="Composite transformers support only default or pandas set_output." + ) + if isinstance(value, Pipeline) and value is not model: + validate_options(value) diff --git a/src/mlbundle/_sklearn/polynomial_names.py b/src/mlbundle/_sklearn/polynomial_names.py new file mode 100644 index 0000000..2778be7 --- /dev/null +++ b/src/mlbundle/_sklearn/polynomial_names.py @@ -0,0 +1,31 @@ +"""Bounded polynomial names without sklearn's dense powers_ allocation.""" + +from collections import Counter +from itertools import combinations, combinations_with_replacement +from typing import Any + +from mlbundle.errors import InvalidModelError, LimitExceededError + + +def polynomial_names(model: Any, names: list[str]) -> list[str]: + # Count term visits before constructing tuples; unlike powers_, work follows + # emitted monomials, not output width multiplied by original feature width. + maximum = ( + min(model._max_degree, len(names)) + if model.interaction_only + else model._max_degree + ) + if model.n_output_features_ * max(1, maximum) > 100_000: + raise LimitExceededError("Transformer feature-name work exceeds the limit.") + choose = combinations if model.interaction_only else combinations_with_replacement + result = ["1"] if model.include_bias else [] + for degree in range(max(1, model._min_degree), maximum + 1): + for term in choose(range(len(names)), degree): + parts = [ + names[index] + ("" if power == 1 else f"^{power}") + for index, power in sorted(Counter(term).items()) + ] + if sum(map(len, parts)) + len(parts) - 1 > 1024: + raise InvalidModelError("Invalid transformed feature names.") + result.append(" ".join(parts)) + return result diff --git a/src/mlbundle/_sklearn/serialization.py b/src/mlbundle/_sklearn/serialization.py new file mode 100644 index 0000000..ae09135 --- /dev/null +++ b/src/mlbundle/_sklearn/serialization.py @@ -0,0 +1,80 @@ +"""One owned skops byte boundary for estimators and transformer components.""" + +from collections.abc import Callable + +import skops.io as sio + +from mlbundle.errors import InvalidModelError, MLBundleError, UnsupportedModelError + +from .payload import validate_payload +from .trusted import TRUSTED_TYPES + + +def audit_types(data: bytes) -> None: + unsupported = tuple( + sorted(set(sio.get_untrusted_types(data=data)) - set(TRUSTED_TYPES)) + ) + if unsupported: + raise UnsupportedModelError(unsupported) + + +class SkopsIO: + """Reuse checked snapshots inside one operation; never across public calls.""" + + def __init__( + self, + validate: Callable[[object], None], + payload: Callable[[bytes], str] = validate_payload, + ) -> None: + self.validate = validate + self.payload = payload + self._states: list[tuple[object, str]] = [] + self._payload: bytes | None = None + self._digest: str | None = None + + def state(self, model: object) -> str: + for native, digest in self._states: + if native is model: + return digest + return self.payload(sio.dumps(model)) + + def serialize(self, model: object) -> bytes: + try: + self._states = [] + self._payload, self._digest = None, None + data = sio.dumps(model) + try: + digest = self.payload(data) + except UnsupportedModelError: + audit_types(data) + raise + audit_types(data) + self.validate(model) + self._states = [(model, digest)] + self._payload, self._digest = data, digest + return data + except MLBundleError: + raise + except Exception: + raise UnsupportedModelError( + reason="Model cannot be serialized safely with skops." + ) from None + + def deserialize(self, data: bytes) -> object: + try: + expected = self._digest if data is self._payload else self.payload(data) + self._payload, self._digest = None, None + audit_types(data) + model = sio.loads(data, trusted=list(TRUSTED_TYPES)) + self.validate(model) + actual = self.payload(sio.dumps(model)) + if expected != actual: + raise InvalidModelError( + "Skops state does not survive native construction." + ) + self._states = [*self._states[-1:], (model, actual)] + return model + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid skops model payload.") from None diff --git a/src/mlbundle/_sklearn/transform_names.py b/src/mlbundle/_sklearn/transform_names.py new file mode 100644 index 0000000..c6f2f03 --- /dev/null +++ b/src/mlbundle/_sklearn/transform_names.py @@ -0,0 +1,111 @@ +"""Input and inner display names, separately from native estimator names.""" + +from typing import Any + +import numpy as np +from sklearn.compose import ColumnTransformer +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import FunctionTransformer, OneHotEncoder, PolynomialFeatures + +from mlbundle.claims import ModelClaims +from mlbundle.errors import InvalidModelError, UnsupportedModelError + +from ._width_checks import input_width, width +from .polynomial_names import polynomial_names +from .transform_width import identity + + +def _first_boundary(prefix: Pipeline) -> tuple[int, list[str] | None] | None: + for _, step in prefix.steps: + if identity(step): + continue + if type(step) is Pipeline: + result = _first_boundary(step) + if result is not None: + return result + continue + count = getattr(step, "n_features_in_", None) + if count is not None: + count = width(count) + input_width(step, count) + names = getattr(step, "feature_names_in_", None) + return count, None if names is None else list(names) + if type(step) is not FunctionTransformer: + raise UnsupportedModelError( + reason="Composite input width cannot be recovered from fitted state." + ) + return None + + +def input_boundary( + prefix: Pipeline | None, leaf: ModelClaims +) -> tuple[int, list[str] | None]: + result = None if prefix is None else _first_boundary(prefix) + return (leaf.n_features, leaf.feature_names) if result is None else result + + +def output_names(model: Any, names: list[str]) -> list[str] | None: + if identity(model): + return names + native = getattr(model, "feature_names_in_", None) + if native is not None and list(native) != names: + raise InvalidModelError("Invalid transformed feature names.") + if type(model) is Pipeline: + for _, step in model.steps: + result = output_names(step, names) + if result is None: + return None + names = result + return names + return leaf_names(model, names) + + +def leaf_names(model: Any, names: list[str]) -> list[str] | None: + if type(model) is FunctionTransformer and model.feature_names_out is None: + return names + if type(model) is PolynomialFeatures: + return checked_names(polynomial_names(model, names)) + if type(model) is ColumnTransformer: + return column_names(model, names) + if type(model) is OneHotEncoder: + # Check string conversion/concatenation bounds before sklearn allocates names. + for categories in model.categories_: + for category in categories: + if len(str(category)) > 1024: + raise InvalidModelError("Invalid transformed feature names.") + method = getattr(model, "get_feature_names_out", None) + if method is None: + return None + return checked_names(method(names)) + + +def checked_names(value: Any) -> list[str]: + result = np.asarray(value) + if ( + result.ndim != 1 + or any( + type(name) not in (str, np.str_) or not 1 <= len(name) <= 1024 + for name in result + ) + or len(set(result)) != len(result) + ): + raise InvalidModelError("Invalid transformed feature names.") + return [str(name) for name in result] + + +def column_names(model: ColumnTransformer, names: list[str]) -> list[str] | None: + if type(model.verbose_feature_names_out) is not bool: + raise InvalidModelError("Invalid transformed feature names.") + result = [] + for name, transformer, _ in model.transformers_: + selected = model._transformer_to_input_indices[name] + if not selected or (type(transformer) is str and transformer == "drop"): + continue + branch = output_names(transformer, [names[i] for i in selected]) + if branch is None: + return None + result.extend( + f"{name}__{item}" if model.verbose_feature_names_out else item + for item in branch + ) + return checked_names(result) diff --git a/src/mlbundle/_sklearn/transform_width.py b/src/mlbundle/_sklearn/transform_width.py new file mode 100644 index 0000000..22314f0 --- /dev/null +++ b/src/mlbundle/_sklearn/transform_width.py @@ -0,0 +1,270 @@ +"""Closed, data-free fitted width proof; no samples or dummy predictions.""" + +from math import comb +from typing import Any + +import numpy as np +from sklearn.compose import ColumnTransformer +from sklearn.decomposition import PCA +from sklearn.feature_selection import ( + GenericUnivariateSelect, + SelectFdr, + SelectFpr, + SelectFwe, + SelectKBest, + SelectPercentile, + VarianceThreshold, +) +from sklearn.impute import MissingIndicator, SimpleImputer +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import ( + FunctionTransformer, + KBinsDiscretizer, + MaxAbsScaler, + MinMaxScaler, + Normalizer, + OneHotEncoder, + OrdinalEncoder, + PolynomialFeatures, + RobustScaler, + StandardScaler, +) +from sklearn.utils._mask import _get_mask + +from mlbundle.errors import UnsupportedModelError + +from ._width_checks import dimensions, indices, input_width, vector, width +from .column_width import column_width +from .encoder_width import encoder_width +from .trusted import NUMPY_FUNCTIONS + +SELECTORS = ( + SelectKBest, + SelectPercentile, + SelectFpr, + SelectFdr, + SelectFwe, + GenericUnivariateSelect, +) + + +def identity(model: Any) -> bool: + return model is None or (type(model) is str and model == "passthrough") + + +def output_width(model: Any, count: int, *, depth: int = 0) -> int: + width(count) + if depth > 16: + raise UnsupportedModelError(reason="Transformer nesting exceeds the limit.") + if identity(model): + return count + if type(model) is Pipeline: + for _, step in model.steps: + count = output_width(step, count, depth=depth + 1) + return count + input_width(model, count) + if type(model) is not FunctionTransformer or model.validate: + dimensions(width(getattr(model, "n_features_in_", None)) == count) + if type(model) is ColumnTransformer: + return column_width(model, count, depth, output_width) + return leaf_width(model, count) + + +def leaf_width(model: Any, count: int) -> int: + rule = WIDTH_RULES.get(type(model)) + if rule is None: + raise UnsupportedModelError( + (f"{type(model).__module__}.{type(model).__name__}",), + reason="Transformer output width cannot be verified without data.", + ) + return rule(model, count) + + +def scaler_width(model: Any, count: int) -> int: + if type(model) is StandardScaler: + for name, required in ( + ("mean_", model.with_mean or model.with_std), + ("var_", model.with_std), + ("scale_", model.with_std), + ): + value = vector(model, name, count, required=bool(required)) + dimensions(required or value is None) + samples = np.asarray(model.n_samples_seen_) + dimensions(samples.ndim == 0 or samples.shape == (count,)) + return count + fields = { + MinMaxScaler: ("min_", "scale_", "data_min_", "data_max_", "data_range_"), + MaxAbsScaler: ("scale_", "max_abs_"), + RobustScaler: (), + Normalizer: (), + } + if type(model) in fields: + for name in fields[type(model)]: + vector(model, name, count) + if type(model) is RobustScaler: + for name, required in ( + ("center_", model.with_centering), + ("scale_", model.with_scaling), + ): + value = vector(model, name, count, required=bool(required)) + dimensions(required or value is None) + return count + return count + + +def function_width(model: Any, count: int) -> int: + if any( + func is not None and all(func is not native for native in NUMPY_FUNCTIONS) + for func in (model.func, model.inverse_func) + ): + raise UnsupportedModelError( + reason="Transformer output width cannot be verified without data." + ) + if model.kw_args not in (None, {}) or model.inv_kw_args not in (None, {}): + raise UnsupportedModelError( + reason="Transformer output width cannot be verified without data." + ) + return count + + +def pca_width(model: Any, count: int) -> int: + result = width(model.n_components_) + dimensions( + result <= count and np.asarray(model.components_).shape == (result, count) + ) + for name in ( + "explained_variance_", + "explained_variance_ratio_", + "singular_values_", + ): + vector(model, name, result) + vector(model, "mean_", count) + return result + + +def selector_width(model: Any, count: int) -> int: + if type(model) is VarianceThreshold: + vector(model, "variances_", count) + else: + vector(model, "scores_", count) + vector(model, "pvalues_", count, required=False) + support = np.asarray(model.get_support()) + dimensions(support.shape == (count,) and support.dtype.kind == "b") + return width(np.count_nonzero(support)) + + +def imputer_width(model: SimpleImputer, count: int) -> int: + statistics = vector(model, "statistics_", count) + dimensions( + type(model.keep_empty_features) is bool and type(model.add_indicator) is bool + ) + result = ( + count + if model.keep_empty_features + else int( + np.count_nonzero(np.logical_not(np.asarray(_get_mask(statistics, np.nan)))) + ) + ) + indicator = model.indicator_ + dimensions((indicator is not None) == model.add_indicator) + if indicator is not None: + dimensions( + type(indicator) is MissingIndicator + and indicator._precomputed is True + and indicator.features == "missing-only" + and indicator.error_on_new is False + ) + dimensions(width(indicator._n_features) == count) + selected = indices(indicator.features_, count) + dimensions(selected == sorted(selected)) + result += len(selected) + return width(result) + + +def bins_width(model: KBinsDiscretizer, count: int) -> int: + bins = vector(model, "n_bins_", count) + dimensions( + bins is not None and bins.dtype.kind in "iu" and len(model.bin_edges_) == count + ) + counts = [width(item) for item in np.asarray(bins)] + for edges, size in zip(model.bin_edges_, counts, strict=True): + values = np.asarray(edges) + dimensions( + values.shape == (size + 1,) + and values.dtype.kind == "f" + and not np.isnan(values).any() + and bool(np.all(np.diff(values) > 0)) + ) + if model.encode == "ordinal": + dimensions(not hasattr(model, "_encoder")) + return count + dimensions( + model.encode in ("onehot", "onehot-dense") + and type(model._encoder) is OneHotEncoder + ) + input_width(model._encoder, count) + dimensions( + all( + np.array_equal(cats, np.arange(size)) + for cats, size in zip(model._encoder.categories_, counts, strict=True) + ) + ) + result = encoder_width(model._encoder, count) + dimensions( + result == sum(counts) + and model._encoder.sparse_output == (model.encode == "onehot") + ) + return width(result) + + +def polynomial_width(model: PolynomialFeatures, count: int) -> int: + minimum, maximum = model._min_degree, model._max_degree + dimensions( + type(minimum) is int + and type(maximum) is int + and 0 <= minimum <= maximum <= 100_000 + ) + degrees = model.degree if isinstance(model.degree, tuple) else (0, model.degree) + dimensions( + tuple(degrees) == (minimum, maximum) + and type(model.include_bias) is bool + and type(model.interaction_only) is bool + ) + + # Check the bounded count before any combinations or powers_ expansion. + def total(start: int) -> int: + result = int(model.include_bias) + for degree in range( + max(1, start), + min(maximum, count) + 1 if model.interaction_only else maximum + 1, + ): + result += ( + comb(count, degree) + if model.interaction_only + else comb(count + degree - 1, degree) + ) + width(result) + return width(result) + + result = total(minimum) + dimensions( + width(model.n_output_features_) == result + and width(model._n_out_full) == total(0) + ) + return result + + +WIDTH_RULES = { + **dict.fromkeys( + (StandardScaler, MinMaxScaler, MaxAbsScaler, RobustScaler, Normalizer), + scaler_width, + ), + FunctionTransformer: function_width, + PCA: pca_width, + **dict.fromkeys((VarianceThreshold, *SELECTORS), selector_width), + OneHotEncoder: encoder_width, + OrdinalEncoder: encoder_width, + SimpleImputer: imputer_width, + KBinsDiscretizer: bins_width, + PolynomialFeatures: polynomial_width, +} diff --git a/src/mlbundle/_sklearn/transformers.py b/src/mlbundle/_sklearn/transformers.py new file mode 100644 index 0000000..0e3f5d2 --- /dev/null +++ b/src/mlbundle/_sklearn/transformers.py @@ -0,0 +1,69 @@ +"""Transformer-only component using the estimator's skops/state/parameter owners.""" + +from typing import Any, cast + +from sklearn.exceptions import NotFittedError +from sklearn.pipeline import Pipeline +from sklearn.utils.validation import check_is_fitted + +from mlbundle._pipeline_contract import TransformerInfo +from mlbundle.errors import UnsupportedModelError + +from .parameters import parameters +from .payload import validate_payload +from .pipeline_scope import native_types, reject_placement, validate_components +from .serialization import SkopsIO +from .structure import validate_tree_storage + + +def validate(model: Any) -> None: + if type(model) is not Pipeline or not model.steps: + raise UnsupportedModelError( + reason="Transformer member must be a nonempty exact Pipeline." + ) + validate_components(model) + found = native_types(model) + if found: + reject_placement(found) + for _, step in model.steps: + if step is None or (type(step) is str and step == "passthrough"): + continue + if not callable(getattr(step, "transform", None)): + raise UnsupportedModelError( + reason="Transformer component contains a non-transformer." + ) + try: + check_is_fitted(step) + except NotFittedError: + raise UnsupportedModelError( + reason="Composite transformers must be fitted." + ) from None + validate_tree_storage(model) + parameters(model) + + +class Adapter: + """Transformer snapshot owner for one composite export/load.""" + + def __init__(self) -> None: + self._io = SkopsIO(validate, validate_payload) + + def serialize(self, model: Pipeline) -> bytes: + if type(model) is not Pipeline or not model.steps: + raise UnsupportedModelError( + reason="Transformer member must be a nonempty exact Pipeline." + ) + validate_components(model) + return self._io.serialize(model) + + def deserialize(self, data: bytes) -> Pipeline: + return cast(Pipeline, self._io.deserialize(data)) + + def claims(self, model: Pipeline) -> TransformerInfo: + validate(model) + return TransformerInfo( + member="model.skops", + estimator_class="Pipeline", + hyperparameters=parameters(model), + state_sha256=self._io.state(model), + ) diff --git a/src/mlbundle/cli.py b/src/mlbundle/cli.py index 6ed5c10..8483880 100644 --- a/src/mlbundle/cli.py +++ b/src/mlbundle/cli.py @@ -11,7 +11,7 @@ from . import inspect, load from .contract import MemberName from .errors import MissingOptionalDependencyError, MLBundleError -from .results import MetadataView +from .results import LoadedBundle, MetadataView _DISPLAY_LIMIT = 512 _NOTICE = ( @@ -54,8 +54,9 @@ def _summary(view: MetadataView) -> str: f"Counts: features={model.n_features}, outputs={model.n_outputs}, classes={_display(model.n_classes)}", f"Features ({manifest.features.origin.value}): {_display(manifest.features.names)}", f"Class labels (output-major): {_display(manifest.target.class_labels)}", - "Members:", ] + lines.extend(_pipeline_summary(view)) + lines.append("Members:") for member in view.members: record = manifest.integrity.get(member) if record is None: @@ -89,6 +90,21 @@ def _summary(view: MetadataView) -> str: return "\n".join(lines) +def _pipeline_summary(view: MetadataView | LoadedBundle) -> list[str]: + model = view.manifest.model + recipe = model.pipeline + if recipe is None: + return [] + leaf = recipe.estimator + boundary = recipe.transformed_features + return [ + f"Steps: {_display(recipe.step_names)}", + f"Estimator: {leaf.library.value} {_display(leaf.claims.estimator_class)} ({leaf.serialization.value})", + f"Boundary: input={model.n_features}, transformed={boundary.n_features}", + f"Transformed features ({boundary.origin}): {_display(boundary.names)}", + ] + + def _parser() -> argparse.ArgumentParser: parser = _Parser(prog="mlbundle", description=_NOTICE) commands = parser.add_subparsers(dest="command", required=True) @@ -156,6 +172,9 @@ def main(argv: Sequence[str] | None = None) -> int: f"Valid bundle: {bundle.library.value} {_display(bundle.class_name)}; " "verified format, integrity, schema/samples and native model claims/structure." ) + details = _pipeline_summary(bundle) + if details: + output += "\n" + "\n".join(details) _write_output(output) except MissingOptionalDependencyError as error: return _write_error( diff --git a/src/mlbundle/contract.py b/src/mlbundle/contract.py index 3501fdb..9db9dcd 100644 --- a/src/mlbundle/contract.py +++ b/src/mlbundle/contract.py @@ -24,6 +24,7 @@ class SerializationFormat(StrEnum): UBJSON = "ubjson" CBM = "cbm" ONNX = "onnx" + PIPELINE = "pipeline" class FeatureOrigin(StrEnum): diff --git a/src/mlbundle/limits.py b/src/mlbundle/limits.py index 689014c..3f3b660 100644 --- a/src/mlbundle/limits.py +++ b/src/mlbundle/limits.py @@ -17,7 +17,7 @@ class ArchiveLimits(ContractModel): - max_members: int = Field(default=5, ge=2, le=5) + max_members: int = Field(default=6, ge=2, le=6) max_archive_bytes: int = Field(default=512 * MIB, gt=0, le=512 * MIB) max_compressed_bytes: int = Field(default=512 * MIB, gt=0, le=512 * MIB) max_member_bytes: int = Field(default=512 * MIB, gt=0, le=512 * MIB) diff --git a/src/mlbundle/manifest.py b/src/mlbundle/manifest.py index f899d58..b934a1a 100644 --- a/src/mlbundle/manifest.py +++ b/src/mlbundle/manifest.py @@ -10,6 +10,7 @@ from pydantic import Field, JsonValue, ValidationError, field_validator, model_validator from ._base import ContractModel, Name, ShortString +from ._pipeline_contract import PipelineInfo, expected_model_members from .canonical import canonical_dumps, canonical_loads from .claims import ( ClassLabels, @@ -46,10 +47,31 @@ class ModelInfo(EstimatorInfo): library: Library serialization: SerializationFormat + pipeline: PipelineInfo | None = None @model_validator(mode="after") def native_serialization(self) -> ModelInfo: - if self.serialization != MODEL_MEMBERS[self.library][0]: + if self.serialization == SerializationFormat.PIPELINE: + if self.library != Library.SKLEARN or self.pipeline is None: + raise ValueError( + "Composite representation requires sklearn and a recipe." + ) + leaf = self.pipeline.estimator.claims + if ( + self.estimator_class != "Pipeline" + or self.state_sha256 is not None + or (self.kind, self.n_classes, self.n_outputs) + != (leaf.kind, leaf.n_classes, leaf.n_outputs) + or set(self.hyperparameters) != {"memory", "verbose", "transform_input"} + or self.hyperparameters["memory"] is not None + or self.hyperparameters["transform_input"] is not None + or type(self.hyperparameters["verbose"]) is not bool + ): + raise ValueError("Invalid composite outer claims.") + elif ( + self.pipeline is not None + or self.serialization != MODEL_MEMBERS[self.library][0] + ): raise ValueError("Library and serialization must agree.") return self @@ -96,7 +118,7 @@ class Manifest(ContractModel): features: FeatureInfo target: TargetInfo environment: TrainingEnvironment - integrity: dict[MemberName, IntegrityRecord] = Field(min_length=1, max_length=4) + integrity: dict[MemberName, IntegrityRecord] = Field(min_length=1, max_length=5) references: MemberReferences user_metadata: dict[str, JsonValue] = Field(default_factory=dict, max_length=1024) @@ -135,7 +157,15 @@ def consistent_manifest(self) -> Manifest: if len(self.features.names) != self.model.n_features: raise ValueError("Feature count and names must agree.") validate_labels(self.model, self.target.class_labels) - expected = {MODEL_MEMBERS[self.model.library][1]} + expected = expected_model_members(self.model.library, self.model.pipeline) + if self.model.pipeline is not None and ( + canonical_dumps(self.target.class_labels) + != canonical_dumps(self.model.pipeline.estimator.claims.class_labels) + or self.references.structure is None + ): + raise ValueError( + "Composite labels and structure must agree with the recipe." + ) expected.update( MemberName(member) for member in self.references.model_dump().values() @@ -156,7 +186,7 @@ def consistent_manifest(self) -> Manifest: return self def verified_claims(self) -> ModelClaims: - fields = self.model.model_dump(exclude={"library", "serialization"}) + fields = self.model.model_dump(exclude={"library", "serialization", "pipeline"}) return ModelClaims( **fields, feature_names=( @@ -169,8 +199,14 @@ def verified_claims(self) -> ModelClaims: onnx=self.environment.onnx, ) + def _wire_fields(self) -> dict[str, JsonValue]: + fields = self.model_dump(mode="json", by_alias=True) + if self.model.pipeline is None: + fields["model"].pop("pipeline") + return fields + def to_bytes(self) -> bytes: - return canonical_dumps(self.model_dump(mode="json", by_alias=True)) + return canonical_dumps(self._wire_fields()) @classmethod def from_bytes(cls, data: bytes) -> Manifest: diff --git a/src/mlbundle/model_io.py b/src/mlbundle/model_io.py index 7ad2535..715036e 100644 --- a/src/mlbundle/model_io.py +++ b/src/mlbundle/model_io.py @@ -1,102 +1,28 @@ """Shared export and validating load; adapters own all native-model behavior.""" from collections.abc import Mapping, Sequence -from datetime import UTC, datetime -from importlib.metadata import version from os import PathLike from typing import cast -from uuid import uuid4 from pydantic import JsonValue from ._container_rules import MEMBER_ORDER, BundleSource, effective_limits -from .claims import ( - Label, - ModelClaims, - verify_claims, - verify_structure, - verify_structure_bytes, -) +from ._export_manifest import manifest_parts +from ._native_io import prepare_native +from .claims import Label, verify_claims, verify_structure_bytes from .container import read_validated_container -from .contract import ( - FORMAT_NAME, - FORMAT_VERSION, - MODEL_MEMBERS, - FeatureOrigin, - Library, - MemberName, - SerializationFormat, -) +from .contract import Library, MemberName from .declarations import ExportDeclarations, export_declarations, resolve_labels -from .environment import capture_environment -from .errors import ClaimMismatchError, LimitExceededError +from .errors import ClaimMismatchError from .inspection import metadata_view from .limits import ArchiveLimits -from .registry import find_adapter, get_adapter +from .registry import find_adapter, get_adapter, is_composite from .results import LoadedBundle, MetadataView from .schema import read_declarations -from .training import TrainingDeclaration, prepare_training +from .training import prepare_training from .writing import write_validated_container -def _manifest_parts( - claims: ModelClaims, - library: Library, - serialization: SerializationFormat, - *, - structure: dict[str, JsonValue] | None, - feature_names: Sequence[str] | None, - training: TrainingDeclaration | None, - target_name: str | None, - metadata: Mapping[str, JsonValue] | None, -) -> dict[str, object]: - names = claims.feature_names - origin = FeatureOrigin.MODEL - if names is None: - origin = ( - FeatureOrigin.DECLARED - if feature_names is not None - else FeatureOrigin.GENERATED - ) - names = ( - list(feature_names) - if feature_names is not None - else [f"feature_{index}" for index in range(1, claims.n_features + 1)] - ) - if training is not None: - names, origin = training.names, FeatureOrigin.DATAFRAME - model = claims.model_dump( - mode="json", exclude={"feature_names", "feature_dtypes", "class_labels", "onnx"} - ) - model.update(library=library.value, serialization=serialization.value) - return { - "format_name": FORMAT_NAME, - "format_version": FORMAT_VERSION, - "bundle_id": str(uuid4()), - "created_at": datetime.now(UTC).isoformat(), - "writer_version": version("mlbundle"), - "model": model, - "features": { - "names": names, - "dtypes": [None] * claims.n_features - if training is None - else training.dtypes, - "model_dtypes": claims.feature_dtypes, - "origin": origin.value, - }, - "target": {"class_labels": claims.class_labels, "name": target_name}, - "environment": capture_environment(onnx=claims.onnx).model_dump(mode="json"), - "references": { - "structure": None if structure is None else MemberName.STRUCTURE.value, - "schema": None if training is None else MemberName.SCHEMA.value, - "samples": None - if training is None or training.samples is None - else MemberName.SAMPLES.value, - }, - "user_metadata": {} if metadata is None else dict(metadata), - } - - def export( model: object, destination: str | PathLike[str], @@ -129,12 +55,14 @@ def export( """ selected = effective_limits(limits) declarations = export_declarations(class_labels) - adapter = find_adapter(model) - model = adapter.prepare_export(model, declarations) - data = adapter.serialize(model) - if len(data) > selected.member_bytes(adapter.model_member): - raise LimitExceededError("Model member size limit exceeded.") - claims = resolve_labels(adapter.extract_claims(model), declarations) + composite = None + if is_composite(model): + from ._composite.io import prepare + + prepared = composite = prepare(model, declarations, selected) + else: + prepared = prepare_native(model, find_adapter(model), declarations, selected) + claims = prepared.claims training = prepare_training( training_data, claims, @@ -143,29 +71,27 @@ def export( sample_rows=sample_rows, limits=selected, ) - structure = adapter.extract_structure(model) - restored = adapter.deserialize(data) - verify_claims( - claims, resolve_labels(adapter.extract_claims(restored), declarations) - ) - structure_bytes = verify_structure(structure, adapter.extract_structure(restored)) - members = {adapter.model_member: data} - if structure is not None: - members[MemberName.STRUCTURE] = structure_bytes + members = prepared.members + structure = prepared.structure if training is not None: members[MemberName.SCHEMA] = training.schema if training.samples is not None: members[MemberName.SAMPLES] = training.samples - parts = _manifest_parts( + parts = manifest_parts( claims, - adapter.library, - MODEL_MEMBERS[adapter.library][0], + prepared.library, + prepared.serialization, structure=structure, feature_names=feature_names, training=training, target_name=target_name, metadata=metadata, ) + if composite is not None: + recipe, structure = composite.finish( + cast(list[str], cast(dict, parts["features"])["names"]) + ) + cast(dict, parts["model"])["pipeline"] = recipe.model_dump(mode="json") _, validated = write_validated_container( parts, members, destination=destination, overwrite=overwrite, limits=selected ) @@ -178,26 +104,39 @@ def load(source: BundleSource, *, limits: ArchiveLimits | None = None) -> Loaded container = read_validated_container(source, limits=selected) manifest, members = container.manifest, container.members schema, samples = read_declarations(manifest, container.json, selected) - adapter = get_adapter(manifest.model.library) - model = adapter.deserialize(members[adapter.model_member]) - actual = adapter.extract_claims(model) - expected = manifest.verified_claims() - # Check native dimensions before using any label declaration from the bundle. - verify_claims( - expected.model_copy(update={"class_labels": actual.class_labels}), actual - ) - actual = resolve_labels( - actual, - ExportDeclarations( - class_labels=manifest.target.class_labels - if actual.class_labels is None - else None - ), - ) - verify_claims(expected, actual) + if manifest.model.pipeline is not None: + get_adapter(Library.SKLEARN) + from ._composite.io import load_components + + model, predictor, actual, extracted = load_components( + manifest.model.pipeline, + members, + manifest.features.names, + cast(bool, manifest.model.hyperparameters["verbose"]), + ) + verify_claims(manifest.verified_claims(), actual) + else: + adapter = get_adapter(manifest.model.library) + model = adapter.deserialize(members[adapter.model_member]) + actual = adapter.extract_claims(model) + expected = manifest.verified_claims() + # Check native dimensions before using any label declaration from the bundle. + verify_claims( + expected.model_copy(update={"class_labels": actual.class_labels}), actual + ) + actual = resolve_labels( + actual, + ExportDeclarations( + class_labels=manifest.target.class_labels + if actual.class_labels is None + else None + ), + ) + verify_claims(expected, actual) + predictor = adapter.predictor(model) + extracted = adapter.extract_structure(model) decoded = container.json.get(MemberName.STRUCTURE) structure = None if decoded is None else decoded.value - extracted = adapter.extract_structure(model) if (MemberName.STRUCTURE in members) != (extracted is not None): raise ClaimMismatchError("structure") verify_structure_bytes(b"null" if decoded is None else decoded, extracted) @@ -205,7 +144,7 @@ def load(source: BundleSource, *, limits: ArchiveLimits | None = None) -> Loaded manifest=manifest, members=tuple(name for name in MEMBER_ORDER if name in members), native_model=model, - predictor=adapter.predictor(model), + predictor=predictor, schema=schema, structure=cast(dict[str, JsonValue] | None, structure), samples=samples, diff --git a/src/mlbundle/registry.py b/src/mlbundle/registry.py index eacdd8a..f2561f1 100644 --- a/src/mlbundle/registry.py +++ b/src/mlbundle/registry.py @@ -9,7 +9,9 @@ from .contract import MODEL_MEMBERS, Library from .errors import ( AdapterNotImplementedError, + InvalidModelError, MissingOptionalDependencyError, + MLBundleError, UnsupportedModelError, ) @@ -83,3 +85,19 @@ def find_adapter(model: object) -> Adapter: ): raise error raise UnsupportedModelError + + +def is_composite(model: object) -> bool: + # Ancestry only gates the constant sklearn route, never a model-supplied path. + roots = {base.__module__.split(".")[0] for base in type(model).__mro__} + if "sklearn" not in roots or roots & {"xgboost", "catboost"}: + return False + get_adapter(Library.SKLEARN) + from ._sklearn.pipeline_scope import composite_candidate + + try: + return composite_candidate(model) + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid composite Pipeline state.") from None diff --git a/src/mlbundle/writing.py b/src/mlbundle/writing.py index e7ff3b1..39395b1 100644 --- a/src/mlbundle/writing.py +++ b/src/mlbundle/writing.py @@ -42,8 +42,8 @@ def write(self, data: bytes) -> int: def _finish_manifest( manifest: Manifest | Mapping[str, object], members: dict[MemberName, bytes] ) -> Manifest: - value = ( - manifest.model_dump(mode="json", by_alias=True) + value: dict[str, object] = ( + dict(manifest._wire_fields()) if isinstance(manifest, Manifest) else dict(manifest) ) @@ -228,7 +228,7 @@ def write_validated_container( raise LimitExceededError("Total member size limit exceeded.") finished = _finish_manifest(manifest, content) content[MemberName.MANIFEST] = canonical_dumps( - finished.model_dump(mode="json", by_alias=True), + finished._wire_fields(), max_bytes=selected.member_bytes(MemberName.MANIFEST), ) if sum(map(len, content.values())) > selected.max_total_bytes: diff --git a/tests/pipeline_helpers.py b/tests/pipeline_helpers.py new file mode 100644 index 0000000..798f791 --- /dev/null +++ b/tests/pipeline_helpers.py @@ -0,0 +1,43 @@ +"""Small deterministic composite fixtures, never owner's model files.""" + +import catboost as cb +import numpy as np +import pandas as pd +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler +from xgboost import XGBClassifier, XGBRegressor + +from mlbundle.container import read_container + + +def data(): + rng = np.random.default_rng(7) + frame = pd.DataFrame(rng.uniform(0.2, 2, (24, 3)), columns=list("abc")) + return frame, (frame.a > 1).astype(int) + + +def estimator(library="xgboost", regression=False): + if library == "xgboost": + return (XGBRegressor if regression else XGBClassifier)( + n_estimators=2, max_depth=2, n_jobs=1 + ) + return (cb.CatBoostRegressor if regression else cb.CatBoostClassifier)( + iterations=2, depth=2, thread_count=1, verbose=False, allow_writing_files=False + ) + + +def pipeline(library="xgboost", regression=False, prefix=True): + frame, y = data() + model = Pipeline( + [ + *(([("scale", StandardScaler())]) if prefix else []), + ("est", estimator(library, regression)), + ] + ) + return model.fit(frame, frame.a if regression else y), frame + + +def parts(source): + manifest, members = read_container(source) + members.pop("manifest.json") + return manifest.model_dump(mode="json", by_alias=True), members diff --git a/tests/test_catboost_validation.py b/tests/test_catboost_validation.py index 97d7dc8..0950ef7 100644 --- a/tests/test_catboost_validation.py +++ b/tests/test_catboost_validation.py @@ -36,13 +36,15 @@ class CustomClassifier(cb.CatBoostClassifier): assert not list(tmp_path.iterdir()) -def test_pipeline_rejection_lists_types_and_direct_export(tmp_path): +def test_nested_pipeline_rejection_lists_types_and_placement(tmp_path): model = classifier() with pytest.raises(mlbundle.UnsupportedModelError) as error: - mlbundle.export(Pipeline([("est", model)]), tmp_path / "pipeline.mlbundle") + mlbundle.export( + Pipeline([("inner", Pipeline([("est", model)]))]), + tmp_path / "pipeline.mlbundle", + ) assert "catboost.core.CatBoostClassifier" in error.value.untrusted_types - assert "Export the estimator directly" in str(error.value) - assert 'pipeline.named_steps["est"]' in str(error.value) + assert "last step of the outermost Pipeline" in str(error.value) mlbundle.export(model, tmp_path / "estimator.mlbundle") diff --git a/tests/test_limits.py b/tests/test_limits.py index ae5256e..a5f7dd6 100644 --- a/tests/test_limits.py +++ b/tests/test_limits.py @@ -8,7 +8,7 @@ def test_limits(): limits = ArchiveLimits() - assert limits.max_members == 5 + assert limits.max_members == 6 assert limits.member_bytes(MemberName.SKOPS) == 512 * 1024**2 assert limits.member_bytes(MemberName.STRUCTURE) == 256 * 1024**2 assert ( @@ -19,7 +19,7 @@ def test_limits(): @pytest.mark.parametrize( "fields", [ - {"max_members": 6}, + {"max_members": 7}, {"max_compression_ratio": 201}, {"max_total_bytes": 0}, {"max_structure_bytes": "10"}, diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 7b03b64..d60837c 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -21,6 +21,7 @@ def read(data: dict) -> Manifest: def test_round_trip_and_claim_projection(manifest_data): manifest = read(manifest_data) assert Manifest.from_bytes(manifest.to_bytes()) == manifest + assert manifest.model.pipeline is None # Old wire records omit this extension. assert manifest.to_bytes() == canonical_dumps(manifest_data) claims = manifest.verified_claims() assert claims.feature_names == ["a", "b"] diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..16bfed5 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,260 @@ +"""Composite public round trips, declarations, inspection and prediction errors.""" + +import builtins +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest +from sklearn import config_context +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler +from sklearn.tree import DecisionTreeClassifier + +import mlbundle +from conftest import build_zip +from mlbundle._sklearn.trusted import TRUSTED_TYPES +from mlbundle.canonical import canonical_dumps +from pipeline_helpers import data, estimator, parts, pipeline + +pytestmark = [ + pytest.mark.filterwarnings("ignore:X does not have valid feature names"), + pytest.mark.filterwarnings("ignore:X has feature names"), +] + + +@pytest.mark.parametrize("library", ["xgboost", "catboost"]) +@pytest.mark.parametrize("regression", [False, True]) +@pytest.mark.parametrize("prefix", [False, True]) +def test_composite_roundtrip(library, regression, prefix, tmp_path): + model, frame = pipeline(library, regression, prefix) + before = vars(model).copy() + final_before = vars(model.steps[-1][1]).copy() + destination = tmp_path / "composite.mlbundle" + view = mlbundle.export(model, destination) + bundle = mlbundle.load(destination) + assert vars(model) == before + assert vars(model.steps[-1][1]).keys() == final_before.keys() + assert bundle.manifest == view.manifest + assert bundle.library == "sklearn" and bundle.class_name == "Pipeline" + assert type(bundle.native_model) is Pipeline + recipe = bundle.manifest.model.pipeline + assert recipe.estimator.library == library + assert (recipe.transformers is not None) == prefix + assert recipe.transformed_features.n_features == 3 + assert bundle.structure["family"] == library + assert bundle.structure["pipeline"]["features"]["n_features"] == 3 + for values in [frame, frame.to_numpy(), frame.to_numpy().tolist()]: + predict = "predict" if regression else "predict_proba" + expected = getattr(model, predict)(values) + assert np.array_equal(getattr(bundle, predict)(values), expected) + assert np.array_equal(getattr(bundle.native_model, predict)(values), expected) + with pytest.raises(ValueError, match="requires"): + getattr(bundle, "predict_proba" if regression else "predict")(frame) + with pytest.raises(FileExistsError): + mlbundle.export(model, destination) + mlbundle.export(model, destination, overwrite=True) + assert not any(name.startswith(("xgboost.", "catboost.")) for name in TRUSTED_TYPES) + + +def test_schema_samples_lowered_limits_and_labels(tmp_path): + model, frame = pipeline() + destination = tmp_path / "with-data.mlbundle" + mlbundle.export( + model, + destination, + training_data=frame, + sample_rows=2, + class_labels=[["no", "yes"]], + ) + bundle = mlbundle.load(destination) + assert len(bundle.members) == 6 + assert bundle.samples == frame.head(2).to_dict("records") + assert bundle.class_labels == [["no", "yes"]] + assert list(bundle.native_model.classes_) == [0, 1] + assert "model.skops" in bundle.manifest.integrity + for limits in [ + mlbundle.ArchiveLimits(max_members=5), + mlbundle.ArchiveLimits(max_member_bytes=1), + mlbundle.ArchiveLimits(max_structure_bytes=1), + ]: + with pytest.raises(mlbundle.LimitExceededError): + mlbundle.export( + model, + tmp_path / "never.mlbundle", + training_data=frame, + sample_rows=2, + limits=limits, + ) + assert not (tmp_path / "never.mlbundle").exists() + model, _ = pipeline("catboost") + with pytest.raises( + mlbundle.UnsupportedModelError, match="class_labels is unsupported" + ): + mlbundle.export(model, tmp_path / "never.mlbundle", class_labels=[[0, 1]]) + + +def test_inspect_import_isolation_and_cli(tmp_path, monkeypatch): + model, _ = pipeline() + destination = tmp_path / "composite.mlbundle" + mlbundle.export(model, destination) + original_import = builtins.__import__ + + def guarded(name, *args, **kwargs): + assert name.split(".")[0] not in { + "sklearn", + "skops", + "xgboost", + "catboost", + "numpy", + "pandas", + } + return original_import(name, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(builtins, "__import__", guarded) + assert mlbundle.inspect(destination).manifest.model.pipeline.step_names == [ + "scale", + "est", + ] + executable = Path(sys.executable).with_name( + "mlbundle.exe" if sys.platform == "win32" else "mlbundle" + ) + for command in ("inspect", "verify"): + result = subprocess.run( + [executable, command, str(destination)], + capture_output=True, + text=True, + check=True, + ) + assert not result.stderr + assert '"Pipeline"' in result.stdout and '"XGBClassifier"' in result.stdout + assert ( + "Steps:" in result.stdout + and "Boundary: input=3, transformed=3" in result.stdout + ) + + +@pytest.mark.parametrize("sentinel", [None, "passthrough"]) +def test_identity_prefix_and_array_feature_declarations(sentinel, tmp_path): + frame, y = data() + model = Pipeline([("identity", sentinel), ("est", estimator())]).fit( + frame.to_numpy(), y + ) + path = tmp_path / "identity.mlbundle" + mlbundle.export(model, path, feature_names=list("abc")) + bundle = mlbundle.load(path) + assert bundle.manifest.model.pipeline.transformed_features.names == list("abc") + assert np.array_equal( + bundle.predict_proba(frame.to_numpy()), model.predict_proba(frame.to_numpy()) + ) + + +def test_per_step_pandas_output_and_global_prediction_context(tmp_path): + frame, y = data() + model = Pipeline( + [ + ("scale", StandardScaler().set_output(transform="pandas")), + ("est", estimator()), + ] + ).fit(frame, y) + path = tmp_path / "pandas.mlbundle" + mlbundle.export(model, path) + bundle = mlbundle.load(path) + assert bundle.manifest.model.pipeline.estimator.claims.feature_names == list("abc") + with config_context(transform_output="pandas", enable_metadata_routing=True): + assert np.array_equal(bundle.predict_proba(frame), model.predict_proba(frame)) + assert mlbundle.load(path).feature_names == tuple("abc") + + +def test_prediction_failures_are_bounded(tmp_path, monkeypatch): + model, frame = pipeline() + path = tmp_path / "prediction.mlbundle" + mlbundle.export(model, path) + bundle = mlbundle.load(path) + for values in [[[1]], [["secret", 1, 2]], [[np.inf, 1, 2]], frame[list("bac")]]: + with pytest.raises(mlbundle.InvalidModelError) as error: + bundle.predict_proba(values) + assert "secret" not in str(error.value) + prefix = bundle.predictor.prefix + monkeypatch.setattr(prefix, "transform", lambda _: np.zeros((1, 3))) + with pytest.raises(mlbundle.InvalidModelError, match="preserve prediction rows"): + bundle.predict_proba(frame) + + def fail(_): + raise RuntimeError("secret") + + monkeypatch.setattr(prefix, "transform", fail) + with pytest.raises(mlbundle.InvalidModelError, match="transformation failed"): + bundle.predict_proba(frame) + + +def test_old_standalone_and_all_sklearn_representation(tmp_path): + frame, y = data() + for model in [ + estimator().fit(frame, y), + Pipeline([("scale", StandardScaler()), ("tree", DecisionTreeClassifier())]).fit( + frame, y + ), + ]: + path = tmp_path / "old.mlbundle" + mlbundle.export(model, path, overwrite=True) + bundle = mlbundle.load(path) + assert bundle.manifest.model.pipeline is None + record, members = parts(path) + record["model"].pop("pipeline") + legacy = build_zip( + [("manifest.json", canonical_dumps(record)), *members.items()] + ) + assert mlbundle.load(legacy).manifest.model.pipeline is None + assert np.array_equal(bundle.predict_proba(frame), model.predict_proba(frame)) + if type(model) is Pipeline: + assert ( + bundle.structure is None + and bundle.manifest.model.serialization == "skops" + ) + + +def test_full_export_roundtrip_rejects_component_loss(tmp_path, monkeypatch): + from mlbundle._sklearn import serialization + + model, _ = pipeline() + original = serialization.sio.loads + + def changed(data, **kwargs): + restored = original(data, **kwargs) + restored.steps[0][1].mean_[0] += 1 + return restored + + monkeypatch.setattr(serialization.sio, "loads", changed) + with pytest.raises(mlbundle.InvalidModelError, match="state does not survive"): + mlbundle.export(model, tmp_path / "never.mlbundle") + assert not list(tmp_path.iterdir()) + + +def test_transformer_snapshots_are_local_to_each_public_operation( + tmp_path, monkeypatch +): + from mlbundle._sklearn import transformers + + model, _ = pipeline() + calls = [] + original = transformers.validate_payload + + def checked(data): + calls.append(data) + return original(data) + + monkeypatch.setattr(transformers, "validate_payload", checked) + path = tmp_path / "model.mlbundle" + mlbundle.export(model, path) + assert len(calls) == 2 + calls.clear() + mlbundle.load(path) + assert len(calls) == 2 + model.steps[0][1].extra_ = 1 + with pytest.raises( + mlbundle.InvalidModelError, match="Unknown sklearn estimator state" + ): + mlbundle.export(model, path, overwrite=True) diff --git a/tests/test_pipeline_names.py b/tests/test_pipeline_names.py new file mode 100644 index 0000000..dae89d7 --- /dev/null +++ b/tests/test_pipeline_names.py @@ -0,0 +1,194 @@ +"""Bounded names, selector dimensionality and categorical native boundaries.""" + +import subprocess +import sys + +import numpy as np +import pandas as pd +import pytest +from sklearn.compose import ColumnTransformer +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import ( + FunctionTransformer, + OneHotEncoder, + OrdinalEncoder, + PolynomialFeatures, + StandardScaler, +) + +import mlbundle +from mlbundle._sklearn.transform_names import output_names +from mlbundle._sklearn.transform_width import output_width +from pipeline_helpers import data, estimator, pipeline + + +@pytest.mark.parametrize("degree", [1, 2, (2, 3)]) +@pytest.mark.parametrize("interaction", [False, True]) +def test_polynomial_names_match_native_without_powers(degree, interaction, monkeypatch): + frame, _ = data() + model = PolynomialFeatures(degree, interaction_only=interaction).fit(frame) + expected = model.get_feature_names_out().tolist() + monkeypatch.setattr( + PolynomialFeatures, + "powers_", + property(lambda _: pytest.fail("dense powers_ allocation")), + ) + assert output_names(model, list("abc")) == expected + + +def test_large_polynomial_names_and_name_work_budget(monkeypatch): + model = PolynomialFeatures(1, include_bias=False).fit(np.zeros((1, 100_000))) + monkeypatch.setattr( + PolynomialFeatures, + "powers_", + property(lambda _: pytest.fail("80 GB powers_ allocation")), + ) + names = [f"x{i}" for i in range(100_000)] + assert output_width(model, 100_000) == 100_000 + assert output_names(model, names) == names + model = PolynomialFeatures(500).fit([[1]]) + with pytest.raises(mlbundle.LimitExceededError, match="feature-name work"): + output_names(model, ["x"]) + model = PolynomialFeatures(100_000, interaction_only=True, include_bias=False).fit( + [[1]] + ) + assert output_names(model, ["x"]) == ["x"] + + +@pytest.mark.parametrize("formatter", ["{feature_name:>10000000000}", "{feature_name}"]) +def test_formatter_rejected_before_expansion(formatter, tmp_path, monkeypatch): + frame, y = data() + transform = ColumnTransformer( + [("sc", StandardScaler(), [0])], verbose_feature_names_out=formatter + ) + model = Pipeline([("ct", transform), ("est", estimator())]).fit(frame, y) + monkeypatch.setattr( + ColumnTransformer, + "get_feature_names_out", + lambda *_: pytest.fail("unbounded formatter"), + ) + with pytest.raises( + mlbundle.InvalidModelError, match="Invalid transformed feature names" + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +def test_numpy_string_selectors_and_scalar_cache_rejection(tmp_path): + frame, y = data() + transform = ColumnTransformer([("sc", StandardScaler(), np.array(["a", "b"]))]).fit( + frame + ) + model = Pipeline( + [("ct", transform), ("est", estimator().fit(transform.transform(frame), y))] + ) + path = tmp_path / "numpy-selector.mlbundle" + mlbundle.export(model, path) + assert np.array_equal( + mlbundle.load(path).predict_proba(frame), model.predict_proba(frame) + ) + transform = ColumnTransformer([("sc", StandardScaler(), [0])]).fit(frame) + model.steps[0] = ("ct", transform) + model.steps[1] = ("est", estimator().fit(transform.transform(frame), y)) + transform.transformers[0] = ("sc", transform.transformers[0][1], 0) + transform.transformers_[0] = ("sc", transform.transformers_[0][1], 0) + transform._columns = [0] + with pytest.raises( + mlbundle.InvalidModelError, match="ColumnTransformer input mapping" + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +def test_missing_ordinal_cache_is_rejected(tmp_path): + frame, y = data() + model = Pipeline([("enc", OrdinalEncoder()), ("est", estimator())]).fit(frame, y) + del model.steps[0][1]._missing_indices + with pytest.raises( + mlbundle.InvalidModelError, match="Invalid composite Pipeline state" + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +def test_fitted_selector_rank_and_manual_name_boundaries(tmp_path): + frame, y = data() + transform = ColumnTransformer([("sc", StandardScaler(), [0])]).fit(frame) + model = Pipeline( + [("ct", transform), ("est", estimator().fit(transform.transform(frame), y))] + ) + transform.transformers_[0] = ("sc", transform.transformers_[0][1], 0) + with pytest.raises( + mlbundle.InvalidModelError, match="ColumnTransformer input mapping" + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + for transform in [ + PolynomialFeatures(2), + ColumnTransformer([("sc", StandardScaler(), [0])]), + ]: + model = Pipeline( + [ + ("sc", StandardScaler().set_output(transform="pandas")), + ("tr", transform), + ("est", estimator()), + ] + ).fit(frame, y) + transform.feature_names_in_ = np.array(list("xyz"), dtype=object) + with pytest.raises( + mlbundle.InvalidModelError, match="Invalid transformed feature names" + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +def test_encoder_drop_cache_native_storage_is_required(tmp_path): + frame, y = data() + model = Pipeline([("enc", OneHotEncoder(drop="first")), ("est", estimator())]).fit( + frame, y + ) + model.steps[0][1]._drop_idx_after_grouping = model.steps[0][ + 1 + ]._drop_idx_after_grouping.tolist() + with pytest.raises(mlbundle.InvalidModelError, match="fitted dimensions disagree"): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +@pytest.mark.parametrize("identity", [None, "passthrough", FunctionTransformer()]) +def test_categorical_xgboost_identity_prefix(identity, tmp_path): + frame, y = data() + frame["b"] = pd.Categorical(np.where(y, "high", "low")) + model = Pipeline([("identity", identity), ("est", estimator())]).fit(frame, y) + path = tmp_path / "categorical.mlbundle" + mlbundle.export(model, path) + bundle = mlbundle.load(path) + assert np.array_equal(bundle.predict_proba(frame), model.predict_proba(frame)) + assert np.array_equal( + bundle.native_model.predict_proba(frame), model.predict_proba(frame) + ) + + +@pytest.mark.parametrize("dependency", ["sklearn", "skops"]) +def test_missing_composite_dependency_is_bounded(dependency, tmp_path): + model, _ = pipeline() + path = tmp_path / "dependency.mlbundle" + mlbundle.export(model, path) + script = """ +import builtins, sys +original = builtins.__import__ +def blocked(name, *args, **kwargs): + if name.split(".")[0] == sys.argv[2]: + raise ModuleNotFoundError(name=sys.argv[2]) + return original(name, *args, **kwargs) +builtins.__import__ = blocked +import mlbundle +view = mlbundle.inspect(sys.argv[1]) +assert view.manifest.model.pipeline is not None +try: + mlbundle.load(sys.argv[1]) +except mlbundle.MissingOptionalDependencyError as error: + assert error.extra == "sklearn" +else: + raise AssertionError("missing dependency was loaded") +""" + subprocess.run( + [sys.executable, "-c", script, str(path), dependency], + capture_output=True, + text=True, + check=True, + ) diff --git a/tests/test_pipeline_scope.py b/tests/test_pipeline_scope.py new file mode 100644 index 0000000..7c30759 --- /dev/null +++ b/tests/test_pipeline_scope.py @@ -0,0 +1,175 @@ +"""Supported prefix scope and every forbidden native placement/outer option.""" + +import numpy as np +import pytest +from sklearn import config_context +from sklearn.calibration import CalibratedClassifierCV +from sklearn.compose import ColumnTransformer, TransformedTargetRegressor +from sklearn.ensemble import StackingClassifier, VotingClassifier +from sklearn.linear_model import LogisticRegression +from sklearn.model_selection import GridSearchCV +from sklearn.pipeline import FeatureUnion, Pipeline +from sklearn.preprocessing import FunctionTransformer, StandardScaler + +import mlbundle +from mlbundle._sklearn import transformers +from pipeline_helpers import data, estimator, pipeline + + +@pytest.mark.parametrize("library", ["xgboost", "catboost"]) +@pytest.mark.parametrize( + "placement", + [ + "nonfinal", + "column", + "union", + "stacking", + "voting", + "calibration", + "search", + "target", + "nested", + ], +) +def test_native_placement(library, placement, tmp_path): + leaf = estimator(library) + variants = { + "nonfinal": Pipeline([("booster", leaf), ("scale", StandardScaler())]), + "column": Pipeline( + [ + ("columns", ColumnTransformer([("leaf", leaf, [0])])), + ("final", estimator(library)), + ] + ), + "union": FeatureUnion([("leaf", leaf)]), + "stacking": StackingClassifier([("leaf", leaf)]), + "voting": VotingClassifier([("leaf", leaf)]), + "calibration": CalibratedClassifierCV(leaf), + "search": GridSearchCV(leaf, {}), + "target": TransformedTargetRegressor(regressor=leaf), + "nested": Pipeline([("outer", Pipeline([("leaf", leaf)]))]), + } + with pytest.raises( + mlbundle.UnsupportedModelError, match="last step of the outermost Pipeline" + ) as error: + mlbundle.export(variants[placement], tmp_path / "never.mlbundle") + assert any(name.startswith(library + ".") for name in error.value.untrusted_types) + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize( + "name,value,message", + [ + ("memory", "cache", "memory must be None"), + ("verbose", 1, "verbose must be a boolean"), + ("transform_input", [], "transform_input and metadata routing"), + ("_metadata_request", {}, "transform_input and metadata routing"), + ("unknown", 1, "Unknown outer Pipeline state"), + ], +) +def test_outer_state(name, value, message, tmp_path): + model, _ = pipeline() + setattr(model, name, value) + with pytest.raises(mlbundle.MLBundleError, match=message): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +@pytest.mark.parametrize( + "names", + [ + ["same", "same"], + ["memory", "est"], + ["x__y", "est"], + ["", "est"], + ["x" * 257, "est"], + [0, "est"], + ], +) +def test_step_names(names, tmp_path): + model, _ = pipeline() + model.steps = list(zip(names, [step for _, step in model.steps], strict=True)) + with pytest.raises( + mlbundle.UnsupportedModelError, match="unique bounded sklearn names" + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +def test_exact_outer_leaf_and_step_count(tmp_path): + class CustomPipeline(Pipeline): + pass + + model, _ = pipeline() + for invalid in [ + CustomPipeline(model.steps), + Pipeline([("p" + str(i), None) for i in range(128)] + [model.steps[-1]]), + ]: + with pytest.raises( + mlbundle.UnsupportedModelError, match="exact outer Pipeline" + ): + mlbundle.export(invalid, tmp_path / "never.mlbundle") + + class CustomEstimator(type(model.steps[-1][1])): + pass + + with pytest.raises(mlbundle.UnsupportedModelError, match="Only XGBClassifier"): + mlbundle.export( + Pipeline([("leaf", CustomEstimator())]), tmp_path / "never.mlbundle" + ) + + +def test_metadata_output_and_unfitted_prefix(tmp_path): + model, _ = pipeline() + with ( + config_context(transform_output="pandas"), + pytest.raises( + mlbundle.UnsupportedModelError, match="default global transform_output" + ), + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + model.steps[0][1]._sklearn_output_config = {"transform": "polars"} + with pytest.raises(mlbundle.UnsupportedModelError, match="default or pandas"): + mlbundle.export(model, tmp_path / "never.mlbundle") + model.steps[0] = ("scale", StandardScaler()) + with pytest.raises( + mlbundle.UnsupportedModelError, match="transformers must be fitted" + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + model.steps[0][1]._metadata_request = {} + with pytest.raises(mlbundle.UnsupportedModelError, match="metadata routing"): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +def test_transformer_route_rejections_and_success(): + frame, y = data() + prefix = Pipeline([("scale", StandardScaler())]).fit(frame) + restored = transformers.Adapter().deserialize( + transformers.Adapter().serialize(prefix) + ) + assert np.array_equal(restored.transform(frame), prefix.transform(frame)) + for invalid, message in [ + (StandardScaler(), "nonempty exact Pipeline"), + (Pipeline([]), "nonempty exact Pipeline"), + (Pipeline([("leaf", LogisticRegression().fit(frame, y))]), "non-transformer"), + ]: + with pytest.raises(mlbundle.UnsupportedModelError, match=message): + transformers.Adapter().serialize(invalid) + + +def test_unverifiable_width_and_custom_function(tmp_path): + from sklearn.kernel_approximation import RBFSampler + + frame, y = data() + model = Pipeline([("r", RBFSampler(n_components=3)), ("est", estimator())]).fit( + frame, y + ) + with pytest.raises( + mlbundle.UnsupportedModelError, + match="output width cannot be verified without data", + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + model = Pipeline( + [("custom", FunctionTransformer(lambda x: x)), ("est", estimator())] + ).fit(frame, y) + with pytest.raises(mlbundle.UnsupportedModelError) as error: + mlbundle.export(model, tmp_path / "never.mlbundle") + assert any("lambda" in name for name in error.value.untrusted_types) diff --git a/tests/test_pipeline_tampering.py b/tests/test_pipeline_tampering.py new file mode 100644 index 0000000..0d5f64c --- /dev/null +++ b/tests/test_pipeline_tampering.py @@ -0,0 +1,252 @@ +"""Rehashed attacker recipes, component state and native structure still fail.""" + +import copy + +import numpy as np +import pytest +import skops.io as sio + +import mlbundle +from conftest import build_zip +from mlbundle._sklearn import transformers +from mlbundle.canonical import canonical_dumps, canonical_loads +from mlbundle.writing import write_container +from pipeline_helpers import parts, pipeline + + +@pytest.fixture +def composite(tmp_path): + model, _ = pipeline() + path = tmp_path / "composite.mlbundle" + mlbundle.export(model, path) + return parts(path) + + +def changed_record(parts, path, value): + record = copy.deepcopy(parts) + current = record + for key in path[:-1]: + current = current[key] + current[path[-1]] = value + return record + + +@pytest.mark.parametrize( + "path,value", + [ + (("model", "pipeline", "unknown"), 1), + (("model", "pipeline", "step_names"), ["same", "same"]), + (("model", "pipeline", "step_names"), ["x__y", "est"]), + (("model", "pipeline", "step_names"), ["memory", "est"]), + (("model", "pipeline", "step_names"), ["x" * 257, "est"]), + (("model", "pipeline", "transformers"), None), + (("model", "pipeline", "transformers", "state_sha256"), None), + (("model", "pipeline", "transformers", "member"), "model.ubj"), + (("model", "pipeline", "estimator", "library"), "sklearn"), + (("model", "pipeline", "estimator", "serialization"), "cbm"), + (("model", "pipeline", "estimator", "member"), "model.cbm"), + (("model", "pipeline", "estimator", "claims", "estimator_class"), "eval"), + (("model", "pipeline", "transformed_features", "n_features"), True), + (("model", "pipeline", "transformed_features", "names"), ["a", "a", "b"]), + (("model", "pipeline", "transformed_features", "origin"), "declared"), + (("model", "hyperparameters", "memory"), "cache"), + (("model", "hyperparameters", "verbose"), 1), + (("model", "state_sha256"), "a" * 64), + (("model", "library"), "xgboost"), + (("model", "serialization"), "skops"), + (("model", "pipeline"), None), + ], +) +def test_strict_recipe(composite, path, value): + record, members = composite + with pytest.raises(mlbundle.InvalidManifestError): + mlbundle.inspect(write_container(changed_record(record, path, value), members)) + + +@pytest.mark.parametrize( + "path,value,claim", + [ + (("model", "pipeline", "step_names", 0), "renamed", "pipeline.transformers"), + ( + ("model", "pipeline", "transformers", "state_sha256"), + "a" * 64, + "pipeline.transformers", + ), + ( + ("model", "pipeline", "transformers", "hyperparameters", "scale__copy"), + False, + "pipeline.transformers", + ), + ( + ("model", "pipeline", "transformed_features", "names"), + ["x", "y", "z"], + "pipeline.transformed_features", + ), + ( + ( + "model", + "pipeline", + "estimator", + "claims", + "hyperparameters", + "prediction", + "importance_type", + ), + "weight", + "pipeline.estimator.hyperparameters", + ), + ( + ("model", "pipeline", "estimator", "claims", "feature_names"), + ["x", "y", "z"], + "pipeline.estimator.feature_names", + ), + ( + ("model", "pipeline", "estimator", "claims", "feature_dtypes"), + ["float", "float", "float"], + "pipeline.estimator.feature_dtypes", + ), + (("model", "hyperparameters", "verbose"), True, None), + ], +) +def test_recomputed_component_claims(composite, path, value, claim): + record, members = composite + data = write_container(changed_record(record, path, value), members) + if claim is None: + # Outer options are explicitly declarations; they do not restore model state. + assert mlbundle.load(data).native_model.verbose is True + else: + with pytest.raises(mlbundle.ClaimMismatchError) as error: + mlbundle.load(data) + assert error.value.claim == claim + + +@pytest.mark.parametrize( + "member_set", + [ + {"model.skops", "model.onnx"}, + {"model.ubj", "model.cbm"}, + {"model.skops", "model.ubj", "model.cbm"}, + set(), + ], +) +def test_invalid_member_candidates(composite, member_set): + record, _ = composite + with pytest.raises( + mlbundle.InvalidContainerError, match="Invalid model member combination" + ): + mlbundle.inspect( + build_zip( + [ + ("manifest.json", canonical_dumps(record)), + *[(name, b"native") for name in member_set], + ] + ) + ) + + +@pytest.mark.parametrize( + "change", + [ + "missing_skops", + "missing_native", + "extra_native", + "pair_without_recipe", + "recipe_without_pair", + ], +) +def test_member_recipe_disagreement(composite, change): + record, members = composite + record, members = copy.deepcopy(record), dict(members) + if change == "missing_skops": + members.pop(mlbundle.MemberName.SKOPS) + elif change == "missing_native": + members.pop(mlbundle.MemberName.UBJSON) + elif change == "extra_native": + members[mlbundle.MemberName.CBM] = b"native" + elif change == "pair_without_recipe": + record["model"]["pipeline"] = None + record["model"]["serialization"] = "skops" + else: + record["model"]["pipeline"]["transformers"] = None + record["model"]["pipeline"]["step_names"] = ["est"] + with pytest.raises((mlbundle.InvalidContainerError, mlbundle.InvalidManifestError)): + mlbundle.inspect(write_container(record, members)) + + +@pytest.mark.parametrize("alteration", ["state", "params", "names", "width"]) +def test_rehashed_transformer_payload(composite, alteration): + record, members = composite + restored = transformers.Adapter().deserialize(members[mlbundle.MemberName.SKOPS]) + step = restored.steps[0][1] + if alteration == "state": + step.mean_[0] += 1 + elif alteration == "params": + step.copy = False + elif alteration == "names": + restored.steps[0] = ("renamed", step) + else: + step.scale_ = np.ones(2) + members = {**members, mlbundle.MemberName.SKOPS: sio.dumps(restored)} + with pytest.raises((mlbundle.ClaimMismatchError, mlbundle.InvalidModelError)): + mlbundle.load(write_container(record, members)) + + +@pytest.mark.parametrize("alteration", ["tree", "boundary", "null", "extra"]) +def test_rehashed_structure(composite, alteration): + record, members = composite + structure = canonical_loads(members[mlbundle.MemberName.STRUCTURE]) + if alteration == "tree": + structure["trees"][0]["nodeid"] += 1 + elif alteration == "boundary": + structure["pipeline"]["features"]["names"][0] = "changed" + elif alteration == "extra": + structure["invented"] = True + else: + structure = None + members = {**members, mlbundle.MemberName.STRUCTURE: canonical_dumps(structure)} + with pytest.raises(mlbundle.ClaimMismatchError, match="structure"): + mlbundle.load(write_container(record, members)) + + +def test_labels_counts_missing_structure_and_unknown_version(composite): + record, members = composite + changed = copy.deepcopy(record) + changed["target"]["class_labels"] = changed["model"]["pipeline"]["estimator"][ + "claims" + ]["class_labels"] = [["no", "yes"]] + with pytest.raises(mlbundle.ClaimMismatchError, match="class_labels"): + mlbundle.load(write_container(changed, members)) + changed = copy.deepcopy(record) + changed["references"]["structure"] = None + with pytest.raises(mlbundle.InvalidManifestError): + write_container( + changed, + { + name: value + for name, value in members.items() + if name != mlbundle.MemberName.STRUCTURE + }, + ) + changed["format_version"] = "unknown" + with pytest.raises(mlbundle.UnsupportedFormatVersionError): + write_container(changed, members) + + +@pytest.mark.parametrize("library", ["xgboost", "catboost"]) +def test_native_preflight_before_native_loader(library, tmp_path, monkeypatch): + model, _ = pipeline(library) + path = tmp_path / "native.mlbundle" + mlbundle.export(model, path) + record, members = parts(path) + final_type = type(model.steps[-1][1]) + + def forbidden(*_args, **_kwargs): + raise AssertionError("native parser was called") + + monkeypatch.setattr(final_type, "load_model", forbidden) + native_member = ( + mlbundle.MemberName.UBJSON if library == "xgboost" else mlbundle.MemberName.CBM + ) + members[native_member] = b"not a native payload" + with pytest.raises(mlbundle.InvalidModelError): + mlbundle.load(write_container(record, members)) diff --git a/tests/test_pipeline_width.py b/tests/test_pipeline_width.py new file mode 100644 index 0000000..3aa00ac --- /dev/null +++ b/tests/test_pipeline_width.py @@ -0,0 +1,263 @@ +"""Closed fitted width transitions, successful prediction and malformed state.""" + +import copy +import warnings + +import numpy as np +import pytest +from sklearn.compose import ColumnTransformer +from sklearn.decomposition import PCA +from sklearn.feature_selection import ( + GenericUnivariateSelect, + SelectFdr, + SelectFpr, + SelectFwe, + SelectKBest, + SelectPercentile, + VarianceThreshold, +) +from sklearn.impute import SimpleImputer +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import ( + FunctionTransformer, + KBinsDiscretizer, + MaxAbsScaler, + MinMaxScaler, + Normalizer, + OneHotEncoder, + OrdinalEncoder, + PolynomialFeatures, + RobustScaler, + StandardScaler, +) + +import mlbundle +from mlbundle._sklearn.transform_width import output_width +from mlbundle._sklearn.trusted import NUMPY_FUNCTIONS +from pipeline_helpers import data, estimator + + +def transform_cases(): + return [ + StandardScaler(), + StandardScaler(with_mean=False, with_std=False), + MinMaxScaler(), + MaxAbsScaler(), + RobustScaler(), + Normalizer(), + PCA(2), + VarianceThreshold(), + SelectKBest(k=2), + SelectPercentile(percentile=75), + SelectFpr(alpha=1), + SelectFdr(alpha=1), + SelectFwe(alpha=1), + GenericUnivariateSelect(mode="k_best", param=2), + SimpleImputer(), + SimpleImputer(add_indicator=True), + SimpleImputer(strategy="constant", keep_empty_features=True), + PolynomialFeatures(2), + PolynomialFeatures((2, 3)), + PolynomialFeatures(2, interaction_only=True), + *[ + KBinsDiscretizer(n_bins=2, encode=encoding) + for encoding in ("ordinal", "onehot", "onehot-dense") + ], + OneHotEncoder(), + OneHotEncoder(min_frequency=2, drop="if_binary"), + OrdinalEncoder(), + OrdinalEncoder(min_frequency=2), + *[FunctionTransformer(func) for func in (None, *NUMPY_FUNCTIONS)], + Pipeline([("sc", StandardScaler()), ("pca", PCA(2))]), + ] + + +@pytest.mark.parametrize("transform", transform_cases(), ids=lambda x: type(x).__name__) +def test_width_roundtrip(transform, tmp_path): + frame, y = data() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + model = Pipeline([("transform", transform), ("est", estimator())]).fit(frame, y) + transformed = transform.transform(frame) + assert output_width(transform, 3) == transformed.shape[1] + path = tmp_path / "width.mlbundle" + mlbundle.export(model, path) + bundle = mlbundle.load(path) + assert ( + bundle.manifest.model.pipeline.transformed_features.n_features + == transformed.shape[1] + ) + assert np.array_equal(bundle.predict_proba(frame), model.predict_proba(frame)) + + +@pytest.mark.parametrize("add_indicator", [False, True]) +@pytest.mark.parametrize("keep_empty", [False, True]) +def test_imputer_empty_columns(add_indicator, keep_empty, tmp_path): + frame, y = data() + frame["b"] = np.nan + model = Pipeline( + [ + ( + "imp", + SimpleImputer( + add_indicator=add_indicator, keep_empty_features=keep_empty + ), + ), + ("est", estimator()), + ] + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + model.fit(frame, y) + path = tmp_path / "empty.mlbundle" + mlbundle.export(model, path) + bundle = mlbundle.load(path) + assert ( + bundle.manifest.model.pipeline.transformed_features.n_features + == 2 + int(keep_empty) + int(add_indicator) + ) + assert np.array_equal(bundle.predict_proba(frame), model.predict_proba(frame)) + + +@pytest.mark.parametrize("remainder", ["drop", "passthrough", StandardScaler()]) +@pytest.mark.parametrize("columns", [["a", "b"], [0, 1], [True, True, False]]) +def test_column_routing_and_remainder(remainder, columns, tmp_path): + frame, y = data() + transform = ColumnTransformer( + [("scale", StandardScaler(), columns), ("empty", StandardScaler(), [])], + remainder=remainder, + ) + model = Pipeline([("ct", transform), ("est", estimator())]).fit(frame, y) + path = tmp_path / "column.mlbundle" + mlbundle.export(model, path) + assert np.array_equal( + mlbundle.load(path).predict_proba(frame), model.predict_proba(frame) + ) + + +def test_column_sparse_strings_and_names(tmp_path): + frame, y = data() + frame["b"] = np.where(y, "high", "low") + transform = ColumnTransformer( + [ + ("enc", OneHotEncoder(handle_unknown="ignore"), ["b"]), + ( + "num", + Pipeline([("imp", SimpleImputer()), ("sc", StandardScaler())]), + ["a", "c"], + ), + ], + sparse_threshold=1, + ) + model = Pipeline([("ct", transform), ("est", estimator())]).fit(frame, y) + path = tmp_path / "sparse.mlbundle" + mlbundle.export(model, path) + bundle = mlbundle.load(path) + assert transform.sparse_output_ + assert bundle.manifest.model.pipeline.estimator.claims.feature_names is None + assert bundle.manifest.model.pipeline.transformed_features.names == [ + "enc__b_high", + "enc__b_low", + "num__a", + "num__c", + ] + assert np.array_equal(bundle.predict_proba(frame), model.predict_proba(frame)) + + +@pytest.mark.parametrize( + "factory,attribute,value", + [ + (StandardScaler, "scale_", np.ones(2)), + (StandardScaler, "n_features_in_", True), + (StandardScaler, "n_samples_seen_", np.ones(2)), + (MinMaxScaler, "data_min_", np.ones(2)), + (lambda: PCA(2), "components_", np.ones((2, 2))), + (lambda: PCA(2), "n_components_", 1.5), + (VarianceThreshold, "variances_", np.ones(2)), + (lambda: SelectKBest(k=2), "scores_", np.ones(2)), + (SimpleImputer, "statistics_", np.ones(2)), + (lambda: SimpleImputer(add_indicator=True), "indicator_", None), + (OneHotEncoder, "categories_", [np.array([1])]), + (OneHotEncoder, "_n_features_outs", [1, 1, 1]), + (OrdinalEncoder, "_missing_indices", {0: 99}), + (lambda: KBinsDiscretizer(n_bins=2), "n_bins_", np.array([2, 2])), + (lambda: KBinsDiscretizer(n_bins=2), "bin_edges_", [np.array([0, 0, 1])] * 3), + (PolynomialFeatures, "n_output_features_", 1), + (PolynomialFeatures, "_max_degree", 5), + ], +) +def test_malformed_fitted_dimensions(factory, attribute, value, tmp_path): + frame, y = data() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + transform = factory().fit(frame, y) + model = Pipeline( + [("tr", transform), ("est", estimator().fit(transform.transform(frame), y))] + ) + setattr(transform, attribute, value) + with pytest.raises(mlbundle.MLBundleError): + mlbundle.export(model, tmp_path / "never.mlbundle") + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize( + "attribute,value", + [ + ("_columns", [[2]]), + ("_transformer_to_input_indices", {"sc": [True], "remainder": [1, 2]}), + ("_transformer_to_input_indices", {"sc": [0], "remainder": [2]}), + ("output_indices_", {"sc": slice(0, 2), "remainder": slice(2, 4)}), + ("sparse_output_", 1), + ("remainder", "drop"), + ], +) +def test_column_cache_disagreement(attribute, value, tmp_path): + frame, y = data() + transform = ColumnTransformer( + [("sc", StandardScaler(), [0])], remainder="passthrough" + ).fit(frame) + model = Pipeline( + [("ct", transform), ("est", estimator().fit(transform.transform(frame), y))] + ) + setattr(transform, attribute, value) + with pytest.raises(mlbundle.InvalidModelError, match="ColumnTransformer"): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +def test_grouping_drop_indicator_and_width_mismatches(tmp_path): + frame, y = data() + for transform in [ + OneHotEncoder(min_frequency=3, drop="first"), + SimpleImputer(add_indicator=True), + ]: + training = frame.copy() + if type(transform) is OneHotEncoder: + training[:] = np.repeat([0, 1, 2], [12, 10, 2])[:, None] + transform.fit(training) + leaf = estimator().fit(transform.transform(training), y) + if type(transform) is OneHotEncoder: + transform._default_to_infrequent_mappings[0][0] = 99 + else: + transform.indicator_.features_ = np.array([99]) + with pytest.raises(mlbundle.InvalidModelError, match="dimensions disagree"): + mlbundle.export( + Pipeline([("tr", transform), ("est", leaf)]), + tmp_path / "never.mlbundle", + ) + scaler = StandardScaler().fit(frame) + leaf = estimator().fit(frame[["a", "b"]], y) + with pytest.raises( + mlbundle.InvalidModelError, match="output width disagrees with final estimator" + ): + mlbundle.export( + Pipeline([("sc", scaler), ("est", leaf)]), tmp_path / "never.mlbundle" + ) + nested = None + for _ in range(18): + nested = Pipeline([("p", nested)]) + with pytest.raises(mlbundle.UnsupportedModelError, match="nesting exceeds"): + output_width(nested, 3) + changed = copy.deepcopy(scaler) + changed.n_features_in_ = 2 + with pytest.raises(mlbundle.InvalidModelError, match="input widths disagree"): + output_width(changed, 3) diff --git a/tests/test_readme.py b/tests/test_readme.py index 8dd08ff..1883aba 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -18,7 +18,9 @@ @pytest.mark.parametrize( - "code", PYTHON, ids=["sklearn", "xgboost", "catboost", "onnx", "schema-samples"] + "code", + PYTHON, + ids=["sklearn", "xgboost", "catboost", "composite", "onnx", "schema-samples"], ) def test_python_example(code, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) @@ -29,7 +31,7 @@ def test_python_example(code, tmp_path, monkeypatch): def test_console_examples(tmp_path, monkeypatch): - assert len(PYTHON) == 5 + assert len(PYTHON) == 6 assert all(language in {"python", "shell"} for language, _ in BLOCKS) monkeypatch.chdir(tmp_path) exec(compile(PYTHON[0], str(README), "exec"), {}) diff --git a/tests/test_sklearn.py b/tests/test_sklearn.py index 4c4b39b..3d4aa94 100644 --- a/tests/test_sklearn.py +++ b/tests/test_sklearn.py @@ -181,7 +181,17 @@ def test_multilabel_chain_is_rejected(data, tmp_path): ), (LinearSVC(), "predict_proba"), (HistGradientBoostingClassifier(), "TreePredictor"), - (Pipeline([("xgb", XGBClassifier(n_estimators=2, n_jobs=1))]), "xgboost"), + ( + Pipeline( + [ + ( + "inner", + Pipeline([("xgb", XGBClassifier(n_estimators=2, n_jobs=1))]), + ) + ] + ), + "xgboost", + ), ], ) def test_export_rejections(model, match, data, tmp_path):