From fd4c925181868e5b48c4826178a6845d3214602f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Moreira=20Souza?= Date: Thu, 30 Jul 2026 17:12:44 -0300 Subject: [PATCH 1/2] feat: add an estimator interface delegating to train fit, transform, predict, fit_transform, score, get_params and set_params on SOM, modelled on KMeans, which is the right precedent: a SOM is a topologically-constrained k-means. train remains the primary way to train and is unchanged, so nothing existing is affected. These are pass-through methods by Ousterhout's definition (A Philosophy of Software Design, 7.1) and would ordinarily be a smell. They are here because their value is conformance to an external convention rather than abstraction: Pipeline, GridSearchCV and cross_val_score call these exact names. Keeping them one-liners is deliberate, and a test asserts fit and train produce weights identical at 0.0 for every mode, so the two cannot drift. predict returns a flat node index, not (row, column). A 1-D label array is what scorers, confusion_matrix and cross_val_score assume, so coordinates would read better for a grid and compose with nothing. np.unravel_index recovers the position, and winner() still returns coordinates. score is the negated quantization error. Every scikit-learn scorer treats larger as better and GridSearchCV maximises, so without the sign a parameter search would confidently select the worst map. set_params changes the rates, radii, decays and distance function. It refuses to change the grid shape or input_len: silently rebuilding would discard trained weights, and leaving them would produce a map whose models do not match its own description. It re-runs learning-rate validation, so it cannot be used to get around the constructor's check. Two differences from scikit-learn are deliberate and documented rather than papered over. fit continues instead of resetting, because a SOM's models are its state and train has always continued. And the models exist before training, since initialization is a separate step, so there is no not-fitted state to raise about. Not sufficient on its own for Pipeline.predict or GridSearchCV, which since scikit-learn 1.7 require __sklearn_tags__. The adapter that provides it follows separately. --- CHANGELOG.md | 26 +++ docs/how-to/use-with-scikit-learn.md | 95 +++++++++ mkdocs.yml | 1 + src/python_som/_som.py | 192 ++++++++++++++++++ tests/test_core_boundary.py | 16 +- tests/test_estimator.py | 281 +++++++++++++++++++++++++++ 6 files changed, 609 insertions(+), 2 deletions(-) create mode 100644 docs/how-to/use-with-scikit-learn.md create mode 100644 tests/test_estimator.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dbd14a..e00c087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/how-to/use-with-scikit-learn.md b/docs/how-to/use-with-scikit-learn.md new file mode 100644 index 0000000..c238495 --- /dev/null +++ b/docs/how-to/use-with-scikit-learn.md @@ -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. diff --git a/mkdocs.yml b/mkdocs.yml index 1594041..7767466 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/src/python_som/_som.py b/src/python_som/_som.py index 5017f78..26886e6 100644 --- a/src/python_som/_som.py +++ b/src/python_som/_som.py @@ -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: diff --git a/tests/test_core_boundary.py b/tests/test_core_boundary.py index 442e2e2..17007aa 100644 --- a/tests/test_core_boundary.py +++ b/tests/test_core_boundary.py @@ -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", diff --git a/tests/test_estimator.py b/tests/test_estimator.py new file mode 100644 index 0000000..1617ffe --- /dev/null +++ b/tests/test_estimator.py @@ -0,0 +1,281 @@ +"""The estimator interface: fit, transform, predict, score, get_params, set_params. + +These methods add no behaviour. They delegate to ``train``, ``activate`` and ``winner``, which by +Ousterhout's definition makes them pass-through methods and ordinarily a design smell. They exist +because their value is conformance to an external convention: scikit-learn's ``Pipeline``, +``GridSearchCV`` and ``cross_val_score`` call these exact names, and ``KMeans`` is the precedent a +SOM should follow, being a topologically-constrained k-means. + +So the tests here are mostly about *agreement*: that ``fit`` does exactly what ``train`` does, that +``predict`` agrees with ``winner``, that ``transform`` agrees with ``activate``. A pass-through that +has drifted from what it passes through is the failure mode worth guarding. + +Full scikit-learn integration needs more than these names and lives in ``python_som.sklearn``; its +tests are in ``tests/test_sklearn_adapter.py``. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import python_som +from python_som import TrainingMode, WeightInit + +#: Fixed so every comparison below is between two runs that should be identical. +SEED = 20260801 + + +def _data(n_samples: int = 50, 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)) + + +def _som(**kwargs: object) -> python_som.SOM: + """Build a small map with a fixed seed. + + :param kwargs: Passed to the constructor. + :return: The map. + """ + return python_som.SOM(x=6, y=5, input_len=4, random_seed=3, **kwargs) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------------------------- +# fit agrees with train +# --------------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize("mode", list(TrainingMode)) +def test_fit_and_train_produce_identical_weights(mode: TrainingMode) -> None: + """The invariant that keeps ``fit`` honest. + + ``fit`` is a one-line delegation, so the two must agree at exactly ``0.0``. If they ever differ, + one of them has grown behaviour the other lacks, which is the whole risk of having two names for + one operation. + """ + data = _data() + trained = _som() + trained.weight_initialization(mode=WeightInit.LINEAR, data=data) + trained.train(data, n_iteration=20, mode=mode) + + fitted = _som() + fitted.weight_initialization(mode=WeightInit.LINEAR, data=data) + fitted.fit(data, n_iteration=20, mode=mode) + + assert np.abs(fitted.get_weights() - trained.get_weights()).max() == 0.0 + + +def test_fit_returns_the_same_object_not_a_copy() -> None: + """``fit`` returns ``self`` so calls chain, which the convention requires.""" + som = _som() + assert som.fit(_data(), n_iteration=5) is som + + +def test_fit_accepts_and_ignores_y() -> None: + """Unsupervised estimators take ``y`` anyway, so Pipeline can call every step the same way.""" + data = _data() + with_y = _som().fit(data, np.zeros(len(data)), n_iteration=10) + without = _som().fit(data, n_iteration=10) + assert np.abs(with_y.get_weights() - without.get_weights()).max() == 0.0 + + +def test_fit_can_be_called_twice_and_continues() -> None: + """Refitting is not forbidden, and it continues rather than resetting. + + Worth pinning because scikit-learn estimators conventionally *reset* on refit. This one does + not, and that difference should be on the record rather than a surprise: a SOM's models are its + state, and ``train`` has always continued from wherever they were. + """ + data = _data() + som = _som() + som.fit(data, n_iteration=10) + once = som.get_weights().copy() + som.fit(data, n_iteration=10) + assert np.abs(som.get_weights() - once).max() > 0.0 + + +# --------------------------------------------------------------------------------------------- +# transform and predict agree with what they delegate to +# --------------------------------------------------------------------------------------------- + + +def test_transform_is_activate_per_sample() -> None: + """One row per sample, one column per node, flattened in C order.""" + data = _data() + som = _som().fit(data, n_iteration=10) + transformed = som.transform(data) + + assert transformed.shape == (len(data), 6 * 5) + for row, sample in zip(transformed, data, strict=True): + np.testing.assert_array_equal(row, som.activate(sample).ravel()) + + +def test_predict_is_winner_flattened() -> None: + """A flat index, and ``unravel_index`` must recover exactly what ``winner`` returns.""" + data = _data() + som = _som().fit(data, n_iteration=10) + labels = som.predict(data) + + assert labels.shape == (len(data),) + assert labels.dtype.kind == "i" + rows, columns = np.unravel_index(labels, som.get_shape()) + for row, column, sample in zip(rows, columns, data, strict=True): + assert (int(row), int(column)) == som.winner(sample) + + +def test_predict_stays_inside_the_grid() -> None: + """A label outside ``x * y`` would silently break any confusion matrix built from it.""" + data = _data() + som = _som().fit(data, n_iteration=10) + labels = som.predict(data) + assert labels.min() >= 0 + assert labels.max() < 6 * 5 + + +def test_transform_and_predict_agree_with_each_other() -> None: + """The nearest node by ``transform`` must be the node ``predict`` names.""" + data = _data() + som = _som().fit(data, n_iteration=10) + np.testing.assert_array_equal(som.transform(data).argmin(axis=1), som.predict(data)) + + +def test_fit_transform_equals_fit_then_transform() -> None: + data = _data() + combined = _som().fit_transform(data, n_iteration=10) + separate = _som().fit(data, n_iteration=10).transform(data) + np.testing.assert_array_equal(combined, separate) + + +def test_the_estimator_methods_accept_a_dataframe() -> None: + """They go through the same port as everything else, so anything array-like works.""" + pd = pytest.importorskip("pandas") + data = _data() + frame = pd.DataFrame(data, columns=[f"f{i}" for i in range(data.shape[1])]) + + from_frame = _som().fit(frame, n_iteration=10) + from_array = _som().fit(data, n_iteration=10) + np.testing.assert_array_equal(from_frame.get_weights(), from_array.get_weights()) + np.testing.assert_array_equal(from_frame.predict(frame), from_array.predict(data)) + + +# --------------------------------------------------------------------------------------------- +# score +# --------------------------------------------------------------------------------------------- + + +def test_score_is_the_negated_quantization_error() -> None: + """The sign is load-bearing, not cosmetic. + + Every scikit-learn scorer treats larger as better and ``GridSearchCV`` maximises. Quantization + error is something to minimise, so without the negation a parameter search would confidently + select the *worst* map. + """ + data = _data() + som = _som().fit(data, n_iteration=10) + assert som.score(data) == pytest.approx(-som.quantization_error(data)) + assert som.score(data) < 0 + + +def test_a_better_map_scores_higher() -> None: + """The property the sign exists to give, checked rather than assumed.""" + data = _data() + trained = _som().fit(data, n_iteration=60, mode=TrainingMode.BATCH) + untrained = _som() + assert trained.score(data) > untrained.score(data) + + +# --------------------------------------------------------------------------------------------- +# get_params / set_params +# --------------------------------------------------------------------------------------------- + + +def test_get_params_reconstructs_an_equivalent_map() -> None: + """What ``clone`` relies on: the returned dict must be accepted by the constructor.""" + som = _som(learning_rate=0.25, neighborhood_radius=2.5, cyclic_x=True) + rebuilt = python_som.SOM(**som.get_params()) + + assert rebuilt.get_params() == som.get_params() + assert rebuilt.get_shape() == som.get_shape() + np.testing.assert_array_equal(rebuilt.get_weights(), som.get_weights()) + + +def test_get_params_covers_every_constructor_argument_that_shapes_a_map() -> None: + """A missing key would make ``clone`` silently drop a setting. + + ``data`` is deliberately absent: it is a constructor argument only for automatic sizing, and the + shape it produced is already reported as ``x`` and ``y``. + """ + import inspect # noqa: PLC0415 + + accepted = set(inspect.signature(python_som.SOM.__init__).parameters) - {"self", "data"} + assert set(_som().get_params()) == accepted + + +def test_set_params_changes_what_it_can_and_returns_self() -> None: + som = _som() + assert som.set_params(learning_rate=0.1, neighborhood_radius=3.0) is som + assert som.get_params()["learning_rate"] == pytest.approx(0.1) + assert som.get_params()["neighborhood_radius"] == pytest.approx(3.0) + + +def test_set_params_refuses_to_change_the_shape() -> None: + """Changing ``x`` would leave a map whose models do not match its own description. + + Raising beats silently rebuilding: a caller who wants a different grid wants a different map, + and quietly discarding trained weights is the kind of help nobody asks for. + """ + som = _som().fit(_data(), n_iteration=5) + for parameter in ("x", "y", "input_len"): + with pytest.raises(ValueError, match="cannot be changed after construction"): + som.set_params(**{parameter: 99}) + + +def test_set_params_rejects_an_unknown_parameter() -> None: + """A typo must not be accepted in silence, which plain attribute assignment would do.""" + with pytest.raises(ValueError, match="Unknown parameter"): + _som().set_params(learnign_rate=0.1) + + +def test_set_params_still_validates_the_learning_rate() -> None: + """The constructor rejects a non-positive rate, so this must not be a way around that.""" + with pytest.raises(ValueError, match="'learning_rate' must be a finite positive number"): + _som().set_params(learning_rate=-1.0) + + +def test_set_params_agrees_with_the_older_setters() -> None: + """``set_learning_rate`` and ``set_neighborhood_radius`` become this. Both still work.""" + old, new = _som(), _som() + old.set_learning_rate(0.3) + old.set_neighborhood_radius(2.0) + new.set_params(learning_rate=0.3, neighborhood_radius=2.0) + assert old.get_params() == new.get_params() + + +# --------------------------------------------------------------------------------------------- +# The fitted-attribute convention +# --------------------------------------------------------------------------------------------- + + +def test_the_trailing_underscore_attributes_mirror_kmeans() -> None: + """``weights_`` for ``cluster_centers_``, ``quantization_error_`` for ``inertia_``.""" + data = _data() + som = _som().fit(data, n_iteration=15, mode=TrainingMode.BATCH) + + np.testing.assert_array_equal(som.weights_, som.get_weights()) + assert som.n_features_in_ == 4 + assert som.quantization_error_ == pytest.approx(som.quantization_error(data)) + + +def test_quantization_error_is_none_before_training() -> None: + """None rather than a number, so an untrained map cannot be mistaken for a bad one.""" + assert _som().quantization_error_ is None + + +def test_weights_is_available_before_training() -> None: + """Unlike scikit-learn, the models exist from construction, since initialization is separate.""" + assert _som().weights_.shape == (6, 5, 4) From 996cda1a31eeb6e747b08d38023676ef895b9766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Moreira=20Souza?= Date: Thu, 30 Jul 2026 17:16:13 -0300 Subject: [PATCH 2/2] style: format the python blocks in the new docs page ruff format applies to fenced python in markdown as well as to .py files. I ran ruff check after writing the page but not ruff format --check, so CI caught it. --- docs/how-to/use-with-scikit-learn.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/how-to/use-with-scikit-learn.md b/docs/how-to/use-with-scikit-learn.md index c238495..903fda4 100644 --- a/docs/how-to/use-with-scikit-learn.md +++ b/docs/how-to/use-with-scikit-learn.md @@ -8,10 +8,10 @@ not going away. 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 +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