Skip to content

API reference

python_som

Python implementation of Kohonen's 2-D self-organizing map.

The public surface is :class:SOM, plus the decay, distance and neighborhood functions that can be passed to it or used directly.

import numpy as np, python_som data = np.random.default_rng(0).normal(size=(150, 4)) som = python_som.SOM(x=10, y=10, input_len=4, random_seed=0) som.weight_initialization(mode="linear", data=data) error = som.train(data, n_iteration=100, mode="batch")

Internally, :mod:python_som._core holds every numeric decision as pure functions over NumPy arrays and imports nothing else; a thin shell around it handles conversion, state and I/O.

Reference: Teuvo Kohonen, Essentials of the self-organizing map, Neural Networks 37 (2013) 52-65, ISSN 0893-6080, https://doi.org/10.1016/j.neunet.2012.09.018

SOM

A 2-D self-organizing map over NumPy arrays, pandas DataFrames or plain lists.

Reference: Teuvo Kohonen, Essentials of the self-organizing map, Neural Networks 37 (2013) 52-65, https://doi.org/10.1016/j.neunet.2012.09.018

Source code in src/python_som/_som.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
class SOM:
    """A 2-D self-organizing map over NumPy arrays, pandas DataFrames or plain lists.

    Reference:
    Teuvo Kohonen, Essentials of the self-organizing map, Neural Networks 37 (2013) 52-65,
    https://doi.org/10.1016/j.neunet.2012.09.018
    """

    def __init__(
        self,
        x: int | None,
        y: int | None,
        input_len: int,
        learning_rate: float = 0.5,
        learning_rate_decay: DecayFunction = asymptotic_decay,
        neighborhood_radius: float = 1.0,
        neighborhood_radius_decay: DecayFunction = asymptotic_decay,
        neighborhood_function: Neighborhood | NeighborhoodStr = Neighborhood.GAUSSIAN,
        distance_function: DistanceFunction = euclidean_distance,
        cyclic_x: bool = False,
        cyclic_y: bool = False,
        random_seed: int | None = None,
        data: DataLike | None = None,
        *,
        min_neighborhood_radius: float = 0.5,
    ) -> None:
        """Construct a self-organizing map.

        :param x: Number of rows. If None, chosen from ``data`` by PCA. At least one of ``x`` and
            ``y`` must be given.
        :param y: Number of columns. If None, chosen from ``data`` by PCA.
        :param input_len: Number of features per input vector.
        :param learning_rate: Initial learning rate. Irrelevant for batch training.
        :param learning_rate_decay: Decay function for the learning rate.
        :param neighborhood_radius: Initial neighborhood radius.
        :param neighborhood_radius_decay: Decay function for the neighborhood radius.
        :param neighborhood_function: One of ``'gaussian'``, ``'bubble'``, ``'mexicanhat'``
            (or its alias ``'mexican_hat'``).
        :param distance_function: Dissimilarity between an input vector and the models.
        :param cyclic_x: Whether the map wraps around vertically (toroidal).
        :param cyclic_y: Whether the map wraps around horizontally (toroidal).
        :param random_seed: Seed for this instance's random generator.
        :param data: Dataset used for automatic sizing. Required when ``x`` or ``y`` is None.
        :param min_neighborhood_radius: Floor applied to the decayed radius during training.
            Kohonen (2013) Section 4.2 warns that "the final value of sigma shall not go to zero,
            because otherwise the process loses its ordering power. It should always remain, say,
            above half of the grid spacing." Defaults to 0.5, that half-spacing.
        :raises ValueError: If the dimensions, the neighborhood function name, or
            ``min_neighborhood_radius`` are invalid.
        """
        if x is None and y is None:
            msg = "At least one of the dimensions (x, y) must be specified"
            raise ValueError(msg)
        if x is None or y is None:
            if data is None:
                msg = (
                    "If one of the dimensions is not specified, a dataset must be provided "
                    "for automatic size initialization."
                )
                raise ValueError(msg)
            x, y = auto_dimensions(x, y, to_numpy(data))

        if x <= 0 or y <= 0:
            msg = f"Map dimensions must be positive, got ({x}, {y})"
            raise ValueError(msg)
        if input_len <= 0:
            msg = f"'input_len' must be positive, got {input_len}"
            raise ValueError(msg)
        if not np.isfinite(min_neighborhood_radius) or min_neighborhood_radius <= 0:
            msg = (
                "'min_neighborhood_radius' must be a finite positive number, "
                f"got {min_neighborhood_radius!r}"
            )
            raise ValueError(msg)
        _validate_learning_rate(learning_rate)

        self._shape: tuple[int, int] = (int(x), int(y))
        self._input_len = int(input_len)
        self._learning_rate = float(learning_rate)
        self._learning_rate_decay = learning_rate_decay
        self._neighborhood_radius = float(neighborhood_radius)
        self._neighborhood_radius_decay = neighborhood_radius_decay
        self._min_neighborhood_radius = float(min_neighborhood_radius)
        self._neighborhood_function_name = neighborhood_function
        self._neighborhood_function: NeighborhoodFunction = resolve(neighborhood_function)
        self._distance_function = distance_function
        self._cyclic = (bool(cyclic_x), bool(cyclic_y))

        # secrets, rather than an unseeded Generator or SeedSequence, so that the intent reads as
        # "draw unpredictable bits for a seed" and the seed we store is always a plain int.
        self._random_seed = (
            int(random_seed) if random_seed is not None else secrets.randbits(_SEED_BITS)
        )
        self._rng = np.random.default_rng(self._random_seed)
        self._weights = random_models(self._shape, self._input_len, self._rng)

        #: Set by `train`. None until then, which is what distinguishes an untrained map from one
        #: trained with zero effect.
        self._last_report: TrainingReport | None = None

    # ------------------------------------------------------------------ accessors

    def get_shape(self) -> tuple[int, int]:
        """Return the shape of the network."""
        return self._shape

    def get_weights(self) -> npt.NDArray[np.floating]:
        """Return the weight matrix of the network."""
        return self._weights

    def get_random_seed(self) -> int:
        """Return the seed of this instance's random generator."""
        return self._random_seed

    def set_learning_rate(self, learning_rate: float) -> None:
        """Set the learning rate.

        :param learning_rate: New learning rate.
        """
        self._learning_rate = float(learning_rate)

    def set_neighborhood_radius(self, neighborhood_radius: float) -> None:
        """Set the neighborhood radius.

        :param neighborhood_radius: New neighborhood radius.
        """
        self._neighborhood_radius = float(neighborhood_radius)

    def neighborhood(self, c: tuple[int, int], sigma: float) -> npt.NDArray[np.floating]:
        """Evaluate the neighborhood function centred on ``c``.

        :param c: Coordinates of the winner.
        :param sigma: Neighborhood radius.
        :return: Neighborhood weights, with the shape of the network.
        """
        return self._neighborhood_function(self._shape, c, sigma, self._cyclic)

    # ------------------------------------------------------------------ inference

    def activate(self, x: npt.ArrayLike) -> npt.NDArray[np.floating]:
        """Return the distance from ``x`` to every model of the network.

        :param x: Input vector.
        :return: Distances, with the shape of the network.
        """
        return activate(x, self._weights, self._distance_function)

    def winner(self, x: npt.ArrayLike) -> tuple[int, int]:
        """Return the coordinates of the best-matching unit for ``x``.

        :param x: Input vector.
        :return: Coordinates of the winner.
        """
        return winner(x, self._weights, self._distance_function)

    def quantization(self, data: DataLike) -> npt.NDArray[np.floating]:
        """Return the distance from each sample to its best-matching model.

        :param data: Dataset of shape ``(n_samples, n_features)``.
        :return: One distance per sample.
        """
        return quantization(to_numpy(data), self._weights, self._distance_function)

    def quantization_error(self, data: DataLike) -> float:
        """Return the mean distance from each sample to its best-matching model.

        :param data: Dataset of shape ``(n_samples, n_features)``.
        :return: Quantization error.
        """
        return float(self.quantization(data).mean())

    # ------------------------------------------------------------------ summaries

    def distance_matrix(self, normalize: bool = False) -> npt.NDArray[np.floating]:
        """Return the U-matrix: the summed distance from each model to its immediate neighbours.

        :param normalize: Whether to rescale the result to ``[0, 1]``.
        :return: U-matrix, with the shape of the network.
        """
        return u_matrix(
            self._weights,
            self._shape,
            self._cyclic,
            self._distance_function,
            normalize=normalize,
        )

    def activation_matrix(self, data: DataLike) -> npt.NDArray[np.floating]:
        """Return how many samples map to each node.

        :param data: Dataset of shape ``(n_samples, n_features)``.
        :return: Counts, with the shape of the network.
        """
        return activation_matrix(
            to_numpy(data), self._weights, self._shape, self._distance_function
        )

    def winner_map(self, data: DataLike) -> dict[tuple[int, int], list[npt.NDArray[Any]]]:
        """Return, for each node, the samples that map to it.

        :param data: Dataset of shape ``(n_samples, n_features)``.
        :return: Mapping from node coordinates to the samples assigned to that node.
        """
        return winner_map(to_numpy(data), self._weights, self._shape, self._distance_function)

    def label_map(self, data: DataLike, labels: DataLike) -> dict[tuple[int, int], Counter[Any]]:
        """Return, for each node, the frequency of each label mapped to it.

        :param data: Dataset of shape ``(n_samples, n_features)``.
        :param labels: One label per sample, in the same order as ``data``.
        :return: Mapping from node coordinates to a label counter.
        :raises ValueError: If ``data`` and ``labels`` have different lengths.
        """
        return label_map(
            to_numpy(data),
            to_numpy(labels),
            self._weights,
            self._shape,
            self._distance_function,
        )

    # ------------------------------------------------------------------ training

    def train(
        self,
        data: DataLike,
        n_iteration: int | None = None,
        mode: TrainingMode | TrainingModeStr = TrainingMode.RANDOM,
        verbose: bool = False,
    ) -> float:
        """Train the map and return the resulting quantization error.

        :param data: Training dataset of shape ``(n_samples, n_features)``.
        :param n_iteration: Number of iterations. Defaults to 1000 per sample for the stepwise
            modes and 10 per sample for batch.
        :param mode: One of ``'random'``, ``'sequential'`` or ``'batch'``. Kohonen (2013)
            Section 3.1 recommends batch: "its convergence is an order of magnitude faster and
            safer", and it has no learning-rate parameter.
        :param verbose: Whether to report progress. Emits a tqdm progress bar when tqdm is
            installed, and logs at INFO level on the ``python_som`` logger either way.
        :return: Quantization error after training.
        :raises ValueError: If ``mode`` is unknown, if the dataset is empty, or if batch training
            is combined with a signed neighborhood function.
        """
        array = to_numpy(data)
        if len(array) == 0:
            msg = "Cannot train on an empty dataset"
            raise ValueError(msg)
        if mode not in TRAINING_MODES:
            msg = (
                f"Invalid value for 'mode' parameter: {str(mode)!r}. "
                f"Value should be one of {list(TRAINING_MODES)}"
            )
            raise ValueError(msg)
        if mode == "batch" and self._neighborhood_function_name in SIGNED_NEIGHBORHOODS:
            msg = (
                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'."
            )
            raise ValueError(msg)
        # A neighborhood that is unsigned but not separable would pass the check above and then be
        # refused by `resolve_axis_profile` in `_train_batch`, which names the constraint. No
        # separate guard here: every unsigned neighborhood is separable today, so it would be an
        # untested branch for a case that cannot yet arise.

        if n_iteration is None:
            n_iteration = DEFAULT_ITERATIONS_PER_SAMPLE[mode] * len(array)
        if n_iteration <= 0:
            msg = f"'n_iteration' must be positive, got {n_iteration}"
            raise ValueError(msg)

        logger.info("Training with %d iterations in %r mode", n_iteration, mode)

        started = time.perf_counter()
        if mode == "batch":
            final_alpha, final_sigma = self._train_batch(array, n_iteration, verbose=verbose)
        else:
            final_alpha, final_sigma = self._train_stepwise(
                array, n_iteration, mode=mode, verbose=verbose
            )
        elapsed = time.perf_counter() - started

        error = self.quantization_error(array)
        logger.info("Quantization error: %g", error)

        self._last_report = TrainingReport(
            mode=str(mode),
            n_iteration=n_iteration,
            n_samples=len(array),
            random_seed=self._random_seed,
            final_learning_rate=final_alpha,
            final_neighborhood_radius=final_sigma,
            quantization_error=float(error),
            python_som_version=__version__,
            numpy_version=np.__version__,
            wall_time_seconds=elapsed,
        )
        return error

    @property
    def last_report(self) -> TrainingReport | None:
        """What the most recent :meth:`train` call did, or None if it has not been called.

        :return: The report for the last training run.
        """
        return self._last_report

    # ------------------------------------------------------------------ estimator interface

    # These delegate to `train`, `activate` and `winner` rather than adding behaviour, which by
    # Ousterhout's definition (A Philosophy of Software Design, 7.1) makes them pass-through methods
    # and ordinarily a smell. They are here because their value is conformance to an external
    # convention rather than abstraction: scikit-learn's `Pipeline`, `GridSearchCV` and
    # `cross_val_score` call these exact names, and `KMeans` is the precedent a SOM should follow
    # since it is a topologically-constrained k-means. `train` stays the primary method, which is
    # also what users of minisom expect.
    #
    # Keeping them one-liners is deliberate: two ways to train that cannot drift apart.
    #
    # Full scikit-learn integration needs more than these names -- since 1.7 `Pipeline.predict` and
    # `GridSearchCV` require `__sklearn_tags__` -- and lives in `python_som.sklearn`.

    def fit(
        self,
        X: DataLike,  # noqa: N803  the estimator convention capitalises the design matrix
        y: object = None,  # noqa: ARG002  accepted and ignored, as the convention requires
        *,
        n_iteration: int | None = None,
        mode: TrainingMode | TrainingModeStr = TrainingMode.RANDOM,
        verbose: bool = False,
    ) -> SOM:
        """Train the map and return it, so calls can be chained.

        ``y`` is accepted and ignored, which is what lets ``Pipeline`` call every step the same way.

        Training options are keyword arguments here rather than constructor arguments;
        :mod:`python_som.sklearn` takes them at construction, because ``get_params`` must expose
        them to ``GridSearchCV``.

        :param X: Training dataset of shape ``(n_samples, n_features)``.
        :param y: Ignored.
        :param n_iteration: Number of iterations. Defaults as for :meth:`train`.
        :param mode: Training mode.
        :param verbose: Whether to show a progress bar.
        :return: This map, trained.
        """
        self.train(X, n_iteration=n_iteration, mode=mode, verbose=verbose)
        return self

    def transform(self, X: DataLike) -> npt.NDArray[np.floating]:  # noqa: N803
        """Return the distance from each sample to every model.

        The counterpart of ``KMeans.transform``, which "transforms X to a cluster-distance space".
        Here the space has one dimension per node, so the result is ``(n_samples, x * y)`` with the
        grid flattened in C order.

        :param X: Dataset of shape ``(n_samples, n_features)``.
        :return: Distances of shape ``(n_samples, x * y)``.
        """
        array = to_numpy(X)
        return np.array([self.activate(sample).ravel() for sample in array])

    def fit_transform(
        self,
        X: DataLike,  # noqa: N803
        y: object = None,  # noqa: ARG002
        **kwargs: Any,  # noqa: ANN401
    ) -> npt.NDArray[np.floating]:
        """Train on ``X`` and return its distances to every model.

        :param X: Training dataset.
        :param y: Ignored.
        :param kwargs: Passed to :meth:`fit`.
        :return: Distances of shape ``(n_samples, x * y)``.
        """
        return self.fit(X, **kwargs).transform(X)

    def predict(self, X: DataLike) -> npt.NDArray[np.integer]:  # noqa: N803
        """Return the index of the best-matching node for each sample.

        A **flat** index, not a ``(row, column)`` pair, because that is what scorers and
        ``confusion_matrix`` assume. Recover the grid position with
        ``np.unravel_index(som.predict(X), som.get_shape())``; :meth:`winner` still returns
        coordinates for a single sample.

        :param X: Dataset of shape ``(n_samples, n_features)``.
        :return: One flat node index per sample.
        """
        return bmu_indices(to_numpy(X), self._weights, self._distance_function, bmu_kernel())

    def score(self, X: DataLike, y: object = None) -> float:  # noqa: ARG002, N803
        """Return the negated quantization error, so that larger is better.

        The sign is the convention every scikit-learn scorer follows: ``GridSearchCV`` maximises,
        and quantization error is something to minimise. Without the negation a parameter search
        would confidently select the worst map.

        :param X: Dataset to score.
        :param y: Ignored.
        :return: Negated mean quantization error.
        """
        return -self.quantization_error(X)

    def get_params(self, *, deep: bool = True) -> dict[str, Any]:  # noqa: ARG002
        """Return the constructor arguments that describe this map.

        Strategies come back as the callables or names that were passed in, so that
        ``SOM(**som.get_params())`` rebuilds an equivalent map. That is what ``clone`` relies on.

        :param deep: Accepted for signature compatibility; this estimator holds no sub-estimators.
        :return: Constructor arguments by name.
        """
        return {
            "x": self._shape[0],
            "y": self._shape[1],
            "input_len": self._input_len,
            "learning_rate": self._learning_rate,
            "learning_rate_decay": self._learning_rate_decay,
            "neighborhood_radius": self._neighborhood_radius,
            "neighborhood_radius_decay": self._neighborhood_radius_decay,
            "neighborhood_function": self._neighborhood_function_name,
            "distance_function": self._distance_function,
            "cyclic_x": self._cyclic[0],
            "cyclic_y": self._cyclic[1],
            "random_seed": self._random_seed,
            "min_neighborhood_radius": self._min_neighborhood_radius,
        }

    def set_params(self, **params: Any) -> SOM:  # noqa: ANN401
        """Set constructor-level parameters in place and return this map.

        Only what can change without rebuilding the models: the rates, radii and decays. The grid
        shape and ``input_len`` raise, rather than leaving a map whose models do not match its own
        description. Replaces :meth:`set_learning_rate` and :meth:`set_neighborhood_radius`, which
        still work and are removed in 1.0.0.

        :param params: Parameters to set.
        :return: This map.
        :raises ValueError: If a parameter is unknown or cannot be changed after construction.
        """
        settable = {
            "learning_rate": "_learning_rate",
            "learning_rate_decay": "_learning_rate_decay",
            "neighborhood_radius": "_neighborhood_radius",
            "neighborhood_radius_decay": "_neighborhood_radius_decay",
            "min_neighborhood_radius": "_min_neighborhood_radius",
            "distance_function": "_distance_function",
        }
        for name, value in params.items():
            if name in settable:
                setattr(self, settable[name], value)
            elif name in self.get_params():
                msg = (
                    f"{name!r} cannot be changed after construction: the models would no longer "
                    f"match it. Build a new SOM instead. Settable: {sorted(settable)}"
                )
                raise ValueError(msg)
            else:
                msg = f"Unknown parameter {name!r}. Valid parameters: {sorted(self.get_params())}"
                raise ValueError(msg)
        _validate_learning_rate(self._learning_rate)
        return self

    @property
    def weights_(self) -> npt.NDArray[np.floating]:
        """The models, under the trailing-underscore name the estimator convention uses.

        :return: Models of shape ``(x, y, n_features)``.
        """
        return self.get_weights()

    @property
    def n_features_in_(self) -> int:
        """Number of features the map accepts, as ``KMeans`` reports it.

        :return: Number of features.
        """
        return self._input_len

    @property
    def quantization_error_(self) -> float | None:
        """Quantization error of the last training run, or None before the first.

        The counterpart of ``KMeans.inertia_``.

        :return: The error, or None.
        """
        return None if self._last_report is None else self._last_report.quantization_error

    # ------------------------------------------------------------------ artifacts

    def config(self) -> SOMConfig:
        """Describe this map in a form that can be written to a file.

        Strategies appear by name. A callable that is not one of the package's registered functions
        is recorded as its ``__name__``, which preserves the provenance but cannot be resolved back
        into a callable; :meth:`load_npz` says so explicitly rather than guessing.

        :return: The configuration.
        """
        return SOMConfig(
            shape=self._shape,
            input_len=self._input_len,
            learning_rate=self._learning_rate,
            neighborhood_radius=self._neighborhood_radius,
            min_neighborhood_radius=self._min_neighborhood_radius,
            cyclic=self._cyclic,
            neighborhood_function=str(self._neighborhood_function_name),
            learning_rate_decay=_name_of(self._learning_rate_decay),
            neighborhood_radius_decay=_name_of(self._neighborhood_radius_decay),
            distance_function=_name_of(self._distance_function),
        )

    def save_npz(self, path: str | os.PathLike[str]) -> None:
        """Write the models and their provenance to a single ``.npz`` file.

        Weights as an array, everything else as JSON beside them: configuration, seed, generator
        state and the last training report. No pickle on either side.

        The generator *state* is saved as well as the seed, which is what lets :meth:`load_npz`
        resume the same stream rather than restarting it.

        :param path: Destination file.
        """
        np.savez(
            path,
            weights=self._weights,
            metadata=np.array(
                metadata_json(
                    self.config(),
                    self._random_seed,
                    self._rng.bit_generator.state,
                    self._last_report,
                    __version__,
                )
            ),
        )
        logger.info("Saved a %s map to %s", "x".join(map(str, self._shape)), path)

    @classmethod
    def load_npz(
        cls,
        path: str | os.PathLike[str],
        *,
        neighborhood_function: NeighborhoodFunction | None = None,
        learning_rate_decay: DecayFunction | None = None,
        neighborhood_radius_decay: DecayFunction | None = None,
        distance_function: DistanceFunction | None = None,
    ) -> SOM:
        """Rebuild a map saved by :meth:`save_npz`, models, generator state and all.

        Continuing to train a loaded map gives the same weights as never having stopped, which is
        what the saved generator state is for.

        The four keyword arguments are for maps trained with a function this package cannot resolve
        by name. Passing one the file did not need is harmless.

        :param path: File to read.
        :param neighborhood_function: Replacement for a neighborhood that cannot be resolved.
        :param learning_rate_decay: Replacement for a learning-rate decay that cannot be resolved.
        :param neighborhood_radius_decay: Replacement for a radius decay that cannot be resolved.
        :param distance_function: Replacement for a distance that cannot be resolved.
        :return: The reconstructed map.
        :raises ArtifactError: If the file is not a readable artifact, or names a function that
            cannot be resolved and was not supplied.
        """
        weights, metadata = load_arrays(path)
        config = config_from(metadata, path)
        strategies = resolve_strategies(
            config,
            {
                "neighborhood_function": neighborhood_function,
                "learning_rate_decay": learning_rate_decay,
                "neighborhood_radius_decay": neighborhood_radius_decay,
                "distance_function": distance_function,
            },
        )

        if weights.shape != (*config.shape, config.input_len):
            msg = (
                f"{path} holds weights of shape {weights.shape}, which does not match the saved "
                f"configuration {(*config.shape, config.input_len)}"
            )
            raise ArtifactError(msg)

        rng = metadata.get("rng") or {}
        som = cls(
            x=config.shape[0],
            y=config.shape[1],
            input_len=config.input_len,
            learning_rate=config.learning_rate,
            neighborhood_radius=config.neighborhood_radius,
            neighborhood_function=strategies.neighborhood_function,
            cyclic_x=config.cyclic[0],
            cyclic_y=config.cyclic[1],
            random_seed=rng.get("seed"),
            min_neighborhood_radius=config.min_neighborhood_radius,
            learning_rate_decay=strategies.learning_rate_decay,
            neighborhood_radius_decay=strategies.neighborhood_radius_decay,
            distance_function=strategies.distance_function,
        )
        som._weights = weights
        if rng.get("state") is not None:
            # NumPy validates the state and raises ValueError on a malformed one ("state must be for
            # a PCG64 RNG"). Translated, so that every way a bad file can fail reaches the caller as
            # ArtifactError rather than as a message about bit generators from two libraries down.
            try:
                som._rng.bit_generator.state = rng["state"]
            except (ValueError, TypeError, KeyError) as exc:
                msg = f"{path} has an unusable random generator state: {exc}"
                raise ArtifactError(msg) from exc
        som._last_report = report_from(metadata)
        _warn_on_major_version_change(metadata.get("python_som_version"), path)
        return som

    def _sigma(self, t: int, n_iteration: int) -> float:
        """Return the neighborhood radius at iteration ``t``, floored.

        :param t: Current iteration.
        :param n_iteration: Total number of iterations.
        :return: Neighborhood radius, never below ``min_neighborhood_radius``.
        """
        sigma = self._neighborhood_radius_decay(self._neighborhood_radius, t, n_iteration)
        return max(float(sigma), self._min_neighborhood_radius)

    @staticmethod
    def _progress(iterable: Iterable[_T], total: int, *, verbose: bool) -> Iterable[_T]:
        """Wrap ``iterable`` in a tqdm bar when progress reporting is wanted and possible.

        :param iterable: Iterable to wrap.
        :param total: Expected number of items.
        :param verbose: Whether progress was requested.
        :return: The iterable, wrapped or not.
        """
        if verbose and _tqdm is not None:
            wrapped: Iterable[_T] = _tqdm.tqdm(iterable, total=total, desc="Training")
            return wrapped
        return iterable

    def _train_stepwise(
        self, array: npt.NDArray[Any], n_iteration: int, *, mode: str, verbose: bool
    ) -> tuple[float | None, float]:
        """Train one sample at a time, updating the winner and its neighbourhood.

        Eq. (3) of Kohonen (2013). ``'sequential'`` cycles the dataset in order; ``'random'`` draws
        i.i.d. **with replacement**, the Robbins-Monro approximation Kohonen cites in Section 4.1.

        :param array: Training dataset.
        :param n_iteration: Number of iterations.
        :param mode: Either ``'random'`` or ``'sequential'``.
        :param verbose: Whether to show a progress bar.
        """
        if mode == "random":
            indices = self._rng.integers(len(array), size=n_iteration)
        else:
            indices = np.resize(np.arange(len(array)), n_iteration)

        alpha = self._learning_rate
        sigma = self._neighborhood_radius
        for t, index in enumerate(self._progress(indices, n_iteration, verbose=verbose)):
            alpha = self._learning_rate_decay(self._learning_rate, t, n_iteration)
            sigma = self._sigma(t, n_iteration)
            sample = array[index]
            winning = self.winner(sample)
            self._weights = stepwise_update(
                self._weights, sample, self.neighborhood(winning, sigma), alpha
            )
        # Returned rather than recomputed by the caller, so the report states what the loop actually
        # used. Recomputing would also add a decay call, which the iteration-count tests measure.
        return float(alpha), float(sigma)

    def _train_batch(
        self, array: npt.NDArray[Any], n_iteration: int, *, verbose: bool
    ) -> tuple[float | None, float]:
        """Train with the batch algorithm, updating every model concurrently.

        Eq. (8) of Kohonen (2013). The winner map is recomputed from the models as they stood at
        the start of each iteration, which is what makes the update concurrent (Section 4.4).

        The neighborhood is contracted as two per-axis matrices rather than evaluated per node; see
        :doc:`/explanation/how-batch-training-is-computed`.

        :param array: Training dataset.
        :param n_iteration: Number of iterations.
        :param verbose: Whether to show a progress bar.
        """
        profile = resolve_axis_profile(self._neighborhood_function_name)
        sigma = self._neighborhood_radius
        for t in self._progress(range(n_iteration), n_iteration, verbose=verbose):
            sigma = self._sigma(t, n_iteration)
            sums, counts = accumulate(
                array, self._weights, self._shape, self._distance_function, bmu_kernel()
            )
            hx = axis_matrix(self._shape[0], sigma, cyclic=self._cyclic[0], profile=profile)
            hy = axis_matrix(self._shape[1], sigma, cyclic=self._cyclic[1], profile=profile)
            self._weights = batch_update(self._weights, sums, counts, hx, hy)

        # No learning rate: Eq. (8) is a weighted mean, so there is no step size to report. None
        # rather than the unused initial value, which would read as though it had been applied.
        return None, float(sigma)

    # ------------------------------------------------------------------ initialization

    def weight_initialization(
        self,
        mode: WeightInit | WeightInitStr = WeightInit.RANDOM,
        **kwargs: Any,  # noqa: ANN401
    ) -> None:
        """Initialize the models of the network.

        :param mode: One of ``'random'``, ``'linear'`` or ``'sample'``.
        :param kwargs: Passed through to the chosen initializer. ``'random'`` accepts
            ``sample_mode`` (``'standard_normal'`` or ``'uniform'``); ``'linear'`` and ``'sample'``
            require ``data``.
        :raises ValueError: If ``mode`` is unknown, or if the arguments do not suit it.
        """
        if mode not in INITIALIZATION_MODES:
            msg = (
                f"Invalid value for 'mode' parameter: {str(mode)!r}. "
                f"Value should be one of {list(INITIALIZATION_MODES)}"
            )
            raise ValueError(msg)
        try:
            if mode == "random":
                self._weights = random_models(
                    self._shape,
                    self._input_len,
                    self._rng,
                    sample_mode=kwargs.pop("sample_mode", SampleMode.STANDARD_NORMAL),
                )
            elif mode == "linear":
                self._weights = linear_models(to_numpy(kwargs.pop("data")), self._shape)
            else:
                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"{str(mode)!r} initialization requires a {exc} argument"
            raise ValueError(msg) from exc
        if kwargs:
            msg = (
                f"Unexpected argument(s) for {str(mode)!r} initialization: {sorted(kwargs)}. "
                f"Valid modes are {list(INITIALIZATION_MODES)}."
            )
            raise ValueError(msg)

last_report property

last_report

What the most recent :meth:train call did, or None if it has not been called.

Returns:

Type Description
TrainingReport | None

The report for the last training run.

weights_ property

weights_

The models, under the trailing-underscore name the estimator convention uses.

Returns:

Type Description
NDArray[floating]

Models of shape (x, y, n_features).

n_features_in_ property

n_features_in_

Number of features the map accepts, as KMeans reports it.

Returns:

Type Description
int

Number of features.

quantization_error_ property

quantization_error_

Quantization error of the last training run, or None before the first.

The counterpart of KMeans.inertia_.

Returns:

Type Description
float | None

The error, or None.

__init__

__init__(
    x,
    y,
    input_len,
    learning_rate=0.5,
    learning_rate_decay=asymptotic_decay,
    neighborhood_radius=1.0,
    neighborhood_radius_decay=asymptotic_decay,
    neighborhood_function=Neighborhood.GAUSSIAN,
    distance_function=euclidean_distance,
    cyclic_x=False,
    cyclic_y=False,
    random_seed=None,
    data=None,
    *,
    min_neighborhood_radius=0.5,
)

Construct a self-organizing map.

Parameters:

Name Type Description Default
x int | None

Number of rows. If None, chosen from data by PCA. At least one of x and y must be given.

required
y int | None

Number of columns. If None, chosen from data by PCA.

required
input_len int

Number of features per input vector.

required
learning_rate float

Initial learning rate. Irrelevant for batch training.

0.5
learning_rate_decay DecayFunction

Decay function for the learning rate.

asymptotic_decay
neighborhood_radius float

Initial neighborhood radius.

1.0
neighborhood_radius_decay DecayFunction

Decay function for the neighborhood radius.

asymptotic_decay
neighborhood_function Neighborhood | NeighborhoodStr

One of 'gaussian', 'bubble', 'mexicanhat' (or its alias 'mexican_hat').

GAUSSIAN
distance_function DistanceFunction

Dissimilarity between an input vector and the models.

euclidean_distance
cyclic_x bool

Whether the map wraps around vertically (toroidal).

False
cyclic_y bool

Whether the map wraps around horizontally (toroidal).

False
random_seed int | None

Seed for this instance's random generator.

None
data DataLike | None

Dataset used for automatic sizing. Required when x or y is None.

None
min_neighborhood_radius float

Floor applied to the decayed radius during training. Kohonen (2013) Section 4.2 warns that "the final value of sigma shall not go to zero, because otherwise the process loses its ordering power. It should always remain, say, above half of the grid spacing." Defaults to 0.5, that half-spacing.

0.5

Raises:

Type Description
ValueError

If the dimensions, the neighborhood function name, or min_neighborhood_radius are invalid.

Source code in src/python_som/_som.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
def __init__(
    self,
    x: int | None,
    y: int | None,
    input_len: int,
    learning_rate: float = 0.5,
    learning_rate_decay: DecayFunction = asymptotic_decay,
    neighborhood_radius: float = 1.0,
    neighborhood_radius_decay: DecayFunction = asymptotic_decay,
    neighborhood_function: Neighborhood | NeighborhoodStr = Neighborhood.GAUSSIAN,
    distance_function: DistanceFunction = euclidean_distance,
    cyclic_x: bool = False,
    cyclic_y: bool = False,
    random_seed: int | None = None,
    data: DataLike | None = None,
    *,
    min_neighborhood_radius: float = 0.5,
) -> None:
    """Construct a self-organizing map.

    :param x: Number of rows. If None, chosen from ``data`` by PCA. At least one of ``x`` and
        ``y`` must be given.
    :param y: Number of columns. If None, chosen from ``data`` by PCA.
    :param input_len: Number of features per input vector.
    :param learning_rate: Initial learning rate. Irrelevant for batch training.
    :param learning_rate_decay: Decay function for the learning rate.
    :param neighborhood_radius: Initial neighborhood radius.
    :param neighborhood_radius_decay: Decay function for the neighborhood radius.
    :param neighborhood_function: One of ``'gaussian'``, ``'bubble'``, ``'mexicanhat'``
        (or its alias ``'mexican_hat'``).
    :param distance_function: Dissimilarity between an input vector and the models.
    :param cyclic_x: Whether the map wraps around vertically (toroidal).
    :param cyclic_y: Whether the map wraps around horizontally (toroidal).
    :param random_seed: Seed for this instance's random generator.
    :param data: Dataset used for automatic sizing. Required when ``x`` or ``y`` is None.
    :param min_neighborhood_radius: Floor applied to the decayed radius during training.
        Kohonen (2013) Section 4.2 warns that "the final value of sigma shall not go to zero,
        because otherwise the process loses its ordering power. It should always remain, say,
        above half of the grid spacing." Defaults to 0.5, that half-spacing.
    :raises ValueError: If the dimensions, the neighborhood function name, or
        ``min_neighborhood_radius`` are invalid.
    """
    if x is None and y is None:
        msg = "At least one of the dimensions (x, y) must be specified"
        raise ValueError(msg)
    if x is None or y is None:
        if data is None:
            msg = (
                "If one of the dimensions is not specified, a dataset must be provided "
                "for automatic size initialization."
            )
            raise ValueError(msg)
        x, y = auto_dimensions(x, y, to_numpy(data))

    if x <= 0 or y <= 0:
        msg = f"Map dimensions must be positive, got ({x}, {y})"
        raise ValueError(msg)
    if input_len <= 0:
        msg = f"'input_len' must be positive, got {input_len}"
        raise ValueError(msg)
    if not np.isfinite(min_neighborhood_radius) or min_neighborhood_radius <= 0:
        msg = (
            "'min_neighborhood_radius' must be a finite positive number, "
            f"got {min_neighborhood_radius!r}"
        )
        raise ValueError(msg)
    _validate_learning_rate(learning_rate)

    self._shape: tuple[int, int] = (int(x), int(y))
    self._input_len = int(input_len)
    self._learning_rate = float(learning_rate)
    self._learning_rate_decay = learning_rate_decay
    self._neighborhood_radius = float(neighborhood_radius)
    self._neighborhood_radius_decay = neighborhood_radius_decay
    self._min_neighborhood_radius = float(min_neighborhood_radius)
    self._neighborhood_function_name = neighborhood_function
    self._neighborhood_function: NeighborhoodFunction = resolve(neighborhood_function)
    self._distance_function = distance_function
    self._cyclic = (bool(cyclic_x), bool(cyclic_y))

    # secrets, rather than an unseeded Generator or SeedSequence, so that the intent reads as
    # "draw unpredictable bits for a seed" and the seed we store is always a plain int.
    self._random_seed = (
        int(random_seed) if random_seed is not None else secrets.randbits(_SEED_BITS)
    )
    self._rng = np.random.default_rng(self._random_seed)
    self._weights = random_models(self._shape, self._input_len, self._rng)

    #: Set by `train`. None until then, which is what distinguishes an untrained map from one
    #: trained with zero effect.
    self._last_report: TrainingReport | None = None

get_shape

get_shape()

Return the shape of the network.

Source code in src/python_som/_som.py
276
277
278
def get_shape(self) -> tuple[int, int]:
    """Return the shape of the network."""
    return self._shape

get_weights

get_weights()

Return the weight matrix of the network.

Source code in src/python_som/_som.py
280
281
282
def get_weights(self) -> npt.NDArray[np.floating]:
    """Return the weight matrix of the network."""
    return self._weights

get_random_seed

get_random_seed()

Return the seed of this instance's random generator.

Source code in src/python_som/_som.py
284
285
286
def get_random_seed(self) -> int:
    """Return the seed of this instance's random generator."""
    return self._random_seed

set_learning_rate

set_learning_rate(learning_rate)

Set the learning rate.

Parameters:

Name Type Description Default
learning_rate float

New learning rate.

required
Source code in src/python_som/_som.py
288
289
290
291
292
293
def set_learning_rate(self, learning_rate: float) -> None:
    """Set the learning rate.

    :param learning_rate: New learning rate.
    """
    self._learning_rate = float(learning_rate)

set_neighborhood_radius

set_neighborhood_radius(neighborhood_radius)

Set the neighborhood radius.

Parameters:

Name Type Description Default
neighborhood_radius float

New neighborhood radius.

required
Source code in src/python_som/_som.py
295
296
297
298
299
300
def set_neighborhood_radius(self, neighborhood_radius: float) -> None:
    """Set the neighborhood radius.

    :param neighborhood_radius: New neighborhood radius.
    """
    self._neighborhood_radius = float(neighborhood_radius)

neighborhood

neighborhood(c, sigma)

Evaluate the neighborhood function centred on c.

Parameters:

Name Type Description Default
c tuple[int, int]

Coordinates of the winner.

required
sigma float

Neighborhood radius.

required

Returns:

Type Description
NDArray[floating]

Neighborhood weights, with the shape of the network.

Source code in src/python_som/_som.py
302
303
304
305
306
307
308
309
def neighborhood(self, c: tuple[int, int], sigma: float) -> npt.NDArray[np.floating]:
    """Evaluate the neighborhood function centred on ``c``.

    :param c: Coordinates of the winner.
    :param sigma: Neighborhood radius.
    :return: Neighborhood weights, with the shape of the network.
    """
    return self._neighborhood_function(self._shape, c, sigma, self._cyclic)

activate

activate(x)

Return the distance from x to every model of the network.

Parameters:

Name Type Description Default
x ArrayLike

Input vector.

required

Returns:

Type Description
NDArray[floating]

Distances, with the shape of the network.

Source code in src/python_som/_som.py
313
314
315
316
317
318
319
def activate(self, x: npt.ArrayLike) -> npt.NDArray[np.floating]:
    """Return the distance from ``x`` to every model of the network.

    :param x: Input vector.
    :return: Distances, with the shape of the network.
    """
    return activate(x, self._weights, self._distance_function)

winner

winner(x)

Return the coordinates of the best-matching unit for x.

Parameters:

Name Type Description Default
x ArrayLike

Input vector.

required

Returns:

Type Description
tuple[int, int]

Coordinates of the winner.

Source code in src/python_som/_som.py
321
322
323
324
325
326
327
def winner(self, x: npt.ArrayLike) -> tuple[int, int]:
    """Return the coordinates of the best-matching unit for ``x``.

    :param x: Input vector.
    :return: Coordinates of the winner.
    """
    return winner(x, self._weights, self._distance_function)

quantization

quantization(data)

Return the distance from each sample to its best-matching model.

Parameters:

Name Type Description Default
data DataLike

Dataset of shape (n_samples, n_features).

required

Returns:

Type Description
NDArray[floating]

One distance per sample.

Source code in src/python_som/_som.py
329
330
331
332
333
334
335
def quantization(self, data: DataLike) -> npt.NDArray[np.floating]:
    """Return the distance from each sample to its best-matching model.

    :param data: Dataset of shape ``(n_samples, n_features)``.
    :return: One distance per sample.
    """
    return quantization(to_numpy(data), self._weights, self._distance_function)

quantization_error

quantization_error(data)

Return the mean distance from each sample to its best-matching model.

Parameters:

Name Type Description Default
data DataLike

Dataset of shape (n_samples, n_features).

required

Returns:

Type Description
float

Quantization error.

Source code in src/python_som/_som.py
337
338
339
340
341
342
343
def quantization_error(self, data: DataLike) -> float:
    """Return the mean distance from each sample to its best-matching model.

    :param data: Dataset of shape ``(n_samples, n_features)``.
    :return: Quantization error.
    """
    return float(self.quantization(data).mean())

distance_matrix

distance_matrix(normalize=False)

Return the U-matrix: the summed distance from each model to its immediate neighbours.

Parameters:

Name Type Description Default
normalize bool

Whether to rescale the result to [0, 1].

False

Returns:

Type Description
NDArray[floating]

U-matrix, with the shape of the network.

Source code in src/python_som/_som.py
347
348
349
350
351
352
353
354
355
356
357
358
359
def distance_matrix(self, normalize: bool = False) -> npt.NDArray[np.floating]:
    """Return the U-matrix: the summed distance from each model to its immediate neighbours.

    :param normalize: Whether to rescale the result to ``[0, 1]``.
    :return: U-matrix, with the shape of the network.
    """
    return u_matrix(
        self._weights,
        self._shape,
        self._cyclic,
        self._distance_function,
        normalize=normalize,
    )

activation_matrix

activation_matrix(data)

Return how many samples map to each node.

Parameters:

Name Type Description Default
data DataLike

Dataset of shape (n_samples, n_features).

required

Returns:

Type Description
NDArray[floating]

Counts, with the shape of the network.

Source code in src/python_som/_som.py
361
362
363
364
365
366
367
368
369
def activation_matrix(self, data: DataLike) -> npt.NDArray[np.floating]:
    """Return how many samples map to each node.

    :param data: Dataset of shape ``(n_samples, n_features)``.
    :return: Counts, with the shape of the network.
    """
    return activation_matrix(
        to_numpy(data), self._weights, self._shape, self._distance_function
    )

winner_map

winner_map(data)

Return, for each node, the samples that map to it.

Parameters:

Name Type Description Default
data DataLike

Dataset of shape (n_samples, n_features).

required

Returns:

Type Description
dict[tuple[int, int], list[NDArray[Any]]]

Mapping from node coordinates to the samples assigned to that node.

Source code in src/python_som/_som.py
371
372
373
374
375
376
377
def winner_map(self, data: DataLike) -> dict[tuple[int, int], list[npt.NDArray[Any]]]:
    """Return, for each node, the samples that map to it.

    :param data: Dataset of shape ``(n_samples, n_features)``.
    :return: Mapping from node coordinates to the samples assigned to that node.
    """
    return winner_map(to_numpy(data), self._weights, self._shape, self._distance_function)

label_map

label_map(data, labels)

Return, for each node, the frequency of each label mapped to it.

Parameters:

Name Type Description Default
data DataLike

Dataset of shape (n_samples, n_features).

required
labels DataLike

One label per sample, in the same order as data.

required

Returns:

Type Description
dict[tuple[int, int], Counter[Any]]

Mapping from node coordinates to a label counter.

Raises:

Type Description
ValueError

If data and labels have different lengths.

Source code in src/python_som/_som.py
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
def label_map(self, data: DataLike, labels: DataLike) -> dict[tuple[int, int], Counter[Any]]:
    """Return, for each node, the frequency of each label mapped to it.

    :param data: Dataset of shape ``(n_samples, n_features)``.
    :param labels: One label per sample, in the same order as ``data``.
    :return: Mapping from node coordinates to a label counter.
    :raises ValueError: If ``data`` and ``labels`` have different lengths.
    """
    return label_map(
        to_numpy(data),
        to_numpy(labels),
        self._weights,
        self._shape,
        self._distance_function,
    )

train

train(
    data,
    n_iteration=None,
    mode=TrainingMode.RANDOM,
    verbose=False,
)

Train the map and return the resulting quantization error.

Parameters:

Name Type Description Default
data DataLike

Training dataset of shape (n_samples, n_features).

required
n_iteration int | None

Number of iterations. Defaults to 1000 per sample for the stepwise modes and 10 per sample for batch.

None
mode TrainingMode | TrainingModeStr

One of 'random', 'sequential' or 'batch'. Kohonen (2013) Section 3.1 recommends batch: "its convergence is an order of magnitude faster and safer", and it has no learning-rate parameter.

RANDOM
verbose bool

Whether to report progress. Emits a tqdm progress bar when tqdm is installed, and logs at INFO level on the python_som logger either way.

False

Returns:

Type Description
float

Quantization error after training.

Raises:

Type Description
ValueError

If mode is unknown, if the dataset is empty, or if batch training is combined with a signed neighborhood function.

Source code in src/python_som/_som.py
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
def train(
    self,
    data: DataLike,
    n_iteration: int | None = None,
    mode: TrainingMode | TrainingModeStr = TrainingMode.RANDOM,
    verbose: bool = False,
) -> float:
    """Train the map and return the resulting quantization error.

    :param data: Training dataset of shape ``(n_samples, n_features)``.
    :param n_iteration: Number of iterations. Defaults to 1000 per sample for the stepwise
        modes and 10 per sample for batch.
    :param mode: One of ``'random'``, ``'sequential'`` or ``'batch'``. Kohonen (2013)
        Section 3.1 recommends batch: "its convergence is an order of magnitude faster and
        safer", and it has no learning-rate parameter.
    :param verbose: Whether to report progress. Emits a tqdm progress bar when tqdm is
        installed, and logs at INFO level on the ``python_som`` logger either way.
    :return: Quantization error after training.
    :raises ValueError: If ``mode`` is unknown, if the dataset is empty, or if batch training
        is combined with a signed neighborhood function.
    """
    array = to_numpy(data)
    if len(array) == 0:
        msg = "Cannot train on an empty dataset"
        raise ValueError(msg)
    if mode not in TRAINING_MODES:
        msg = (
            f"Invalid value for 'mode' parameter: {str(mode)!r}. "
            f"Value should be one of {list(TRAINING_MODES)}"
        )
        raise ValueError(msg)
    if mode == "batch" and self._neighborhood_function_name in SIGNED_NEIGHBORHOODS:
        msg = (
            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'."
        )
        raise ValueError(msg)
    # A neighborhood that is unsigned but not separable would pass the check above and then be
    # refused by `resolve_axis_profile` in `_train_batch`, which names the constraint. No
    # separate guard here: every unsigned neighborhood is separable today, so it would be an
    # untested branch for a case that cannot yet arise.

    if n_iteration is None:
        n_iteration = DEFAULT_ITERATIONS_PER_SAMPLE[mode] * len(array)
    if n_iteration <= 0:
        msg = f"'n_iteration' must be positive, got {n_iteration}"
        raise ValueError(msg)

    logger.info("Training with %d iterations in %r mode", n_iteration, mode)

    started = time.perf_counter()
    if mode == "batch":
        final_alpha, final_sigma = self._train_batch(array, n_iteration, verbose=verbose)
    else:
        final_alpha, final_sigma = self._train_stepwise(
            array, n_iteration, mode=mode, verbose=verbose
        )
    elapsed = time.perf_counter() - started

    error = self.quantization_error(array)
    logger.info("Quantization error: %g", error)

    self._last_report = TrainingReport(
        mode=str(mode),
        n_iteration=n_iteration,
        n_samples=len(array),
        random_seed=self._random_seed,
        final_learning_rate=final_alpha,
        final_neighborhood_radius=final_sigma,
        quantization_error=float(error),
        python_som_version=__version__,
        numpy_version=np.__version__,
        wall_time_seconds=elapsed,
    )
    return error

fit

fit(
    X,
    y=None,
    *,
    n_iteration=None,
    mode=TrainingMode.RANDOM,
    verbose=False,
)

Train the map and return it, so calls can be chained.

y is accepted and ignored, which is what lets Pipeline call every step the same way.

Training options are keyword arguments here rather than constructor arguments; :mod:python_som.sklearn takes them at construction, because get_params must expose them to GridSearchCV.

Parameters:

Name Type Description Default
X DataLike

Training dataset of shape (n_samples, n_features).

required
y object

Ignored.

None
n_iteration int | None

Number of iterations. Defaults as for :meth:train.

None
mode TrainingMode | TrainingModeStr

Training mode.

RANDOM
verbose bool

Whether to show a progress bar.

False

Returns:

Type Description
SOM

This map, trained.

Source code in src/python_som/_som.py
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
def fit(
    self,
    X: DataLike,  # noqa: N803  the estimator convention capitalises the design matrix
    y: object = None,  # noqa: ARG002  accepted and ignored, as the convention requires
    *,
    n_iteration: int | None = None,
    mode: TrainingMode | TrainingModeStr = TrainingMode.RANDOM,
    verbose: bool = False,
) -> SOM:
    """Train the map and return it, so calls can be chained.

    ``y`` is accepted and ignored, which is what lets ``Pipeline`` call every step the same way.

    Training options are keyword arguments here rather than constructor arguments;
    :mod:`python_som.sklearn` takes them at construction, because ``get_params`` must expose
    them to ``GridSearchCV``.

    :param X: Training dataset of shape ``(n_samples, n_features)``.
    :param y: Ignored.
    :param n_iteration: Number of iterations. Defaults as for :meth:`train`.
    :param mode: Training mode.
    :param verbose: Whether to show a progress bar.
    :return: This map, trained.
    """
    self.train(X, n_iteration=n_iteration, mode=mode, verbose=verbose)
    return self

transform

transform(X)

Return the distance from each sample to every model.

The counterpart of KMeans.transform, which "transforms X to a cluster-distance space". Here the space has one dimension per node, so the result is (n_samples, x * y) with the grid flattened in C order.

Parameters:

Name Type Description Default
X DataLike

Dataset of shape (n_samples, n_features).

required

Returns:

Type Description
NDArray[floating]

Distances of shape (n_samples, x * y).

Source code in src/python_som/_som.py
526
527
528
529
530
531
532
533
534
535
536
537
def transform(self, X: DataLike) -> npt.NDArray[np.floating]:  # noqa: N803
    """Return the distance from each sample to every model.

    The counterpart of ``KMeans.transform``, which "transforms X to a cluster-distance space".
    Here the space has one dimension per node, so the result is ``(n_samples, x * y)`` with the
    grid flattened in C order.

    :param X: Dataset of shape ``(n_samples, n_features)``.
    :return: Distances of shape ``(n_samples, x * y)``.
    """
    array = to_numpy(X)
    return np.array([self.activate(sample).ravel() for sample in array])

fit_transform

fit_transform(X, y=None, **kwargs)

Train on X and return its distances to every model.

Parameters:

Name Type Description Default
X DataLike

Training dataset.

required
y object

Ignored.

None
kwargs Any

Passed to :meth:fit.

{}

Returns:

Type Description
NDArray[floating]

Distances of shape (n_samples, x * y).

Source code in src/python_som/_som.py
539
540
541
542
543
544
545
546
547
548
549
550
551
552
def fit_transform(
    self,
    X: DataLike,  # noqa: N803
    y: object = None,  # noqa: ARG002
    **kwargs: Any,  # noqa: ANN401
) -> npt.NDArray[np.floating]:
    """Train on ``X`` and return its distances to every model.

    :param X: Training dataset.
    :param y: Ignored.
    :param kwargs: Passed to :meth:`fit`.
    :return: Distances of shape ``(n_samples, x * y)``.
    """
    return self.fit(X, **kwargs).transform(X)

predict

predict(X)

Return the index of the best-matching node for each sample.

A flat index, not a (row, column) pair, because that is what scorers and confusion_matrix assume. Recover the grid position with np.unravel_index(som.predict(X), som.get_shape()); :meth:winner still returns coordinates for a single sample.

Parameters:

Name Type Description Default
X DataLike

Dataset of shape (n_samples, n_features).

required

Returns:

Type Description
NDArray[integer]

One flat node index per sample.

Source code in src/python_som/_som.py
554
555
556
557
558
559
560
561
562
563
564
565
def predict(self, X: DataLike) -> npt.NDArray[np.integer]:  # noqa: N803
    """Return the index of the best-matching node for each sample.

    A **flat** index, not a ``(row, column)`` pair, because that is what scorers and
    ``confusion_matrix`` assume. Recover the grid position with
    ``np.unravel_index(som.predict(X), som.get_shape())``; :meth:`winner` still returns
    coordinates for a single sample.

    :param X: Dataset of shape ``(n_samples, n_features)``.
    :return: One flat node index per sample.
    """
    return bmu_indices(to_numpy(X), self._weights, self._distance_function, bmu_kernel())

score

score(X, y=None)

Return the negated quantization error, so that larger is better.

The sign is the convention every scikit-learn scorer follows: GridSearchCV maximises, and quantization error is something to minimise. Without the negation a parameter search would confidently select the worst map.

Parameters:

Name Type Description Default
X DataLike

Dataset to score.

required
y object

Ignored.

None

Returns:

Type Description
float

Negated mean quantization error.

Source code in src/python_som/_som.py
567
568
569
570
571
572
573
574
575
576
577
578
def score(self, X: DataLike, y: object = None) -> float:  # noqa: ARG002, N803
    """Return the negated quantization error, so that larger is better.

    The sign is the convention every scikit-learn scorer follows: ``GridSearchCV`` maximises,
    and quantization error is something to minimise. Without the negation a parameter search
    would confidently select the worst map.

    :param X: Dataset to score.
    :param y: Ignored.
    :return: Negated mean quantization error.
    """
    return -self.quantization_error(X)

get_params

get_params(*, deep=True)

Return the constructor arguments that describe this map.

Strategies come back as the callables or names that were passed in, so that SOM(**som.get_params()) rebuilds an equivalent map. That is what clone relies on.

Parameters:

Name Type Description Default
deep bool

Accepted for signature compatibility; this estimator holds no sub-estimators.

True

Returns:

Type Description
dict[str, Any]

Constructor arguments by name.

Source code in src/python_som/_som.py
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
def get_params(self, *, deep: bool = True) -> dict[str, Any]:  # noqa: ARG002
    """Return the constructor arguments that describe this map.

    Strategies come back as the callables or names that were passed in, so that
    ``SOM(**som.get_params())`` rebuilds an equivalent map. That is what ``clone`` relies on.

    :param deep: Accepted for signature compatibility; this estimator holds no sub-estimators.
    :return: Constructor arguments by name.
    """
    return {
        "x": self._shape[0],
        "y": self._shape[1],
        "input_len": self._input_len,
        "learning_rate": self._learning_rate,
        "learning_rate_decay": self._learning_rate_decay,
        "neighborhood_radius": self._neighborhood_radius,
        "neighborhood_radius_decay": self._neighborhood_radius_decay,
        "neighborhood_function": self._neighborhood_function_name,
        "distance_function": self._distance_function,
        "cyclic_x": self._cyclic[0],
        "cyclic_y": self._cyclic[1],
        "random_seed": self._random_seed,
        "min_neighborhood_radius": self._min_neighborhood_radius,
    }

set_params

set_params(**params)

Set constructor-level parameters in place and return this map.

Only what can change without rebuilding the models: the rates, radii and decays. The grid shape and input_len raise, rather than leaving a map whose models do not match its own description. Replaces :meth:set_learning_rate and :meth:set_neighborhood_radius, which still work and are removed in 1.0.0.

Parameters:

Name Type Description Default
params Any

Parameters to set.

{}

Returns:

Type Description
SOM

This map.

Raises:

Type Description
ValueError

If a parameter is unknown or cannot be changed after construction.

Source code in src/python_som/_som.py
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
def set_params(self, **params: Any) -> SOM:  # noqa: ANN401
    """Set constructor-level parameters in place and return this map.

    Only what can change without rebuilding the models: the rates, radii and decays. The grid
    shape and ``input_len`` raise, rather than leaving a map whose models do not match its own
    description. Replaces :meth:`set_learning_rate` and :meth:`set_neighborhood_radius`, which
    still work and are removed in 1.0.0.

    :param params: Parameters to set.
    :return: This map.
    :raises ValueError: If a parameter is unknown or cannot be changed after construction.
    """
    settable = {
        "learning_rate": "_learning_rate",
        "learning_rate_decay": "_learning_rate_decay",
        "neighborhood_radius": "_neighborhood_radius",
        "neighborhood_radius_decay": "_neighborhood_radius_decay",
        "min_neighborhood_radius": "_min_neighborhood_radius",
        "distance_function": "_distance_function",
    }
    for name, value in params.items():
        if name in settable:
            setattr(self, settable[name], value)
        elif name in self.get_params():
            msg = (
                f"{name!r} cannot be changed after construction: the models would no longer "
                f"match it. Build a new SOM instead. Settable: {sorted(settable)}"
            )
            raise ValueError(msg)
        else:
            msg = f"Unknown parameter {name!r}. Valid parameters: {sorted(self.get_params())}"
            raise ValueError(msg)
    _validate_learning_rate(self._learning_rate)
    return self

config

config()

Describe this map in a form that can be written to a file.

Strategies appear by name. A callable that is not one of the package's registered functions is recorded as its __name__, which preserves the provenance but cannot be resolved back into a callable; :meth:load_npz says so explicitly rather than guessing.

Returns:

Type Description
SOMConfig

The configuration.

Source code in src/python_som/_som.py
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
def config(self) -> SOMConfig:
    """Describe this map in a form that can be written to a file.

    Strategies appear by name. A callable that is not one of the package's registered functions
    is recorded as its ``__name__``, which preserves the provenance but cannot be resolved back
    into a callable; :meth:`load_npz` says so explicitly rather than guessing.

    :return: The configuration.
    """
    return SOMConfig(
        shape=self._shape,
        input_len=self._input_len,
        learning_rate=self._learning_rate,
        neighborhood_radius=self._neighborhood_radius,
        min_neighborhood_radius=self._min_neighborhood_radius,
        cyclic=self._cyclic,
        neighborhood_function=str(self._neighborhood_function_name),
        learning_rate_decay=_name_of(self._learning_rate_decay),
        neighborhood_radius_decay=_name_of(self._neighborhood_radius_decay),
        distance_function=_name_of(self._distance_function),
    )

save_npz

save_npz(path)

Write the models and their provenance to a single .npz file.

Weights as an array, everything else as JSON beside them: configuration, seed, generator state and the last training report. No pickle on either side.

The generator state is saved as well as the seed, which is what lets :meth:load_npz resume the same stream rather than restarting it.

Parameters:

Name Type Description Default
path str | PathLike[str]

Destination file.

required
Source code in src/python_som/_som.py
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
def save_npz(self, path: str | os.PathLike[str]) -> None:
    """Write the models and their provenance to a single ``.npz`` file.

    Weights as an array, everything else as JSON beside them: configuration, seed, generator
    state and the last training report. No pickle on either side.

    The generator *state* is saved as well as the seed, which is what lets :meth:`load_npz`
    resume the same stream rather than restarting it.

    :param path: Destination file.
    """
    np.savez(
        path,
        weights=self._weights,
        metadata=np.array(
            metadata_json(
                self.config(),
                self._random_seed,
                self._rng.bit_generator.state,
                self._last_report,
                __version__,
            )
        ),
    )
    logger.info("Saved a %s map to %s", "x".join(map(str, self._shape)), path)

load_npz classmethod

load_npz(
    path,
    *,
    neighborhood_function=None,
    learning_rate_decay=None,
    neighborhood_radius_decay=None,
    distance_function=None,
)

Rebuild a map saved by :meth:save_npz, models, generator state and all.

Continuing to train a loaded map gives the same weights as never having stopped, which is what the saved generator state is for.

The four keyword arguments are for maps trained with a function this package cannot resolve by name. Passing one the file did not need is harmless.

Parameters:

Name Type Description Default
path str | PathLike[str]

File to read.

required
neighborhood_function NeighborhoodFunction | None

Replacement for a neighborhood that cannot be resolved.

None
learning_rate_decay DecayFunction | None

Replacement for a learning-rate decay that cannot be resolved.

None
neighborhood_radius_decay DecayFunction | None

Replacement for a radius decay that cannot be resolved.

None
distance_function DistanceFunction | None

Replacement for a distance that cannot be resolved.

None

Returns:

Type Description
SOM

The reconstructed map.

Raises:

Type Description
ArtifactError

If the file is not a readable artifact, or names a function that cannot be resolved and was not supplied.

Source code in src/python_som/_som.py
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
@classmethod
def load_npz(
    cls,
    path: str | os.PathLike[str],
    *,
    neighborhood_function: NeighborhoodFunction | None = None,
    learning_rate_decay: DecayFunction | None = None,
    neighborhood_radius_decay: DecayFunction | None = None,
    distance_function: DistanceFunction | None = None,
) -> SOM:
    """Rebuild a map saved by :meth:`save_npz`, models, generator state and all.

    Continuing to train a loaded map gives the same weights as never having stopped, which is
    what the saved generator state is for.

    The four keyword arguments are for maps trained with a function this package cannot resolve
    by name. Passing one the file did not need is harmless.

    :param path: File to read.
    :param neighborhood_function: Replacement for a neighborhood that cannot be resolved.
    :param learning_rate_decay: Replacement for a learning-rate decay that cannot be resolved.
    :param neighborhood_radius_decay: Replacement for a radius decay that cannot be resolved.
    :param distance_function: Replacement for a distance that cannot be resolved.
    :return: The reconstructed map.
    :raises ArtifactError: If the file is not a readable artifact, or names a function that
        cannot be resolved and was not supplied.
    """
    weights, metadata = load_arrays(path)
    config = config_from(metadata, path)
    strategies = resolve_strategies(
        config,
        {
            "neighborhood_function": neighborhood_function,
            "learning_rate_decay": learning_rate_decay,
            "neighborhood_radius_decay": neighborhood_radius_decay,
            "distance_function": distance_function,
        },
    )

    if weights.shape != (*config.shape, config.input_len):
        msg = (
            f"{path} holds weights of shape {weights.shape}, which does not match the saved "
            f"configuration {(*config.shape, config.input_len)}"
        )
        raise ArtifactError(msg)

    rng = metadata.get("rng") or {}
    som = cls(
        x=config.shape[0],
        y=config.shape[1],
        input_len=config.input_len,
        learning_rate=config.learning_rate,
        neighborhood_radius=config.neighborhood_radius,
        neighborhood_function=strategies.neighborhood_function,
        cyclic_x=config.cyclic[0],
        cyclic_y=config.cyclic[1],
        random_seed=rng.get("seed"),
        min_neighborhood_radius=config.min_neighborhood_radius,
        learning_rate_decay=strategies.learning_rate_decay,
        neighborhood_radius_decay=strategies.neighborhood_radius_decay,
        distance_function=strategies.distance_function,
    )
    som._weights = weights
    if rng.get("state") is not None:
        # NumPy validates the state and raises ValueError on a malformed one ("state must be for
        # a PCG64 RNG"). Translated, so that every way a bad file can fail reaches the caller as
        # ArtifactError rather than as a message about bit generators from two libraries down.
        try:
            som._rng.bit_generator.state = rng["state"]
        except (ValueError, TypeError, KeyError) as exc:
            msg = f"{path} has an unusable random generator state: {exc}"
            raise ArtifactError(msg) from exc
    som._last_report = report_from(metadata)
    _warn_on_major_version_change(metadata.get("python_som_version"), path)
    return som

weight_initialization

weight_initialization(mode=WeightInit.RANDOM, **kwargs)

Initialize the models of the network.

Parameters:

Name Type Description Default
mode WeightInit | WeightInitStr

One of 'random', 'linear' or 'sample'.

RANDOM
kwargs Any

Passed through to the chosen initializer. 'random' accepts sample_mode ('standard_normal' or 'uniform'); 'linear' and 'sample' require data.

{}

Raises:

Type Description
ValueError

If mode is unknown, or if the arguments do not suit it.

Source code in src/python_som/_som.py
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
def weight_initialization(
    self,
    mode: WeightInit | WeightInitStr = WeightInit.RANDOM,
    **kwargs: Any,  # noqa: ANN401
) -> None:
    """Initialize the models of the network.

    :param mode: One of ``'random'``, ``'linear'`` or ``'sample'``.
    :param kwargs: Passed through to the chosen initializer. ``'random'`` accepts
        ``sample_mode`` (``'standard_normal'`` or ``'uniform'``); ``'linear'`` and ``'sample'``
        require ``data``.
    :raises ValueError: If ``mode`` is unknown, or if the arguments do not suit it.
    """
    if mode not in INITIALIZATION_MODES:
        msg = (
            f"Invalid value for 'mode' parameter: {str(mode)!r}. "
            f"Value should be one of {list(INITIALIZATION_MODES)}"
        )
        raise ValueError(msg)
    try:
        if mode == "random":
            self._weights = random_models(
                self._shape,
                self._input_len,
                self._rng,
                sample_mode=kwargs.pop("sample_mode", SampleMode.STANDARD_NORMAL),
            )
        elif mode == "linear":
            self._weights = linear_models(to_numpy(kwargs.pop("data")), self._shape)
        else:
            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"{str(mode)!r} initialization requires a {exc} argument"
        raise ValueError(msg) from exc
    if kwargs:
        msg = (
            f"Unexpected argument(s) for {str(mode)!r} initialization: {sorted(kwargs)}. "
            f"Valid modes are {list(INITIALIZATION_MODES)}."
        )
        raise ValueError(msg)

Neighborhood functions

python_som._core._neighborhood

Neighborhood functions: how the winner's correction spreads over the grid.

Kohonen (2013) Eq. (5) defines a neighborhood as a function of sqdist(c, i), the squared grid distance between two nodes, so it must depend on that distance and not on the two axis offsets separately. See :doc:/explanation/why-isotropy-matters for why an outer product of two 1-D profiles is wrong for anything but the gaussian.

References: Teuvo Kohonen, Essentials of the self-organizing map, Neural Networks 37 (2013) 52-65, https://doi.org/10.1016/j.neunet.2012.09.018

O. J. Vrieze, Kohonen network, in: Artificial Neural Networks, Lecture Notes in Computer Science, vol. 931, Springer, 1995, pp. 83-100, https://doi.org/10.1007/BFb0027024

gaussian

gaussian(shape, c, sigma, cyclic)

Gaussian neighborhood, exp(-sqdist(c, i) / (2 * sigma**2)).

Eq. (5) of Kohonen (2013) with the learning rate factored out, so h(c, c) == 1. Strictly positive and monotonically decreasing with distance.

Parameters:

Name Type Description Default
shape Grid

Shape of the network.

required
c Coordinates

Coordinates of the winner.

required
sigma float

Neighborhood radius. Must be finite and positive.

required
cyclic tuple[bool, bool]

Whether each axis wraps around.

required

Returns:

Type Description
NDArray[floating]

Neighborhood weights, with the shape of the network.

Raises:

Type Description
ValueError

If the radius is not a finite positive number.

Source code in src/python_som/_core/_neighborhood.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def gaussian(
    shape: Grid, c: Coordinates, sigma: float, cyclic: tuple[bool, bool]
) -> npt.NDArray[np.floating]:
    """Gaussian neighborhood, ``exp(-sqdist(c, i) / (2 * sigma**2))``.

    Eq. (5) of Kohonen (2013) with the learning rate factored out, so ``h(c, c) == 1``. Strictly
    positive and monotonically decreasing with distance.

    :param shape: Shape of the network.
    :param c: Coordinates of the winner.
    :param sigma: Neighborhood radius. Must be finite and positive.
    :param cyclic: Whether each axis wraps around.
    :return: Neighborhood weights, with the shape of the network.
    :raises ValueError: If the radius is not a finite positive number.
    """
    return _gaussian_profile(
        axis_offsets(shape[0], c[0], cyclic=cyclic[0]),
        axis_offsets(shape[1], c[1], cyclic=cyclic[1]),
        sigma,
    )

bubble

bubble(shape, c, sigma, cyclic)

Flat neighborhood: 1 for nodes within sigma of the winner, 0 elsewhere.

The truncated inner lobe of the mexican hat, which Vrieze (1995) p. 85 calls "just as effective and sometimes even better" than a distance-dependent one.

The metric is Chebyshev, not Euclidean, so the region is a square: a node is included when max(|dx|, |dy|) <= round(sigma). This follows Vrieze's appendix, where Kohonen's "up to a certain radius" (Section 4.1) reads as Euclidean; the two sources differ, and :doc:/explanation/why-isotropy-matters covers the consequence, that a Chebyshev ball is not isotropic under the Euclidean metric.

A radius of zero is admissible here and not for the other two: it selects the winner alone, where they would divide by zero.

Parameters:

Name Type Description Default
shape Grid

Shape of the network.

required
c Coordinates

Coordinates of the winner.

required
sigma float

Neighborhood radius, rounded to the nearest integer. Must be finite and non-negative.

required
cyclic tuple[bool, bool]

Whether each axis wraps around.

required

Returns:

Type Description
NDArray[floating]

Neighborhood weights, with the shape of the network.

Raises:

Type Description
ValueError

If the radius is not a finite non-negative number.

Source code in src/python_som/_core/_neighborhood.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def bubble(
    shape: Grid, c: Coordinates, sigma: float, cyclic: tuple[bool, bool]
) -> npt.NDArray[np.floating]:
    """Flat neighborhood: 1 for nodes within ``sigma`` of the winner, 0 elsewhere.

    The truncated inner lobe of the mexican hat, which Vrieze (1995) p. 85 calls "just as effective
    and sometimes even better" than a distance-dependent one.

    **The metric is Chebyshev, not Euclidean**, so the region is a square: a node is included when
    ``max(|dx|, |dy|) <= round(sigma)``. This follows Vrieze's appendix, where Kohonen's "up to a
    certain radius" (Section 4.1) reads as Euclidean; the two sources differ, and
    :doc:`/explanation/why-isotropy-matters` covers the consequence, that a Chebyshev ball is not
    isotropic under the Euclidean metric.

    A radius of zero is admissible here and not for the other two: it selects the winner alone,
    where they would divide by zero.

    :param shape: Shape of the network.
    :param c: Coordinates of the winner.
    :param sigma: Neighborhood radius, rounded to the nearest integer. Must be finite and
        non-negative.
    :param cyclic: Whether each axis wraps around.
    :return: Neighborhood weights, with the shape of the network.
    :raises ValueError: If the radius is not a finite non-negative number.
    """
    return _bubble_profile(
        axis_offsets(shape[0], c[0], cyclic=cyclic[0]),
        axis_offsets(shape[1], c[1], cyclic=cyclic[1]),
        sigma,
    )

mexican_hat

mexican_hat(shape, c, sigma, cyclic)

Mexican hat neighborhood, (1 - u) * exp(-u) over u = sqdist(c, i) / (2 * sigma**2).

The Ricker wavelet, or Laplacian of Gaussian: excitatory near the winner, inhibitory beyond it, vanishing with distance (Vrieze 1995, Fig. 3). Normalized so h(c, c) == 1, zero at sqrt(2) * sigma, minimum -exp(-2) at 2 * sigma.

Not an outer product of two 1-D Ricker wavelets, which is a different and wrong function; see :doc:/explanation/why-isotropy-matters. Signed, so batch training rejects it.

Parameters:

Name Type Description Default
shape Grid

Shape of the network.

required
c Coordinates

Coordinates of the winner.

required
sigma float

Neighborhood radius. Must be finite and positive.

required
cyclic tuple[bool, bool]

Whether each axis wraps around.

required

Returns:

Type Description
NDArray[floating]

Neighborhood weights, with the shape of the network.

Raises:

Type Description
ValueError

If the radius is not a finite positive number.

Source code in src/python_som/_core/_neighborhood.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
def mexican_hat(
    shape: Grid, c: Coordinates, sigma: float, cyclic: tuple[bool, bool]
) -> npt.NDArray[np.floating]:
    """Mexican hat neighborhood, ``(1 - u) * exp(-u)`` over ``u = sqdist(c, i) / (2 * sigma**2)``.

    The Ricker wavelet, or Laplacian of Gaussian: excitatory near the winner, inhibitory beyond it,
    vanishing with distance (Vrieze 1995, Fig. 3). Normalized so ``h(c, c) == 1``, zero at
    ``sqrt(2) * sigma``, minimum ``-exp(-2)`` at ``2 * sigma``.

    Not an outer product of two 1-D Ricker wavelets, which is a different and wrong function; see
    :doc:`/explanation/why-isotropy-matters`. Signed, so batch training rejects it.

    :param shape: Shape of the network.
    :param c: Coordinates of the winner.
    :param sigma: Neighborhood radius. Must be finite and positive.
    :param cyclic: Whether each axis wraps around.
    :return: Neighborhood weights, with the shape of the network.
    :raises ValueError: If the radius is not a finite positive number.
    """
    return _mexican_hat_profile(
        axis_offsets(shape[0], c[0], cyclic=cyclic[0]),
        axis_offsets(shape[1], c[1], cyclic=cyclic[1]),
        sigma,
    )

axis_offsets

axis_offsets(length, center, *, cyclic)

Signed offsets from center to every coordinate along one axis.

On a cyclic axis the minimum-image convention folds each offset into [-length/2, length/2). Both tails must be folded: an offset of -9 on an axis of length 10 is a distance of 1, not 9.

Parameters:

Name Type Description Default
length int

Number of nodes along the axis.

required
center int

Coordinate of the winner along the axis.

required
cyclic bool

Whether the axis wraps around.

required

Returns:

Type Description
NDArray[floating]

Signed offsets, one per coordinate.

Source code in src/python_som/_core/_neighborhood.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def axis_offsets(length: int, center: int, *, cyclic: bool) -> npt.NDArray[np.floating]:
    """Signed offsets from ``center`` to every coordinate along one axis.

    On a cyclic axis the minimum-image convention folds each offset into ``[-length/2, length/2)``.
    Both tails must be folded: an offset of -9 on an axis of length 10 is a distance of 1, not 9.

    :param length: Number of nodes along the axis.
    :param center: Coordinate of the winner along the axis.
    :param cyclic: Whether the axis wraps around.
    :return: Signed offsets, one per coordinate.
    """
    d = np.arange(length, dtype=float) - center
    if cyclic:
        d = (d + length / 2) % length - length / 2
    return d

axis_matrix

axis_matrix(length, sigma, *, cyclic, profile)

Build H[a, c] = profile(a - c) for every pair of coordinates on one axis.

Contracting sums against one of these per axis evaluates Eq. (8) for every node at once. The cyclic fold is the minimum-image convention of :func:axis_offsets, on pairwise offsets.

Parameters:

Name Type Description Default
length int

Number of nodes along the axis.

required
sigma float

Neighborhood radius.

required
cyclic bool

Whether the axis wraps around.

required
profile AxisProfile

Per-axis factor to evaluate, from :data:AXIS_PROFILES.

required

Returns:

Type Description
NDArray[floating]

Weights of shape (length, length).

Source code in src/python_som/_core/_neighborhood.py
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
def axis_matrix(
    length: int, sigma: float, *, cyclic: bool, profile: AxisProfile
) -> npt.NDArray[np.floating]:
    """Build ``H[a, c] = profile(a - c)`` for every pair of coordinates on one axis.

    Contracting ``sums`` against one of these per axis evaluates Eq. (8) for every node at once. The
    cyclic fold is the minimum-image convention of :func:`axis_offsets`, on pairwise offsets.

    :param length: Number of nodes along the axis.
    :param sigma: Neighborhood radius.
    :param cyclic: Whether the axis wraps around.
    :param profile: Per-axis factor to evaluate, from :data:`AXIS_PROFILES`.
    :return: Weights of shape ``(length, length)``.
    """
    d = np.subtract.outer(np.arange(length), np.arange(length)).astype(float)
    if cyclic:
        d = (d + length / 2) % length - length / 2
    return profile(d, sigma)

gaussian_axis_profile

gaussian_axis_profile(d, sigma)

Per-axis factor of the gaussian, exp(-d^2 / (2 sigma^2)).

Its product over the two axes is :func:gaussian, because the exponential factors.

Parameters:

Name Type Description Default
d NDArray[floating]

Offsets along one axis.

required
sigma float

Neighborhood radius. Must be finite and positive.

required

Returns:

Type Description
NDArray[floating]

Weights for those offsets.

Raises:

Type Description
ValueError

If the radius is not a finite positive number.

Source code in src/python_som/_core/_neighborhood.py
245
246
247
248
249
250
251
252
253
254
255
256
def gaussian_axis_profile(d: npt.NDArray[np.floating], sigma: float) -> npt.NDArray[np.floating]:
    """Per-axis factor of the gaussian, ``exp(-d^2 / (2 sigma^2))``.

    Its product over the two axes is :func:`gaussian`, because the exponential factors.

    :param d: Offsets along one axis.
    :param sigma: Neighborhood radius. Must be finite and positive.
    :return: Weights for those offsets.
    :raises ValueError: If the radius is not a finite positive number.
    """
    _validate_radius(sigma)
    return np.exp(-np.square(d) / (2.0 * sigma * sigma))

bubble_axis_profile

bubble_axis_profile(d, sigma)

Per-axis factor of the bubble, the indicator |d| <= round(sigma).

Its product over the two axes is :func:bubble. It factors because the metric is Chebyshev; a Euclidean disc would not.

Parameters:

Name Type Description Default
d NDArray[floating]

Offsets along one axis.

required
sigma float

Neighborhood radius, rounded to the nearest integer. Must be finite and non-negative.

required

Returns:

Type Description
NDArray[floating]

Weights for those offsets.

Raises:

Type Description
ValueError

If the radius is not a finite non-negative number.

Source code in src/python_som/_core/_neighborhood.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
def bubble_axis_profile(d: npt.NDArray[np.floating], sigma: float) -> npt.NDArray[np.floating]:
    """Per-axis factor of the bubble, the indicator ``|d| <= round(sigma)``.

    Its product over the two axes is :func:`bubble`. It factors because the metric is Chebyshev; a
    Euclidean disc would not.

    :param d: Offsets along one axis.
    :param sigma: Neighborhood radius, rounded to the nearest integer. Must be finite and
        non-negative.
    :return: Weights for those offsets.
    :raises ValueError: If the radius is not a finite non-negative number.
    """
    _validate_radius(sigma, allow_zero=True)
    return (np.abs(d) <= int(np.around(sigma))).astype(float)

resolve_axis_profile

resolve_axis_profile(name)

Look up the per-axis factor of a neighborhood function by name.

Parameters:

Name Type Description Default
name str

Name of the neighborhood function.

required

Returns:

Type Description
AxisProfile

The corresponding axis profile.

Raises:

Type Description
ValueError

If the function has no axis profile, and so is not separable.

Source code in src/python_som/_core/_neighborhood.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
def resolve_axis_profile(name: str) -> AxisProfile:
    """Look up the per-axis factor of a neighborhood function by name.

    :param name: Name of the neighborhood function.
    :return: The corresponding axis profile.
    :raises ValueError: If the function has no axis profile, and so is not separable.
    """
    try:
        return AXIS_PROFILES[name]
    except KeyError as exc:
        valid = sorted(AXIS_PROFILES)
        msg = (
            f"The {name!r} neighborhood function is not separable, so it has no axis profile. "
            f"Value should be one of {valid}"
        )
        raise ValueError(msg) from exc

squared_grid_distance

squared_grid_distance(shape, c, cyclic)

Squared geometric distance from c to every node, i.e. sqdist(c, i) of Eq. (5).

Parameters:

Name Type Description Default
shape Grid

Shape of the network.

required
c Coordinates

Coordinates of the winner.

required
cyclic tuple[bool, bool]

Whether each axis wraps around.

required

Returns:

Type Description
NDArray[floating]

Squared distances, with the shape of the network.

Source code in src/python_som/_core/_neighborhood.py
81
82
83
84
85
86
87
88
89
90
91
92
93
def squared_grid_distance(
    shape: Grid, c: Coordinates, cyclic: tuple[bool, bool]
) -> npt.NDArray[np.floating]:
    """Squared geometric distance from ``c`` to every node, i.e. ``sqdist(c, i)`` of Eq. (5).

    :param shape: Shape of the network.
    :param c: Coordinates of the winner.
    :param cyclic: Whether each axis wraps around.
    :return: Squared distances, with the shape of the network.
    """
    dx = axis_offsets(shape[0], c[0], cyclic=cyclic[0])
    dy = axis_offsets(shape[1], c[1], cyclic=cyclic[1])
    return np.add.outer(np.square(dx), np.square(dy))

resolve

resolve(name)

Look up a neighborhood function by name.

Parameters:

Name Type Description Default
name str

Name of the neighborhood function.

required

Returns:

Type Description
NeighborhoodFunction

The corresponding function.

Raises:

Type Description
ValueError

If the name is not recognised.

Source code in src/python_som/_core/_neighborhood.py
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
def resolve(name: str) -> NeighborhoodFunction:
    """Look up a neighborhood function by name.

    :param name: Name of the neighborhood function.
    :return: The corresponding function.
    :raises ValueError: If the name is not recognised.
    """
    try:
        return NEIGHBORHOOD_FUNCTIONS[name]
    except KeyError as exc:
        valid = sorted(NEIGHBORHOOD_FUNCTIONS)
        msg = (
            f"Invalid value for 'neighborhood_function' parameter: {name!r}. "
            f"Value should be one of {valid}"
        )
        raise ValueError(msg) from exc

Decay functions

python_som._core._decay

Decay functions for the learning rate and the neighborhood radius.

Each function maps an initial value, the current iteration and the total number of iterations to the current value. They share one signature so that any of them, or a user-supplied equivalent, can be passed as learning_rate_decay or neighborhood_radius_decay.

Kohonen (2013) Section 4.1 prescribes no particular form: "The true mathematical form of sigma(t) is not crucial, as long as its value is fairly large in the beginning of the process ... whereafter it is gradually reduced to a fraction of it in about 1000 steps".

DECAY_FUNCTIONS module-attribute

DECAY_FUNCTIONS = {
    "asymptotic_decay": asymptotic_decay,
    "linear_decay": linear_decay,
    "exponential_decay": exponential_decay,
    "inverse_decay": inverse_decay,
}

Decay functions by name, so a saved map can name the one it used.

Each key is the function's own name. These are written into artifacts, so they are public API from 0.4.0 and fixed at 1.0.0. A caller may pass any callable; what a name buys is restoring it from a file, and the loader says so when it cannot.

asymptotic_decay

asymptotic_decay(x, t, max_t)

Decay x hyperbolically, reaching x / 2 at the halfway point.

Never reaches zero, which suits Kohonen's warning that the neighborhood radius should not decay all the way down (Section 4.2).

Parameters:

Name Type Description Default
x float

Initial value.

required
t int

Current iteration.

required
max_t int

Total number of iterations.

required

Returns:

Type Description
float

Value of x after t iterations.

Source code in src/python_som/_core/_decay.py
29
30
31
32
33
34
35
36
37
38
39
40
def asymptotic_decay(x: float, t: int, max_t: int) -> float:
    """Decay ``x`` hyperbolically, reaching ``x / 2`` at the halfway point.

    Never reaches zero, which suits Kohonen's warning that the neighborhood radius should not decay
    all the way down (Section 4.2).

    :param x: Initial value.
    :param t: Current iteration.
    :param max_t: Total number of iterations.
    :return: Value of ``x`` after ``t`` iterations.
    """
    return x / (1 + t / (max_t / 2))

linear_decay

linear_decay(x, t, max_t)

Decay x linearly to zero at t == max_t.

Parameters:

Name Type Description Default
x float

Initial value.

required
t int

Current iteration.

required
max_t int

Total number of iterations.

required

Returns:

Type Description
float

Value of x after t iterations.

Source code in src/python_som/_core/_decay.py
43
44
45
46
47
48
49
50
51
def linear_decay(x: float, t: int, max_t: int) -> float:
    """Decay ``x`` linearly to zero at ``t == max_t``.

    :param x: Initial value.
    :param t: Current iteration.
    :param max_t: Total number of iterations.
    :return: Value of ``x`` after ``t`` iterations.
    """
    return x * (1.0 - t / max_t)

exponential_decay

exponential_decay(x, t, max_t, factor=2.0)

Decay x geometrically by factor / max_t per iteration.

Parameters:

Name Type Description Default
x float

Initial value.

required
t int

Current iteration.

required
max_t int

Total number of iterations.

required
factor float

Decay factor. Defaults to 2.0.

2.0

Returns:

Type Description
float

Value of x after t iterations.

Source code in src/python_som/_core/_decay.py
54
55
56
57
58
59
60
61
62
63
def exponential_decay(x: float, t: int, max_t: int, factor: float = 2.0) -> float:
    """Decay ``x`` geometrically by ``factor / max_t`` per iteration.

    :param x: Initial value.
    :param t: Current iteration.
    :param max_t: Total number of iterations.
    :param factor: Decay factor. Defaults to 2.0.
    :return: Value of ``x`` after ``t`` iterations.
    """
    return x * (1 - (factor / max_t)) ** t

inverse_decay

inverse_decay(x, t, max_t)

Decay x inversely with t, scaled so the shape is independent of max_t.

Parameters:

Name Type Description Default
x float

Initial value.

required
t int

Current iteration.

required
max_t int

Total number of iterations.

required

Returns:

Type Description
float

Value of x after t iterations.

Source code in src/python_som/_core/_decay.py
66
67
68
69
70
71
72
73
74
def inverse_decay(x: float, t: int, max_t: int) -> float:
    """Decay ``x`` inversely with ``t``, scaled so the shape is independent of ``max_t``.

    :param x: Initial value.
    :param t: Current iteration.
    :param max_t: Total number of iterations.
    :return: Value of ``x`` after ``t`` iterations.
    """
    return (max_t / 100) * x / ((max_t / 100) + t)

resolve_decay

resolve_decay(name)

Look up a decay function by name.

Parameters:

Name Type Description Default
name str

Name of the decay function.

required

Returns:

Type Description
DecayFunction

The corresponding function.

Raises:

Type Description
ValueError

If the name is not recognised.

Source code in src/python_som/_core/_decay.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def resolve_decay(name: str) -> DecayFunction:
    """Look up a decay function by name.

    :param name: Name of the decay function.
    :return: The corresponding function.
    :raises ValueError: If the name is not recognised.
    """
    try:
        return DECAY_FUNCTIONS[name]
    except KeyError as exc:
        valid = sorted(DECAY_FUNCTIONS)
        msg = f"Unknown decay function {name!r}. Value should be one of {valid}"
        raise ValueError(msg) from exc

Distance functions

python_som._core._distance

Distance measures between input vectors and the models of the network.

DISTANCE_FUNCTIONS module-attribute

DISTANCE_FUNCTIONS = {
    "euclidean_distance": euclidean_distance
}

Distance functions by name, so a saved map can name the one it used.

One entry, because one is what the package ships. The registry exists for the same reason :data:DECAY_FUNCTIONS does -- a name is what makes a map reloadable -- and a dictionary with one key is the honest shape for that rather than a special case in the loader.

euclidean_distance

euclidean_distance(a, b)

Euclidean distance between the elements of the last axis of a and b.

Both arguments may be n-dimensional as long as their shapes broadcast. The result takes the shape of the broadcast minus its last axis, so passing one input vector and the whole (x, y, input_len) weight array yields an (x, y) map of distances.

Kohonen (2013) Section 3.3 notes that the Euclidean distance, applied to normalized data, "is already applicable to most practical studies".

Parameters:

Name Type Description Default
a ArrayLike

Array-like of values. Must not be a scalar.

required
b ArrayLike

Array-like of values. Must not be a scalar.

required

Returns:

Type Description
NDArray[floating]

Distances between a and b.

Source code in src/python_som/_core/_distance.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def euclidean_distance(a: npt.ArrayLike, b: npt.ArrayLike) -> npt.NDArray[np.floating]:
    """Euclidean distance between the elements of the last axis of ``a`` and ``b``.

    Both arguments may be n-dimensional as long as their shapes broadcast. The result takes the
    shape of the broadcast minus its last axis, so passing one input vector and the whole
    ``(x, y, input_len)`` weight array yields an ``(x, y)`` map of distances.

    Kohonen (2013) Section 3.3 notes that the Euclidean distance, applied to normalized data, "is
    already applicable to most practical studies".

    :param a: Array-like of values. Must not be a scalar.
    :param b: Array-like of values. Must not be a scalar.
    :return: Distances between ``a`` and ``b``.
    """
    result: npt.NDArray[np.floating] = np.linalg.norm(np.subtract(a, b), ord=2, axis=-1)
    return result

resolve_distance

resolve_distance(name)

Look up a distance function by name.

Parameters:

Name Type Description Default
name str

Name of the distance function.

required

Returns:

Type Description
DistanceFunction

The corresponding function.

Raises:

Type Description
ValueError

If the name is not recognised.

Source code in src/python_som/_core/_distance.py
45
46
47
48
49
50
51
52
53
54
55
56
57
def resolve_distance(name: str) -> DistanceFunction:
    """Look up a distance function by name.

    :param name: Name of the distance function.
    :return: The corresponding function.
    :raises ValueError: If the name is not recognised.
    """
    try:
        return DISTANCE_FUNCTIONS[name]
    except KeyError as exc:
        valid = sorted(DISTANCE_FUNCTIONS)
        msg = f"Unknown distance function {name!r}. Value should be one of {valid}"
        raise ValueError(msg) from exc

Options

python_som._enums

Names for the string-valued options, so a typo is a type error rather than a runtime one.

Every option is also accepted as a plain string, permanently. mode=TrainingMode.BATCH and mode="batch" are interchangeable, compare equal, hash equal and serialise identically, because each member is a str. 0.5.0 briefly deprecated the string form and 0.6.0 withdrew that; the changelog has the reasoning.

The type-checking benefit comes from the Literal unions below rather than from the enums: mode="bacth" is a type error while mode="batch" is not.

On the base class. enum.StrEnum needs 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() returns 'X.MEMBER' rather than the value, which would put the wrong text into any f-string or filename built from a member.

TrainingMode

Bases: _StrEnum

How :meth:~python_som.SOM.train presents the data.

RANDOM and SEQUENTIAL are the stepwise algorithm of Kohonen (2013) Eq. (3), differing only in the order samples arrive. BATCH is Eq. (8), which updates every model concurrently.

Source code in src/python_som/_enums.py
45
46
47
48
49
50
51
52
53
54
class TrainingMode(_StrEnum):
    """How :meth:`~python_som.SOM.train` presents the data.

    ``RANDOM`` and ``SEQUENTIAL`` are the stepwise algorithm of Kohonen (2013) Eq. (3), differing
    only in the order samples arrive. ``BATCH`` is Eq. (8), which updates every model concurrently.
    """

    RANDOM = "random"
    SEQUENTIAL = "sequential"
    BATCH = "batch"

Neighborhood

Bases: _StrEnum

Which neighborhood function spreads the winner's correction over the grid.

MEXICAN_HAT takes negative values and so cannot be used with :attr:TrainingMode.BATCH; see :data:~python_som.SIGNED_NEIGHBORHOODS. The legacy spelling "mexicanhat" remains accepted as a plain string and resolves to the same function, but is not given a member of its own: one canonical spelling per option is the point of having an enum.

Source code in src/python_som/_enums.py
57
58
59
60
61
62
63
64
65
66
67
68
class Neighborhood(_StrEnum):
    """Which neighborhood function spreads the winner's correction over the grid.

    ``MEXICAN_HAT`` takes negative values and so cannot be used with :attr:`TrainingMode.BATCH`; see
    :data:`~python_som.SIGNED_NEIGHBORHOODS`. The legacy spelling ``"mexicanhat"`` remains accepted
    as a plain string and resolves to the same function, but is not given a member of its own: one
    canonical spelling per option is the point of having an enum.
    """

    GAUSSIAN = "gaussian"
    BUBBLE = "bubble"
    MEXICAN_HAT = "mexican_hat"

WeightInit

Bases: _StrEnum

How :meth:~python_som.SOM.weight_initialization seeds the models.

LINEAR and SAMPLE both need a dataset; RANDOM does not.

Source code in src/python_som/_enums.py
71
72
73
74
75
76
77
78
79
class WeightInit(_StrEnum):
    """How :meth:`~python_som.SOM.weight_initialization` seeds the models.

    ``LINEAR`` and ``SAMPLE`` both need a dataset; ``RANDOM`` does not.
    """

    RANDOM = "random"
    LINEAR = "linear"
    SAMPLE = "sample"

SampleMode

Bases: _StrEnum

Which distribution :attr:WeightInit.RANDOM draws from.

Source code in src/python_som/_enums.py
82
83
84
85
86
class SampleMode(_StrEnum):
    """Which distribution :attr:`WeightInit.RANDOM` draws from."""

    STANDARD_NORMAL = "standard_normal"
    UNIFORM = "uniform"

Strategy protocols

python_som._core._protocols

Contracts for the strategies a caller can replace.

Protocols rather than bare Callable aliases, so mypy checks the shape of the call against a named contract instead of only the argument count.

Every parameter is positional-only. Without the /, a Protocol also requires the parameter names to match, so a user's def my_decay(rate, step, total) would fail against a protocol that named them differently.

Structural, so nothing inherits from them: this adds checking, not a requirement. See :doc:/how-to/use-a-custom-strategy.

NeighborhoodFunction

Bases: Protocol

Weights the winner's correction across the grid, as a function of grid distance.

Kohonen (2013) Eq. (5) requires this to depend on sqdist(c, i) alone, not on the two axis offsets separately. A separable product satisfies the signature and is only correct for the gaussian.

Source code in src/python_som/_core/_protocols.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@runtime_checkable
class NeighborhoodFunction(Protocol):
    """Weights the winner's correction across the grid, as a function of grid distance.

    Kohonen (2013) Eq. (5) requires this to depend on ``sqdist(c, i)`` alone, not on the two axis
    offsets separately. A separable product satisfies the signature and is only correct for the
    gaussian.
    """

    def __call__(
        self,
        shape: tuple[int, int],
        c: tuple[int, int],
        sigma: float,
        cyclic: tuple[bool, bool],
        /,
    ) -> npt.NDArray[np.floating]:
        """Evaluate the neighborhood centred on ``c``.

        :param shape: Shape of the network.
        :param c: Coordinates of the winner.
        :param sigma: Neighborhood radius.
        :param cyclic: Whether each axis wraps around.
        :return: Weights with the shape of the network.
        """
        ...

__call__

__call__(shape, c, sigma, cyclic)

Evaluate the neighborhood centred on c.

Parameters:

Name Type Description Default
shape tuple[int, int]

Shape of the network.

required
c tuple[int, int]

Coordinates of the winner.

required
sigma float

Neighborhood radius.

required
cyclic tuple[bool, bool]

Whether each axis wraps around.

required

Returns:

Type Description
NDArray[floating]

Weights with the shape of the network.

Source code in src/python_som/_core/_protocols.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def __call__(
    self,
    shape: tuple[int, int],
    c: tuple[int, int],
    sigma: float,
    cyclic: tuple[bool, bool],
    /,
) -> npt.NDArray[np.floating]:
    """Evaluate the neighborhood centred on ``c``.

    :param shape: Shape of the network.
    :param c: Coordinates of the winner.
    :param sigma: Neighborhood radius.
    :param cyclic: Whether each axis wraps around.
    :return: Weights with the shape of the network.
    """
    ...

DecayFunction

Bases: Protocol

Reduces a learning rate or a neighborhood radius as training proceeds.

Source code in src/python_som/_core/_protocols.py
60
61
62
63
64
65
66
67
68
69
70
71
72
@runtime_checkable
class DecayFunction(Protocol):
    """Reduces a learning rate or a neighborhood radius as training proceeds."""

    def __call__(self, value: float, step: int, total: int, /) -> float:
        """Return the decayed value for this step.

        :param value: The initial value being decayed.
        :param step: Current iteration, counted from zero.
        :param total: Total number of iterations.
        :return: The value to use at this step.
        """
        ...

__call__

__call__(value, step, total)

Return the decayed value for this step.

Parameters:

Name Type Description Default
value float

The initial value being decayed.

required
step int

Current iteration, counted from zero.

required
total int

Total number of iterations.

required

Returns:

Type Description
float

The value to use at this step.

Source code in src/python_som/_core/_protocols.py
64
65
66
67
68
69
70
71
72
def __call__(self, value: float, step: int, total: int, /) -> float:
    """Return the decayed value for this step.

    :param value: The initial value being decayed.
    :param step: Current iteration, counted from zero.
    :param total: Total number of iterations.
    :return: The value to use at this step.
    """
    ...

DistanceFunction

Bases: Protocol

Dissimilarity between an input vector and one or many models.

Called both with a single model and with the whole (x, y, n_features) array, so an implementation must broadcast over leading axes.

Source code in src/python_som/_core/_protocols.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
@runtime_checkable
class DistanceFunction(Protocol):
    """Dissimilarity between an input vector and one or many models.

    Called both with a single model and with the whole ``(x, y, n_features)`` array, so an
    implementation must broadcast over leading axes.
    """

    def __call__(self, x: Any, weights: Any, /) -> npt.NDArray[np.floating]:  # noqa: ANN401
        """Return the distance from ``x`` to each of ``weights``.

        :param x: Input vector.
        :param weights: One model, or an array of them.
        :return: Distances, with the leading shape of ``weights``.
        """
        ...

__call__

__call__(x, weights)

Return the distance from x to each of weights.

Parameters:

Name Type Description Default
x Any

Input vector.

required
weights Any

One model, or an array of them.

required

Returns:

Type Description
NDArray[floating]

Distances, with the leading shape of weights.

Source code in src/python_som/_core/_protocols.py
83
84
85
86
87
88
89
90
def __call__(self, x: Any, weights: Any, /) -> npt.NDArray[np.floating]:  # noqa: ANN401
    """Return the distance from ``x`` to each of ``weights``.

    :param x: Input vector.
    :param weights: One model, or an array of them.
    :return: Distances, with the leading shape of ``weights``.
    """
    ...

BmuKernel

Bases: Protocol

An accelerated best-matching-unit search, supplied from outside the core.

Optional. python_som._accelerate provides one with the fast extra installed; otherwise the NumPy path in :func:~python_som._core._match.bmu_indices runs. Passed as an argument rather than imported, so the core stays numpy-only.

Both arrays arrive already shifted by a common vector, which is what stops the expanded norm cancelling far from the origin. A kernel must not shift them again.

Source code in src/python_som/_core/_protocols.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
@runtime_checkable
class BmuKernel(Protocol):
    """An accelerated best-matching-unit search, supplied from outside the core.

    Optional. ``python_som._accelerate`` provides one with the ``fast`` extra installed; otherwise
    the NumPy path in :func:`~python_som._core._match.bmu_indices` runs. Passed as an argument
    rather than imported, so the core stays numpy-only.

    Both arrays arrive already shifted by a common vector, which is what stops the expanded norm
    cancelling far from the origin. A kernel must not shift them again.
    """

    def __call__(
        self,
        centred_data: npt.NDArray[np.floating],
        centred_models: npt.NDArray[np.floating],
        squared: npt.NDArray[np.floating],
        /,
    ) -> npt.NDArray[np.intp]:
        """Return the index of the nearest model for each sample.

        :param centred_data: Samples, shifted, of shape ``(n_samples, n_features)``.
        :param centred_models: Models, shifted, of shape ``(n_nodes, n_features)``.
        :param squared: Squared norm of each centred model.
        :return: One flat node index per sample, ties going to the lowest index.
        """
        ...

__call__

__call__(centred_data, centred_models, squared)

Return the index of the nearest model for each sample.

Parameters:

Name Type Description Default
centred_data NDArray[floating]

Samples, shifted, of shape (n_samples, n_features).

required
centred_models NDArray[floating]

Models, shifted, of shape (n_nodes, n_features).

required
squared NDArray[floating]

Squared norm of each centred model.

required

Returns:

Type Description
NDArray[intp]

One flat node index per sample, ties going to the lowest index.

Source code in src/python_som/_core/_protocols.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def __call__(
    self,
    centred_data: npt.NDArray[np.floating],
    centred_models: npt.NDArray[np.floating],
    squared: npt.NDArray[np.floating],
    /,
) -> npt.NDArray[np.intp]:
    """Return the index of the nearest model for each sample.

    :param centred_data: Samples, shifted, of shape ``(n_samples, n_features)``.
    :param centred_models: Models, shifted, of shape ``(n_nodes, n_features)``.
    :param squared: Squared norm of each centred model.
    :return: One flat node index per sample, ties going to the lowest index.
    """
    ...

AxisProfile

Bases: Protocol

The per-axis factor of a separable neighborhood, over offsets along one axis.

Defined only where the factorisation is an identity, which is the gaussian and the bubble. Not a general way to build a neighborhood: :class:NeighborhoodFunction remains the definition.

Source code in src/python_som/_core/_protocols.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
@runtime_checkable
class AxisProfile(Protocol):
    """The per-axis factor of a separable neighborhood, over offsets along one axis.

    Defined only where the factorisation is an identity, which is the gaussian and the bubble. Not a
    general way to build a neighborhood: :class:`NeighborhoodFunction` remains the definition.
    """

    def __call__(self, d: npt.NDArray[np.floating], sigma: float, /) -> npt.NDArray[np.floating]:
        """Evaluate the factor over offsets along one axis.

        :param d: Offsets along the axis.
        :param sigma: Neighborhood radius.
        :return: Weights for those offsets.
        """
        ...

__call__

__call__(d, sigma)

Evaluate the factor over offsets along one axis.

Parameters:

Name Type Description Default
d NDArray[floating]

Offsets along the axis.

required
sigma float

Neighborhood radius.

required

Returns:

Type Description
NDArray[floating]

Weights for those offsets.

Source code in src/python_som/_core/_protocols.py
130
131
132
133
134
135
136
137
def __call__(self, d: npt.NDArray[np.floating], sigma: float, /) -> npt.NDArray[np.floating]:
    """Evaluate the factor over offsets along one axis.

    :param d: Offsets along the axis.
    :param sigma: Neighborhood radius.
    :return: Weights for those offsets.
    """
    ...

KernelFunction

Bases: Protocol

A neighborhood evaluated over every offset at once, independent of any particular winner.

.. deprecated:: 0.7.0 Batch training now contracts an :class:AxisProfile per axis, and nothing in the package produces a kernel. Retained because it is part of the public surface; it will be removed at 1.0.0.

Source code in src/python_som/_core/_protocols.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
@runtime_checkable
class KernelFunction(Protocol):
    """A neighborhood evaluated over every offset at once, independent of any particular winner.

    .. deprecated:: 0.7.0
        Batch training now contracts an :class:`AxisProfile` per axis, and nothing in the package
        produces a kernel. Retained because it is part of the public surface; it will be removed at
        1.0.0.
    """

    def __call__(
        self, shape: tuple[int, int], sigma: float, cyclic: tuple[bool, bool], /
    ) -> npt.NDArray[np.floating]:
        """Evaluate the neighborhood over every reachable offset.

        :param shape: Shape of the network.
        :param sigma: Neighborhood radius.
        :param cyclic: Whether each axis wraps around.
        :return: Weights of shape ``(2 * shape[0] - 1, 2 * shape[1] - 1)``.
        """
        ...

__call__

__call__(shape, sigma, cyclic)

Evaluate the neighborhood over every reachable offset.

Parameters:

Name Type Description Default
shape tuple[int, int]

Shape of the network.

required
sigma float

Neighborhood radius.

required
cyclic tuple[bool, bool]

Whether each axis wraps around.

required

Returns:

Type Description
NDArray[floating]

Weights of shape (2 * shape[0] - 1, 2 * shape[1] - 1).

Source code in src/python_som/_core/_protocols.py
150
151
152
153
154
155
156
157
158
159
160
def __call__(
    self, shape: tuple[int, int], sigma: float, cyclic: tuple[bool, bool], /
) -> npt.NDArray[np.floating]:
    """Evaluate the neighborhood over every reachable offset.

    :param shape: Shape of the network.
    :param sigma: Neighborhood radius.
    :param cyclic: Whether each axis wraps around.
    :return: Weights of shape ``(2 * shape[0] - 1, 2 * shape[1] - 1)``.
    """
    ...

Artifacts

python_som._artifact

Saving and loading a trained map, with the provenance needed to defend a result.

Wilson et al., Best Practices for Scientific Computing: a result should carry its inputs, parameters and versions.

One file. A single .npz holding the models as an array and the metadata as a JSON string. Provenance that can be separated from its artifact will be.

Callables are stored by name. A map holds four: the neighborhood, two decays and the distance. Each is resolved on load through the registries in :mod:python_som._core, so a map built from the shipped functions round-trips completely. One built with a caller's own function records the name and refuses to load silently, naming the argument to pass it back through.

No pickle. allow_pickle=False on load, so a crafted file is refused rather than executed, and no name from a file is ever imported. See :doc:/explanation/artifact-safety for the limits of that guarantee.

SOMConfig dataclass

Everything needed to rebuild a map, with strategies recorded by name.

Frozen, because it describes a map that has already been trained: changing it after the fact would describe something that never ran.

Source code in src/python_som/_artifact.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
@dataclass(frozen=True, slots=True)
class SOMConfig:
    """Everything needed to rebuild a map, with strategies recorded by name.

    Frozen, because it describes a map that has already been trained: changing it after the fact
    would describe something that never ran.
    """

    #: Grid shape.
    shape: tuple[int, int]
    #: Number of input features.
    input_len: int
    #: Initial learning rate.
    learning_rate: float
    #: Initial neighborhood radius.
    neighborhood_radius: float
    #: Floor applied to the decayed radius.
    min_neighborhood_radius: float
    #: Whether each axis wraps around.
    cyclic: tuple[bool, bool]
    #: Name of the neighborhood function.
    neighborhood_function: str
    #: Name of the learning-rate decay function.
    learning_rate_decay: str
    #: Name of the neighborhood-radius decay function.
    neighborhood_radius_decay: str
    #: Name of the distance function.
    distance_function: str

    def unresolvable(self) -> dict[str, str]:
        """Return the strategies whose names are not in a registry.

        These are the ones a caller has to pass back in by hand, because a name is all that was
        saved and it does not correspond to anything this package can look up.

        :return: Field name to the saved function name, for each strategy that cannot be restored.
        """
        return {
            attribute: getattr(self, attribute)
            for attribute, registry in _STRATEGIES.items()
            if getattr(self, attribute) not in registry
        }

unresolvable

unresolvable()

Return the strategies whose names are not in a registry.

These are the ones a caller has to pass back in by hand, because a name is all that was saved and it does not correspond to anything this package can look up.

Returns:

Type Description
dict[str, str]

Field name to the saved function name, for each strategy that cannot be restored.

Source code in src/python_som/_artifact.py
112
113
114
115
116
117
118
119
120
121
122
123
124
def unresolvable(self) -> dict[str, str]:
    """Return the strategies whose names are not in a registry.

    These are the ones a caller has to pass back in by hand, because a name is all that was
    saved and it does not correspond to anything this package can look up.

    :return: Field name to the saved function name, for each strategy that cannot be restored.
    """
    return {
        attribute: getattr(self, attribute)
        for attribute, registry in _STRATEGIES.items()
        if getattr(self, attribute) not in registry
    }

TrainingReport dataclass

What one training run did, recorded so a figure can be traced back to it.

wall_time_seconds is excluded from equality: it is provenance rather than an input, and two identical runs should compare equal.

Source code in src/python_som/_artifact.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
@dataclass(frozen=True, slots=True)
class TrainingReport:
    """What one training run did, recorded so a figure can be traced back to it.

    ``wall_time_seconds`` is excluded from equality: it is provenance rather than an input, and two
    identical runs should compare equal.
    """

    #: Training mode used.
    mode: str
    #: Number of iterations run.
    n_iteration: int
    #: Number of samples presented.
    n_samples: int
    #: Seed the map was constructed with.
    random_seed: int
    #: Learning rate at the final step, or None for batch training, which has no step size: Eq. (8)
    #: is a weighted mean.
    final_learning_rate: float | None
    #: Neighborhood radius at the final step.
    final_neighborhood_radius: float
    #: Mean quantization error after training.
    quantization_error: float
    #: Version of this package that ran it.
    python_som_version: str
    #: Version of NumPy it ran against.
    numpy_version: str
    #: Wall-clock duration. Excluded from equality; see the class docstring.
    wall_time_seconds: float = field(compare=False, default=0.0)

ArtifactError

Bases: ValueError

Raised when a file cannot be read as a python-som artifact.

A subclass of :class:ValueError so that except ValueError in existing code still catches it, and distinct so that a caller can tell "this file is not ours" from "this map used a custom function".

Source code in src/python_som/_artifact.py
74
75
76
77
78
79
80
class ArtifactError(ValueError):
    """Raised when a file cannot be read as a python-som artifact.

    A subclass of :class:`ValueError` so that ``except ValueError`` in existing code still catches
    it, and distinct so that a caller can tell "this file is not ours" from "this map used a custom
    function".
    """