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
10 changes: 8 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ jobs:
# not depend on whichever interpreter the runner happens to default to.
python-version: "3.12"

- run: uv sync --all-extras
# Named rather than --all-extras: the `analysis` extra holds review tools (bandit, pip-audit,
# pylint) that no CI job runs, so installing them here would be dead weight on every matrix
# entry. `examples` is needed because tests/test_end_to_end.py imports matplotlib.
- run: uv sync --extra dev --extra docs --extra examples --extra cli
- name: ruff check
run: uv run ruff check --output-format=github .
- name: ruff format
Expand All @@ -50,7 +53,10 @@ jobs:
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
- run: uv sync --all-extras
# Named rather than --all-extras: the `analysis` extra holds review tools (bandit, pip-audit,
# pylint) that no CI job runs, so installing them here would be dead weight on every matrix
# entry. `examples` is needed because tests/test_end_to_end.py imports matplotlib.
- run: uv sync --extra dev --extra docs --extra examples --extra cli
- name: pytest
run: uv run pytest --cov --cov-report=term-missing --cov-report=xml

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
- run: uv sync --all-extras
- run: uv sync --extra dev --extra docs
# --strict turns warnings, including broken internal links, into failures.
- run: uv run mkdocs build --strict
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
Expand Down
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,47 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- **`save_npz` and `load_npz`**, so a trained map can be kept without reaching for `pickle`. One
`.npz` holds the models plus a JSON block with the configuration, the seed, the generator state
and
the last training report. No pickle on either side, so loading one cannot execute code.

A reloaded map **continues the same random stream**, so training it further gives what never
stopping would have given. Both the seed and the generator's current state are saved: the seed
alone
would restart the stream and a resumed run would quietly diverge from an uninterrupted one.

Strategies are saved by name and resolved through registries. A map built from the shipped
functions
round-trips completely; one built with your own function records the name for provenance and
raises
`ArtifactError` on load, naming the argument to pass it back through. A `functools.partial` is
treated as custom, so `partial(exponential_decay, factor=3.0)` cannot silently reload as
`factor=2.0`.

On safety: `allow_pickle=False` is passed explicitly, names resolve only through the registries,
and
the member list, JSON, format version and weight shape are all validated before a map is built.
What
that buys is "cannot execute code", not "safe to load anything" — an `.npz` is a zip, so a hostile
file can still attempt resource exhaustion. `docs/save-and-load.md` states both halves.

- **`SOM.last_report`**, a frozen `TrainingReport`: mode, iterations, sample count, seed, the final
learning rate and radius, quantization error, the `python_som` and NumPy versions, and wall time.
`train()` still returns the quantization error, so nothing that used the return value changes.
`final_learning_rate` is `None` for batch training, which has no step size — Eq. (8) is a weighted
mean, and reporting the unused initial value would read as though it had been applied.

- **`SOMConfig`**, the serialisable description of a map, available as `som.config()`.

- **`DECAY_FUNCTIONS` and `DISTANCE_FUNCTIONS`** registries with `resolve_decay` and
`resolve_distance`, mirroring `NEIGHBORHOOD_FUNCTIONS`. These names are written into artifacts, so
they are public API from 0.4.0.

- An `analysis` extra with pinned `bandit`, `pip-audit` and `pylint`, for the deeper review passes.
Not wired into any gate; ruff and mypy remain the enforced checks. CI names the extras it needs
rather than using `--all-extras`, so these are not installed on every matrix entry.

- **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
Expand Down
10 changes: 5 additions & 5 deletions architecture-profile.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@
# MIT repository. The profile ships anyway, because the declared architecture is worth stating in
# the repository rather than only in a plan, and because the local checker needs something to read.
#
# It reports 0 ERROR and 13 WARN today, and passes `--strict`. Both WARN kinds are expected, and are
# written down here so a reviewer does not read them as regressions:
# It reports 0 ERROR and 13 WARN today, and passes `--strict`. The provenance marker the preset asks
# for arrived with `TrainingReport` and `save_npz`/`load_npz`, so that WARN is gone. The count is
# unchanged at 13 only by coincidence: the strategy protocols added one function of their own to the
# positional-argument list below, which is the single remaining WARN kind.
#
# - No provenance marker. PR #10 adds `TrainingReport`, which is what the preset is asking for.
#
# - Twelve functions take 4 or 5 positional args against a preset limit of 3, with the advice to
# - Thirteen functions take 4 or 5 positional args against a preset limit of 3, with the advice to
# "group into a dataclass". Not taken, and the limit is deliberately left at 3 rather than raised
# to make the warnings disappear. `gaussian(shape, center, sigma, cyclic)` is four independent
# inputs with no natural grouping; a `NeighborhoodParams` holding four fields for one call site
Expand Down
9 changes: 9 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,12 @@
## Strategy protocols

::: python_som._core._protocols

## Artifacts

::: python_som._artifact
options:
members:
- SOMConfig
- TrainingReport
- ArtifactError
145 changes: 145 additions & 0 deletions docs/save-and-load.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Saving and loading a map

```python
som.save_npz("iris-map.npz")

som = python_som.SOM.load_npz("iris-map.npz")
som.train(more_data, n_iteration=50) # continues where it left off
```

One file holds the models and everything needed to describe how they were produced. No `pickle` is
involved on either side, so loading one cannot execute code.

## What is in the file

An `.npz` with two members:

| member | contents |
| --- | --- |
| `weights` | the models, `float64`, shape `(x, y, n_features)` |
| `metadata` | a JSON string |

The metadata is deliberately plain text, so an artifact can be inspected without this package
installed:

```python
import json, numpy as np

with np.load("iris-map.npz", allow_pickle=False) as archive:
print(json.loads(str(archive["metadata"]))["report"])
```

```json
{
"format_version": 1,
"python_som_version": "0.4.0",
"numpy_version": "2.5.1",
"config": {
"shape": [10, 10], "input_len": 4,
"learning_rate": 0.5, "neighborhood_radius": 1.0, "min_neighborhood_radius": 0.5,
"cyclic": [false, false],
"neighborhood_function": "gaussian",
"learning_rate_decay": "asymptotic_decay",
"neighborhood_radius_decay": "asymptotic_decay",
"distance_function": "euclidean_distance"
},
"rng": { "seed": 42, "state": { "bit_generator": "PCG64", "state": {"state": 1, "inc": 2} } },
"report": {
"mode": "batch", "n_iteration": 100, "n_samples": 150, "random_seed": 42,
"final_learning_rate": null, "final_neighborhood_radius": 0.5,
"quantization_error": 0.3142,
"python_som_version": "0.4.0", "numpy_version": "2.5.1", "wall_time_seconds": 0.42
}
}
```

`final_learning_rate` is `null` for batch training. Eq. (8) is a weighted mean, so there is no step
size to report; recording the unused initial value would read as though it had been applied.

## Resuming training

A reloaded map continues the *same* random stream, so training it further gives what never stopping
would have given. That is why both the seed and the generator's current state are saved: the seed
alone would restart the stream, and a resumed run would quietly diverge from an uninterrupted one.

```python
# these two end up with identical weights
a = fit(data, iterations=200)

b = fit(data, iterations=100)
b.save_npz("checkpoint.npz")
python_som.SOM.load_npz("checkpoint.npz").train(data, n_iteration=100)
```

## The training report

`som.last_report` describes the most recent `train()` call, and `None` before the first one. It is
saved with the map, so a figure can be traced back to the run that produced it — the seed, the
iteration count, the error, and the versions of this package and NumPy that computed it.

`train()` still returns the quantization error, so nothing that used the return value changes.

## Custom functions

A map's neighborhood, decays and distance are *callables*, and a callable cannot go into a file
without `pickle`. What gets saved is the **name**, resolved on load through the registries.

A map built from the shipped functions round-trips completely. One built with your own function
records the name for provenance and refuses to load silently:

```python
som = python_som.SOM(x=10, y=10, input_len=4, distance_function=cosine)
som.save_npz("custom.npz")

python_som.SOM.load_npz("custom.npz")
# ArtifactError: this map was saved with custom function(s) that cannot be restored from a name
# (distance_function='cosine'). ... Pass them back explicitly:
# load_npz(path, distance_function=...)

python_som.SOM.load_npz("custom.npz", distance_function=cosine) # works
```

The same applies to a `functools.partial`: it has no name to record, so it is treated as custom.
That is the correct outcome — resolving `partial(exponential_decay, factor=3.0)` by the wrapped
function's name would silently restore a different decay from the one that trained the map.

The names that *do* resolve are the keys of `NEIGHBORHOOD_FUNCTIONS`, `DECAY_FUNCTIONS` and
`DISTANCE_FUNCTIONS`, each of which is simply the function's own name.

## How safe is it to load a file from someone else

Safer than a pickle, and the difference is categorical rather than a matter of degree — but not
unconditionally safe, so here is exactly what is and is not guaranteed.

**Cannot happen:**

- **Code execution.** `allow_pickle=False` is passed explicitly, so a crafted object array is
refused by NumPy, not unpickled. The test suite builds a payload that really would create a file,
proves it executes when NumPy is *allowed* to unpickle it, then shows the loader refusing the same
file with nothing created.
- **Loading an arbitrary function by name.** Names resolve only through the registries. Nothing from
the file is imported, evaluated, or looked up in a module.
- **A partly-read file being treated as valid.** The member list, the metadata JSON, the format
version, the weight dimensions and the weight shape against the saved config are all checked
before a map is constructed. Every failure raises `ArtifactError`.

**Can still happen:**

- **Resource exhaustion.** An `.npz` is a zip, so a hostile file can be built to decompress to
something enormous. Nothing here caps that.

So: treat an artifact from an untrusted source the way you would a JPEG from one — it cannot run
code, but it can still be malformed or oversized. Do not treat it as you would a signed archive.

If you need integrity as well, hash the file and record the digest alongside whatever references it;
that is outside what this format tries to do.

## Version differences

Loading an artifact written by a different **major** version is allowed and warns. A major version
is where this package permits numerical results to change, so a map trained under one and reloaded
under another may not reproduce the run its own report describes. Refusing outright would make old
artifacts unreadable, which is the opposite of what provenance is for.

An artifact written in a newer *format* version is refused, naming the version that wrote it, rather
than being half-understood.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ nav:
- Neighborhood functions: neighborhood-functions.md
- Training modes: training-modes.md
- Options and types: options-and-types.md
- Saving and loading: save-and-load.md
- API reference: api.md
- Changelog: changelog.md

Expand Down
33 changes: 33 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,33 @@ docs = [
"mkdocs-material==9.7.7",
"mkdocstrings-python==2.0.5",
]
# Static analysis used during review, kept out of `dev` so the CI jobs that never run it do not
# install it. Pinned exactly, like the rest of the tooling. Nothing here gates a merge: ruff and mypy
# are the enforced checks, and these are for the deeper passes a human asks for.
#
# `osv-scanner` is deliberately absent. The real tool is a Go binary from Google; the PyPI package of
# that name was registered on 2026-07-16 with one release, no author, no home page, and a summary of
# "Reserved name placeholder. No functionality." Install the Go binary if you want it.
#
# `guarddog` is also absent: genuine (DataDog) but first released 2022-11-28, three months *after*
# this project, so it fails the rule that a dependency should predate what it is added to. Add it
# deliberately if the supply-chain checks are wanted, not as part of a batch.
#
# `semgrep` was added and then removed. It pins `mcp==1.23.3` exactly, and that version carries
# PYSEC-2026-3481/3482/3483 (fixed in mcp 1.27.2/1.28.1) -- so the exact pin makes the advisories
# unremediable from here, and 1.172.0 is already the latest semgrep. Against that, bandit reports
# nothing in `src/`, and this is a pure-numpy library with no network, auth, crypto, SQL or
# templating, which is most of what semgrep's rulesets look for. Low coverage gained for three CVEs
# that cannot be patched. Revisit if semgrep relaxes the pin.
analysis = [
# PyCQA, first released 2015. Security-oriented AST checks.
"bandit==1.9.4",
# PyPA. Reports known CVEs in the resolved dependency set; expected to report none.
"pip-audit==2.10.1",
# PyCQA, first released 2009. Not wired into any gate; here so the findings a contributor's IDE
# reports can be reproduced from the command line.
"pylint==4.0.6",
]
# Restores the install set that 0.3.0 pulled in by default, for anyone who was relying on it.
examples = [
"matplotlib>=3.8",
Expand Down Expand Up @@ -205,3 +232,9 @@ exclude_lines = [
# entire content is an ellipsis, which in this package occurs solely in _core/_protocols.py.
"^\\s*\\.\\.\\.$",
]

[tool.bandit]
# `assert` is how a test states its expectation, so B101 fires on every one of them and says nothing.
# Excluded by directory rather than by skipping the check, so B101 still applies to `src/`, where an
# assert *would* be wrong: asserts vanish under `python -O`, so validation must raise instead.
exclude_dirs = ["tests", ".venv", "site", "dist"]
9 changes: 8 additions & 1 deletion src/python_som/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from __future__ import annotations

from ._artifact import ArtifactError, SOMConfig, TrainingReport
from ._core._decay import (
asymptotic_decay,
exponential_decay,
Expand Down Expand Up @@ -53,20 +54,27 @@
)
from ._som import SOM

# `as` rather than a plain import: the explicit re-export idiom, which tells a type checker
# this is public without putting a dunder into __all__, where the public API lives.
from ._version import __version__ as __version__

__all__ = [
"NEIGHBORHOOD_FUNCTIONS",
"SIGNED_NEIGHBORHOODS",
"SOM",
"ArtifactError",
"DecayFunction",
"DistanceFunction",
"KernelFunction",
"Neighborhood",
"NeighborhoodFunction",
"NeighborhoodStr",
"SOMConfig",
"SampleMode",
"SampleModeStr",
"TrainingMode",
"TrainingModeStr",
"TrainingReport",
"WeightInit",
"WeightInitStr",
"asymptotic_decay",
Expand All @@ -79,7 +87,6 @@
"mexican_hat",
]

__version__ = "0.3.0"

# Backwards-compatible aliases. Before 0.3.0 these functions lived in the top-level module under
# underscore-prefixed names; they were private by convention but reachable, and the README listed
Expand Down
Loading
Loading