From 9561a5e7abab6b70a2675680725ed508f47e1a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Moreira=20Souza?= Date: Thu, 30 Jul 2026 15:11:30 -0300 Subject: [PATCH] feat: plain strings emit DeprecationWarning 0.4.0 deprecated the plain-string options in writing only, because `mode="batch"` was what every documentation page showed at the time. This is the warning that follows the written notice, and the last step before 1.0.0 removes strings entirely: the policy is that a removal in a major release follows at least one minor release that warns. The message names the exact replacement rather than describing one. A deprecation that makes the reader work out the substitution is a deprecation people silence. Two cases deliberately stay silent. A string that names nothing does not warn. `mode="stochastic"` still raises its ValueError, rather than burying the real mistake under a notice about modernising a spelling that never worked. The first implementation warned before validating, which under `-W error` replaced the ValueError outright; warning only for values the enum recognises removes the ordering question entirely. Loading a map from a `.npz` does not warn. The name in an artifact is a serialisation detail, since JSON has no enums, so handing it straight to the constructor would warn the caller about a string the file chose and they never wrote. The loader converts it to the member it denotes. `mexicanhat` has no member of its own and is an alias for the same function, so it maps to MEXICAN_HAT. Also fixes error messages that showed an enum's repr when one was passed: `weight_initialization(mode=WeightInit.LINEAR)` reported ` initialization requires ...`. Both spellings now produce identical text. The suite runs with `filterwarnings = ["error"]`, so 55 call sites move to enum members and the tests that exercise both spellings now assert the warning as well as the equivalence. Dedicated tests cover the deprecation itself: that the message names the replacement, that an invalid spelling raises instead, that an enum never warns, and that the warning blames the caller's line. --- CHANGELOG.md | 39 ++++++-- docs/reference/options.md | 42 ++++++--- src/python_som/_artifact.py | 33 ++++++- src/python_som/_enums.py | 54 +++++++++++- src/python_som/_som.py | 17 ++-- tests/test_analysis.py | 8 +- tests/test_artifacts.py | 67 ++++++++++++-- tests/test_construction.py | 3 +- tests/test_core_boundary.py | 11 +-- tests/test_end_to_end.py | 24 +++-- tests/test_io_types.py | 9 +- tests/test_kernel_equivalence.py | 10 +-- tests/test_training.py | 62 +++++++------ tests/test_typed_seams.py | 147 ++++++++++++++++++++++++++++--- tests/test_weight_init.py | 31 +++---- 15 files changed, 431 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca2a72..b3e84c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,43 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- **Plain strings now emit `DeprecationWarning`.** 0.4.0 deprecated them in writing only, because + `mode="batch"` was what the documentation showed at the time. This is the warning that follows the + written notice, and it is the last step before 1.0.0 removes strings entirely. + + The message names the exact replacement, so migrating is a substitution you read off it: + + ``` + Passing mode='batch' as a plain string is deprecated and will stop working in 1.0.0. + Use TrainingMode.BATCH instead. + ``` + + Two things it deliberately does not do. It does not fire for a spelling that was never valid, so + `mode="stochastic"` still raises its `ValueError` instead of burying the real mistake under a + deprecation notice. And it does not fire when a map is loaded from a `.npz`: the name in an + artifact is a serialisation detail, since JSON has no enums, so the loader converts it rather than + warning about a string the caller never wrote. + + To silence it while migrating, filter `DeprecationWarning` for the `python_som` module. + +### Fixed + +- Error messages showed an enum's repr when one was passed. `weight_initialization(mode=WeightInit + .LINEAR)` reported ` initialization requires ...`. Both spellings now + produce identical text. + ## [0.4.0] - 2026-07-30 `numpy` is now the only runtime dependency, and a trained map can be saved without `pickle`. **Results change in two places.** Linear initialization is corrected for data far from the origin, where 0.3.0 was wrong by up to 5.8%; and anyone who imported pandas or scikit-learn transitively -through this package must now depend on them directly. Everything else is additive: no public name or -signature is removed, and `mode="batch"` and the other plain strings keep working. +through this package must now depend on them directly. Everything else is additive: no public name +or signature is removed, and `mode="batch"` and the other plain strings keep working. To reproduce a figure made with an earlier version, pin that version. @@ -113,13 +142,13 @@ To reproduce a figure made with an earlier version, pin that version. - **Batch training is 1.2x to 1.5x faster**, with identical results. Eq. (8) needs the neighborhood between every pair of nodes, and the previous implementation got it by evaluating the neighborhood - function once per node, once per iteration, which is 1600 full-grid evaluations per iteration on a 40x40 + function once per node, once per iteration: 1600 full-grid evaluations per iteration on a 40x40 map. Profiling made that 42% of batch training, more than the contraction it feeds. It is unnecessary, because a neighborhood depends only on the offset between two nodes and never on where the winner sits. One kernel over every reachable offset serves the whole grid, and - each node's neighborhood is a slice of it, as a view rather than a copy. The kernel costs `4xy` floats, - 111 KB on a 60x60 map, against the 800 MB a full `(x, y, x, y)` tensor would need. + each node's neighborhood is a slice of it, as a view rather than a copy. The kernel is `4xy` + floats, 111 KB on a 60x60 map, against the 800 MB a full `(x, y, x, y)` tensor would need. The offset-only dependence holds on a torus as well as a flat grid, because making the fold depend on the offset alone is exactly what the minimum-image convention does. It holds per axis, so mixed diff --git a/docs/reference/options.md b/docs/reference/options.md index 1c5f305..79c50ec 100644 --- a/docs/reference/options.md +++ b/docs/reference/options.md @@ -19,8 +19,8 @@ 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 +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 @@ -62,19 +62,35 @@ 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. +| 0.4.0 | Enums added. Strings accepted, no warning. | +| **0.5.0 (current)** | Strings emit `DeprecationWarning`. | +| 1.0.0 | Strings removed; the enums become the only accepted form. | + +0.4.0 deliberately warned about nothing, because `mode="batch"` was what this documentation showed +until that release. The written notice came first, and this is the warning that follows it. + +The warning names the exact replacement, so migrating is a substitution you read off the message: + +``` +DeprecationWarning: Passing mode='batch' as a plain string is deprecated and will +stop working in 1.0.0. Use TrainingMode.BATCH instead. +``` + +Two things it deliberately does not do. It does not fire for a spelling that was never valid, so +`mode="stochastic"` still raises its `ValueError` rather than burying the real mistake under a +notice about modernising something that never worked. And it does not fire when a map is loaded +from an artifact: the name in a `.npz` is a serialisation detail, since JSON has no enums, so the +loader converts it rather than warning you about a string you never wrote. + +To silence it while you migrate: + +```python +import warnings + +warnings.filterwarnings("ignore", category=DeprecationWarning, module="python_som") +``` Migrating early costs nothing and is a mechanical substitution: `"batch"` becomes `TrainingMode.BATCH`, and so on down the table above. diff --git a/src/python_som/_artifact.py b/src/python_som/_artifact.py index 2920732..cbb1ce9 100644 --- a/src/python_som/_artifact.py +++ b/src/python_som/_artifact.py @@ -35,6 +35,7 @@ from ._core._decay import DECAY_FUNCTIONS, resolve_decay from ._core._distance import DISTANCE_FUNCTIONS, resolve_distance from ._core._neighborhood import NEIGHBORHOOD_FUNCTIONS +from ._enums import Neighborhood if TYPE_CHECKING: # pragma: no cover import os @@ -64,6 +65,10 @@ #: A weight array is ``(x, y, n_features)``, by definition. _WEIGHT_DIMENSIONS = 3 +#: Spellings that resolve but have no enum member of their own. An artifact written before the enums +#: existed can carry one, and it means the same function. +_LEGACY_NEIGHBORHOOD_ALIASES = {"mexicanhat": Neighborhood.MEXICAN_HAT} + #: Which config fields name a callable, and the registry that restores each. The loader walks this #: rather than repeating four near-identical branches. _STRATEGIES: Mapping[str, Mapping[str, Any]] = { @@ -305,6 +310,23 @@ class Strategies: distance_function: DistanceFunction +def _as_neighborhood(name: str) -> Neighborhood | str: + """Turn a stored neighborhood name into the enum member it denotes. + + Returns the name unchanged when it names nothing this package knows, so the constructor still + raises the error a corrupt artifact deserves rather than this function swallowing it. + + :param name: Name as recorded in the artifact. + :return: The matching member, or the name unchanged. + """ + if name in _LEGACY_NEIGHBORHOOD_ALIASES: + return _LEGACY_NEIGHBORHOOD_ALIASES[name] + try: + return Neighborhood(name) + except ValueError: + return name + + def resolve_strategies(config: SOMConfig, overrides: Mapping[str, Any]) -> Strategies: """Turn the saved strategy names back into callables, honouring any the caller supplied. @@ -339,10 +361,15 @@ def either(key: str, resolve: Callable[[str], Any], name: str) -> Any: # noqa: """ return supplied[key] if key in supplied else resolve(name) - # The neighborhood is the one the constructor takes by name as well as by callable, so the saved - # name is passed straight through unless the caller overrode it. + # The constructor takes the neighborhood by name as well as by callable. The saved name is a + # *string*, because JSON has no enums, and handing it straight to the constructor would emit the + # plain-string DeprecationWarning for something the caller never wrote. So convert it to the + # member it denotes. The legacy "mexicanhat" spelling has no member of its own and is an alias + # for the same function, so it maps to MEXICAN_HAT. return Strategies( - neighborhood_function=supplied.get("neighborhood_function", config.neighborhood_function), + neighborhood_function=supplied.get( + "neighborhood_function", _as_neighborhood(config.neighborhood_function) + ), learning_rate_decay=either( "learning_rate_decay", resolve_decay, config.learning_rate_decay ), diff --git a/src/python_som/_enums.py b/src/python_som/_enums.py index 4d6aeec..03655af 100644 --- a/src/python_som/_enums.py +++ b/src/python_som/_enums.py @@ -4,9 +4,10 @@ 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. +**Deprecation runway.** Plain strings are deprecated as of 0.4.0 and emit ``DeprecationWarning`` +from 0.5.0. 0.4.0 deliberately warned about nothing, because ``mode="batch"`` was what every +documentation page showed at the time, so the written notice came first and this is the warning that +follows it. 1.0.0 removes strings entirely, and :func:`warn_if_string` goes with them. **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()`` @@ -17,6 +18,7 @@ from __future__ import annotations +import warnings from enum import Enum from typing import Literal @@ -29,6 +31,7 @@ "TrainingModeStr", "WeightInit", "WeightInitStr", + "warn_if_string", ] @@ -107,3 +110,48 @@ class SampleMode(_StrEnum): #: Accepted strings for the ``sample_mode`` argument of random initialization. SampleModeStr = Literal["standard_normal", "uniform"] + + +#: Names that are accepted but have no member of their own, mapped to the member that replaces them. +#: ``mexicanhat`` is the spelling the original contribution used; the enum carries one canonical +#: spelling per option, so this is where the other one is answered for. +_LEGACY_SPELLINGS = {"mexicanhat": "Neighborhood.MEXICAN_HAT"} + + +def warn_if_string(value: object, enum: type[Enum], parameter: str, *, stacklevel: int = 3) -> None: + """Emit a ``DeprecationWarning`` when a plain string is passed instead of an enum member. + + An enum member *is* a ``str``, so the test is "a string that is not one of ours" rather than + ``isinstance(value, str)``, which would fire on the enum too. + + Only *valid* spellings warn. A string naming nothing returns silently and is left to whatever + validates it, so ``mode="stochastic"`` still raises its ``ValueError`` rather than surfacing a + deprecation notice about a spelling that never worked. + + The message names the exact replacement rather than describing one. A deprecation warning that + makes the reader work out the substitution is a deprecation warning people silence. + + :param value: What the caller passed. + :param enum: The enum that should replace a string here. + :param parameter: Name of the parameter, for the message. + :param stacklevel: Frames to skip so the warning blames the caller's line rather than this one. + """ + if isinstance(value, enum) or not isinstance(value, str): + return + + try: + replacement = f"{enum.__name__}.{enum(value).name}" + except ValueError: + replacement = _LEGACY_SPELLINGS.get(value, "") + if not replacement: + # Not a value this enum knows and not a legacy alias, so it is simply wrong. Whoever + # validates it will say so; telling the caller to modernise a spelling that was never + # valid would bury the actual error under a deprecation notice. + return + + warnings.warn( + f"Passing {parameter}={value!r} as a plain string is deprecated and will stop working in " + f"1.0.0. Use {replacement} instead.", + DeprecationWarning, + stacklevel=stacklevel, + ) diff --git a/src/python_som/_som.py b/src/python_som/_som.py index 3cca76a..98ba5ad 100644 --- a/src/python_som/_som.py +++ b/src/python_som/_som.py @@ -52,6 +52,7 @@ TrainingModeStr, WeightInit, WeightInitStr, + warn_if_string, ) from ._version import __version__ @@ -271,6 +272,7 @@ def __init__( self._min_neighborhood_radius = float(min_neighborhood_radius) self._neighborhood_function_name = neighborhood_function self._neighborhood_function: NeighborhoodFunction = resolve(neighborhood_function) + warn_if_string(neighborhood_function, Neighborhood, "neighborhood_function") self._distance_function = distance_function self._cyclic = (bool(cyclic_x), bool(cyclic_y)) @@ -436,13 +438,15 @@ def train( raise ValueError(msg) if mode not in TRAINING_MODES: msg = ( - f"Invalid value for 'mode' parameter: {mode!r}. " + f"Invalid value for 'mode' parameter: {str(mode)!r}. " f"Value should be one of {list(TRAINING_MODES)}" ) raise ValueError(msg) + warn_if_string(mode, TrainingMode, "mode") if mode == "batch" and self._neighborhood_function_name in SIGNED_NEIGHBORHOODS: msg = ( - f"The {self._neighborhood_function_name!r} neighborhood function cannot be used " + f"The {str(self._neighborhood_function_name)!r} neighborhood function cannot be " + "used " "with the 'batch' training mode: the weighted mean of Kohonen (2013), Eq. (8), is " "undefined for a neighborhood function that takes negative values, as its " "denominator is not sign-definite. Use mode='random' or mode='sequential'." @@ -748,10 +752,13 @@ def weight_initialization( """ if mode not in INITIALIZATION_MODES: msg = ( - f"Invalid value for 'mode' parameter: {mode!r}. " + f"Invalid value for 'mode' parameter: {str(mode)!r}. " f"Value should be one of {list(INITIALIZATION_MODES)}" ) raise ValueError(msg) + warn_if_string(mode, WeightInit, "mode") + if "sample_mode" in kwargs: + warn_if_string(kwargs["sample_mode"], SampleMode, "sample_mode") try: if mode == "random": self._weights = random_models( @@ -766,11 +773,11 @@ def weight_initialization( self._weights = sample_models(to_numpy(kwargs.pop("data")), self._shape, self._rng) except KeyError as exc: # Without this the caller sees a bare KeyError naming the missing key and nothing else. - msg = f"{mode!r} initialization requires a {exc} argument" + msg = f"{str(mode)!r} initialization requires a {exc} argument" raise ValueError(msg) from exc if kwargs: msg = ( - f"Unexpected argument(s) for {mode!r} initialization: {sorted(kwargs)}. " + f"Unexpected argument(s) for {str(mode)!r} initialization: {sorted(kwargs)}. " f"Valid modes are {list(INITIALIZATION_MODES)}." ) raise ValueError(msg) diff --git a/tests/test_analysis.py b/tests/test_analysis.py index 383cf45..877fe70 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -5,7 +5,7 @@ import numpy as np import pytest -from python_som import euclidean_distance +from python_som import TrainingMode, WeightInit, euclidean_distance from tests.conftest import make_som @@ -36,7 +36,7 @@ def test_quantization_error_is_zero_for_an_exact_fit() -> None: """A map whose models are exactly the data has no quantization error.""" data = np.arange(12, dtype=float).reshape(4, 3) som = make_som(x=2, y=2, input_len=3) - som.weight_initialization(mode="sample", data=data) + som.weight_initialization(mode=WeightInit.SAMPLE, data=data) som._weights = data.reshape(2, 2, 3).copy() assert som.quantization_error(data) == pytest.approx(0.0) @@ -71,8 +71,8 @@ def test_label_map_separates_well_separated_clusters( ) -> None: """After training on three distant blobs, most nodes should be label-pure.""" som = make_som(x=8, y=8, neighborhood_radius=2.0) - som.weight_initialization(mode="linear", data=blobs) - som.train(blobs, n_iteration=40, mode="batch") + som.weight_initialization(mode=WeightInit.LINEAR, data=blobs) + som.train(blobs, n_iteration=40, mode=TrainingMode.BATCH) label_map = som.label_map(blobs, blob_labels) occupied = [c for c in label_map.values() if sum(c.values()) > 0] pure = [c for c in occupied if len(c) == 1] diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index ed2ffdd..32c6355 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -25,7 +25,14 @@ import pytest import python_som -from python_som import ArtifactError, Neighborhood, SOMConfig, TrainingMode, TrainingReport +from python_som import ( + ArtifactError, + Neighborhood, + SOMConfig, + TrainingMode, + TrainingReport, + WeightInit, +) from python_som._artifact import FORMAT_VERSION from python_som._core._decay import DECAY_FUNCTIONS from python_som._core._distance import DISTANCE_FUNCTIONS @@ -52,7 +59,7 @@ def _trained(**kwargs: Any) -> python_som.SOM: # noqa: ANN401 """ data = _data() som = python_som.SOM(x=7, y=5, input_len=4, random_seed=11, **kwargs) - som.weight_initialization(mode="linear", data=data) + som.weight_initialization(mode=WeightInit.LINEAR, data=data) som.train(data, n_iteration=20, mode=TrainingMode.BATCH) return som @@ -85,12 +92,12 @@ def test_continuing_to_train_a_loaded_map_matches_never_having_stopped( data = _data() uninterrupted = python_som.SOM(x=6, y=5, input_len=4, random_seed=3) - uninterrupted.weight_initialization(mode="linear", data=data) + uninterrupted.weight_initialization(mode=WeightInit.LINEAR, data=data) uninterrupted.train(data, n_iteration=20, mode=TrainingMode.RANDOM) uninterrupted.train(data, n_iteration=20, mode=TrainingMode.RANDOM) interrupted = python_som.SOM(x=6, y=5, input_len=4, random_seed=3) - interrupted.weight_initialization(mode="linear", data=data) + interrupted.weight_initialization(mode=WeightInit.LINEAR, data=data) interrupted.train(data, n_iteration=20, mode=TrainingMode.RANDOM) path = tmp_path / "checkpoint.npz" interrupted.save_npz(path) @@ -112,7 +119,7 @@ def test_reseeding_instead_of_restoring_state_would_diverge(tmp_path: pathlib.Pa """ data = _data() som = python_som.SOM(x=6, y=5, input_len=4, random_seed=3) - som.weight_initialization(mode="linear", data=data) + som.weight_initialization(mode=WeightInit.LINEAR, data=data) som.train(data, n_iteration=20, mode=TrainingMode.RANDOM) path = tmp_path / "map.npz" som.save_npz(path) @@ -192,7 +199,7 @@ def test_every_neighborhood_round_trips(neighborhood: Neighborhood, tmp_path: pa """The mexican hat included, which cannot be trained in batch but can certainly be saved.""" data = _data() som = python_som.SOM(x=6, y=4, input_len=4, neighborhood_function=neighborhood, random_seed=9) - som.weight_initialization(mode="linear", data=data) + som.weight_initialization(mode=WeightInit.LINEAR, data=data) path = tmp_path / f"{neighborhood.value}.npz" som.save_npz(path) @@ -578,7 +585,7 @@ def test_a_config_can_be_built_by_hand() -> None: neighborhood_radius=1.0, min_neighborhood_radius=0.5, cyclic=(False, True), - neighborhood_function="gaussian", + neighborhood_function=Neighborhood.GAUSSIAN, learning_rate_decay="asymptotic_decay", neighborhood_radius_decay="asymptotic_decay", distance_function="euclidean_distance", @@ -660,3 +667,49 @@ def test_an_artifact_with_no_rng_state_still_loads(tmp_path: pathlib.Path) -> No loaded = python_som.SOM.load_npz(stateless) assert np.abs(loaded.get_weights() - som.get_weights()).max() == 0.0 assert loaded.get_random_seed() == 11 + + +@pytest.mark.parametrize( + ("stored", "expected"), + [ + ("gaussian", Neighborhood.GAUSSIAN), + ("mexican_hat", Neighborhood.MEXICAN_HAT), + ("mexicanhat", Neighborhood.MEXICAN_HAT), + ("sombrero", "sombrero"), + ], + ids=["member", "canonical-alias-target", "legacy-spelling", "unknown"], +) +def test_a_stored_neighborhood_name_becomes_the_member_it_denotes( + stored: str, expected: object +) -> None: + """Loading a map must not warn the caller about a string they never wrote. + + The name in an artifact is a serialisation detail: JSON has no enums. Handing it straight to the + constructor would emit the plain-string ``DeprecationWarning`` for something the file chose, not + the caller. ``mexicanhat`` has no member of its own and is an alias for the same function, so it + maps to ``MEXICAN_HAT``. + + An unrecognised name is returned unchanged, so a corrupt artifact still gets the constructor's + error rather than having it swallowed here. + """ + from python_som._artifact import _as_neighborhood # noqa: PLC0415 + + assert _as_neighborhood(stored) == expected + + +def test_loading_a_map_does_not_warn_about_deprecated_spellings(tmp_path: pathlib.Path) -> None: + """The behaviour the conversion above exists for, asserted end to end.""" + # Stepwise, because the mexican hat is signed and batch rejects it. + data = _data() + som = python_som.SOM( + x=6, y=5, input_len=4, neighborhood_function=Neighborhood.MEXICAN_HAT, random_seed=11 + ) + som.weight_initialization(mode=WeightInit.LINEAR, data=data) + som.train(data, n_iteration=10, mode=TrainingMode.RANDOM) + path = tmp_path / "m.npz" + som.save_npz(path) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + loaded = python_som.SOM.load_npz(path) + assert np.abs(loaded.get_weights() - som.get_weights()).max() == 0.0 diff --git a/tests/test_construction.py b/tests/test_construction.py index e9343e8..4a84e04 100644 --- a/tests/test_construction.py +++ b/tests/test_construction.py @@ -10,6 +10,7 @@ import sklearn.preprocessing import python_som +from python_som import Neighborhood from tests.conftest import SEED, make_som if TYPE_CHECKING: @@ -137,7 +138,7 @@ def test_setters_update_the_hyperparameters() -> None: def test_neighborhood_is_reachable_from_the_public_api() -> None: - som = make_som(x=9, y=9, neighborhood_function="mexicanhat") + som = make_som(x=9, y=9, neighborhood_function=Neighborhood.MEXICAN_HAT) h = som.neighborhood((4, 4), 2.0) assert h.shape == (9, 9) assert h[4, 4] == pytest.approx(1.0) diff --git a/tests/test_core_boundary.py b/tests/test_core_boundary.py index d1150fd..442e2e2 100644 --- a/tests/test_core_boundary.py +++ b/tests/test_core_boundary.py @@ -15,6 +15,7 @@ import pytest import python_som +from python_som import WeightInit from python_som._core import _update from python_som._core._neighborhood import gaussian from tests.conftest import MODEL_SEED, make_som @@ -363,22 +364,22 @@ def test_missing_data_argument_names_the_argument() -> None: That leaked a private method name and did not say what the caller should do. """ som = make_som(x=4, y=4) - for mode in ("linear", "sample"): - with pytest.raises(ValueError, match=f"{mode!r} initialization requires"): + for mode in (WeightInit.LINEAR, WeightInit.SAMPLE): + with pytest.raises(ValueError, match=f"{mode.value!r} initialization requires"): som.weight_initialization(mode=mode) def test_unexpected_argument_names_the_argument() -> None: som = make_som(x=4, y=4) with pytest.raises(ValueError, match="Unexpected argument"): - som.weight_initialization(mode="random", data=np.zeros((4, 2))) + som.weight_initialization(mode=WeightInit.RANDOM, data=np.zeros((4, 2))) @pytest.mark.parametrize( ("kwargs", "expected"), [ - ({"mode": "linear"}, "initialization requires"), - ({"mode": "random", "nonsense": 1}, "Unexpected argument"), + ({"mode": WeightInit.LINEAR}, "initialization requires"), + ({"mode": WeightInit.RANDOM, "nonsense": 1}, "Unexpected argument"), ({"mode": "spectral"}, "Invalid value for 'mode' parameter"), ], ids=["missing-data", "unexpected-kwarg", "unknown-mode"], diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 9d12f85..93e4f6a 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -10,15 +10,11 @@ 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 python_som import TrainingMode, WeightInit from tests.conftest import SEED pytestmark = pytest.mark.slow @@ -47,8 +43,8 @@ def test_batch_training_separates_known_clusters( """The whole point of the library, asserted once: structure in, structure on the grid.""" data, labels = clusters som = python_som.SOM(x=12, y=12, input_len=5, neighborhood_radius=4.0, random_seed=SEED) - som.weight_initialization(mode="linear", data=data) - error = som.train(data, n_iteration=40, mode="batch") + som.weight_initialization(mode=WeightInit.LINEAR, data=data) + error = som.train(data, n_iteration=40, mode=TrainingMode.BATCH) # every cluster claims at least one node, and almost every occupied node is label-pure label_map = som.label_map(data, labels) @@ -72,20 +68,20 @@ def test_the_u_matrix_shows_the_cluster_boundaries( data, _ = clusters untrained = python_som.SOM(x=12, y=12, input_len=5, random_seed=SEED) trained = python_som.SOM(x=12, y=12, input_len=5, neighborhood_radius=4.0, random_seed=SEED) - trained.weight_initialization(mode="linear", data=data) - trained.train(data, n_iteration=40, mode="batch") + trained.weight_initialization(mode=WeightInit.LINEAR, data=data) + trained.train(data, n_iteration=40, mode=TrainingMode.BATCH) assert np.ptp(trained.distance_matrix()) > np.ptp(untrained.distance_matrix()) -@pytest.mark.parametrize("mode", ["random", "sequential", "batch"]) +@pytest.mark.parametrize("mode", list(TrainingMode)) def test_every_mode_reaches_a_usable_map( - mode: TrainingModeStr, clusters: tuple[np.ndarray, np.ndarray] + mode: TrainingMode, clusters: tuple[np.ndarray, np.ndarray] ) -> None: """All three training modes must produce finite models and reduce the error.""" data, _ = clusters som = python_som.SOM(x=10, y=10, input_len=5, neighborhood_radius=3.0, random_seed=SEED) - som.weight_initialization(mode="linear", data=data) + som.weight_initialization(mode=WeightInit.LINEAR, data=data) before = som.quantization_error(data) after = som.train(data, n_iteration=60, mode=mode) @@ -109,8 +105,8 @@ def test_a_toroidal_map_has_no_edge(clusters: tuple[np.ndarray, np.ndarray]) -> cyclic_y=True, random_seed=SEED, ) - som.weight_initialization(mode="linear", data=data) - som.train(data, n_iteration=60, mode="batch") + som.weight_initialization(mode=WeightInit.LINEAR, data=data) + som.train(data, n_iteration=60, mode=TrainingMode.BATCH) counts = som.activation_matrix(data) top_half = counts[:4].sum() diff --git a/tests/test_io_types.py b/tests/test_io_types.py index ac491f7..db9b472 100644 --- a/tests/test_io_types.py +++ b/tests/test_io_types.py @@ -8,6 +8,7 @@ import pandas as pd import pytest +from python_som import TrainingMode, WeightInit from tests.conftest import SEED, make_som @@ -31,7 +32,7 @@ def test_training_agrees_across_input_types(variants: dict[str, Any]) -> None: weights = [] for v in variants.values(): som = make_som(x=6, y=6, random_seed=SEED) - som.train(v, n_iteration=20, mode="batch") + som.train(v, n_iteration=20, mode=TrainingMode.BATCH) weights.append(som.get_weights()) np.testing.assert_allclose(weights[0], weights[1]) np.testing.assert_allclose(weights[0], weights[2]) @@ -47,7 +48,7 @@ def test_linear_init_agrees_across_input_types(variants: dict[str, Any]) -> None weights = [] for v in variants.values(): som = make_som(x=5, y=5) - som.weight_initialization(mode="linear", data=v) + som.weight_initialization(mode=WeightInit.LINEAR, data=v) weights.append(som.get_weights()) np.testing.assert_allclose(weights[0], weights[1]) np.testing.assert_allclose(weights[0], weights[2]) @@ -70,11 +71,11 @@ def test_labels_accept_strings(blobs: np.ndarray) -> None: def test_a_single_feature_dataset_works() -> None: data = np.linspace(0, 1, 20).reshape(-1, 1) som = make_som(x=4, y=3, input_len=1) - som.train(data, n_iteration=10, mode="batch") + som.train(data, n_iteration=10, mode=TrainingMode.BATCH) assert np.isfinite(som.get_weights()).all() def test_a_single_sample_dataset_works() -> None: som = make_som(x=3, y=3, input_len=3) - som.train(np.array([[1.0, 2.0, 3.0]]), n_iteration=3, mode="batch") + som.train(np.array([[1.0, 2.0, 3.0]]), n_iteration=3, mode=TrainingMode.BATCH) assert np.isfinite(som.get_weights()).all() diff --git a/tests/test_kernel_equivalence.py b/tests/test_kernel_equivalence.py index c7f3e79..b517350 100644 --- a/tests/test_kernel_equivalence.py +++ b/tests/test_kernel_equivalence.py @@ -23,6 +23,7 @@ import pytest import python_som +from python_som import Neighborhood from python_som._core._match import accumulate from python_som._core._neighborhood import ( NEIGHBORHOOD_FUNCTIONS, @@ -41,7 +42,6 @@ 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. @@ -242,10 +242,10 @@ def test_resolve_kernel_rejects_an_unknown_name() -> None: # --------------------------------------------------------------------------------------------- -@pytest.mark.parametrize("neighborhood", ["gaussian", "bubble"]) +@pytest.mark.parametrize("neighborhood", [Neighborhood.GAUSSIAN, Neighborhood.BUBBLE]) @pytest.mark.parametrize("cyclic", [(False, False), (True, True), (True, False)]) def test_batch_training_is_unchanged_by_the_kernel( - neighborhood: NeighborhoodStr, cyclic: tuple[bool, bool] + neighborhood: Neighborhood, cyclic: tuple[bool, bool] ) -> None: """Many iterations, with a decaying radius, against the per-node path it replaced. @@ -298,11 +298,11 @@ def train(*, use_kernel: bool) -> np.ndarray: sums, counts = accumulate(data, current, shape, som._distance_function) neighborhood_of: Callable[[tuple[int, int]], np.ndarray] if use_kernel: - kernel = resolve_kernel(neighborhood)(shape, sigma, cyclic) + kernel = resolve_kernel(neighborhood.value)(shape, sigma, cyclic) neighborhood_of = functools.partial(kernel_view, kernel, shape) else: neighborhood_of = functools.partial( - _evaluate_per_node, FUNCTIONS[neighborhood], shape, sigma, cyclic + _evaluate_per_node, FUNCTIONS[neighborhood.value], shape, sigma, cyclic ) current = batch_update(current, sums, counts, neighborhood_of, shape) return current diff --git a/tests/test_training.py b/tests/test_training.py index 886eaf0..81a9227 100644 --- a/tests/test_training.py +++ b/tests/test_training.py @@ -7,15 +7,11 @@ 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 python_som import Neighborhood, TrainingMode, WeightInit from tests.conftest import SEED, make_som @@ -28,10 +24,10 @@ def test_batch_never_zeroes_a_model(rng: np.random.Generator) -> None: """ data = rng.normal(size=(20, 3)) som = make_som(x=30, y=30, neighborhood_radius=0.5) - som.weight_initialization(mode="sample", data=data) + som.weight_initialization(mode=WeightInit.SAMPLE, data=data) before = som.get_weights().copy() - som.train(data, n_iteration=1, mode="batch") + som.train(data, n_iteration=1, mode=TrainingMode.BATCH) after = som.get_weights() zeroed = np.all(after == 0.0, axis=-1) @@ -43,12 +39,12 @@ def test_batch_never_zeroes_a_model(rng: np.random.Generator) -> None: def test_batch_preserves_finiteness(blobs: np.ndarray) -> None: som = make_som(x=10, y=10, neighborhood_radius=2.0) - som.train(blobs, n_iteration=5, mode="batch") + som.train(blobs, n_iteration=5, mode=TrainingMode.BATCH) assert np.isfinite(som.get_weights()).all() -@pytest.mark.parametrize("mode", ["random", "sequential", "batch"]) -def test_training_reduces_quantization_error(mode: TrainingModeStr, blobs: np.ndarray) -> None: +@pytest.mark.parametrize("mode", list(TrainingMode)) +def test_training_reduces_quantization_error(mode: TrainingMode, 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) @@ -56,9 +52,9 @@ def test_training_reduces_quantization_error(mode: TrainingModeStr, blobs: np.nd assert after < before -@pytest.mark.parametrize("mode", ["random", "sequential"]) +@pytest.mark.parametrize("mode", [TrainingMode.RANDOM, TrainingMode.SEQUENTIAL]) def test_stepwise_runs_the_requested_number_of_iterations( - mode: TrainingModeStr, blobs: np.ndarray, monkeypatch: pytest.MonkeyPatch + mode: TrainingMode, blobs: np.ndarray, monkeypatch: pytest.MonkeyPatch ) -> None: """Regression: sequential mode used to run ``len(data)`` steps regardless of ``n_iteration``. @@ -85,7 +81,7 @@ def counting_sigma(t: int, n: int) -> float: def test_sequential_cycles_through_the_dataset(blobs: np.ndarray) -> None: """More iterations than samples must wrap around rather than stop early.""" som = make_som(x=5, y=5) - som.train(blobs, n_iteration=len(blobs) * 3, mode="sequential") + som.train(blobs, n_iteration=len(blobs) * 3, mode=TrainingMode.SEQUENTIAL) assert np.isfinite(som.get_weights()).all() @@ -112,7 +108,7 @@ def recording_winner(x: np.ndarray) -> tuple[int, int]: return original(x) monkeypatch.setattr(som, "winner", recording_winner) - som.train(blobs, n_iteration=len(blobs), mode="random") + som.train(blobs, n_iteration=len(blobs), mode=TrainingMode.RANDOM) drawn = seen[: len(blobs)] assert len(drawn) == len(blobs) assert len(set(drawn)) < len(blobs), "a with-replacement draw should repeat an index" @@ -131,7 +127,7 @@ def recording_winner(x: np.ndarray) -> tuple[int, int]: return original(x) monkeypatch.setattr(som, "winner", recording_winner) - som.train(blobs, n_iteration=len(blobs), mode="sequential") + som.train(blobs, n_iteration=len(blobs), mode=TrainingMode.SEQUENTIAL) assert seen[: len(blobs)] == list(range(len(blobs))) @@ -140,28 +136,30 @@ def test_batch_rejects_a_signed_neighborhood(blobs: np.ndarray) -> None: Measured on a 12x12 grid, 49 of 144 denominators come out negative. """ - som = make_som(x=12, y=12, neighborhood_function="mexicanhat") + som = make_som(x=12, y=12, neighborhood_function=Neighborhood.MEXICAN_HAT) with pytest.raises(ValueError, match="batch"): - som.train(blobs, n_iteration=1, mode="batch") + som.train(blobs, n_iteration=1, mode=TrainingMode.BATCH) def test_batch_rejects_the_alias_too(blobs: np.ndarray) -> None: - som = make_som(x=12, y=12, neighborhood_function="mexican_hat") + som = make_som(x=12, y=12, neighborhood_function=Neighborhood.MEXICAN_HAT) with pytest.raises(ValueError, match="batch"): - som.train(blobs, n_iteration=1, mode="batch") + som.train(blobs, n_iteration=1, mode=TrainingMode.BATCH) -@pytest.mark.parametrize("mode", ["random", "sequential"]) -def test_stepwise_accepts_a_signed_neighborhood(mode: TrainingModeStr, blobs: np.ndarray) -> None: +@pytest.mark.parametrize("mode", [TrainingMode.RANDOM, TrainingMode.SEQUENTIAL]) +def test_stepwise_accepts_a_signed_neighborhood(mode: TrainingMode, 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 = make_som(x=12, y=12, neighborhood_function=Neighborhood.MEXICAN_HAT) som.train(blobs, n_iteration=30, mode=mode) assert np.isfinite(som.get_weights()).all() -@pytest.mark.parametrize(("mode", "per_sample"), [("batch", 10), ("sequential", 1000)]) +@pytest.mark.parametrize( + ("mode", "per_sample"), [(TrainingMode.BATCH, 10), (TrainingMode.SEQUENTIAL, 1000)] +) def test_omitting_n_iteration_uses_the_documented_default( - mode: TrainingModeStr, per_sample: int, monkeypatch: pytest.MonkeyPatch + mode: TrainingMode, per_sample: int, monkeypatch: pytest.MonkeyPatch ) -> None: """The default is 1000 iterations per sample for stepwise modes and 10 for batch. @@ -189,7 +187,7 @@ def test_verbose_training_runs_with_a_progress_bar(blobs: np.ndarray) -> None: tqdm ships in the dev extra, so this exercises the wrapped path rather than the fallback. """ som = make_som(x=5, y=5) - error = som.train(blobs, n_iteration=3, mode="batch", verbose=True) + error = som.train(blobs, n_iteration=3, mode=TrainingMode.BATCH, verbose=True) assert np.isfinite(error) @@ -215,16 +213,16 @@ def test_non_positive_iteration_count_raises_value_error(blobs: np.ndarray) -> N def test_same_seed_reproduces_the_same_map(blobs: np.ndarray) -> None: a = make_som(x=8, y=8, random_seed=SEED) b = make_som(x=8, y=8, random_seed=SEED) - a.train(blobs, n_iteration=25, mode="random") - b.train(blobs, n_iteration=25, mode="random") + a.train(blobs, n_iteration=25, mode=TrainingMode.RANDOM) + b.train(blobs, n_iteration=25, mode=TrainingMode.RANDOM) np.testing.assert_array_equal(a.get_weights(), b.get_weights()) def test_different_seeds_give_different_maps(blobs: np.ndarray) -> None: a = make_som(x=8, y=8, random_seed=1) b = make_som(x=8, y=8, random_seed=2) - a.train(blobs, n_iteration=25, mode="random") - b.train(blobs, n_iteration=25, mode="random") + a.train(blobs, n_iteration=25, mode=TrainingMode.RANDOM) + b.train(blobs, n_iteration=25, mode=TrainingMode.RANDOM) assert not np.allclose(a.get_weights(), b.get_weights()) @@ -258,7 +256,7 @@ def test_radius_is_floored_during_training(blobs: np.ndarray) -> None: neighborhood_radius_decay=python_som.linear_decay, min_neighborhood_radius=0.5, ) - som.train(blobs, n_iteration=10, mode="random") + som.train(blobs, n_iteration=10, mode=TrainingMode.RANDOM) assert np.isfinite(som.get_weights()).all() @@ -278,7 +276,7 @@ def test_winner_is_always_inside_the_grid(blobs: np.ndarray) -> None: def test_batch_matches_a_reference_implementation(blobs: np.ndarray) -> None: """The contracted batch update must agree with the literal double loop of Eq. (8).""" som = make_som(x=6, y=5, neighborhood_radius=1.5) - som.weight_initialization(mode="sample", data=blobs) + som.weight_initialization(mode=WeightInit.SAMPLE, data=blobs) reference = som.get_weights().copy() sigma = som._sigma(0, 1) @@ -294,5 +292,5 @@ def test_batch_matches_a_reference_implementation(blobs: np.ndarray) -> None: if abs(bottom) > 1e-12: expected[i] = upper / bottom - som.train(blobs, n_iteration=1, mode="batch") + som.train(blobs, n_iteration=1, mode=TrainingMode.BATCH) np.testing.assert_allclose(som.get_weights(), expected, atol=1e-12) diff --git a/tests/test_typed_seams.py b/tests/test_typed_seams.py index 78f93cd..e369588 100644 --- a/tests/test_typed_seams.py +++ b/tests/test_typed_seams.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import re import warnings from typing import TYPE_CHECKING @@ -29,6 +30,7 @@ from tests.conftest import make_som if TYPE_CHECKING: # pragma: no cover + from collections.abc import Callable from enum import Enum ALL_ENUMS = [TrainingMode, Neighborhood, WeightInit, SampleMode] @@ -91,6 +93,10 @@ def test_every_neighborhood_member_resolves() -> None: # --------------------------------------------------------------------------------------------- # Enums and strings are interchangeable at every call site that takes one +# +# From 0.5.0 a plain string also warns, so each of these asserts both halves: the string still does +# exactly what the enum does, *and* it says it is going away. The warning is the point of the +# release, so it is asserted rather than filtered out. # --------------------------------------------------------------------------------------------- @@ -109,7 +115,8 @@ def test_training_accepts_either_spelling_with_identical_results( 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] + with pytest.warns(DeprecationWarning, match=f"mode={as_string!r}"): + 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()) @@ -125,20 +132,22 @@ def test_weight_initialization_accepts_either_spelling( from_enum = som.get_weights().copy() other = make_som(x=5, y=4) - other.weight_initialization(mode=member.value, **kwargs) + with pytest.warns(DeprecationWarning, match=f"mode={member.value!r}"): + 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, - ) + with pytest.warns(DeprecationWarning, match=f"neighborhood_function={member.value!r}"): + 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) ) @@ -149,7 +158,8 @@ def test_sample_mode_accepts_either_spelling() -> None: 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) + with pytest.warns(DeprecationWarning, match=f"sample_mode={mode.value!r}"): + second.weight_initialization(mode=WeightInit.RANDOM, sample_mode=mode.value) np.testing.assert_array_equal(first.get_weights(), second.get_weights()) @@ -244,3 +254,120 @@ def test_the_warning_points_at_the_caller() -> None: 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}" + + +# --------------------------------------------------------------------------------------------- +# The deprecation itself +# --------------------------------------------------------------------------------------------- + +#: Each valid plain-string spelling, with the replacement its warning must name. +DEPRECATED_SPELLINGS = [ + ( + "train-mode", + lambda som, data: som.train(data, n_iteration=5, mode="batch"), + "TrainingMode.BATCH", + ), + ( + "init-mode", + lambda som, data: som.weight_initialization(mode="linear", data=data), + "WeightInit.LINEAR", + ), + ( + "sample-mode", + lambda som, _data: som.weight_initialization(mode=WeightInit.RANDOM, sample_mode="uniform"), + "SampleMode.UNIFORM", + ), +] + +#: Spellings that never named anything. These must raise, not warn. +INVALID_SPELLINGS = [ + ("bad-train-mode", lambda som, data: som.train(data, n_iteration=1, mode="stochastic")), + ("bad-init-mode", lambda som, _data: som.weight_initialization(mode="spectral")), + ( + "bad-sample-mode", + lambda som, _data: som.weight_initialization(mode=WeightInit.RANDOM, sample_mode="cauchy"), + ), +] + + +@pytest.mark.parametrize( + ("call", "expected"), + [(c, e) for _, c, e in DEPRECATED_SPELLINGS], + ids=[i for i, _, _ in DEPRECATED_SPELLINGS], +) +def test_the_warning_names_the_exact_replacement( + call: Callable[[python_som.SOM, np.ndarray], object], expected: str +) -> None: + """A deprecation that makes the reader work out the substitution is one they will silence.""" + som = make_som(x=5, y=4) + data = np.random.default_rng(1).normal(size=(20, 3)) + with pytest.warns(DeprecationWarning, match=re.escape(expected)): + call(som, data) + + +def test_the_legacy_neighborhood_spelling_warns_toward_the_canonical_member() -> None: + """``mexicanhat`` has no member of its own, so the message names the one that replaces it.""" + with pytest.warns(DeprecationWarning, match=re.escape("Neighborhood.MEXICAN_HAT")): + python_som.SOM( + x=4, + y=4, + input_len=3, + neighborhood_function="mexicanhat", + ) + + +@pytest.mark.parametrize( + "call", [c for _, c in INVALID_SPELLINGS], ids=[i for i, _ in INVALID_SPELLINGS] +) +def test_an_invalid_string_raises_rather_than_warning( + call: Callable[[python_som.SOM, np.ndarray], object], +) -> None: + """A spelling that never worked is an error, not a deprecation. + + Warning would bury the real mistake under a notice telling the caller to modernise something + that was never valid, and under ``-W error`` would replace the ``ValueError`` outright. + """ + som = make_som(x=5, y=4) + data = np.random.default_rng(1).normal(size=(20, 3)) + with warnings.catch_warnings(): + warnings.simplefilter("error") + with pytest.raises(ValueError, match=r"Invalid value|sample_mode"): + call(som, data) + + +def test_an_enum_never_warns() -> None: + """The whole point: migrating removes the warning.""" + data = np.random.default_rng(2).normal(size=(30, 3)) + with warnings.catch_warnings(): + warnings.simplefilter("error") + som = python_som.SOM( + x=5, y=4, input_len=3, neighborhood_function=Neighborhood.GAUSSIAN, random_seed=3 + ) + som.weight_initialization(mode=WeightInit.RANDOM, sample_mode=SampleMode.UNIFORM) + som.train(data, n_iteration=5, mode=TrainingMode.BATCH) + + +def test_the_deprecation_warning_blames_the_caller() -> None: + """``stacklevel`` must point at the user's line, not at a line inside this package.""" + som = make_som(x=4, y=4) + with pytest.warns(DeprecationWarning, match="deprecated") as caught: + som.weight_initialization(mode="random") + assert caught[0].filename == __file__, f"warning blamed {caught[0].filename}" + + +def test_error_messages_read_the_same_for_both_spellings() -> None: + """An enum member's repr is ````, which has no place in an error. + + Regression: the messages interpolated the value directly, so passing an enum produced + `` initialization requires ...``. + """ + som = make_som(x=4, y=4) + with pytest.raises(ValueError, match="initialization requires") as from_enum: + som.weight_initialization(mode=WeightInit.LINEAR) + with ( + pytest.warns(DeprecationWarning, match="deprecated"), + pytest.raises(ValueError, match="initialization requires") as from_string, + ): + som.weight_initialization(mode="linear") + assert str(from_enum.value) == str(from_string.value) + assert "WeightInit." not in str(from_enum.value) diff --git a/tests/test_weight_init.py b/tests/test_weight_init.py index 48b221d..446a584 100644 --- a/tests/test_weight_init.py +++ b/tests/test_weight_init.py @@ -6,6 +6,7 @@ import pytest import sklearn.decomposition +from python_som import SampleMode, WeightInit from tests.conftest import make_som @@ -20,7 +21,7 @@ def test_linear_init_spans_the_principal_plane(blobs: np.ndarray) -> None: hyperplane spanned by the two largest principal components". """ som = make_som(x=6, y=5, input_len=3) - som.weight_initialization(mode="linear", data=blobs) + som.weight_initialization(mode=WeightInit.LINEAR, data=blobs) weights = som.get_weights().reshape(-1, 3) # not constant within a model @@ -35,7 +36,7 @@ def test_linear_init_spans_the_principal_plane(blobs: np.ndarray) -> None: def test_linear_init_is_centred_on_the_data_mean(blobs: np.ndarray) -> None: som = make_som(x=7, y=7, input_len=3) - som.weight_initialization(mode="linear", data=blobs) + som.weight_initialization(mode=WeightInit.LINEAR, data=blobs) np.testing.assert_allclose( som.get_weights().reshape(-1, 3).mean(axis=0), blobs.mean(axis=0), atol=1e-8 ) @@ -44,7 +45,7 @@ def test_linear_init_is_centred_on_the_data_mean(blobs: np.ndarray) -> None: def test_linear_init_aligns_with_the_principal_components(blobs: np.ndarray) -> None: """The plane the models span must be the plane of the first two components.""" som = make_som(x=6, y=6, input_len=3) - som.weight_initialization(mode="linear", data=blobs) + som.weight_initialization(mode=WeightInit.LINEAR, data=blobs) weights = som.get_weights().reshape(-1, 3) pca = sklearn.decomposition.PCA(n_components=2, random_state=0).fit(blobs) @@ -61,7 +62,7 @@ def test_linear_init_lives_in_the_data_space(blobs: np.ndarray) -> None: a different space entirely. """ som = make_som(x=6, y=6, input_len=3) - som.weight_initialization(mode="linear", data=blobs) + som.weight_initialization(mode=WeightInit.LINEAR, data=blobs) weights = som.get_weights().reshape(-1, 3) assert weights.min() >= blobs.min() - 1.0 assert weights.max() <= blobs.max() + 1.0 @@ -69,20 +70,20 @@ def test_linear_init_lives_in_the_data_space(blobs: np.ndarray) -> None: def test_linear_init_is_deterministic(blobs: np.ndarray) -> None: a, b = make_som(x=5, y=5), make_som(x=5, y=5, random_seed=999) - a.weight_initialization(mode="linear", data=blobs) - b.weight_initialization(mode="linear", data=blobs) + a.weight_initialization(mode=WeightInit.LINEAR, data=blobs) + b.weight_initialization(mode=WeightInit.LINEAR, data=blobs) np.testing.assert_allclose(a.get_weights(), b.get_weights()) def test_linear_init_rejects_a_degenerate_dataset() -> None: som = make_som(x=4, y=4, input_len=1) with pytest.raises(ValueError, match="at least 2 samples"): - som.weight_initialization(mode="linear", data=np.zeros((5, 1))) + som.weight_initialization(mode=WeightInit.LINEAR, data=np.zeros((5, 1))) def test_sample_init_draws_from_the_dataset(blobs: np.ndarray) -> None: som = make_som(x=4, y=4, input_len=3) - som.weight_initialization(mode="sample", data=blobs) + som.weight_initialization(mode=WeightInit.SAMPLE, data=blobs) for model in som.get_weights().reshape(-1, 3): assert np.isclose(blobs, model).all(axis=1).any() @@ -90,14 +91,14 @@ def test_sample_init_draws_from_the_dataset(blobs: np.ndarray) -> None: def test_sample_init_handles_more_nodes_than_samples() -> None: data = np.arange(12, dtype=float).reshape(4, 3) som = make_som(x=5, y=5, input_len=3) - som.weight_initialization(mode="sample", data=data) + som.weight_initialization(mode=WeightInit.SAMPLE, data=data) assert som.get_weights().shape == (5, 5, 3) -@pytest.mark.parametrize("sample_mode", ["standard_normal", "uniform"]) -def test_random_init_modes(sample_mode: str) -> None: +@pytest.mark.parametrize("sample_mode", list(SampleMode)) +def test_random_init_modes(sample_mode: SampleMode) -> None: som = make_som(x=5, y=5, input_len=3) - som.weight_initialization(mode="random", sample_mode=sample_mode) + som.weight_initialization(mode=WeightInit.RANDOM, sample_mode=sample_mode) weights = som.get_weights() assert weights.shape == (5, 5, 3) if sample_mode == "uniform": @@ -108,7 +109,7 @@ def test_random_init_modes(sample_mode: str) -> None: def test_random_init_rejects_an_unknown_sample_mode() -> None: som = make_som(x=4, y=4) with pytest.raises(ValueError, match="sample_mode"): - som.weight_initialization(mode="random", sample_mode="cauchy") + som.weight_initialization(mode=WeightInit.RANDOM, sample_mode="cauchy") def test_unknown_initialization_mode_raises() -> None: @@ -121,6 +122,6 @@ def test_unknown_initialization_mode_raises() -> None: def test_initialization_is_reproducible_from_the_seed() -> None: a = make_som(x=5, y=5, random_seed=7) b = make_som(x=5, y=5, random_seed=7) - a.weight_initialization(mode="random") - b.weight_initialization(mode="random") + a.weight_initialization(mode=WeightInit.RANDOM) + b.weight_initialization(mode=WeightInit.RANDOM) np.testing.assert_array_equal(a.get_weights(), b.get_weights())