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: