From 0c5c4de1c95f3a8a90230ec776e82ab493c0e78c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Moreira=20Souza?= Date: Thu, 30 Jul 2026 00:05:28 -0300 Subject: [PATCH] feat: add enums and strategy protocols, and validate learning_rate Three additions, all backward compatible. No numerical behaviour changes: weights are identical to master across all 81 combinations of training mode, neighborhood, initializer and cyclic setting. Enums for the string-valued options: TrainingMode, Neighborhood, WeightInit, SampleMode. Each member is a `str`, so `TrainingMode.BATCH` and `"batch"` are equal, hash equal, serialise to the same JSON and work as the same dict key. The parameters are typed as the enum or a Literal of the valid strings, so a type checker now rejects `mode="bacth"` while accepting both spellings. The cost is that a variable of plain `str` type no longer satisfies the parameter; the `*Str` aliases are exported for annotating one. `enum.StrEnum` is 3.11+ and this package supports 3.10, so `_StrEnum` reproduces it. A bare `class X(str, Enum)` is not equivalent -- its `str()` returns `'X.MEMBER'`, which would put the wrong text into any f-string, filename or log line built from a member. Behaviour was checked against native StrEnum on 3.10, 3.12 and 3.13. Plain strings are deprecated in writing only. They warn in 0.5.0 and are removed in 1.0.0. Warning now would fire on `mode="batch"`, which is what the documentation showed until this release. This makes 0.5.0 a prerequisite for 1.0.0, since the policy is that a removal in a major release follows at least one minor release that warns. Protocols for the three replaceable strategies, plus KernelFunction, replacing bare Callable aliases. Structural, so nothing inherits from them and every existing callable already satisfies its protocol. Parameters are positional-only: without that a Protocol also requires matching parameter names, and a user's `def my_decay(rate, step, total)` would fail for naming its arguments differently. learning_rate is validated, having been unchecked. Non-positive or non-finite raises ValueError: `0` freezes every model so training completes and changes nothing, and `-1` moves models away from the samples they match, taking the quantization error from 0.0 to 11.7. Above 1 warns rather than raises -- it overshoots and oscillates but does not necessarily diverge, measuring max|w| 3.61 at alpha=5 with decay disabled, and Kohonen gives no upper bound. TRAINING_MODES and INITIALIZATION_MODES are now derived from the enums so the two cannot drift. The public-surface test is split in two: a subset check that nothing 0.3.0 exported has been removed, which is the compatibility promise, and an equality check on the full surface, so an addition shows up as a diff to read rather than a failure to explain. Coverage excludes a line whose entire content is `...`, which is a Protocol method body: a declaration of shape that never executes. Narrow enough to stay honest -- such lines occur only in _core/_protocols.py. --- CHANGELOG.md | 110 +++++++----- docs/api.md | 14 ++ docs/options-and-types.md | 120 +++++++++++++ mkdocs.yml | 1 + pyproject.toml | 5 + src/python_som/__init__.py | 28 +++ src/python_som/_core/_maps.py | 2 +- src/python_som/_core/_match.py | 5 +- src/python_som/_core/_neighborhood.py | 10 +- src/python_som/_core/_protocols.py | 108 +++++++++++ src/python_som/_enums.py | 109 ++++++++++++ src/python_som/_som.py | 77 ++++++-- tests/test_construction.py | 4 +- tests/test_core_boundary.py | 53 +++++- tests/test_end_to_end.py | 7 +- tests/test_kernel_equivalence.py | 3 +- tests/test_training.py | 16 +- tests/test_typed_seams.py | 246 ++++++++++++++++++++++++++ tests/test_weight_init.py | 3 +- 19 files changed, 847 insertions(+), 74 deletions(-) create mode 100644 docs/options-and-types.md create mode 100644 src/python_som/_core/_protocols.py create mode 100644 src/python_som/_enums.py create mode 100644 tests/test_typed_seams.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1770e12..61a5e6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,58 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **Enums for every string-valued option**: `TrainingMode`, `Neighborhood`, `WeightInit` and + `SampleMode`. Each member *is* a `str`, so `TrainingMode.BATCH` and `"batch"` are + interchangeable: equal, same hash, same JSON, usable as the same dictionary key. Nothing that + accepted a string stops accepting one. + + The parameters are typed as the enum *or* the exact set of valid strings, so a type checker now + rejects `mode="bacth"` while accepting both `mode="batch"` and `mode=TrainingMode.BATCH`. If you + build an option from configuration, annotate it with the matching `TrainingModeStr` alias or call + `TrainingMode(value)` to validate it at runtime. + + **Plain strings are deprecated but do not warn yet.** They warn in 0.5.0 and are removed in 1.0.0. + Warning now would fire on `mode="batch"`, which is what the documentation showed until this + release, so the written notice comes first. See `docs/options-and-types.md`. + +- **`Protocol`s for the three replaceable strategies**: `NeighborhoodFunction`, `DecayFunction` and + `DistanceFunction`, plus `KernelFunction` for the batch kernel. Structural, so nothing inherits + from them and every existing callable already satisfies its protocol; their parameters are + positional-only, so your own parameter names are your own. A type checker now checks a + user-supplied strategy against a named contract instead of a bare `Callable`. + +### Changed + +- **`learning_rate` is validated**, having been unchecked. A non-positive or non-finite rate raises + `ValueError`: `0` freezes every model so training completes and changes nothing, and `-1` moves + models away from the samples they match, taking the quantization error from 0.0 to 11.7. Both were + previously accepted in silence. + + A rate above 1 emits `UserWarning` rather than raising. It overshoots and oscillates but does not + necessarily diverge: at `alpha = 5` with decay disabled the largest weight stayed at 3.61, because + the neighborhood damps the correction away from the winner. Kohonen gives no upper bound, so + rejecting it would invent a limit the sources do not. + +- The package is now a pure functional core with a thin shell around it. `python_som._core` holds + every numeric decision as functions over NumPy arrays and imports nothing but NumPy; + `python_som._convert` adapts pandas at the boundary; `python_som._som` keeps the state, the + validation and the training loops. **No public name, signature or numerical result changes** — + trained weights are bit-identical to 0.3.0 for every combination of training mode and neighborhood + function, which is asserted rather than assumed. + + The boundary is machine-checked, not merely documented: ruff's `TID251` bans pandas and + scikit-learn outside the two shell modules that exist to adapt them, and reports the reason at the + point of violation. `architecture-profile.toml` records the style. + +- The two update rules are now pure functions returning new arrays. An earlier note claimed this + was also faster; **it is not**, and the claim is withdrawn. Measured with interleaved arms and + medians, the pure form is about 10% slower on small maps and indistinguishable above roughly + 100x100. The cost buys functions testable without constructing a network, and is single-digit + milliseconds over a 10,000-iteration run on a 20x20 map. `benchmarks/bench_update.py` is the + corrected measurement. + ### Performance - **Batch training is 1.2x to 1.5x faster**, with identical results. Eq. (8) needs the neighborhood @@ -32,26 +84,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Stepwise training is unaffected: it already evaluates the neighborhood once per iteration, so there is nothing to amortize. -### Removed - -- **pandas and scikit-learn are no longer required.** `numpy` is the only runtime dependency. - Measured on Linux/CPython 3.12, a fresh install drops from **333 MB across 10 packages to 69 MB - across 1** — a 264 MB reduction, 79% of the payload, since the two also pulled in scipy, joblib, - narwhals, python-dateutil, six and threadpoolctl. - - Nothing is lost, and input support is *wider*. pandas was used for one `isinstance` check before - calling `.to_numpy()`; `np.asarray` already does that via the `__array__` protocol. Because - that is a protocol rather than a library, polars, pyarrow, xarray and CuPy objects now work too, - without this package knowing they exist. - - scikit-learn supplied one PCA and one z-score, now about twenty lines of `np.linalg.svd`. Both - libraries remain in the `dev` extra, where `tests/test_linalg_matches_sklearn.py` re-derives every - fit both ways on every CI run rather than trusting a one-off measurement. `pip install - python-som[examples]` restores the previous install set for anyone relying on it. - - If you imported pandas or scikit-learn *transitively* through this package, add them to your own - dependencies. That reliance was always an accident of packaging. - ### Fixed - **Linear initialization was inaccurate for data far from the origin.** Through 0.3.0 its PCA went @@ -69,32 +101,28 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and `auto_dimensions` is unaffected because it standardizes first). Far from the origin it is large, and 0.4.0 is the correct one. -### Changed - -- The package is now a pure functional core with a thin shell around it. `python_som._core` holds - every numeric decision as functions over NumPy arrays and imports nothing but NumPy; - `python_som._convert` adapts pandas at the boundary; `python_som._som` keeps the state, the - validation and the training loops. **No public name, signature or numerical result changes** — - trained weights are bit-identical to 0.3.0 for every combination of training mode and neighborhood - function, which is asserted rather than assumed. +- `_train_stepwise` looked up `array[index]` twice per iteration, allocating the sample twice on the + hot loop. Found by profiling the refactor rather than by reading it. - The boundary is machine-checked, not merely documented: ruff's `TID251` bans pandas and - scikit-learn outside the two shell modules that exist to adapt them, and reports the reason at the - point of violation. `architecture-profile.toml` records the style. +### Removed -- The two update rules are now pure functions returning new arrays. An earlier note claimed this - was also faster; **it is not**, and the claim is withdrawn. Measured with interleaved arms and - medians, the pure form is about 10% slower on small maps and indistinguishable above roughly - 100x100. The cost buys functions testable without constructing a network, and is single-digit - milliseconds over a 10,000-iteration run on a 20x20 map. `benchmarks/bench_update.py` is the - corrected measurement. +- **pandas and scikit-learn are no longer required.** `numpy` is the only runtime dependency. + Measured on Linux/CPython 3.12, a fresh install drops from **333 MB across 10 packages to 69 MB + across 1** — a 264 MB reduction, 79% of the payload, since the two also pulled in scipy, joblib, + narwhals, python-dateutil, six and threadpoolctl. -### Fixed + Nothing is lost, and input support is *wider*. pandas was used for one `isinstance` check before + calling `.to_numpy()`; `np.asarray` already does that via the `__array__` protocol. Because + that is a protocol rather than a library, polars, pyarrow, xarray and CuPy objects now work too, + without this package knowing they exist. -- `_train_stepwise` looked up `array[index]` twice per iteration, allocating the sample twice on the - hot loop. Found by profiling the refactor rather than by reading it. + scikit-learn supplied one PCA and one z-score, now about twenty lines of `np.linalg.svd`. Both + libraries remain in the `dev` extra, where `tests/test_linalg_matches_sklearn.py` re-derives every + fit both ways on every CI run rather than trusting a one-off measurement. `pip install + python-som[examples]` restores the previous install set for anyone relying on it. -### Removed + If you imported pandas or scikit-learn *transitively* through this package, add them to your own + dependencies. That reliance was always an accident of packaging. - The batch denominator's `1e-12` tolerance, replaced by `> 0`. The constant had no source, and it guarded a condition that cannot occur: every term of `sum_j n_j h_ji` is non-negative, because diff --git a/docs/api.md b/docs/api.md index 4e0ba36..e88c80e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -29,3 +29,17 @@ ## Distance functions ::: python_som._core._distance + +## Options + +::: python_som._enums + options: + members: + - TrainingMode + - Neighborhood + - WeightInit + - SampleMode + +## Strategy protocols + +::: python_som._core._protocols diff --git a/docs/options-and-types.md b/docs/options-and-types.md new file mode 100644 index 0000000..fbbe5f9 --- /dev/null +++ b/docs/options-and-types.md @@ -0,0 +1,120 @@ +# Options and types + +Every option that is spelled as a string has an enum member too, and the two are interchangeable. + +```python +import python_som +from python_som import Neighborhood, TrainingMode, WeightInit + +som = python_som.SOM(x=10, y=10, input_len=4, neighborhood_function=Neighborhood.GAUSSIAN) +som.weight_initialization(mode=WeightInit.LINEAR, data=data) +som.train(data, n_iteration=100, mode=TrainingMode.BATCH) +``` + +is the same call as + +```python +som = python_som.SOM(x=10, y=10, input_len=4, neighborhood_function="gaussian") +som.weight_initialization(mode="linear", data=data) +som.train(data, n_iteration=100, mode="batch") +``` + +Each enum member **is** a `str`, so `TrainingMode.BATCH == "batch"` is `True`, it hashes the same, it +serialises to `"batch"` in JSON, and it works as a dictionary key. Nothing that accepted a string +before stops accepting one. + +## Why bother + +A type checker cannot tell `"batch"` from `"bacth"`, so the misspelling survives until the +`ValueError` at runtime — after however long it took to get there. The parameters are typed as the +enum *or* the exact set of valid strings, so both spellings pass and a typo does not: + +```python +som.train(data, mode="batch") # fine +som.train(data, mode=TrainingMode.BATCH) # fine +som.train(data, mode="bacth") # error: incompatible type "Literal['bacth']" +``` + +The trade is that a variable of plain `str` type no longer satisfies the parameter. If you build a +mode from configuration, narrow it or annotate it: + +```python +from python_som import TrainingModeStr + +mode: TrainingModeStr = config["mode"] # or TrainingMode(config["mode"]) to validate at runtime +som.train(data, mode=mode) +``` + +`TrainingMode(config["mode"])` raises `ValueError` on an unknown value, which is often what you want +when the value comes from outside the program. + +## The options + +| Parameter | Enum | Values | +| --- | --- | --- | +| `SOM(neighborhood_function=...)` | `Neighborhood` | `gaussian`, `bubble`, `mexican_hat` | +| `SOM.train(mode=...)` | `TrainingMode` | `random`, `sequential`, `batch` | +| `SOM.weight_initialization(mode=...)` | `WeightInit` | `random`, `linear`, `sample` | +| `weight_initialization(sample_mode=...)` | `SampleMode` | `standard_normal`, `uniform` | + +The legacy spelling `"mexicanhat"` still resolves and is still accepted, but has no enum member: +one canonical spelling per option is most of the point of having an enum. + +## Deprecation timetable for plain strings + +Strings are deprecated as of **0.4.0**, and nothing warns yet. + +| Version | What happens | +| --- | --- | +| **0.4.0** | Enums added. Strings accepted, no warning. | +| **0.5.0** | Strings emit `DeprecationWarning`. | +| **1.0.0** | Strings removed; the enums become the only accepted form. | + +No warning in 0.4.0 is deliberate. `mode="batch"` is what this documentation showed until this page +existed, so a minor release that warned on it would be scolding people for following its own +instructions. The written notice comes first and the warning follows in 0.5.0, which also means +0.5.0 has to ship before 1.0.0 can remove anything — the policy is that a removal in a major release +is preceded by at least one minor release that warns. + +Migrating early costs nothing and is a mechanical substitution: `"batch"` becomes +`TrainingMode.BATCH`, and so on down the table above. + +## Custom strategies + +Three things can be replaced with your own implementation: the neighborhood, the decay applied to +the learning rate and radius, and the distance. Each has a `Protocol` describing what it must accept +and return, so a type checker verifies yours against a named contract rather than a bare `Callable`. + +```python +from python_som import DecayFunction + + +def linear_to_zero(value: float, step: int, total: int) -> float: + return value * (1.0 - step / total) + + +som = python_som.SOM(x=10, y=10, input_len=4, learning_rate_decay=linear_to_zero) +``` + +The protocols are **structural**: nothing needs to inherit from them, and every callable that +already worked still does. Their parameters are positional-only, so your parameter names are your +own — `linear_to_zero(value, step, total)` and `linear_to_zero(a, b, c)` both satisfy +`DecayFunction`. + +One contract is worth reading before writing a neighborhood of your own: +[`NeighborhoodFunction`][python_som.NeighborhoodFunction] must be a function of the grid distance +between two nodes, not of the two axis offsets separately. See +[Neighborhood functions](neighborhood-functions.md) for what goes wrong otherwise. + +## Learning rate + +`learning_rate` is validated from 0.4.0, having been unchecked before. + +- **Rejected**: zero, negative, `nan`, `inf`. A rate of `0` freezes every model so that training + completes and changes nothing; `-1` moves models *away* from the samples they match, taking the + quantization error from 0.0 to 11.7 in one run. Both used to be accepted in silence. +- **Warned about**: anything above 1. Eq. (3) moves a model a fraction `alpha * h` of the way to the + sample, so above 1 it overshoots and oscillates. It does not necessarily diverge — at `alpha = 5` + with decay disabled the largest weight stayed at 3.61, because the neighborhood damps the + correction away from the winner — and Kohonen gives no upper bound, so it is a warning rather than + an error. diff --git a/mkdocs.yml b/mkdocs.yml index ed3c0c9..89e52dc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,6 +32,7 @@ nav: - Getting started: getting-started.md - Neighborhood functions: neighborhood-functions.md - Training modes: training-modes.md + - Options and types: options-and-types.md - API reference: api.md - Changelog: changelog.md diff --git a/pyproject.toml b/pyproject.toml index 73cee94..934fa62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -199,4 +199,9 @@ exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "raise NotImplementedError", + # A bare `...` is a Protocol method body: a declaration of shape that is never executed, since + # the protocols are structural and nothing inherits from them. Excluded as a rule rather than + # with four identical pragmas, and narrow enough to stay honest -- it matches only a line whose + # entire content is an ellipsis, which in this package occurs solely in _core/_protocols.py. + "^\\s*\\.\\.\\.$", ] diff --git a/src/python_som/__init__.py b/src/python_som/__init__.py index e04b731..fb58e81 100644 --- a/src/python_som/__init__.py +++ b/src/python_som/__init__.py @@ -35,12 +35,40 @@ gaussian, mexican_hat, ) +from ._core._protocols import ( + DecayFunction, + DistanceFunction, + KernelFunction, + NeighborhoodFunction, +) +from ._enums import ( + Neighborhood, + NeighborhoodStr, + SampleMode, + SampleModeStr, + TrainingMode, + TrainingModeStr, + WeightInit, + WeightInitStr, +) from ._som import SOM __all__ = [ "NEIGHBORHOOD_FUNCTIONS", "SIGNED_NEIGHBORHOODS", "SOM", + "DecayFunction", + "DistanceFunction", + "KernelFunction", + "Neighborhood", + "NeighborhoodFunction", + "NeighborhoodStr", + "SampleMode", + "SampleModeStr", + "TrainingMode", + "TrainingModeStr", + "WeightInit", + "WeightInitStr", "asymptotic_decay", "bubble", "euclidean_distance", diff --git a/src/python_som/_core/_maps.py b/src/python_som/_core/_maps.py index 284c0de..2b33796 100644 --- a/src/python_som/_core/_maps.py +++ b/src/python_som/_core/_maps.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: # pragma: no cover import numpy.typing as npt - from ._match import DistanceFunction + from ._protocols import DistanceFunction __all__ = ["activation_matrix", "label_map", "u_matrix", "winner_map"] diff --git a/src/python_som/_core/_match.py b/src/python_som/_core/_match.py index 23d2437..fcaf46a 100644 --- a/src/python_som/_core/_match.py +++ b/src/python_som/_core/_match.py @@ -11,12 +11,9 @@ import numpy as np if TYPE_CHECKING: # pragma: no cover - from collections.abc import Callable - import numpy.typing as npt - #: Dissimilarity between an input vector and an array of models. - DistanceFunction = Callable[[Any, Any], npt.NDArray[np.floating]] + from ._protocols import DistanceFunction __all__ = ["accumulate", "activate", "quantization", "winner"] diff --git a/src/python_som/_core/_neighborhood.py b/src/python_som/_core/_neighborhood.py index 9d5f872..662f9d1 100644 --- a/src/python_som/_core/_neighborhood.py +++ b/src/python_som/_core/_neighborhood.py @@ -23,16 +23,19 @@ from __future__ import annotations -from collections.abc import Callable from typing import Final import numpy as np import numpy.typing as npt +from ._protocols import KernelFunction, NeighborhoodFunction + __all__ = [ "NEIGHBORHOOD_FUNCTIONS", "NEIGHBORHOOD_KERNELS", "SIGNED_NEIGHBORHOODS", + "KernelFunction", + "NeighborhoodFunction", "axis_offsets", "bubble", "bubble_kernel", @@ -49,11 +52,6 @@ Grid = tuple[int, int] Coordinates = tuple[int, int] -NeighborhoodFunction = Callable[ - [Grid, Coordinates, float, tuple[bool, bool]], npt.NDArray[np.floating] -] -#: A neighborhood evaluated over every offset at once, independent of any particular winner. -KernelFunction = Callable[[Grid, float, tuple[bool, bool]], npt.NDArray[np.floating]] def _validate_radius(sigma: float, *, allow_zero: bool = False) -> None: diff --git a/src/python_som/_core/_protocols.py b/src/python_som/_core/_protocols.py new file mode 100644 index 0000000..785facc --- /dev/null +++ b/src/python_som/_core/_protocols.py @@ -0,0 +1,108 @@ +"""Contracts for the three strategies a caller can replace. + +A neighborhood, a decay and a distance are all things a user may supply their own version of. Typed +as bare ``Callable[...]`` aliases, mypy checks little more than the argument count; as Protocols it +checks the shape of the call against a named contract, and the error names the protocol rather than +printing two structural types side by side. + +**Every parameter is positional-only** (the ``/`` in each ``__call__``). Without it, a Protocol +requires the *names* to match as well as the types, so a user's ``def my_decay(rate, step, total)`` +would fail against a protocol that named them differently. Positional-only says what is actually +true: these are called positionally, and only the order and the types matter. + +These are structural, so nothing needs to inherit from them. Every function already in the package +satisfies its protocol, and so does any existing user-supplied callable with the right signature -- +this adds checking, not a requirement. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: # pragma: no cover + import numpy as np + import numpy.typing as npt + +__all__ = ["DecayFunction", "DistanceFunction", "KernelFunction", "NeighborhoodFunction"] + + +@runtime_checkable +class NeighborhoodFunction(Protocol): + """Weights the winner's correction across the grid, as a function of grid distance. + + Kohonen (2013) Eq. (5) requires this to depend on ``sqdist(c, i)`` alone -- the distance between + two nodes -- not on the two axis offsets separately. A separable product of per-axis profiles + satisfies the signature but is only correct for the gaussian. + """ + + def __call__( + self, + shape: tuple[int, int], + c: tuple[int, int], + sigma: float, + cyclic: tuple[bool, bool], + /, + ) -> npt.NDArray[np.floating]: + """Evaluate the neighborhood centred on ``c``. + + :param shape: Shape of the network. + :param c: Coordinates of the winner. + :param sigma: Neighborhood radius. + :param cyclic: Whether each axis wraps around. + :return: Weights with the shape of the network. + """ + ... + + +@runtime_checkable +class DecayFunction(Protocol): + """Reduces a learning rate or a neighborhood radius as training proceeds.""" + + def __call__(self, value: float, step: int, total: int, /) -> float: + """Return the decayed value for this step. + + :param value: The initial value being decayed. + :param step: Current iteration, counted from zero. + :param total: Total number of iterations. + :return: The value to use at this step. + """ + ... + + +@runtime_checkable +class DistanceFunction(Protocol): + """Dissimilarity between an input vector and one or many models. + + Called both with a single model and with the whole ``(x, y, n_features)`` array, so an + implementation must broadcast over leading axes rather than assume one vector. + """ + + def __call__(self, x: Any, weights: Any, /) -> npt.NDArray[np.floating]: # noqa: ANN401 + """Return the distance from ``x`` to each of ``weights``. + + :param x: Input vector. + :param weights: One model, or an array of them. + :return: Distances, with the leading shape of ``weights``. + """ + ... + + +@runtime_checkable +class KernelFunction(Protocol): + """A neighborhood evaluated over every offset at once, independent of any particular winner. + + The kernel form of a :class:`NeighborhoodFunction`, used by batch training so that the + neighborhood is computed once per iteration rather than once per node. + """ + + def __call__( + self, shape: tuple[int, int], sigma: float, cyclic: tuple[bool, bool], / + ) -> npt.NDArray[np.floating]: + """Evaluate the neighborhood over every reachable offset. + + :param shape: Shape of the network. + :param sigma: Neighborhood radius. + :param cyclic: Whether each axis wraps around. + :return: Weights of shape ``(2 * shape[0] - 1, 2 * shape[1] - 1)``. + """ + ... diff --git a/src/python_som/_enums.py b/src/python_som/_enums.py new file mode 100644 index 0000000..4d6aeec --- /dev/null +++ b/src/python_som/_enums.py @@ -0,0 +1,109 @@ +"""Names for the string-valued options, so a typo is a type error rather than a runtime one. + +Every option these cover is still accepted as a plain string, and will be for the whole 0.4.x and +0.5.x series. ``mode=TrainingMode.BATCH`` and ``mode="batch"`` are interchangeable, compare equal, +hash equal, and serialise to the same JSON, because each member *is* a ``str``. + +**Deprecation runway.** Plain strings are deprecated as of 0.4.0 but emit nothing: warning +immediately would fire on ``mode="batch"``, which is what the README and every documentation page +currently show. They begin emitting ``DeprecationWarning`` in 0.5.0 and are removed in 1.0.0. + +**On the base class.** ``enum.StrEnum`` arrived in Python 3.11 and this package supports 3.10, so +:class:`_StrEnum` reproduces it. A bare ``class X(str, Enum)`` is *not* equivalent: its ``str()`` +returns ``'X.MEMBER'`` rather than the value, which would put the wrong text into any f-string, +filename or log line built from a member. Defining ``__str__`` explicitly makes the behaviour +identical on every supported version, which was checked on 3.10, 3.12 and 3.13 rather than assumed. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Literal + +__all__ = [ + "Neighborhood", + "NeighborhoodStr", + "SampleMode", + "SampleModeStr", + "TrainingMode", + "TrainingModeStr", + "WeightInit", + "WeightInitStr", +] + + +class _StrEnum(str, Enum): + """A string enum whose members render as their value on every supported Python version.""" + + def __str__(self) -> str: + """Return the member's value, as ``enum.StrEnum`` does. + + :return: The string value. + """ + return str(self.value) + + +class TrainingMode(_StrEnum): + """How :meth:`~python_som.SOM.train` presents the data. + + ``RANDOM`` and ``SEQUENTIAL`` are the stepwise algorithm of Kohonen (2013) Eq. (3), differing + only in the order samples arrive. ``BATCH`` is Eq. (8), which updates every model concurrently. + """ + + RANDOM = "random" + SEQUENTIAL = "sequential" + BATCH = "batch" + + +class Neighborhood(_StrEnum): + """Which neighborhood function spreads the winner's correction over the grid. + + ``MEXICAN_HAT`` takes negative values and so cannot be used with :attr:`TrainingMode.BATCH`; see + :data:`~python_som.SIGNED_NEIGHBORHOODS`. The legacy spelling ``"mexicanhat"`` remains accepted + as a plain string and resolves to the same function, but is not given a member of its own: one + canonical spelling per option is the point of having an enum. + """ + + GAUSSIAN = "gaussian" + BUBBLE = "bubble" + MEXICAN_HAT = "mexican_hat" + + +class WeightInit(_StrEnum): + """How :meth:`~python_som.SOM.weight_initialization` seeds the models. + + ``LINEAR`` and ``SAMPLE`` both need a dataset; ``RANDOM`` does not. + """ + + RANDOM = "random" + LINEAR = "linear" + SAMPLE = "sample" + + +class SampleMode(_StrEnum): + """Which distribution :attr:`WeightInit.RANDOM` draws from.""" + + STANDARD_NORMAL = "standard_normal" + UNIFORM = "uniform" + + +# The literal spellings, so that passing a plain string is still checked. Annotating these +# parameters as bare ``str`` would accept ``mode="bacth"`` silently, which is most of what the enums +# are for; a union of the enum and a ``Literal`` catches the typo and keeps both spellings working. +# +# The cost is that code passing a variable of unannotated ``str`` type now needs a cast or a +# narrowing check. That is the intended trade: an unvalidated string reaching a mode parameter is +# exactly the case worth surfacing, and it is still accepted at runtime. + +#: Accepted strings for :meth:`~python_som.SOM.train`. +TrainingModeStr = Literal["random", "sequential", "batch"] + +#: Accepted strings for ``neighborhood_function``. Includes the legacy ``"mexicanhat"`` spelling, +#: which has no enum member but still resolves. +NeighborhoodStr = Literal["gaussian", "bubble", "mexican_hat", "mexicanhat"] + +#: Accepted strings for :meth:`~python_som.SOM.weight_initialization`. +WeightInitStr = Literal["random", "linear", "sample"] + +#: Accepted strings for the ``sample_mode`` argument of random initialization. +SampleModeStr = Literal["standard_normal", "uniform"] diff --git a/src/python_som/_som.py b/src/python_som/_som.py index d2453f4..924b847 100644 --- a/src/python_som/_som.py +++ b/src/python_som/_som.py @@ -13,6 +13,7 @@ import logging import secrets +import warnings from typing import TYPE_CHECKING, Any, TypeVar import numpy as np @@ -32,13 +33,23 @@ resolve_kernel, ) from ._core._update import batch_update, stepwise_update +from ._enums import ( + Neighborhood, + NeighborhoodStr, + SampleMode, + TrainingMode, + TrainingModeStr, + WeightInit, + WeightInitStr, +) if TYPE_CHECKING: # pragma: no cover from collections import Counter - from collections.abc import Callable, Iterable + from collections.abc import Iterable from ._convert import DataLike from ._core._neighborhood import NeighborhoodFunction + from ._core._protocols import DecayFunction, DistanceFunction try: import tqdm @@ -53,8 +64,10 @@ _T = TypeVar("_T") -TRAINING_MODES = ("random", "sequential", "batch") -INITIALIZATION_MODES = ("random", "linear", "sample") +#: Accepted values, derived from the enums so the two cannot drift. Kept as plain strings because +#: they appear verbatim in error messages. +TRAINING_MODES = tuple(m.value for m in TrainingMode) +INITIALIZATION_MODES = tuple(m.value for m in WeightInit) #: Iterations per sample when ``n_iteration`` is not given, by training mode. Kohonen (2013) #: Section 3.1: the batch process "usually needs to be reiterated a few to a few dozen times", @@ -66,6 +79,45 @@ _SEED_BITS = 128 +#: Learning rates above this are accepted but warned about. Kohonen gives no hard upper bound, so +#: this is a plausibility threshold rather than a limit: Eq. (3) moves a model a fraction +#: ``alpha * h`` of the way to the sample, and a fraction above 1 overshoots it. +_IMPLAUSIBLE_LEARNING_RATE = 1.0 + + +def _validate_learning_rate(learning_rate: float) -> None: + """Reject a learning rate that cannot train, and warn about one that is merely unwise. + + Unchecked through 0.3.0, and the two failure modes are different in kind: + + A **non-positive** rate is rejected. ``alpha = 0`` freezes every model, so training runs to + completion and changes nothing. ``alpha = -1`` is worse: it moves models *away* from the samples + they match, taking the quantization error from 0.0 to 11.7 and the largest weight to 30 on a map + that started inside the unit cube. Neither can be what a caller meant, and both are silent. + + A rate **above 1** is warned about, not rejected. Eq. (3) moves a model a fraction ``alpha * h`` + of the way to the sample, so above 1 it overshoots and oscillates around the target rather than + settling on it. It does not necessarily diverge: measured at ``alpha = 5`` with decay disabled, + the largest weight stayed at 3.61, because the neighborhood damps the correction away from the + winner. Kohonen sets no upper bound, so rejecting it would invent a limit the sources do not + give. + + :param learning_rate: The rate to check. + :raises ValueError: If the rate is not a finite positive number. + """ + if not np.isfinite(learning_rate) or learning_rate <= 0: + msg = f"'learning_rate' must be a finite positive number, got {learning_rate!r}" + raise ValueError(msg) + if learning_rate > _IMPLAUSIBLE_LEARNING_RATE: + warnings.warn( + f"'learning_rate' is {learning_rate!r}, above 1. Each step moves a model more than the " + "whole distance to the sample, so training will overshoot and oscillate rather than " + "converge. Kohonen (2013) Section 4.1 uses rates below 1.", + UserWarning, + stacklevel=3, + ) + + class SOM: """A 2-D self-organizing map over NumPy arrays, pandas DataFrames or plain lists. @@ -90,11 +142,11 @@ def __init__( y: int | None, input_len: int, learning_rate: float = 0.5, - learning_rate_decay: Callable[[float, int, int], float] = asymptotic_decay, + learning_rate_decay: DecayFunction = asymptotic_decay, neighborhood_radius: float = 1.0, - neighborhood_radius_decay: Callable[[float, int, int], float] = asymptotic_decay, - neighborhood_function: str = "gaussian", - distance_function: Callable[[Any, Any], npt.NDArray[np.floating]] = euclidean_distance, + neighborhood_radius_decay: DecayFunction = asymptotic_decay, + neighborhood_function: Neighborhood | NeighborhoodStr = Neighborhood.GAUSSIAN, + distance_function: DistanceFunction = euclidean_distance, cyclic_x: bool = False, cyclic_y: bool = False, random_seed: int | None = None, @@ -150,6 +202,7 @@ def __init__( f"got {min_neighborhood_radius!r}" ) raise ValueError(msg) + _validate_learning_rate(learning_rate) self._shape: tuple[int, int] = (int(x), int(y)) self._input_len = int(input_len) @@ -298,7 +351,7 @@ def train( self, data: DataLike, n_iteration: int | None = None, - mode: str = "random", + mode: TrainingMode | TrainingModeStr = TrainingMode.RANDOM, verbose: bool = False, ) -> float: """Train the map and return the resulting quantization error. @@ -450,7 +503,11 @@ def neighborhood_of( # ------------------------------------------------------------------ initialization - def weight_initialization(self, mode: str = "random", **kwargs: Any) -> None: # noqa: ANN401 + def weight_initialization( + self, + mode: WeightInit | WeightInitStr = WeightInit.RANDOM, + **kwargs: Any, # noqa: ANN401 + ) -> None: """Initialize the models of the network. :param mode: One of ``'random'``, ``'linear'`` or ``'sample'``. @@ -471,7 +528,7 @@ def weight_initialization(self, mode: str = "random", **kwargs: Any) -> None: # self._shape, self._input_len, self._rng, - sample_mode=kwargs.pop("sample_mode", "standard_normal"), + sample_mode=kwargs.pop("sample_mode", SampleMode.STANDARD_NORMAL), ) elif mode == "linear": self._weights = linear_models(to_numpy(kwargs.pop("data")), self._shape) diff --git a/tests/test_construction.py b/tests/test_construction.py index 0636e7d..e9343e8 100644 --- a/tests/test_construction.py +++ b/tests/test_construction.py @@ -64,7 +64,9 @@ def test_non_positive_input_len_raises() -> None: def test_unknown_neighborhood_function_raises_listing_the_options() -> None: with pytest.raises(ValueError, match="neighborhood_function") as excinfo: - python_som.SOM(x=5, y=5, input_len=3, neighborhood_function="sombrero") + # Deliberately invalid. The Literal annotation is what stops this reaching a caller in + # the first place; the runtime check is what catches it when it comes from a config file. + python_som.SOM(x=5, y=5, input_len=3, neighborhood_function="sombrero") # type: ignore[arg-type] assert "gaussian" in str(excinfo.value) diff --git a/tests/test_core_boundary.py b/tests/test_core_boundary.py index b1f6d38..7f86549 100644 --- a/tests/test_core_boundary.py +++ b/tests/test_core_boundary.py @@ -198,16 +198,63 @@ def test_a_signed_neighborhood_moves_models_away() -> None: # --------------------------------------------------------------------------------------------- -# The public surface is unchanged by the split +# The public surface # --------------------------------------------------------------------------------------------- +#: Everything 0.3.0 exported. Nothing here may disappear before 1.0.0. +_SHIPPED_IN_0_3_0 = frozenset( + { + "NEIGHBORHOOD_FUNCTIONS", + "SIGNED_NEIGHBORHOODS", + "SOM", + "asymptotic_decay", + "bubble", + "euclidean_distance", + "exponential_decay", + "gaussian", + "inverse_decay", + "linear_decay", + "mexican_hat", + } +) + + +def test_nothing_that_0_3_0_exported_has_been_removed() -> None: + """The compatibility promise, stated separately from the current surface. -def test_public_surface_is_exactly_what_0_3_0_shipped() -> None: - """The module split must not add or remove a single public name.""" + Removing a name is a breaking change and belongs in 1.0.0. Adding one is not, which is why this + is a subset check rather than an equality check -- the equality check lives in the test below, + where a deliberate addition shows up as a diff to read rather than a failure to explain. + """ + missing = _SHIPPED_IN_0_3_0 - set(python_som.__all__) + assert not missing, f"0.3.0 exported these and they are gone: {sorted(missing)}" + for name in sorted(_SHIPPED_IN_0_3_0): + assert hasattr(python_som, name), f"{name} is in __all__ but not importable" + + +def test_the_public_surface_is_exactly_this() -> None: + """Pin the whole surface, so growing it is a decision rather than an accident. + + 0.4.0 adds the enums, their ``Literal`` counterparts, and the strategy protocols. All are + additive: every existing call keeps working, and the enum members are ``str`` subclasses that + compare equal to the strings they replace. + """ assert sorted(python_som.__all__) == [ + "DecayFunction", + "DistanceFunction", + "KernelFunction", "NEIGHBORHOOD_FUNCTIONS", + "Neighborhood", + "NeighborhoodFunction", + "NeighborhoodStr", "SIGNED_NEIGHBORHOODS", "SOM", + "SampleMode", + "SampleModeStr", + "TrainingMode", + "TrainingModeStr", + "WeightInit", + "WeightInitStr", "asymptotic_decay", "bubble", "euclidean_distance", diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index c7defdb..9d12f85 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -10,9 +10,14 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import numpy as np import pytest +if TYPE_CHECKING: # pragma: no cover + from python_som._enums import TrainingModeStr + import python_som from tests.conftest import SEED @@ -75,7 +80,7 @@ def test_the_u_matrix_shows_the_cluster_boundaries( @pytest.mark.parametrize("mode", ["random", "sequential", "batch"]) def test_every_mode_reaches_a_usable_map( - mode: str, clusters: tuple[np.ndarray, np.ndarray] + mode: TrainingModeStr, clusters: tuple[np.ndarray, np.ndarray] ) -> None: """All three training modes must produce finite models and reduce the error.""" data, _ = clusters diff --git a/tests/test_kernel_equivalence.py b/tests/test_kernel_equivalence.py index 08dafa4..c7f3e79 100644 --- a/tests/test_kernel_equivalence.py +++ b/tests/test_kernel_equivalence.py @@ -41,6 +41,7 @@ from collections.abc import Callable from python_som._core._neighborhood import NeighborhoodFunction + from python_som._enums import NeighborhoodStr #: Grid shapes to sweep, including degenerate single-row and single-column maps, where the offset #: span collapses and an off-by-one in the slice would be invisible on a square grid. @@ -244,7 +245,7 @@ def test_resolve_kernel_rejects_an_unknown_name() -> None: @pytest.mark.parametrize("neighborhood", ["gaussian", "bubble"]) @pytest.mark.parametrize("cyclic", [(False, False), (True, True), (True, False)]) def test_batch_training_is_unchanged_by_the_kernel( - neighborhood: str, cyclic: tuple[bool, bool] + neighborhood: NeighborhoodStr, cyclic: tuple[bool, bool] ) -> None: """Many iterations, with a decaying radius, against the per-node path it replaced. diff --git a/tests/test_training.py b/tests/test_training.py index 9c531e4..886eaf0 100644 --- a/tests/test_training.py +++ b/tests/test_training.py @@ -7,9 +7,14 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import numpy as np import pytest +if TYPE_CHECKING: # pragma: no cover + from python_som._enums import TrainingModeStr + import python_som from tests.conftest import SEED, make_som @@ -43,7 +48,7 @@ def test_batch_preserves_finiteness(blobs: np.ndarray) -> None: @pytest.mark.parametrize("mode", ["random", "sequential", "batch"]) -def test_training_reduces_quantization_error(mode: str, blobs: np.ndarray) -> None: +def test_training_reduces_quantization_error(mode: TrainingModeStr, blobs: np.ndarray) -> None: """Training should fit the data better than the initial state.""" som = make_som(x=8, y=8, neighborhood_radius=2.0, learning_rate=0.5) before = som.quantization_error(blobs) @@ -53,7 +58,7 @@ def test_training_reduces_quantization_error(mode: str, blobs: np.ndarray) -> No @pytest.mark.parametrize("mode", ["random", "sequential"]) def test_stepwise_runs_the_requested_number_of_iterations( - mode: str, blobs: np.ndarray, monkeypatch: pytest.MonkeyPatch + mode: TrainingModeStr, blobs: np.ndarray, monkeypatch: pytest.MonkeyPatch ) -> None: """Regression: sequential mode used to run ``len(data)`` steps regardless of ``n_iteration``. @@ -147,7 +152,7 @@ def test_batch_rejects_the_alias_too(blobs: np.ndarray) -> None: @pytest.mark.parametrize("mode", ["random", "sequential"]) -def test_stepwise_accepts_a_signed_neighborhood(mode: str, blobs: np.ndarray) -> None: +def test_stepwise_accepts_a_signed_neighborhood(mode: TrainingModeStr, blobs: np.ndarray) -> None: """The mexican hat is fine for stepwise training; only the batch mean is undefined.""" som = make_som(x=12, y=12, neighborhood_function="mexicanhat") som.train(blobs, n_iteration=30, mode=mode) @@ -156,7 +161,7 @@ def test_stepwise_accepts_a_signed_neighborhood(mode: str, blobs: np.ndarray) -> @pytest.mark.parametrize(("mode", "per_sample"), [("batch", 10), ("sequential", 1000)]) def test_omitting_n_iteration_uses_the_documented_default( - mode: str, per_sample: int, monkeypatch: pytest.MonkeyPatch + mode: TrainingModeStr, per_sample: int, monkeypatch: pytest.MonkeyPatch ) -> None: """The default is 1000 iterations per sample for stepwise modes and 10 for batch. @@ -191,7 +196,8 @@ def test_verbose_training_runs_with_a_progress_bar(blobs: np.ndarray) -> None: def test_unknown_mode_raises_value_error() -> None: som = make_som(x=5, y=5) with pytest.raises(ValueError, match="mode"): - som.train(np.zeros((4, 3)), n_iteration=1, mode="stochastic") + # Deliberately invalid: the Literal annotation is what stops this reaching a caller. + som.train(np.zeros((4, 3)), n_iteration=1, mode="stochastic") # type: ignore[arg-type] def test_empty_dataset_raises_value_error() -> None: diff --git a/tests/test_typed_seams.py b/tests/test_typed_seams.py new file mode 100644 index 0000000..78f93cd --- /dev/null +++ b/tests/test_typed_seams.py @@ -0,0 +1,246 @@ +"""The enums, the strategy protocols, and learning-rate validation. + +Three additions in 0.4.0 that share one property: every call that worked before still works. The +enums are ``str`` subclasses, the protocols are structural, and the only behaviour that changes is +that a learning rate which could never have trained is now rejected instead of silently accepted. +""" + +from __future__ import annotations + +import json +import warnings +from typing import TYPE_CHECKING + +import numpy as np +import pytest + +import python_som +from python_som import ( + DecayFunction, + DistanceFunction, + Neighborhood, + NeighborhoodFunction, + SampleMode, + TrainingMode, + WeightInit, +) +from python_som._core._neighborhood import NEIGHBORHOOD_FUNCTIONS, bubble, gaussian +from python_som._som import INITIALIZATION_MODES, TRAINING_MODES +from tests.conftest import make_som + +if TYPE_CHECKING: # pragma: no cover + from enum import Enum + +ALL_ENUMS = [TrainingMode, Neighborhood, WeightInit, SampleMode] + + +# --------------------------------------------------------------------------------------------- +# The enums behave as strings, on every supported Python version +# --------------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize("enum", ALL_ENUMS) +def test_every_member_is_a_string_equal_to_its_value(enum: type[Enum]) -> None: + """The property that makes this additive rather than breaking. + + Existing code compares these against plain strings and uses them as dict keys; all of that has + to keep working, which it does only because each member *is* a ``str``. + """ + for member in enum: + assert isinstance(member, str) + assert member == member.value + assert hash(member) == hash(member.value) + assert {member.value: "found"}[member] == "found" + + +@pytest.mark.parametrize("enum", ALL_ENUMS) +def test_every_member_renders_as_its_value(enum: type[Enum]) -> None: + """``enum.StrEnum`` is 3.11+, so the base class here is a shim; this is what it must reproduce. + + A bare ``class X(str, Enum)`` renders as ``'X.MEMBER'``, which would silently put the wrong text + into an f-string, a filename or a log line. Checked for ``str``, f-strings, ``format`` and JSON, + because they take different paths through the type. + """ + for member in enum: + assert str(member) == member.value + assert f"{member}" == member.value + assert format(member) == member.value + assert json.loads(json.dumps({"m": member}))["m"] == member.value + + +def test_the_enums_cover_exactly_the_accepted_values() -> None: + """The enums and the runtime validation must not drift apart. + + ``TRAINING_MODES`` and ``INITIALIZATION_MODES`` are derived from the enums, so this is really + checking that the derivation is still wired up rather than hand-maintained again. + """ + assert set(TRAINING_MODES) == {m.value for m in TrainingMode} + assert set(INITIALIZATION_MODES) == {m.value for m in WeightInit} + + +def test_every_neighborhood_member_resolves() -> None: + """The registry is keyed by string, so a member has to be a valid key. + + ``mexicanhat`` is deliberately absent from the enum -- one canonical spelling per option -- but + must still resolve as a plain string, which the second assertion covers. + """ + for member in Neighborhood: + assert member.value in NEIGHBORHOOD_FUNCTIONS + assert "mexicanhat" in NEIGHBORHOOD_FUNCTIONS, "the legacy spelling must keep working" + + +# --------------------------------------------------------------------------------------------- +# Enums and strings are interchangeable at every call site that takes one +# --------------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("as_enum", "as_string"), + [ + (TrainingMode.RANDOM, "random"), + (TrainingMode.SEQUENTIAL, "sequential"), + (TrainingMode.BATCH, "batch"), + ], +) +def test_training_accepts_either_spelling_with_identical_results( + as_enum: TrainingMode, as_string: str, blobs: np.ndarray +) -> None: + """Not merely accepted -- identical, since the enum member is the string it wraps.""" + first = make_som(x=6, y=5) + second = make_som(x=6, y=5) + error_enum = first.train(blobs, n_iteration=15, mode=as_enum) + error_string = second.train(blobs, n_iteration=15, mode=as_string) # type: ignore[arg-type] + + assert error_enum == error_string + np.testing.assert_array_equal(first.get_weights(), second.get_weights()) + + +@pytest.mark.parametrize("member", list(WeightInit)) +def test_weight_initialization_accepts_either_spelling( + member: WeightInit, blobs: np.ndarray +) -> None: + som = make_som(x=5, y=4) + kwargs = {} if member is WeightInit.RANDOM else {"data": blobs} + som.weight_initialization(mode=member, **kwargs) + from_enum = som.get_weights().copy() + + other = make_som(x=5, y=4) + other.weight_initialization(mode=member.value, **kwargs) + np.testing.assert_array_equal(from_enum, other.get_weights()) + + +@pytest.mark.parametrize("member", list(Neighborhood)) +def test_the_constructor_accepts_either_spelling(member: Neighborhood) -> None: + from_enum = python_som.SOM(x=4, y=4, input_len=3, neighborhood_function=member, random_seed=1) + from_string = python_som.SOM( + x=4, + y=4, + input_len=3, + neighborhood_function=member.value, + random_seed=1, + ) + np.testing.assert_array_equal( + from_enum.neighborhood((2, 2), 1.5), from_string.neighborhood((2, 2), 1.5) + ) + + +def test_sample_mode_accepts_either_spelling() -> None: + for mode in SampleMode: + first = make_som(x=4, y=4) + first.weight_initialization(mode=WeightInit.RANDOM, sample_mode=mode) + second = make_som(x=4, y=4) + second.weight_initialization(mode=WeightInit.RANDOM, sample_mode=mode.value) + np.testing.assert_array_equal(first.get_weights(), second.get_weights()) + + +# --------------------------------------------------------------------------------------------- +# The strategy protocols are satisfied by what already exists +# --------------------------------------------------------------------------------------------- + + +def test_the_shipped_functions_satisfy_their_protocols() -> None: + """Structural, so this is a statement about shape rather than inheritance. + + ``runtime_checkable`` only verifies that ``__call__`` exists, not its signature -- that is + mypy's job, and the suite type-checks under ``--strict``. This catches the coarser mistake of a + protocol that nothing at all satisfies. + """ + assert isinstance(gaussian, NeighborhoodFunction) + assert isinstance(bubble, NeighborhoodFunction) + assert isinstance(python_som.asymptotic_decay, DecayFunction) + assert isinstance(python_som.linear_decay, DecayFunction) + assert isinstance(python_som.euclidean_distance, DistanceFunction) + + +def test_a_user_supplied_strategy_still_works() -> None: + """The protocols add checking, not a requirement to inherit from anything. + + Parameter names deliberately differ from the protocol's, which is why each protocol declares + its parameters positional-only. Without that, this callable would fail type checking purely for + naming its arguments differently. + """ + + def my_decay(start: float, at: int, out_of: int) -> float: + return float(start) * (1.0 - at / out_of) + + som = python_som.SOM( + x=5, + y=5, + input_len=3, + learning_rate_decay=my_decay, + neighborhood_radius_decay=my_decay, + random_seed=3, + ) + error = som.train(np.random.default_rng(0).normal(size=(40, 3)), n_iteration=10) + assert np.isfinite(error) + + +# --------------------------------------------------------------------------------------------- +# learning_rate validation +# --------------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize("rate", [0.0, -0.5, -1.0, float("nan"), float("-inf"), float("inf")]) +def test_a_rate_that_cannot_train_is_rejected(rate: float) -> None: + """Silently accepted through 0.3.0. + + ``0`` freezes every model; ``-1`` drives them away from the data, taking the quantization error + from 0.0 to 11.7. Neither can be intended, and neither announced itself. + + ``nan`` needs the explicit ``isfinite`` check: ``nan <= 0`` is ``False``, so a bare comparison + lets it through and every weight becomes ``nan`` on the first step. + """ + with pytest.raises(ValueError, match="'learning_rate' must be a finite positive number"): + python_som.SOM(x=4, y=4, input_len=3, learning_rate=rate) + + +@pytest.mark.parametrize("rate", [1.0, 0.5, 0.001]) +def test_a_plausible_rate_is_silent(rate: float) -> None: + """Including exactly 1.0, which is the boundary and is not warned about.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + python_som.SOM(x=4, y=4, input_len=3, learning_rate=rate) + + +@pytest.mark.parametrize("rate", [1.0001, 2.0, 5.0]) +def test_a_rate_above_one_warns_but_is_accepted(rate: float) -> None: + """Warned rather than rejected, because it is unwise rather than impossible. + + Kohonen gives no upper bound, and at ``alpha = 5`` with decay disabled the largest weight stayed + at 3.61 -- the neighborhood damps the correction away from the winner. Rejecting it would invent + a limit the sources do not give. + """ + with pytest.warns(UserWarning, match="above 1"): + som = python_som.SOM(x=4, y=4, input_len=3, learning_rate=rate, random_seed=2) + assert som.get_shape() == (4, 4) + + +def test_the_warning_points_at_the_caller() -> None: + """``stacklevel`` must name the user's line, not a line inside this package. + + A warning that blames ``_som.py`` tells the reader nothing about which of their own constructor + calls to fix. + """ + with pytest.warns(UserWarning, match="above 1") as caught: + python_som.SOM(x=4, y=4, input_len=3, learning_rate=3.0) + assert caught[0].filename == __file__, f"warning blamed {caught[0].filename}" diff --git a/tests/test_weight_init.py b/tests/test_weight_init.py index f167746..48b221d 100644 --- a/tests/test_weight_init.py +++ b/tests/test_weight_init.py @@ -114,7 +114,8 @@ def test_random_init_rejects_an_unknown_sample_mode() -> None: def test_unknown_initialization_mode_raises() -> None: som = make_som(x=4, y=4) with pytest.raises(ValueError, match="mode"): - som.weight_initialization(mode="spectral") + # Deliberately invalid; see the note in test_construction.py. + som.weight_initialization(mode="spectral") # type: ignore[arg-type] def test_initialization_is_reproducible_from_the_seed() -> None: