From 0de05afc26886143fc059aecbfb67f58ade11231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Moreira=20Souza?= Date: Thu, 30 Jul 2026 17:32:09 -0300 Subject: [PATCH 1/2] feat: add a scikit-learn adapter behind an optional extra python_som.sklearn.SOMEstimator inherits BaseEstimator, ClusterMixin and TransformerMixin, and is installed with `pip install "python-som[sklearn]"`. The methods added to SOM in the previous change are enough when the caller invokes them directly. They are not enough when scikit-learn does the calling. Measured against 1.9: pure duck typing gives clone and Pipeline.fit, then fails Pipeline.predict, GridSearchCV and cross_val_score, all with AttributeError, because since 1.7 those paths call get_tags() and need __sklearn_tags__. scikit-learn's own error says to inherit BaseEstimator. Defining __sklearn_tags__ by hand also works and was rejected: it couples to an internal that changed shape between 1.6 and 1.7, and scikit-learn explicitly discourages it. So the integration is an adapter in its own module. An adapter may depend on the thing it adapts, which is what keeps the core numpy-only: importing python_som pulls in nothing but NumPy, asserted in a subprocess, and a second test asserts sklearn.py is the only module in the package that imports it. Every constructor argument is stored unmodified. clone rebuilds an estimator by passing get_params() back to __init__ and then requires the result to compare equal, so an __init__ that validates or derives anything breaks cloning. All of that is deferred to fit. input_len is not a constructor argument, since scikit-learn infers the feature count from X. The estimator's fit starts over rather than continuing, the opposite of SOM.fit. GridSearchCV fits one cloned estimator on fold after fold, and weights carried between folds would leak one fold into the next and make the scores meaningless. Both behaviours are pinned by tests so the difference stays a decision. The boundary test that scans for banned imports now exempts sklearn.py by name, and a new test asserts the exemption covers exactly one module, so a second scikit-learn import would need a deliberate edit. mypy relaxes disallow_subclassing_any for this module alone: scikit-learn ships no py.typed, so BaseEstimator is Any. A blanket type: ignore on the class would have hidden any other subclassing mistake in the same file. Verified in a clean venv: with numpy alone the core trains and predicts, and importing the adapter raises an ImportError naming the extra to install; after installing the extra it works. --- CHANGELOG.md | 20 +++ pyproject.toml | 17 +++ src/python_som/sklearn.py | 220 +++++++++++++++++++++++++++++++++ tests/test_core_boundary.py | 31 ++++- tests/test_sklearn_adapter.py | 226 ++++++++++++++++++++++++++++++++++ uv.lock | 7 +- 6 files changed, 516 insertions(+), 5 deletions(-) create mode 100644 src/python_som/sklearn.py create mode 100644 tests/test_sklearn_adapter.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e00c087..5a43f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **`python_som.sklearn.SOMEstimator`**, a scikit-learn estimator, behind a new `sklearn` extra: + + ```bash + pip install "python-som[sklearn]" + ``` + + The methods on `SOM` are enough when you call them yourself. They are not enough when scikit-learn + does the calling: since 1.7, `Pipeline.predict`, `GridSearchCV` and `cross_val_score` all require + `__sklearn_tags__`, which in practice means inheriting `BaseEstimator`. So the integration lives in + its own module and scikit-learn stays optional. Importing `python_som` still pulls in nothing but + NumPy, which a test asserts in a subprocess. + + `input_len` is absent from its constructor, since scikit-learn infers the feature count from `X`. + Unlike `SOM.fit`, the estimator's `fit` starts over rather than continuing, because `GridSearchCV` + fits one cloned estimator fold after fold and carrying weights across folds would leak one into the + next. + + All five integration points have tests that call the real library rather than asserting about it: + `clone`, `Pipeline.fit`, `Pipeline.predict`, `GridSearchCV`, `cross_val_score`. + - **An estimator interface on `SOM`**: `fit`, `transform`, `predict`, `fit_transform`, `score`, `get_params` and `set_params`, modelled on `KMeans`. `train` remains the primary way to train and is unchanged, so nothing existing is affected. diff --git a/pyproject.toml b/pyproject.toml index 0be3d2b..e52896a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,6 +106,12 @@ analysis = [ "pylint==4.0.6", ] # Restores the install set that 0.3.0 pulled in by default, for anyone who was relying on it. +# scikit-learn integration. The core never imports it; only python_som.sklearn does, and that module +# exists because these paths need `__sklearn_tags__`, which in practice means inheriting BaseEstimator. +# A lower bound rather than a pin: this is a user-facing install set, not tooling. +sklearn = [ + "scikit-learn>=1.4", +] examples = [ "matplotlib>=3.8", "pandas>=2.0", @@ -162,6 +168,9 @@ ignore = [ [tool.ruff.lint.per-file-ignores] # Tests must be able to build a DataFrame to exercise the port, and to compare our PCA against # sklearn's differentially. The ban protects the core, not the suite that checks it. +# The scikit-learn adapter is the module that exists to import scikit-learn. +"src/python_som/sklearn.py" = ["TID251"] +"tests/test_sklearn_adapter.py" = ["TID251"] "tests/*" = [ "TID251", # Tests reach into private helpers deliberately; that is what is under test. @@ -205,6 +214,14 @@ enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] module = ["sklearn.*"] ignore_missing_imports = true +# scikit-learn ships no py.typed, so mypy sees BaseEstimator as Any and --strict refuses to subclass +# it. Relaxed for the adapter module alone, which is the only place that inherits from scikit-learn; +# everything else stays under full strictness. The alternative, a blanket type: ignore on the class, +# would hide any *other* subclassing mistake in the same file. +[[tool.mypy.overrides]] +module = ["python_som.sklearn"] +disallow_subclassing_any = false + [tool.pytest.ini_options] minversion = "8.0" testpaths = ["tests"] diff --git a/src/python_som/sklearn.py b/src/python_som/sklearn.py new file mode 100644 index 0000000..ffb0ec8 --- /dev/null +++ b/src/python_som/sklearn.py @@ -0,0 +1,220 @@ +"""scikit-learn adapter. Import this only if you want a map to work inside scikit-learn. + +:class:`~python_som.SOM` already provides ``fit``, ``transform``, ``predict`` and ``score``, which +is enough when *you* are the one calling them. It is not enough when scikit-learn does the calling: +since 1.7, ``Pipeline.predict``, ``GridSearchCV`` and ``cross_val_score`` all reach for +``__sklearn_tags__``, and the recommended way to have it is to inherit ``BaseEstimator``. + +Measured against scikit-learn 1.9, the methods on :class:`~python_som.SOM` alone give ``clone`` and +``Pipeline.fit`` and then fail: ``Pipeline.predict``, ``GridSearchCV`` and ``cross_val_score`` all +raise ``AttributeError``. :class:`SOMEstimator` passes all five. + +So the integration lives here rather than in the core, and scikit-learn stays optional:: + + pip install "python-som[sklearn]" + +This is the ports-and-adapters shape the package already uses. An adapter may depend on the thing it +adapts; the core stays numpy-only, and importing :mod:`python_som` pulls none of this in. + +Defining ``__sklearn_tags__`` by hand was the alternative and was rejected: it couples to an +internal that already changed shape once between 1.6 and 1.7, and scikit-learn's own error message +says it does not recommend the approach. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +try: + from sklearn.base import BaseEstimator, ClusterMixin, TransformerMixin +except ImportError as exc: # pragma: no cover exercised in a subprocess, not in this one + msg = ( + "python_som.sklearn needs scikit-learn, which is not installed. Install it with " + '`pip install "python-som[sklearn]"`. The rest of python_som does not need it: ' + "python_som.SOM already has fit, transform, predict and score for direct use." + ) + raise ImportError(msg) from exc + +from ._core._decay import asymptotic_decay +from ._core._distance import euclidean_distance +from ._enums import Neighborhood, NeighborhoodStr, TrainingMode, TrainingModeStr +from ._som import SOM + +if TYPE_CHECKING: # pragma: no cover + import numpy.typing as npt + + from ._artifact import TrainingReport + from ._core._protocols import DecayFunction, DistanceFunction + +__all__ = ["SOMEstimator"] + + +class SOMEstimator(ClusterMixin, TransformerMixin, BaseEstimator): + """A self-organizing map as a scikit-learn estimator. + + A SOM is a topologically-constrained k-means, so this follows ``KMeans``: ``transform`` gives a + cluster-distance space, ``predict`` gives one label per sample, ``score`` is negated so that + larger is better, and fitted attributes carry a trailing underscore. + + >>> from python_som.sklearn import SOMEstimator + >>> from sklearn.model_selection import GridSearchCV + >>> search = GridSearchCV(SOMEstimator(), {"x": [4, 6]}, cv=3) # doctest: +SKIP + + **Every argument is stored unmodified.** scikit-learn's ``clone`` rebuilds an estimator by + passing ``get_params()`` back to ``__init__`` and then checks the result is identical, so an + ``__init__`` that validates, coerces or derives anything breaks cloning. All of that is deferred + to :meth:`fit`, which is why this class holds settings rather than a + :class:`~python_som.SOM`. + + ``input_len`` is deliberately absent: scikit-learn infers the feature count from ``X``, and + :attr:`n_features_in_` reports it after fitting. + """ + + def __init__( + self, + x: int = 10, + y: int = 10, + *, + n_iteration: int | None = None, + mode: TrainingMode | TrainingModeStr = TrainingMode.BATCH, + learning_rate: float = 0.5, + learning_rate_decay: DecayFunction | None = None, + neighborhood_radius: float = 1.0, + neighborhood_radius_decay: DecayFunction | None = None, + neighborhood_function: Neighborhood | NeighborhoodStr = Neighborhood.GAUSSIAN, + distance_function: DistanceFunction | None = None, + cyclic_x: bool = False, + cyclic_y: bool = False, + random_seed: int | None = None, + min_neighborhood_radius: float = 0.5, + initialization: str = "linear", + ) -> None: + """Record the settings a map will be built from. + + Keyword-only after the grid dimensions, as ``KMeans`` is. The decays and the distance + default to None rather than to the functions themselves, so that the recorded parameters + stay exactly what the caller passed; :meth:`fit` substitutes the real defaults. + + :param x: Number of rows. + :param y: Number of columns. + :param n_iteration: Iterations to train for. Defaults as for :meth:`~python_som.SOM.train`. + :param mode: Training mode. Batch by default, which Kohonen Section 3.1 recommends. + :param learning_rate: Initial learning rate. Unused by batch training. + :param learning_rate_decay: Decay for the learning rate. + :param neighborhood_radius: Initial neighborhood radius. + :param neighborhood_radius_decay: Decay for the radius. + :param neighborhood_function: Which neighborhood to use. + :param distance_function: Dissimilarity between an input and the models. + :param cyclic_x: Whether the map wraps vertically. + :param cyclic_y: Whether the map wraps horizontally. + :param random_seed: Seed for this estimator's generator. + :param min_neighborhood_radius: Floor applied to the decayed radius. + :param initialization: How to seed the models before training, as for + :meth:`~python_som.SOM.weight_initialization`. + """ + self.x = x + self.y = y + self.n_iteration = n_iteration + self.mode = mode + self.learning_rate = learning_rate + self.learning_rate_decay = learning_rate_decay + self.neighborhood_radius = neighborhood_radius + self.neighborhood_radius_decay = neighborhood_radius_decay + self.neighborhood_function = neighborhood_function + self.distance_function = distance_function + self.cyclic_x = cyclic_x + self.cyclic_y = cyclic_y + self.random_seed = random_seed + self.min_neighborhood_radius = min_neighborhood_radius + self.initialization = initialization + + def fit(self, X: Any, y: Any = None, **kwargs: Any) -> SOMEstimator: # noqa: ANN401, ARG002, N803 + """Build a map from the recorded settings and train it on ``X``. + + A fresh map every call, unlike :meth:`python_som.SOM.fit`, which continues from wherever + its models were. Refitting an estimator is expected to start over: ``GridSearchCV`` fits the + same cloned estimator on fold after fold, and carrying weights between folds would leak one + fold into the next. + + :param X: Training dataset of shape ``(n_samples, n_features)``. + :param y: Ignored. + :param kwargs: Ignored; accepted because ``ClusterMixin.fit_predict`` forwards them. + :return: This estimator. + """ + data = np.asarray(X, dtype=float) + # Substituted here rather than defaulted in the signature: a callable default that `clone` + # round-trips would compare unequal to itself under some wrappers, and None keeps the + # recorded parameters exactly what the caller passed. + som = SOM( + x=self.x, + y=self.y, + input_len=data.shape[1], + learning_rate=self.learning_rate, + learning_rate_decay=( + asymptotic_decay if self.learning_rate_decay is None else self.learning_rate_decay + ), + neighborhood_radius=self.neighborhood_radius, + neighborhood_radius_decay=( + asymptotic_decay + if self.neighborhood_radius_decay is None + else self.neighborhood_radius_decay + ), + neighborhood_function=self.neighborhood_function, + distance_function=( + euclidean_distance if self.distance_function is None else self.distance_function + ), + cyclic_x=self.cyclic_x, + cyclic_y=self.cyclic_y, + random_seed=self.random_seed, + min_neighborhood_radius=self.min_neighborhood_radius, + ) + needs_data = self.initialization in {"linear", "sample"} + som.weight_initialization( + mode=self.initialization, # type: ignore[arg-type] + **({"data": data} if needs_data else {}), + ) + som.train(data, n_iteration=self.n_iteration, mode=self.mode) + + self.som_ = som + self.n_features_in_ = data.shape[1] + self.weights_ = som.get_weights() + self.quantization_error_ = som.quantization_error(data) + self.labels_ = som.predict(data) + return self + + def transform(self, X: Any) -> npt.NDArray[np.floating]: # noqa: ANN401, N803 + """Return the distance from each sample to every node. + + :param X: Dataset of shape ``(n_samples, n_features)``. + :return: Distances of shape ``(n_samples, x * y)``. + """ + return self.som_.transform(X) + + def predict(self, X: Any) -> npt.NDArray[np.integer]: # noqa: ANN401, N803 + """Return the flat index of the best-matching node for each sample. + + :param X: Dataset of shape ``(n_samples, n_features)``. + :return: One flat node index per sample. + """ + return self.som_.predict(X) + + def score(self, X: Any, y: Any = None) -> float: # noqa: ANN401, ARG002, N803 + """Return the negated quantization error, so that larger is better. + + :param X: Dataset to score. + :param y: Ignored. + :return: Negated mean quantization error. + """ + return self.som_.score(X) + + @property + def report_(self) -> TrainingReport | None: + """The training report of the fitted map, or None before fitting. + + :return: The report. + """ + if not hasattr(self, "som_"): + return None + return self.som_.last_report diff --git a/tests/test_core_boundary.py b/tests/test_core_boundary.py index 17007aa..d768a6b 100644 --- a/tests/test_core_boundary.py +++ b/tests/test_core_boundary.py @@ -76,20 +76,43 @@ def test_no_core_module_imports_sklearn() -> None: def test_the_whole_package_is_numpy_only_at_runtime() -> None: """The shell, not just the core. This is what the 264 MB saving actually rests on. - Scans every module under ``src/`` rather than only ``_core/``, because after 0.4.0 no module in - the package imports pandas or scikit-learn -- the port is ``np.asarray`` and the PCA is - ``np.linalg.svd``. A `per-file-ignores` entry restoring either would pass ruff silently. + Scans every module under ``src/`` rather than only ``_core/``, because after 0.4.0 no module on + the import path of ``python_som`` reaches pandas or scikit-learn: the port is ``np.asarray`` and + the PCA is ``np.linalg.svd``. A `per-file-ignores` entry restoring either would pass ruff + silently. + + ``sklearn.py`` is exempt and is the *only* exemption. It is the adapter whose entire job is to + import scikit-learn, it is never imported by anything else in the package, and + ``tests/test_sklearn_adapter.py`` asserts that importing ``python_som`` does not pull it in. An + adapter may depend on the thing it adapts; that is what makes it an adapter rather than a + dependency. """ package = pathlib.Path(python_som.__file__).parent banned = {"pandas", "sklearn", "scipy"} + exempt = {"sklearn.py"} offenders = { str(path.relative_to(package)): sorted(_imports_of(path) & banned) for path in package.rglob("*.py") - if _imports_of(path) & banned + if path.name not in exempt and _imports_of(path) & banned } assert not offenders, f"runtime modules must not import {banned}: {offenders}" +def test_the_only_module_allowed_to_import_sklearn_is_the_adapter() -> None: + """State the exemption positively, so it cannot quietly grow. + + The test above skips ``sklearn.py`` by name. This one asserts that the skip covers exactly one + module, so adding a second scikit-learn import anywhere would need a deliberate edit here. + """ + package = pathlib.Path(python_som.__file__).parent + users = sorted( + str(path.relative_to(package)) + for path in package.rglob("*.py") + if "sklearn" in _imports_of(path) + ) + assert users == ["sklearn.py"], users + + def test_every_core_module_is_importable() -> None: """A module that no longer imports is a broken boundary, not a missing test.""" package = python_som._core diff --git a/tests/test_sklearn_adapter.py b/tests/test_sklearn_adapter.py new file mode 100644 index 0000000..3d6687b --- /dev/null +++ b/tests/test_sklearn_adapter.py @@ -0,0 +1,226 @@ +"""The scikit-learn adapter, tested by handing it to scikit-learn rather than by asserting about it. + +The earlier plan for this claimed that duck typing was enough and that inheriting ``BaseEstimator`` +was unnecessary. That was wrong: since scikit-learn 1.7, ``Pipeline.predict``, ``GridSearchCV`` and +``cross_val_score`` reach for ``__sklearn_tags__`` and raise ``AttributeError`` without it. That +claim had been reasoned about rather than run. + +So every test here calls the real scikit-learn. The five integration points that failed under duck +typing each get one, because those are the claims that were wrong, and a claim that was wrong once +is worth checking on every run rather than describing again. + +Skipped wholesale when scikit-learn is absent, since the adapter is behind an optional extra. +""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest + +pytest.importorskip("sklearn") + +from sklearn.base import clone +from sklearn.model_selection import GridSearchCV, cross_val_score +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler + +import python_som +from python_som import Neighborhood, TrainingMode +from python_som.sklearn import SOMEstimator + +#: Fixed so a search's choice is reproducible. +SEED = 20260801 + + +def _data(n_samples: int = 60, n_features: int = 4) -> np.ndarray: + """Build a reproducible dataset. + + :param n_samples: Number of samples. + :param n_features: Number of features. + :return: The dataset. + """ + return np.random.default_rng(SEED).normal(size=(n_samples, n_features)) + + +# --------------------------------------------------------------------------------------------- +# The five points that duck typing could not reach +# --------------------------------------------------------------------------------------------- + + +def test_clone_reproduces_the_parameters() -> None: + """``clone`` rebuilds from ``get_params`` and requires the result to compare equal. + + This is why ``__init__`` stores every argument unmodified: validating or deriving anything there + would make the rebuilt estimator differ from the original and break cloning, which is what + ``GridSearchCV`` does to every candidate. + """ + original = SOMEstimator(x=5, y=4, neighborhood_radius=2.5, random_seed=7) + copied = clone(original) + assert copied.get_params() == original.get_params() + assert copied is not original + + +def test_pipeline_fit_and_predict() -> None: + """``Pipeline.predict`` is the first thing duck typing failed.""" + data = _data() + pipeline = Pipeline([("scale", StandardScaler()), ("som", SOMEstimator(x=5, y=4))]) + pipeline.fit(data) + + labels = pipeline.predict(data) + assert labels.shape == (len(data),) + assert labels.max() < 5 * 4 + + +def test_pipeline_transform() -> None: + """One column per node, after the scaler has had its way with the data.""" + data = _data() + pipeline = Pipeline([("scale", StandardScaler()), ("som", SOMEstimator(x=5, y=4))]).fit(data) + assert pipeline.transform(data).shape == (len(data), 5 * 4) + + +def test_grid_search_selects_the_better_map() -> None: + """A search must tell a good map from a bad one, which is what the sign on ``score`` is for. + + A 2x2 grid cannot represent this data as well as a 6x6 one, so the search should prefer the + larger. If ``score`` were not negated this test would fail, and it is what would catch that. + """ + search = GridSearchCV(SOMEstimator(), {"x": [2, 6], "y": [2, 6]}, cv=3) + search.fit(_data()) + assert search.best_params_ == {"x": 6, "y": 6} + + +def test_cross_val_score_runs_and_returns_one_score_per_fold() -> None: + scores = cross_val_score(SOMEstimator(x=4, y=4), _data(), cv=3) + assert scores.shape == (3,) + assert np.isfinite(scores).all() + assert (scores < 0).all(), "the score is a negated error, so every fold should be negative" + + +def test_fit_predict_comes_from_the_cluster_mixin() -> None: + """Inheriting ``ClusterMixin`` is what provides it, and it must agree with fit-then-predict.""" + data = _data() + combined = SOMEstimator(x=5, y=4, random_seed=1).fit_predict(data) + separate = SOMEstimator(x=5, y=4, random_seed=1).fit(data).predict(data) + np.testing.assert_array_equal(combined, separate) + + +# --------------------------------------------------------------------------------------------- +# The estimator contract +# --------------------------------------------------------------------------------------------- + + +def test_fitted_attributes_follow_the_convention() -> None: + """Trailing underscore, and set only by ``fit``.""" + estimator = SOMEstimator(x=5, y=4) + assert not hasattr(estimator, "weights_") + + estimator.fit(_data()) + assert estimator.weights_.shape == (5, 4, 4) + assert estimator.n_features_in_ == 4 + assert estimator.quantization_error_ > 0 + assert estimator.labels_.shape == (60,) + assert isinstance(estimator.som_, python_som.SOM) + + +def test_input_len_is_inferred_from_the_data() -> None: + """scikit-learn infers the feature count, so it is not a constructor argument. + + A caller who changes their feature count should not have to remember to change a second number + that has to agree with it. + """ + assert "input_len" not in SOMEstimator().get_params() + for n_features in (2, 7): + fitted = SOMEstimator(x=4, y=4).fit(_data(n_features=n_features)) + assert fitted.n_features_in_ == n_features + assert fitted.weights_.shape == (4, 4, n_features) + + +def test_refitting_starts_over_rather_than_continuing() -> None: + """The opposite of ``SOM.fit``, and deliberately so. + + ``GridSearchCV`` fits one cloned estimator on fold after fold. If weights carried over, every + fold would inherit the previous one and the scores would be quietly meaningless. + """ + data = _data() + estimator = SOMEstimator(x=5, y=4, random_seed=2) + first = estimator.fit(data).weights_.copy() + second = estimator.fit(data).weights_ + np.testing.assert_array_equal(first, second) + + +def test_the_report_is_none_before_fitting() -> None: + """None rather than absent, so reading it before fitting is not an AttributeError.""" + assert SOMEstimator(x=4, y=4).report_ is None + + +def test_the_report_is_available_after_fitting() -> None: + """Separate from the test above so neither narrows the other's type for a checker.""" + report = SOMEstimator(x=4, y=4, n_iteration=12).fit(_data()).report_ + assert report is not None + assert report.n_iteration == 12 + + +def test_every_neighborhood_and_mode_combination_that_is_legal_works() -> None: + """The adapter must not narrow what the core accepts.""" + data = _data() + for neighborhood in Neighborhood: + for mode in TrainingMode: + if neighborhood is Neighborhood.MEXICAN_HAT and mode is TrainingMode.BATCH: + continue # rejected by the core, and the core's error is the right one + fitted = SOMEstimator( + x=4, y=4, neighborhood_function=neighborhood, mode=mode, n_iteration=5 + ).fit(data) + assert np.isfinite(fitted.weights_).all() + + +def test_a_plain_string_option_works_here_too() -> None: + """Strings are permanent, and the adapter is not a place they stop being accepted.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + fitted = SOMEstimator(x=4, y=4, neighborhood_function="bubble", mode="batch").fit(_data()) + assert fitted.n_features_in_ == 4 + + +def test_the_core_error_surfaces_unchanged() -> None: + """The adapter adds no validation of its own, so the core's message is what a caller sees.""" + with pytest.raises(ValueError, match="cannot be used with the 'batch' training mode"): + SOMEstimator( + x=4, y=4, neighborhood_function=Neighborhood.MEXICAN_HAT, mode=TrainingMode.BATCH + ).fit(_data()) + + +# --------------------------------------------------------------------------------------------- +# The boundary: the core must not depend on this +# --------------------------------------------------------------------------------------------- + + +def test_importing_python_som_does_not_import_sklearn() -> None: + """The whole point of putting the adapter in its own module. + + A subprocess, because scikit-learn is certainly already imported in this one. + """ + import subprocess # noqa: PLC0415 + import sys # noqa: PLC0415 + + code = ( + "import sys, python_som; " + "assert 'sklearn' not in sys.modules, sorted(m for m in sys.modules if 'sklearn' in m); " + "print('clean')" + ) + result = subprocess.run( # noqa: S603 + [sys.executable, "-c", code], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "clean" in result.stdout + + +def test_the_adapter_is_not_reachable_from_the_package_root() -> None: + """``python_som.sklearn`` is an explicit import, never something you get by accident. + + If it were re-exported from ``__init__``, importing the package would import scikit-learn and + the numpy-only promise would be gone. + """ + assert "sklearn" not in python_som.__all__ + assert not hasattr(python_som, "SOMEstimator") diff --git a/uv.lock b/uv.lock index 34bddfd..c879bcd 100644 --- a/uv.lock +++ b/uv.lock @@ -2606,6 +2606,10 @@ examples = [ { name = "scikit-learn", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "seaborn" }, ] +sklearn = [ + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scikit-learn", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] [package.metadata] requires-dist = [ @@ -2630,13 +2634,14 @@ requires-dist = [ { name = "scikit-learn", marker = "python_full_version >= '3.11' and extra == 'dev'", specifier = "==1.9.0" }, { name = "scikit-learn", marker = "python_full_version < '3.11' and extra == 'dev'", specifier = "==1.7.2" }, { name = "scikit-learn", marker = "extra == 'examples'", specifier = ">=1.3" }, + { name = "scikit-learn", marker = "extra == 'sklearn'", specifier = ">=1.4" }, { name = "seaborn", marker = "extra == 'examples'", specifier = ">=0.13" }, { name = "tomli", marker = "python_full_version < '3.11' and extra == 'dev'", specifier = "==2.4.1" }, { name = "tqdm", marker = "extra == 'cli'", specifier = ">=4.66" }, { name = "twine", marker = "extra == 'dev'", specifier = "==7.0.0" }, { name = "types-tqdm", marker = "extra == 'dev'", specifier = "==4.69.0.20260728" }, ] -provides-extras = ["cli", "dev", "docs", "analysis", "examples"] +provides-extras = ["cli", "dev", "docs", "analysis", "sklearn", "examples"] [[package]] name = "pytz" From 800079b922c217695b956fd1ecbea958ce221903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Moreira=20Souza?= Date: Thu, 30 Jul 2026 17:33:36 -0300 Subject: [PATCH 2/2] docs: wrap two changelog lines to the line limit --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a43f2c..ef6e2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,14 +17,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The methods on `SOM` are enough when you call them yourself. They are not enough when scikit-learn does the calling: since 1.7, `Pipeline.predict`, `GridSearchCV` and `cross_val_score` all require - `__sklearn_tags__`, which in practice means inheriting `BaseEstimator`. So the integration lives in - its own module and scikit-learn stays optional. Importing `python_som` still pulls in nothing but - NumPy, which a test asserts in a subprocess. + `__sklearn_tags__`, which in practice means inheriting `BaseEstimator`. So the integration lives + in its own module and scikit-learn stays optional. Importing `python_som` still pulls in nothing + but NumPy, which a test asserts in a subprocess. `input_len` is absent from its constructor, since scikit-learn infers the feature count from `X`. Unlike `SOM.fit`, the estimator's `fit` starts over rather than continuing, because `GridSearchCV` - fits one cloned estimator fold after fold and carrying weights across folds would leak one into the - next. + fits one cloned estimator fold after fold, and carrying weights across folds would leak one fold + into the next. All five integration points have tests that call the real library rather than asserting about it: `clone`, `Pipeline.fit`, `Pipeline.predict`, `GridSearchCV`, `cross_val_score`.