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
110 changes: 69 additions & 41 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
120 changes: 120 additions & 0 deletions docs/options-and-types.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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*\\.\\.\\.$",
]
28 changes: 28 additions & 0 deletions src/python_som/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/python_som/_core/_maps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
5 changes: 1 addition & 4 deletions src/python_som/_core/_match.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
10 changes: 4 additions & 6 deletions src/python_som/_core/_neighborhood.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:
Expand Down
Loading
Loading