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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **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.

`predict` returns a **flat** node index rather than `(row, column)`, because a 1-D label array is
what scorers, `confusion_matrix` and `cross_val_score` assume;
`np.unravel_index(som.predict(X), som.get_shape())` recovers the grid position, and `winner()`
still returns coordinates. `score` is the *negated* quantization error, since `GridSearchCV`
maximises and without the sign a search would select the worst map.

Fitted attributes follow the convention: `weights_` for `cluster_centers_`,
`quantization_error_` for `inertia_`, plus `n_features_in_`.

Two deliberate differences from scikit-learn, both documented: `fit` *continues* rather than
resetting, because a SOM's models are its state; and the models exist before training, since
initialization is a separate step.

`set_params` changes the rates, radii, decays and distance function. It refuses to change the grid
shape or `input_len`, which would leave a map whose models no longer match its own description.
It is what `set_learning_rate` and `set_neighborhood_radius` become; both still work.

These names alone are **not** enough for `Pipeline.predict` or `GridSearchCV`, which since
scikit-learn 1.7 require `__sklearn_tags__`. A separate `python_som.sklearn` adapter follows.

### Changed

- **The plain-string deprecation introduced in 0.5.0 is withdrawn.** Strings are permanently
Expand Down
95 changes: 95 additions & 0 deletions docs/how-to/use-with-scikit-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Use the estimator interface

`SOM` provides `fit`, `transform`, `predict`, `score`, `get_params` and `set_params`, so a map can
be used the way a scikit-learn estimator is used. `train` remains the primary way to train and is
not going away.

```python
som = python_som.SOM(x=10, y=10, input_len=4, random_seed=42)
som.weight_initialization(mode=WeightInit.LINEAR, data=X)

som.fit(X, n_iteration=100, mode=TrainingMode.BATCH) # returns the map, so calls chain
labels = som.predict(X) # (n_samples,) flat node index
distances = som.transform(X) # (n_samples, x*y)
som.score(X) # negated quantization error
```

## What maps onto what

Modelled on `KMeans`, which is the right precedent: a SOM is a topologically-constrained k-means.

| `KMeans` | `SOM` | |
| --- | --- | --- |
| `fit(X, y=None)` | same | returns the estimator |
| `transform(X)` → `(n, n_clusters)` | `(n, x*y)` | distance to every node |
| `predict(X)` → `(n,)` | `(n,)` | **flat** node index |
| `score(X)` | same | negated, so larger is better |
| `cluster_centers_` | `weights_` | |
| `inertia_` | `quantization_error_` | `None` before training |
| `n_features_in_` | same | |

## predict returns a flat index

Not `(row, column)`. A 1-D array of labels is what scorers, `confusion_matrix` and `cross_val_score`
all assume, so returning coordinates would read better for a grid and compose with nothing.

```python
rows, columns = np.unravel_index(som.predict(X), som.get_shape())
```

`winner(x)` still returns `(row, column)` for a single sample.

## Two differences from scikit-learn worth knowing

**`fit` continues rather than resetting.** scikit-learn estimators conventionally discard their
fitted state on a second `fit`. This one does not: a SOM's models *are* its state, and `train` has
always continued from wherever they were. Calling `fit` twice trains twice.

**The models exist before training.** Initialization is a separate step
(`weight_initialization`), so `weights_` is available from construction. There is no
"not fitted" state to raise about.

## set_params, and what it will not change

```python
som.set_params(learning_rate=0.1, neighborhood_radius=3.0)
```

The rates, the radii, the decays and the distance function can be changed. The grid shape and
`input_len` cannot, and asking raises:

```python
som.set_params(x=20)
# ValueError: 'x' cannot be changed after construction: the models would no longer match it.
```

Silently rebuilding would discard trained weights, which is the kind of help nobody asks for.

`set_params` is what `set_learning_rate` and `set_neighborhood_radius` become. Both still work and
are removed in 1.0.0.

## Full scikit-learn integration

These method names alone are **not** enough for `Pipeline.predict`, `GridSearchCV` or
`cross_val_score`. Since scikit-learn 1.7 those paths require `__sklearn_tags__`, which in practice
means inheriting `BaseEstimator`:

| approach | `clone` | `Pipeline.fit` | `Pipeline.predict` | `GridSearchCV` | `cross_val_score` |
| --- | --- | --- | --- | --- | --- |
| these methods alone | ok | ok | fails | fails | fails |
| `python_som.sklearn.SOMEstimator` | ok | ok | ok | ok | ok |

So a proper adapter ships separately, and scikit-learn stays an optional dependency:

```bash
pip install "python-som[sklearn]"
```

```python
from python_som.sklearn import SOMEstimator

GridSearchCV(SOMEstimator(x=10, y=10), {"x": [8, 10, 12]}, cv=3).fit(X)
```

Use the methods on `SOM` when you are calling them yourself; use the adapter when scikit-learn is
doing the calling.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ nav:
- Save and load a map: how-to/save-and-load-a-map.md
- Reproduce a result: how-to/reproduce-a-result.md
- Use a custom strategy: how-to/use-a-custom-strategy.md
- Use the estimator interface: how-to/use-with-scikit-learn.md
- Reference:
- Options and types: reference/options.md
- Neighborhood functions: reference/neighborhood-functions.md
Expand Down
192 changes: 192 additions & 0 deletions src/python_som/_som.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,198 @@ def last_report(self) -> TrainingReport | None:
"""
return self._last_report

# ------------------------------------------------------------------ estimator interface

# These delegate to `train`, `activate` and `winner` rather than adding behaviour, which by
# Ousterhout's definition (A Philosophy of Software Design, 7.1) makes them pass-through methods
# and ordinarily a smell. They are here because their value is conformance to an external
# convention rather than abstraction: scikit-learn's `Pipeline`, `GridSearchCV` and
# `cross_val_score` call these exact names, and `KMeans` is the precedent a SOM should follow
# since it is a topologically-constrained k-means. `train` stays the primary method, which is
# also what users of minisom expect.
#
# Keeping them one-liners is deliberate: two ways to train that cannot drift apart.
#
# Full scikit-learn integration needs more than these names -- since 1.7 `Pipeline.predict` and
# `GridSearchCV` require `__sklearn_tags__` -- and lives in `python_som.sklearn`.

def fit(
self,
X: DataLike, # noqa: N803 the estimator convention capitalises the design matrix
y: object = None, # noqa: ARG002 accepted and ignored, as the convention requires
*,
n_iteration: int | None = None,
mode: TrainingMode | TrainingModeStr = TrainingMode.RANDOM,
verbose: bool = False,
) -> SOM:
"""Train the map and return it, so calls can be chained.

``y`` is accepted and ignored. Unsupervised estimators take it anyway, because that is what
lets ``Pipeline`` and ``cross_val_score`` call every step in the same way.

The training options are keyword arguments here rather than constructor arguments, so that
:class:`SOM` keeps one place where training is configured. The scikit-learn adapter in
:mod:`python_som.sklearn` takes them at construction instead, because ``get_params`` has to
expose them for ``GridSearchCV`` to tune them.

:param X: Training dataset of shape ``(n_samples, n_features)``.
:param y: Ignored.
:param n_iteration: Number of iterations. Defaults as for :meth:`train`.
:param mode: Training mode.
:param verbose: Whether to show a progress bar.
:return: This map, trained.
"""
self.train(X, n_iteration=n_iteration, mode=mode, verbose=verbose)
return self

def transform(self, X: DataLike) -> npt.NDArray[np.floating]: # noqa: N803
"""Return the distance from each sample to every model.

The counterpart of ``KMeans.transform``, which "transforms X to a cluster-distance space".
Here the space has one dimension per node, so the result is ``(n_samples, x * y)`` with the
grid flattened in C order.

:param X: Dataset of shape ``(n_samples, n_features)``.
:return: Distances of shape ``(n_samples, x * y)``.
"""
array = to_numpy(X)
return np.array([self.activate(sample).ravel() for sample in array])

def fit_transform(
self,
X: DataLike, # noqa: N803
y: object = None, # noqa: ARG002
**kwargs: Any, # noqa: ANN401
) -> npt.NDArray[np.floating]:
"""Train on ``X`` and return its distances to every model.

:param X: Training dataset.
:param y: Ignored.
:param kwargs: Passed to :meth:`fit`.
:return: Distances of shape ``(n_samples, x * y)``.
"""
return self.fit(X, **kwargs).transform(X)

def predict(self, X: DataLike) -> npt.NDArray[np.integer]: # noqa: N803
"""Return the index of the best-matching node for each sample.

A **flat** index, not a ``(row, column)`` pair. A 1-D array of labels is what scorers,
``confusion_matrix`` and ``cross_val_score`` all assume, so returning coordinates would read
better for a grid and compose with nothing. Recover the grid position with
``np.unravel_index(som.predict(X), som.get_shape())``, or call :meth:`winner` for a single
sample, which still returns ``(row, column)``.

:param X: Dataset of shape ``(n_samples, n_features)``.
:return: One flat node index per sample.
"""
array = to_numpy(X)
shape = self._shape
return np.array(
[np.ravel_multi_index(self.winner(sample), shape) for sample in array], dtype=int
)

def score(self, X: DataLike, y: object = None) -> float: # noqa: ARG002, N803
"""Return the negated quantization error, so that larger is better.

The sign is the convention every scikit-learn scorer follows: ``GridSearchCV`` maximises,
and quantization error is something to minimise. Without the negation a parameter search
would confidently select the worst map.

:param X: Dataset to score.
:param y: Ignored.
:return: Negated mean quantization error.
"""
return -self.quantization_error(X)

def get_params(self, *, deep: bool = True) -> dict[str, Any]: # noqa: ARG002
"""Return the constructor arguments that describe this map.

Strategies come back as the callables or names that were passed in, so that
``SOM(**som.get_params())`` rebuilds an equivalent map. That is what ``clone`` relies on.

:param deep: Accepted for signature compatibility; this estimator holds no sub-estimators.
:return: Constructor arguments by name.
"""
return {
"x": self._shape[0],
"y": self._shape[1],
"input_len": self._input_len,
"learning_rate": self._learning_rate,
"learning_rate_decay": self._learning_rate_decay,
"neighborhood_radius": self._neighborhood_radius,
"neighborhood_radius_decay": self._neighborhood_radius_decay,
"neighborhood_function": self._neighborhood_function_name,
"distance_function": self._distance_function,
"cyclic_x": self._cyclic[0],
"cyclic_y": self._cyclic[1],
"random_seed": self._random_seed,
"min_neighborhood_radius": self._min_neighborhood_radius,
}

def set_params(self, **params: Any) -> SOM: # noqa: ANN401
"""Set constructor-level parameters in place and return this map.

Only the parameters that can be changed without rebuilding the models are accepted: the
rates, the radii and the decays. Changing the grid shape or ``input_len`` would invalidate
the weights, so those raise rather than silently leaving a map whose models do not match its
own description.

This is what :meth:`set_learning_rate` and :meth:`set_neighborhood_radius` will become; both
still work and are removed in 1.0.0.

:param params: Parameters to set.
:return: This map.
:raises ValueError: If a parameter is unknown or cannot be changed after construction.
"""
settable = {
"learning_rate": "_learning_rate",
"learning_rate_decay": "_learning_rate_decay",
"neighborhood_radius": "_neighborhood_radius",
"neighborhood_radius_decay": "_neighborhood_radius_decay",
"min_neighborhood_radius": "_min_neighborhood_radius",
"distance_function": "_distance_function",
}
for name, value in params.items():
if name in settable:
setattr(self, settable[name], value)
elif name in self.get_params():
msg = (
f"{name!r} cannot be changed after construction: the models would no longer "
f"match it. Build a new SOM instead. Settable: {sorted(settable)}"
)
raise ValueError(msg)
else:
msg = f"Unknown parameter {name!r}. Valid parameters: {sorted(self.get_params())}"
raise ValueError(msg)
_validate_learning_rate(self._learning_rate)
return self

@property
def weights_(self) -> npt.NDArray[np.floating]:
"""The models, under the trailing-underscore name the estimator convention uses.

:return: Models of shape ``(x, y, n_features)``.
"""
return self.get_weights()

@property
def n_features_in_(self) -> int:
"""Number of features the map accepts, as ``KMeans`` reports it.

:return: Number of features.
"""
return self._input_len

@property
def quantization_error_(self) -> float | None:
"""Quantization error of the last training run, or None before the first.

The counterpart of ``KMeans.inertia_``.

:return: The error, or None.
"""
return None if self._last_report is None else self._last_report.quantization_error

# ------------------------------------------------------------------ artifacts

def config(self) -> SOMConfig:
Expand Down
16 changes: 14 additions & 2 deletions tests/test_core_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,26 +327,38 @@ def test_no_public_method_that_0_3_0_shipped_has_been_removed() -> None:
def test_the_public_methods_are_exactly_these() -> None:
"""Pin the whole set, so growing it is deliberate.

0.4.0 adds ``config``, ``save_npz`` and ``load_npz``. ``last_report`` is a property rather than
a method, so it does not appear here; ``tests/test_artifacts.py`` covers it.
0.6.0 adds the estimator interface: ``fit``, ``fit_transform``, ``transform``, ``predict``,
``score``, ``get_params``, ``set_params``. All additive, all delegating to methods that already
existed. ``train`` remains the primary way to train.

The properties the same release adds (``weights_``, ``n_features_in_``, ``quantization_error_``,
``last_report``) are not methods and so do not appear here; ``tests/test_estimator.py`` covers
them.
"""
assert _public_methods() == [
"activate",
"activation_matrix",
"config",
"distance_matrix",
"fit",
"fit_transform",
"get_params",
"get_random_seed",
"get_shape",
"get_weights",
"label_map",
"load_npz",
"neighborhood",
"predict",
"quantization",
"quantization_error",
"save_npz",
"score",
"set_learning_rate",
"set_neighborhood_radius",
"set_params",
"train",
"transform",
"weight_initialization",
"winner",
"winner_map",
Expand Down
Loading