Skip to content

feat: add an estimator interface delegating to train - #17

Merged
andremsouza merged 2 commits into
masterfrom
feat/estimator-api
Jul 30, 2026
Merged

feat: add an estimator interface delegating to train#17
andremsouza merged 2 commits into
masterfrom
feat/estimator-api

Conversation

@andremsouza

Copy link
Copy Markdown
Owner

fit, transform, predict, fit_transform, score, get_params, set_params on SOM. Entirely
additive: train is unchanged and remains the primary way to train. Second of three PRs for
0.6.0.

Modelled on KMeans, verified against the installed 1.9

KMeans SOM
fit(X, y=None) same returns the estimator
transform(X)(n, n_clusters) (n, x*y) distance to every node
predict(X)(n,) (n,) flat node index
score(X) same negated, so larger is better
cluster_centers_ weights_
inertia_ quantization_error_ None before training
n_features_in_ same

On Ousterhout

These are pass-through methods by the definition in A Philosophy of Software Design §7.1 — "does
little except invoke another method, whose signature is similar or identical" — and that is ordinarily
a smell. They are here because their value is conformance to an external convention rather than
abstraction: Pipeline, GridSearchCV and cross_val_score call these exact names.

The mitigation is to keep them one-liners and prove they cannot drift:

@pytest.mark.parametrize("mode", list(TrainingMode))
def test_fit_and_train_produce_identical_weights(mode):
    ...
    assert np.abs(fitted.get_weights() - trained.get_weights()).max() == 0.0

Exactly 0.0, for every mode. A pass-through that has diverged from what it passes through is the
failure this guards.

Two decisions worth reviewing

predict returns a flat index, not (row, column). A 1-D label array is what scorers,
confusion_matrix and cross_val_score assume, so coordinates would read better for a grid and
compose with nothing. np.unravel_index(som.predict(X), som.get_shape()) recovers the position, and
a test asserts it matches winner() sample by sample. winner() itself is unchanged.

score is the negated error. Every scikit-learn scorer treats larger as better and
GridSearchCV maximises. Without the sign, a parameter search would confidently select the worst
map. A test asserts a trained map outscores an untrained one, so the sign is checked rather than
assumed.

Two differences from scikit-learn, deliberate and documented

  • fit continues rather than resetting. scikit-learn estimators conventionally discard fitted
    state on refit. A SOM's models are its state, and train has always continued. Pinned by a test
    so it stays a decision rather than becoming a surprise.
  • The models exist before training, because initialization is a separate step. There is no
    not-fitted state to raise about.

set_params refuses some things

It changes the rates, radii, decays and distance function. It raises for the grid shape and
input_len:

ValueError: 'x' cannot be changed after construction: the models would no longer match it.

Silently rebuilding would discard trained weights; leaving them would give a map whose models
contradict its own description. It also re-runs learning-rate validation, so it cannot be used to get
around the constructor's check.

set_params is what set_learning_rate/set_neighborhood_radius become. Both still work, and a test
asserts the two routes agree.

Not sufficient for Pipeline on its own

Stated plainly because the earlier plan got this wrong: since scikit-learn 1.7, Pipeline.predict,
GridSearchCV and cross_val_score require __sklearn_tags__. These method names alone give you
fit and nothing else. The adapter that provides the rest is the next PR, keeping scikit-learn an
optional dependency.

Verification

407 tests (24 new), 100% coverage, ruff / ruff format / mypy --strict clean, mkdocs build --strict exits 0, bandit clean, architecture profile --strict passes. New docs page in the nav.

fit, transform, predict, fit_transform, score, get_params and set_params on SOM, modelled on KMeans,
which is the right precedent: a SOM is a topologically-constrained k-means. train remains the primary
way to train and is unchanged, so nothing existing is affected.

These are pass-through methods by Ousterhout's definition (A Philosophy of Software Design, 7.1) and
would ordinarily be a smell. They are here because their value is conformance to an external
convention rather than abstraction: Pipeline, GridSearchCV and cross_val_score call these exact names.
Keeping them one-liners is deliberate, and a test asserts fit and train produce weights identical at
0.0 for every mode, so the two cannot drift.

predict returns a flat node index, not (row, column). A 1-D label array is what scorers,
confusion_matrix and cross_val_score assume, so coordinates would read better for a grid and compose
with nothing. np.unravel_index recovers the position, and winner() still returns coordinates.

score is the negated quantization error. Every scikit-learn scorer treats larger as better and
GridSearchCV maximises, so without the sign a parameter search would confidently select the worst map.

set_params changes the rates, radii, decays and distance function. It refuses to change the grid shape
or input_len: silently rebuilding would discard trained weights, and leaving them would produce a map
whose models do not match its own description. It re-runs learning-rate validation, so it cannot be
used to get around the constructor's check.

Two differences from scikit-learn are deliberate and documented rather than papered over. fit
continues instead of resetting, because a SOM's models are its state and train has always continued.
And the models exist before training, since initialization is a separate step, so there is no
not-fitted state to raise about.

Not sufficient on its own for Pipeline.predict or GridSearchCV, which since scikit-learn 1.7 require
__sklearn_tags__. The adapter that provides it follows separately.
ruff format applies to fenced python in markdown as well as to .py files. I ran ruff check after
writing the page but not ruff format --check, so CI caught it.
@andremsouza
andremsouza merged commit 260b6de into master Jul 30, 2026
8 checks passed
@andremsouza
andremsouza deleted the feat/estimator-api branch July 31, 2026 12:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant