Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<WeightInit.LINEAR: 'linear'> 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.

Expand Down Expand Up @@ -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
Expand Down
42 changes: 29 additions & 13 deletions docs/reference/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 30 additions & 3 deletions src/python_som/_artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]] = {
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
),
Expand Down
54 changes: 51 additions & 3 deletions src/python_som/_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()``
Expand All @@ -17,6 +18,7 @@

from __future__ import annotations

import warnings
from enum import Enum
from typing import Literal

Expand All @@ -29,6 +31,7 @@
"TrainingModeStr",
"WeightInit",
"WeightInitStr",
"warn_if_string",
]


Expand Down Expand Up @@ -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,
)
17 changes: 12 additions & 5 deletions src/python_som/_som.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
TrainingModeStr,
WeightInit,
WeightInitStr,
warn_if_string,
)
from ._version import __version__

Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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'."
Expand Down Expand Up @@ -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(
Expand All @@ -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)
8 changes: 4 additions & 4 deletions tests/test_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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]
Expand Down
Loading