Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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`.

- **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.
Expand Down
17 changes: 17 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"]
Expand Down
220 changes: 220 additions & 0 deletions src/python_som/sklearn.py
Original file line number Diff line number Diff line change
@@ -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
31 changes: 27 additions & 4 deletions tests/test_core_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading