From 34b22518630367d946a15a1210f90ed2814f0f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Tue, 6 Oct 2026 09:39:37 +0200 Subject: [PATCH 01/35] feat(serializer): format v4 with estimator_package class identity and allowlisted function loading --- CHANGELOG.md | 34 +++ SECURITY.md | 19 +- docs/format.md | 28 +- openmodels/serializers/base.py | 61 +++- .../serializers/sklearn/_custom_estimator.py | 37 ++- .../serializers/sklearn/sklearn_serializer.py | 163 +++++++++- pyproject.toml | 2 +- test/test_format_v4.py | 281 ++++++++++++++++++ 8 files changed, 583 insertions(+), 42 deletions(-) create mode 100644 test/test_format_v4.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ea31834..460b12d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to the OpenModels project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.3] - 2026-10-05 + +### Security + +- **Breaking:** functions referenced by a model file (e.g. a `SelectKBest` `score_func` or a + `FunctionTransformer` `func`) are no longer imported from whatever module the file names. A + function is only returned if it's a plain function, builtin or NumPy ufunc from an + already-imported module of numpy, scipy, scikit-learn or a registered custom estimator's + package; private names and `__main__` are refused. Anything else raises + `DeserializationError`. Files referencing your own functions need the new + `SklearnSerializer(trusted_function_modules=[...])`, which also allows importing those modules +- A Gaussian-process kernel's `kernel_type` read from a file must now name a `Kernel` class; + before, any callable in `sklearn.gaussian_process.kernels` could be called with the file's + parameters + +### Changed + +- **Wire format v4:** every estimator node records `estimator_package` (the class's top-level + package) next to `estimator_class`, and classes are resolved by `(package, class name)`. + Files without it (v1-v3) resolve by bare name as before, now warning when the name is + ambiguous. Type tags are unchanged, so openmodels 0.2.2 still loads v4 files. + `openmodels_format_version` is now `4`. See `docs/format.md` + +### Fixed + +- A registered custom estimator sharing a scikit-learn class's name (e.g. chemotools' + `MinMaxScaler`) silently replaced it on load, giving a model with different output. Both now + coexist, also inside one `Pipeline`, and so do two custom classes with the same name +- `metadata.packages` listed a custom estimator's package for models that only used the + scikit-learn class with the same name +- An unknown estimator class raised a bare `KeyError`; it now raises + `UnsupportedEstimatorError`. A nested unknown estimator (e.g. a `Pipeline` step) used to load + silently as a raw dict; it now raises too + ## [0.2.2] - 2026-09-22 ### Changed diff --git a/SECURITY.md b/SECURITY.md index 8a66e42..00e936b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,7 +21,7 @@ OpenModels ships four output formats with very different trust requirements: - **JSON** (`format_name="json"`, the default): the serialized payload is plain data (numbers, strings, arrays). Deserializing it does not execute arbitrary code, and it is safe to load JSON produced by an untrusted party in the same way loading any other JSON - document is. + document is (see "Functions referenced by a model" below). - **MessagePack** (`format_name="msgpack"`, requires the optional `msgpack` package - see `pip install openmodels[msgpack]`): the same trust profile as JSON. MessagePack has the @@ -57,3 +57,20 @@ OpenModels ships four output formats with very different trust requirements: If you don't have a specific reason to use the pickle format, prefer JSON — it's the default, it's human-readable, and it doesn't carry this risk. + +### Functions referenced by a model + +Some estimators take a function as a parameter, e.g. a `FunctionTransformer`'s `func` or a +`SelectKBest`'s `score_func`. The file stores these by module and name. Loading never imports +a module named by the file, and only accepts functions from numpy, scipy, scikit-learn and the +packages of your registered `custom_estimators`. Any other function reference raises +`DeserializationError`. + +To load models that use your own functions, list their modules explicitly: +`SklearnSerializer(trusted_function_modules=["mypkg"])`. Those modules (and their submodules) +may then be imported while loading, so only list code you trust. Functions defined in a script +or notebook (`__main__`) are never accepted. + +Loading a model doesn't call these functions, but using it does: `predict`/`transform`/`fit` on +a model from an untrusted source run whichever allowed function the file chose. Treat such a +model's outputs as untrusted. diff --git a/docs/format.md b/docs/format.md index 53124ce..918690c 100644 --- a/docs/format.md +++ b/docs/format.md @@ -11,7 +11,8 @@ requirements, and `README.md`'s Security section for the short version. | Field | Type | Meaning | |---|---|---| -| `estimator_class` | `str` | The model's class name, e.g. `"LogisticRegression"`. Looked up against a registry of known scikit-learn (and registered custom) classes on deserialize. | +| `estimator_class` | `str` | The model's class name, e.g. `"LogisticRegression"`. Together with `estimator_package`, looked up against a registry of known scikit-learn (and registered custom) classes on deserialize. Nothing named by the file is ever imported: an unregistered class raises `UnsupportedEstimatorError`. | +| `estimator_package` | `str` | The top-level package the class comes from (`cls.__module__.split(".")[0]`), e.g. `"sklearn"`, `"chemotools"`, or `"__main__"` for a class defined in a script or notebook. Classes are resolved by `(estimator_package, estimator_class)`, so two classes with the same name from different packages (scikit-learn's and chemotools' `MinMaxScaler`) coexist. The top-level package is used rather than the full module path because scikit-learn's real modules are private and get renamed between releases. Since format `4`; a node without it resolves by bare name, with a registered custom class winning over a built-in one of the same name (and a `UserWarning` when the name is ambiguous). | | `params` | `dict` | The estimator's constructor parameters, from `model.get_params(deep=False)`. | | `param_types` | `dict` | `{param_name: type(value).__name__}` for every entry in `params` - lets the deserializer reconstruct types JSON can't represent natively (e.g. `tuple`). | | `param_dtypes` | `dict` | Like `param_types`, but for numpy dtypes specifically (e.g. a `np.dtype` param), when applicable. | @@ -44,7 +45,7 @@ The autofilled fields fall into four groups: | `domain` | `str` | The framework the file is reconstructed into. Currently always `"sklearn"`. Reserved for future non-scikit-learn model serializers. | | `domain_version` | `str` | The `domain` framework's version whose attribute layout this file follows - for openmodels-written files, the installed `sklearn.__version__` at serialize time. This is the version the deserialize-time check compares against the installed scikit-learn: any difference in the version string (patch releases included) raises a `UserWarning` listing the tested versions - it never blocks loading. Skipped if absent or `"unknown"`. Files older than format `3` have no `domain_version`; for those only, `producer_version` (which then always held the scikit-learn version) is used instead. | | `packages` | `dict` | `{package_name: version}` for every package whose estimator classes appear anywhere in the tree - including the outermost one - plus the `domain` package (`sklearn`), which is always listed. For a plain scikit-learn model this is the single entry `{"sklearn": }`; for a third-party root model or e.g. a `Pipeline` mixing a scikit-learn step with a registered third-party step, it has one entry per distinct package. A class defined in a script or notebook is listed under its module name (e.g. `"__main__"`). Version lookup is best-effort (an already-imported module's own `__version__` attribute, falling back to installed-package metadata, falling back to `"unknown"`) since it must work for packages like scikit-learn itself, whose import name (`sklearn`) differs from its distribution name (`scikit-learn`). On deserialize, every non-`sklearn` entry is compared with the installed version and a mismatch raises a `UserWarning` (entries that are `"unknown"` on either side are skipped). Called `producers` in v2 files, which are still read. | -| `openmodels_format_version` | `int` | The version of *this wire format's shape*, independent of both `domain_version` and `producer_version` - bumped only when the structure or meaning of the fields documented on this page changes. Currently `3`. A file with no `openmodels_format_version` key predates this field and has version `1`'s flat shape (see "Format history" below); a value newer than what your installed openmodels understands triggers a `UserWarning` (not an error) on deserialize. | +| `openmodels_format_version` | `int` | The version of *this wire format's shape*, independent of both `domain_version` and `producer_version` - bumped only when the structure or meaning of the fields documented on this page changes. Currently `4`. A file with no `openmodels_format_version` key predates this field and has version `1`'s flat shape (see "Format history" below); a value newer than what your installed openmodels understands triggers a `UserWarning` (not an error) on deserialize. | | `created_at` | `str` | ISO 8601 UTC timestamp of when `serialize()` was called. Informational only - not checked at deserialize time, and makes two serializations of an otherwise-identical model no longer byte-identical. | | `dependency_versions` | `dict` | The writer's runtime environment at serialize time. openmodels records `{"python": ..., "numpy": ..., "scipy": ...}`: the Python interpreter version (`platform.python_version()`, e.g. `"3.11.9"`) plus the array/sparse-matrix libraries this format's dtype-preserving round trip depends on. Other writers record their own runtime instead (e.g. `{"R": "4.6.1", "jsonlite": "2.0.0"}`). Informational only (not checked) - the serialized dict itself is Python-version-independent; these are for tracing and reproducing the environment that produced a file. | @@ -81,6 +82,7 @@ A fitted `LogisticRegression`, serialized to JSON: ```json { "estimator_class": "LogisticRegression", + "estimator_package": "sklearn", "params": { "C": 1.0, "class_weight": null, "dual": false, "fit_intercept": true, "intercept_scaling": 1, "l1_ratio": 0.0, "max_iter": 100, "n_jobs": null, @@ -111,7 +113,7 @@ A fitted `LogisticRegression`, serialized to JSON: "domain": "sklearn", "domain_version": "1.9.0", "packages": {"sklearn": "1.9.0"}, - "openmodels_format_version": 3, + "openmodels_format_version": 4, "created_at": "2026-09-05T12:00:00+00:00", "dependency_versions": {"python": "3.11.9", "numpy": "2.1.0", "scipy": "1.14.0"}, "title": "Customer churn classifier" @@ -139,7 +141,9 @@ A meta-estimator that holds other estimators - a `Pipeline` step, a `VotingClass `estimators`, a `ColumnTransformer`'s `transformers` - doesn't get a special graph representation. Wherever a `BaseEstimator` value appears (inside `params` or `attributes`), it's serialized recursively as a nested copy of this same dict shape, minus `metadata` - that block is -root-only, so it's never duplicated across a pipeline's steps. A serialized `Pipeline` +root-only, so it's never duplicated across a pipeline's steps. Every node, root and nested, +carries its own `estimator_class`/`estimator_package`, so a `Pipeline` can mix same-named +classes from different packages. A serialized `Pipeline` is a normal JSON tree, not a separate node/edge graph - openmodels reconstructs models by calling `set_params()`/rebuilding fitted attributes on the real scikit-learn class, not by executing an independent computation graph, so there's no separate graph representation to keep @@ -158,12 +162,13 @@ registered third-party) estimator tree. Such a writer should fill `metadata` as was written and tested against. openmodels warns when the loading environment differs, which is exactly when a hand-written attribute layout may no longer match. If the writer can't name a version, omit it rather than writing a placeholder. -- `openmodels_format_version`: the format version the file follows (`3`). +- `openmodels_format_version`: the format version the file follows (`4`). - `dependency_versions`: the writer's own runtime. - `created_at`: as above. -Every `attributes` block also needs its `attribute_types`, and every entry in `params` a -`param_types` entry. +Every estimator node needs `estimator_package` next to `estimator_class` (the top-level Python +package of the class the file targets, e.g. `"sklearn"`). Every `attributes` block also needs its +`attribute_types`, and every entry in `params` a `param_types` entry. ```json "metadata": { @@ -172,7 +177,7 @@ Every `attributes` block also needs its `attribute_types`, and every entry in `p "domain": "sklearn", "domain_version": "1.9.1", "packages": {"my_estimators": "0.3.0", "sklearn": "1.9.1"}, - "openmodels_format_version": 3, + "openmodels_format_version": 4, "created_at": "2026-09-21T09:23:50Z", "dependency_versions": {"R": "4.6.1", "jsonlite": "2.0.0"} } @@ -199,6 +204,13 @@ Every `attributes` block also needs its `attribute_types`, and every entry in `p scikit-learn), and read `producers` when `packages` is absent. `openmodels_version` was removed: `producer_version` now carries the same information for openmodels-written files (v1/v2 files still have it; nothing reads it). +- **v4**: every estimator node (root and nested) records `estimator_package` next to + `estimator_class`, and classes are resolved by `(package, class name)` - before, a registered + custom class silently replaced a built-in one with the same name. Files without the field + (v1-v3) still resolve by bare name with the old rule (custom class wins), now warning when the + name is ambiguous. Type tags in `param_types`/`attribute_types` are unchanged (still the class + name), so openmodels 0.2.x readers still load v4 files, with only the "newer format version" + warning. ## Why not ONNX or PMML? diff --git a/openmodels/serializers/base.py b/openmodels/serializers/base.py index 71d915a..a0db52d 100644 --- a/openmodels/serializers/base.py +++ b/openmodels/serializers/base.py @@ -7,13 +7,24 @@ support for new serialization targets. """ +import importlib +import inspect +import sys +from types import ModuleType + import numpy as np from scipy.sparse import csr_matrix, csc_matrix, csr_array, csc_array # type: ignore from scipy.interpolate import interp1d, BSpline # type: ignore from scipy.stats._distn_infrastructure import rv_continuous_frozen # type: ignore import scipy.stats # type: ignore -from typing import Any, Optional, Callable, Dict +from typing import Any, Optional, Callable, Dict, Set + +from openmodels.exceptions import DeserializationError + +# Top-level packages whose (already-imported) functions a model file may reference, e.g. a +# SelectKBest score_func such as sklearn.feature_selection.chi2. +DEFAULT_FUNCTION_ROOTS = frozenset({"numpy", "scipy", "sklearn"}) class SerializerMixin: @@ -103,10 +114,52 @@ def _serialize_function(self, func: Callable) -> Dict[str, str]: "name": func.__name__, } + def _allowed_function_roots(self) -> Set[str]: + """Top-level packages whose already-imported modules may provide deserialized + functions. Subclasses extend this (e.g. with the packages of registered estimators). + """ + return set(DEFAULT_FUNCTION_ROOTS) + + def _is_trusted_function_module(self, module_name: str) -> bool: + """Whether the user explicitly trusted this module, allowing it to be imported. + Nothing is trusted by default.""" + return False + def _deserialize_function(self, data: Dict[str, str]) -> Callable: - """Deserialize a Python function from its module and name.""" - module = __import__(data["module"], fromlist=[data["name"]]) - return getattr(module, data["name"]) + """ + Deserialize a Python function from its module and name. + + The file names the module, so it is untrusted: the function is only looked up in a + module that is already imported and belongs to an allowed top-level package + (`_allowed_function_roots`), and only modules the user explicitly trusted + (`_is_trusted_function_module`) may be imported. Importing an arbitrary module would + run its import-time side effects. Private names, ``__main__`` modules and anything + that isn't a plain function, builtin or NumPy ufunc are refused. + """ + module_name, name = data.get("module"), data.get("name") + obj = None + if ( + isinstance(module_name, str) + and isinstance(name, str) + and not name.startswith("_") + and "__main__" not in module_name.split(".") + ): + module: Optional[ModuleType] = None + if self._is_trusted_function_module(module_name): + module = importlib.import_module(module_name) + elif module_name.split(".")[0] in self._allowed_function_roots(): + module = sys.modules.get(module_name) + obj = getattr(module, name, None) if module is not None else None + if not ( + inspect.isfunction(obj) + or inspect.isbuiltin(obj) + or isinstance(obj, np.ufunc) + ): + raise DeserializationError( + f"function '{module_name}.{name}' is not allowed; pass " + f"trusted_function_modules=[...] to permit it" + ) + return obj def _deserialize_dict(self, value: Any) -> Any: """Deserialize a dict, restoring non-string key types for the envelope produced by diff --git a/openmodels/serializers/sklearn/_custom_estimator.py b/openmodels/serializers/sklearn/_custom_estimator.py index 91a7579..4d463fb 100644 --- a/openmodels/serializers/sklearn/_custom_estimator.py +++ b/openmodels/serializers/sklearn/_custom_estimator.py @@ -1,6 +1,6 @@ import inspect import warnings -from typing import Any, Callable, Dict, List, Tuple, Type, Union +from typing import Any, Callable, Dict, Iterator, List, Tuple, Type, Union def is_valid_estimator(name: str, cls: Any) -> bool: @@ -27,14 +27,13 @@ def normalize_estimators( return list(estimators) -def load_custom_estimators( +def iter_custom_estimators( custom_estimators: Union[ Callable[..., Any], List[Any], Tuple[Any, ...], Dict[str, Any] ], - all_estimators: Dict[str, Type], -) -> Dict[str, Type]: - """Convert user-provided estimators into a dictionary of valid ones.""" - extra = {} +) -> Iterator[Tuple[str, Type]]: + """Yield every valid (name, class) pair from user-provided estimators, in order and + without merging pairs that share a name.""" for est in normalize_estimators(custom_estimators): try: items = est() if callable(est) else est @@ -62,12 +61,26 @@ def load_custom_estimators( if not is_valid_estimator(name, cls): continue - if name in all_estimators and all_estimators[name] is not cls: - warnings.warn( - f"Estimator '{name}' conflicts with built-in one; preferring custom version.", - UserWarning, - ) + yield name, cls + + +def load_custom_estimators( + custom_estimators: Union[ + Callable[..., Any], List[Any], Tuple[Any, ...], Dict[str, Any] + ], + all_estimators: Dict[str, Type], +) -> Dict[str, Type]: + """Convert user-provided estimators into a dictionary of valid ones.""" + extra = {} + for name, cls in iter_custom_estimators(custom_estimators): + if name in all_estimators and all_estimators[name] is not cls: + warnings.warn( + f"Estimator '{name}' conflicts with built-in one; the custom version wins " + f"only for files without estimator_package (format v1-v3). Files written by " + f"openmodels >= 0.2.3 record each class's package and keep both.", + UserWarning, + ) - extra[name] = cls + extra[name] = cls return extra diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index cb931b6..8659c0f 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -5,7 +5,18 @@ converted to and from dictionary representations. """ -from typing import Any, Callable, Dict, List, Set, Tuple, Type, Optional, Union +from typing import ( + Any, + Callable, + Dict, + Iterable, + List, + Set, + Tuple, + Type, + Optional, + Union, +) from importlib.metadata import version as _package_version, PackageNotFoundError from datetime import datetime, timezone import platform @@ -15,7 +26,7 @@ import inspect from scipy.sparse import issparse # type: ignore -from ._custom_estimator import load_custom_estimators +from ._custom_estimator import iter_custom_estimators, load_custom_estimators import sklearn from sklearn.calibration import _CalibratedClassifier, _SigmoidCalibration @@ -46,7 +57,7 @@ from sklearn.utils.discovery import all_estimators from sklearn.neighbors import KDTree -from openmodels.exceptions import UnsupportedEstimatorError +from openmodels.exceptions import DeserializationError, UnsupportedEstimatorError from openmodels.protocols import ModelSerializer from openmodels.serializers.base import ( NumpySerializerMixin, @@ -103,7 +114,18 @@ # renamed "packages" (always including the domain package). Readers fall back to # producer_version as the scikit-learn version, and to "producers", for v1/v2 files only. # openmodels_version was dropped: producer_version holds the same value. -OPENMODELS_FORMAT_VERSION = 3 +# v4: every estimator node (root and nested) records "estimator_package" (the class's top-level +# package) next to "estimator_class", and classes are resolved by (package, class name), so +# same-named classes from different packages coexist. Files without it (v1-v3) resolve by bare +# name, custom estimators winning. Type tags are unchanged, so v3 readers still load v4 files. +OPENMODELS_FORMAT_VERSION = 4 + + +def _package_of(cls: type) -> str: + """Top-level package of a class (e.g. "sklearn", "chemotools", "__main__"). The full + module path isn't used: scikit-learn's real modules are private and renamed between + releases.""" + return cls.__module__.split(".")[0] def _openmodels_version() -> str: @@ -285,6 +307,18 @@ class SklearnSerializer( These estimators are merged into the serializer's internal registry for this instance only, allowing support for custom or external estimators without affecting the global registry. + Classes are identified by (top-level package, class name), so a custom class sharing a + built-in's name (e.g. chemotools' ``MinMaxScaler``) coexists with it. Only files + without ``estimator_package`` (format v1-v3) resolve by bare name, where the custom + class wins. + + trusted_function_modules : iterable of str, optional + Modules (and their submodules) from which deserialization may import and return + functions referenced by the file, e.g. the ``func`` of a ``FunctionTransformer``. + By default only already-imported functions from numpy, scipy, scikit-learn and the + packages of registered custom estimators are allowed; anything else raises + ``DeserializationError``. Functions defined in ``__main__`` are never allowed. + References ---------- .. [1] scikit-learn developer guide: @@ -321,13 +355,43 @@ def __init__( Dict[str, Type[BaseEstimator]], ] ] = None, + trusted_function_modules: Iterable[str] = (), ): + custom_pairs = ( + list(iter_custom_estimators(custom_estimators)) if custom_estimators else [] + ) + # Wrapped in a list: load_custom_estimators treats each list element as one source. extra = ( - load_custom_estimators(custom_estimators, ALL_ESTIMATORS) - if custom_estimators + load_custom_estimators([custom_pairs], ALL_ESTIMATORS) + if custom_pairs else {} ) - self._all_estimators: Dict[str, Type] = {**ALL_ESTIMATORS, **extra} + # Bare-name index, custom classes winning: only for files without estimator_package + # (format v1-v3). _all_estimators is kept as an alias for anything reading it. + self._by_name: Dict[str, Type] = {**ALL_ESTIMATORS, **extra} + self._all_estimators = self._by_name + # (package, class name) index, built from the raw pairs (not `extra`, which merges + # same-named classes from different packages and keys by the caller-supplied name). + self._by_id: Dict[Tuple[str, str], Type] = { + (_package_of(cls), cls.__name__): cls for cls in ALL_ESTIMATORS.values() + } + for _, cls in custom_pairs: + key = (_package_of(cls), cls.__name__) + if key in self._by_id and self._by_id[key] is not cls: + warnings.warn( + f"Estimator '{key[0]}.{key[1]}' is registered by two different classes; " + f"preferring the later one.", + UserWarning, + ) + self._by_id[key] = cls + self._custom_packages: Set[str] = { + _package_of(cls) for _, cls in custom_pairs + } - {"__main__"} + self._trusted_function_modules: Tuple[str, ...] = tuple( + trusted_function_modules + ) + # Bare names already warned about as ambiguous during the current deserialize() call. + self._ambiguity_warned: Set[str] = set() # Scratch state for one deserialize() call: (node, "prev_leaf_"|"next_leaf_") pairs # a Birch _CFNode's leaf-chain pointer couldn't resolve within its own subtree (root_ # and dummy_leaf_ are independently-deserialized top-level attributes; the pointer @@ -335,6 +399,48 @@ def __init__( self._birch_pending_leaf_links: List[Tuple[Any, str]] = [] # --- Helpers --- + def _allowed_function_roots(self) -> Set[str]: + # Registered custom estimators' packages are already imported, so their functions + # (e.g. a package's own score functions) resolve without importing anything. + return super()._allowed_function_roots() | self._custom_packages + + def _is_trusted_function_module(self, module_name: str) -> bool: + return any( + module_name == trusted or module_name.startswith(trusted + ".") + for trusted in self._trusted_function_modules + ) + + def _resolve_class(self, data: Dict[str, Any]) -> Type: + """ + Resolve the class of a serialized estimator node by (package, class name), or by bare + name for files without "estimator_package" (format v1-v3, custom estimators winning). + Only registered classes are reachable: nothing named by the file is ever imported. + """ + name = data["estimator_class"] + package = data.get("estimator_package") + if package is not None: + cls = self._by_id.get((package, name)) + if cls is None: + raise UnsupportedEstimatorError( + f"{package}.{name} is not registered; install '{package}' and pass it " + f"via custom_estimators" + ) + return cls + + cls = self._by_name.get(name) + if cls is None: + raise UnsupportedEstimatorError(f"Unknown estimator class '{name}'") + if name not in self._ambiguity_warned and ( + sum(1 for _, other in self._by_id if other == name) > 1 + ): + self._ambiguity_warned.add(name) + warnings.warn( + f"'{name}' resolved to {_package_of(cls)} (custom estimators win for files " + f"without estimator_package); re-save the model to record its package", + UserWarning, + ) + return cls + def _check_version(self, stored_version: Optional[str]) -> None: """ Check compatibility between stored scikit-learn version and the current environment. @@ -566,6 +672,21 @@ def is_valid_attribute(key: str) -> bool: return attributes + def convert_from_serializable( + self, value: Any, value_type: Any = "none", value_dtype: Optional[str] = None + ) -> Any: + # Every estimator node goes through _deserialize_core (and so _resolve_class), + # whatever its type tag: an unregistered class name has no tag handler and would + # otherwise silently come back as a raw dict instead of raising. + if ( + isinstance(value, dict) + and "estimator_class" in value + and isinstance(value_type, str) + and value_type != "dict" + ): + return self._deserialize_core(value) + return super().convert_from_serializable(value, value_type, value_dtype) + # --- Handlers --- def _get_serializer_handlers(self): # important to run before super() to deal with possible np.ndarray of estimators @@ -591,8 +712,7 @@ def _get_deserializer_handlers(self): ] # Estimators estimator_handlers = [ - (est_name, self._deserialize_core) - for est_name in self._all_estimators.keys() + (est_name, self._deserialize_core) for est_name in self._by_name.keys() ] kernel_handlers = [ @@ -1074,7 +1194,12 @@ def _deserialize_kernel(self, data: Dict[str, Any]) -> Kernel: kernel_cls = getattr( __import__("sklearn.gaussian_process.kernels", fromlist=[kernel_type]), kernel_type, + None, ) + # kernel_type comes from the file: only Kernel classes may be constructed, never an + # arbitrary callable living in the kernels module. + if not (isinstance(kernel_cls, type) and issubclass(kernel_cls, Kernel)): + raise DeserializationError(f"Unknown kernel type '{kernel_type}'") deserialized_params = {} for k, v in params.items(): if isinstance(v, dict) and "kernel_type" in v: @@ -1151,6 +1276,7 @@ def _serialize_core(self, model: BaseEstimator) -> Dict[str, Any]: # Build serializable estimator including extra info serialized_estimator = { "estimator_class": model.__class__.__name__, + "estimator_package": _package_of(type(model)), "params": self.convert_to_serializable(params), "param_types": param_types, "param_dtypes": param_dtypes, @@ -1198,16 +1324,20 @@ def _collect_package_names(self, serialized: Any, names: Set[str]) -> None: """ Recursively walk an already-serialized estimator dict (params/attributes, however deeply nested) and collect the top-level package name of every estimator class found - in it, via the same class registry used to deserialize them - this is what lets a - composite estimator mixing packages (e.g. a scikit-learn `Pipeline` with a - third-party step) report every package involved, not just the outermost one. + in it, from each node's own "estimator_package" - this is what lets a composite + estimator mixing packages (e.g. a scikit-learn `Pipeline` with a third-party step) + report every package involved, not just the outermost one. Nodes without the field + fall back to the bare-name class registry. """ if isinstance(serialized, dict): estimator_class = serialized.get("estimator_class") if estimator_class is not None: - cls = self._all_estimators.get(estimator_class) - if cls is not None: - names.add(cls.__module__.split(".")[0]) + package = serialized.get("estimator_package") + if package is None: + cls = self._by_name.get(estimator_class) + package = _package_of(cls) if cls is not None else None + if package is not None: + names.add(package) for value in serialized.values(): self._collect_package_names(value, names) elif isinstance(serialized, (list, tuple)): @@ -1320,6 +1450,7 @@ def deserialize(self, data: Dict[str, Any]) -> BaseEstimator: ) self._check_format_version(format_version) + self._ambiguity_warned = set() return self._deserialize_core(data) def _deserialize_core(self, data: Dict[str, Any]) -> BaseEstimator: @@ -1348,7 +1479,7 @@ def _deserialize_core(self, data: Dict[str, Any]) -> BaseEstimator: params[key] = tuple(value) # Get valid constructor arguments for the estimator - estimator_cls = self._all_estimators[estimator_class] + estimator_cls = self._resolve_class(data) valid_args = list(inspect.signature(estimator_cls.__init__).parameters.keys()) # Remove 'self' if present valid_args = [arg for arg in valid_args if arg != "self"] diff --git a/pyproject.toml b/pyproject.toml index cb152da..4ea55fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openmodels" -version = "0.2.2" +version = "0.2.3" description = "Export scikit-learn model files to JSON for sharing or deploying predictive models with peace of mind." authors = [ { name = "Alejandro Gutierrez", email = "a.gutierrez@g-npd.com" }, diff --git a/test/test_format_v4.py b/test/test_format_v4.py new file mode 100644 index 0000000..f34566e --- /dev/null +++ b/test/test_format_v4.py @@ -0,0 +1,281 @@ +""" +Format v4: estimator classes are identified by (package, class name), and function/kernel +references read from a file are restricted to an allowlist. +""" + +import copy +import json +import sys +import warnings + +import numpy as np +import pytest +import sklearn +from sklearn.base import BaseEstimator, TransformerMixin +from sklearn.feature_selection import SelectKBest, chi2 +from sklearn.gaussian_process import GaussianProcessRegressor +from sklearn.gaussian_process.kernels import RBF, WhiteKernel +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import FunctionTransformer, MinMaxScaler, StandardScaler + +from openmodels.core import SerializationManager +from openmodels.exceptions import DeserializationError, UnsupportedEstimatorError +from openmodels.serializers.sklearn.sklearn_serializer import SklearnSerializer + +X = np.array([[1.0, 5.0, 2.0], [4.0, 0.0, 3.0], [2.0, 2.0, 8.0], [0.0, 1.0, 1.0]]) +y = np.array([0, 1, 1, 0]) + + +def _make_scaler(package: str, offset: float) -> type: + """A class named MinMaxScaler that lives in `package` and scales each row (not each + column) like chemotools' MinMaxScaler, so its output differs from scikit-learn's.""" + + class MinMaxScaler(TransformerMixin, BaseEstimator): + def __init__(self, use_min=True): + self.use_min = use_min + + def fit(self, X, y=None): + self.offset_ = offset + self.n_features_in_ = np.asarray(X).shape[1] + return self + + def transform(self, X): + X = np.asarray(X, dtype=float) + return X / X.max(axis=1, keepdims=True) + self.offset_ + + MinMaxScaler.__module__ = f"{package}.scale" + return MinMaxScaler + + +FakeMinMaxScaler = _make_scaler("fakepkg", 0.0) +OtherMinMaxScaler = _make_scaler("otherpkg", 10.0) + + +def _serializer(*classes, **kwargs): + # A list of dicts, so classes sharing a name stay distinct registrations. + return SklearnSerializer( + custom_estimators=[{cls.__name__: cls} for cls in classes], **kwargs + ) + + +def _json(serialized): + # Like a real file: tuples in the type maps become lists, which deserialization expects. + return json.loads(json.dumps(serialized)) + + +def _roundtrip(serializer, model): + return serializer.deserialize(_json(serializer.serialize(model))) + + +def _estimator_nodes(obj): + """Every serialized estimator node (dict with "estimator_class") in obj.""" + if isinstance(obj, dict): + if "estimator_class" in obj: + yield obj + for value in obj.values(): + yield from _estimator_nodes(value) + elif isinstance(obj, list): + for item in obj: + yield from _estimator_nodes(item) + + +def _as_v3(serialized): + """A v4 dict turned into what format v3 wrote: no estimator_package anywhere.""" + old = _json(serialized) + for node in _estimator_nodes(old): + node.pop("estimator_package", None) + old["metadata"]["openmodels_format_version"] = 3 + return old + + +def _ambiguity_warnings(caught): + return [w for w in caught if "resolved to" in str(w.message)] + + +# ==== Identity written ==== + + +def test_every_node_records_its_package(): + serializer = _serializer(FakeMinMaxScaler) + pipeline = Pipeline([("fake", FakeMinMaxScaler()), ("sk", MinMaxScaler())]).fit(X) + serialized = serializer.serialize(pipeline) + + nodes = list(_estimator_nodes(serialized)) + assert [(n["estimator_class"], n["estimator_package"]) for n in nodes] == [ + ("Pipeline", "sklearn"), + ("MinMaxScaler", "fakepkg"), + ("MinMaxScaler", "sklearn"), + ] + assert "estimator_package" not in serialized["metadata"] + + +def test_metadata_packages_not_mislabelled_by_colliding_custom_class(): + serializer = _serializer(FakeMinMaxScaler) + packages = serializer.serialize(MinMaxScaler().fit(X))["metadata"]["packages"] + assert packages == {"sklearn": sklearn.__version__} + + +# ==== Collision coexistence ==== + + +@pytest.mark.parametrize("cls", [MinMaxScaler, FakeMinMaxScaler]) +def test_same_named_classes_each_resolve_to_their_own(cls): + model = cls().fit(X) + back = _roundtrip(_serializer(FakeMinMaxScaler), model) + assert type(back) is cls + np.testing.assert_array_equal(back.transform(X), model.transform(X)) + + +def test_same_named_classes_coexist_in_one_pipeline(): + pipeline = Pipeline([("fake", FakeMinMaxScaler()), ("sk", MinMaxScaler())]).fit(X) + back = _roundtrip(_serializer(FakeMinMaxScaler), pipeline) + assert type(back.steps[0][1]) is FakeMinMaxScaler + assert type(back.steps[1][1]) is MinMaxScaler + np.testing.assert_array_equal(back.transform(X), pipeline.transform(X)) + + +def test_two_custom_classes_with_the_same_name_coexist(): + serializer = _serializer(FakeMinMaxScaler, OtherMinMaxScaler) + pipeline = Pipeline( + [("fake", FakeMinMaxScaler()), ("other", OtherMinMaxScaler())] + ).fit(X) + back = _roundtrip(serializer, pipeline) + assert type(back.steps[0][1]) is FakeMinMaxScaler + assert type(back.steps[1][1]) is OtherMinMaxScaler + np.testing.assert_array_equal(back.transform(X), pipeline.transform(X)) + + +# ==== Files without estimator_package (v1-v3) ==== + + +def test_old_file_resolves_by_bare_name_without_warning(): + serializer = SklearnSerializer() + old = _as_v3(serializer.serialize(MinMaxScaler().fit(X))) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + back = serializer.deserialize(old) + assert type(back) is MinMaxScaler + assert not _ambiguity_warnings(caught) + + +def test_old_file_keeps_custom_wins_rule_and_warns_once(): + pipeline = Pipeline([("a", MinMaxScaler()), ("b", MinMaxScaler())]).fit(X) + old = _as_v3(SklearnSerializer().serialize(pipeline)) + + serializer = _serializer(FakeMinMaxScaler) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + back = serializer.deserialize(old) + assert all(type(step) is FakeMinMaxScaler for _, step in back.steps) + assert len(_ambiguity_warnings(caught)) == 1 + + # Reset per deserialize() call, so the next load warns again. + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + serializer.deserialize(old) + assert len(_ambiguity_warnings(caught)) == 1 + + +# ==== Unregistered / unknown classes ==== + + +def test_unregistered_package_raises_and_imports_nothing(): + data = { + "estimator_class": "Thing", + "estimator_package": "notinstalled", + "params": {}, + } + with pytest.raises(UnsupportedEstimatorError, match="notinstalled.Thing"): + SklearnSerializer().deserialize(data) + assert "notinstalled" not in sys.modules + + +def test_unknown_root_class_raises_unsupported_estimator(): + with pytest.raises(UnsupportedEstimatorError, match="Nope"): + SklearnSerializer().deserialize({"estimator_class": "Nope", "params": {}}) + + +@pytest.mark.parametrize("with_package", [True, False]) +def test_unknown_nested_class_raises_instead_of_loading_a_dict(with_package): + serializer = SklearnSerializer() + serialized = _json( + serializer.serialize( + Pipeline([("scale", StandardScaler()), ("sk", MinMaxScaler())]).fit(X) + ) + ) + step = serialized["params"]["steps"][0][1] + step["estimator_class"] = "Nope" + serialized["param_types"]["steps"][0][1] = "Nope" + if not with_package: + step.pop("estimator_package") + with pytest.raises(UnsupportedEstimatorError, match="Nope"): + serializer.deserialize(serialized) + + +# ==== Function allowlist ==== + + +def _select_k_best_dict(score_func_ref=None): + serialized = SklearnSerializer().serialize(SelectKBest(chi2, k=2).fit(X, y)) + if score_func_ref is not None: + serialized["params"]["score_func"] = score_func_ref + return serialized + + +def test_allowlisted_function_roundtrips(): + model = SelectKBest(chi2, k=2).fit(X, y) + back = _roundtrip(SklearnSerializer(), model) + assert back.score_func is chi2 + np.testing.assert_array_equal(back.transform(X), model.transform(X)) + + +@pytest.mark.parametrize( + "ref", + [ + {"module": "os", "name": "getcwd"}, + {"module": "numpy", "name": "_private"}, + {"module": "numpy", "name": "ndarray"}, # a class, not a function + {"module": "numpy.f2py.__main__", "name": "main"}, + {"module": "notinstalled_mod", "name": "f"}, + {"module": "sklearn", "name": None}, + ], +) +def test_disallowed_function_is_refused(ref): + manager = SerializationManager(SklearnSerializer()) + with pytest.raises(DeserializationError, match="not allowed"): + manager.deserialize(json.dumps(_select_k_best_dict(ref))) + assert "notinstalled_mod" not in sys.modules + + +def test_trusted_function_module(tmp_path, monkeypatch): + (tmp_path / "om_trusted_mod.py").write_text("def double(X):\n return X * 2\n") + monkeypatch.syspath_prepend(str(tmp_path)) + import om_trusted_mod # type: ignore[import-not-found] + + try: + model = FunctionTransformer(func=om_trusted_mod.double).fit(X) + serialized = _json(SklearnSerializer().serialize(model)) + + # Refused by default, even though the module is already imported. + with pytest.raises(DeserializationError, match="om_trusted_mod.double"): + SklearnSerializer().deserialize(copy.deepcopy(serialized)) + + # Trusted: imported on demand. + del sys.modules["om_trusted_mod"] + trusted = SklearnSerializer(trusted_function_modules=["om_trusted_mod"]) + back = trusted.deserialize(serialized) + np.testing.assert_array_equal(back.transform(X), X * 2) + finally: + sys.modules.pop("om_trusted_mod", None) + + +# ==== Kernels ==== + + +def test_kernel_type_must_be_a_kernel_class(): + serializer = SklearnSerializer() + model = GaussianProcessRegressor(kernel=RBF() + WhiteKernel()).fit(X, y) + serialized = serializer.serialize(model) + serialized["params"]["kernel"]["params"]["k1"]["kernel_type"] = "np" + with pytest.raises(DeserializationError, match="np"): + serializer.deserialize(serialized) From 616f2d9c57c4b1d9ed1b4b97a1daf072a307b9e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Tue, 6 Oct 2026 09:50:45 +0200 Subject: [PATCH 02/35] fix(serializer): deserialize tuple type maps so pickle and in-memory composites load --- CHANGELOG.md | 5 ++ openmodels/serializers/base.py | 5 +- test/test_format_v4.py | 2 +- test/test_formats_composite.py | 104 +++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 test/test_formats_composite.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 460b12d..244dab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - An unknown estimator class raised a bare `KeyError`; it now raises `UnsupportedEstimatorError`. A nested unknown estimator (e.g. a `Pipeline` step) used to load silently as a raw dict; it now raises too +- Composite models (`Pipeline`, `FeatureUnion`, `ColumnTransformer`) saved with + `format_name="pickle"` loaded with raw-dict steps and couldn't predict; `VotingClassifier`/ + `StackingClassifier` predicted, but their `estimators` param held raw dicts, breaking + `clone()`. The same happened when deserializing a `serialize()` result directly. Pickle files + written by earlier versions now load correctly ## [0.2.2] - 2026-09-22 diff --git a/openmodels/serializers/base.py b/openmodels/serializers/base.py index a0db52d..0513052 100644 --- a/openmodels/serializers/base.py +++ b/openmodels/serializers/base.py @@ -64,7 +64,10 @@ def convert_from_serializable( self, value: Any, value_type: Any = "none", value_dtype: Optional[str] = None ) -> Any: - if isinstance(value_type, list) and isinstance(value, list): + # Type maps hold tuples for tuple values (e.g. a Pipeline step's ("str", "")) + # when the dict stays in memory or goes through pickle; JSON-like formats have already + # turned them into lists. Both describe the same nested structure. + if isinstance(value_type, (list, tuple)) and isinstance(value, (list, tuple)): return [ self.convert_from_serializable(v, t, value_dtype) for v, t in zip(value, value_type) diff --git a/test/test_format_v4.py b/test/test_format_v4.py index f34566e..8cd5c36 100644 --- a/test/test_format_v4.py +++ b/test/test_format_v4.py @@ -59,7 +59,7 @@ def _serializer(*classes, **kwargs): def _json(serialized): - # Like a real file: tuples in the type maps become lists, which deserialization expects. + # Like a real JSON file. return json.loads(json.dumps(serialized)) diff --git a/test/test_formats_composite.py b/test/test_formats_composite.py new file mode 100644 index 0000000..4ec5eb5 --- /dev/null +++ b/test/test_formats_composite.py @@ -0,0 +1,104 @@ +""" +Composite estimators must round-trip in every format, and also when a serialize() result is +deserialized directly. Pickle and in-memory dicts keep tuples in the type maps (e.g. each +Pipeline step's type is a tuple), while JSON-like formats turn them into lists. +""" + +import numpy as np +import pytest +from sklearn.base import clone +from sklearn.compose import ColumnTransformer +from sklearn.ensemble import StackingClassifier, VotingClassifier +from sklearn.linear_model import LogisticRegression +from sklearn.pipeline import FeatureUnion, Pipeline +from sklearn.preprocessing import MinMaxScaler, StandardScaler +from sklearn.tree import DecisionTreeClassifier + +from openmodels import SerializationManager, SklearnSerializer + +X = np.random.RandomState(0).rand(60, 4) +y = (X[:, 0] + X[:, 1] > 1).astype(int) + +MODELS = { + "pipeline": lambda: Pipeline( + [("scale", StandardScaler()), ("clf", LogisticRegression())] + ), + "feature_union": lambda: Pipeline( + [ + ( + "union", + FeatureUnion([("std", StandardScaler()), ("mm", MinMaxScaler())]), + ), + ("clf", LogisticRegression()), + ] + ), + "column_transformer_in_pipeline": lambda: Pipeline( + [ + ( + "ct", + ColumnTransformer( + [("scale", StandardScaler(), [0, 1])], remainder="passthrough" + ), + ), + ("clf", LogisticRegression()), + ] + ), + "column_transformer": lambda: ColumnTransformer( + [("scale", StandardScaler(), [0, 1]), ("mm", MinMaxScaler(), [2, 3])] + ), + "voting": lambda: VotingClassifier( + [("lr", LogisticRegression()), ("dt", DecisionTreeClassifier(random_state=0))] + ), + "stacking": lambda: StackingClassifier([("lr", LogisticRegression())], cv=2), +} + +FORMATS = ["json", "pickle", "msgpack", "yaml"] + + +def _output(model): + return model.predict(X) if hasattr(model, "predict") else model.transform(X) + + +def _assert_fully_deserialized(loaded, original): + np.testing.assert_array_equal(_output(loaded), _output(original)) + # No sub-estimator may be left as a raw serialized dict, wherever it sits in the params + # (e.g. Voting/Stacking `estimators`, whose predict() doesn't use them). + for name, value in loaded.get_params(deep=True).items(): + assert not (isinstance(value, dict) and "estimator_class" in value), name + if isinstance(value, list): + for item in value: + parts = item if isinstance(item, (list, tuple)) else [item] + assert not any( + isinstance(p, dict) and "estimator_class" in p for p in parts + ), name + clone(loaded) + + +@pytest.mark.parametrize("fmt", FORMATS) +@pytest.mark.parametrize("model_name", MODELS) +def test_composite_roundtrip(model_name, fmt): + if fmt in ("msgpack", "yaml"): + pytest.importorskip(fmt) + model = MODELS[model_name]().fit(X, y) + manager = SerializationManager(SklearnSerializer()) + loaded = manager.deserialize( + manager.serialize(model, format_name=fmt), format_name=fmt + ) + _assert_fully_deserialized(loaded, model) + + +@pytest.mark.parametrize("model_name", MODELS) +def test_composite_roundtrip_in_memory(model_name): + model = MODELS[model_name]().fit(X, y) + serializer = SklearnSerializer() + _assert_fully_deserialized( + serializer.deserialize(serializer.serialize(model)), model + ) + + +def test_pipeline_pickle_file_roundtrip(tmp_path): + model = MODELS["pipeline"]().fit(X, y) + manager = SerializationManager(SklearnSerializer()) + path = tmp_path / "pipeline.pkl" + manager.save(model, path, format_name="pickle") + _assert_fully_deserialized(manager.load(path, format_name="pickle"), model) From 7fd7401d0a1c170c06882109e835b363d65ad136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Tue, 6 Oct 2026 11:22:40 +0200 Subject: [PATCH 03/35] fix(serializer): rebuild neighbors search trees on load from the estimator's state --- CHANGELOG.md | 5 + .../serializers/sklearn/sklearn_serializer.py | 51 +++++- test/test_neighbors_tree.py | 163 ++++++++++++++++++ 3 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 test/test_neighbors_tree.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 244dab3..b018465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `StackingClassifier` predicted, but their `estimators` param held raw dicts, breaking `clone()`. The same happened when deserializing a `serialize()` result directly. Pickle files written by earlier versions now load correctly +- `KNeighborsRegressor`/`RadiusNeighborsRegressor` couldn't predict after load when fitted with + a tree (the default for dense data), and every neighbors estimator using a non-euclidean + metric with `kd_tree`/`ball_tree` returned different neighbors after load. The search tree + is now rebuilt on load from the estimator's own state, exactly as scikit-learn builds it; + files written by earlier versions benefit too ## [0.2.2] - 2026-09-22 diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index 8659c0f..dfe4afa 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -55,7 +55,11 @@ from sklearn.base import BaseEstimator, check_is_fitted from sklearn.exceptions import NotFittedError from sklearn.utils.discovery import all_estimators -from sklearn.neighbors import KDTree +from sklearn.neighbors import BallTree, KDTree + +# Private, but its tree-building code (NeighborsBase._fit) is identical across TESTED_VERSIONS; +# see _rebuild_neighbors_tree. +from sklearn.neighbors._base import NeighborsBase from openmodels.exceptions import DeserializationError, UnsupportedEstimatorError from openmodels.protocols import ModelSerializer @@ -67,6 +71,14 @@ ConverterFunc = Callable[[Any], Any] +# Fitted attributes a neighbors estimator's search tree is rebuilt from on load. +_NEIGHBORS_TREE_INPUTS = ( + "_fit_X", + "_fit_method", + "effective_metric_", + "effective_metric_params_", +) + LOSS_CLASS_REGISTRY = { "AbsoluteError": AbsoluteError, "HalfBinomialLoss": HalfBinomialLoss, @@ -1112,6 +1124,35 @@ def _deserialize_kdtree(self, kdtree_data: Dict[str, Any]) -> KDTree: # Create KDTree with data - the tree will be rebuilt automatically return KDTree(data) + def _rebuild_neighbors_tree(self, model: BaseEstimator) -> None: + """ + Rebuild a neighbors estimator's search tree (`_tree`) from its own fitted state, + exactly as `NeighborsBase._fit` builds it: `KDTree`/`BallTree` over `_fit_X` with + `leaf_size`, `effective_metric_` and `effective_metric_params_`. The tree is + deterministic in those inputs, so the result is identical to the original. + + Rebuilding instead of restoring a stored tree means files never need to carry one: + regressors never stored `_tree` at all, and the trees older files did store were + rebuilt as `KDTree(data)`, dropping the metric and leaf_size (wrong neighbors for any + non-euclidean metric). Models fitted with `brute` (e.g. on sparse data) have no tree, + and files missing any of the inputs are left as they are. + """ + if not isinstance(model, NeighborsBase) or not all( + hasattr(model, attr) for attr in _NEIGHBORS_TREE_INPUTS + ): + return + fit_method = model._fit_method + if fit_method not in ("kd_tree", "ball_tree"): + model._tree = None # "brute", as NeighborsBase._fit sets it + return + tree_cls = KDTree if fit_method == "kd_tree" else BallTree + model._tree = tree_cls( + model._fit_X, + model.leaf_size, + metric=model.effective_metric_, + **model.effective_metric_params_, + ) + def _serialize_estimators_collection( self, value: Union[np.ndarray, List[BaseEstimator]] ) -> List[Any]: @@ -1506,10 +1547,17 @@ def _deserialize_core(self, data: Dict[str, Any]) -> BaseEstimator: if "attributes" not in data: return model # Unfitted model + # A neighbors estimator's search tree is rebuilt after the loop from the estimator's + # own state; a stored one (which lacks the metric and leaf_size) isn't loaded then. + rebuilds_tree = isinstance(model, NeighborsBase) and all( + key in data["attributes"] for key in _NEIGHBORS_TREE_INPUTS + ) for attribute, value in data["attributes"].items(): attr_type = data["attribute_types"].get(attribute) attr_dtype = data.get("attribute_dtypes", {}).get(attribute) or None + if attribute == "_tree" and rebuilds_tree: + continue # Handle tree_ separately if attr_type == "Tree": model.tree_ = self._deserialize_tree(value) @@ -1527,5 +1575,6 @@ def _deserialize_core(self, data: Dict[str, Any]) -> BaseEstimator: if estimator_class == "Birch" and self._birch_pending_leaf_links: self._resolve_birch_leaf_links() + self._rebuild_neighbors_tree(model) return model diff --git a/test/test_neighbors_tree.py b/test/test_neighbors_tree.py new file mode 100644 index 0000000..e8bbfa1 --- /dev/null +++ b/test/test_neighbors_tree.py @@ -0,0 +1,163 @@ +""" +Neighbors estimators' search trees (`_tree`) are rebuilt on load from the estimator's own +state (`_fit_X`, `leaf_size`, `effective_metric_`, `effective_metric_params_`), exactly as +scikit-learn builds them. All fits here are on dense data, where `algorithm="auto"` picks a +tree: sparse input forces brute force and would hide a missing or wrong tree. +""" + +import json + +import numpy as np +import pytest +from scipy.sparse import csr_matrix +from sklearn.neighbors import ( + KNeighborsClassifier, + KNeighborsRegressor, + KNeighborsTransformer, + LocalOutlierFactor, + NearestNeighbors, + RadiusNeighborsClassifier, + RadiusNeighborsRegressor, +) + +from openmodels import SerializationManager, SklearnSerializer + +rng = np.random.RandomState(0) +X = rng.rand(60, 4) +y = (X[:, 0] + X[:, 1] > 1).astype(int) +yr = X @ np.array([1.0, -2.0, 0.5, 3.0]) + +CASES = { + "knn_regressor_auto": (lambda: KNeighborsRegressor(), yr), + "knn_regressor_kd_manhattan": ( + lambda: KNeighborsRegressor(algorithm="kd_tree", metric="manhattan"), + yr, + ), + "knn_regressor_ball_tree": (lambda: KNeighborsRegressor(algorithm="ball_tree"), yr), + "radius_regressor_kd": ( + lambda: RadiusNeighborsRegressor(radius=1.0, algorithm="kd_tree"), + yr, + ), + "knn_classifier_kd_manhattan": ( + lambda: KNeighborsClassifier(algorithm="kd_tree", metric="manhattan"), + y, + ), + "knn_classifier_leaf_size": ( + lambda: KNeighborsClassifier(algorithm="kd_tree", leaf_size=5), + y, + ), + "radius_classifier_kd_chebyshev": ( + lambda: RadiusNeighborsClassifier( + radius=1.0, algorithm="kd_tree", metric="chebyshev" + ), + y, + ), + "nearest_neighbors_kd_p3": ( + lambda: NearestNeighbors(algorithm="kd_tree", p=3), + None, + ), + "local_outlier_factor_auto": (lambda: LocalOutlierFactor(novelty=True), None), + "kneighbors_transformer_kd": ( + lambda: KNeighborsTransformer(algorithm="kd_tree"), + None, + ), +} + + +def _fit(case): + make, target = CASES[case] + model = make() + return model.fit(X) if target is None else model.fit(X, target) + + +def _roundtrip(model, fmt="json"): + manager = SerializationManager(SklearnSerializer()) + return manager.deserialize( + manager.serialize(model, format_name=fmt), format_name=fmt + ) + + +def _assert_same_neighbors(loaded, original): + if hasattr(original, "kneighbors"): + d1, i1 = original.kneighbors(X[:20]) + d2, i2 = loaded.kneighbors(X[:20]) + np.testing.assert_allclose(d2, d1) + np.testing.assert_array_equal(i2, i1) + else: + d1, i1 = original.radius_neighbors(X[:20]) + d2, i2 = loaded.radius_neighbors(X[:20]) + for a, b in zip(d1, d2): + np.testing.assert_allclose(b, a) + for a, b in zip(i1, i2): + np.testing.assert_array_equal(b, a) + + if isinstance(original, LocalOutlierFactor): + np.testing.assert_allclose(loaded.score_samples(X), original.score_samples(X)) + elif hasattr(original, "predict"): + np.testing.assert_allclose(loaded.predict(X), original.predict(X)) + + +def _assert_same_tree(loaded, original): + assert type(loaded._tree) is type(original._tree) + for a, b in zip(original._tree.get_arrays(), loaded._tree.get_arrays()): + np.testing.assert_array_equal(b, a) + + +@pytest.mark.parametrize("case", CASES) +def test_neighbors_tree_rebuilt_identically(case): + model = _fit(case) + assert model._fit_method in ("kd_tree", "ball_tree") # the case must use a tree + loaded = _roundtrip(model) + _assert_same_neighbors(loaded, model) + _assert_same_tree(loaded, model) + + +def test_neighbors_tree_rebuilt_with_pickle(): + model = _fit("knn_regressor_kd_manhattan") + loaded = _roundtrip(model, "pickle") + _assert_same_neighbors(loaded, model) + _assert_same_tree(loaded, model) + + +def test_file_with_stored_tree_gets_correct_metric(): + # Classifiers keep writing `_tree` (so 0.2.2 readers still load these files). That stored + # tree only holds the data - 0.2.x rebuilt it as KDTree(data), i.e. euclidean - so it must + # be ignored in favour of a rebuild with the real metric. + model = _fit("knn_classifier_kd_manhattan") + serializer = SklearnSerializer() + serialized = json.loads(json.dumps(serializer.serialize(model))) + assert serialized["attribute_types"]["_tree"] == "KDTree" + + loaded = serializer.deserialize(serialized) + _assert_same_neighbors(loaded, model) + _assert_same_tree(loaded, model) + + +def test_brute_model_has_no_tree(): + model = KNeighborsClassifier().fit(csr_matrix(X), y) + assert model._fit_method == "brute" + loaded = _roundtrip(model) + assert loaded._tree is None + np.testing.assert_array_equal(loaded.predict(X), model.predict(X)) + + +def test_file_without_rebuild_inputs_still_loads(): + model = _fit("knn_classifier_leaf_size") + serializer = SklearnSerializer() + serialized = json.loads(json.dumps(serializer.serialize(model))) + del serialized["attributes"]["effective_metric_"] + + loaded = serializer.deserialize(serialized) + # No rebuild without the metric: the stored tree is used, as before. + assert loaded._tree is not None + np.testing.assert_array_equal(loaded.predict(X), model.predict(X)) + + +@pytest.mark.xfail( + strict=True, + reason="BUG_AUDIT.md #7: LOF still writes _tree, and a BallTree can't be serialized", +) +def test_local_outlier_factor_ball_tree(): + model = LocalOutlierFactor(novelty=True, algorithm="ball_tree").fit(X) + loaded = _roundtrip(model) + _assert_same_neighbors(loaded, model) From 9bbf57f099082b60edee6065cdb0e711dea5127b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Tue, 6 Oct 2026 12:03:31 +0200 Subject: [PATCH 04/35] test(helpers): round-trip the dense and sparse fits separately in run_test_model --- CHANGELOG.md | 5 ++ openmodels/test_helpers.py | 126 ++++++++++++++++++------------------- test/test_test_helpers.py | 75 ++++++++++++++++++++++ 3 files changed, 140 insertions(+), 66 deletions(-) create mode 100644 test/test_test_helpers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b018465..e3f1ab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 metric with `kd_tree`/`ball_tree` returned different neighbors after load. The search tree is now rebuilt on load from the estimator's own state, exactly as scikit-learn builds it; files written by earlier versions benefit too +- `run_test_model` test helper (`openmodels/test_helpers.py`): when sparse data was given, it + refitted the same instance on it and round-tripped only that sparse fit, so the dense fit + (e.g. neighbors search trees) was never tested - which hid the neighbors bug above. Dense and + sparse fits are now round-tripped separately, each on its own clone, and the caller's model + is no longer fitted ## [0.2.2] - 2026-09-22 diff --git a/openmodels/test_helpers.py b/openmodels/test_helpers.py index 336514c..8e937e0 100644 --- a/openmodels/test_helpers.py +++ b/openmodels/test_helpers.py @@ -18,7 +18,7 @@ ) import numpy as np from numpy import testing -from sklearn.base import BaseEstimator +from sklearn.base import BaseEstimator, clone from sklearn.feature_extraction import FeatureHasher from sklearn.feature_selection import GenericUnivariateSelect, f_classif from sklearn.preprocessing import LabelBinarizer @@ -308,6 +308,52 @@ def run_test_transformed_data( ) +def _roundtrip_and_compare( + fitted_model: FittableModel, + x: Union[np.ndarray, csr_matrix], + abs: bool, + model_name: str, +) -> None: + """ + Serialize `fitted_model` to JSON, deserialize it (in memory and through a file), and check + that both copies predict/transform `x` exactly like the original. Assertion errors are + prefixed with `model_name`, so a failure says which fit (e.g. dense or sparse) it was. + """ + manager = SerializationManager(SklearnSerializer()) + serialized_model = manager.serialize(fitted_model, format_name="json") + + def compare(deserialized_model) -> None: + if isinstance(fitted_model, PredictorModel): + run_test_predictions( + cast(PredictorModel, fitted_model), + cast(PredictorModel, deserialized_model), + x, + abs, + ) + elif isinstance(fitted_model, TransformerModel): + run_test_transformed_data( + cast(TransformerModel, fitted_model), + cast(TransformerModel, deserialized_model), + x, + ) + + model_file_path = f"./test/temp/{model_name}.json" + os.makedirs(os.path.dirname(model_file_path), exist_ok=True) + try: + compare(manager.deserialize(serialized_model, format_name="json")) + + with open(model_file_path, "w", encoding="utf-8") as f: + f.write(serialized_model) + with open(model_file_path, "r", encoding="utf-8") as f: + serialized_model_from_file = f.read() + compare(manager.deserialize(serialized_model_from_file, format_name="json")) + except AssertionError as e: + raise AssertionError(f"[{model_name}] {e}") from e + finally: + if os.path.exists(model_file_path): + os.remove(model_file_path) + + def run_test_model( model: FittableModel, x: Union[np.ndarray, csr_matrix], @@ -320,10 +366,16 @@ def run_test_model( """ Tests the serialization and deserialization of a scikit-learn model. + The model is fitted on `x`/`y` and round-tripped. If `x_sparse` and `y_sparse` are both + given, a separate copy is also fitted on them and round-tripped, compared on `x_sparse`. + Each fit uses its own clone of `model` (which is left unfitted): fitting both on one + instance would overwrite the dense fit, and sparse input changes what many estimators + learn (e.g. neighbors estimators then use brute force instead of a search tree). + Parameters ---------- model : Union[BaseEstimator, ModelType] - The scikit-learn model to test. + The scikit-learn model to test. Not modified. x : Union[np.ndarray, csr_matrix] The training input samples. y : np.ndarray @@ -331,78 +383,20 @@ def run_test_model( x_sparse : Optional[Union[np.ndarray, csr_matrix]] The sparse training input samples. y_sparse : np.ndarray or None - The sparse target values. + The sparse target values. The sparse fit is only tested when this is given. model_name : str The name of the file to save the serialized model to. abs : bool, default=False Whether to take the absolute value of the input data before fitting the model. """ - # Always ensure input data is in correct format, regardless of type x = ensure_correct_sparse_format(x) - if x_sparse is not None: - x_sparse = ensure_correct_sparse_format(x_sparse) - - # Fit and test the model - fitted_model = fit_model(model, x, y, abs) + dense_model = fit_model(clone(model), x, y, abs) + _roundtrip_and_compare(dense_model, x, abs, model_name) if x_sparse is not None and y_sparse is not None: - fit_model(model, x_sparse, y_sparse, abs) - - # Create a SerializationManager instance - manager = SerializationManager(SklearnSerializer()) - - # Serialize and deserialize the model - serialized_model = manager.serialize(fitted_model, format_name="json") - deserialized_model = manager.deserialize(serialized_model, format_name="json") - - # Test the deserialized model with properly formatted data - if isinstance(model, PredictorModel): - run_test_predictions( - cast(PredictorModel, fitted_model), - cast(PredictorModel, deserialized_model), - x, # x is already properly formatted - abs, - ) - elif isinstance(model, TransformerModel): - run_test_transformed_data( - cast(TransformerModel, fitted_model), - cast(TransformerModel, deserialized_model), - x, # x is already properly formatted - ) - - # Serialize and deserialize the model to/from a file - model_file_path = f"./test/temp/{model_name}.json" - - # Ensure the directory exists - os.makedirs(os.path.dirname(model_file_path), exist_ok=True) - - with open(model_file_path, "w", encoding="utf-8") as f: - f.write(serialized_model) - - with open(model_file_path, "r", encoding="utf-8") as f: - serialized_model_from_file = f.read() - - deserialized_model_from_file = manager.deserialize( - serialized_model_from_file, format_name="json" - ) - - # Test the deserialized model from file - if isinstance(model, PredictorModel): - run_test_predictions( - cast(PredictorModel, fitted_model), - cast(PredictorModel, deserialized_model_from_file), - x, - abs, # Pass the abs parameter to run_test_predictions - ) - elif isinstance(model, TransformerModel): - run_test_transformed_data( - cast(TransformerModel, fitted_model), - cast(TransformerModel, deserialized_model_from_file), - x, - ) - - # Clean up the temporary file - os.remove(model_file_path) + x_sparse = ensure_correct_sparse_format(x_sparse) + sparse_model = fit_model(clone(model), x_sparse, y_sparse, abs) + _roundtrip_and_compare(sparse_model, x_sparse, abs, f"{model_name}_sparse") def create_test_data( diff --git a/test/test_test_helpers.py b/test/test_test_helpers.py new file mode 100644 index 0000000..b837986 --- /dev/null +++ b/test/test_test_helpers.py @@ -0,0 +1,75 @@ +""" +Tests for openmodels.test_helpers.run_test_model itself: it must round-trip the dense fit and +the sparse fit separately, never the caller's own instance. +""" + +import os + +import numpy as np +import pytest +from scipy.sparse import csr_matrix +from sklearn.exceptions import NotFittedError +from sklearn.neighbors import KNeighborsRegressor +from sklearn.utils.validation import check_is_fitted + +from openmodels import test_helpers +from openmodels.serializers.sklearn.sklearn_serializer import SklearnSerializer + +rng = np.random.RandomState(0) +X = rng.rand(60, 3) +Y = X @ np.array([1.0, -2.0, 0.5]) +X_SPARSE = csr_matrix(rng.rand(60, 3) * (rng.rand(60, 3) > 0.5)) +Y_SPARSE = rng.rand(60) + + +def test_dense_and_sparse_fits_are_both_round_tripped(monkeypatch): + fit_methods = [] + original_serialize = SklearnSerializer.serialize + + def recording_serialize(self, model): + serialized = original_serialize(self, model) + fit_methods.append(serialized["attributes"]["_fit_method"]) + return serialized + + monkeypatch.setattr(SklearnSerializer, "serialize", recording_serialize) + test_helpers.run_test_model( + KNeighborsRegressor(), X, Y, X_SPARSE, Y_SPARSE, "helper_knn" + ) + + # Dense data gives a search tree, sparse forces brute force: both fits must be saved. + assert fit_methods == ["kd_tree", "brute"] + + +def test_sparse_fit_skipped_without_y_sparse(monkeypatch): + fit_methods = [] + original_serialize = SklearnSerializer.serialize + + def recording_serialize(self, model): + serialized = original_serialize(self, model) + fit_methods.append(serialized["attributes"]["_fit_method"]) + return serialized + + monkeypatch.setattr(SklearnSerializer, "serialize", recording_serialize) + test_helpers.run_test_model( + KNeighborsRegressor(), X, Y, X_SPARSE, None, "helper_knn" + ) + assert fit_methods == ["kd_tree"] + + +def test_callers_model_is_left_unfitted(): + model = KNeighborsRegressor() + test_helpers.run_test_model(model, X, Y, X_SPARSE, Y_SPARSE, "helper_knn") + with pytest.raises(NotFittedError): + check_is_fitted(model) + + +def test_temp_file_removed_and_failure_labelled(monkeypatch): + def failing_compare(*args, **kwargs): + raise AssertionError("mismatch") + + monkeypatch.setattr(test_helpers, "run_test_predictions", failing_compare) + with pytest.raises(AssertionError, match=r"\[helper_fail\] mismatch"): + test_helpers.run_test_model( + KNeighborsRegressor(), X, Y, None, None, "helper_fail" + ) + assert not os.path.exists("./test/temp/helper_fail.json") From 2ec961c293c84dcf540acd2078c7afd012ca4dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Tue, 6 Oct 2026 12:55:50 +0200 Subject: [PATCH 05/35] fix(serializer): restore numpy scalar types passed as parameters instead of float --- CHANGELOG.md | 5 ++ openmodels/serializers/base.py | 47 +++++++++---- test/test_type_params.py | 124 +++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 test/test_type_params.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e3f1ab6..e90f71f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (e.g. neighbors search trees) was never tested - which hid the neighbors bug above. Dense and sparse fits are now round-tripped separately, each on its own clone, and the caller's model is no longer fitted +- NumPy types passed as parameters were loaded as Python `float`, silently changing the model: + `CountVectorizer`'s default `dtype=np.int64` produced float64 counts after load, and e.g. + `OneHotEncoder(dtype=np.float32)` produced float64 output. They now load as the original + NumPy type, including from files written by earlier versions. An unknown type name still + loads as `float`, but now with a `UserWarning` ## [0.2.2] - 2026-09-22 diff --git a/openmodels/serializers/base.py b/openmodels/serializers/base.py index 0513052..7f9e42f 100644 --- a/openmodels/serializers/base.py +++ b/openmodels/serializers/base.py @@ -10,6 +10,7 @@ import importlib import inspect import sys +import warnings from types import ModuleType import numpy as np @@ -26,6 +27,13 @@ # SelectKBest score_func such as sklearn.feature_selection.chi2. DEFAULT_FUNCTION_ROOTS = frozenset({"numpy", "scipy", "sklearn"}) +# Python builtin types a type-valued param (e.g. `dtype=float`) may name; NumPy scalar types +# are resolved separately (see SerializerMixin._deserialize_type). +_BUILTIN_TYPES: Dict[str, type] = { + t.__name__: t + for t in (int, float, bool, str, bytes, complex, tuple, list, dict, object) +} + class SerializerMixin: """ @@ -97,18 +105,33 @@ def _serialize_type(self, value: type): return {"type_name": value.__name__} def _deserialize_type(self, value): - # Deserialize Python type objects from their string name - # Only allow a safe whitelist of types; default to float if not found - allowed_types = { - "int": int, - "float": float, - "str": str, - "bool": bool, - "tuple": tuple, - "list": list, - "dict": dict, - } - return allowed_types.get(value["type_name"], float) + """ + Resolve a type written by `_serialize_type` (its `__name__`, e.g. a `dtype=np.int64` + param) back to the type: a Python builtin from `_BUILTIN_TYPES`, else a NumPy scalar + type. Only canonical NumPy names (`np.dtype(name).type.__name__ == name`) are + accepted, since that's all the writer produces; aliases like "f8" and structured specs + like "i4,f8" are not. `np.dtype` only parses the name - nothing is imported or called. + An unknown name falls back to `float`, as before, but with a warning. + """ + name = value.get("type_name") + if name in _BUILTIN_TYPES: + return _BUILTIN_TYPES[name] + try: + numpy_type = np.dtype(name).type + except (TypeError, ValueError): + numpy_type = None + if ( + numpy_type is not None + and numpy_type is not np.void + and numpy_type.__name__ == name + ): + return numpy_type + warnings.warn( + f"Unknown type '{name}' in the model file; loading it as float. The loaded " + f"model may behave differently from the original.", + UserWarning, + ) + return float def _serialize_function(self, func: Callable) -> Dict[str, str]: """Serialize a Python function by its module and name.""" diff --git a/test/test_type_params.py b/test/test_type_params.py new file mode 100644 index 0000000..bb6e6db --- /dev/null +++ b/test/test_type_params.py @@ -0,0 +1,124 @@ +""" +Type-valued params (e.g. `dtype=np.int64`) must load as the identical type. They are written as +the type's `__name__`; NumPy scalar types used to fall back to Python `float`. +""" + +import json +import warnings + +import numpy as np +import pytest +from sklearn.feature_extraction import DictVectorizer, FeatureHasher +from sklearn.feature_extraction.text import ( + CountVectorizer, + HashingVectorizer, + TfidfVectorizer, +) +from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder + +from openmodels import SerializationManager, SklearnSerializer + +DOCS = ["apple banana apple", "banana cherry", "cherry date apple"] +CATEGORIES = [["a"], ["b"], ["c"], ["a"]] + + +def _roundtrip(model): + manager = SerializationManager(SklearnSerializer()) + return manager.deserialize(manager.serialize(model)) + + +def _type_roundtrip(t): + serializer = SklearnSerializer() + written = json.loads(json.dumps(serializer.convert_to_serializable(t))) + return serializer.convert_from_serializable( + written, serializer._get_nested_types(t) + ) + + +@pytest.mark.parametrize( + "t", + [ + int, + float, + bool, + str, + bytes, + complex, + object, + np.int64, + np.int32, + np.uint8, + np.float16, + np.float32, + np.float64, + np.complex128, + np.str_, + np.datetime64, + ], +) +def test_type_roundtrips_to_identical_object(t): + assert _type_roundtrip(t) is t + + +def test_numpy_bool_loads_as_python_bool(): + # Both are named "bool", so the file can't tell them apart; they're the same dtype. + assert _type_roundtrip(np.bool_) is bool + assert np.dtype(bool) == np.dtype(np.bool_) + + +@pytest.mark.parametrize( + "model, data", + [ + (CountVectorizer(), DOCS), + (TfidfVectorizer(), DOCS), + (HashingVectorizer(n_features=16), DOCS), + (DictVectorizer(), [{"a": 1}, {"b": 2}]), + (FeatureHasher(n_features=8), [{"a": 1}, {"b": 2}]), + (OneHotEncoder(), CATEGORIES), + (OrdinalEncoder(), CATEGORIES), + ], + ids=lambda v: type(v).__name__ if not isinstance(v, list) else "", +) +def test_default_dtype_estimators_keep_their_dtype(model, data): + model.fit(data) + assert _roundtrip(model).dtype is model.dtype + + +@pytest.mark.parametrize( + "model, data, expected", + [ + (CountVectorizer(), DOCS, np.int64), + (TfidfVectorizer(dtype=np.float32), DOCS, np.float32), + (OneHotEncoder(dtype=np.float32), CATEGORIES, np.float32), + ], + ids=["CountVectorizer", "TfidfVectorizer-float32", "OneHotEncoder-float32"], +) +def test_output_dtype_preserved(model, data, expected): + model.fit(data) + loaded = _roundtrip(model) + assert loaded.transform(data).dtype == expected + assert loaded.transform(data).dtype == model.transform(data).dtype + + +def test_file_written_by_earlier_versions_loads_numpy_type(): + # 0.2.x writes exactly this shape. + serializer = SklearnSerializer() + assert serializer.convert_from_serializable({"type_name": "int64"}, "type") is ( + np.int64 + ) + + +@pytest.mark.parametrize("name", ["nonsense", "f8", "i4,f8", "void", None]) +def test_unknown_or_non_canonical_name_warns_and_loads_as_float(name): + serializer = SklearnSerializer() + with pytest.warns(UserWarning, match="Unknown type"): + result = serializer.convert_from_serializable({"type_name": name}, "type") + assert result is float + + +def test_known_names_dont_warn(): + serializer = SklearnSerializer() + with warnings.catch_warnings(): + warnings.simplefilter("error") + for name in ["int", "float64", "int64", "float32"]: + serializer.convert_from_serializable({"type_name": name}, "type") From ecf9bde1a53de29f7aa8dd80a88f838e4f0ad7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Tue, 6 Oct 2026 14:12:59 +0200 Subject: [PATCH 06/35] fix(serializer): load numpy/scipy functions passed as parameters (ufuncs, array functions) --- CHANGELOG.md | 10 ++ openmodels/serializers/base.py | 82 ++++++++--- .../serializers/sklearn/sklearn_serializer.py | 2 + test/test_function_params.py | 137 ++++++++++++++++++ 4 files changed, 209 insertions(+), 22 deletions(-) create mode 100644 test/test_function_params.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e90f71f..d9ecec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `OneHotEncoder(dtype=np.float32)` produced float64 output. They now load as the original NumPy type, including from files written by earlier versions. An unknown type name still loads as `float`, but now with a `UserWarning` +- Functions passed as parameters: NumPy ufuncs (e.g. `FunctionTransformer(func=np.log1p)`, + `TransformedTargetRegressor(func=np.log1p, inverse_func=np.expm1)`) loaded as raw dicts and + failed at `transform`; SciPy ufuncs such as `scipy.special.expit`/`logit` couldn't be saved at + all; and NumPy array functions other than mean/median/max/min/sum (e.g. `np.std`, `np.clip`, + `np.linalg.norm`) were saved but could never be loaded. All of these now load as the original + function, including files written by earlier versions (except submodule functions such as + `np.linalg.norm`, whose module older files didn't record). Builtins like `abs` now raise a + clear `DeserializationError` (they're outside the function allowlist; opt in with + `trusted_function_modules`), and a callable that can't be located now fails at save with + `SerializationError` instead of `AttributeError` ## [0.2.2] - 2026-09-22 diff --git a/openmodels/serializers/base.py b/openmodels/serializers/base.py index 7f9e42f..6fe0caa 100644 --- a/openmodels/serializers/base.py +++ b/openmodels/serializers/base.py @@ -21,12 +21,32 @@ from typing import Any, Optional, Callable, Dict, Set -from openmodels.exceptions import DeserializationError +from openmodels.exceptions import DeserializationError, SerializationError # Top-level packages whose (already-imported) functions a model file may reference, e.g. a # SelectKBest score_func such as sklearn.feature_selection.chi2. DEFAULT_FUNCTION_ROOTS = frozenset({"numpy", "scipy", "sklearn"}) +# Type of NumPy's array functions (np.mean, np.std, np.linalg.norm, ...). +_NUMPY_ARRAY_FUNCTION_TYPE = type(np.mean) + +# Modules searched for a function that doesn't record its own `__module__` (SciPy's ufuncs, +# e.g. scipy.special.expit). Only already-imported modules are searched. +_FUNCTION_MODULE_CANDIDATES = ("numpy", "scipy.special") + + +def _find_function_module(func: Any) -> Optional[str]: + """Name of the module (among `_FUNCTION_MODULE_CANDIDATES`) that exposes `func` under its + own `__name__`, or None.""" + name = getattr(func, "__name__", None) + if not isinstance(name, str): + return None + for module_name in _FUNCTION_MODULE_CANDIDATES: + if getattr(sys.modules.get(module_name), name, None) is func: + return module_name + return None + + # Python builtin types a type-valued param (e.g. `dtype=float`) may name; NumPy scalar types # are resolved separately (see SerializerMixin._deserialize_type). _BUILTIN_TYPES: Dict[str, type] = { @@ -135,10 +155,13 @@ def _deserialize_type(self, value): def _serialize_function(self, func: Callable) -> Dict[str, str]: """Serialize a Python function by its module and name.""" - return { - "module": func.__module__, - "name": func.__name__, - } + name = getattr(func, "__name__", None) + module = getattr(func, "__module__", None) or _find_function_module(func) + if not isinstance(name, str) or module is None: + raise SerializationError( + f"Can't serialize callable {func!r}: it has no importable module and name" + ) + return {"module": module, "name": name} def _allowed_function_roots(self) -> Set[str]: """Top-level packages whose already-imported modules may provide deserialized @@ -222,6 +245,10 @@ def _get_deserializer_handlers(self): ("tuple", tuple), ("dict", self._deserialize_dict), ("function", self._deserialize_function), + # NumPy ufuncs (np.log1p, scipy.special.expit) and C builtins (abs) are tagged by + # their own type name; _deserialize_function accepts both under the allowlist. + ("ufunc", self._deserialize_function), + ("builtin_function_or_method", self._deserialize_function), ] @@ -242,14 +269,6 @@ def _get_dtype(self, value: Any) -> str: return "float64" # Use float64 for float lists return "" - _POOLING_FUNC_REGISTRY = { - "mean": np.mean, - "median": np.median, - "max": np.max, - "min": np.min, - "sum": np.sum, - } - # --- NumPy specific serializers/deserializers --- def _serialize_ndarray(self, value: np.ndarray): return self.convert_to_serializable(value.tolist()) @@ -263,18 +282,37 @@ def _deserialize_randomstate(self, value): return rs def _serialize_numpy_function(self, value): - # Only handle known numpy functions - for name, func in self._POOLING_FUNC_REGISTRY.items(): - if value is func: - return {"numpy_function": name} - # fallback: use __name__ if possible - return {"numpy_function": getattr(value, "__name__", None)} + # NumPy array functions (np.std, np.linalg.norm, ...). The module is recorded because + # the name alone can't find submodule functions (and np.fft is a module, not np.fft.fft). + name = getattr(value, "__name__", None) + if not isinstance(name, str): + raise SerializationError( + f"Can't serialize NumPy function {value!r}: no name" + ) + return {"numpy_function": name, "module": value.__module__} def _deserialize_numpy_function(self, value, value_dtype=None): + """ + Resolve a NumPy array function by name, from its recorded module (`"numpy"` for files + written before the module was recorded). Only already-imported `numpy` modules are + searched (nothing is imported), private names are refused, and the result must be a + NumPy array function, which rules out modules (e.g. `np.fft`), classes and the like. + """ name = value.get("numpy_function") - if name in self._POOLING_FUNC_REGISTRY: - return self._POOLING_FUNC_REGISTRY[name] - raise ValueError(f"Unknown numpy function: {name}") + module_name = value.get("module", "numpy") + obj = None + if ( + isinstance(name, str) + and not name.startswith("_") + and isinstance(module_name, str) + and module_name.split(".")[0] == "numpy" + ): + obj = getattr(sys.modules.get(module_name), name, None) + if not isinstance(obj, _NUMPY_ARRAY_FUNCTION_TYPE): + raise DeserializationError( + f"Unknown NumPy function '{module_name}.{name}' in the model file" + ) + return obj # --- Handlers --- def _get_serializer_handlers(self): diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index dfe4afa..5583857 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -330,6 +330,8 @@ class wins. By default only already-imported functions from numpy, scipy, scikit-learn and the packages of registered custom estimators are allowed; anything else raises ``DeserializationError``. Functions defined in ``__main__`` are never allowed. + Trusting ``"builtins"`` (e.g. for ``func=abs``) makes every builtin loadable, + including ``eval``, ``exec`` and ``open``. References ---------- diff --git a/test/test_function_params.py b/test/test_function_params.py new file mode 100644 index 0000000..d549b19 --- /dev/null +++ b/test/test_function_params.py @@ -0,0 +1,137 @@ +""" +Functions passed as parameters must load as the identical object: NumPy/SciPy ufuncs +(`np.log1p`, `scipy.special.expit`), NumPy array functions (`np.std`, `np.linalg.norm`) and +plain functions. Builtins are outside the function allowlist unless explicitly trusted. +""" + +import json + +import numpy as np +import pytest +import scipy.special +from sklearn.cluster import FeatureAgglomeration +from sklearn.compose import TransformedTargetRegressor +from sklearn.linear_model import LinearRegression +from sklearn.preprocessing import FunctionTransformer + +from openmodels import SerializationManager, SklearnSerializer +from openmodels.exceptions import DeserializationError, SerializationError + +rng = np.random.RandomState(0) +X = rng.rand(30, 4) + 0.1 # positive, so log1p/logit-friendly transforms are defined +X_UNIT = rng.rand(30, 4) * 0.8 + 0.1 # in (0, 1), for logit + + +def _roundtrip(model, serializer=None): + manager = SerializationManager(serializer or SklearnSerializer()) + return manager.deserialize(manager.serialize(model)) + + +def _transformer_roundtrip(func, data=X): + model = FunctionTransformer(func=func).fit(data) + loaded = _roundtrip(model) + assert loaded.func is func + np.testing.assert_array_equal(loaded.transform(data), model.transform(data)) + + +# ==== #5: ufuncs ==== + + +@pytest.mark.parametrize("func", [np.log1p, np.exp, np.sqrt, np.abs, np.expm1]) +def test_numpy_ufunc_roundtrips(func): + _transformer_roundtrip(func) + + +@pytest.mark.parametrize("func", [scipy.special.expit, scipy.special.logit]) +def test_scipy_ufunc_roundtrips(func): + _transformer_roundtrip(func, X_UNIT) + + +@pytest.mark.parametrize( + "func, inverse_func, y", + [ + (np.log1p, np.expm1, X @ np.array([1.0, 2.0, 0.5, 3.0])), + (scipy.special.logit, scipy.special.expit, X_UNIT[:, 0]), + ], + ids=["log1p-expm1", "logit-expit"], +) +def test_transformed_target_regressor_roundtrips(func, inverse_func, y): + model = TransformedTargetRegressor( + regressor=LinearRegression(), func=func, inverse_func=inverse_func + ).fit(X, y) + loaded = _roundtrip(model) + assert loaded.func is func and loaded.inverse_func is inverse_func + np.testing.assert_allclose(loaded.predict(X), model.predict(X)) + + +def test_ufunc_written_by_earlier_versions_loads(): + # 0.2.x writes exactly this shape for np.log1p. + serializer = SklearnSerializer() + loaded = serializer.convert_from_serializable( + {"module": "numpy", "name": "log1p"}, "ufunc" + ) + assert loaded is np.log1p + + +def test_unlocatable_ufunc_fails_at_save_with_serialization_error(): + vectorized = np.frompyfunc(lambda a: a, 1, 1) # no __module__, not in numpy/scipy + with pytest.raises(SerializationError, match="Can't serialize callable"): + SklearnSerializer().serialize(FunctionTransformer(func=vectorized)) + + +# ==== builtins: refused unless trusted ==== + + +def test_builtin_refused_by_default(): + with pytest.raises(DeserializationError, match="builtins.abs"): + _roundtrip(FunctionTransformer(func=abs).fit(X)) + + +def test_builtin_allowed_when_trusted(): + trusted = SklearnSerializer(trusted_function_modules=["builtins"]) + loaded = _roundtrip(FunctionTransformer(func=abs).fit(X), trusted) + assert loaded.func is abs + + +# ==== #11: NumPy array functions ==== + + +@pytest.mark.parametrize( + "func", [np.mean, np.std, np.clip, np.percentile, np.linalg.norm] +) +def test_numpy_array_function_roundtrips(func): + model = FunctionTransformer(func=func) + serialized = json.loads(json.dumps(SklearnSerializer().serialize(model))) + assert SklearnSerializer().deserialize(serialized).func is func + + +def test_feature_agglomeration_pooling_func_roundtrips(): + model = FeatureAgglomeration(n_clusters=2, pooling_func=np.std).fit(X) + loaded = _roundtrip(model) + assert loaded.pooling_func is np.std + np.testing.assert_allclose(loaded.transform(X), model.transform(X)) + + +@pytest.mark.parametrize("name, expected", [("std", np.std), ("mean", np.mean)]) +def test_array_function_written_by_earlier_versions_loads(name, expected): + # 0.2.x wrote only the name, no module. + serializer = SklearnSerializer() + loaded = serializer.convert_from_serializable( + {"numpy_function": name}, "_ArrayFunctionDispatcher" + ) + assert loaded is expected + + +@pytest.mark.parametrize( + "value", + [ + {"numpy_function": "fft"}, # resolves to the np.fft *module* + {"numpy_function": "_private"}, + {"numpy_function": "std", "module": "notnumpy"}, + {"numpy_function": "ndarray"}, # a class + {"numpy_function": None}, + ], +) +def test_array_function_refused(value): + with pytest.raises(DeserializationError, match="Unknown NumPy function"): + SklearnSerializer().convert_from_serializable(value, "_ArrayFunctionDispatcher") From 04232c1e7473b94bfbd508b8ef2358c6dd022f22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Tue, 6 Oct 2026 17:05:24 +0200 Subject: [PATCH 07/35] fix(security): only rebuild scipy.stats distributions from files, and round-trip them --- CHANGELOG.md | 7 ++ openmodels/serializers/base.py | 60 ++++++++++++-- test/test_scipy_distributions.py | 138 +++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 test/test_scipy_distributions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ecec3..eb4c5f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A Gaussian-process kernel's `kernel_type` read from a file must now name a `Kernel` class; before, any callable in `sklearn.gaussian_process.kernels` could be called with the file's parameters +- A SciPy distribution read from a file must now name a `scipy.stats` distribution generator; + before, a crafted file could make loading call any public `scipy.stats` function (e.g. + `describe`) with arguments of its choosing ### Changed @@ -68,6 +71,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 clear `DeserializationError` (they're outside the function allowlist; opt in with `trusted_function_modules`), and a callable that can't be located now fails at save with `SerializationError` instead of `AttributeError` +- SciPy distributions (e.g. in `RandomizedSearchCV(param_distributions=...)`) always loaded as + raw dicts, and discrete ones (`randint`, `poisson`) couldn't be saved at all. Both now + round-trip, inside `param_distributions` as a dict or a list of dicts, including continuous + ones in files written by earlier versions ## [0.2.2] - 2026-09-22 diff --git a/openmodels/serializers/base.py b/openmodels/serializers/base.py index 6fe0caa..89126df 100644 --- a/openmodels/serializers/base.py +++ b/openmodels/serializers/base.py @@ -16,7 +16,7 @@ import numpy as np from scipy.sparse import csr_matrix, csc_matrix, csr_array, csc_array # type: ignore from scipy.interpolate import interp1d, BSpline # type: ignore -from scipy.stats._distn_infrastructure import rv_continuous_frozen # type: ignore +from scipy.stats._distn_infrastructure import rv_frozen # type: ignore import scipy.stats # type: ignore from typing import Any, Optional, Callable, Dict, Set @@ -47,6 +47,9 @@ def _find_function_module(func: Any) -> Optional[str]: return None +# Keys of a saved frozen SciPy distribution (see ScipySerializerMixin._serialize_scipy_dist). +_SCIPY_DIST_KEYS = frozenset({"dist_name", "args", "kwargs"}) + # Python builtin types a type-valued param (e.g. `dtype=float`) may name; NumPy scalar types # are resolved separately (see SerializerMixin._deserialize_type). _BUILTIN_TYPES: Dict[str, type] = { @@ -210,10 +213,17 @@ def _deserialize_function(self, data: Dict[str, str]) -> Callable: ) return obj + def _restore_dict_value(self, value: Any) -> Any: + """Hook for restoring a value inside a plain string-keyed dict, whose values carry no + type tags of their own. Mixins override it for values recognisable by their shape + (see ScipySerializerMixin). Returns the value unchanged by default.""" + return value + def _deserialize_dict(self, value: Any) -> Any: """Deserialize a dict, restoring non-string key types for the envelope produced by - convert_to_serializable's dict branch. Plain string-keyed dicts (the common case, - including dicts produced by older openmodels versions) pass through unchanged. + convert_to_serializable's dict branch. Values of plain string-keyed dicts (the common + case, including dicts produced by older openmodels versions) go through + `_restore_dict_value`, which leaves them unchanged unless a mixin recognises them. """ if isinstance(value, dict) and value.get("__openmodels_dict__"): allowed_key_types = {"int": int, "float": float, "bool": bool, "str": str} @@ -223,6 +233,8 @@ def _deserialize_dict(self, value: Any) -> Any: ): self.convert_from_serializable(v) for k, kt, v in zip(value["keys"], value["key_types"], value["values"]) } + if isinstance(value, dict): + return {k: self._restore_dict_value(v) for k, v in value.items()} return value # --- Handlers --- @@ -392,12 +404,41 @@ def _deserialize_interp1d(self, value, value_dtype=None): copy=value["copy"], ) - def _serialize_scipy_dist(self, value: rv_continuous_frozen): - return {"dist_name": value.dist.name, "args": value.args, "kwargs": value.kwds} + def _serialize_scipy_dist(self, value: rv_frozen): + # Continuous and discrete frozen distributions (uniform(0, 1), randint(1, 10), ...). + return { + "dist_name": value.dist.name, + "args": self.convert_to_serializable(value.args), + "kwargs": self.convert_to_serializable(value.kwds), + } def _deserialize_scipy_dist(self, value, value_dtype=None): - dist = getattr(scipy.stats, value["dist_name"]) - return dist(*value["args"], **value["kwargs"]) + """ + Rebuild a frozen distribution by calling the named `scipy.stats` distribution generator + with the stored args. The name comes from the file, so it must name a distribution + generator (`rv_continuous`/`rv_discrete` instance), never any other `scipy.stats` + callable (e.g. `describe`), and private names are refused. + """ + name = value.get("dist_name") + generator = ( + getattr(scipy.stats, name, None) + if isinstance(name, str) and not name.startswith("_") + else None + ) + if not isinstance( + generator, (scipy.stats.rv_continuous, scipy.stats.rv_discrete) + ): + raise DeserializationError( + f"Unknown distribution '{name}' in the model file" + ) + return generator(*value.get("args", []), **value.get("kwargs", {})) + + def _restore_dict_value(self, value: Any) -> Any: + # Distributions sit inside dict params (RandomizedSearchCV's param_distributions), + # whose values have no type tags; they're recognised by their exact saved shape. + if isinstance(value, dict) and set(value) == _SCIPY_DIST_KEYS: + return self._deserialize_scipy_dist(value) + return super()._restore_dict_value(value) def _serialize_bspline(self, spline: BSpline) -> Dict[str, Any]: """ @@ -434,7 +475,7 @@ def _get_serializer_handlers(self): self._serialize_csr_matrix, ), (interp1d, self._serialize_interp1d), - (rv_continuous_frozen, self._serialize_scipy_dist), + (rv_frozen, self._serialize_scipy_dist), ] + super()._get_serializer_handlers() def _get_deserializer_handlers(self): @@ -442,5 +483,6 @@ def _get_deserializer_handlers(self): ("BSpline", self._deserialize_bspline), ("csr_matrix", self._deserialize_csr_matrix), ("interp1d", self._deserialize_interp1d), - ("scipy_dist", self._deserialize_scipy_dist), + ("rv_continuous_frozen", self._deserialize_scipy_dist), + ("rv_discrete_frozen", self._deserialize_scipy_dist), ] + super()._get_deserializer_handlers() diff --git a/test/test_scipy_distributions.py b/test/test_scipy_distributions.py new file mode 100644 index 0000000..2756c14 --- /dev/null +++ b/test/test_scipy_distributions.py @@ -0,0 +1,138 @@ +""" +SciPy frozen distributions (as used in RandomizedSearchCV's param_distributions) must +round-trip, and a model file must never be able to make loading call any other `scipy.stats` +callable. +""" + +import json + +import numpy as np +import pytest +import scipy.stats as st +from sklearn.base import clone +from sklearn.linear_model import LogisticRegression +from sklearn.model_selection import RandomizedSearchCV +from sklearn.preprocessing import FunctionTransformer + +from openmodels import SerializationManager, SklearnSerializer +from openmodels.exceptions import DeserializationError + +X = np.random.RandomState(0).rand(40, 3) +y = (X[:, 0] > 0.5).astype(int) + + +def _roundtrip(model): + manager = SerializationManager(SklearnSerializer()) + return manager.deserialize(manager.serialize(model)) + + +def _search_dict(param_distributions): + """A serialized (JSON round-tripped) unfitted RandomizedSearchCV.""" + model = RandomizedSearchCV(LogisticRegression(), param_distributions, n_iter=2) + return json.loads(json.dumps(SklearnSerializer().serialize(model))) + + +# ==== security ==== + + +@pytest.mark.parametrize( + "name", ["describe", "zscore", "_private", "nonexistent", None] +) +@pytest.mark.parametrize("tag", ["rv_continuous_frozen", "rv_discrete_frozen"]) +def test_non_distribution_names_refused(name, tag): + crafted = {"dist_name": name, "args": [[1, 2, 3]], "kwargs": {}} + with pytest.raises(DeserializationError, match="Unknown distribution"): + SklearnSerializer().convert_from_serializable(crafted, tag) + + +def test_non_distribution_name_inside_dict_param_refused(): + serialized = _search_dict({"C": st.uniform(0.1, 1)}) + serialized["params"]["param_distributions"]["C"]["dist_name"] = "describe" + with pytest.raises(DeserializationError, match="Unknown distribution 'describe'"): + SklearnSerializer().deserialize(serialized) + + +def test_legacy_scipy_dist_tag_calls_nothing(monkeypatch): + calls = [] + monkeypatch.setattr(st, "describe", lambda *a, **k: calls.append(a)) + crafted = {"dist_name": "describe", "args": [[1, 2, 3]], "kwargs": {}} + assert ( + SklearnSerializer().convert_from_serializable(crafted, "scipy_dist") == crafted + ) + assert calls == [] + + +# ==== round trips ==== + + +@pytest.mark.parametrize( + "dist", + [ + st.uniform(0.1, 1), + st.loguniform(1e-3, 1e2), + st.norm(loc=0, scale=2), + st.randint(1, 10), + st.poisson(3), + ], + ids=lambda d: d.dist.name, +) +def test_distribution_roundtrips(dist): + serializer = SklearnSerializer() + written = json.loads(json.dumps(serializer.convert_to_serializable(dist))) + loaded = serializer.convert_from_serializable( + written, serializer._get_nested_types(dist) + ) + assert type(loaded) is type(dist) + assert loaded.dist.name == dist.dist.name + assert list(loaded.args) == list(dist.args) and loaded.kwds == dist.kwds + np.testing.assert_array_equal( + loaded.rvs(size=5, random_state=0), dist.rvs(size=5, random_state=0) + ) + + +@pytest.mark.parametrize("fitted", [False, True], ids=["unfitted", "fitted"]) +@pytest.mark.parametrize( + "param_distributions", + [ + {"C": st.loguniform(1e-2, 1e1), "max_iter": st.randint(50, 200)}, + [{"C": st.loguniform(1e-2, 1e1)}, {"max_iter": st.randint(50, 200)}], + ], + ids=["dict", "list_of_dicts"], +) +def test_randomized_search_roundtrips(param_distributions, fitted): + model = RandomizedSearchCV( + LogisticRegression(), param_distributions, n_iter=3, cv=2, random_state=0 + ) + if fitted: + model.fit(X, y) + loaded = _roundtrip(model) + + grids = loaded.param_distributions + for grid in grids if isinstance(grids, list) else [grids]: + assert all(hasattr(v, "rvs") for v in grid.values()) + clone(loaded) + if fitted: + np.testing.assert_array_equal(loaded.predict(X), model.predict(X)) + + # Refitting the loaded search samples the same candidates as the original. + refit_original = clone(model).fit(X, y) + refit_loaded = clone(loaded).fit(X, y) + assert refit_loaded.cv_results_["params"] == refit_original.cv_results_["params"] + + +def test_param_distributions_written_by_earlier_versions_load(): + # 0.2.x wrote continuous distributions in exactly this shape inside the dict param. + serialized = _search_dict({"C": st.uniform(0.1, 1)}) + serialized["params"]["param_distributions"]["C"] = { + "dist_name": "uniform", + "args": [0.1, 1], + "kwargs": {}, + } + loaded = SklearnSerializer().deserialize(serialized) + assert loaded.param_distributions["C"].dist.name == "uniform" + + +def test_dict_with_other_keys_left_unchanged(): + kw_args = {"dist_name": "x", "args": [1]} # not the exact three-key shape + loaded = _roundtrip(FunctionTransformer(kw_args=kw_args)) + assert loaded.kw_args == kw_args From aa12f2bac74df4c934c8bf4aba8d84f3d249f744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Tue, 6 Oct 2026 17:17:15 +0200 Subject: [PATCH 08/35] fix(serializer): save neighbors models fitted with a BallTree; save RadiusNeighborsTransformer data --- CHANGELOG.md | 5 ++ .../serializers/sklearn/sklearn_serializer.py | 11 ++- test/test_neighbors_tree.py | 83 +++++++++++++++++-- 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb4c5f6..529f1ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 raw dicts, and discrete ones (`randint`, `poisson`) couldn't be saved at all. Both now round-trip, inside `param_distributions` as a dict or a list of dicts, including continuous ones in files written by earlier versions +- Neighbors estimators fitted with a `BallTree` (`algorithm="ball_tree"`, or `auto` with metrics + such as `haversine`) couldn't be saved (`TypeError`). They now save and load, including inside + `Isomap`/`LocallyLinearEmbedding`; the tree is rebuilt on load rather than stored +- `RadiusNeighborsTransformer` didn't save its training data, so brute-force models couldn't + `transform` after load and non-euclidean `kd_tree` ones gave wrong results ## [0.2.2] - 2026-09-22 diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index 5583857..d4d4dfc 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -256,7 +256,7 @@ def _openmodels_version() -> str: "KNNImputer": ["_mask_fit_X", "_valid_mask"], "KNeighborsTransformer": ["_fit_method", "_tree", "_fit_X"], "PowerTransformer": ["_scaler"], - "RadiusNeighborsTransformer": ["_fit_method", "_tree"], + "RadiusNeighborsTransformer": ["_fit_method", "_tree", "_fit_X"], "SimpleImputer": ["_fit_dtype", "_fill_dtype"], "MiniBatchNMF": ["_n_components", "_transform_max_iter", "_beta_loss", "_gamma"], "MissingIndicator": ["_n_features", "_precomputed"], @@ -684,6 +684,15 @@ def is_valid_attribute(key: str) -> bool: if hasattr(estimator, key) } + # A neighbors estimator's search tree is rebuilt on load from its own state (see + # _rebuild_neighbors_tree), so a stored tree is never used. A KDTree is still written + # only so openmodels 0.2.2 can read the file; a BallTree can't be serialized and no + # older reader could use it, so it's left out. + if isinstance(estimator, NeighborsBase) and isinstance( + attributes.get("_tree"), BallTree + ): + del attributes["_tree"] + return attributes def convert_from_serializable( diff --git a/test/test_neighbors_tree.py b/test/test_neighbors_tree.py index e8bbfa1..f819e3a 100644 --- a/test/test_neighbors_tree.py +++ b/test/test_neighbors_tree.py @@ -10,6 +10,7 @@ import numpy as np import pytest from scipy.sparse import csr_matrix +from sklearn.manifold import Isomap, LocallyLinearEmbedding from sklearn.neighbors import ( KNeighborsClassifier, KNeighborsRegressor, @@ -18,6 +19,7 @@ NearestNeighbors, RadiusNeighborsClassifier, RadiusNeighborsRegressor, + RadiusNeighborsTransformer, ) from openmodels import SerializationManager, SklearnSerializer @@ -61,8 +63,41 @@ lambda: KNeighborsTransformer(algorithm="kd_tree"), None, ), + "radius_transformer_kd_manhattan": ( + lambda: RadiusNeighborsTransformer( + radius=1.0, algorithm="kd_tree", metric="manhattan" + ), + None, + ), + # BallTree cases (BUG_AUDIT.md #7): the tree isn't written, only rebuilt on load. + "knn_classifier_ball_tree": ( + lambda: KNeighborsClassifier(algorithm="ball_tree"), + y, + ), + "radius_classifier_ball_tree": ( + lambda: RadiusNeighborsClassifier(radius=1.0, algorithm="ball_tree"), + y, + ), + "nearest_neighbors_ball_tree": ( + lambda: NearestNeighbors(algorithm="ball_tree"), + None, + ), + "local_outlier_factor_ball_tree": ( + lambda: LocalOutlierFactor(novelty=True, algorithm="ball_tree"), + None, + ), + "kneighbors_transformer_ball_tree": ( + lambda: KNeighborsTransformer(algorithm="ball_tree"), + None, + ), + "radius_transformer_ball_tree": ( + lambda: RadiusNeighborsTransformer(radius=1.0, algorithm="ball_tree"), + None, + ), } +BALL_TREE_CASES = [case for case in CASES if case.endswith("ball_tree")] + def _fit(case): make, target = CASES[case] @@ -153,11 +188,47 @@ def test_file_without_rebuild_inputs_still_loads(): np.testing.assert_array_equal(loaded.predict(X), model.predict(X)) -@pytest.mark.xfail( - strict=True, - reason="BUG_AUDIT.md #7: LOF still writes _tree, and a BallTree can't be serialized", +@pytest.mark.parametrize("case", BALL_TREE_CASES) +def test_ball_tree_not_written(case): + model = _fit(case) + assert model._fit_method == "ball_tree" + serialized = SklearnSerializer().serialize(model) + assert "_tree" not in serialized["attributes"] + + +def test_auto_haversine_uses_ball_tree_and_roundtrips(): + coords = rng.rand(40, 2) # (lat, lon) in radians + labels = (coords[:, 0] > 0.5).astype(int) + model = KNeighborsClassifier(metric="haversine").fit(coords, labels) + assert model._fit_method == "ball_tree" + loaded = _roundtrip(model) + np.testing.assert_array_equal(loaded.predict(coords), model.predict(coords)) + d1, i1 = model.kneighbors(coords) + d2, i2 = loaded.kneighbors(coords) + np.testing.assert_allclose(d2, d1) + np.testing.assert_array_equal(i2, i1) + + +@pytest.mark.parametrize( + "model", + [ + Isomap(n_neighbors=5, neighbors_algorithm="ball_tree"), + LocallyLinearEmbedding(n_neighbors=8, neighbors_algorithm="ball_tree"), + ], + ids=["Isomap", "LocallyLinearEmbedding"], ) -def test_local_outlier_factor_ball_tree(): - model = LocalOutlierFactor(novelty=True, algorithm="ball_tree").fit(X) +def test_nested_ball_tree_roundtrips(model): + model.fit(X) loaded = _roundtrip(model) - _assert_same_neighbors(loaded, model) + np.testing.assert_allclose(loaded.transform(X), model.transform(X)) + + +def test_radius_neighbors_transformer_brute_roundtrips(): + # Its training data (_fit_X) wasn't saved before, so a brute-force model couldn't transform. + model = RadiusNeighborsTransformer(radius=1.0).fit(csr_matrix(X)) + assert model._fit_method == "brute" + loaded = _roundtrip(model) + np.testing.assert_allclose( + loaded.transform(csr_matrix(X)).toarray(), + model.transform(csr_matrix(X)).toarray(), + ) From 834df888aef9adc7cdc92578486f28c635a5556b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Tue, 6 Oct 2026 17:37:58 +0200 Subject: [PATCH 09/35] fix(serializer): restore KernelDensity's search tree with its metric and sample weights --- CHANGELOG.md | 4 + .../serializers/sklearn/sklearn_serializer.py | 74 +++++++++++---- test/test_kernel_density.py | 92 +++++++++++++++++++ 3 files changed, 152 insertions(+), 18 deletions(-) create mode 100644 test/test_kernel_density.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 529f1ee..a390bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `Isomap`/`LocallyLinearEmbedding`; the tree is rebuilt on load rather than stored - `RadiusNeighborsTransformer` didn't save its training data, so brute-force models couldn't `transform` after load and non-euclidean `kd_tree` ones gave wrong results +- `KernelDensity` couldn't be used after loading in any configuration: its search tree was + restored under the wrong attribute name, without its metric or sample weights, and a + `ball_tree` one couldn't be saved. It now round-trips exactly. Weighted models saved by earlier + versions load with uniform weights, since their weights were never saved ## [0.2.2] - 2026-09-22 diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index d4d4dfc..166739a 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -55,7 +55,7 @@ from sklearn.base import BaseEstimator, check_is_fitted from sklearn.exceptions import NotFittedError from sklearn.utils.discovery import all_estimators -from sklearn.neighbors import BallTree, KDTree +from sklearn.neighbors import BallTree, KDTree, KernelDensity # Private, but its tree-building code (NeighborsBase._fit) is identical across TESTED_VERSIONS; # see _rebuild_neighbors_tree. @@ -686,8 +686,8 @@ def is_valid_attribute(key: str) -> bool: # A neighbors estimator's search tree is rebuilt on load from its own state (see # _rebuild_neighbors_tree), so a stored tree is never used. A KDTree is still written - # only so openmodels 0.2.2 can read the file; a BallTree can't be serialized and no - # older reader could use it, so it's left out. + # only so openmodels 0.2.2 can read the file; no older reader can load a BallTree, so + # it's left out (smaller files). if isinstance(estimator, NeighborsBase) and isinstance( attributes.get("_tree"), BallTree ): @@ -716,7 +716,7 @@ def _get_serializer_handlers(self): return [ (BaseEstimator, self._serialize_core), (BaseLoss, self._serialize_loss), - (KDTree, self._serialize_kdtree), + ((KDTree, BallTree), self._serialize_search_tree), (Kernel, self._serialize_kernel), (Tree, self._serialize_tree), (TreePredictor, self._serialize_tree_predictor), @@ -1112,28 +1112,61 @@ def _deserialize_loss(self, value: Dict[str, Any], loss_name: str) -> BaseLoss: params = value.get("params", {}) return loss_cls(**params) - def _serialize_kdtree(self, value: KDTree) -> Dict[str, Any]: + def _serialize_search_tree(self, value: Union[KDTree, BallTree]) -> Dict[str, Any]: """ - Serializes a KDTree object to a dictionary. + Serialize a KDTree/BallTree as the inputs it's rebuilt from: its data and its sample + weights (None when unweighted). The weights are stored only inside the tree - e.g. + KernelDensity keeps them nowhere else. The metric and leaf_size live on the owning + estimator, which rebuilds the tree with them on load (`_rebuild_neighbors_tree`, + `_rebuild_kernel_density_tree`). dtype is captured explicitly (same reasoning as + _serialize_bisecting_tree) so non-float64 data isn't silently widened by JSON. """ - # For KDTree, we'll use a simpler approach - just serialize the essential data - # and let the tree be reconstructed from the data. dtype is captured explicitly - # (same reasoning as _serialize_bisecting_tree) so non-float64 data isn't silently - # widened by the generic JSON round-trip. data = np.array(value.data) + sample_weight = value.sample_weight return { "data": self.convert_to_serializable(data), "data_dtype": str(data.dtype), + "sample_weight": ( + None + if sample_weight is None + else self.convert_to_serializable(np.asarray(sample_weight)) + ), } - def _deserialize_kdtree(self, kdtree_data: Dict[str, Any]) -> KDTree: + def _deserialize_search_tree( + self, tree_data: Dict[str, Any], tree_cls: Type[Union[KDTree, BallTree]] + ) -> Union[KDTree, BallTree]: """ - Deserializes a dictionary representation of a KDTree back to a KDTree object. + Rebuild a KDTree/BallTree from its data and sample weights, with the default metric + and leaf_size. Estimators that own a tree rebuild it again with their real metric + (see `_serialize_search_tree`). Files written before 0.2.3 have no "sample_weight". """ - data = np.array(kdtree_data["data"], dtype=kdtree_data.get("data_dtype")) + data = np.array(tree_data["data"], dtype=tree_data.get("data_dtype")) + sample_weight = tree_data.get("sample_weight") + return tree_cls( + data, + sample_weight=None if sample_weight is None else np.asarray(sample_weight), + ) - # Create KDTree with data - the tree will be rebuilt automatically - return KDTree(data) + def _rebuild_kernel_density_tree(self, model: BaseEstimator) -> None: + """ + Rebuild a KernelDensity's `tree_` exactly as `KernelDensity.fit` builds it: the loaded + tree's class, data and sample weights, plus the estimator's `metric`, `leaf_size` and + `metric_params` (which the loaded tree, built with defaults, lacks). + """ + tree = getattr(model, "tree_", None) + if not isinstance(model, KernelDensity) or not isinstance( + tree, (KDTree, BallTree) + ): + return + sample_weight = tree.sample_weight + model.tree_ = type(tree)( + np.asarray(tree.data), + metric=model.metric, + leaf_size=model.leaf_size, + sample_weight=None if sample_weight is None else np.asarray(sample_weight), + **(model.metric_params or {}), + ) def _rebuild_neighbors_tree(self, model: BaseEstimator) -> None: """ @@ -1573,9 +1606,13 @@ def _deserialize_core(self, data: Dict[str, Any]) -> BaseEstimator: if attr_type == "Tree": model.tree_ = self._deserialize_tree(value) continue - # Skip _tree attribute for KDTree - let the transformer recreate it - if attr_type == "KDTree": - model._tree = self._deserialize_kdtree(value) + # Search trees (a neighbors estimator's `_tree`, KernelDensity's `tree_`), restored + # under the attribute's own name; their owners rebuild them after the loop. + if attr_type in ("KDTree", "BallTree"): + tree_cls = KDTree if attr_type == "KDTree" else BallTree + setattr( + model, attribute, self._deserialize_search_tree(value, tree_cls) + ) continue # Use convert_from_serializable for all attributes setattr( @@ -1587,5 +1624,6 @@ def _deserialize_core(self, data: Dict[str, Any]) -> BaseEstimator: if estimator_class == "Birch" and self._birch_pending_leaf_links: self._resolve_birch_leaf_links() self._rebuild_neighbors_tree(model) + self._rebuild_kernel_density_tree(model) return model diff --git a/test/test_kernel_density.py b/test/test_kernel_density.py new file mode 100644 index 0000000..f754974 --- /dev/null +++ b/test/test_kernel_density.py @@ -0,0 +1,92 @@ +""" +KernelDensity's search tree (`tree_`) must round-trip with its metric, leaf_size and sample +weights; the weights are stored only inside the tree. +""" + +import json + +import numpy as np +import pytest +from sklearn.neighbors import BallTree, KDTree, KernelDensity + +from openmodels import SerializationManager, SklearnSerializer + +rng = np.random.RandomState(0) +X = rng.rand(40, 3) +WEIGHTS = rng.rand(40) + +CASES = { + "default": (lambda: KernelDensity(), None), + "ball_tree": (lambda: KernelDensity(algorithm="ball_tree"), None), + "kd_tree_manhattan": ( + lambda: KernelDensity(algorithm="kd_tree", metric="manhattan"), + None, + ), + "leaf_size": (lambda: KernelDensity(leaf_size=5), None), + "sample_weight": (lambda: KernelDensity(), WEIGHTS), + "bandwidth_scott": (lambda: KernelDensity(bandwidth="scott"), None), +} + + +def _fit(case): + make, weights = CASES[case] + return make().fit(X, sample_weight=weights) + + +def _roundtrip(model, fmt="json"): + manager = SerializationManager(SklearnSerializer()) + return manager.deserialize( + manager.serialize(model, format_name=fmt), format_name=fmt + ) + + +def _assert_same(loaded, original): + np.testing.assert_allclose(loaded.score_samples(X), original.score_samples(X)) + assert type(loaded.tree_) is type(original.tree_) + for a, b in zip(original.tree_.get_arrays(), loaded.tree_.get_arrays()): + np.testing.assert_array_equal(b, a) + + +@pytest.mark.parametrize("case", CASES) +def test_kernel_density_roundtrips(case): + model = _fit(case) + _assert_same(_roundtrip(model), model) + + +def test_kernel_density_roundtrips_with_pickle(): + model = _fit("sample_weight") + _assert_same(_roundtrip(model, "pickle"), model) + + +def test_tree_payload_holds_sample_weights(): + serialized = SklearnSerializer().serialize(_fit("sample_weight")) + assert serialized["attribute_types"]["tree_"] == "KDTree" + np.testing.assert_allclose( + serialized["attributes"]["tree_"]["sample_weight"], WEIGHTS + ) + + unweighted = SklearnSerializer().serialize(_fit("ball_tree")) + assert unweighted["attribute_types"]["tree_"] == "BallTree" + assert unweighted["attributes"]["tree_"]["sample_weight"] is None + + +def test_file_written_by_earlier_versions_loads_unweighted_model(): + # 0.2.x wrote KDTree payloads without "sample_weight"; such files never loaded before. + model = _fit("kd_tree_manhattan") + serializer = SklearnSerializer() + serialized = json.loads(json.dumps(serializer.serialize(model))) + del serialized["attributes"]["tree_"]["sample_weight"] + _assert_same(serializer.deserialize(serialized), model) + + +def test_neighbors_tree_payload_unchanged_for_old_readers(): + # Neighbors estimators still write a KDTree with "data"/"data_dtype" (all 0.2.2 reads). + from sklearn.neighbors import KNeighborsClassifier + + model = KNeighborsClassifier(algorithm="kd_tree").fit( + X, (X[:, 0] > 0.5).astype(int) + ) + payload = SklearnSerializer().serialize(model)["attributes"]["_tree"] + assert {"data", "data_dtype"} <= set(payload) + assert isinstance(KDTree(np.array(payload["data"])), KDTree) + assert not isinstance(model._tree, BallTree) From 5a981481310b2aba71c0d518de11318ca88d1c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Tue, 6 Oct 2026 18:20:03 +0200 Subject: [PATCH 10/35] fix(serializer): load every Gaussian-process kernel and serialize kernel hyperparameters --- CHANGELOG.md | 6 + .../serializers/sklearn/sklearn_serializer.py | 97 +++++++++------- test/test_gp_kernels.py | 106 ++++++++++++++++++ 3 files changed, 166 insertions(+), 43 deletions(-) create mode 100644 test/test_gp_kernels.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a390bb2..6101ee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 restored under the wrong attribute name, without its metric or sample weights, and a `ball_tree` one couldn't be saved. It now round-trips exactly. Weighted models saved by earlier versions load with uniform weights, since their weights were never saved +- Gaussian-process kernels other than `RBF`, `WhiteKernel`, `Sum`, `Product`, `ConstantKernel` + and `DotProduct` (e.g. `Matern`, `RationalQuadratic`, `Exponentiation`, `PairwiseKernel`) + loaded as raw dicts when used at top level, breaking `GaussianProcessRegressor`/`Classifier` + and `KernelRidge`; kernels with array hyperparameters (a fitted anisotropic `RBF`) or lists of + kernels (`CompoundKernel`) couldn't be saved. All now round-trip; an unknown or abstract + kernel type raises `DeserializationError` ## [0.2.2] - 2026-09-22 diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index 166739a..cdeb5b1 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -34,6 +34,7 @@ from sklearn.cluster._bisect_k_means import _BisectingTree from sklearn.ensemble._hist_gradient_boosting.predictor import TreePredictor from sklearn.ensemble._hist_gradient_boosting.binning import _BinMapper +from sklearn.gaussian_process import kernels as _gp_kernels from sklearn.gaussian_process.kernels import Kernel from sklearn.gaussian_process._gpc import _BinaryGaussianProcessClassifierLaplace from sklearn._loss.loss import ( @@ -91,15 +92,6 @@ "PinballLoss": PinballLoss, } -KERNEL_REGISTRY = [ - "RBF", - "WhiteKernel", - "Sum", - "Product", - "ConstantKernel", - "DotProduct", -] - ALL_ESTIMATORS = { name: cls for name, cls in all_estimators() if issubclass(cls, BaseEstimator) } @@ -708,6 +700,16 @@ def convert_from_serializable( and value_type != "dict" ): return self._deserialize_core(value) + # Same for Gaussian-process kernels ({"kernel_type", "params"}): routing by content + # covers every kernel class, and an unknown kernel raises instead of loading as a dict. + if ( + isinstance(value, dict) + and "kernel_type" in value + and "params" in value + and isinstance(value_type, str) + and value_type != "dict" + ): + return self._deserialize_kernel(value) return super().convert_from_serializable(value, value_type, value_dtype) # --- Handlers --- @@ -737,10 +739,6 @@ def _get_deserializer_handlers(self): estimator_handlers = [ (est_name, self._deserialize_core) for est_name in self._by_name.keys() ] - - kernel_handlers = [ - (kernel_name, self._deserialize_kernel) for kernel_name in KERNEL_REGISTRY - ] return ( [ ("estimators_collection", self._deserialize_estimators_collection), @@ -750,7 +748,6 @@ def _get_deserializer_handlers(self): ("_CurveScorer", self._deserialize_curve_scorer), ("_CFNode", self._deserialize_cfnode), ] - + kernel_handlers + loss_handlers + estimator_handlers + super()._get_deserializer_handlers() @@ -1254,44 +1251,58 @@ def _deserialize_estimators_collection( def _serialize_kernel(self, kernel: Kernel) -> Dict[str, Any]: """ - Recursively serialize a sklearn.gaussian_process.kernels.Kernel object. + Recursively serialize a sklearn.gaussian_process.kernels.Kernel object as its class + name and constructor params. Params that are kernels - also inside lists, e.g. + CompoundKernel's `kernels` - are serialized recursively; every other value goes through + convert_to_serializable (e.g. a fitted anisotropic length_scale ndarray). """ - kernel_type = type(kernel).__name__ - params = kernel.get_params(deep=False) - # Recursively serialize kernel parameters that are also kernels - serialized_params = {} - for k, v in params.items(): - if isinstance(v, Kernel): - serialized_params[k] = self._serialize_kernel(v) - else: - serialized_params[k] = v return { - "kernel_type": kernel_type, - "params": serialized_params, + "kernel_type": type(kernel).__name__, + "params": { + k: self._serialize_kernel_value(v) + for k, v in kernel.get_params(deep=False).items() + }, } + def _serialize_kernel_value(self, value: Any) -> Any: + if isinstance(value, Kernel): + return self._serialize_kernel(value) + if isinstance(value, (list, tuple)): + return [self._serialize_kernel_value(v) for v in value] + return self.convert_to_serializable(value) + def _deserialize_kernel(self, data: Dict[str, Any]) -> Kernel: """ - Recursively deserialize a kernel dict back to a Kernel object. + Recursively deserialize a kernel dict back to a Kernel object. `kernel_type` comes from + the file, so it must name a concrete Kernel class in sklearn.gaussian_process.kernels + (looked up as an attribute of that already-imported module; nothing is imported), never + any other callable there. """ - kernel_type = data["kernel_type"] - params = data["params"] - kernel_cls = getattr( - __import__("sklearn.gaussian_process.kernels", fromlist=[kernel_type]), - kernel_type, - None, + kernel_type = data.get("kernel_type") + kernel_cls = ( + getattr(_gp_kernels, kernel_type, None) + if isinstance(kernel_type, str) and not kernel_type.startswith("_") + else None ) - # kernel_type comes from the file: only Kernel classes may be constructed, never an - # arbitrary callable living in the kernels module. - if not (isinstance(kernel_cls, type) and issubclass(kernel_cls, Kernel)): + if not ( + isinstance(kernel_cls, type) + and issubclass(kernel_cls, Kernel) + and not inspect.isabstract(kernel_cls) + ): raise DeserializationError(f"Unknown kernel type '{kernel_type}'") - deserialized_params = {} - for k, v in params.items(): - if isinstance(v, dict) and "kernel_type" in v: - deserialized_params[k] = self._deserialize_kernel(v) - else: - deserialized_params[k] = v - return kernel_cls(**deserialized_params) + return kernel_cls( + **{ + k: self._deserialize_kernel_value(v) + for k, v in data.get("params", {}).items() + } + ) + + def _deserialize_kernel_value(self, value: Any) -> Any: + if isinstance(value, dict) and "kernel_type" in value: + return self._deserialize_kernel(value) + if isinstance(value, list): + return [self._deserialize_kernel_value(v) for v in value] + return value def _serialize_curve_scorer(self, scorer: _CurveScorer) -> Dict[str, Any]: # Find the scorer name in sklearn.metrics.get_scorer_names() diff --git a/test/test_gp_kernels.py b/test/test_gp_kernels.py new file mode 100644 index 0000000..1951025 --- /dev/null +++ b/test/test_gp_kernels.py @@ -0,0 +1,106 @@ +""" +Gaussian-process kernels must round-trip wherever they're used (GPR, GPC, KernelRidge), for +every kernel class, at top level and nested, including array hyperparameters and lists of +kernels. A file can only make loading build a concrete scikit-learn Kernel class. +""" + +import json + +import numpy as np +import pytest +from sklearn.gaussian_process import ( + GaussianProcessClassifier, + GaussianProcessRegressor, +) +from sklearn.gaussian_process import kernels as K +from sklearn.kernel_ridge import KernelRidge + +from openmodels import SerializationManager, SklearnSerializer +from openmodels.exceptions import DeserializationError + +rng = np.random.RandomState(0) +X = rng.rand(30, 3) +y_reg = X @ np.array([1.0, -2.0, 0.5]) + 0.1 * rng.rand(30) +y_clf = (X[:, 0] > 0.5).astype(int) + + +def _roundtrip(model, fmt="json"): + manager = SerializationManager(SklearnSerializer()) + return manager.deserialize( + manager.serialize(model, format_name=fmt), format_name=fmt + ) + + +GPR_KERNELS = { + "RBF": lambda: K.RBF(), + "RBF_anisotropic": lambda: K.RBF(length_scale=[1.0, 1.0, 1.0]), + "Matern": lambda: K.Matern(nu=1.5), + "RationalQuadratic": lambda: K.RationalQuadratic(), + "Exponentiation": lambda: K.Exponentiation(K.RBF(), 2), + "PairwiseKernel": lambda: K.PairwiseKernel(metric="laplacian"), + "Constant_x_RBF_plus_White": lambda: K.ConstantKernel() * K.RBF() + K.WhiteKernel(), + "Matern_plus_White": lambda: K.Matern() + K.WhiteKernel(), +} + + +@pytest.mark.parametrize("kernel", GPR_KERNELS) +def test_gpr_kernel_roundtrips(kernel): + model = GaussianProcessRegressor(kernel=GPR_KERNELS[kernel](), random_state=0) + model.fit(X, y_reg) + loaded = _roundtrip(model) + assert type(loaded.kernel_) is type(model.kernel_) + assert type(loaded.kernel) is type(model.kernel) + np.testing.assert_allclose(loaded.predict(X), model.predict(X)) + np.testing.assert_allclose(loaded.kernel_.theta, model.kernel_.theta) + + +def test_gpr_kernel_roundtrips_with_pickle(): + model = GaussianProcessRegressor(kernel=K.Matern(), random_state=0).fit(X, y_reg) + loaded = _roundtrip(model, "pickle") + np.testing.assert_allclose(loaded.predict(X), model.predict(X)) + + +def test_gpc_matern_roundtrips(): + model = GaussianProcessClassifier(kernel=K.Matern(), random_state=0).fit(X, y_clf) + loaded = _roundtrip(model) + np.testing.assert_allclose(loaded.predict_proba(X), model.predict_proba(X)) + + +@pytest.mark.parametrize( + "kernel", + [K.Matern(), K.ExpSineSquared(), K.RationalQuadratic()], + ids=lambda k: type(k).__name__, +) +def test_kernel_ridge_kernel_roundtrips(kernel): + model = KernelRidge(kernel=kernel).fit(X, y_reg) + loaded = _roundtrip(model) + assert type(loaded.kernel) is type(kernel) + np.testing.assert_allclose(loaded.predict(X), model.predict(X)) + + +def test_compound_kernel_roundtrips(): + # CompoundKernel's `kernels` param is a list of kernels. + kernel = K.CompoundKernel([K.RBF(), K.WhiteKernel()]) + loaded = _roundtrip(KernelRidge(kernel=kernel)).kernel + assert type(loaded) is K.CompoundKernel + assert [type(k) for k in loaded.kernels] == [K.RBF, K.WhiteKernel] + np.testing.assert_allclose(loaded(X), kernel(X)) + + +@pytest.mark.parametrize("kernel_type", ["np", "Kernel", "_private", "MyKernel", None]) +def test_non_kernel_types_refused(kernel_type): + model = GaussianProcessRegressor(kernel=K.RBF()).fit(X, y_reg) + serialized = json.loads(json.dumps(SklearnSerializer().serialize(model))) + serialized["attributes"]["kernel_"]["kernel_type"] = kernel_type + with pytest.raises(DeserializationError, match="Unknown kernel type"): + SklearnSerializer().deserialize(serialized) + + +def test_top_level_matern_written_by_earlier_versions_loads(): + # 0.2.x wrote a top-level Matern exactly in this shape (and couldn't load it back). + model = GaussianProcessRegressor(kernel=K.Matern(), random_state=0).fit(X, y_reg) + serialized = json.loads(json.dumps(SklearnSerializer().serialize(model))) + assert serialized["attribute_types"]["kernel_"] == "Matern" + assert serialized["attributes"]["kernel_"]["kernel_type"] == "Matern" + loaded = SklearnSerializer().deserialize(serialized) + np.testing.assert_allclose(loaded.predict(X), model.predict(X)) From cf7a8247e66d4e7e83801a462daa963224fa9198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Tue, 6 Oct 2026 19:38:49 +0200 Subject: [PATCH 11/35] build(deps): add pandas as a dev dependency for DataFrame tests --- poetry.toml | 2 ++ pyproject.toml | 1 + 2 files changed, 3 insertions(+) create mode 100644 poetry.toml diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 0000000..ab1033b --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs] +in-project = true diff --git a/pyproject.toml b/pyproject.toml index 4ea55fd..f7f22a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ flake8 = "^7.1.1" pytest-cov = "^7.0.0" msgpack = "^1.0" PyYAML = "^6.0" +pandas = ">=2.2" [tool.poetry.group.docs] optional = true From b461838dd032fedccda4dad938dde7d81d6195e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Tue, 6 Oct 2026 19:41:41 +0200 Subject: [PATCH 12/35] fix(serializer): restore ColumnTransformer column maps, slices and column selectors --- CHANGELOG.md | 8 + openmodels/serializers/base.py | 31 ++- .../serializers/sklearn/sklearn_serializer.py | 47 +++- test/test_column_transformer.py | 205 ++++++++++++++++++ 4 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 test/test_column_transformer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6101ee0..9b3052e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and `KernelRidge`; kernels with array hyperparameters (a fitted anisotropic `RBF`) or lists of kernels (`CompoundKernel`) couldn't be saved. All now round-trip; an unknown or abstract kernel type raises `DeserializationError` +- A `ColumnTransformer` couldn't `transform` pandas DataFrames after loading, so a fitted + `Pipeline` starting with one couldn't `predict`; `get_feature_names_out` and + `set_output(transform="pandas")` failed for any input. Its private column index map is now + saved; files written by earlier versions need to be re-saved +- Any `slice` value (e.g. `ColumnTransformer` columns given as `slice(0, 2)`) crashed on load; + slices inside dicts (such as `output_indices_`) now come back as slices too +- `make_column_selector` column selections can now be saved, including NumPy dtype classes + such as `np.number` ## [0.2.2] - 2026-09-22 diff --git a/openmodels/serializers/base.py b/openmodels/serializers/base.py index 89126df..c9b0211 100644 --- a/openmodels/serializers/base.py +++ b/openmodels/serializers/base.py @@ -47,6 +47,9 @@ def _find_function_module(func: Any) -> Optional[str]: return None +# Keys of a saved slice (see SerializerMixin._serialize_slice). +_SLICE_KEYS = frozenset({"start", "stop", "step"}) + # Keys of a saved frozen SciPy distribution (see ScipySerializerMixin._serialize_scipy_dist). _SCIPY_DIST_KEYS = frozenset({"dist_name", "args", "kwargs"}) @@ -118,11 +121,8 @@ def _serialize_slice(self, value: slice): return {"start": value.start, "stop": value.stop, "step": value.step} def _deserialize_slice(self, value): - return slice( - start=value.get("start"), - stop=value.get("stop"), - step=value.get("step"), - ) + # slice() takes positional arguments only. + return slice(value.get("start"), value.get("stop"), value.get("step")) def _serialize_type(self, value: type): return {"type_name": value.__name__} @@ -134,6 +134,8 @@ def _deserialize_type(self, value): type. Only canonical NumPy names (`np.dtype(name).type.__name__ == name`) are accepted, since that's all the writer produces; aliases like "f8" and structured specs like "i4,f8" are not. `np.dtype` only parses the name - nothing is imported or called. + NumPy's abstract scalar types (`np.number`, `np.integer`, ...), which aren't dtypes, are + resolved as `np.` when that is an `np.generic` subclass with that exact name. An unknown name falls back to `float`, as before, but with a warning. """ name = value.get("type_name") @@ -149,6 +151,18 @@ def _deserialize_type(self, value): and numpy_type.__name__ == name ): return numpy_type + abstract_type = ( + getattr(np, name, None) + if isinstance(name, str) and not name.startswith("_") + else None + ) + if ( + isinstance(abstract_type, type) + and issubclass(abstract_type, np.generic) + and abstract_type is not np.void + and abstract_type.__name__ == name + ): + return abstract_type warnings.warn( f"Unknown type '{name}' in the model file; loading it as float. The loaded " f"model may behave differently from the original.", @@ -215,8 +229,11 @@ def _deserialize_function(self, data: Dict[str, str]) -> Callable: def _restore_dict_value(self, value: Any) -> Any: """Hook for restoring a value inside a plain string-keyed dict, whose values carry no - type tags of their own. Mixins override it for values recognisable by their shape - (see ScipySerializerMixin). Returns the value unchanged by default.""" + type tags of their own, recognised by its exact saved shape: slices here (e.g. + ColumnTransformer's output_indices_), more in mixins (see ScipySerializerMixin). + Anything else is returned unchanged.""" + if isinstance(value, dict) and set(value) == _SLICE_KEYS: + return self._deserialize_slice(value) return value def _deserialize_dict(self, value: Any) -> Any: diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index cdeb5b1..0cf9580 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -30,6 +30,7 @@ import sklearn from sklearn.calibration import _CalibratedClassifier, _SigmoidCalibration +from sklearn.compose import make_column_selector from sklearn.cluster._birch import _CFNode, _CFSubcluster from sklearn.cluster._bisect_k_means import _BisectingTree from sklearn.ensemble._hist_gradient_boosting.predictor import TreePredictor @@ -236,7 +237,7 @@ def _openmodels_version() -> str: ], "TunedThresholdClassifierCV": ["_curve_scorer"], # Transformers: - "ColumnTransformer": ["_columns", "_remainder"], + "ColumnTransformer": ["_columns", "_remainder", "_transformer_to_input_indices"], "OneHotEncoder": [ "_infrequent_enabled", "_drop_idx_after_grouping", @@ -727,6 +728,8 @@ def _get_serializer_handlers(self): (_BisectingTree, self._serialize_bisecting_tree), (_CurveScorer, self._serialize_curve_scorer), (_CFNode, self._serialize_cfnode), + # Callable, so it must come before the generic function handler in super(). + (make_column_selector, self._serialize_column_selector), ] + super()._get_serializer_handlers() def _get_deserializer_handlers(self): @@ -747,6 +750,7 @@ def _get_deserializer_handlers(self): ("_CalibratedClassifier", self._deserialize_calibrated_classifier), ("_CurveScorer", self._deserialize_curve_scorer), ("_CFNode", self._deserialize_cfnode), + ("make_column_selector", self._deserialize_column_selector), ] + loss_handlers + estimator_handlers @@ -1304,6 +1308,47 @@ def _deserialize_kernel_value(self, value: Any) -> Any: return [self._deserialize_kernel_value(v) for v in value] return value + def _serialize_column_selector( + self, selector: make_column_selector + ) -> Dict[str, Any]: + """ + Serialize a ColumnTransformer `make_column_selector` as its three settings. A dtype + spec is a string ("number"), a type (np.number, float) or a list of these; types are + written as `_serialize_type` does. + """ + + def dtype_spec(spec: Any) -> Any: + if isinstance(spec, (list, tuple)): + return [dtype_spec(s) for s in spec] + if isinstance(spec, type): + return self._serialize_type(spec) + return spec + + return { + "pattern": selector.pattern, + "dtype_include": dtype_spec(selector.dtype_include), + "dtype_exclude": dtype_spec(selector.dtype_exclude), + } + + def _deserialize_column_selector( + self, data: Dict[str, Any] + ) -> make_column_selector: + """Rebuild a `make_column_selector` from its saved settings. Only this fixed class is + constructed, and type names go through `_deserialize_type`.""" + + def dtype_spec(spec: Any) -> Any: + if isinstance(spec, list): + return [dtype_spec(s) for s in spec] + if isinstance(spec, dict) and "type_name" in spec: + return self._deserialize_type(spec) + return spec + + return make_column_selector( + pattern=data.get("pattern"), + dtype_include=dtype_spec(data.get("dtype_include")), + dtype_exclude=dtype_spec(data.get("dtype_exclude")), + ) + def _serialize_curve_scorer(self, scorer: _CurveScorer) -> Dict[str, Any]: # Find the scorer name in sklearn.metrics.get_scorer_names() score_func = None diff --git a/test/test_column_transformer.py b/test/test_column_transformer.py new file mode 100644 index 0000000..b2b8c36 --- /dev/null +++ b/test/test_column_transformer.py @@ -0,0 +1,205 @@ +""" +ColumnTransformer must work after loading: transform (numpy and pandas), get_feature_names_out, +set_output(transform="pandas"), slice and make_column_selector columns, and inside a Pipeline +fed a DataFrame (the common real-world setup). +""" + +import json + +import numpy as np +import pytest +from sklearn.compose import ColumnTransformer, make_column_selector +from sklearn.linear_model import LogisticRegression +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import ( + FunctionTransformer, + MinMaxScaler, + OneHotEncoder, + StandardScaler, +) + +from openmodels import SerializationManager, SklearnSerializer + +rng = np.random.RandomState(0) +X = rng.rand(30, 4) +X_CAT = np.column_stack([rng.rand(30), rng.randint(0, 3, 30)]) + + +def _roundtrip(model, fmt="json"): + manager = SerializationManager(SklearnSerializer()) + return manager.deserialize( + manager.serialize(model, format_name=fmt), format_name=fmt + ) + + +def _dense(a): + return a.toarray() if hasattr(a, "toarray") else np.asarray(a) + + +def _assert_same(loaded, model, data): + np.testing.assert_allclose( + _dense(loaded.transform(data)), _dense(model.transform(data)) + ) + assert list(loaded.get_feature_names_out()) == list(model.get_feature_names_out()) + assert sorted(loaded.named_transformers_) == sorted(model.named_transformers_) + assert all(isinstance(s, slice) for s in loaded.output_indices_.values()) + assert loaded.output_indices_ == model.output_indices_ + + +# ==== numpy input ==== + +NUMPY_CASES = { + "passthrough": ( + lambda: ColumnTransformer( + [("s", StandardScaler(), [0, 1])], remainder="passthrough" + ), + X, + ), + "two_transformers_drop": ( + lambda: ColumnTransformer( + [("s", StandardScaler(), [0, 1]), ("m", MinMaxScaler(), [2])] + ), + X, + ), + "one_hot_sparse": ( + lambda: ColumnTransformer( + [("s", StandardScaler(), [0]), ("o", OneHotEncoder(), [1])] + ), + X_CAT, + ), + "slice_columns_estimator_remainder": ( + lambda: ColumnTransformer( + [("s", StandardScaler(), slice(0, 2))], remainder=MinMaxScaler() + ), + X, + ), +} + + +@pytest.mark.parametrize("case", NUMPY_CASES) +def test_numpy_column_transformer_roundtrips(case): + make, data = NUMPY_CASES[case] + model = make().fit(data) + _assert_same(_roundtrip(model), model, data) + + +def test_column_transformer_roundtrips_with_pickle(): + make, data = NUMPY_CASES["slice_columns_estimator_remainder"] + model = make().fit(data) + _assert_same(_roundtrip(model, "pickle"), model, data) + + +def test_file_written_by_earlier_versions_still_transforms_numpy(): + # 0.2.x didn't save _transformer_to_input_indices; numpy transform worked without it. + make, data = NUMPY_CASES["two_transformers_drop"] + model = make().fit(data) + serializer = SklearnSerializer() + serialized = json.loads(json.dumps(serializer.serialize(model))) + del serialized["attributes"]["_transformer_to_input_indices"] + loaded = serializer.deserialize(serialized) + np.testing.assert_allclose(loaded.transform(data), model.transform(data)) + + +# ==== slices anywhere ==== + + +def test_slice_param_roundtrips(): + # Any slice used to crash on load: slice() takes no keyword arguments. + model = FunctionTransformer(kw_args={"cols": slice(1, 3)}) + serializer = SklearnSerializer() + assert serializer.convert_from_serializable( + {"start": 1, "stop": 3, "step": None}, "slice" + ) == slice(1, 3) + assert _roundtrip(model).kw_args == {"cols": slice(1, 3)} + + +# ==== NumPy abstract types (used by make_column_selector dtype specs) ==== + + +@pytest.mark.parametrize("t", [np.number, np.integer, np.floating, np.inexact]) +def test_numpy_abstract_type_roundtrips(t): + serializer = SklearnSerializer() + written = json.loads(json.dumps(serializer.convert_to_serializable(t))) + assert serializer.convert_from_serializable(written, "type") is t + + +# ==== pandas input ==== + + +def _frame(): + pd = pytest.importorskip("pandas") + return pd.DataFrame( + { + "a": rng.rand(30), + "b": rng.rand(30), + "c": rng.choice(["x", "y", "z"], 30), + "d": rng.rand(30), + } + ) + + +def test_pandas_named_columns_roundtrip(): + df = _frame() + model = ColumnTransformer( + [("num", StandardScaler(), ["a", "b"]), ("cat", OneHotEncoder(), ["c"])] + ).fit(df) + _assert_same(_roundtrip(model), model, df) + + +@pytest.mark.parametrize( + "selector", + [ + make_column_selector(dtype_include=np.number), + make_column_selector(dtype_include="number"), + make_column_selector(dtype_exclude=[object, "category"]), + make_column_selector(pattern="^[ab]$"), + ], + ids=["np.number", "number_string", "exclude_list", "pattern"], +) +def test_pandas_make_column_selector_roundtrips(selector): + df = _frame() + model = ColumnTransformer([("sel", "passthrough", selector)]).fit(df) + loaded = _roundtrip(model) + _assert_same(loaded, model, df) + restored = loaded.transformers[0][2] + assert isinstance(restored, make_column_selector) + assert restored(df) == selector(df) + + +def test_pandas_set_output_roundtrips(): + df = _frame() + model = ColumnTransformer( + [ + ("num", StandardScaler(), ["a", "b"]), + ("cat", OneHotEncoder(sparse_output=False), ["c"]), + ] + ).fit(df) + loaded = _roundtrip(model) + out_model = model.set_output(transform="pandas").transform(df) + out_loaded = loaded.set_output(transform="pandas").transform(df) + assert list(out_loaded.columns) == list(out_model.columns) + np.testing.assert_allclose(out_loaded.to_numpy(), out_model.to_numpy()) + + +def test_pandas_pipeline_predicts_after_load(): + df = _frame() + target = (df["a"] > 0.5).astype(int) + model = Pipeline( + [ + ( + "ct", + ColumnTransformer( + [ + ("num", StandardScaler(), ["a", "b"]), + ("cat", OneHotEncoder(), ["c"]), + ] + ), + ), + ("clf", LogisticRegression()), + ] + ).fit(df, target) + loaded = _roundtrip(model) + np.testing.assert_array_equal(loaded.predict(df), model.predict(df)) + assert list(loaded[:-1].get_feature_names_out()) == list( + model[:-1].get_feature_names_out() + ) From 1c9f26cde43339720a201c42fb771b6a28d17479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 10:13:38 +0200 Subject: [PATCH 13/35] fix(core): raise SerializationError/DeserializationError for every serializer failure --- CHANGELOG.md | 5 +++ openmodels/core.py | 37 ++++++++++++++++++---- test/test_core.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b3052e..28212d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 slices inside dicts (such as `output_indices_`) now come back as slices too - `make_column_selector` column selections can now be saved, including NumPy dtype classes such as `np.number` +- `SerializationManager.serialize/deserialize/save/load` now raise + `SerializationError`/`DeserializationError` (with the original exception as the cause) for + malformed input or unserializable models, as documented, instead of `KeyError`, + `AttributeError` or `TypeError`. Library errors such as `UnsupportedEstimatorError` are + unchanged ## [0.2.2] - 2026-09-22 diff --git a/openmodels/core.py b/openmodels/core.py index 6b0fef1..7554350 100644 --- a/openmodels/core.py +++ b/openmodels/core.py @@ -9,7 +9,7 @@ from pathlib import Path from .protocols import ModelSerializer from .format_registry import FormatRegistry -from .exceptions import SerializationError, DeserializationError +from .exceptions import OpenModelsError, SerializationError, DeserializationError class SerializationManager: @@ -66,7 +66,8 @@ def serialize( ------ SerializationError If the model serializer doesn't return a dictionary or if there's an error during - serialization. + serialization (e.g. the model isn't an estimator or holds a value the format can't + encode). The original exception, if any, is kept as ``__cause__``. UnsupportedFormatError If the specified format is not supported. @@ -80,7 +81,12 @@ def serialize( ... ) """ converter = FormatRegistry.get_converter(format_name) - serialized_dict = self.model_serializer.serialize(model) + try: + serialized_dict = self.model_serializer.serialize(model) + except OpenModelsError: + raise + except Exception as e: + raise SerializationError(f"Error during serialization: {e}") from e if not isinstance(serialized_dict, dict): raise SerializationError( f"Model serializer must return a dict, got {type(serialized_dict)}" @@ -90,7 +96,12 @@ def serialize( **metadata, **serialized_dict.get("metadata", {}), } - return converter.serialize_to_format(serialized_dict) + try: + return converter.serialize_to_format(serialized_dict) + except OpenModelsError: + raise + except Exception as e: + raise SerializationError(f"Error during serialization: {e}") from e def deserialize(self, serialized_model: Any, format_name: str = "json") -> Any: """ @@ -112,7 +123,10 @@ def deserialize(self, serialized_model: Any, format_name: str = "json") -> Any: ------ DeserializationError If the format converter doesn't return a dictionary or if there's an error during - deserialization. + deserialization (e.g. malformed data). The original exception, if any, is kept as + ``__cause__``. + UnsupportedEstimatorError + If the data names an estimator class that is unknown or not supported. UnsupportedFormatError If the specified format_name is not supported. @@ -125,13 +139,20 @@ def deserialize(self, serialized_model: Any, format_name: str = "json") -> Any: converter = FormatRegistry.get_converter(format_name) try: deserialized_dict = converter.deserialize_from_format(serialized_model) + except OpenModelsError: + raise except Exception as e: - raise DeserializationError(f"Error during deserialization: {e}") + raise DeserializationError(f"Error during deserialization: {e}") from e if not isinstance(deserialized_dict, dict): raise DeserializationError( f"Format converter must return a dict, got {type(deserialized_dict)}" ) - return self.model_serializer.deserialize(deserialized_dict) + try: + return self.model_serializer.deserialize(deserialized_dict) + except OpenModelsError: + raise + except Exception as e: + raise DeserializationError(f"Error during deserialization: {e}") from e def save( self, @@ -204,6 +225,8 @@ def load(self, file_path: Union[str, Path], format_name: str = "json") -> Any: ------ DeserializationError If deserialization or file I/O fails. + UnsupportedEstimatorError + If the file names an estimator class that is unknown or not supported. UnsupportedFormatError If the specified format is not supported. diff --git a/test/test_core.py b/test/test_core.py index 51e5b56..514d216 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -1,11 +1,16 @@ +import json + import numpy as np import pytest +from sklearn.feature_selection import SelectKBest, chi2 from sklearn.linear_model import LogisticRegression +from sklearn.preprocessing import FunctionTransformer from openmodels.core import SerializationManager from openmodels.serializers.sklearn.sklearn_serializer import SklearnSerializer from openmodels.exceptions import ( SerializationError, DeserializationError, + UnsupportedEstimatorError, UnsupportedFormatError, ) @@ -130,3 +135,77 @@ def bad_open(*args, **kwargs): monkeypatch.setattr("builtins.open", bad_open) with pytest.raises(DeserializationError): manager.load(file_path, format_name="json") + + +# ==== every manager failure is an OpenModels error (BUG_AUDIT #17) ==== + + +class _Unencodable: + pass + + +def test_serialize_non_estimator_raises_serialization_error(): + manager = SerializationManager(SklearnSerializer()) + with pytest.raises(SerializationError) as exc_info: + manager.serialize(object()) + assert isinstance(exc_info.value.__cause__, AttributeError) + + +def test_serialize_unencodable_param_raises_serialization_error(): + manager = SerializationManager(SklearnSerializer()) + model = FunctionTransformer(kw_args={"w": _Unencodable()}) + with pytest.raises(SerializationError, match="not JSON serializable") as exc_info: + manager.serialize(model, format_name="json") + assert isinstance(exc_info.value.__cause__, TypeError) + + +def test_deserialize_malformed_model_raises_deserialization_error(): + manager = SerializationManager(SklearnSerializer()) + with pytest.raises(DeserializationError, match="estimator_class") as exc_info: + manager.deserialize('{"params": {}}') + assert isinstance(exc_info.value.__cause__, KeyError) + + +def test_deserialize_invalid_json_keeps_cause(): + manager = SerializationManager(SklearnSerializer()) + with pytest.raises(DeserializationError) as exc_info: + manager.deserialize("not a valid json") + assert isinstance(exc_info.value.__cause__, json.JSONDecodeError) + + +def test_unknown_estimator_class_passes_through(): + model, _ = get_fitted_model() + manager = SerializationManager(SklearnSerializer()) + data = json.loads(manager.serialize(model)) + data["estimator_class"] = "Nope" + with pytest.raises(UnsupportedEstimatorError, match="Nope"): + manager.deserialize(json.dumps(data)) + + +def test_crafted_function_reference_is_not_double_wrapped(): + X = np.array([[1, 2], [3, 4], [5, 6]]) + y = np.array([0, 1, 1]) + manager = SerializationManager(SklearnSerializer()) + data = json.loads(manager.serialize(SelectKBest(chi2, k=1).fit(X, y))) + data["params"]["score_func"] = {"module": "os", "name": "getcwd"} + with pytest.raises(DeserializationError) as exc_info: + manager.deserialize(json.dumps(data)) + assert str(exc_info.value).startswith("function 'os.getcwd' is not allowed") + assert exc_info.value.__cause__ is None + + +def test_save_unserializable_model_raises_serialization_error(tmp_path): + manager = SerializationManager(SklearnSerializer()) + file_path = tmp_path / "model.json" + with pytest.raises(SerializationError): + manager.save(object(), file_path, format_name="json") + assert not file_path.exists() + + +def test_load_malformed_file_raises_deserialization_error(tmp_path): + manager = SerializationManager(SklearnSerializer()) + file_path = tmp_path / "model.json" + file_path.write_text('{"params": {}}') + with pytest.raises(DeserializationError) as exc_info: + manager.load(file_path, format_name="json") + assert isinstance(exc_info.value.__cause__, KeyError) From a732c2c08d6c8fdabdabaae0b563b3dd6b1e4e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 10:26:56 +0200 Subject: [PATCH 14/35] fix(serializer): accept custom_estimators as a list of (name, class) pairs --- CHANGELOG.md | 2 + .../serializers/sklearn/_custom_estimator.py | 26 ++++-- .../serializers/sklearn/sklearn_serializer.py | 2 + test/test_custom_estimator.py | 89 ++++++++++++++++++- 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28212d3..2d7dd00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 malformed input or unserializable models, as documented, instead of `KeyError`, `AttributeError` or `TypeError`. Library errors such as `UnsupportedEstimatorError` are unchanged +- `custom_estimators=[("Name", cls), ...]` (a list of pairs, as documented) registered nothing; + it now works, alongside the existing forms ## [0.2.2] - 2026-09-22 diff --git a/openmodels/serializers/sklearn/_custom_estimator.py b/openmodels/serializers/sklearn/_custom_estimator.py index 4d463fb..fa0cea9 100644 --- a/openmodels/serializers/sklearn/_custom_estimator.py +++ b/openmodels/serializers/sklearn/_custom_estimator.py @@ -18,13 +18,26 @@ def is_valid_estimator(name: str, cls: Any) -> bool: return False +def _is_pair(item: Any) -> bool: + """Whether `item` has the shape of one (name, class) pair rather than a source of pairs. + No source (a callable, a dict or an iterable of pairs) starts with a string, so a + 2-item tuple/list starting with one is a pair; its class is validated later.""" + return ( + isinstance(item, (list, tuple)) and len(item) == 2 and isinstance(item[0], str) + ) + + def normalize_estimators( estimators: Union[Callable[..., Any], List[Any], Tuple[Any, ...], Dict[str, Any]], ) -> List[Any]: - """Normalize input into a flat list of estimators or (name, class) items.""" + """Normalize input into a flat list of sources, each a callable, a dict or an iterable of + (name, class) pairs. A single pair, or a pair given as an element of a list of sources, is + wrapped into a one-pair source of its own.""" + if _is_pair(estimators): + return [[estimators]] if not isinstance(estimators, (list, tuple, set)): return [estimators] - return list(estimators) + return [[est] if _is_pair(est) else est for est in estimators] def iter_custom_estimators( @@ -44,10 +57,11 @@ def iter_custom_estimators( if items is None: continue - if isinstance(items, dict): - iterator = items.items() - else: - iterator = items + try: + iterator = iter(items.items() if isinstance(items, dict) else items) + except TypeError: + warnings.warn("Unexpected custom_estimator format; skipping.", UserWarning) + continue for item in iterator: try: diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index 0cf9580..17de913 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -308,6 +308,8 @@ class SklearnSerializer( - A callable returning an iterable or dict of (name, class) pairs (e.g., a function like ``all_estimators``). - A list or tuple of (name, class) pairs. - A dict mapping estimator names to their classes. + - A single (name, class) pair, or a list mixing any of the above (e.g. + ``[("MyEstimator", MyEstimator), all_estimators]``). These estimators are merged into the serializer's internal registry for this instance only, allowing support for custom or external estimators without affecting the global registry. diff --git a/test/test_custom_estimator.py b/test/test_custom_estimator.py index 5b54a90..eec7bcb 100644 --- a/test/test_custom_estimator.py +++ b/test/test_custom_estimator.py @@ -1,7 +1,12 @@ import inspect -from openmodels.serializers.sklearn import _custom_estimator import warnings +import pytest +from sklearn.base import BaseEstimator + +from openmodels.serializers.sklearn import _custom_estimator +from openmodels.serializers.sklearn.sklearn_serializer import SklearnSerializer + class DummyEstimator: pass @@ -147,4 +152,84 @@ def test_is_valid_estimator_typeerror_branch(monkeypatch): class Dummy: pass dummy_instance = Dummy() result = _custom_estimator.is_valid_estimator("BadEstimator", dummy_instance) - assert result is False \ No newline at end of file + assert result is False + +# ==== (name, class) pairs (BUG_AUDIT #19) ==== + + +class PairEstimatorA(BaseEstimator): + pass + + +class PairEstimatorB(BaseEstimator): + pass + + +def _pair_sources(): + return [("PairEstimatorB", PairEstimatorB)] + + +def _registered(serializer): + return {name: cls for name, cls in serializer._by_name.items() if cls in (PairEstimatorA, PairEstimatorB)} + + +def _no_warnings(fn): + with warnings.catch_warnings(): + warnings.simplefilter("error") + return fn() + + +@pytest.mark.parametrize( + "custom", + [ + [("PairEstimatorA", PairEstimatorA), ("PairEstimatorB", PairEstimatorB)], + (("PairEstimatorA", PairEstimatorA), ("PairEstimatorB", PairEstimatorB)), + [("PairEstimatorA", PairEstimatorA), _pair_sources], + [["PairEstimatorA", PairEstimatorA], {"PairEstimatorB": PairEstimatorB}], + ], + ids=["list_of_pairs", "tuple_of_pairs", "pair_and_callable", "list_pair_and_dict"], +) +def test_serializer_registers_pairs(custom): + serializer = _no_warnings(lambda: SklearnSerializer(custom_estimators=custom)) + assert _registered(serializer) == {"PairEstimatorA": PairEstimatorA, "PairEstimatorB": PairEstimatorB} + + +def test_serializer_registers_single_pair(): + serializer = _no_warnings(lambda: SklearnSerializer(custom_estimators=("PairEstimatorA", PairEstimatorA))) + assert _registered(serializer) == {"PairEstimatorA": PairEstimatorA} + + +@pytest.mark.parametrize( + "custom", + [ + _pair_sources, + {"PairEstimatorB": PairEstimatorB}, + [{"PairEstimatorA": PairEstimatorA}, {"PairEstimatorB": PairEstimatorB}], + [lambda: [("PairEstimatorA", PairEstimatorA)], _pair_sources], + ], + ids=["callable", "dict", "list_of_dicts", "list_of_callables"], +) +def test_existing_source_forms_still_register(custom): + serializer = _no_warnings(lambda: SklearnSerializer(custom_estimators=custom)) + expected = {"PairEstimatorB": PairEstimatorB} + if not callable(custom) and not isinstance(custom, dict): + expected["PairEstimatorA"] = PairEstimatorA + assert _registered(serializer) == expected + + +def test_normalize_estimators_wraps_pairs(): + pair_a = ("PairEstimatorA", PairEstimatorA) + assert _custom_estimator.normalize_estimators(pair_a) == [[pair_a]] + assert _custom_estimator.normalize_estimators([pair_a, _pair_sources]) == [[pair_a], _pair_sources] + + +def test_pair_with_invalid_class_is_skipped_silently(): + result = _no_warnings(lambda: _custom_estimator.load_custom_estimators([("Bad", NotEstimator)], {})) + assert result == {} + + +@pytest.mark.parametrize("element", [42, ["badformat"]], ids=["not_iterable", "not_a_pair"]) +def test_malformed_element_warns(element): + with pytest.warns(UserWarning, match="Unexpected custom_estimator format"): + result = _custom_estimator.load_custom_estimators([("PairEstimatorA", PairEstimatorA), element], {}) + assert result == {"PairEstimatorA": PairEstimatorA} From 625f0c60990cbdd331f921e1763b94c55db9b2b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 10:34:10 +0200 Subject: [PATCH 15/35] fix(serializer): load CalibratedClassifier calibrators through _deserialize_core --- .../serializers/sklearn/sklearn_serializer.py | 2 +- test/test_calibration.py | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 test/test_calibration.py diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index 17de913..1679b95 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -807,7 +807,7 @@ def _deserialize_calibrated_classifier( self, data: Dict[str, Any] ) -> _CalibratedClassifier: estimator = self._deserialize_core(data["estimator"]) - calibrators = [self.deserialize(c) for c in data["calibrators"]] + calibrators = [self._deserialize_core(c) for c in data["calibrators"]] classes = np.array(data["classes"]) method = data["method"] return _CalibratedClassifier( diff --git a/test/test_calibration.py b/test/test_calibration.py new file mode 100644 index 0000000..e4f6b99 --- /dev/null +++ b/test/test_calibration.py @@ -0,0 +1,40 @@ +import numpy as np +import pytest +from sklearn.calibration import CalibratedClassifierCV +from sklearn.datasets import make_classification +from sklearn.linear_model import LogisticRegression + +from openmodels.core import SerializationManager +from openmodels.serializers.sklearn.sklearn_serializer import SklearnSerializer + +X, y = make_classification(n_samples=60, n_features=4, random_state=0) + + +@pytest.mark.parametrize("method", ["sigmoid", "isotonic"]) +@pytest.mark.parametrize("format_name", ["json", "pickle"]) +def test_calibrated_classifier_roundtrips(method, format_name): + model = CalibratedClassifierCV(LogisticRegression(), cv=2, method=method).fit(X, y) + manager = SerializationManager(SklearnSerializer()) + loaded = manager.deserialize(manager.serialize(model, format_name), format_name) + assert len(loaded.calibrated_classifiers_) == 2 + np.testing.assert_array_equal(loaded.predict_proba(X), model.predict_proba(X)) + + +def test_calibrators_load_through_deserialize_core(monkeypatch): + """The public, root-only deserialize() (version checks, per-load state reset) runs once + per load, not once more for every calibrator (BUG_AUDIT #22).""" + model = CalibratedClassifierCV(LogisticRegression(), cv=2).fit(X, y) + serializer = SklearnSerializer() + data = serializer.serialize(model) + + calls = [] + original = SklearnSerializer.deserialize + + def counting_deserialize(self, data): + calls.append(data) + return original(self, data) + + monkeypatch.setattr(SklearnSerializer, "deserialize", counting_deserialize) + loaded = serializer.deserialize(data) + assert len(calls) == 1 + np.testing.assert_array_equal(loaded.predict_proba(X), model.predict_proba(X)) From 5ef4c34a2da23dc6cda0bbe5e0372e0b45e589e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 10:36:20 +0200 Subject: [PATCH 16/35] docs: use format_name in the package docstring example --- openmodels/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openmodels/__init__.py b/openmodels/__init__.py index 0ea181d..d8d3444 100644 --- a/openmodels/__init__.py +++ b/openmodels/__init__.py @@ -24,10 +24,10 @@ manager = SerializationManager(SklearnSerializer()) # Serialize the model to JSON - serialized_model = manager.serialize(model, format="json") + serialized_model = manager.serialize(model, format_name="json") # Deserialize the model from JSON - deserialized_model = manager.deserialize(serialized_model, format="json") + deserialized_model = manager.deserialize(serialized_model, format_name="json") # Use the deserialized model predictions = deserialized_model.predict(X_test) From 1d0f7e1aded2e434767764218f6031516d3b9fe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 11:06:43 +0200 Subject: [PATCH 17/35] fix(serializer): restore private state needed by predict_proba, transform and get_feature_names_out --- CHANGELOG.md | 12 ++ openmodels/serializers/base.py | 21 ++- .../serializers/sklearn/sklearn_serializer.py | 47 ++++- test/test_private_attributes.py | 177 ++++++++++++++++++ 4 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 test/test_private_attributes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d7dd00..b4da498 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 unchanged - `custom_estimators=[("Name", cls), ...]` (a list of pairs, as documented) registered nothing; it now works, alongside the existing forms +- Methods other than `predict`/`transform` failed on loaded models because private state wasn't + saved: + - `HistGradientBoostingClassifier.predict_proba` (so a soft `VotingClassifier` containing one + couldn't `predict`), and `staged_predict*` for both HistGradientBoosting estimators; + - `LinearDiscriminantAnalysis.transform`; + - `get_feature_names_out` for 24 estimators, including `KMeans`, `Birch`, `Nystroem`, + `RBFSampler`, the random projections, `PLS*`/`CCA`, `Isomap` and `Stacking*`, so a + `Pipeline` using one of them couldn't `predict` with `set_output(transform="pandas")`; + - `HDBSCAN.dbscan_clustering`. + + The HistGradientBoosting and LDA state is rebuilt on load, so files written by earlier + versions work too. For `get_feature_names_out` and `HDBSCAN`, re-save older files ## [0.2.2] - 2026-09-22 diff --git a/openmodels/serializers/base.py b/openmodels/serializers/base.py index c9b0211..96dd89a 100644 --- a/openmodels/serializers/base.py +++ b/openmodels/serializers/base.py @@ -7,6 +7,7 @@ support for new serialization targets. """ +import ast import importlib import inspect import sys @@ -302,6 +303,24 @@ def _get_dtype(self, value: Any) -> str: def _serialize_ndarray(self, value: np.ndarray): return self.convert_to_serializable(value.tolist()) + def _deserialize_ndarray(self, value, value_dtype=None): + """ + Rebuild an array from its nested lists and the `str(dtype)` written next to it. A + structured dtype (e.g. HDBSCAN's `_single_linkage_tree_`) is written as a list of + `(name, type)` tuples, which is a plain literal: it's parsed with `ast.literal_eval` + (never `eval`), and each row goes back to a tuple, as NumPy requires for records. + Only 1-D structured arrays are supported, which is what scikit-learn stores. + """ + if value_dtype and value_dtype.startswith("["): + try: + dtype = np.dtype(ast.literal_eval(value_dtype)) + except (ValueError, TypeError, SyntaxError) as e: + raise DeserializationError( + f"Invalid structured dtype {value_dtype!r}: {e}" + ) from e + return np.array([tuple(row) for row in value], dtype=dtype) + return np.array(value, dtype=(value_dtype or None)) + def _serialize_generic(self, value: np.generic): return value.item() @@ -359,7 +378,7 @@ def _get_serializer_handlers(self): def _get_deserializer_handlers(self): return [ - ("ndarray", lambda v, dt=None: np.array(v, dtype=(dt or None))), + ("ndarray", self._deserialize_ndarray), ("generic", lambda v: np.array(v).item()), ("float64", np.float64), ("int32", int), diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index 1679b95..451cf6a 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -58,6 +58,11 @@ from sklearn.exceptions import NotFittedError from sklearn.utils.discovery import all_estimators from sklearn.neighbors import BallTree, KDTree, KernelDensity +from sklearn.discriminant_analysis import LinearDiscriminantAnalysis +from sklearn.ensemble import ( + HistGradientBoostingClassifier, + HistGradientBoostingRegressor, +) # Private, but its tree-building code (NeighborsBase._fit) is identical across TESTED_VERSIONS; # see _rebuild_neighbors_tree. @@ -194,9 +199,11 @@ def _openmodels_version() -> str: "PLSCanonical": ["_x_mean", "_x_std", "_y_mean", "_y_std", "_predict_1d"], "IsotonicRegression": ["f_"], "TransformedTargetRegressor": ["_training_dim"], + "StackingRegressor": ["_n_feature_outs"], # Clusters: "BisectingKMeans": ["_bisecting_tree", "_n_threads", "_X_mean"], "Birch": ["_subcluster_norms"], + "HDBSCAN": ["_single_linkage_tree_"], "KMeans": ["_n_threads"], "MiniBatchKMeans": ["_n_threads"], # Classifiers: @@ -224,7 +231,7 @@ def _openmodels_version() -> str: "RadiusNeighborsClassifier": ["_fit_method", "_fit_X", "_y", "_tree"], "RidgeClassifier": ["_label_binarizer"], "RidgeClassifierCV": ["_label_binarizer"], - "StackingClassifier": ["_label_encoder"], + "StackingClassifier": ["_label_encoder", "_n_feature_outs"], "SVC": [ "_sparse", "_n_support", @@ -284,6 +291,12 @@ def _openmodels_version() -> str: "TfidfVectorizer": ["_tfidf"], } +# Private attributes saved for every estimator that has them as plain instance attributes (a +# property, as PCA's `_n_features_out` is, is computed and can't be set on load). +# `_n_features_out` is how ClassNamePrefixFeaturesOutMixin estimators (KMeans, Nystroem, random +# projections, ...) tell in get_feature_names_out that they're fitted. +GENERIC_PRIVATE_ATTRIBUTES: List[str] = ["_n_features_out"] + class SklearnSerializer( ModelSerializer, @@ -671,6 +684,9 @@ def is_valid_attribute(key: str) -> bool: # Collect attributes attribute_keys = [key for key in dir(estimator) if is_valid_attribute(key)] attribute_keys += ATTRIBUTE_EXCEPTIONS.get(estimator.__class__.__name__, []) + attribute_keys += [ + key for key in GENERIC_PRIVATE_ATTRIBUTES if key in vars(estimator) + ] # Prevents attribute exceptions introduced in newer scikit-learn versions from breaking older versions of the serializer attributes = { @@ -1200,6 +1216,34 @@ def _rebuild_neighbors_tree(self, model: BaseEstimator) -> None: **model.effective_metric_params_, ) + def _rebuild_derived_attributes(self, model: BaseEstimator) -> None: + """ + Recompute private attributes that `fit` derives from fitted state openmodels does save, + exactly as `fit` computes them, so files without them (every file written by 0.2.x) + load complete: + + - `HistGradientBoosting*._loss`, needed by `predict_proba` (the regressor's is also + saved). `sample_weight` only changes the loss during training, never its link. + - `HistGradientBoosting*._n_features`, needed by `staged_predict*`: the input width. + - `LinearDiscriminantAnalysis._max_components`, needed by `transform`. + """ + if isinstance( + model, (HistGradientBoostingClassifier, HistGradientBoostingRegressor) + ) and hasattr(model, "n_trees_per_iteration_"): + if not hasattr(model, "_loss"): + model._loss = model._get_loss(sample_weight=None) + if not hasattr(model, "_n_features"): + model._n_features = model.n_features_in_ + if ( + isinstance(model, LinearDiscriminantAnalysis) + and hasattr(model, "classes_") + and not hasattr(model, "_max_components") + ): + max_components = min(len(model.classes_) - 1, model.n_features_in_) + model._max_components = ( + max_components if model.n_components is None else model.n_components + ) + def _serialize_estimators_collection( self, value: Union[np.ndarray, List[BaseEstimator]] ) -> List[Any]: @@ -1683,5 +1727,6 @@ def _deserialize_core(self, data: Dict[str, Any]) -> BaseEstimator: self._resolve_birch_leaf_links() self._rebuild_neighbors_tree(model) self._rebuild_kernel_density_tree(model) + self._rebuild_derived_attributes(model) return model diff --git a/test/test_private_attributes.py b/test/test_private_attributes.py new file mode 100644 index 0000000..909c612 --- /dev/null +++ b/test/test_private_attributes.py @@ -0,0 +1,177 @@ +""" +Private attributes that fitted estimators need after loading for methods other than +predict/transform (BUG_AUDIT #25, #26, #27, #29): each was missing, so the method raised on the +loaded copy while predict kept working, which is all the smoke tests compare. +""" + +import json + +import numpy as np +import pandas as pd +import pytest +from sklearn.cluster import HDBSCAN, KMeans +from sklearn.decomposition import PCA +from sklearn.discriminant_analysis import LinearDiscriminantAnalysis +from sklearn.ensemble import ( + HistGradientBoostingClassifier, + HistGradientBoostingRegressor, + VotingClassifier, +) +from sklearn.linear_model import LogisticRegression +from sklearn.pipeline import make_pipeline +from sklearn.utils.discovery import all_estimators + +from openmodels.core import SerializationManager +from openmodels.exceptions import DeserializationError +from openmodels.serializers.sklearn.sklearn_serializer import SklearnSerializer +from test._estimator_construction import construct + +FORMATS = ["json", "pickle", "msgpack", "yaml"] + +rng = np.random.RandomState(0) +X = rng.rand(80, 4) + 0.1 # positive: some transformers below need non-negative input +y = (X[:, 0] + X[:, 1] > 1.1).astype(int) +y3 = (X[:, 0] * 3).astype(int) % 3 +yr = X @ np.array([1.0, -2.0, 0.5, 3.0]) + + +def _roundtrip(model, format_name="json"): + manager = SerializationManager(SklearnSerializer()) + return manager.deserialize(manager.serialize(model, format_name), format_name) + + +# ==== #26 HistGradientBoostingClassifier._loss, #29 HistGradientBoosting*._n_features ==== + + +@pytest.mark.parametrize("format_name", FORMATS) +@pytest.mark.parametrize("target", [y, y3], ids=["binary", "multiclass"]) +def test_hgb_classifier_predict_proba(target, format_name): + model = HistGradientBoostingClassifier(max_iter=10).fit(X, target) + loaded = _roundtrip(model, format_name) + np.testing.assert_array_equal(loaded.predict_proba(X), model.predict_proba(X)) + for got, expected in zip( + loaded.staged_predict_proba(X), model.staged_predict_proba(X) + ): + np.testing.assert_array_equal(got, expected) + + +def test_hgb_regressor_staged_predict(): + model = HistGradientBoostingRegressor(max_iter=10).fit(X, yr) + loaded = _roundtrip(model) + staged = list(loaded.staged_predict(X)) + assert len(staged) == 10 + np.testing.assert_array_equal(staged[-1], model.predict(X)) + + +def test_soft_voting_with_hgb_classifier_predicts(): + model = VotingClassifier( + [ + ("h", HistGradientBoostingClassifier(max_iter=10)), + ("l", LogisticRegression()), + ], + voting="soft", + ).fit(X, y) + np.testing.assert_array_equal(_roundtrip(model).predict(X), model.predict(X)) + + +# ==== #27 LinearDiscriminantAnalysis._max_components ==== + + +@pytest.mark.parametrize("n_components", [None, 1]) +def test_lda_transform(n_components): + model = LinearDiscriminantAnalysis(n_components=n_components).fit(X, y3) + loaded = _roundtrip(model) + assert loaded._max_components == model._max_components + np.testing.assert_allclose(loaded.transform(X), model.transform(X)) + assert list(loaded.get_feature_names_out()) == list(model.get_feature_names_out()) + + +# ==== #25 _n_features_out (generic) and Stacking's _n_feature_outs ==== + +N_FEATURES_OUT_ESTIMATORS = [ + "BernoulliRBM", + "Birch", + "BisectingKMeans", + "CCA", + "FeatureAgglomeration", + "GaussianRandomProjection", + "Isomap", + "KMeans", + "KNeighborsTransformer", + "LinearDiscriminantAnalysis", + "LocallyLinearEmbedding", + "MiniBatchKMeans", + "Nystroem", + "PLSCanonical", + "PLSRegression", + "PLSSVD", + "PolynomialCountSketch", + "RBFSampler", + "RadiusNeighborsTransformer", + "RandomTreesEmbedding", + "SkewedChi2Sampler", + "SparseRandomProjection", + "StackingClassifier", + "StackingRegressor", +] +_CLASSES = dict(all_estimators()) + + +@pytest.mark.parametrize("name", N_FEATURES_OUT_ESTIMATORS) +def test_get_feature_names_out(name): + model = construct(_CLASSES[name]) + target = ( + yr + if name + in ("CCA", "PLSCanonical", "PLSRegression", "PLSSVD", "StackingRegressor") + else y + ) + model.fit(X, target) + loaded = _roundtrip(model) + assert list(loaded.get_feature_names_out()) == list(model.get_feature_names_out()) + + +def test_pandas_output_pipeline_predicts(): + model = make_pipeline( + KMeans(n_clusters=3, n_init=1, random_state=0), LogisticRegression() + ).fit(X, y) + loaded = _roundtrip(model).set_output(transform="pandas") + np.testing.assert_array_equal(loaded.predict(pd.DataFrame(X)), model.predict(X)) + + +def test_n_features_out_saved_only_as_instance_attribute(): + """PCA computes _n_features_out in a property: saving it would make loading try to set a + property. KMeans stores it on the instance, so it's saved.""" + serializer = SklearnSerializer() + assert ( + "_n_features_out" in serializer.serialize(KMeans(n_init=1).fit(X))["attributes"] + ) + pca = PCA(2).fit(X) + assert "_n_features_out" not in serializer.serialize(pca)["attributes"] + assert _roundtrip(pca)._n_features_out == 2 + + +# ==== #29 HDBSCAN._single_linkage_tree_ (a structured array) ==== + + +@pytest.mark.parametrize("format_name", FORMATS) +def test_hdbscan_dbscan_clustering(format_name): + model = HDBSCAN().fit(X) + loaded = _roundtrip(model, format_name) + assert loaded._single_linkage_tree_.dtype == model._single_linkage_tree_.dtype + np.testing.assert_array_equal( + loaded._single_linkage_tree_, model._single_linkage_tree_ + ) + np.testing.assert_array_equal( + loaded.dbscan_clustering(0.3), model.dbscan_clustering(0.3) + ) + + +@pytest.mark.parametrize( + "bad_dtype", ["[__import__('os').getcwd()]", "[('a', 'not-a-type')]", "[unclosed"] +) +def test_invalid_structured_dtype_is_refused(bad_dtype): + data = SklearnSerializer().serialize(HDBSCAN().fit(X)) + data["attribute_dtypes"]["_single_linkage_tree_"] = bad_dtype + with pytest.raises(DeserializationError, match="Invalid structured dtype"): + SerializationManager(SklearnSerializer()).deserialize(json.dumps(data)) From cf33d1512ab743e065165bd5d9947a4024ee67fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 11:28:48 +0200 Subject: [PATCH 18/35] test: compare every inference method of original and loaded models --- test/_estimator_construction.py | 5 +- test/test_calibration.py | 2 +- test/test_core.py | 2 +- test/test_custom_estimator.py | 2 +- test/test_function_params.py | 4 +- test/test_inference_parity.py | 367 ++++++++++++++++++++++++++++++++ test/test_neighbors_tree.py | 2 +- test/test_private_attributes.py | 12 +- 8 files changed, 381 insertions(+), 15 deletions(-) create mode 100644 test/test_inference_parity.py diff --git a/test/_estimator_construction.py b/test/_estimator_construction.py index b063fea..a9f3aae 100644 --- a/test/_estimator_construction.py +++ b/test/_estimator_construction.py @@ -11,9 +11,8 @@ CONSTRUCTOR_ARGS[name] entries (via BASE_CLASSIFIER/BASE_REGRESSOR or the dict directly) for the subset of estimators where their own special-casing needs nothing more than "a valid estimator to wrap" - avoiding maintaining the same "this class needs an estimator= kwarg" - fact independently in multiple files (see the sklearn-1.9-compat branch's audit notes: this - already caused HalvingGridSearchCV support to be added in only one of two files that needed - it). Where a file's fitting data legitimately calls for a richer choice than the minimal + fact independently in multiple files (that once caused HalvingGridSearchCV support to be + added in only one of the two files that needed it). Where a file's fitting data legitimately calls for a richer choice than the minimal default here (e.g. StackingRegressor/VotingRegressor mixing in a RandomForestRegressor for more realistic coverage, or ColumnTransformer/FeatureUnion needing column selectors matched to real data), that file keeps its own local, richer construction instead of using this diff --git a/test/test_calibration.py b/test/test_calibration.py index e4f6b99..03687c9 100644 --- a/test/test_calibration.py +++ b/test/test_calibration.py @@ -22,7 +22,7 @@ def test_calibrated_classifier_roundtrips(method, format_name): def test_calibrators_load_through_deserialize_core(monkeypatch): """The public, root-only deserialize() (version checks, per-load state reset) runs once - per load, not once more for every calibrator (BUG_AUDIT #22).""" + per load, not once more for every calibrator.""" model = CalibratedClassifierCV(LogisticRegression(), cv=2).fit(X, y) serializer = SklearnSerializer() data = serializer.serialize(model) diff --git a/test/test_core.py b/test/test_core.py index 514d216..3117440 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -137,7 +137,7 @@ def bad_open(*args, **kwargs): manager.load(file_path, format_name="json") -# ==== every manager failure is an OpenModels error (BUG_AUDIT #17) ==== +# ==== every manager failure is an OpenModels error ==== class _Unencodable: diff --git a/test/test_custom_estimator.py b/test/test_custom_estimator.py index eec7bcb..eb10938 100644 --- a/test/test_custom_estimator.py +++ b/test/test_custom_estimator.py @@ -154,7 +154,7 @@ class Dummy: pass result = _custom_estimator.is_valid_estimator("BadEstimator", dummy_instance) assert result is False -# ==== (name, class) pairs (BUG_AUDIT #19) ==== +# ==== (name, class) pairs ==== class PairEstimatorA(BaseEstimator): diff --git a/test/test_function_params.py b/test/test_function_params.py index d549b19..13b833a 100644 --- a/test/test_function_params.py +++ b/test/test_function_params.py @@ -34,7 +34,7 @@ def _transformer_roundtrip(func, data=X): np.testing.assert_array_equal(loaded.transform(data), model.transform(data)) -# ==== #5: ufuncs ==== +# ==== ufuncs ==== @pytest.mark.parametrize("func", [np.log1p, np.exp, np.sqrt, np.abs, np.expm1]) @@ -93,7 +93,7 @@ def test_builtin_allowed_when_trusted(): assert loaded.func is abs -# ==== #11: NumPy array functions ==== +# ==== NumPy array functions ==== @pytest.mark.parametrize( diff --git a/test/test_inference_parity.py b/test/test_inference_parity.py new file mode 100644 index 0000000..99172d4 --- /dev/null +++ b/test/test_inference_parity.py @@ -0,0 +1,367 @@ +""" +Post-load inference parity: for every supported estimator, every public inference method must +give the same result on the original fitted model and on its serialize -> deserialize copy. + +The smoke tests (run_test_model) compare only predict/transform, and scikit-learn's conformance +battery never calls get_feature_names_out, staged_*, dbscan_clustering, ... So private state +that only those methods need could go unsaved unnoticed (see test_private_attributes.py). + +`test_every_public_method_is_classified` keeps "every" true: a public method scikit-learn adds +to a supported estimator fails it until it's listed in INFERENCE_CALLS or NOT_INFERENCE. +""" + +import copy +import inspect +from functools import lru_cache +from types import GeneratorType, SimpleNamespace +from typing import Any, Callable, Dict + +import numpy as np +import pandas as pd +import pytest +from scipy import sparse +from sklearn.base import is_regressor +from sklearn.linear_model import LogisticRegression + +from openmodels.core import SerializationManager +from openmodels.serializers.sklearn.sklearn_serializer import ( + ALL_ESTIMATORS, + NOT_SUPPORTED_ESTIMATORS, + SklearnSerializer, +) +from test._estimator_construction import construct + +pytestmark = pytest.mark.filterwarnings("ignore") + +FORMATS = ["json", "pickle"] + +ESTIMATOR_NAMES = sorted( + name + for name in ALL_ESTIMATORS + if not name.startswith("_") and name not in NOT_SUPPORTED_ESTIMATORS +) + +# ==== which methods are compared, and how they're called ==== + +# Inference methods: read the fitted state, leave it unchanged. Each entry calls the method +# with the estimator's data `d` (see _data_for): d.X/d.y for methods taking samples. +INFERENCE_CALLS: Dict[str, Callable[[Any, SimpleNamespace], Any]] = { + "predict": lambda e, d: e.predict(d.X), + "predict_proba": lambda e, d: e.predict_proba(d.X), + "predict_log_proba": lambda e, d: e.predict_log_proba(d.X), + "predict_joint_log_proba": lambda e, d: e.predict_joint_log_proba(d.X), + "decision_function": lambda e, d: e.decision_function(d.X), + "transform": lambda e, d: e.transform(d.X), + "inverse_transform": lambda e, d: e.inverse_transform(e.transform(d.X)), + "score": lambda e, d: e.score(d.X, d.y), + "score_samples": lambda e, d: e.score_samples(d.X), + "staged_predict": lambda e, d: e.staged_predict(d.X), + "staged_predict_proba": lambda e, d: e.staged_predict_proba(d.X), + "staged_decision_function": lambda e, d: e.staged_decision_function(d.X), + "staged_score": lambda e, d: e.staged_score(d.X, d.y), + "apply": lambda e, d: e.apply(d.X), + "decision_path": lambda e, d: e.decision_path(d.X), + "get_feature_names_out": lambda e, d: e.get_feature_names_out(), + "get_support": lambda e, d: e.get_support(), + "kneighbors": lambda e, d: e.kneighbors(d.X), + "kneighbors_graph": lambda e, d: e.kneighbors_graph(d.X), + "radius_neighbors": lambda e, d: e.radius_neighbors(d.X), + "radius_neighbors_graph": lambda e, d: e.radius_neighbors_graph(d.X), + "dbscan_clustering": lambda e, d: e.dbscan_clustering(0.3), + "mahalanobis": lambda e, d: e.mahalanobis(d.X), + "error_norm": lambda e, d: e.error_norm(np.eye(d.X.shape[1])), + "get_covariance": lambda e, d: e.get_covariance(), + "get_precision": lambda e, d: e.get_precision(), + "get_depth": lambda e, d: e.get_depth(), + "get_n_leaves": lambda e, d: e.get_n_leaves(), + "aic": lambda e, d: e.aic(d.X), + "bic": lambda e, d: e.bic(d.X), + "perplexity": lambda e, d: e.perplexity(d.X), + "reconstruction_error": lambda e, d: e.reconstruction_error(), + "log_marginal_likelihood": lambda e, d: e.log_marginal_likelihood(), + "latent_mean_and_variance": lambda e, d: e.latent_mean_and_variance(d.X), + "get_indices": lambda e, d: e.get_indices(0), + "get_shape": lambda e, d: e.get_shape(0), + "get_submatrix": lambda e, d: e.get_submatrix(0, d.X), + # Random, but seeded: from random_state=0 (set on every estimator that has it), from the + # method's own random_state argument, or from the fitted random_state_, which the copy + # restores in the same state the original has before the call. + "sample": lambda e, d: ( + e.sample(5, random_state=0) + if "random_state" in inspect.signature(e.sample).parameters + else e.sample(5) + ), + "sample_y": lambda e, d: e.sample_y(d.X[:5]), + "gibbs": lambda e, d: e.gibbs(d.X), +} + +# Public methods that aren't inference, so aren't compared. +NOT_INFERENCE: Dict[str, str] = { + "fit": "training", + "fit_predict": "training", + "fit_transform": "training", + "partial_fit": "training", + "path": "training (a static method computing a regularization path from data)", + "cost_complexity_pruning_path": "training (fits a new tree from the params)", + "get_params": "configuration", + "set_params": "configuration", + "set_output": "configuration", + "set_callbacks": "configuration", + "get_metadata_routing": "configuration", + "set_fit_request": "configuration", + "set_partial_fit_request": "configuration", + "set_predict_request": "configuration", + "set_score_request": "configuration", + "set_transform_request": "configuration", + "set_inverse_transform_request": "configuration", + "densify": "changes the fitted state (coef_ storage)", + "sparsify": "changes the fitted state (coef_ storage)", + "restrict": "changes the fitted state (DictVectorizer vocabulary)", + "correct_covariance": "changes the fitted state (rescales dist_; part of fit)", + "reweight_covariance": "changes the fitted state (part of fit)", + "build_analyzer": "depends only on params (returns a function)", + "build_preprocessor": "depends only on params (returns a function)", + "build_tokenizer": "depends only on params (returns a function)", + "get_stop_words": "depends only on params", + "decode": "depends only on params", +} + +# Known, understood parity gaps: (estimator, method) -> reason. Each must still fail; once it +# passes, the test asks for the entry to be removed. +KNOWN_GAPS: Dict[tuple, str] = { + ( + "GridSearchCV", + "score", + ): "scorer_ isn't saved; it should be rebuilt from `scoring` on load", + ( + "RandomizedSearchCV", + "score", + ): "scorer_ isn't saved; it should be rebuilt from `scoring` on load", +} + +# ==== data ==== + +rng = np.random.RandomState(0) +X = rng.rand(60, 4) + 0.1 # positive: needed by e.g. MultinomialNB, chi2, NMF +y = (X[:, 0] + X[:, 1] > 1.1).astype(int) +yr = X @ np.array([1.0, -2.0, 0.5, 3.0]) +DOCS = [f"doc {i} word{i % 7} token{i % 3} shared" for i in range(60)] + + +def _data_for(name: str, estimator: Any) -> SimpleNamespace: + """What to fit `estimator` on (fit_args) and what to call its methods with (X, y).""" + target = yr if is_regressor(estimator) else y + data = SimpleNamespace(X=X, y=target, fit_args=(X, target)) + if name.startswith("MultiTask") or name in ( + "MultiOutputRegressor", + "RegressorChain", + ): + data.y = np.c_[yr, -yr] + elif name in ("MultiOutputClassifier", "ClassifierChain"): + data.y = np.c_[y, 1 - y] + elif name in ("GammaRegressor", "PoissonRegressor"): + data.y = yr - yr.min() + 1 # strictly positive + elif name == "IsotonicRegression": + data.X = X[:, 0] + elif name == "KernelCenterer": + data.X = X @ X.T + elif name in ("CountVectorizer", "TfidfVectorizer", "HashingVectorizer"): + data.X = DOCS + elif name in ("DictVectorizer", "FeatureHasher"): + data.X = [ + {"a": float(r[0]), "b": float(r[1]), f"c{i % 3}": 1.0} + for i, r in enumerate(X) + ] + elif name in ("LabelEncoder", "LabelBinarizer"): + data.X = y + data.fit_args = (y,) + return data + elif name == "MultiLabelBinarizer": + data.X = [{int(v), int(v) + 1} for v in y] + data.fit_args = (data.X,) + return data + elif name == "PatchExtractor": + data.X = rng.rand(3, 20, 20) # the default patch size is a tenth of the image + elif name == "ColumnTransformer": + data.X = np.c_[X[:, :2], (X[:, 2] * 3).astype(int)] + data.fit_args = (data.X, data.y) + return data + + +def _construct(name: str) -> Any: + cls = ALL_ESTIMATORS[name] + if name == "SparseCoder": + estimator = cls(dictionary=rng.rand(3, X.shape[1])) + elif name == "FrozenEstimator": + estimator = cls(LogisticRegression().fit(X, y)) + else: + estimator = construct(cls) + if "random_state" in estimator.get_params(deep=False): + estimator.set_params(random_state=0) + return estimator + + +@lru_cache(maxsize=None) +def _fitted(name: str) -> tuple: + """The fitted estimator and its data, fitted once per module and never called on: each + test calls methods on a deep copy, so seeded random state starts identical every time. + """ + estimator = _construct(name) + data = _data_for(name, estimator) + try: + estimator.fit(*data.fit_args) + except TypeError: # fit(X) only + estimator.fit(data.fit_args[0]) + return estimator, data + + +# ==== comparison ==== + + +def _normalize(value: Any) -> Any: + """Turn a method's result into nested lists/arrays that can be compared directly.""" + if isinstance(value, GeneratorType): + return [_normalize(v) for v in value] + if isinstance(value, tuple): + return tuple(_normalize(v) for v in value) + if isinstance(value, list): + return [_normalize(v) for v in value] + if sparse.issparse(value): + return ("sparse", value.format, _normalize(value.toarray())) + if isinstance(value, (pd.DataFrame, pd.Series)): + return ( + "pandas", + list(getattr(value, "columns", [value.name])), + _normalize(value.to_numpy()), + ) + if isinstance(value, np.ndarray) and value.dtype == object: + return [_normalize(v) for v in value.ravel()] + [("shape", value.shape)] + return value + + +def _assert_same(got: Any, expected: Any) -> None: + got, expected = _normalize(got), _normalize(expected) + if isinstance(expected, (tuple, list)): + assert type(got) is type(expected) and len(got) == len( + expected + ), f"{got!r} != {expected!r}" + for g, e in zip(got, expected): + _assert_same(g, e) + return + if isinstance(expected, (np.ndarray, np.generic)): + got_arr, expected_arr = np.asarray(got), np.asarray(expected) + assert ( + got_arr.dtype == expected_arr.dtype + ), f"dtype {got_arr.dtype} != {expected_arr.dtype}" + if np.issubdtype(expected_arr.dtype, np.inexact): + np.testing.assert_allclose( + got_arr, expected_arr, rtol=1e-12, atol=0, equal_nan=True + ) + else: + np.testing.assert_array_equal(got_arr, expected_arr) + return + if isinstance(expected, float): + assert isinstance(got, float) and np.isclose( + got, expected, rtol=1e-12, atol=0, equal_nan=True + ), f"{got!r} != {expected!r}" + return + assert got == expected, f"{got!r} != {expected!r}" + + +def _has_method(estimator: Any, method: str) -> bool: + # callable: some names are also params on other estimators (TSNE's perplexity is an int). + return callable(getattr(estimator, method, None)) + + +def _check_method( + method: str, original: Any, loaded: Any, data: SimpleNamespace +) -> bool: + """The loaded copy must expose the method iff the original does, raise the same type of + error if the original raises, and otherwise return the same result. Returns whether + results were compared (not just both missing, or both raising).""" + has = _has_method(original, method) + assert ( + _has_method(loaded, method) == has + ), f"available: loaded {not has}, original {has}" + if not has: + return False + call = INFERENCE_CALLS[method] + try: + expected = call(original, data) + expected = _normalize( + expected + ) # drain generators while the original's state is current + except Exception as e: + with pytest.raises(type(e)): + _normalize(call(loaded, data)) + return False + _assert_same(call(loaded, data), expected) + return True + + +# ==== tests ==== + + +@pytest.mark.parametrize("format_name", FORMATS) +@pytest.mark.parametrize("name", ESTIMATOR_NAMES) +def test_inference_parity(name, format_name): + pristine, data = _fitted(name) + manager = SerializationManager(SklearnSerializer()) + loaded_pristine = manager.deserialize( + manager.serialize(pristine, format_name), format_name + ) + + failures, stale, compared = [], [], 0 + for method in INFERENCE_CALLS: + known = KNOWN_GAPS.get((name, method)) + # A fresh pair per method: some methods set missing private state as a side effect + # (StackingClassifier.predict sets _n_feature_outs), which would hide a later method's + # failure on a freshly loaded model. + original = copy.deepcopy(pristine) + loaded = copy.deepcopy(loaded_pristine) + try: + compared += _check_method(method, original, loaded, data) + except AssertionError as e: + if known is None: + failures.append(f"{method}: {str(e).strip()[:300]}") + continue + except Exception as e: + if known is None: + failures.append(f"{method}: {type(e).__name__}: {str(e)[:300]}") + continue + if known is not None: + stale.append(f"{method} ({known})") + + assert ( + not failures + ), f"{name} [{format_name}] differs after loading:\n " + "\n ".join(failures) + assert not stale, f"{name}: KNOWN_GAPS entries now pass, remove them: {stale}" + # Guards against a data recipe that makes every method raise on both copies. + has_any = any(_has_method(pristine, method) for method in INFERENCE_CALLS) + assert ( + compared or not has_any + ), f"{name}: no inference method returned a result to compare" + + +def test_every_public_method_is_classified(): + """A public method on a supported estimator that's neither compared nor listed as + non-inference (e.g. one added by a new scikit-learn release) must be classified.""" + classified = set(INFERENCE_CALLS) | set(NOT_INFERENCE) + unclassified = {} + for name in ESTIMATOR_NAMES: + cls = ALL_ESTIMATORS[name] + for method in dir(cls): + if method.startswith("_") or method in classified: + continue + if isinstance(inspect.getattr_static(cls, method), property): + continue + if callable(getattr(cls, method, None)): + unclassified.setdefault(method, []).append(name) + assert ( + not unclassified + ), f"add these to INFERENCE_CALLS or NOT_INFERENCE: {unclassified}" + + +def test_known_gaps_are_real_pairs(): + for name, method in KNOWN_GAPS: + assert name in ESTIMATOR_NAMES and method in INFERENCE_CALLS diff --git a/test/test_neighbors_tree.py b/test/test_neighbors_tree.py index f819e3a..5974a92 100644 --- a/test/test_neighbors_tree.py +++ b/test/test_neighbors_tree.py @@ -69,7 +69,7 @@ ), None, ), - # BallTree cases (BUG_AUDIT.md #7): the tree isn't written, only rebuilt on load. + # BallTree cases: the tree isn't written, only rebuilt on load. "knn_classifier_ball_tree": ( lambda: KNeighborsClassifier(algorithm="ball_tree"), y, diff --git a/test/test_private_attributes.py b/test/test_private_attributes.py index 909c612..e9d12a8 100644 --- a/test/test_private_attributes.py +++ b/test/test_private_attributes.py @@ -1,7 +1,7 @@ """ Private attributes that fitted estimators need after loading for methods other than -predict/transform (BUG_AUDIT #25, #26, #27, #29): each was missing, so the method raised on the -loaded copy while predict kept working, which is all the smoke tests compare. +predict/transform. Each used to be missing, so the method raised on the loaded copy while +predict kept working, which is all the smoke tests compare. """ import json @@ -40,7 +40,7 @@ def _roundtrip(model, format_name="json"): return manager.deserialize(manager.serialize(model, format_name), format_name) -# ==== #26 HistGradientBoostingClassifier._loss, #29 HistGradientBoosting*._n_features ==== +# ==== HistGradientBoostingClassifier._loss, HistGradientBoosting*._n_features ==== @pytest.mark.parametrize("format_name", FORMATS) @@ -74,7 +74,7 @@ def test_soft_voting_with_hgb_classifier_predicts(): np.testing.assert_array_equal(_roundtrip(model).predict(X), model.predict(X)) -# ==== #27 LinearDiscriminantAnalysis._max_components ==== +# ==== LinearDiscriminantAnalysis._max_components ==== @pytest.mark.parametrize("n_components", [None, 1]) @@ -86,7 +86,7 @@ def test_lda_transform(n_components): assert list(loaded.get_feature_names_out()) == list(model.get_feature_names_out()) -# ==== #25 _n_features_out (generic) and Stacking's _n_feature_outs ==== +# ==== _n_features_out (generic) and Stacking's _n_feature_outs ==== N_FEATURES_OUT_ESTIMATORS = [ "BernoulliRBM", @@ -151,7 +151,7 @@ def test_n_features_out_saved_only_as_instance_attribute(): assert _roundtrip(pca)._n_features_out == 2 -# ==== #29 HDBSCAN._single_linkage_tree_ (a structured array) ==== +# ==== HDBSCAN._single_linkage_tree_ (a structured array) ==== @pytest.mark.parametrize("format_name", FORMATS) From 7a3fab4f7a85dbc9edf78c5a65b679b03cb26fa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 11:47:17 +0200 Subject: [PATCH 19/35] =?UTF-8?q?test(helpers):=20make=20roundtrip=5Ffit?= =?UTF-8?q?=20drop=20fitted=20state=20the=20round=20trip=20doesn't=20resto?= =?UTF-8?q?re=20=E2=80=94=20test=5Fhelpers.py,=20test=5Ftest=5Fhelpers.py,?= =?UTF-8?q?=20test=5Festimator=5Fconformance.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openmodels/test_helpers.py | 50 ++++++++++++++++++++---------- test/test_estimator_conformance.py | 19 ++++++++++++ test/test_inference_parity.py | 3 ++ test/test_test_helpers.py | 49 +++++++++++++++++++++++++++-- 4 files changed, 102 insertions(+), 19 deletions(-) diff --git a/openmodels/test_helpers.py b/openmodels/test_helpers.py index 8e937e0..f490f39 100644 --- a/openmodels/test_helpers.py +++ b/openmodels/test_helpers.py @@ -65,21 +65,29 @@ def roundtrip_fit( ) -> Iterator[None]: """ Monkeypatch fit()/fit_transform()/fit_predict() on the given estimator classes so that, - immediately after fitting, the estimator's *fitted* attributes are replaced with the result - of an openmodels serialize -> deserialize round-trip. Restores the original methods on exit. + immediately after fitting, the estimator's fitted state is replaced with that of an + openmodels serialize -> deserialize round-trip. Restores the original methods on exit. Only methods defined directly on the class are patched (not inherited ones), since e.g. TransformerMixin.fit_transform already calls self.fit(...) and would be covered by patching fit alone; only classes that override fit_transform/fit_predict directly need those patched. - Only attributes openmodels actually serializes as fitted state (per - SklearnSerializer._extract_estimator_attributes - sklearn's trailing-underscore convention, - plus the small set of private attributes some estimators need at predict time) are copied - back onto the original instance. Constructor-set parameters are deliberately left untouched: - JSON has no tuple type, so e.g. a tuple-valued param would come back as a list after a - round-trip even though openmodels never claims to preserve exact param typing - copying it - over would make the *test infrastructure* look like a fidelity bug in checks like - check_dont_overwrite_parameters. + The fitted state is every instance attribute the fit call created or replaced: each is set + to the loaded copy's value, or **deleted** if the loaded copy doesn't have it. So an + attribute openmodels doesn't save is missing afterwards, exactly as on a really loaded + model, and any later method that needs it fails. Two kinds of attribute are kept as they + are: + + - constructor parameters: JSON has no tuple type, so e.g. a tuple-valued param would come + back as a list, and copying it over would make the *test infrastructure* look like a + fidelity bug in checks like check_dont_overwrite_parameters; + - attributes the fit call left untouched (the same object before and after), such as the + `set_output` configuration, metadata requests, or a parent estimator's callback context. + An attribute fit mutates in place without rebinding counts as untouched, so this can't + see it. + + Nested estimators held in parameters (e.g. a Pipeline's steps) are fitted in place and are + only round-tripped if their own class is passed too. This lets sklearn's own estimator-specific test suites be reused unmodified against round-tripped models: any assertion they make about a fitted estimator becomes, implicitly, @@ -89,13 +97,20 @@ def roundtrip_fit( manager = SerializationManager(serializer) originals: dict = {} - def _roundtrip(instance) -> None: - fitted_keys = serializer._extract_estimator_attributes(instance).keys() + def _roundtrip(instance, before_fit: dict) -> None: serialized = manager.serialize(instance, format_name=format_name) - deserialized = manager.deserialize(serialized, format_name=format_name) - for key in fitted_keys: - if hasattr(deserialized, key): - setattr(instance, key, getattr(deserialized, key)) + loaded = vars(manager.deserialize(serialized, format_name=format_name)) + params = instance.get_params(deep=False) + for key, value in list(vars(instance).items()): + if key in params or (key in before_fit and before_fit[key] is value): + continue + if key in loaded: + setattr(instance, key, loaded[key]) + else: + delattr(instance, key) + for key, value in loaded.items(): + if key not in params and key not in vars(instance): + setattr(instance, key, value) class _RoundtripDescriptor: """ @@ -121,8 +136,9 @@ def __get__(self, obj, objtype=None): @functools.wraps(bound_orig) def wrapper(*args, **kwargs): + before_fit = dict(vars(obj)) result = bound_orig(*args, **kwargs) - _roundtrip(obj) + _roundtrip(obj, before_fit) return result return wrapper diff --git a/test/test_estimator_conformance.py b/test/test_estimator_conformance.py index 5a47d10..0ac4bd7 100644 --- a/test/test_estimator_conformance.py +++ b/test/test_estimator_conformance.py @@ -221,6 +221,25 @@ def _entries(reason: str, estimator_checks: dict) -> dict: ) ) +# Resuming training after loading isn't supported: partial_fit after fit needs running training +# state (e.g. MiniBatchKMeans._counts) that openmodels doesn't save, since a loaded model is meant +# for inference. check_fit_score_takes_y calls partial_fit on the already-fitted, round-tripped +# instance. If resuming training becomes supported, these flip to XPASS (strict). +_RESUME_TRAINING_REASON = ( + "partial_fit after loading needs running training state (e.g. MiniBatchKMeans._counts) " + "that isn't saved; resuming training on a loaded model isn't supported." +) +KNOWN_ROUNDTRIP_XFAILS.update( + _entries( + _RESUME_TRAINING_REASON, + { + "MiniBatchDictionaryLearning": ["check_fit_score_takes_y"], + "MiniBatchKMeans": ["check_fit_score_takes_y"], + "MiniBatchNMF": ["check_fit_score_takes_y"], + }, + ) +) + def _check_name(check) -> str: return getattr(check, "func", check).__name__ diff --git a/test/test_inference_parity.py b/test/test_inference_parity.py index 99172d4..a3dfd50 100644 --- a/test/test_inference_parity.py +++ b/test/test_inference_parity.py @@ -182,6 +182,9 @@ def _data_for(name: str, estimator: Any) -> SimpleNamespace: return data elif name == "PatchExtractor": data.X = rng.rand(3, 20, 20) # the default patch size is a tenth of the image + elif name in ("KNNImputer", "SimpleImputer", "MissingIndicator"): + data.X = X.copy() + data.X[::5, 1] = np.nan # without NaNs, transform never reads the fitted data elif name == "ColumnTransformer": data.X = np.c_[X[:, :2], (X[:, 2] * 3).astype(int)] data.fit_args = (data.X, data.y) diff --git a/test/test_test_helpers.py b/test/test_test_helpers.py index b837986..00ca299 100644 --- a/test/test_test_helpers.py +++ b/test/test_test_helpers.py @@ -1,15 +1,21 @@ """ -Tests for openmodels.test_helpers.run_test_model itself: it must round-trip the dense fit and -the sparse fit separately, never the caller's own instance. +Tests for openmodels.test_helpers itself: run_test_model must round-trip the dense fit and the +sparse fit separately, never the caller's own instance; roundtrip_fit must leave an instance +with exactly the state a loaded model has. """ +import inspect import os import numpy as np +import pandas as pd import pytest from scipy.sparse import csr_matrix +from sklearn.cluster import KMeans from sklearn.exceptions import NotFittedError from sklearn.neighbors import KNeighborsRegressor +from sklearn.neural_network import MLPRegressor +from sklearn.preprocessing import StandardScaler from sklearn.utils.validation import check_is_fitted from openmodels import test_helpers @@ -73,3 +79,42 @@ def failing_compare(*args, **kwargs): KNeighborsRegressor(), X, Y, None, None, "helper_fail" ) assert not os.path.exists("./test/temp/helper_fail.json") + + +# ==== roundtrip_fit ==== + + +def test_roundtrip_fit_drops_fitted_state_that_isnt_saved(): + """An attribute fit creates but openmodels doesn't save (KMeans' training-only _tol) is + missing afterwards, as on a loaded model. Saved state is the loaded copy's.""" + plain = KMeans(n_clusters=2, n_init=1, random_state=0).fit(X) + assert hasattr(plain, "_tol") + + model = KMeans(n_clusters=2, n_init=1, random_state=0) + with test_helpers.roundtrip_fit(KMeans): + model.fit(X) + assert not hasattr(model, "_tol") + np.testing.assert_array_equal(model.cluster_centers_, plain.cluster_centers_) + np.testing.assert_array_equal(model.predict(X), plain.predict(X)) + + +def test_roundtrip_fit_keeps_params_and_untouched_configuration(): + hidden = (3,) + model = MLPRegressor(hidden_layer_sizes=hidden, max_iter=5, random_state=0) + with test_helpers.roundtrip_fit(MLPRegressor): + model.fit(X, Y) + assert model.hidden_layer_sizes is hidden # JSON would have made it a list + + scaler = StandardScaler().set_output(transform="pandas") + with test_helpers.roundtrip_fit(StandardScaler): + scaler.fit(X) + assert isinstance(scaler.transform(X), pd.DataFrame) + + +def test_roundtrip_fit_restores_methods_and_keeps_signature(): + original_fit = KMeans.__dict__["fit"] + with test_helpers.roundtrip_fit(KMeans): + # scikit-learn's check_fit_score_takes_y inspects fit's signature + params = list(inspect.signature(KMeans(n_init=1).fit).parameters) + assert params[:2] == ["X", "y"] + assert KMeans.__dict__["fit"] is original_fit From b28e34e472e0b5cf0321a69194d1676a85428bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 11:47:46 +0200 Subject: [PATCH 20/35] fix(serializer): save KNNImputer, OneClassSVM and NMF private state needed after load --- CHANGELOG.md | 10 ++++++++++ openmodels/serializers/sklearn/sklearn_serializer.py | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4da498..4e7c668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Files without it (v1-v3) resolve by bare name as before, now warning when the name is ambiguous. Type tags are unchanged, so openmodels 0.2.2 still loads v4 files. `openmodels_format_version` is now `4`. See `docs/format.md` +- `roundtrip_fit()` test helper (`openmodels/test_helpers.py`): after `fit`, every attribute the + fit created or replaced now takes the loaded copy's value, and is **deleted** if the loaded copy + lacks it. Before, only attributes openmodels saved were copied back, so state it failed to + save stayed on the instance and tests couldn't notice. Constructor parameters and attributes + the fit left untouched (e.g. the `set_output` configuration) are kept as before ### Fixed @@ -117,6 +122,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The HistGradientBoosting and LDA state is rebuilt on load, so files written by earlier versions work too. For `get_feature_names_out` and `HDBSCAN`, re-save older files +- A loaded `KNNImputer` couldn't impute: `transform` failed on any data with missing values. + Re-save older files +- A loaded `OneClassSVM` fitted on sparse data couldn't `predict`. Re-save older files +- `transform` on a loaded `NMF` re-created private state it needed, changing the model as a side + effect; that state is now saved ## [0.2.2] - 2026-09-22 diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index 451cf6a..465eee1 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -253,12 +253,13 @@ def _openmodels_version() -> str: "OrdinalEncoder": ["_missing_indices", "_infrequent_enabled"], "KBinsDiscretizer": ["_encoder"], "KernelPCA": ["_centerer"], - "KNNImputer": ["_mask_fit_X", "_valid_mask"], + "KNNImputer": ["_fit_X", "_mask_fit_X", "_valid_mask"], "KNeighborsTransformer": ["_fit_method", "_tree", "_fit_X"], "PowerTransformer": ["_scaler"], "RadiusNeighborsTransformer": ["_fit_method", "_tree", "_fit_X"], "SimpleImputer": ["_fit_dtype", "_fill_dtype"], "MiniBatchNMF": ["_n_components", "_transform_max_iter", "_beta_loss", "_gamma"], + "NMF": ["_n_components", "_beta_loss"], "MissingIndicator": ["_n_features", "_precomputed"], "MultiLabelBinarizer": ["_cached_dict"], "PolynomialFeatures": ["_max_degree", "_n_out_full", "_min_degree"], @@ -279,6 +280,7 @@ def _openmodels_version() -> str: "_gamma", "_dual_coef_", "_intercept_", + "_effective_probability", ], "NearestNeighbors": ["_fit_method", "_tree", "_fit_X"], "LocalOutlierFactor": [ From 6e3113b5e10762c884a8c7460abadefae1a62e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 11:58:35 +0200 Subject: [PATCH 21/35] fix(serializer): rebuild search estimators' scorer_ from scoring on load --- CHANGELOG.md | 4 ++ .../serializers/sklearn/sklearn_serializer.py | 24 ++++++- test/test_inference_parity.py | 11 +--- test/test_private_attributes.py | 63 +++++++++++++++++-- 4 files changed, 87 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e7c668..332ce27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A loaded `OneClassSVM` fitted on sparse data couldn't `predict`. Re-save older files - `transform` on a loaded `NMF` re-created private state it needed, changing the model as a side effect; that state is now saved +- `score` on a loaded `GridSearchCV`/`RandomizedSearchCV` raised `AttributeError`, and a search + with several metrics (`scoring` as a list or dict) couldn't be saved at all. `scorer_` is now + rebuilt on load from `scoring`, as `fit` builds it, so files written by earlier versions work + too. A `scoring` given as a scorer object (`make_scorer(...)`) still can't be saved ## [0.2.2] - 2026-09-22 diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index 465eee1..61e5bea 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -50,7 +50,7 @@ PinballLoss, BaseLoss, ) -from sklearn.metrics._scorer import _CurveScorer +from sklearn.metrics._scorer import _CurveScorer, _MultimetricScorer from sklearn.metrics import get_scorer_names, get_scorer from sklearn.multiclass import _ConstantPredictor from sklearn.tree._tree import Tree @@ -68,6 +68,10 @@ # see _rebuild_neighbors_tree. from sklearn.neighbors._base import NeighborsBase +# Private, but `_get_scorers` (how fit builds scorer_ from `scoring`) is identical across +# TESTED_VERSIONS; see _rebuild_derived_attributes. +from sklearn.model_selection._search import BaseSearchCV + from openmodels.exceptions import DeserializationError, UnsupportedEstimatorError from openmodels.protocols import ModelSerializer from openmodels.serializers.base import ( @@ -706,6 +710,12 @@ def is_valid_attribute(key: str) -> bool: ): del attributes["_tree"] + # A search's scorer_ is rebuilt on load from its `scoring` param (see + # _rebuild_derived_attributes). Scorer objects can't be written: a multi-metric + # scorer_ (a dict of them) made saving fail. + if isinstance(estimator, BaseSearchCV): + attributes.pop("scorer_", None) + return attributes def convert_from_serializable( @@ -1228,6 +1238,9 @@ def _rebuild_derived_attributes(self, model: BaseEstimator) -> None: saved). `sample_weight` only changes the loss during training, never its link. - `HistGradientBoosting*._n_features`, needed by `staged_predict*`: the input width. - `LinearDiscriminantAnalysis._max_components`, needed by `transform`. + - A search's (`GridSearchCV`, ...) `scorer_`, needed by `score`: built from its `scoring` + param by `_get_scorers`, as `fit` does. It's never saved (see + `_extract_estimator_attributes`). """ if isinstance( model, (HistGradientBoostingClassifier, HistGradientBoostingRegressor) @@ -1245,6 +1258,15 @@ def _rebuild_derived_attributes(self, model: BaseEstimator) -> None: model._max_components = ( max_components if model.n_components is None else model.n_components ) + if ( + isinstance(model, BaseSearchCV) + and hasattr(model, "multimetric_") + and not hasattr(model, "scorer_") + ): + scorers, _ = model._get_scorers() + model.scorer_ = ( + scorers._scorers if isinstance(scorers, _MultimetricScorer) else scorers + ) def _serialize_estimators_collection( self, value: Union[np.ndarray, List[BaseEstimator]] diff --git a/test/test_inference_parity.py b/test/test_inference_parity.py index a3dfd50..8809fe7 100644 --- a/test/test_inference_parity.py +++ b/test/test_inference_parity.py @@ -128,16 +128,7 @@ # Known, understood parity gaps: (estimator, method) -> reason. Each must still fail; once it # passes, the test asks for the entry to be removed. -KNOWN_GAPS: Dict[tuple, str] = { - ( - "GridSearchCV", - "score", - ): "scorer_ isn't saved; it should be rebuilt from `scoring` on load", - ( - "RandomizedSearchCV", - "score", - ): "scorer_ isn't saved; it should be rebuilt from `scoring` on load", -} +KNOWN_GAPS: Dict[tuple, str] = {} # ==== data ==== diff --git a/test/test_private_attributes.py b/test/test_private_attributes.py index e9d12a8..cd715f2 100644 --- a/test/test_private_attributes.py +++ b/test/test_private_attributes.py @@ -1,6 +1,6 @@ """ -Private attributes that fitted estimators need after loading for methods other than -predict/transform. Each used to be missing, so the method raised on the loaded copy while +Fitted state, mostly private attributes, that estimators need after loading for methods other +than predict/transform. Each used to be missing, so the method raised on the loaded copy while predict kept working, which is all the smoke tests compare. """ @@ -17,12 +17,14 @@ HistGradientBoostingRegressor, VotingClassifier, ) -from sklearn.linear_model import LogisticRegression +from sklearn.linear_model import LogisticRegression, Ridge +from sklearn.metrics import make_scorer, mean_absolute_error +from sklearn.model_selection import GridSearchCV, RandomizedSearchCV from sklearn.pipeline import make_pipeline from sklearn.utils.discovery import all_estimators from openmodels.core import SerializationManager -from openmodels.exceptions import DeserializationError +from openmodels.exceptions import DeserializationError, SerializationError from openmodels.serializers.sklearn.sklearn_serializer import SklearnSerializer from test._estimator_construction import construct @@ -175,3 +177,56 @@ def test_invalid_structured_dtype_is_refused(bad_dtype): data["attribute_dtypes"]["_single_linkage_tree_"] = bad_dtype with pytest.raises(DeserializationError, match="Invalid structured dtype"): SerializationManager(SklearnSerializer()).deserialize(json.dumps(data)) + + +# ==== search estimators' scorer_, rebuilt from `scoring` ==== + +SCORINGS = { + "default": None, + "string": "neg_mean_absolute_error", + "list": ["r2", "neg_mean_absolute_error"], + "dict": {"fit": "r2", "error": "neg_max_error"}, +} + + +@pytest.mark.parametrize("format_name", ["json", "pickle"]) +@pytest.mark.parametrize("scoring", SCORINGS.values(), ids=SCORINGS.keys()) +@pytest.mark.parametrize("search_cls", [GridSearchCV, RandomizedSearchCV]) +def test_search_score(search_cls, scoring, format_name): + refit = ( + scoring[0] + if isinstance(scoring, list) + else "error" if isinstance(scoring, dict) else True + ) + model = search_cls( + Ridge(), {"alpha": [0.1, 1.0]}, cv=2, scoring=scoring, refit=refit + ) + if search_cls is RandomizedSearchCV: + model.set_params(n_iter=2, random_state=0) + model.fit(X, yr) + loaded = _roundtrip(model, format_name) + assert loaded.score(X, yr) == model.score(X, yr) + assert type(loaded.scorer_) is type(model.scorer_) + if isinstance(model.scorer_, dict): + assert loaded.scorer_.keys() == model.scorer_.keys() + + +def test_search_scorer_is_never_saved(): + model = GridSearchCV( + Ridge(), + {"alpha": [0.1, 1.0]}, + cv=2, + scoring=["r2", "neg_mean_absolute_error"], + refit="r2", + ).fit(X, yr) + assert "scorer_" not in SklearnSerializer().serialize(model)["attributes"] + + +def test_search_with_scorer_object_as_scoring_still_refused(): + """The `scoring` param itself can't hold a scorer object (make_scorer(...)): saving fails + cleanly, before scorer_ is involved.""" + model = GridSearchCV( + Ridge(), {"alpha": [0.1, 1.0]}, cv=2, scoring=make_scorer(mean_absolute_error) + ).fit(X, yr) + with pytest.raises(SerializationError, match="Can't serialize callable"): + SerializationManager(SklearnSerializer()).serialize(model) From 88f13ada6c7e6cdcd12c35cd1dfd9a731d18ac49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 12:24:41 +0200 Subject: [PATCH 22/35] test: pin which estimators can resume training after load --- test/test_resume_training.py | 158 +++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 test/test_resume_training.py diff --git a/test/test_resume_training.py b/test/test_resume_training.py new file mode 100644 index 0000000..9f92bda --- /dev/null +++ b/test/test_resume_training.py @@ -0,0 +1,158 @@ +""" +Resuming training on a loaded model: for every supported estimator that can continue training +(`partial_fit`, or `warm_start=True` and fit again), continuing on the loaded copy must give the +same model as continuing on the original. Compared with every inference method, as in +test_inference_parity.py. + +Loaded models are meant for inference; resuming works for most estimators because their +training state is part of their fitted state. KNOWN_FAILURES lists the ones whose running +training state (optimizer momentum, running sums, random generator position, ...) isn't saved. +They're strict xfails: once one is supported, the test asks for its entry to be removed. + +The two models are fitted separately with the same seed, never copied: copy.deepcopy doesn't +keep NumPy views, which some estimators update through during training (Birch's CF-tree nodes), +so a deep copy of a fitted model can continue differently from the model itself. +""" + +from types import SimpleNamespace +from typing import Any, Dict, List, Tuple + +import numpy as np +import pytest +from sklearn.base import is_classifier, is_regressor +from sklearn.linear_model import SGDClassifier, SGDRegressor + +from openmodels.core import SerializationManager +from openmodels.serializers.sklearn.sklearn_serializer import ( + ALL_ESTIMATORS, + SklearnSerializer, +) +from test import test_inference_parity as parity + +pytestmark = pytest.mark.filterwarnings("ignore") + +# Params grown between the two fits of a warm start, so the second fit does more work. +WARM_START_GROWN = ("max_iter", "n_estimators") + +_NOT_SAVED = "running training state isn't saved: " +KNOWN_FAILURES: Dict[Tuple[str, str], str] = { + ("MiniBatchDictionaryLearning", "partial_fit"): _NOT_SAVED + + "_random_state, _A, _B, ...", + ("MiniBatchKMeans", "partial_fit"): _NOT_SAVED + + "_counts, _n_since_last_reassign, ...", + ("MiniBatchNMF", "partial_fit"): _NOT_SAVED + "_components_numerator, ...", + ("MLPClassifier", "partial_fit"): _NOT_SAVED + + "_optimizer, _no_improvement_count, ...", + ("MLPRegressor", "partial_fit"): _NOT_SAVED + + "_optimizer, _no_improvement_count, ...", + ("GradientBoostingClassifier", "warm_start"): _NOT_SAVED + "_rng", + ("GradientBoostingRegressor", "warm_start"): _NOT_SAVED + "_rng", + ("HistGradientBoostingClassifier", "warm_start"): _NOT_SAVED + "_random_seed, ...", + ("HistGradientBoostingRegressor", "warm_start"): _NOT_SAVED + "_random_seed, ...", + ("HashingVectorizer", "partial_fit"): ( + "the tuple param ngram_range loads as a list, which scikit-learn's param validation " + "rejects on any later fit" + ), +} + + +def _construct(name: str) -> Any: + """parity._construct, except that a meta-estimator whose partial_fit is only available + when its wrapped estimator has one (OneVsRestClassifier, MultiOutputRegressor, + SelectFromModel, ...) wraps an SGD model, so its partial_fit is tested too.""" + estimator = parity._construct(name) + if hasattr(ALL_ESTIMATORS[name], "partial_fit") and not hasattr( + estimator, "partial_fit" + ): + inner = SGDRegressor if is_regressor(estimator) else SGDClassifier + estimator.set_params(estimator=inner(random_state=0)) + return estimator + + +def _resumable() -> List[Tuple[str, str]]: + cases = [] + for name in parity.ESTIMATOR_NAMES: + estimator = _construct(name) + if hasattr(estimator, "partial_fit"): + cases.append((name, "partial_fit")) + if "warm_start" in estimator.get_params(deep=False): + cases.append((name, "warm_start")) + return cases + + +RESUMABLE = _resumable() + + +def _fit(name: str, kind: str) -> Tuple[Any, SimpleNamespace]: + estimator = _construct(name) + if kind == "warm_start": + estimator.set_params(warm_start=True) + data = parity._data_for(name, estimator) + try: + estimator.fit(*data.fit_args) + except TypeError: # fit(X) only + estimator.fit(data.fit_args[0]) + return estimator, data + + +def _continue(estimator: Any, kind: str, data: SimpleNamespace) -> None: + if kind == "partial_fit": + kwargs = {} + if is_classifier(estimator): + y = np.asarray(data.y) + kwargs["classes"] = ( + [np.unique(col) for col in y.T] if y.ndim == 2 else np.unique(y) + ) + try: + estimator.partial_fit(*data.fit_args, **kwargs) + except TypeError: # partial_fit(X) only + estimator.partial_fit(data.fit_args[0]) + return + params = estimator.get_params(deep=False) + estimator.set_params( + **{ + key: params[key] * 2 + for key in WARM_START_GROWN + if isinstance(params.get(key), int) and not isinstance(params[key], bool) + } + ) + try: + estimator.fit(*data.fit_args) + except TypeError: + estimator.fit(data.fit_args[0]) + + +@pytest.mark.parametrize("format_name", parity.FORMATS) +@pytest.mark.parametrize( + "name, kind", RESUMABLE, ids=[f"{name}-{kind}" for name, kind in RESUMABLE] +) +def test_resume_training(name, kind, format_name, request): + reason = KNOWN_FAILURES.get((name, kind)) + if reason is not None: + request.applymarker(pytest.mark.xfail(reason=reason, strict=True)) + + original, data = _fit(name, kind) + to_save, _ = _fit(name, kind) + manager = SerializationManager(SklearnSerializer()) + loaded = manager.deserialize(manager.serialize(to_save, format_name), format_name) + + _continue(original, kind, data) + _continue(loaded, kind, data) + + failures, compared = [], 0 + for method in parity.INFERENCE_CALLS: + try: + compared += parity._check_method(method, original, loaded, data) + except Exception as e: + failures.append(f"{method}: {type(e).__name__}: {str(e)[:300]}") + assert ( + not failures + ), f"{name} [{kind}, {format_name}] differs after resuming:\n " + "\n ".join( + failures + ) + assert compared, f"{name}: no inference method returned a result to compare" + + +def test_known_failures_are_real_cases(): + for case in KNOWN_FAILURES: + assert case in RESUMABLE, case From 46dca6d0519de47b0dba44851def689c799c5d8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 12:26:20 +0200 Subject: [PATCH 23/35] docs: document resuming training on loaded models --- CHANGELOG.md | 6 ++++++ README.md | 6 ++++++ docs/supported_models.md | 20 ++++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 332ce27..374980a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 rebuilt on load from `scoring`, as `fit` builds it, so files written by earlier versions work too. A `scoring` given as a scorer object (`make_scorer(...)`) still can't be saved +### Added + +- `docs/supported_models.md`: "Resuming Training" section. Continuing to train a loaded model + (`partial_fit`, `warm_start`) works for most estimators; the MiniBatch estimators, MLP + `partial_fit` and gradient boosting `warm_start` can't resume yet (planned for 0.3) + ## [0.2.2] - 2026-09-22 ### Changed diff --git a/README.md b/README.md index 7a0ce14..a673de0 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,12 @@ for the full breakdown by category, and the [serialized format reference](https://gnpd.github.io/openmodels/format.html) for what actually gets written to disk. +Loaded models are meant for inference. Continuing to train one (`partial_fit`, `warm_start`) +works for most estimators, but not yet for a few (the MiniBatch estimators, MLP `partial_fit`, +gradient boosting `warm_start`) - see +[Resuming Training](https://gnpd.github.io/openmodels/supported_models.html#resuming-training). +Full support is planned for 0.3. + You can also retrieve the list programmatically using the `SklearnSerializer.all_estimators()` method: ```python diff --git a/docs/supported_models.md b/docs/supported_models.md index dd46dcb..e517650 100644 --- a/docs/supported_models.md +++ b/docs/supported_models.md @@ -74,6 +74,26 @@ All other scikit-learn estimators are supported, including: Every scikit-learn estimator discoverable via `SklearnSerializer.all_estimators()` is supported - see {doc}`format` for the details of what gets serialized. +## Resuming Training + +A loaded model is meant for inference: `predict`, `transform`, `score` and the other methods +that use the fitted model give the same results as the original. + +Continuing to train a loaded model - `partial_fit`, or `warm_start=True` and `fit` again - +works for most estimators that support it, and gives the same model as continuing on the +original. These estimators can't resume yet, because state they only use while training isn't +saved: + +| Estimator | Can't resume with | +|---|---| +| `MiniBatchKMeans`, `MiniBatchNMF`, `MiniBatchDictionaryLearning` | `partial_fit` | +| `MLPClassifier`, `MLPRegressor` | `partial_fit` (`warm_start` works) | +| `GradientBoostingClassifier`, `GradientBoostingRegressor` | `warm_start` | +| `HistGradientBoostingClassifier`, `HistGradientBoostingRegressor` | `warm_start` | + +For these, fit a new model on all the data instead. Support for resuming training on every +estimator is planned for openmodels 0.3. + ## Custom & Third-Party Estimators OpenModels supports custom estimators that follow scikit-learn's API via the From 3bb0a6d0b6059af15238213f7ec45ca50b327fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 13:42:49 +0200 Subject: [PATCH 24/35] fix(serializer): restore tuple-only params as tuples so loaded models can be refitted --- CHANGELOG.md | 6 ++ .../serializers/sklearn/sklearn_serializer.py | 37 +++++-- test/test_resume_training.py | 4 - test/test_tuple_params.py | 97 +++++++++++++++++++ 4 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 test/test_tuple_params.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 374980a..e338d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with several metrics (`scoring` as a list or dict) couldn't be saved at all. `scorer_` is now rebuilt on load from `scoring`, as `fit` builds it, so files written by earlier versions work too. A `scoring` given as a scorer object (`make_scorer(...)`) still can't be saved +- Tuple-valued params came back as lists, which scikit-learn rejects for tuple-only params, so + some loaded models couldn't be fitted again (e.g. in cross-validation, or when retraining a + loaded pipeline): `MinMaxScaler`, `RobustScaler`, `CountVectorizer`, `TfidfVectorizer` and + `HashingVectorizer` with default settings, `PatchExtractor` with a `patch_size`, and + `SpectralBiclustering` with a tuple `n_clusters`. Params that scikit-learn declares + tuple-only are now restored as tuples, for files written by earlier versions too ### Added diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index 61e5bea..d10577c 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -304,6 +304,30 @@ def _openmodels_version() -> str: GENERIC_PRIVATE_ATTRIBUTES: List[str] = ["_n_features_out"] +def _restore_tuple_param(estimator_cls: type, name: str, value: Any) -> Any: + """ + Turn a param that came back as a list into the tuple its estimator requires. Every format + stores tuples as lists (JSON has no tuple type, and the type map recording the tuple goes + through JSON too), but scikit-learn's param validation rejects a list for params declared + tuple-only - e.g. `MinMaxScaler.feature_range` or `CountVectorizer.ngram_range` - so the + loaded model couldn't be fitted again. A param is converted only when its class's + `_parameter_constraints` allow `tuple` but not `list` or "array-like"; classes without + constraints (most third-party estimators) are left unchanged, and so are params declared + "no_validation" (a string, not a list). + """ + if not isinstance(value, list): + return value + constraints = getattr(estimator_cls, "_parameter_constraints", {}).get(name, []) + if ( + isinstance(constraints, list) + and tuple in constraints + and list not in constraints + and "array-like" not in constraints + ): + return tuple(value) + return value + + class SklearnSerializer( ModelSerializer, NumpySerializerMixin, @@ -1700,17 +1724,12 @@ def _deserialize_core(self, data: Dict[str, Any]) -> BaseEstimator: # Only include params that are valid constructor arguments if param_name not in valid_args: continue - # Handle PatchExtractor's 'patch_size' parameter - if ( - estimator_class == "PatchExtractor" - and param_name == "patch_size" - and isinstance(param_value, list) - ): - param_value = tuple(param_value) param_type = param_types.get(param_name) param_dtype = param_dtypes.get(param_name) or None - reconstructed_params[param_name] = self.convert_from_serializable( - param_value, param_type, param_dtype + reconstructed_params[param_name] = _restore_tuple_param( + estimator_cls, + param_name, + self.convert_from_serializable(param_value, param_type, param_dtype), ) model = estimator_cls(**reconstructed_params) diff --git a/test/test_resume_training.py b/test/test_resume_training.py index 9f92bda..16e8627 100644 --- a/test/test_resume_training.py +++ b/test/test_resume_training.py @@ -49,10 +49,6 @@ ("GradientBoostingRegressor", "warm_start"): _NOT_SAVED + "_rng", ("HistGradientBoostingClassifier", "warm_start"): _NOT_SAVED + "_random_seed, ...", ("HistGradientBoostingRegressor", "warm_start"): _NOT_SAVED + "_random_seed, ...", - ("HashingVectorizer", "partial_fit"): ( - "the tuple param ngram_range loads as a list, which scikit-learn's param validation " - "rejects on any later fit" - ), } diff --git a/test/test_tuple_params.py b/test/test_tuple_params.py new file mode 100644 index 0000000..928f188 --- /dev/null +++ b/test/test_tuple_params.py @@ -0,0 +1,97 @@ +""" +Tuple-valued params: every format stores them as lists, and scikit-learn's param validation +rejects a list for params declared tuple-only, so a loaded model couldn't be fitted again +(clone() in cross-validation, retraining a loaded pipeline, ...). They're turned back into +tuples on load when the estimator's _parameter_constraints require one. +""" + +import json + +import numpy as np +import pytest +from sklearn.base import BaseEstimator, clone +from sklearn.cluster import SpectralBiclustering +from sklearn.feature_extraction.image import PatchExtractor +from sklearn.feature_extraction.text import ( + CountVectorizer, + HashingVectorizer, + TfidfVectorizer, +) +from sklearn.neural_network import MLPClassifier +from sklearn.preprocessing import MinMaxScaler, RobustScaler + +from openmodels.core import SerializationManager +from openmodels.serializers.sklearn.sklearn_serializer import SklearnSerializer + +rng = np.random.RandomState(0) +X = rng.rand(30, 4) +DOCS = [f"doc {i} word{i % 5} token{i % 3}" for i in range(30)] +IMAGES = rng.rand(3, 10, 10) + +# (estimator, tuple-only param, data to fit on) +CASES = { + "MinMaxScaler": (MinMaxScaler(feature_range=(-1, 1)), "feature_range", X), + "RobustScaler": (RobustScaler(), "quantile_range", X), + "CountVectorizer": (CountVectorizer(ngram_range=(1, 2)), "ngram_range", DOCS), + "TfidfVectorizer": (TfidfVectorizer(), "ngram_range", DOCS), + "HashingVectorizer": (HashingVectorizer(), "ngram_range", DOCS), + "PatchExtractor": (PatchExtractor(patch_size=(3, 3)), "patch_size", IMAGES), + "SpectralBiclustering": ( + SpectralBiclustering(n_clusters=(2, 2), random_state=0), + "n_clusters", + X, + ), +} + + +def _roundtrip(model, format_name="json", serializer=None): + manager = SerializationManager(serializer or SklearnSerializer()) + return manager.deserialize(manager.serialize(model, format_name), format_name) + + +@pytest.mark.parametrize("format_name", ["json", "pickle"]) +@pytest.mark.parametrize("name", CASES) +def test_tuple_param_restored_and_refittable(name, format_name): + model, param, data = CASES[name] + model = clone(model).fit(data) + loaded = _roundtrip(model, format_name) + restored = getattr(loaded, param) + assert isinstance(restored, tuple) and restored == getattr(model, param) + loaded.fit(data) # param validation used to reject the list + clone(loaded).fit(data) + + +def test_array_like_params_still_validate(): + """Params that also accept lists/array-likes are left as they come back.""" + loaded = _roundtrip(MLPClassifier(hidden_layer_sizes=(3,))) + loaded._validate_params() + + +class _TupleParamEstimator(BaseEstimator): + """A third-party-style estimator: a tuple param and no _parameter_constraints.""" + + def __init__(self, sizes=(1, 2)): + self.sizes = sizes + + def fit(self, X=None, y=None): + self.fitted_ = True + return self + + +class _NoValidationEstimator(BaseEstimator): + _parameter_constraints = {"sizes": "no_validation"} + + def __init__(self, sizes=(1, 2)): + self.sizes = sizes + + def fit(self, X=None, y=None): + self.fitted_ = True + return self + + +@pytest.mark.parametrize("cls", [_TupleParamEstimator, _NoValidationEstimator]) +def test_params_without_tuple_constraints_are_unchanged(cls): + serializer = SklearnSerializer(custom_estimators={cls.__name__: cls}) + data = serializer.serialize(cls().fit()) + loaded = SerializationManager(serializer).deserialize(json.dumps(data)) + assert loaded.sizes == [1, 2] From e4e2dd7a4ead02304be7d511a702eb166976d242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 15:18:41 +0200 Subject: [PATCH 25/35] feat(serializer): save non-string dict keys as text with key types instead of an envelope --- CHANGELOG.md | 14 +- docs/format.md | 53 +++- openmodels/serializers/base.py | 129 ++++++++-- .../serializers/sklearn/sklearn_serializer.py | 151 ++++++++++- test/test_dict_values.py | 240 ++++++++++++++++++ test/test_serializer_base.py | 28 +- 6 files changed, 588 insertions(+), 27 deletions(-) create mode 100644 test/test_dict_values.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e338d66..50ba0ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Wire format v4:** every estimator node records `estimator_package` (the class's top-level package) next to `estimator_class`, and classes are resolved by `(package, class name)`. Files without it (v1-v3) resolve by bare name as before, now warning when the name is - ambiguous. Type tags are unchanged, so openmodels 0.2.2 still loads v4 files. + ambiguous. Dicts whose values need restoring are typed per key, and non-string keys (e.g. + `class_weight={0: 1.0}`) are saved as text with their types next to the value types, instead + of a keys/values envelope (see the fix below). openmodels 0.2.2 still loads v4 files but + doesn't know these types: dict values load as plain JSON, as they always did, and non-string + keys as strings, so such a `class_weight` model predicts but can't be refitted there. `openmodels_format_version` is now `4`. See `docs/format.md` - `roundtrip_fit()` test helper (`openmodels/test_helpers.py`): after `fit`, every attribute the fit created or replaced now takes the loaded copy's value, and is **deleted** if the loaded copy @@ -137,6 +141,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `HashingVectorizer` with default settings, `PatchExtractor` with a `patch_size`, and `SpectralBiclustering` with a tuple `n_clusters`. Params that scikit-learn declares tuple-only are now restored as tuples, for files written by earlier versions too +- Values inside string-keyed dicts loaded as plain JSON: arrays and tuples came back as lists, + NumPy scalars as Python ones, and estimators as raw dicts. This affected e.g. + `FunctionTransformer(kw_args=...)`, a search's `cv_results_` (whose arrays came back as lists + and its masked `param_*` columns as lists with `None`), and `Voting*`/`Stacking*`'s + `named_estimators_`, which was a dict of raw dicts instead of a `Bunch` of estimators. These + values are now typed per key and restored exactly, including in dicts with non-string keys; + re-save older files. Tuple dict keys, which saved but failed on load, now raise + `SerializationError` at save; so does a dict holding keys with the same text (`1` and `"1"`) ### Added diff --git a/docs/format.md b/docs/format.md index 918690c..b5a9f51 100644 --- a/docs/format.md +++ b/docs/format.md @@ -14,7 +14,7 @@ requirements, and `README.md`'s Security section for the short version. | `estimator_class` | `str` | The model's class name, e.g. `"LogisticRegression"`. Together with `estimator_package`, looked up against a registry of known scikit-learn (and registered custom) classes on deserialize. Nothing named by the file is ever imported: an unregistered class raises `UnsupportedEstimatorError`. | | `estimator_package` | `str` | The top-level package the class comes from (`cls.__module__.split(".")[0]`), e.g. `"sklearn"`, `"chemotools"`, or `"__main__"` for a class defined in a script or notebook. Classes are resolved by `(estimator_package, estimator_class)`, so two classes with the same name from different packages (scikit-learn's and chemotools' `MinMaxScaler`) coexist. The top-level package is used rather than the full module path because scikit-learn's real modules are private and get renamed between releases. Since format `4`; a node without it resolves by bare name, with a registered custom class winning over a built-in one of the same name (and a `UserWarning` when the name is ambiguous). | | `params` | `dict` | The estimator's constructor parameters, from `model.get_params(deep=False)`. | -| `param_types` | `dict` | `{param_name: type(value).__name__}` for every entry in `params` - lets the deserializer reconstruct types JSON can't represent natively (e.g. `tuple`). | +| `param_types` | `dict` | `{param_name: type(value).__name__}` for every entry in `params` - lets the deserializer reconstruct types JSON can't represent natively (e.g. `tuple`). Nested lists and dicts get nested types (see [Values inside dicts](#values-inside-dicts)). | | `param_dtypes` | `dict` | Like `param_types`, but for numpy dtypes specifically (e.g. a `np.dtype` param), when applicable. | | `attributes` | `dict` | The model's *fitted* state - every public attribute following scikit-learn's trailing-underscore convention (`coef_`, `classes_`, ...), plus a small per-class allowlist of private attributes some estimators need at predict/transform time (see `ATTRIBUTE_EXCEPTIONS` in `sklearn_serializer.py`). Omitted entirely if the model hasn't been fit yet. | | `attribute_types` | `dict` | Like `param_types`, for `attributes`. | @@ -137,6 +137,45 @@ A model whose classes come from a registered third-party package lists that pack ## Nested and composite estimators +## Values inside dicts + +A dict param or attribute (`FunctionTransformer(kw_args=...)`, a search's `cv_results_`, +`Voting*`'s `named_estimators_`, `class_weight`, ...) is saved as a plain JSON object. When every +key is a string and every value is plain JSON (`str`, `int`, `float`, `bool`, `None`, or lists +of them), its type is just `"dict"`. Otherwise the type is `{"dict": {key: type}}`, using the same tags as everywhere else, +and the dtype entry mirrors the dict with the dtypes of its arrays: + +```json +"params": {"kw_args": {"x": [1.0, 2.0], "pair": [1, 2], "n": 3}}, +"param_types": {"kw_args": {"dict": {"x": "ndarray", "pair": {"tuple": ["int", "int"]}, "n": "int"}}}, +"param_dtypes": {"kw_args": {"x": "float32"}} +``` + +Inside such a dict: + +- a tuple is typed `{"tuple": [types]}` (a list of types means a list); +- a nested dict is typed the same way, recursively, with a nested dtype dict; a list's dtype + entry is a list with one entry per element; +- a scikit-learn `Bunch` is typed `{"Bunch": {key: type}}`, and always is, so it loads as a + `Bunch`; +- a masked array (`cv_results_["param_*"]`) is typed `"MaskedArray"` and saved as + `{"data", "mask", "shape", "dtype"}`, plus `"types"` (one per element) for object arrays, + whose masked slots are written as `null`. + +Non-string keys (`int`, `float`, `bool`, `None`, NumPy scalars) are written as text, as JSON +requires, and their types go in a `"key_types"` entry next to `"dict"` (string keys aren't +listed). For `class_weight={0: 1.0, 1: 3.0}`: + +```json +"params": {"class_weight": {"0": 1.0, "1": 3.0}}, +"param_types": {"class_weight": {"dict": {"0": "float", "1": "float"}, "key_types": {"0": "int", "1": "int"}}} +``` + +Other keys (e.g. tuples), and keys whose text is the same (`1` and `"1"` in one dict), can't be +written: `serialize()` raises `SerializationError`. Files written before v4 saved dicts with +non-string keys as an `{"__openmodels_dict__": true, "keys", "key_types", "values"}` envelope +tagged `"dict"`; it is still read. + A meta-estimator that holds other estimators - a `Pipeline` step, a `VotingClassifier`'s `estimators`, a `ColumnTransformer`'s `transformers` - doesn't get a special graph representation. Wherever a `BaseEstimator` value appears (inside `params` or `attributes`), it's @@ -208,9 +247,15 @@ package of the class the file targets, e.g. `"sklearn"`). Every `attributes` blo `estimator_class`, and classes are resolved by `(package, class name)` - before, a registered custom class silently replaced a built-in one with the same name. Files without the field (v1-v3) still resolve by bare name with the old rule (custom class wins), now warning when the - name is ambiguous. Type tags in `param_types`/`attribute_types` are unchanged (still the class - name), so openmodels 0.2.x readers still load v4 files, with only the "newer format version" - warning. + name is ambiguous. Dicts whose values need restoring are typed per key, and non-string keys + are written as text with a `"key_types"` entry instead of the `__openmodels_dict__` envelope + (see [Values inside dicts](#values-inside-dicts)) - before, values loaded as plain JSON + (arrays and tuples as lists, estimators as raw dicts). Dicts of plain values with string keys + keep the `"dict"` tag, and older files load as before. openmodels 0.2.x readers still load + v4 files, with the "newer format version" warning, but don't know the new types: dict values + load as plain JSON, as they always did, and non-string keys as strings - e.g. a loaded + `class_weight={0: 1.0}` becomes `{"0": 1.0}`, which still predicts but makes refitting fail + with scikit-learn's "classes are not in class_weight" error. ## Why not ONNX or PMML? diff --git a/openmodels/serializers/base.py b/openmodels/serializers/base.py index 96dd89a..02f8612 100644 --- a/openmodels/serializers/base.py +++ b/openmodels/serializers/base.py @@ -62,6 +62,26 @@ def _find_function_module(func: Any) -> Optional[str]: } +def _key_to_text(key: Any) -> str: + """ + A dict key as the text JSON requires for object keys: strings unchanged; ints, floats, + bools, None and NumPy scalars as `str()`. Their type is recorded in the dict's type entry + ("key_types") and restored by `SerializerMixin._text_to_key`. Dicts in values written + without a type entry (kernels, Birch nodes, tree predictors, ...) only ever have string + keys. Other keys (tuples, ...) can't be written. + """ + if isinstance(key, str): + return key + if isinstance(key, (np.integer, np.floating, np.bool_)): + key = key.item() + if key is None or isinstance(key, (int, float)): # bool is an int + return str(key) + raise SerializationError( + f"Can't serialize dict key {key!r} of type {type(key).__name__}: only str, int, " + f"float, bool and None keys are supported" + ) + + class SerializerMixin: """ Base mixin providing recursive serialization and native Python object @@ -78,17 +98,18 @@ def convert_to_serializable(self, value): # Recursive case: dict, list, tuple if isinstance(value, dict): - if all(isinstance(k, str) for k in value): - return {k: self.convert_to_serializable(v) for k, v in value.items()} - # Non-string keys (e.g. int) can't be represented as JSON object keys without - # losing their type, so fall back to a self-describing keys/values envelope that - # _deserialize_dict can reconstruct exactly. - return { - "__openmodels_dict__": True, - "keys": [self.convert_to_serializable(k) for k in value.keys()], - "key_types": [type(k).__name__ for k in value.keys()], - "values": [self.convert_to_serializable(v) for v in value.values()], + # Keys are written as text, as JSON requires; a non-string key's type is recorded + # in the dict's type entry ("key_types", see SklearnSerializer._get_dict_types). + serialized = { + _key_to_text(k): self.convert_to_serializable(v) + for k, v in value.items() } + if len(serialized) != len(value): + raise SerializationError( + f"Can't serialize a dict whose keys have the same text " + f"(e.g. 1 and '1'): {list(value)!r}" + ) + return serialized if isinstance(value, (list, tuple)): return [self.convert_to_serializable(v) for v in value] @@ -103,11 +124,43 @@ def convert_from_serializable( # when the dict stays in memory or goes through pickle; JSON-like formats have already # turned them into lists. Both describe the same nested structure. if isinstance(value_type, (list, tuple)) and isinstance(value, (list, tuple)): + # A list inside a typed dict has one dtype per element (see the "dict" case below). + value_dtypes = ( + value_dtype + if isinstance(value_dtype, list) + else [value_dtype] * len(value) + ) return [ - self.convert_from_serializable(v, t, value_dtype) - for v, t in zip(value, value_type) + self.convert_from_serializable(v, t, d) + for v, t, d in zip(value, value_type, value_dtypes) ] + # Typed containers, written for dicts whose values or keys need restoring: {"dict": + # {key: type}} (plus "key_types" for non-string keys) with a dtype dict mirroring it, + # and {"tuple": [types]} for a tuple inside one. + if isinstance(value_type, dict): + if ( + isinstance(value_type.get("dict"), dict) + and set(value_type) <= {"dict", "key_types"} + and isinstance(value, dict) + ): + return self._deserialize_typed_dict( + value, + value_type["dict"], + value_dtype, + value_type.get("key_types"), + ) + if ( + len(value_type) == 1 + and isinstance(value_type.get("tuple"), list) + and isinstance(value, list) + ): + return tuple( + self.convert_from_serializable( + value, value_type["tuple"], value_dtype + ) + ) + if isinstance(value_type, str): for typ_name, handler in self._get_deserializer_handlers(): if typ_name == value_type: @@ -228,19 +281,63 @@ def _deserialize_function(self, data: Dict[str, str]) -> Callable: ) return obj + def _deserialize_typed_dict( + self, + value: Dict[str, Any], + value_types: Dict[str, Any], + value_dtypes: Any, + key_types: Optional[Dict[str, str]] = None, + ) -> Dict[Any, Any]: + """Restore each value of a dict from its own type (and dtype, for arrays), and each + key listed in `key_types` from its text. A key without a type falls back to + `_restore_dict_value`.""" + dtypes = value_dtypes if isinstance(value_dtypes, dict) else {} + key_types = key_types if isinstance(key_types, dict) else {} + return { + (self._text_to_key(k, key_types[k]) if k in key_types else k): ( + self.convert_from_serializable(v, value_types[k], dtypes.get(k)) + if k in value_types + else self._restore_dict_value(v) + ) + for k, v in value.items() + } + + def _text_to_key(self, text: str, key_type: str) -> Any: + """Inverse of `_key_to_text` for a key of type `key_type` (e.g. "int", "float64", + "bool", "NoneType"): the text is parsed, then restored with that type's handler. + """ + try: + if key_type == "NoneType" and text == "None": + return None + if key_type in ("bool", "bool_") and text in ("True", "False"): + return text == "True" + if key_type.startswith(("int", "uint")): + parsed: Any = int(text) + elif key_type.startswith("float"): + parsed = float(text) + else: + raise ValueError("unsupported key type") + except (AttributeError, TypeError, ValueError) as e: + raise DeserializationError( + f"Invalid dict key {text!r} of type {key_type!r} in the model file" + ) from e + return self.convert_from_serializable(parsed, key_type) + def _restore_dict_value(self, value: Any) -> Any: """Hook for restoring a value inside a plain string-keyed dict, whose values carry no type tags of their own, recognised by its exact saved shape: slices here (e.g. ColumnTransformer's output_indices_), more in mixins (see ScipySerializerMixin). - Anything else is returned unchanged.""" + Anything else is returned unchanged. Since format v4, dicts whose values need + restoring are typed per key instead; this is for files written before that.""" if isinstance(value, dict) and set(value) == _SLICE_KEYS: return self._deserialize_slice(value) return value def _deserialize_dict(self, value: Any) -> Any: - """Deserialize a dict, restoring non-string key types for the envelope produced by - convert_to_serializable's dict branch. Values of plain string-keyed dicts (the common - case, including dicts produced by older openmodels versions) go through + """Deserialize a dict tagged "dict". Files written before format v4 saved dicts with + non-string keys as an `__openmodels_dict__` keys/values envelope, rebuilt here with + its key types (newer files type such keys in the dict's type entry instead, see + `_deserialize_typed_dict`). Values of plain string-keyed dicts go through `_restore_dict_value`, which leaves them unchanged unless a mixin recognises them. """ if isinstance(value, dict) and value.get("__openmodels_dict__"): diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index d10577c..41c5390 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -56,6 +56,7 @@ from sklearn.tree._tree import Tree from sklearn.base import BaseEstimator, check_is_fitted from sklearn.exceptions import NotFittedError +from sklearn.utils import Bunch from sklearn.utils.discovery import all_estimators from sklearn.neighbors import BallTree, KDTree, KernelDensity from sklearn.discriminant_analysis import LinearDiscriminantAnalysis @@ -77,6 +78,7 @@ from openmodels.serializers.base import ( NumpySerializerMixin, ScipySerializerMixin, + _key_to_text, ) import warnings @@ -131,9 +133,29 @@ # v4: every estimator node (root and nested) records "estimator_package" (the class's top-level # package) next to "estimator_class", and classes are resolved by (package, class name), so # same-named classes from different packages coexist. Files without it (v1-v3) resolve by bare -# name, custom estimators winning. Type tags are unchanged, so v3 readers still load v4 files. +# name, custom estimators winning. Also in v4: a dict whose values need restoring (arrays, +# tuples, estimators, ...) is typed per key, as {"dict": {key: type}} ({"Bunch": ...} for a +# scikit-learn Bunch), with a matching {key: dtype} dtypes entry; a tuple inside it is typed +# {"tuple": [types]}, and a masked array (cv_results_'s param_* columns) "MaskedArray". Non-string +# keys (e.g. class_weight={0: 1.0}) are written as text, with their types in a "key_types" +# entry next to "dict"; v1-v3 files wrote such dicts as an `__openmodels_dict__` envelope, +# which is still read. Dicts of plain JSON values with string keys keep the "dict" tag. v3 +# readers still load v4 files but don't know these types: values load as their saved JSON, +# and non-string keys as strings. OPENMODELS_FORMAT_VERSION = 4 +# Type tags whose saved values every format gives back unchanged: a dict whose values all have +# one of these (or are lists of them) needs no per-key types and keeps the "dict" tag. +_PLAIN_TYPES = frozenset({"str", "int", "float", "bool", "NoneType"}) + + +def _is_plain_type(tag: Any) -> bool: + if isinstance(tag, str): + return tag in _PLAIN_TYPES + if isinstance(tag, list): + return all(_is_plain_type(t) for t in tag) + return False + def _package_of(cls: type) -> str: """Top-level package of a class (e.g. "sklearn", "chemotools", "__main__"). The full @@ -608,14 +630,18 @@ def all_estimators( if name not in NOT_SUPPORTED_ESTIMATORS ] - def _get_nested_types(self, item: Any) -> Any: + def _get_nested_types(self, item: Any, in_dict: bool = False) -> Any: """ - Recursively determine the type of elements within nested lists. + Recursively determine the type of elements within nested lists and dicts. Parameters ---------- item : Any The item to inspect for nested types. + in_dict : bool + Whether `item` sits inside a typed dict, where a tuple is typed {"tuple": [...]} + so it comes back as a tuple. Elsewhere a tuple is typed as a tuple of its + elements' types, which formats save as a list, as before v4. Returns ------- @@ -626,8 +652,17 @@ def _get_nested_types(self, item: Any) -> Any: --------- [1, [1, 2, [1, 2, 3]], 2] -> ['int',['int','int','ndarray'],'int'] + {"a": np.zeros(2), "b": (1, 2)} -> {"dict": {"a": "ndarray", "b": {"tuple": ["int", "int"]}}} + {"a": 1, "b": [1.0, 2.0]} -> "dict" """ + # Before the ndarray checks: a masked array is an ndarray subclass. + if isinstance(item, np.ma.MaskedArray): + return "MaskedArray" + + if isinstance(item, dict): + return self._get_dict_types(item) + # Handle np.ndarray of estimators if ( isinstance(item, np.ndarray) @@ -647,11 +682,13 @@ def _get_nested_types(self, item: Any) -> Any: # Handle tuples explicitly if isinstance(item, tuple): + if in_dict: + return {"tuple": [self._get_nested_types(s, True) for s in item]} return tuple(self._get_nested_types(subitem) for subitem in item) # Handle lists if isinstance(item, list): - return [self._get_nested_types(subitem) for subitem in item] + return [self._get_nested_types(subitem, in_dict) for subitem in item] elif isinstance(item, BaseEstimator): # For estimators, return their class name instead of just 'BaseEstimator' @@ -672,6 +709,50 @@ def _get_nested_types(self, item: Any) -> Any: # Return the type name if it's not a list or it's an empty list return type(item).__name__ + def _get_dict_types(self, item: dict) -> Any: + """ + Type of a dict: {"dict": {key: type}} when a value needs restoring on load, plus + {"key_types": {key: type}} for its non-string keys, which are saved as text (see + `_key_to_text`); "dict" when every key is a string and every value plain JSON. A Bunch + is always typed {"Bunch": {...}}, so it comes back as a Bunch. + """ + value_types = { + _key_to_text(key): self._get_nested_types(value, in_dict=True) + for key, value in item.items() + } + key_types = { + _key_to_text(key): self._get_nested_types(key) + for key in item + if not isinstance(key, str) + } + if isinstance(item, Bunch): + return {"Bunch": value_types} + if key_types: + return {"dict": value_types, "key_types": key_types} + if all(_is_plain_type(t) for t in value_types.values()): + return "dict" + return {"dict": value_types} + + def _get_nested_dtypes(self, item: Any) -> Any: + """dtypes for the values of a typed dict, mirroring it: an array's dtype, a dict of + them for a nested dict, a list of them for a list. None when nothing inside needs one + (a masked array records its own).""" + if isinstance(item, np.ma.MaskedArray): + return None + if isinstance(item, np.ndarray): + return str(item.dtype) + if isinstance(item, dict): + dtypes = { + _key_to_text(key): dtype + for key, value in item.items() + if (dtype := self._get_nested_dtypes(value)) is not None + } + return dtypes or None + if isinstance(item, (list, tuple)): + dtypes_list = [self._get_nested_dtypes(value) for value in item] + return dtypes_list if any(d is not None for d in dtypes_list) else None + return None + def _get_type_maps(self, values_dict: dict) -> tuple[dict, dict]: """ Given a dict of raw values (e.g. model attributes or params), @@ -692,6 +773,13 @@ def _get_type_maps(self, values_dict: dict) -> tuple[dict, dict]: if isinstance(value, tuple): dtypes_map.pop(key, None) # Remove tuples from dtypes_map + # A typed dict's dtypes mirror its values (see _get_nested_dtypes). + for key, value in values_dict.items(): + if isinstance(types_map[key], dict): + dict_dtypes = self._get_nested_dtypes(value) + if dict_dtypes is not None: + dtypes_map[key] = dict_dtypes + return types_map, dtypes_map def _extract_estimator_attributes(self, estimator: BaseEstimator) -> Dict[str, Any]: @@ -765,6 +853,17 @@ def convert_from_serializable( and value_type != "dict" ): return self._deserialize_kernel(value) + # A Bunch (e.g. Voting*/Stacking*'s named_estimators_) is typed like a dict, under + # its own name (see _get_dict_types). + if ( + isinstance(value_type, dict) + and len(value_type) == 1 + and isinstance(value_type.get("Bunch"), dict) + and isinstance(value, dict) + ): + return Bunch( + **self._deserialize_typed_dict(value, value_type["Bunch"], value_dtype) + ) return super().convert_from_serializable(value, value_type, value_dtype) # --- Handlers --- @@ -777,6 +876,8 @@ def _get_serializer_handlers(self): (Kernel, self._serialize_kernel), (Tree, self._serialize_tree), (TreePredictor, self._serialize_tree_predictor), + # Before np.ndarray: a masked array is an ndarray subclass. + (np.ma.MaskedArray, self._serialize_masked_array), (np.ndarray, self._serialize_estimators_collection), (_CalibratedClassifier, self._serialize_calibrated_classifier), (_BisectingTree, self._serialize_bisecting_tree), @@ -805,6 +906,7 @@ def _get_deserializer_handlers(self): ("_CurveScorer", self._deserialize_curve_scorer), ("_CFNode", self._deserialize_cfnode), ("make_column_selector", self._deserialize_column_selector), + ("MaskedArray", self._deserialize_masked_array), ] + loss_handlers + estimator_handlers @@ -844,6 +946,47 @@ def _deserialize_bisecting_tree(self, data: dict) -> _BisectingTree: node.right = self._deserialize_bisecting_tree(data["right"]) return node + def _serialize_masked_array(self, value: np.ma.MaskedArray) -> Dict[str, Any]: + """ + A masked array (a search's cv_results_["param_*"] columns): data, mask, shape and + dtype. Masked slots are written as the array's fill value, or None for object arrays, + whose elements (strings, None, dicts, estimators, ...) are typed one by one. + """ + mask = np.ma.getmaskarray(value).ravel() + serialized: Dict[str, Any] = { + "mask": mask.tolist(), + "shape": list(value.shape), + "dtype": str(value.dtype), + } + if value.dtype == np.dtype("O"): + items = [ + None if masked else item + for item, masked in zip(value.data.ravel().tolist(), mask) + ] + serialized["data"] = self.convert_to_serializable(items) + serialized["types"] = [ + self._get_nested_types(item, in_dict=True) for item in items + ] + else: + serialized["data"] = self.convert_to_serializable(value.filled().ravel()) + return serialized + + def _deserialize_masked_array(self, data: Dict[str, Any]) -> np.ma.MaskedArray: + shape = tuple(data["shape"]) + mask = np.array(data["mask"], dtype=bool).reshape(shape) + if "types" in data: + items = [ + self.convert_from_serializable(item, item_type) + for item, item_type in zip(data["data"], data["types"]) + ] + # Filled one by one: np.array would turn list or tuple elements into dimensions. + values = np.empty(len(items), dtype=object) + for i, item in enumerate(items): + values[i] = item + else: + values = np.array(data["data"], dtype=np.dtype(data["dtype"])) + return np.ma.MaskedArray(values.reshape(shape), mask=mask) + def _serialize_calibrated_classifier( self, obj: _CalibratedClassifier ) -> Dict[str, Any]: diff --git a/test/test_dict_values.py b/test/test_dict_values.py new file mode 100644 index 0000000..3198f36 --- /dev/null +++ b/test/test_dict_values.py @@ -0,0 +1,240 @@ +""" +Values inside dicts (FunctionTransformer's kw_args, a search's cv_results_, Voting*/Stacking*'s +named_estimators_, ...): formats save them as plain JSON, so arrays, tuples, estimators and the +like are typed per key ({"dict": {key: type}}) and restored on load. Non-string keys (e.g. +class_weight={0: 1.0}) are saved as text, with their types in "key_types". Dicts of plain JSON +values with string keys keep the "dict" tag, and files without per-key types still load as +before. +""" + +import json + +import numpy as np +import pytest +from sklearn.base import clone +from sklearn.ensemble import StackingClassifier, VotingClassifier +from sklearn.linear_model import LogisticRegression +from sklearn.model_selection import GridSearchCV, RandomizedSearchCV +from sklearn.preprocessing import FunctionTransformer, StandardScaler +from sklearn.svm import SVC +from sklearn.utils import Bunch + +from openmodels.core import SerializationManager +from openmodels.exceptions import SerializationError +from openmodels.serializers.sklearn.sklearn_serializer import SklearnSerializer + +FORMATS = ["json", "pickle"] + +rng = np.random.RandomState(0) +X = rng.rand(40, 3) +y = np.arange(40) % 2 + + +def _roundtrip(model, format_name="json"): + manager = SerializationManager(SklearnSerializer()) + return manager.deserialize(manager.serialize(model, format_name), format_name) + + +def _assert_same(restored, original, path="value"): + """Same value and same types all the way down (a list is not a tuple, a list is not an + array, a float64 array is not a float32 one).""" + assert type(restored) is type( + original + ), f"{path}: {type(restored)} != {type(original)}" + if isinstance(original, np.ma.MaskedArray): + assert restored.dtype == original.dtype, path + np.testing.assert_array_equal(restored.mask, original.mask) + for i in np.flatnonzero(~np.ma.getmaskarray(original)): + _assert_same(restored.data[i], original.data[i], f"{path}[{i}]") + elif isinstance(original, np.ndarray): + assert restored.dtype == original.dtype, path + np.testing.assert_array_equal(restored, original) + elif isinstance(original, dict): + assert list(restored) == list(original), path + assert [type(k) for k in restored] == [type(k) for k in original], path + for key in original: + _assert_same(restored[key], original[key], f"{path}[{key!r}]") + elif isinstance(original, (list, tuple)): + assert len(restored) == len(original), path + for i, (r, o) in enumerate(zip(restored, original)): + _assert_same(r, o, f"{path}[{i}]") + elif hasattr(original, "get_params"): + assert restored.get_params() == original.get_params(), path + else: + assert restored == original, path + + +KW_ARGS = { + "array": np.array([1.0, 2.0], dtype=np.float32), + "int_array": np.arange(3, dtype=np.int16), + "pair": (1, 2), + "pairs": [(1, "a"), (2, "b")], + "nested": {"inner": np.zeros((2, 2)), "plain": [1, 2]}, + "cols": slice(1, 3), + "scalar": np.float64(0.5), + "int_keys": {0: 1.0, 1: 3.0}, + "plain": "x", + "none": None, +} + + +@pytest.mark.parametrize("format_name", FORMATS) +def test_dict_values_restored(format_name): + loaded = _roundtrip(FunctionTransformer(kw_args=KW_ARGS), format_name) + _assert_same(loaded.kw_args, KW_ARGS, "kw_args") + + +@pytest.mark.parametrize("format_name", FORMATS) +def test_estimator_inside_dict_restored(format_name): + kw_args = {"scaler": StandardScaler(with_mean=False)} + loaded = _roundtrip(FunctionTransformer(kw_args=kw_args), format_name) + _assert_same(loaded.kw_args, kw_args, "kw_args") + + +def test_typed_dict_format(): + """How a dict that needs restoring is written: data unchanged, per-key types next to it, + and dtypes for its arrays.""" + model = FunctionTransformer( + kw_args={"x": np.array([1.0, 2.0], dtype=np.float32), "pair": (1, 2), "n": 3} + ) + data = json.loads(json.dumps(SklearnSerializer().serialize(model))) + assert data["params"]["kw_args"] == {"x": [1.0, 2.0], "pair": [1, 2], "n": 3} + assert data["param_types"]["kw_args"] == { + "dict": {"x": "ndarray", "pair": {"tuple": ["int", "int"]}, "n": "int"} + } + assert data["param_dtypes"]["kw_args"] == {"x": "float32"} + + +def test_plain_dict_keeps_dict_tag(): + kw_args = {"a": 1, "b": [1.0, 2.0], "c": None, "d": "x", "e": True} + data = SklearnSerializer().serialize(FunctionTransformer(kw_args=kw_args)) + assert data["param_types"]["kw_args"] == "dict" + assert "kw_args" not in data["param_dtypes"] + assert _roundtrip(FunctionTransformer(kw_args=kw_args)).kw_args == kw_args + + +def test_files_without_dict_types_still_load(): + """Files written before dicts were typed per key tag them "dict": values load as their + saved JSON, except slices, which are recognised by shape.""" + data = SklearnSerializer().serialize( + FunctionTransformer(kw_args={"cols": slice(1, 3), "x": np.zeros(2)}) + ) + data["param_types"]["kw_args"] = "dict" + data["param_dtypes"].pop("kw_args", None) + loaded = SerializationManager(SklearnSerializer()).deserialize(json.dumps(data)) + assert loaded.kw_args == {"cols": slice(1, 3), "x": [0.0, 0.0]} + + +@pytest.mark.parametrize("format_name", FORMATS) +def test_class_weight_keys_restored_and_refittable(format_name): + model = LogisticRegression(class_weight={0: 1.0, 1: 3.0}).fit(X, y) + loaded = _roundtrip(model, format_name) + _assert_same(loaded.class_weight, model.class_weight, "class_weight") + loaded.fit(X, y) + clone(loaded).fit(X, y) + + +def test_non_string_keys_format(): + """Non-string keys are written as text, with their types next to the value types; no + keys/values envelope.""" + model = LogisticRegression(class_weight={0: 1.0, 1: 3.0}) + data = SklearnSerializer().serialize(model) + assert data["params"]["class_weight"] == {"0": 1.0, "1": 3.0} + assert data["param_types"]["class_weight"] == { + "dict": {"0": "float", "1": "float"}, + "key_types": {"0": "int", "1": "int"}, + } + assert "__openmodels_dict__" not in json.dumps(data) + + +MIXED_KEYS = { + 0: np.array([1.0, 2.0], dtype=np.float32), + 1.5: (1, 2), + True: "t", + None: {3: "x"}, + "a": StandardScaler(), +} + + +@pytest.mark.parametrize("format_name", FORMATS) +def test_mixed_keys_restored(format_name): + loaded = _roundtrip(FunctionTransformer(kw_args=MIXED_KEYS), format_name) + _assert_same(loaded.kw_args, MIXED_KEYS, "kw_args") + + +def test_numpy_keys_restored(): + """NumPy scalar keys (e.g. class labels from np.unique) load with equal values.""" + kw_args = {np.int64(7): 1, np.float32(0.5): 2} + loaded = _roundtrip(FunctionTransformer(kw_args=kw_args)) + assert loaded.kw_args == {7: 1, 0.5: 2} + + +@pytest.mark.parametrize( + "kw_args", + [{1: "a", "1": "b"}, {(1, 2): "a"}], + ids=["keys-with-same-text", "tuple-key"], +) +def test_unsupported_keys_refused_at_save(kw_args): + manager = SerializationManager(SklearnSerializer()) + with pytest.raises(SerializationError): + manager.serialize(FunctionTransformer(kw_args=kw_args)) + + +def test_files_with_envelope_dicts_still_load(): + """Files written before format v4 saved dicts with non-string keys as a keys/values + envelope tagged "dict".""" + data = SklearnSerializer().serialize(LogisticRegression()) + data["params"]["class_weight"] = { + "__openmodels_dict__": True, + "keys": [0, 1], + "key_types": ["int", "int"], + "values": [1.0, 3.0], + } + data["param_types"]["class_weight"] = "dict" + loaded = SerializationManager(SklearnSerializer()).deserialize(json.dumps(data)) + _assert_same(loaded.class_weight, {0: 1.0, 1: 3.0}, "class_weight") + + +SEARCHES = { + # param_* columns are masked arrays: float, string and object (None/dict) ones. + "grid": GridSearchCV( + SVC(), + [ + {"C": [0.1, 1.0], "kernel": ["rbf"]}, + {"kernel": ["linear"], "class_weight": [None, {0: 1, 1: 2}]}, + ], + cv=2, + ), + "random": RandomizedSearchCV( + LogisticRegression(), + {"C": np.array([0.1, 1.0, 10.0])}, + n_iter=2, + cv=2, + random_state=0, + ), +} + + +@pytest.mark.parametrize("format_name", FORMATS) +@pytest.mark.parametrize("name", SEARCHES) +def test_cv_results_restored(name, format_name): + search = clone(SEARCHES[name]).fit(X, y) + loaded = _roundtrip(search, format_name) + _assert_same(loaded.cv_results_, search.cv_results_, "cv_results_") + _assert_same(loaded.best_params_, search.best_params_, "best_params_") + + +@pytest.mark.parametrize("format_name", FORMATS) +@pytest.mark.parametrize("cls", [VotingClassifier, StackingClassifier]) +def test_named_estimators_restored(cls, format_name): + model = cls([("lr", LogisticRegression()), ("svc", SVC())]).fit(X, y) + loaded = _roundtrip(model, format_name) + assert isinstance(loaded.named_estimators_, Bunch) + _assert_same( + dict(loaded.named_estimators_), + dict(model.named_estimators_), + "named_estimators_", + ) + np.testing.assert_array_equal( + loaded.named_estimators_.lr.predict(X), model.named_estimators_.lr.predict(X) + ) diff --git a/test/test_serializer_base.py b/test/test_serializer_base.py index 1550be1..933944c 100644 --- a/test/test_serializer_base.py +++ b/test/test_serializer_base.py @@ -9,10 +9,16 @@ def test_str_keyed_dict_wire_shape_unchanged(): def test_non_string_keyed_dict_round_trips(): + """Non-string keys are written as text; their types come from the dict's type entry.""" mixin = SerializerMixin() value = {1: "one", 2: "two", 3: "three"} serialized = mixin.convert_to_serializable(value) - restored = mixin.convert_from_serializable(serialized, "dict") + assert serialized == {"1": "one", "2": "two", "3": "three"} + value_type = { + "dict": {"1": "str", "2": "str", "3": "str"}, + "key_types": {"1": "int", "2": "int", "3": "int"}, + } + restored = mixin.convert_from_serializable(serialized, value_type) assert restored == value assert {type(k) for k in restored} == {int} @@ -21,6 +27,24 @@ def test_mixed_key_type_dict_round_trips(): mixin = SerializerMixin() value = {1: "a", "x": "b", 2.5: "c"} serialized = mixin.convert_to_serializable(value) - restored = mixin.convert_from_serializable(serialized, "dict") + value_type = { + "dict": {"1": "str", "x": "str", "2.5": "str"}, + "key_types": {"1": "int", "2.5": "float"}, + } + restored = mixin.convert_from_serializable(serialized, value_type) assert restored == value assert {type(k) for k in restored} == {int, str, float} + + +def test_envelope_dict_from_older_files_round_trips(): + """Files written before format v4 saved non-string-keyed dicts as an envelope.""" + mixin = SerializerMixin() + envelope = { + "__openmodels_dict__": True, + "keys": [1, "x", 2.5], + "key_types": ["int", "str", "float"], + "values": ["a", "b", "c"], + } + restored = mixin.convert_from_serializable(envelope, "dict") + assert restored == {1: "a", "x": "b", 2.5: "c"} + assert {type(k) for k in restored} == {int, str, float} From 87721f80abfd2c519bae5a9f7cdeb6bab8b21d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 16:05:50 +0200 Subject: [PATCH 26/35] fix(serializer): load NumPy scalar values as their NumPy type --- CHANGELOG.md | 5 + openmodels/serializers/base.py | 26 ++++-- .../serializers/sklearn/sklearn_serializer.py | 3 + test/test_scalar_values.py | 93 +++++++++++++++++++ 4 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 test/test_scalar_values.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ba0ee..3561913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,6 +149,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 values are now typed per key and restored exactly, including in dicts with non-string keys; re-save older files. Tuple dict keys, which saved but failed on load, now raise `SerializationError` at save; so does a dict holding keys with the same text (`1` and `"1"`) +- NumPy scalar values (e.g. `Ridge(alpha=np.float32(0.5))`, or an `np.int64` class label) + loaded as Python `float`/`int`; only `np.float64` kept its type. Every NumPy integer and float + scalar type now loads as itself, for files written by earlier versions too. `np.bool_` values + are now tagged `"bool_"` (NumPy 2 names the type `"bool"`, like Python's), so they load as + `np.bool_`; earlier readers load them as Python `bool`, as before ### Added diff --git a/openmodels/serializers/base.py b/openmodels/serializers/base.py index 02f8612..d608adf 100644 --- a/openmodels/serializers/base.py +++ b/openmodels/serializers/base.py @@ -61,6 +61,22 @@ def _find_function_module(func: Any) -> Optional[str]: for t in (int, float, bool, str, bytes, complex, tuple, list, dict, object) } +# NumPy's integer and float scalar types by name (int8 ... uint64, float16 ... float64, ...), +# for scalar values, which are tagged with `type(value).__name__` and written as their +# `.item()`. Some names depend on the platform (np.longlong is "int64" on Windows and +# "longlong" on Linux), so the C names are added for files written elsewhere. np.bool_ values +# are tagged "bool_" (see SklearnSerializer._get_nested_types), as NumPy 2 names it "bool". +_NUMPY_SCALAR_TYPES: Dict[str, type] = { + t.__name__: t + for t in ( + np.dtype(code).type + for code in np.typecodes["AllInteger"] + np.typecodes["Float"] + ) +} +for _name in ("intc", "uintc", "longlong", "ulonglong", "longdouble"): + _NUMPY_SCALAR_TYPES.setdefault(_name, getattr(np, _name)) +_NUMPY_SCALAR_TYPES["bool_"] = np.bool_ + def _key_to_text(key: Any) -> str: """ @@ -310,9 +326,9 @@ def _text_to_key(self, text: str, key_type: str) -> Any: if key_type == "NoneType" and text == "None": return None if key_type in ("bool", "bool_") and text in ("True", "False"): - return text == "True" - if key_type.startswith(("int", "uint")): - parsed: Any = int(text) + parsed: Any = text == "True" + elif key_type.startswith(("int", "uint")): + parsed = int(text) elif key_type.startswith("float"): parsed = float(text) else: @@ -477,9 +493,7 @@ def _get_deserializer_handlers(self): return [ ("ndarray", self._deserialize_ndarray), ("generic", lambda v: np.array(v).item()), - ("float64", np.float64), - ("int32", int), - ("int64", int), + *_NUMPY_SCALAR_TYPES.items(), ("dtype", np.dtype), ("Float64DType", np.dtype), ("RandomState", self._deserialize_randomstate), diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index 41c5390..3fad9c4 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -705,6 +705,9 @@ def _get_nested_types(self, item: Any, in_dict: bool = False) -> Any: # via the csr_matrix(value) constructor, so type(item).__name__ (e.g. "csr_array") # would tag a value the deserializer dispatch table has no matching entry for. return "csr_matrix" + elif isinstance(item, np.bool_): + # NumPy 2 names the type "bool", the tag of Python's bool; NumPy 1 named it "bool_". + return "bool_" else: # Return the type name if it's not a list or it's an empty list return type(item).__name__ diff --git a/test/test_scalar_values.py b/test/test_scalar_values.py new file mode 100644 index 0000000..a2ebf44 --- /dev/null +++ b/test/test_scalar_values.py @@ -0,0 +1,93 @@ +""" +NumPy scalar values (e.g. `Ridge(alpha=np.float32(0.5))`) must load as the same NumPy type, not +as a Python scalar. They are written as their `.item()` and tagged with their type's name. +""" + +import json + +import numpy as np +import pytest +from sklearn.linear_model import Ridge +from sklearn.preprocessing import FunctionTransformer + +from openmodels import SerializationManager, SklearnSerializer + +X = np.random.RandomState(0).rand(20, 3) +Y = X @ np.array([1.0, -2.0, 0.5]) + +SCALARS = [ + np.int8(-3), + np.int16(-300), + np.int32(-70000), + np.int64(-(2**40)), + np.uint8(200), + np.uint16(60000), + np.uint32(4_000_000_000), + np.uint64(2**64 - 1), + np.float16(0.1), + np.float32(0.1), + np.float64(0.1), + np.bool_(True), + np.bool_(False), +] + + +def _roundtrip(model, format_name="json"): + manager = SerializationManager(SklearnSerializer()) + return manager.deserialize( + manager.serialize(model, format_name=format_name), format_name=format_name + ) + + +def _value_roundtrip(value): + serializer = SklearnSerializer() + written = json.loads(json.dumps(serializer.convert_to_serializable(value))) + return serializer.convert_from_serializable( + written, serializer._get_nested_types(value) + ) + + +@pytest.mark.parametrize("value", SCALARS, ids=lambda v: f"{type(v).__name__}-{v}") +def test_scalar_roundtrips_to_same_type_and_value(value): + loaded = _value_roundtrip(value) + assert type(loaded) is type(value) + assert loaded == value + + +@pytest.mark.parametrize("format_name", ["json", "pickle"]) +def test_param_keeps_numpy_type(format_name): + model = Ridge(alpha=np.float32(0.5)).fit(X, Y) + loaded = _roundtrip(model, format_name) + assert type(loaded.alpha) is np.float32 + assert loaded.alpha == model.alpha + np.testing.assert_array_equal(loaded.predict(X), model.predict(X)) + + +def test_scalars_inside_dict_param_keep_numpy_type(): + kw_args = {"a": np.float32(0.5), "b": np.int64(3), "c": np.bool_(True)} + loaded = _roundtrip(FunctionTransformer(kw_args=kw_args)) + for key, value in kw_args.items(): + assert type(loaded.kw_args[key]) is type(value) + assert loaded.kw_args[key] == value + + +def test_numpy_scalar_dict_keys_keep_numpy_type(): + class_weight = {np.int64(0): 1.0, np.int64(1): 3.0} + loaded = _roundtrip(FunctionTransformer(kw_args=class_weight)) + assert [type(k) for k in loaded.kw_args] == [np.int64, np.int64] + assert loaded.kw_args == class_weight + + +@pytest.mark.parametrize( + "tag, expected", + [ + ("int64", np.int64), + ("float32", np.float32), + ("bool_", np.bool_), + ("longlong", np.longlong), + ], +) +def test_tags_written_elsewhere_load_as_numpy_type(tag, expected): + # "bool_" is NumPy 1's name for np.bool_; "longlong" is how Linux names np.longlong. + loaded = SklearnSerializer().convert_from_serializable(1, tag) + assert type(loaded) is expected From 9d4e7b85e36e046b70324d86ce0dae4fef33fdf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 17:34:43 +0200 Subject: [PATCH 27/35] fix(serializer): keep each array's dtype in lists of arrays --- CHANGELOG.md | 5 ++ docs/format.md | 18 +++++++ openmodels/serializers/base.py | 34 ++++++++++++- test/test_array_list_dtypes.py | 91 ++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 test/test_array_list_dtypes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3561913..eba45f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 scalar type now loads as itself, for files written by earlier versions too. `np.bool_` values are now tagged `"bool_"` (NumPy 2 names the type `"bool"`, like Python's), so they load as `np.bool_`; earlier readers load them as Python `bool`, as before +- Lists of arrays lost their dtypes: a `float32` `MLPRegressor`/`MLPClassifier` loaded with + `float64` `coefs_`/`intercepts_` and predicted `float64`. Such lists now record one dtype per + array, but only when the values alone would rebuild an array with another dtype, since + openmodels 0.2.2 can't read that list: it still reads every file it read correctly before. + Re-save older files ### Added diff --git a/docs/format.md b/docs/format.md index b5a9f51..7b91a55 100644 --- a/docs/format.md +++ b/docs/format.md @@ -176,6 +176,24 @@ written: `serialize()` raises `SerializationError`. Files written before v4 save non-string keys as an `{"__openmodels_dict__": true, "keys", "key_types", "values"}` envelope tagged `"dict"`; it is still read. +## Lists of arrays + +A list of arrays (e.g. an MLP's `coefs_`) is typed `["ndarray", ...]`. Its dtype entry is +normally `""`, and each array is rebuilt from its values (floats as `float64`, ints as the +default int). When that would give an array another dtype - a `float32` MLP, `uint8` or `int16` +arrays - the dtype entry lists one dtype per element instead (`null` for an element that isn't +an array): + +```json +"attribute_types": {"coefs_": ["ndarray", "ndarray"]}, +"attribute_dtypes": {"coefs_": ["float32", "float32"]} +``` + +openmodels 0.2.2 can't read a dtype list, so it's only written when needed: 0.2.2 still reads +every list of arrays it read correctly before, and fails on the ones it would have widened. +Arrays of strings or objects inside a list are rebuilt from their values, as before (a list of +`object` arrays of strings loads as string arrays). + A meta-estimator that holds other estimators - a `Pipeline` step, a `VotingClassifier`'s `estimators`, a `ColumnTransformer`'s `transformers` - doesn't get a special graph representation. Wherever a `BaseEstimator` value appears (inside `params` or `attributes`), it's diff --git a/openmodels/serializers/base.py b/openmodels/serializers/base.py index d608adf..5d38d63 100644 --- a/openmodels/serializers/base.py +++ b/openmodels/serializers/base.py @@ -397,21 +397,51 @@ def _get_deserializer_handlers(self): class NumpySerializerMixin(SerializerMixin): # --- Helpers --- - def _get_dtype(self, value: Any) -> str: + def _get_dtype(self, value: Any) -> Any: """ - Get the dtype of a numpy array, otherwise return empty string. + Get the dtype of a numpy array, otherwise return empty string. A list of arrays gets + one dtype per element (see `_get_element_dtypes`). """ if isinstance(value, np.ndarray): return str(value.dtype) # Get the actual numpy dtype elif isinstance(value, (list, tuple)) and value: # If it's a list/tuple that will become an ndarray, check its elements first_elem = value[0] + if isinstance(first_elem, np.ndarray): + return self._get_element_dtypes(value) if isinstance(first_elem, (int, np.integer)): return "int32" # Use int32 for integer lists elif isinstance(first_elem, (float, np.floating)): return "float64" # Use float64 for float lists return "" + def _get_element_dtypes(self, value: Any) -> Any: + """ + One dtype per element of a list of arrays (e.g. MLP's `coefs_`), None for non-array + elements, so a float32 model isn't widened to float64 on load. Written only when an + array's values alone would rebuild it with another dtype (float32, int8, uint*, ...); + otherwise "", as before, because openmodels 0.2.2 can't read a dtype list. Arrays of + other kinds (object, strings) are rebuilt from their values, as before. + """ + + def inferred(array: np.ndarray) -> np.dtype: + # What np.array() makes of the array's values: float64 for floats, the default + # int for ints, bool for bools. + return ( + np.array(array.ravel()[:1].tolist()).dtype + if array.size + else np.dtype(float) + ) + + if not any( + isinstance(v, np.ndarray) + and v.dtype.kind in "biuf" + and v.dtype != inferred(v) + for v in value + ): + return "" + return [str(v.dtype) if isinstance(v, np.ndarray) else None for v in value] + # --- NumPy specific serializers/deserializers --- def _serialize_ndarray(self, value: np.ndarray): return self.convert_to_serializable(value.tolist()) diff --git a/test/test_array_list_dtypes.py b/test/test_array_list_dtypes.py new file mode 100644 index 0000000..fa849b8 --- /dev/null +++ b/test/test_array_list_dtypes.py @@ -0,0 +1,91 @@ +""" +Lists of arrays (e.g. MLP's `coefs_`) must keep each array's dtype. They used to be rebuilt from +their values alone, so a float32 MLP came back float64. The dtype list is only written when +that inference would be wrong, because openmodels 0.2.2 can't read one. +""" + +import json + +import numpy as np +import pytest +from sklearn.neural_network import MLPClassifier, MLPRegressor + +from openmodels import SerializationManager, SklearnSerializer + +rng = np.random.RandomState(0) +X = rng.rand(40, 3).astype(np.float32) +Y_REG = (X @ np.array([1.0, -2.0, 0.5])).astype(np.float32) +Y_CLF = (X[:, 0] > 0.5).astype(int) + + +def _roundtrip(model, format_name="json"): + manager = SerializationManager(SklearnSerializer()) + return manager.deserialize( + manager.serialize(model, format_name=format_name), format_name=format_name + ) + + +def _attribute_dtypes(model): + return SklearnSerializer().serialize(model)["attribute_dtypes"] + + +@pytest.mark.parametrize("format_name", ["json", "pickle"]) +@pytest.mark.parametrize( + "model, y", + [(MLPRegressor(max_iter=5), Y_REG), (MLPClassifier(max_iter=5), Y_CLF)], + ids=["MLPRegressor", "MLPClassifier"], +) +def test_float32_mlp_keeps_float32(model, y, format_name): + model.fit(X, y) + loaded = _roundtrip(model, format_name) + for name in ("coefs_", "intercepts_"): + assert [a.dtype for a in getattr(loaded, name)] == [ + a.dtype for a in getattr(model, name) + ] + for original, restored in zip(getattr(model, name), getattr(loaded, name)): + np.testing.assert_array_equal(restored, original) + method = "predict_proba" if hasattr(model, "predict_proba") else "predict" + expected = getattr(model, method)(X) + result = getattr(loaded, method)(X) + assert result.dtype == expected.dtype == np.float32 + np.testing.assert_array_equal(result, expected) + + +def test_float64_mlp_writes_no_dtype_list(): + # What 0.2.2 can read: lists of arrays their values rebuild exactly get no dtype list. + model = MLPRegressor(max_iter=5).fit(X.astype(np.float64), Y_REG.astype(np.float64)) + dtypes = _attribute_dtypes(model) + assert dtypes["coefs_"] == "" + assert dtypes["intercepts_"] == "" + + +@pytest.mark.parametrize( + "arrays", + [ + [np.zeros(2, np.float32), np.ones(3, np.float64)], + [np.arange(3, dtype=np.uint8), np.arange(2, dtype=np.int64)], + [np.arange(3, dtype=np.int16), np.array([True, False])], + [np.zeros(0, np.int64), np.zeros(2, np.float32)], + ], + ids=["float32-float64", "uint8-int64", "int16-bool", "empty-int64"], +) +def test_mixed_list_keeps_every_dtype(arrays): + serializer = SklearnSerializer() + written = json.loads(json.dumps(serializer.convert_to_serializable(arrays))) + loaded = serializer.convert_from_serializable( + written, serializer._get_nested_types(arrays), serializer._get_dtype(arrays) + ) + assert [a.dtype for a in loaded] == [a.dtype for a in arrays] + for original, restored in zip(arrays, loaded): + np.testing.assert_array_equal(restored, original) + + +def test_file_without_dtype_list_loads_as_before(): + # Files written before the dtype list existed: arrays are rebuilt from their values. + model = MLPRegressor(max_iter=5).fit(X, Y_REG) + data = SklearnSerializer().serialize(model) + data["attribute_dtypes"]["coefs_"] = "" + data["attribute_dtypes"]["intercepts_"] = "" + loaded = SklearnSerializer().deserialize(data) + assert [a.dtype for a in loaded.coefs_] == [np.float64, np.float64] + np.testing.assert_allclose(loaded.predict(X), model.predict(X), rtol=1e-5) From 4f0a611fa1ec03254dec8794a6140f91ad487315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Wed, 7 Oct 2026 17:46:39 +0200 Subject: [PATCH 28/35] fix(serializer): load module-level bound methods and refuse methods of objects at save --- CHANGELOG.md | 7 ++++ docs/format.md | 66 +--------------------------------- openmodels/serializers/base.py | 19 ++++++++-- test/test_function_params.py | 48 +++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eba45f6..37af45b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,6 +159,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 array, but only when the values alone would rebuild an array with another dtype, since openmodels 0.2.2 can't read that list: it still reads every file it read correctly before. Re-save older files +- Bound methods passed as parameters (e.g. `FunctionTransformer(func=np.random.rand)`) loaded + as raw dicts, so the model failed only when called. Methods a module exposes by name, such as + `np.random.rand`, now load as themselves (the function allowlist applies). Methods of an + object (a fitted estimator's `fit_transform`, your own instance's or class's methods, a + `RandomState` you created) lose that object when saved by name, so `serialize()` now raises + `SerializationError` for them; files written by earlier versions holding one raise + `DeserializationError` on load ### Added diff --git a/docs/format.md b/docs/format.md index 7b91a55..6fe9ab6 100644 --- a/docs/format.md +++ b/docs/format.md @@ -137,63 +137,6 @@ A model whose classes come from a registered third-party package lists that pack ## Nested and composite estimators -## Values inside dicts - -A dict param or attribute (`FunctionTransformer(kw_args=...)`, a search's `cv_results_`, -`Voting*`'s `named_estimators_`, `class_weight`, ...) is saved as a plain JSON object. When every -key is a string and every value is plain JSON (`str`, `int`, `float`, `bool`, `None`, or lists -of them), its type is just `"dict"`. Otherwise the type is `{"dict": {key: type}}`, using the same tags as everywhere else, -and the dtype entry mirrors the dict with the dtypes of its arrays: - -```json -"params": {"kw_args": {"x": [1.0, 2.0], "pair": [1, 2], "n": 3}}, -"param_types": {"kw_args": {"dict": {"x": "ndarray", "pair": {"tuple": ["int", "int"]}, "n": "int"}}}, -"param_dtypes": {"kw_args": {"x": "float32"}} -``` - -Inside such a dict: - -- a tuple is typed `{"tuple": [types]}` (a list of types means a list); -- a nested dict is typed the same way, recursively, with a nested dtype dict; a list's dtype - entry is a list with one entry per element; -- a scikit-learn `Bunch` is typed `{"Bunch": {key: type}}`, and always is, so it loads as a - `Bunch`; -- a masked array (`cv_results_["param_*"]`) is typed `"MaskedArray"` and saved as - `{"data", "mask", "shape", "dtype"}`, plus `"types"` (one per element) for object arrays, - whose masked slots are written as `null`. - -Non-string keys (`int`, `float`, `bool`, `None`, NumPy scalars) are written as text, as JSON -requires, and their types go in a `"key_types"` entry next to `"dict"` (string keys aren't -listed). For `class_weight={0: 1.0, 1: 3.0}`: - -```json -"params": {"class_weight": {"0": 1.0, "1": 3.0}}, -"param_types": {"class_weight": {"dict": {"0": "float", "1": "float"}, "key_types": {"0": "int", "1": "int"}}} -``` - -Other keys (e.g. tuples), and keys whose text is the same (`1` and `"1"` in one dict), can't be -written: `serialize()` raises `SerializationError`. Files written before v4 saved dicts with -non-string keys as an `{"__openmodels_dict__": true, "keys", "key_types", "values"}` envelope -tagged `"dict"`; it is still read. - -## Lists of arrays - -A list of arrays (e.g. an MLP's `coefs_`) is typed `["ndarray", ...]`. Its dtype entry is -normally `""`, and each array is rebuilt from its values (floats as `float64`, ints as the -default int). When that would give an array another dtype - a `float32` MLP, `uint8` or `int16` -arrays - the dtype entry lists one dtype per element instead (`null` for an element that isn't -an array): - -```json -"attribute_types": {"coefs_": ["ndarray", "ndarray"]}, -"attribute_dtypes": {"coefs_": ["float32", "float32"]} -``` - -openmodels 0.2.2 can't read a dtype list, so it's only written when needed: 0.2.2 still reads -every list of arrays it read correctly before, and fails on the ones it would have widened. -Arrays of strings or objects inside a list are rebuilt from their values, as before (a list of -`object` arrays of strings loads as string arrays). - A meta-estimator that holds other estimators - a `Pipeline` step, a `VotingClassifier`'s `estimators`, a `ColumnTransformer`'s `transformers` - doesn't get a special graph representation. Wherever a `BaseEstimator` value appears (inside `params` or `attributes`), it's @@ -266,14 +209,7 @@ package of the class the file targets, e.g. `"sklearn"`). Every `attributes` blo custom class silently replaced a built-in one with the same name. Files without the field (v1-v3) still resolve by bare name with the old rule (custom class wins), now warning when the name is ambiguous. Dicts whose values need restoring are typed per key, and non-string keys - are written as text with a `"key_types"` entry instead of the `__openmodels_dict__` envelope - (see [Values inside dicts](#values-inside-dicts)) - before, values loaded as plain JSON - (arrays and tuples as lists, estimators as raw dicts). Dicts of plain values with string keys - keep the `"dict"` tag, and older files load as before. openmodels 0.2.x readers still load - v4 files, with the "newer format version" warning, but don't know the new types: dict values - load as plain JSON, as they always did, and non-string keys as strings - e.g. a loaded - `class_weight={0: 1.0}` becomes `{"0": 1.0}`, which still predicts but makes refitting fail - with scikit-learn's "classes are not in class_weight" error. + are written as text with a `"key_types"` entry instead of the `__openmodels_dict__` envelope - before, values loaded as plain JSON (arrays and tuples as lists, estimators as raw dicts). Dicts of plain values with string keys keep the `"dict"` tag, and older files load as before. openmodels 0.2.x readers still load v4 files, with the "newer format version" warning, but don't know the new types: dict values load as plain JSON, as they always did, and non-string keys as strings - e.g. a loaded `class_weight={0: 1.0}` becomes `{"0": 1.0}`, which still predicts but makes refitting fail with scikit-learn's "classes are not in class_weight" error. ## Why not ONNX or PMML? diff --git a/openmodels/serializers/base.py b/openmodels/serializers/base.py index 5d38d63..70c17a6 100644 --- a/openmodels/serializers/base.py +++ b/openmodels/serializers/base.py @@ -248,6 +248,17 @@ def _serialize_function(self, func: Callable) -> Dict[str, str]: raise SerializationError( f"Can't serialize callable {func!r}: it has no importable module and name" ) + # A bound method can only be saved by name when its module exposes that very method + # (np.random.rand is the global RandomState's); otherwise the object it's bound to, + # e.g. a fitted scaler's fit_transform, would be lost. + if ( + inspect.ismethod(func) + and getattr(sys.modules.get(module), name, None) is not func + ): + raise SerializationError( + f"Can't serialize bound method {func!r}: only functions a module exposes by " + f"name can be saved, not methods of an object" + ) return {"module": module, "name": name} def _allowed_function_roots(self) -> Set[str]: @@ -270,7 +281,8 @@ def _deserialize_function(self, data: Dict[str, str]) -> Callable: (`_allowed_function_roots`), and only modules the user explicitly trusted (`_is_trusted_function_module`) may be imported. Importing an arbitrary module would run its import-time side effects. Private names, ``__main__`` modules and anything - that isn't a plain function, builtin or NumPy ufunc are refused. + that isn't a plain function, builtin, NumPy ufunc or a bound method the module exposes + by name (np.random.rand) are refused. """ module_name, name = data.get("module"), data.get("name") obj = None @@ -290,6 +302,7 @@ def _deserialize_function(self, data: Dict[str, str]) -> Callable: inspect.isfunction(obj) or inspect.isbuiltin(obj) or isinstance(obj, np.ufunc) + or inspect.ismethod(obj) ): raise DeserializationError( f"function '{module_name}.{name}' is not allowed; pass " @@ -389,9 +402,11 @@ def _get_deserializer_handlers(self): ("dict", self._deserialize_dict), ("function", self._deserialize_function), # NumPy ufuncs (np.log1p, scipy.special.expit) and C builtins (abs) are tagged by - # their own type name; _deserialize_function accepts both under the allowlist. + # their own type name, and bound methods (np.random.rand) "method"; + # _deserialize_function accepts them under the allowlist. ("ufunc", self._deserialize_function), ("builtin_function_or_method", self._deserialize_function), + ("method", self._deserialize_function), ] diff --git a/test/test_function_params.py b/test/test_function_params.py index 13b833a..9c76c39 100644 --- a/test/test_function_params.py +++ b/test/test_function_params.py @@ -93,6 +93,54 @@ def test_builtin_allowed_when_trusted(): assert loaded.func is abs +# ==== bound methods ==== + + +class _Holder: + def method(self, x): + return x + + @classmethod + def class_method(cls, x): + return x + + +@pytest.mark.parametrize("func", [np.random.rand, np.random.normal]) +def test_module_level_bound_method_roundtrips(func): + # np.random.rand is a method of NumPy's global RandomState that numpy.random exposes. + model = FunctionTransformer(func=func) + assert _roundtrip(model).func is func + + +@pytest.mark.parametrize( + "func", + [ + FunctionTransformer().fit_transform, + _Holder().method, + _Holder.class_method, + np.random.RandomState(0).rand, + ], + ids=["estimator-method", "instance-method", "classmethod", "other-randomstate"], +) +def test_method_of_an_object_fails_at_save(func): + with pytest.raises(SerializationError, match="Can't serialize bound method"): + SklearnSerializer().serialize(FunctionTransformer(func=func)) + + +def test_method_written_by_earlier_versions_loads_or_raises(): + # Earlier versions saved any bound method by its module and name, and loaded it as a raw + # dict. One its module exposes loads; others, which lost their object, raise. + serializer = SklearnSerializer() + loaded = serializer.convert_from_serializable( + {"module": "numpy.random", "name": "rand"}, "method" + ) + assert loaded is np.random.rand + with pytest.raises(DeserializationError, match="sklearn.base.fit_transform"): + serializer.convert_from_serializable( + {"module": "sklearn.base", "name": "fit_transform"}, "method" + ) + + # ==== NumPy array functions ==== From 446bb307f5963e86219dbae1819c12fd1d676b77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Thu, 8 Oct 2026 09:18:02 +0200 Subject: [PATCH 29/35] docs(format): rewrite format reference as tables for format version 4 --- docs/format.md | 303 +++++++++++++++++++++---------------------------- 1 file changed, 127 insertions(+), 176 deletions(-) diff --git a/docs/format.md b/docs/format.md index 6fe9ab6..4f9b241 100644 --- a/docs/format.md +++ b/docs/format.md @@ -1,83 +1,12 @@ # Serialized Model Format -`SerializationManager.serialize()`/`SklearnSerializer.serialize()` produce a plain Python -`dict`. This page documents that dict's shape - every `FormatConverter` (`JSONConverter`, -`PickleConverter`, `MsgpackConverter`, `YAMLConverter`) encodes the exact same dict, just in a -different wire encoding (`format_name="json"`/`"pickle"`/`"msgpack"`/`"yaml"`); see -[Security](https://github.com/Gnpd/openmodels/blob/main/SECURITY.md) for each format's trust -requirements, and `README.md`'s Security section for the short version. - -## Top-level fields - -| Field | Type | Meaning | -|---|---|---| -| `estimator_class` | `str` | The model's class name, e.g. `"LogisticRegression"`. Together with `estimator_package`, looked up against a registry of known scikit-learn (and registered custom) classes on deserialize. Nothing named by the file is ever imported: an unregistered class raises `UnsupportedEstimatorError`. | -| `estimator_package` | `str` | The top-level package the class comes from (`cls.__module__.split(".")[0]`), e.g. `"sklearn"`, `"chemotools"`, or `"__main__"` for a class defined in a script or notebook. Classes are resolved by `(estimator_package, estimator_class)`, so two classes with the same name from different packages (scikit-learn's and chemotools' `MinMaxScaler`) coexist. The top-level package is used rather than the full module path because scikit-learn's real modules are private and get renamed between releases. Since format `4`; a node without it resolves by bare name, with a registered custom class winning over a built-in one of the same name (and a `UserWarning` when the name is ambiguous). | -| `params` | `dict` | The estimator's constructor parameters, from `model.get_params(deep=False)`. | -| `param_types` | `dict` | `{param_name: type(value).__name__}` for every entry in `params` - lets the deserializer reconstruct types JSON can't represent natively (e.g. `tuple`). Nested lists and dicts get nested types (see [Values inside dicts](#values-inside-dicts)). | -| `param_dtypes` | `dict` | Like `param_types`, but for numpy dtypes specifically (e.g. a `np.dtype` param), when applicable. | -| `attributes` | `dict` | The model's *fitted* state - every public attribute following scikit-learn's trailing-underscore convention (`coef_`, `classes_`, ...), plus a small per-class allowlist of private attributes some estimators need at predict/transform time (see `ATTRIBUTE_EXCEPTIONS` in `sklearn_serializer.py`). Omitted entirely if the model hasn't been fit yet. | -| `attribute_types` | `dict` | Like `param_types`, for `attributes`. | -| `attribute_dtypes` | `dict` | Like `param_dtypes`, for `attributes` - this is how e.g. a `float32` array survives the round trip instead of silently widening to JSON's one numeric type. | -| `metadata` | `dict` | Producer/format bookkeeping plus optional user-supplied fields. Present exactly once, only at the root of the serialized dict - see below. Absent on nested/composite sub-estimators. | - -## `metadata` - -The root-level `metadata` object mixes two kinds of fields: - -**Autofilled** (written by the model serializer, e.g. `SklearnSerializer`, on every call to -`serialize()`): - -The autofilled fields fall into four groups: - -- **Who wrote the file** - `producer_name`/`producer_version`, following - [ONNX](https://onnx.ai/)'s meaning of these fields (the tool that generated the file, e.g. - `skl2onnx`). Never checked. -- **What the file needs to load** - `domain`/`domain_version` and `packages`. Checked on - deserialize, warning only (never blocking). -- **The writer's environment** - `dependency_versions`. Never checked. -- **Bookkeeping** - `openmodels_format_version` (checked), `created_at`. - -| Field | Type | Meaning | -|---|---|---| -| `producer_name` | `str` | The tool that wrote this file. Always `"openmodels"` for files written by `serialize()`; another tool writing this format sets its own name here (see "Files written by other tools" below). It does *not* name the model's own package: that's in `packages` (and derivable from the root `estimator_class`). | -| `producer_version` | `str` | The version of the `producer_name` tool - for openmodels-written files, the openmodels release that wrote the file (from package metadata), useful for tracing whether a file was written before a particular bug fix landed. Never compared against scikit-learn. | -| `domain` | `str` | The framework the file is reconstructed into. Currently always `"sklearn"`. Reserved for future non-scikit-learn model serializers. | -| `domain_version` | `str` | The `domain` framework's version whose attribute layout this file follows - for openmodels-written files, the installed `sklearn.__version__` at serialize time. This is the version the deserialize-time check compares against the installed scikit-learn: any difference in the version string (patch releases included) raises a `UserWarning` listing the tested versions - it never blocks loading. Skipped if absent or `"unknown"`. Files older than format `3` have no `domain_version`; for those only, `producer_version` (which then always held the scikit-learn version) is used instead. | -| `packages` | `dict` | `{package_name: version}` for every package whose estimator classes appear anywhere in the tree - including the outermost one - plus the `domain` package (`sklearn`), which is always listed. For a plain scikit-learn model this is the single entry `{"sklearn": }`; for a third-party root model or e.g. a `Pipeline` mixing a scikit-learn step with a registered third-party step, it has one entry per distinct package. A class defined in a script or notebook is listed under its module name (e.g. `"__main__"`). Version lookup is best-effort (an already-imported module's own `__version__` attribute, falling back to installed-package metadata, falling back to `"unknown"`) since it must work for packages like scikit-learn itself, whose import name (`sklearn`) differs from its distribution name (`scikit-learn`). On deserialize, every non-`sklearn` entry is compared with the installed version and a mismatch raises a `UserWarning` (entries that are `"unknown"` on either side are skipped). Called `producers` in v2 files, which are still read. | -| `openmodels_format_version` | `int` | The version of *this wire format's shape*, independent of both `domain_version` and `producer_version` - bumped only when the structure or meaning of the fields documented on this page changes. Currently `4`. A file with no `openmodels_format_version` key predates this field and has version `1`'s flat shape (see "Format history" below); a value newer than what your installed openmodels understands triggers a `UserWarning` (not an error) on deserialize. | -| `created_at` | `str` | ISO 8601 UTC timestamp of when `serialize()` was called. Informational only - not checked at deserialize time, and makes two serializations of an otherwise-identical model no longer byte-identical. | -| `dependency_versions` | `dict` | The writer's runtime environment at serialize time. openmodels records `{"python": ..., "numpy": ..., "scipy": ...}`: the Python interpreter version (`platform.python_version()`, e.g. `"3.11.9"`) plus the array/sparse-matrix libraries this format's dtype-preserving round trip depends on. Other writers record their own runtime instead (e.g. `{"R": "4.6.1", "jsonlite": "2.0.0"}`). Informational only (not checked) - the serialized dict itself is Python-version-independent; these are for tracing and reproducing the environment that produced a file. | - -**Optional** (user-supplied, passed via `SerializationManager.serialize()`/`.save()`'s -`metadata` parameter and merged into this same object - an autofilled field above always wins -if a key collides): - -| Field | Type | Meaning | -|---|---|---| -| `title` | `str` | A short human-readable name for the model. | -| `description` | `str` | A longer free-text description. | -| `author` | `dict` | `{"name": str, "email": str}`. | -| `license` | `str` | The model's license, e.g. `"MIT"` or `"CC-BY-4.0"`. | -| `metrics` | `dict` | Free-form evaluation metrics, e.g. `{"accuracy": 0.94}` - openmodels doesn't give this a fixed schema, since it's evaluation metadata rather than something the serializer itself produces or checks. | - -```python -manager.save( - model, - "model.json", - metadata={ - "title": "Customer churn classifier", - "description": "Trained on Q3 2026 data.", - "author": {"name": "Jane Doe", "email": "jane@example.com"}, - "license": "MIT", - "metrics": {"accuracy": 0.94, "f1": 0.91}, - }, -) -``` +`serialize()` turns a scikit-learn model into a plain `dict`, the same for every encoding +(`"json"`, `"pickle"`, `"msgpack"`, `"yaml"`). Format version: **4**. For what each encoding +requires you to trust, see [Security](https://github.com/Gnpd/openmodels/blob/main/SECURITY.md). ## Example -A fitted `LogisticRegression`, serialized to JSON: +A fitted `LogisticRegression`, as JSON: ```json { @@ -98,123 +27,145 @@ A fitted `LogisticRegression`, serialized to JSON: "param_dtypes": {}, "attributes": { "classes_": [0, 1], - "coef_": [[1.88878277495458, -0.6412359253840524, 0.2560199894923986, 0.0321823421317183]], - "intercept_": [0.6645191069666503] + "coef_": [[1.9208796306558977, 1.6969321460065898, -0.47739463445708574, 0.2715726107798272]], + "intercept_": [-1.791565553734519], + "n_features_in_": 4, + "n_iter_": [8] }, "attribute_types": { - "classes_": "ndarray", "coef_": "ndarray", "intercept_": "ndarray" + "classes_": "ndarray", "coef_": "ndarray", "intercept_": "ndarray", + "n_features_in_": "int", "n_iter_": "ndarray" }, "attribute_dtypes": { - "classes_": "int64", "coef_": "float64", "intercept_": "float64" + "classes_": "int64", "coef_": "float64", "intercept_": "float64", "n_iter_": "int32" }, "metadata": { "producer_name": "openmodels", - "producer_version": "0.2.0", + "producer_version": "0.2.3", "domain": "sklearn", - "domain_version": "1.9.0", - "packages": {"sklearn": "1.9.0"}, + "domain_version": "1.9.1", + "packages": {"sklearn": "1.9.1"}, "openmodels_format_version": 4, - "created_at": "2026-09-05T12:00:00+00:00", - "dependency_versions": {"python": "3.11.9", "numpy": "2.1.0", "scipy": "1.14.0"}, - "title": "Customer churn classifier" + "created_at": "2026-10-08T06:19:40.587109+00:00", + "dependency_versions": {"python": "3.11.9", "numpy": "2.4.6", "scipy": "1.17.1"} } } ``` -A model whose classes come from a registered third-party package lists that package in -`packages`, next to `sklearn`; `producer_*` still names openmodels, the tool that wrote it: +## Estimator node -```json -"metadata": { - "producer_name": "openmodels", - "producer_version": "0.2.0", - "domain": "sklearn", - "domain_version": "1.9.0", - "packages": {"chemotools": "0.4.4", "sklearn": "1.9.0"}, - ... -} -``` +Every model, and every estimator nested inside one, is a node with these fields. + +| Field | Type | Meaning | +|---|---|---| +| `estimator_class` | `str` | Class name, e.g. `"LogisticRegression"`. | +| `estimator_package` | `str` | Top-level package of the class: `"sklearn"`, `"chemotools"`, `"__main__"`, ... The class is looked up by package and name among scikit-learn's and the registered custom estimators; nothing is imported. | +| `params` | `dict` | Constructor parameters (`get_params(deep=False)`). | +| `param_types` | `dict` | Type tag of each param. | +| `param_dtypes` | `dict` | dtype of each param that holds arrays. | +| `attributes` | `dict` | Fitted state: public attributes ending in `_`, plus the private ones some estimators need (`ATTRIBUTE_EXCEPTIONS`, `_n_features_out`). Absent if not fitted. | +| `attribute_types` | `dict` | Type tag of each attribute. | +| `attribute_dtypes` | `dict` | dtype of each attribute that holds arrays. | +| `metadata` | `dict` | Root node only. | + +Not stored, rebuilt on load: neighbors search trees, search estimators' `scorer_`, +`HistGradientBoosting*._loss`/`_n_features` and LDA's `_max_components`. + +## Type tags -## Nested and composite estimators - -A meta-estimator that holds other estimators - a `Pipeline` step, a `VotingClassifier`'s -`estimators`, a `ColumnTransformer`'s `transformers` - doesn't get a special graph -representation. Wherever a `BaseEstimator` value appears (inside `params` or `attributes`), it's -serialized recursively as a nested copy of this same dict shape, minus `metadata` - that block is -root-only, so it's never duplicated across a pipeline's steps. Every node, root and nested, -carries its own `estimator_class`/`estimator_package`, so a `Pipeline` can mix same-named -classes from different packages. A serialized `Pipeline` -is a normal JSON tree, not a separate node/edge graph - openmodels reconstructs models by -calling `set_params()`/rebuilding fitted attributes on the real scikit-learn class, not by -executing an independent computation graph, so there's no separate graph representation to keep -in sync with it. - -## Files written by other tools - -Any tool can write this format for openmodels to load - for example an exporter in another -language (R, MATLAB, Julia, ...) that writes a model fitted there as the equivalent scikit-learn (or -registered third-party) estimator tree. Such a writer should fill `metadata` as follows: - -- `producer_name`/`producer_version`: the writer's own name and version. -- `domain`: `"sklearn"`. -- `domain_version` and `packages`: the versions of scikit-learn and of every package whose - classes the file uses, **as targeted by the writer** - the versions whose attribute layout it - was written and tested against. openmodels warns when the loading environment differs, which - is exactly when a hand-written attribute layout may no longer match. If the writer can't name - a version, omit it rather than writing a placeholder. -- `openmodels_format_version`: the format version the file follows (`4`). -- `dependency_versions`: the writer's own runtime. -- `created_at`: as above. - -Every estimator node needs `estimator_package` next to `estimator_class` (the top-level Python -package of the class the file targets, e.g. `"sklearn"`). Every `attributes` block also needs its -`attribute_types`, and every entry in `params` a `param_types` entry. +A tag says how to rebuild a value; usually it's the value's type name. Tags mirror the value's +structure: a list of tags for a list, a typed entry for a dict. + +| Tag | Stored as | Loads as | +|---|---|---| +| `str`, `int`, `float`, `bool`, `NoneType` | the JSON value | the same value | +| `ndarray` | nested lists; dtype text (`"float32"`, `" Date: Thu, 8 Oct 2026 09:19:01 +0200 Subject: [PATCH 30/35] fix(serializer): save UserList values as plain lists so scikit-learn 1.6 ColumnTransformers can be saved --- CHANGELOG.md | 6 +++- openmodels/serializers/base.py | 7 ++++ .../serializers/sklearn/sklearn_serializer.py | 5 ++- test/test_serializer_base.py | 32 +++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37af45b..1fa8bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to the OpenModels project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.2.3] - 2026-10-05 +## [0.2.3] - 2026-10-07 ### Security @@ -166,6 +166,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `RandomState` you created) lose that object when saved by name, so `serialize()` now raises `SerializationError` for them; files written by earlier versions holding one raise `DeserializationError` on load +- With scikit-learn 1.6, a fitted `ColumnTransformer` whose columns are selected by name (e.g. + fitted on a pandas DataFrame) or by boolean mask couldn't be saved: `SerializationError: + Object of type _RemainderColsList is not JSON serializable`. Any `collections.UserList` is now + saved, and loads, as its plain list ### Added diff --git a/openmodels/serializers/base.py b/openmodels/serializers/base.py index 70c17a6..c305271 100644 --- a/openmodels/serializers/base.py +++ b/openmodels/serializers/base.py @@ -12,6 +12,7 @@ import inspect import sys import warnings +from collections import UserList from types import ModuleType import numpy as np @@ -130,6 +131,12 @@ def convert_to_serializable(self, value): if isinstance(value, (list, tuple)): return [self.convert_to_serializable(v) for v in value] + # A UserList isn't a list subclass; it's saved, and loads, as its plain list. E.g. + # scikit-learn 1.6's ColumnTransformer keeps its remainder columns in one that warns + # when indexed, so its `.data` is read directly. + if isinstance(value, UserList): + return self.convert_to_serializable(value.data) + return value def convert_from_serializable( diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index 3fad9c4..aca0273 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -17,6 +17,7 @@ Optional, Union, ) +from collections import UserList from importlib.metadata import version as _package_version, PackageNotFoundError from datetime import datetime, timezone import platform @@ -686,7 +687,9 @@ def _get_nested_types(self, item: Any, in_dict: bool = False) -> Any: return {"tuple": [self._get_nested_types(s, True) for s in item]} return tuple(self._get_nested_types(subitem) for subitem in item) - # Handle lists + # Handle lists; a UserList is saved as its plain list (see convert_to_serializable). + if isinstance(item, UserList): + item = item.data if isinstance(item, list): return [self._get_nested_types(subitem, in_dict) for subitem in item] diff --git a/test/test_serializer_base.py b/test/test_serializer_base.py index 933944c..9d0d6bd 100644 --- a/test/test_serializer_base.py +++ b/test/test_serializer_base.py @@ -1,3 +1,8 @@ +import json +from collections import UserList + +import numpy as np + from openmodels.serializers.base import SerializerMixin @@ -48,3 +53,30 @@ def test_envelope_dict_from_older_files_round_trips(): restored = mixin.convert_from_serializable(envelope, "dict") assert restored == {1: "a", "x": "b", 2.5: "c"} assert {type(k) for k in restored} == {int, str, float} + + +class _WarningList(UserList): + """Like scikit-learn 1.6's _RemainderColsList: indexing it warns.""" + + def __getitem__(self, index): + raise AssertionError("the list was indexed") + + +def test_user_list_saved_as_plain_list_without_indexing_it(): + from sklearn.compose import ColumnTransformer + from sklearn.preprocessing import StandardScaler + + from openmodels import SklearnSerializer + + serializer = SklearnSerializer() + value = ("remainder", "drop", _WarningList([2, 3])) + written = json.loads(json.dumps(serializer.convert_to_serializable(value))) + assert written == ["remainder", "drop", [2, 3]] + assert serializer._get_nested_types(value) == ("str", "str", ["int", "int"]) + + # As scikit-learn 1.6 stores it in a fitted ColumnTransformer's transformers_. + model = ColumnTransformer([("s", StandardScaler(), [0])]).fit(np.ones((3, 4))) + name, transformer, _ = model.transformers_[-1] + model.transformers_[-1] = (name, transformer, _WarningList([1, 2, 3])) + loaded = serializer.deserialize(json.loads(json.dumps(serializer.serialize(model)))) + assert loaded.transformers_[-1][-1] == [1, 2, 3] From 3a20a21d7af1b2704c672aa134998a6ec0ddf349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Thu, 8 Oct 2026 10:00:40 +0200 Subject: [PATCH 31/35] fix(security): check a deserialized function's own module against the allowlist --- CHANGELOG.md | 4 ++- SECURITY.md | 5 ++-- openmodels/serializers/base.py | 17 ++++++++++++ test/test_function_params.py | 48 ++++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa8bc4..5b8912f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `FunctionTransformer` `func`) are no longer imported from whatever module the file names. A function is only returned if it's a plain function, builtin or NumPy ufunc from an already-imported module of numpy, scipy, scikit-learn or a registered custom estimator's - package; private names and `__main__` are refused. Anything else raises + package, and if the module it's defined in (its `__module__`) is one of those too, so a + re-export such as `sklearn.datasets._base.makedirs` (`os.makedirs`) is refused; private + names and `__main__` are refused. Anything else raises `DeserializationError`. Files referencing your own functions need the new `SklearnSerializer(trusted_function_modules=[...])`, which also allows importing those modules - A Gaussian-process kernel's `kernel_type` read from a file must now name a `Kernel` class; diff --git a/SECURITY.md b/SECURITY.md index 00e936b..f24e245 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -63,8 +63,9 @@ default, it's human-readable, and it doesn't carry this risk. Some estimators take a function as a parameter, e.g. a `FunctionTransformer`'s `func` or a `SelectKBest`'s `score_func`. The file stores these by module and name. Loading never imports a module named by the file, and only accepts functions from numpy, scipy, scikit-learn and the -packages of your registered `custom_estimators`. Any other function reference raises -`DeserializationError`. +packages of your registered `custom_estimators`, checked against the module each function is +defined in, so functions those packages merely re-export (e.g. `os.makedirs`) are refused. Any +other function reference raises `DeserializationError`. To load models that use your own functions, list their modules explicitly: `SklearnSerializer(trusted_function_modules=["mypkg"])`. Those modules (and their submodules) diff --git a/openmodels/serializers/base.py b/openmodels/serializers/base.py index c305271..3a72b6a 100644 --- a/openmodels/serializers/base.py +++ b/openmodels/serializers/base.py @@ -290,6 +290,10 @@ def _deserialize_function(self, data: Dict[str, str]) -> Callable: run its import-time side effects. Private names, ``__main__`` modules and anything that isn't a plain function, builtin, NumPy ufunc or a bound method the module exposes by name (np.random.rand) are refused. + + The function's own module (its ``__module__``) must be allowed the same way, since + allowed modules re-export functions from elsewhere: sklearn.datasets._base.makedirs + is os.makedirs, and sklearn.utils._testing.check_output is subprocess.check_output. """ module_name, name = data.get("module"), data.get("name") obj = None @@ -315,6 +319,19 @@ def _deserialize_function(self, data: Dict[str, str]) -> Callable: f"function '{module_name}.{name}' is not allowed; pass " f"trusted_function_modules=[...] to permit it" ) + own_module = getattr(obj, "__module__", None) or _find_function_module(obj) + if not ( + isinstance(own_module, str) + and "__main__" not in own_module.split(".") + and ( + self._is_trusted_function_module(own_module) + or own_module.split(".")[0] in self._allowed_function_roots() + ) + ): + raise DeserializationError( + f"function '{module_name}.{name}' is defined in '{own_module}', which is " + f"not allowed; pass trusted_function_modules=[...] to permit it" + ) return obj def _deserialize_typed_dict( diff --git a/test/test_function_params.py b/test/test_function_params.py index 9c76c39..0f980f4 100644 --- a/test/test_function_params.py +++ b/test/test_function_params.py @@ -4,6 +4,7 @@ plain functions. Builtins are outside the function allowlist unless explicitly trusted. """ +import importlib import json import numpy as np @@ -93,6 +94,53 @@ def test_builtin_allowed_when_trusted(): assert loaded.func is abs +# ==== re-exports: the function's own module must be allowed ==== + +# Allowed modules that re-export functions from modules outside the allowlist. +REEXPORTS = [ + ("sklearn.datasets._base", "makedirs"), # os.makedirs + ("sklearn.utils._testing", "check_output"), # subprocess.check_output + ("scipy._lib.deprecation", "import_module"), # importlib.import_module +] + + +def _reexported(module_name, name): + try: + func = getattr(importlib.import_module(module_name), name) + except (ImportError, AttributeError): + pytest.skip(f"{module_name}.{name} doesn't exist in this version") + return func + + +@pytest.mark.parametrize("module_name, name", REEXPORTS) +def test_reexported_function_refused(module_name, name): + func = _reexported(module_name, name) + with pytest.raises(DeserializationError, match=f"defined in '{func.__module__}'"): + SklearnSerializer().convert_from_serializable( + {"module": module_name, "name": name}, "function" + ) + + +def test_reexported_function_in_a_file_refused(): + _reexported("sklearn.datasets._base", "makedirs") + manager = SerializationManager(SklearnSerializer()) + data = json.loads(manager.serialize(FunctionTransformer(func=np.log1p).fit(X))) + data["params"]["func"] = {"module": "sklearn.datasets._base", "name": "makedirs"} + data["param_types"]["func"] = "function" + with pytest.raises(DeserializationError, match="defined in 'os'"): + manager.deserialize(json.dumps(data)) + + +@pytest.mark.parametrize("module_name, name", REEXPORTS) +def test_reexported_function_allowed_when_its_module_is_trusted(module_name, name): + func = _reexported(module_name, name) + trusted = SklearnSerializer(trusted_function_modules=[func.__module__]) + loaded = trusted.convert_from_serializable( + {"module": module_name, "name": name}, "function" + ) + assert loaded is func + + # ==== bound methods ==== From ef0ffef15be51add231311e5ca9fcae037f0f476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Thu, 8 Oct 2026 10:12:33 +0200 Subject: [PATCH 32/35] style(test): format test files with black and split test_transformer setup to satisfy flake8 --- test/test_classification.py | 26 +++++-- test/test_clustering.py | 4 +- test/test_custom_estimator.py | 65 ++++++++++++++--- test/test_metadata.py | 8 ++- test/test_others.py | 31 +++++--- test/test_regression.py | 69 +++++++++--------- test/test_transformation.py | 129 +++++++++++++++++++--------------- 7 files changed, 214 insertions(+), 118 deletions(-) diff --git a/test/test_classification.py b/test/test_classification.py index 3794857..304bb6a 100644 --- a/test/test_classification.py +++ b/test/test_classification.py @@ -7,8 +7,11 @@ from test._estimator_construction import BASE_CLASSIFIER, CONSTRUCTOR_ARGS # Get all classifier estimators, filtering out not supported classifiers -CLASSIFIERS = [cls for name, cls in all_estimators(type_filter="classifier") - if name not in NOT_SUPPORTED_ESTIMATORS] +CLASSIFIERS = [ + cls + for name, cls in all_estimators(type_filter="classifier") + if name not in NOT_SUPPORTED_ESTIMATORS +] # Define constants N_SAMPLES = 50 @@ -41,6 +44,7 @@ def data(): return x, y + @pytest.mark.parametrize("Classifier", CLASSIFIERS) def test_classifier(Classifier, data): x, y = data @@ -56,11 +60,18 @@ def test_classifier(Classifier, data): args.update(CONSTRUCTOR_ARGS["ClassifierChain"]) y_multi = np.column_stack([(y == i).astype(int) for i in np.unique(y)]) y = y_multi - elif Classifier.__name__ in ["FixedThresholdClassifier", "TunedThresholdClassifierCV"]: + elif Classifier.__name__ in [ + "FixedThresholdClassifier", + "TunedThresholdClassifierCV", + ]: args.update(CONSTRUCTOR_ARGS[Classifier.__name__]) y_binary = (y == 0).astype(int) y = y_binary - elif Classifier.__name__ in ["OneVsOneClassifier", "OutputCodeClassifier", "SelfTrainingClassifier"]: + elif Classifier.__name__ in [ + "OneVsOneClassifier", + "OutputCodeClassifier", + "SelfTrainingClassifier", + ]: args.update(CONSTRUCTOR_ARGS[Classifier.__name__]) elif Classifier.__name__ in ["MultiOutputClassifier", "OneVsRestClassifier"]: args.update(CONSTRUCTOR_ARGS[Classifier.__name__]) @@ -73,7 +84,8 @@ def test_classifier(Classifier, data): args.update(CONSTRUCTOR_ARGS["VotingClassifier"]) classifier = Classifier(**args) - + # Run the test model - run_test_model(classifier, x, y, None, None, f"{Classifier.__name__.lower()}.json", abs) - + run_test_model( + classifier, x, y, None, None, f"{Classifier.__name__.lower()}.json", abs + ) diff --git a/test/test_clustering.py b/test/test_clustering.py index 39f1808..43774ae 100644 --- a/test/test_clustering.py +++ b/test/test_clustering.py @@ -8,10 +8,12 @@ # Get all cluster estimators, filtering out not supported clusters CLUSTERS = [ - cls for name, cls in all_estimators(type_filter="cluster") + cls + for name, cls in all_estimators(type_filter="cluster") if name not in NOT_SUPPORTED_ESTIMATORS ] + @pytest.fixture(scope="module") def data(): # Generate test data diff --git a/test/test_custom_estimator.py b/test/test_custom_estimator.py index eb10938..375a83e 100644 --- a/test/test_custom_estimator.py +++ b/test/test_custom_estimator.py @@ -18,10 +18,13 @@ class NotEstimator: # ==== is_valid_estimator ==== + def test_is_valid_estimator_true(): from sklearn.base import BaseEstimator + class MyEstimator(BaseEstimator): pass + assert _custom_estimator.is_valid_estimator("MyEstimator", MyEstimator) @@ -40,12 +43,14 @@ def test_is_valid_estimator_typeerror(): # cls is an instance, triggering TypeError inside issubclass class Fake: pass + fake_instance = Fake() assert not _custom_estimator.is_valid_estimator("FakeInstance", fake_instance) # ==== normalize_estimators ==== + def test_normalize_estimators_list(): ests = [DummyEstimator, DummyEstimator] result = _custom_estimator.normalize_estimators(ests) @@ -67,10 +72,13 @@ def test_normalize_estimators_tuple_and_set(): # ==== load_custom_estimators ==== + def test_load_custom_estimators_dict(): from sklearn.base import BaseEstimator + class MyEstimator(BaseEstimator): pass + custom = {"MyEstimator": MyEstimator} all_estimators = {} result = _custom_estimator.load_custom_estimators(custom, all_estimators) @@ -80,10 +88,13 @@ class MyEstimator(BaseEstimator): def test_load_custom_estimators_callable(): from sklearn.base import BaseEstimator + class MyEstimator(BaseEstimator): pass + def custom_callable(): return [("MyEstimator", MyEstimator)] + all_estimators = {} result = _custom_estimator.load_custom_estimators(custom_callable, all_estimators) assert "MyEstimator" in result @@ -93,6 +104,7 @@ def custom_callable(): def test_load_custom_estimators_invalid(): def bad_callable(): return [("Bad", NotEstimator)] + all_estimators = {} result = _custom_estimator.load_custom_estimators(bad_callable, all_estimators) assert result == {} @@ -102,6 +114,7 @@ def test_load_custom_estimators_callable_raises(): # Covers line 36-38: callable raises exception def bad_callable(): raise ValueError("fail") + all_estimators = {} with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") @@ -114,6 +127,7 @@ def test_load_custom_estimators_callable_returns_none(): # Covers line 41: callable returns None def none_callable(): return None + all_estimators = {} result = _custom_estimator.load_custom_estimators(none_callable, all_estimators) assert result == {} @@ -123,10 +137,13 @@ def test_load_custom_estimators_item_unpack_fail(): # Covers lines 51-55: bad iterable unpacking def weird_callable(): return [["badformat"]] + all_estimators = {} with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - result = _custom_estimator.load_custom_estimators(weird_callable, all_estimators) + result = _custom_estimator.load_custom_estimators( + weird_callable, all_estimators + ) assert result == {} assert any("Unexpected" in str(x.message) for x in w) @@ -134,26 +151,36 @@ def weird_callable(): def test_load_custom_estimators_conflict_warning(): # Covers line 61: estimator name conflict from sklearn.base import BaseEstimator + class MyEstimator(BaseEstimator): pass + def custom_callable(): return [("MyEstimator", MyEstimator)] + all_estimators = {"MyEstimator": object} with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - result = _custom_estimator.load_custom_estimators(custom_callable, all_estimators) + result = _custom_estimator.load_custom_estimators( + custom_callable, all_estimators + ) assert "MyEstimator" in result assert any("conflicts" in str(x.message) for x in w) + def test_is_valid_estimator_typeerror_branch(monkeypatch): # Force inspect.isclass to return True so code reaches issubclass monkeypatch.setattr(inspect, "isclass", lambda x: True) + # Passing an instance so issubclass(...) raises TypeError - class Dummy: pass + class Dummy: + pass + dummy_instance = Dummy() result = _custom_estimator.is_valid_estimator("BadEstimator", dummy_instance) assert result is False + # ==== (name, class) pairs ==== @@ -170,7 +197,11 @@ def _pair_sources(): def _registered(serializer): - return {name: cls for name, cls in serializer._by_name.items() if cls in (PairEstimatorA, PairEstimatorB)} + return { + name: cls + for name, cls in serializer._by_name.items() + if cls in (PairEstimatorA, PairEstimatorB) + } def _no_warnings(fn): @@ -191,11 +222,16 @@ def _no_warnings(fn): ) def test_serializer_registers_pairs(custom): serializer = _no_warnings(lambda: SklearnSerializer(custom_estimators=custom)) - assert _registered(serializer) == {"PairEstimatorA": PairEstimatorA, "PairEstimatorB": PairEstimatorB} + assert _registered(serializer) == { + "PairEstimatorA": PairEstimatorA, + "PairEstimatorB": PairEstimatorB, + } def test_serializer_registers_single_pair(): - serializer = _no_warnings(lambda: SklearnSerializer(custom_estimators=("PairEstimatorA", PairEstimatorA))) + serializer = _no_warnings( + lambda: SklearnSerializer(custom_estimators=("PairEstimatorA", PairEstimatorA)) + ) assert _registered(serializer) == {"PairEstimatorA": PairEstimatorA} @@ -220,16 +256,25 @@ def test_existing_source_forms_still_register(custom): def test_normalize_estimators_wraps_pairs(): pair_a = ("PairEstimatorA", PairEstimatorA) assert _custom_estimator.normalize_estimators(pair_a) == [[pair_a]] - assert _custom_estimator.normalize_estimators([pair_a, _pair_sources]) == [[pair_a], _pair_sources] + assert _custom_estimator.normalize_estimators([pair_a, _pair_sources]) == [ + [pair_a], + _pair_sources, + ] def test_pair_with_invalid_class_is_skipped_silently(): - result = _no_warnings(lambda: _custom_estimator.load_custom_estimators([("Bad", NotEstimator)], {})) + result = _no_warnings( + lambda: _custom_estimator.load_custom_estimators([("Bad", NotEstimator)], {}) + ) assert result == {} -@pytest.mark.parametrize("element", [42, ["badformat"]], ids=["not_iterable", "not_a_pair"]) +@pytest.mark.parametrize( + "element", [42, ["badformat"]], ids=["not_iterable", "not_a_pair"] +) def test_malformed_element_warns(element): with pytest.warns(UserWarning, match="Unexpected custom_estimator format"): - result = _custom_estimator.load_custom_estimators([("PairEstimatorA", PairEstimatorA), element], {}) + result = _custom_estimator.load_custom_estimators( + [("PairEstimatorA", PairEstimatorA), element], {} + ) assert result == {"PairEstimatorA": PairEstimatorA} diff --git a/test/test_metadata.py b/test/test_metadata.py index 538328c..0607af9 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -260,7 +260,9 @@ def test_package_version_mismatch_warns(monkeypatch): serialized = serializer.serialize(model) serialized["metadata"]["packages"]["somepkg"] = "1.0.0" monkeypatch.setattr( - SklearnSerializer, "_resolve_package_version", staticmethod(lambda name: "2.0.0") + SklearnSerializer, + "_resolve_package_version", + staticmethod(lambda name: "2.0.0"), ) with pytest.warns(UserWarning, match="package 'somepkg'"): @@ -274,7 +276,9 @@ def test_v2_producers_map_still_checked(monkeypatch): metadata["producers"] = {**metadata.pop("packages"), "somepkg": "1.0.0"} metadata["openmodels_format_version"] = 2 monkeypatch.setattr( - SklearnSerializer, "_resolve_package_version", staticmethod(lambda name: "2.0.0") + SklearnSerializer, + "_resolve_package_version", + staticmethod(lambda name: "2.0.0"), ) with pytest.warns(UserWarning, match="package 'somepkg'"): diff --git a/test/test_others.py b/test/test_others.py index b3db566..ee1d21b 100644 --- a/test/test_others.py +++ b/test/test_others.py @@ -2,7 +2,10 @@ from sklearn.utils.discovery import all_estimators from sklearn.datasets import make_classification from openmodels.test_helpers import run_test_model -from openmodels.serializers.sklearn.sklearn_serializer import ALL_ESTIMATORS, NOT_SUPPORTED_ESTIMATORS +from openmodels.serializers.sklearn.sklearn_serializer import ( + ALL_ESTIMATORS, + NOT_SUPPORTED_ESTIMATORS, +) from test.test_classification import CLASSIFIERS from test.test_clustering import CLUSTERS from test.test_regression import REGRESSORS @@ -15,11 +18,13 @@ # experimental-only estimator (e.g. HalvingGridSearchCV) that becomes discoverable later in the # process - such as importing sklearn.utils.estimator_checks, which enables it as a side effect - # would be constructible here but not actually deserializable by openmodels, raising KeyError. -OTHERS = [cls for name, cls in all_estimators() - if cls not in CLASSIFIERS + CLUSTERS + REGRESSORS + TRANSFORMERS - and name not in NOT_SUPPORTED_ESTIMATORS - and name in ALL_ESTIMATORS - ] +OTHERS = [ + cls + for name, cls in all_estimators() + if cls not in CLASSIFIERS + CLUSTERS + REGRESSORS + TRANSFORMERS + and name not in NOT_SUPPORTED_ESTIMATORS + and name in ALL_ESTIMATORS +] # Define constants N_SAMPLES = 50 @@ -52,6 +57,7 @@ def data(): return x, y + @pytest.mark.parametrize("Others", OTHERS) def test_others(Others, data): x, y = data @@ -65,16 +71,18 @@ def test_others(Others, data): "another test document", "text data for vectorizer", "machine learning is fun", - "openmodels serialization test" + "openmodels serialization test", ] y = None # y is not used for vectorizers if Others.__name__ == "FrozenEstimator": from sklearn.linear_model import LogisticRegression + base_estimator = LogisticRegression() base_estimator.fit(x, y) args["estimator"] = base_estimator if Others.__name__ == "GridSearchCV": from sklearn.linear_model import LogisticRegression + # Define a simple parameter grid param_grid = {"C": [0.1, 1.0]} # Use a simple base estimator @@ -84,14 +92,16 @@ def test_others(Others, data): if Others.__name__ == "Pipeline": from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression + # Example pipeline: scaler + classifier args["steps"] = [ ("scaler", StandardScaler()), - ("clf", LogisticRegression()) # The last step must be a predictor! - ] + ("clf", LogisticRegression()), # The last step must be a predictor! + ] if Others.__name__ == "RandomizedSearchCV": from sklearn.linear_model import LogisticRegression from scipy.stats import uniform + # Define a simple parameter distribution param_distributions = {"C": uniform(0.1, 1.0)} # Use a simple base estimator @@ -100,11 +110,13 @@ def test_others(Others, data): args["param_distributions"] = param_distributions if Others.__name__ == "HalvingGridSearchCV": from sklearn.linear_model import LogisticRegression + args["estimator"] = LogisticRegression() args["param_grid"] = {"C": [0.1, 1.0]} if Others.__name__ == "HalvingRandomSearchCV": from sklearn.linear_model import LogisticRegression from scipy.stats import uniform + args["estimator"] = LogisticRegression() args["param_distributions"] = {"C": uniform(0.1, 1.0)} if Others.__name__ == "LocalOutlierFactor": @@ -114,4 +126,3 @@ def test_others(Others, data): # Run the test model run_test_model(others, x, y, None, None, f"{Others.__name__.lower()}.json") - diff --git a/test/test_regression.py b/test/test_regression.py index 1dbf182..76909c0 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -11,8 +11,12 @@ from test._estimator_construction import CONSTRUCTOR_ARGS # Get all regressor estimators, filtering out not supported regressors -REGRESSORS = [cls for name, cls in all_estimators(type_filter="regressor") - if name not in NOT_SUPPORTED_ESTIMATORS] +REGRESSORS = [ + cls + for name, cls in all_estimators(type_filter="regressor") + if name not in NOT_SUPPORTED_ESTIMATORS +] + @pytest.fixture(scope="module") def data(): @@ -42,7 +46,7 @@ def data(): @pytest.mark.parametrize("Regressor", REGRESSORS) def test_regressor(Regressor, data): - x, y, x_sparse, y_sparse = data + x, y, x_sparse, y_sparse = data args = {} # Handle specific regressors that require special treatment @@ -55,11 +59,16 @@ def test_regressor(Regressor, data): x_sparse = None y_sparse = None elif Regressor.__name__ == "PLSRegression": - x = [[0., 0., 1.], [1.,0.,0.], [2.,2.,2.], [2.,5.,4.]] - y = [[0.1, -0.2], [0.9, 1.1], [6.2, 5.9], [11.9, 12.3]] + x = [[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [2.0, 2.0, 2.0], [2.0, 5.0, 4.0]] + y = [[0.1, -0.2], [0.9, 1.1], [6.2, 5.9], [11.9, 12.3]] x_sparse = None y_sparse = None - elif Regressor.__name__ in ["MultiTaskElasticNet", "MultiTaskElasticNetCV", "MultiTaskLasso", "MultiTaskLassoCV"]: + elif Regressor.__name__ in [ + "MultiTaskElasticNet", + "MultiTaskElasticNetCV", + "MultiTaskLasso", + "MultiTaskLassoCV", + ]: x, y = make_regression(n_samples=50, n_features=3, n_targets=2, random_state=42) x_sparse = None y_sparse = None @@ -76,55 +85,49 @@ def test_regressor(Regressor, data): elif Regressor.__name__ == "StackingRegressor": # Create simpler base estimators that are easier to serialize - estimators = [ - ('lr1', LinearRegression()), - ('lr2', LinearRegression()) - ] + estimators = [("lr1", LinearRegression()), ("lr2", LinearRegression())] # Use a simple final estimator final_estimator = LinearRegression() - + # Create multi-output data since StackingRegressor works better with it - x, y = make_regression( - n_samples=50, - n_features=3, - n_targets=1, - random_state=42 - ) + x, y = make_regression(n_samples=50, n_features=3, n_targets=1, random_state=42) x_sparse = None y_sparse = None - + args = { "estimators": estimators, "final_estimator": final_estimator, - "cv": 3 # Add cross-validation folds + "cv": 3, # Add cross-validation folds } elif Regressor.__name__ == "VotingRegressor": # VotingRegressor expects a list of (name, estimator) tuples estimators = [ - ('lr', LinearRegression()), - ('rf', RandomForestRegressor(n_estimators=10, random_state=42)) + ("lr", LinearRegression()), + ("rf", RandomForestRegressor(n_estimators=10, random_state=42)), ] - x, y = make_regression( - n_samples=50, - n_features=3, - n_targets=1, - random_state=42 - ) + x, y = make_regression(n_samples=50, n_features=3, n_targets=1, random_state=42) x_sparse = None y_sparse = None - args = { - "estimators": estimators - } + args = {"estimators": estimators} regressor = Regressor(**args) try: # Try with sparse input - run_test_model(regressor, x, y, x_sparse, y_sparse, f"{Regressor.__name__.lower()}.json") + run_test_model( + regressor, x, y, x_sparse, y_sparse, f"{Regressor.__name__.lower()}.json" + ) except TypeError as e: if "Sparse data was passed" in str(e): # Retry with dense input - run_test_model(regressor, x, y, x_sparse.toarray(), y_sparse, f"{Regressor.__name__.lower()}.json") + run_test_model( + regressor, + x, + y, + x_sparse.toarray(), + y_sparse, + f"{Regressor.__name__.lower()}.json", + ) else: - raise \ No newline at end of file + raise diff --git a/test/test_transformation.py b/test/test_transformation.py index 1fc2ce6..5ce527e 100644 --- a/test/test_transformation.py +++ b/test/test_transformation.py @@ -18,7 +18,8 @@ # Get all transformer estimators, filtering out not supported ones and those that are also regressors/classifiers TRANSFORMERS = [ - cls for name, cls in all_estimators(type_filter="transformer") + cls + for name, cls in all_estimators(type_filter="transformer") if name not in NOT_SUPPORTED_ESTIMATORS and name not in [reg.__name__ for reg in REGRESSORS] and name not in [clf.__name__ for clf in CLASSIFIERS] @@ -50,112 +51,130 @@ def data(): x_sparse = feature_hasher.transform(iter(features)) return x, y, x_sparse -@pytest.mark.parametrize("Transformer", TRANSFORMERS) -def test_transformer(Transformer, data): - x, y, x_sparse = data +def _transformer_data(name, x, y, x_sparse): + """Inputs for `name`, plus the constructor args tied to them.""" args = {} - if Transformer.__name__ in ["AdditiveChi2Sampler", "MiniBatchNMF", "LatentDirichletAllocation", "NMF"]: + if name in [ + "AdditiveChi2Sampler", + "MiniBatchNMF", + "LatentDirichletAllocation", + "NMF", + ]: x = np.abs(x) - - if Transformer.__name__ in ["CCA", "PLSCanonical"]: - args["n_components"] = 1 - if Transformer.__name__ in ["FeatureHasher"]: + if name in ["FeatureHasher"]: y = None x = None x_sparse = None - if Transformer.__name__ in ["DictVectorizer"]: + if name in ["DictVectorizer"]: y = None - x = [{'foo': 1, 'bar': 2}, {'foo': 3, 'baz': 1}] + x = [{"foo": 1, "bar": 2}, {"foo": 3, "baz": 1}] x_sparse = None - if Transformer.__name__ in ["GaussianRandomProjection"]: + if name in ["GaussianRandomProjection"]: rng = np.random.RandomState(42) x = rng.rand(25, 3000) args["random_state"] = rng - if Transformer.__name__ in ["KernelCenterer"]: + if name in ["KernelCenterer"]: from sklearn.metrics.pairwise import pairwise_kernels - X = [[ 1., -2., 2.], [ -2., 1., 3.], [ 4., 1., -2.]] + + X = [[1.0, -2.0, 2.0], [-2.0, 1.0, 3.0], [4.0, 1.0, -2.0]] x = pairwise_kernels(X, metric="linear") - if Transformer.__name__ in ["PLSSVD"]: - args["n_components"] = 1 - if Transformer.__name__ == "ColumnTransformer": + if name == "ColumnTransformer": from sklearn.preprocessing import StandardScaler, OneHotEncoder + # Example input: 2 numeric columns, 1 categorical column - x = np.array([ - [0.5, 1.0, 'A'], - [1.5, 2.0, 'B'], - [3.0, 3.5, 'A'], - [2.5, 2.0, 'C'] - ], dtype=object) + x = np.array( + [[0.5, 1.0, "A"], [1.5, 2.0, "B"], [3.0, 3.5, "A"], [2.5, 2.0, "C"]], + dtype=object, + ) y = None x_sparse = None args["transformers"] = [ ("num", StandardScaler(), [0, 1]), - ("cat", OneHotEncoder(), [2]) - ] - if Transformer.__name__ == "FeatureUnion": + ("cat", OneHotEncoder(), [2]), + ] + if name == "FeatureUnion": from sklearn.preprocessing import StandardScaler, MinMaxScaler + # Example input: numeric data - x = np.array([ - [0.5, 1.0], - [1.5, 2.0], - [3.0, 3.5], - [2.5, 2.0] - ]) + x = np.array([[0.5, 1.0], [1.5, 2.0], [3.0, 3.5], [2.5, 2.0]]) y = None x_sparse = None args["transformer_list"] = [ ("scaler1", StandardScaler()), - ("scaler2", MinMaxScaler()) + ("scaler2", MinMaxScaler()), ] - if Transformer.__name__ in ["SelectFromModel", "SequentialFeatureSelector", "RFE", "RFECV"]: - args.update(CONSTRUCTOR_ARGS[Transformer.__name__]) - if Transformer.__name__ == "SparseCoder": - # SparseCoder requires a dictionary (components) for initialization - # Let's create a random dictionary with shape (n_components, n_features) - n_components = 5 - n_features = x.shape[1] if x is not None else 5 - rng = np.random.RandomState(42) - dictionary = rng.rand(n_components, n_features) - args["dictionary"] = dictionary - if Transformer.__name__ == "HashingVectorizer": + if name == "HashingVectorizer": y = None - x = ["This is a test document.", "Another document for testing.", "Yet another one."] + x = [ + "This is a test document.", + "Another document for testing.", + "Yet another one.", + ] x_sparse = None - if Transformer.__name__ == "PatchExtractor": + if name == "PatchExtractor": # PatchExtractor expects an (n_images, height, width) image batch, not 2D tabular data y = None x_sparse = None rng = np.random.RandomState(0) x = rng.rand(5, 10, 10) - if Transformer.__name__ == "SkewedChi2Sampler": + if name == "SkewedChi2Sampler": # Ensure X satisfies the condition X >= -skewedness skewedness = 0.5 # Default value for skewedness args["skewedness"] = skewedness x = np.abs(x) + skewedness # Make all values >= -skewedness - if Transformer.__name__ == "SparseRandomProjection": + return x, y, x_sparse, args + + +def _transformer_args(name, x): + """Constructor args for `name` that don't change its inputs.""" + args = {} + if name in ["CCA", "PLSCanonical", "PLSSVD"]: + args["n_components"] = 1 + if name in [ + "SelectFromModel", + "SequentialFeatureSelector", + "RFE", + "RFECV", + ]: + args.update(CONSTRUCTOR_ARGS[name]) + if name == "SparseCoder": + # SparseCoder requires a dictionary (components) for initialization + # Let's create a random dictionary with shape (n_components, n_features) + n_components = 5 + n_features = x.shape[1] if x is not None else 5 + rng = np.random.RandomState(42) + dictionary = rng.rand(n_components, n_features) + args["dictionary"] = dictionary + if name == "SparseRandomProjection": # Explicitly set n_components to avoid the ValueError args["n_components"] = min(x.shape[1], 3) # Set to a small value + return args +@pytest.mark.parametrize("Transformer", TRANSFORMERS) +def test_transformer(Transformer, data): + name = Transformer.__name__ + x, y, x_sparse, args = _transformer_data(name, *data) + args.update(_transformer_args(name, x)) + transformer = Transformer(**args) - if Transformer.__name__ == "MultiLabelBinarizer": + if name == "MultiLabelBinarizer": test_multilabelbinarizer_minimal() return - if Transformer.__name__ in ("LabelBinarizer", "LabelEncoder"): + if name in ("LabelBinarizer", "LabelEncoder"): # Ensure y is a 1D array of discrete labels _, y_int = np.unique(y, return_inverse=True) y_int = y_int.astype(int) # Only y is used for fit/transform; x is ignored - run_test_label_binarizer(transformer, y_int, f"{Transformer.__name__.lower()}.json") + run_test_label_binarizer(transformer, y_int, f"{name.lower()}.json") return - if Transformer.__name__ in ["FeatureHasher"]: + if name in ["FeatureHasher"]: test_feature_hasher_serialization() return - if Transformer.__name__ == "GenericUnivariateSelect": + if name == "GenericUnivariateSelect": test_generic_univariate_select_serialization() return - - run_test_model(transformer, x, y, x_sparse, None, f"{Transformer.__name__.lower()}.json") + run_test_model(transformer, x, y, x_sparse, None, f"{name.lower()}.json") From 73ab3f0491090a542abc858c4575fc5f727f68aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Thu, 8 Oct 2026 10:40:06 +0200 Subject: [PATCH 33/35] test(conformance): let coordinate-descent CV estimators converge in sample-weight equivalence checks --- test/test_estimator_conformance.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/test_estimator_conformance.py b/test/test_estimator_conformance.py index 0ac4bd7..bf696c9 100644 --- a/test/test_estimator_conformance.py +++ b/test/test_estimator_conformance.py @@ -270,6 +270,27 @@ def _check_name(check) -> str: }, } +# The coordinate-descent CV estimators don't converge on the sample-weight equivalence checks' +# data with their default max_iter (the final refit stops at max_iter=1000 with a +# ConvergenceWarning). Two unconverged fits on weighted vs repeated rows only agree when every +# floating-point operation happens in the same order, so the check passed locally but failed +# for LassoCV on CI (Linux, Python 3.13), with a 23% relative difference in predictions. With +# these settings every fit converges (a few thousand iterations), so the weighted and repeated +# solutions coincide on any platform. +_CONVERGED_COORDINATE_DESCENT = {"max_iter": 100_000, "tol": 1e-10} +PER_CHECK_CONSTRUCTOR_OVERRIDES.update( + { + (estimator_name, check_name): _CONVERGED_COORDINATE_DESCENT + for estimator_name in [ + "ElasticNetCV", + "LassoCV", + "MultiTaskElasticNetCV", + "MultiTaskLassoCV", + ] + for check_name in _BOTH_SAMPLE_WEIGHT_CHECKS + } +) + @parametrize_with_checks(ESTIMATORS) def test_estimator_conformance_roundtrip(estimator, check, request): From 5f70d28d0020a4e289589705536419312842eeb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Thu, 8 Oct 2026 14:45:27 +0200 Subject: [PATCH 34/35] fix(types): annotate masked-array values so mypy passes with numpy 2.2 --- .../serializers/sklearn/sklearn_serializer.py | 1 + test/test_estimator_conformance.py | 15 ++++++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/openmodels/serializers/sklearn/sklearn_serializer.py b/openmodels/serializers/sklearn/sklearn_serializer.py index aca0273..e9bf049 100644 --- a/openmodels/serializers/sklearn/sklearn_serializer.py +++ b/openmodels/serializers/sklearn/sklearn_serializer.py @@ -980,6 +980,7 @@ def _serialize_masked_array(self, value: np.ma.MaskedArray) -> Dict[str, Any]: def _deserialize_masked_array(self, data: Dict[str, Any]) -> np.ma.MaskedArray: shape = tuple(data["shape"]) mask = np.array(data["mask"], dtype=bool).reshape(shape) + values: np.ndarray if "types" in data: items = [ self.convert_from_serializable(item, item_type) diff --git a/test/test_estimator_conformance.py b/test/test_estimator_conformance.py index bf696c9..4fe3aa8 100644 --- a/test/test_estimator_conformance.py +++ b/test/test_estimator_conformance.py @@ -270,13 +270,14 @@ def _check_name(check) -> str: }, } -# The coordinate-descent CV estimators don't converge on the sample-weight equivalence checks' -# data with their default max_iter (the final refit stops at max_iter=1000 with a -# ConvergenceWarning). Two unconverged fits on weighted vs repeated rows only agree when every -# floating-point operation happens in the same order, so the check passed locally but failed -# for LassoCV on CI (Linux, Python 3.13), with a 23% relative difference in predictions. With -# these settings every fit converges (a few thousand iterations), so the weighted and repeated -# solutions coincide on any platform. +# With their default tol=1e-4 and max_iter=1000, the coordinate-descent CV estimators stop +# short of the exact solution on the sample-weight equivalence checks' data: locally the final +# refit hits max_iter (ConvergenceWarning), while on CI (Linux, Python 3.13) LassoCV stopped +# within tol without a warning. Either way, the weighted and repeated fits stop at different +# points, so they only agree when every floating-point operation happens in the same order: +# the check passed locally but failed for LassoCV on CI, with predictions up to 0.0024 apart. +# With these settings every fit converges to the exact solution (a few thousand iterations), so +# the weighted and repeated fits coincide on any platform. _CONVERGED_COORDINATE_DESCENT = {"max_iter": 100_000, "tol": 1e-10} PER_CHECK_CONSTRUCTOR_OVERRIDES.update( { From b58bcd1dba139dfc9ba6ec01ce3de84ad1b4c7d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= Date: Thu, 8 Oct 2026 15:01:49 +0200 Subject: [PATCH 35/35] test(dtypes): fix MLP random_state and add atol so the float64 compatibility test isn't flaky --- .gitignore | 1 + test/test_array_list_dtypes.py | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 1fb2c60..510f64d 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ CLAUDE.md # Sphinx documentation build output # ################################## docs/_build/ +local/ diff --git a/test/test_array_list_dtypes.py b/test/test_array_list_dtypes.py index fa849b8..192646d 100644 --- a/test/test_array_list_dtypes.py +++ b/test/test_array_list_dtypes.py @@ -32,7 +32,10 @@ def _attribute_dtypes(model): @pytest.mark.parametrize("format_name", ["json", "pickle"]) @pytest.mark.parametrize( "model, y", - [(MLPRegressor(max_iter=5), Y_REG), (MLPClassifier(max_iter=5), Y_CLF)], + [ + (MLPRegressor(max_iter=5, random_state=0), Y_REG), + (MLPClassifier(max_iter=5, random_state=0), Y_CLF), + ], ids=["MLPRegressor", "MLPClassifier"], ) def test_float32_mlp_keeps_float32(model, y, format_name): @@ -53,7 +56,9 @@ def test_float32_mlp_keeps_float32(model, y, format_name): def test_float64_mlp_writes_no_dtype_list(): # What 0.2.2 can read: lists of arrays their values rebuild exactly get no dtype list. - model = MLPRegressor(max_iter=5).fit(X.astype(np.float64), Y_REG.astype(np.float64)) + model = MLPRegressor(max_iter=5, random_state=0).fit( + X.astype(np.float64), Y_REG.astype(np.float64) + ) dtypes = _attribute_dtypes(model) assert dtypes["coefs_"] == "" assert dtypes["intercepts_"] == "" @@ -82,10 +87,14 @@ def test_mixed_list_keeps_every_dtype(arrays): def test_file_without_dtype_list_loads_as_before(): # Files written before the dtype list existed: arrays are rebuilt from their values. - model = MLPRegressor(max_iter=5).fit(X, Y_REG) + model = MLPRegressor(max_iter=5, random_state=0).fit(X, Y_REG) data = SklearnSerializer().serialize(model) data["attribute_dtypes"]["coefs_"] = "" data["attribute_dtypes"]["intercepts_"] = "" loaded = SklearnSerializer().deserialize(data) assert [a.dtype for a in loaded.coefs_] == [np.float64, np.float64] - np.testing.assert_allclose(loaded.predict(X), model.predict(X), rtol=1e-5) + # The float64 copy differs from the float32 model by float32 rounding (~1e-7), so an + # absolute tolerance is needed for predictions close to zero. + np.testing.assert_allclose( + loaded.predict(X), model.predict(X), rtol=1e-5, atol=1e-6 + )