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 the package is a pure functional core with a thin shell around it: :mod:python_som._core holds every numeric decision as functions over NumPy arrays, and imports nothing but NumPy. :mod:python_som._convert adapts pandas and anything else array-like at the boundary, and :mod:python_som._som holds the state and the training loops.

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.

Features: - Stepwise and batch training - Random, random-sampling and linear (PCA) weight initialization - Automatic selection of the map size ratio (with PCA) - Support for cyclic arrays, for toroidal maps - Gaussian, bubble and mexican hat neighborhood functions - Support for custom decay functions - Support for visualization (U-matrix, activation matrix) - Support for supervised learning (label map)

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
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
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
class SOM:
    """A 2-D self-organizing map over NumPy arrays, pandas DataFrames or plain lists.

    Features:
        - Stepwise and batch training
        - Random, random-sampling and linear (PCA) weight initialization
        - Automatic selection of the map size ratio (with PCA)
        - Support for cyclic arrays, for toroidal maps
        - Gaussian, bubble and mexican hat neighborhood functions
        - Support for custom decay functions
        - Support for visualization (U-matrix, activation matrix)
        - Support for supervised learning (label map)

    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)

        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. Unsupervised estimators take it anyway, because that is what
        lets ``Pipeline`` and ``cross_val_score`` call every step in the same way.

        The training options are keyword arguments here rather than constructor arguments, so that
        :class:`SOM` keeps one place where training is configured. The scikit-learn adapter in
        :mod:`python_som.sklearn` takes them at construction instead, because ``get_params`` has to
        expose them for ``GridSearchCV`` to tune them.

        :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. A 1-D array of labels is what scorers,
        ``confusion_matrix`` and ``cross_val_score`` all assume, so returning coordinates would read
        better for a grid and compose with nothing. Recover the grid position with
        ``np.unravel_index(som.predict(X), som.get_shape())``, or call :meth:`winner` for a single
        sample, which still returns ``(row, column)``.

        :param X: Dataset of shape ``(n_samples, n_features)``.
        :return: One flat node index per sample.
        """
        array = to_numpy(X)
        shape = self._shape
        return np.array(
            [np.ravel_multi_index(self.winner(sample), shape) for sample in array], dtype=int
        )

    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 the parameters that can be changed without rebuilding the models are accepted: the
        rates, the radii and the decays. Changing the grid shape or ``input_len`` would invalidate
        the weights, so those raise rather than silently leaving a map whose models do not match its
        own description.

        This is what :meth:`set_learning_rate` and :meth:`set_neighborhood_radius` will become; both
        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.

        The file holds the weights as an array and everything else as JSON beside them: the
        configuration, the seed, the generator's current state, and the last training report. No
        pickle is involved on either side, so the result is safe to load without executing code.

        Saving the generator *state* as well as the seed is what lets :meth:`load_npz` resume the
        same random stream. Re-seeding would restart it, and a resumed run would then silently
        diverge from an uninterrupted one.

        :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 produces the same weights as never having stopped, which is
        the only useful definition of "loaded" for a stochastic process and is what the saved
        generator state is for.

        The four keyword arguments exist for maps trained with a function this package cannot look
        up by name. Passing one the file did not need is harmless: it takes precedence over the
        registered function of the same role.

        :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.

        Implements Eq. (3) of Kohonen (2013). ``'sequential'`` cycles through the dataset in order,
        wrapping around until ``n_iteration`` steps have run.

        ``'random'`` draws samples **with replacement**, i.i.d., which is the stochastic
        approximation of Robbins and Monro (1951) that Kohonen cites in Section 4.1. Before 0.3.0
        the draw used ``replace=(n_iteration > len(data))``, so it was a random permutation when the
        iteration count did not exceed the sample count and i.i.d. only beyond it. That made the
        character of the sampling depend on the iteration count, which is why it is now uniform.

        :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.

        Implements 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.

        The neighborhood is evaluated **once per iteration**, not once per node. Eq. (8) needs
        ``h_ji`` for every pair of nodes, and a neighborhood depends only on the offset between the
        two -- so a single kernel over every offset serves the whole grid, and each node's
        neighborhood is a slice of it. Evaluating per node instead made the neighborhood 42% of
        batch training on a 40x40 map, more than the contraction it feeds; measured end to end, the
        kernel is worth **1.2x to 1.5x**, more with the gaussian than the cheaper bubble. See
        :func:`~python_som._core._neighborhood.offset_span` for why the offset-only dependence holds
        on a torus as well as a flat grid, and ``benchmarks/bench_batch.py`` for the measurement.

        :param array: Training dataset.
        :param n_iteration: Number of iterations.
        :param verbose: Whether to show a progress bar.
        """
        build_kernel = resolve_kernel(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)
            kernel = build_kernel(self._shape, sigma, self._cyclic)

            def neighborhood_of(
                node: tuple[int, int], evaluated: npt.NDArray[np.floating] = kernel
            ) -> npt.NDArray[np.floating]:
                """Take this iteration's neighborhood for ``node`` out of the kernel.

                ``evaluated`` is a default argument rather than a closure over ``kernel`` so that
                the value is bound at definition time, once per iteration.

                :param node: Coordinates of the node whose neighborhood is wanted.
                :param evaluated: This iteration's kernel.
                :return: Neighborhood weights over the grid, as a view into the kernel.
                """
                return kernel_view(evaluated, self._shape, node)

            self._weights = batch_update(self._weights, sums, counts, neighborhood_of, self._shape)

        # 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
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
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
291
292
293
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
295
296
297
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
299
300
301
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
303
304
305
306
307
308
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
310
311
312
313
314
315
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
317
318
319
320
321
322
323
324
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
328
329
330
331
332
333
334
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
336
337
338
339
340
341
342
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
344
345
346
347
348
349
350
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
352
353
354
355
356
357
358
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
362
363
364
365
366
367
368
369
370
371
372
373
374
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
376
377
378
379
380
381
382
383
384
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
386
387
388
389
390
391
392
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
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
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
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
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)

    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. Unsupervised estimators take it anyway, because that is what lets Pipeline and cross_val_score call every step in the same way.

The training options are keyword arguments here rather than constructor arguments, so that :class:SOM keeps one place where training is configured. The scikit-learn adapter in :mod:python_som.sklearn takes them at construction instead, because get_params has to expose them for GridSearchCV to tune them.

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
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
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. Unsupervised estimators take it anyway, because that is what
    lets ``Pipeline`` and ``cross_val_score`` call every step in the same way.

    The training options are keyword arguments here rather than constructor arguments, so that
    :class:`SOM` keeps one place where training is configured. The scikit-learn adapter in
    :mod:`python_som.sklearn` takes them at construction instead, because ``get_params`` has to
    expose them for ``GridSearchCV`` to tune them.

    :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
539
540
541
542
543
544
545
546
547
548
549
550
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
552
553
554
555
556
557
558
559
560
561
562
563
564
565
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. A 1-D array of labels is what scorers, confusion_matrix and cross_val_score all assume, so returning coordinates would read better for a grid and compose with nothing. Recover the grid position with np.unravel_index(som.predict(X), som.get_shape()), or call :meth:winner for a single sample, which still returns (row, column).

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
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
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. A 1-D array of labels is what scorers,
    ``confusion_matrix`` and ``cross_val_score`` all assume, so returning coordinates would read
    better for a grid and compose with nothing. Recover the grid position with
    ``np.unravel_index(som.predict(X), som.get_shape())``, or call :meth:`winner` for a single
    sample, which still returns ``(row, column)``.

    :param X: Dataset of shape ``(n_samples, n_features)``.
    :return: One flat node index per sample.
    """
    array = to_numpy(X)
    shape = self._shape
    return np.array(
        [np.ravel_multi_index(self.winner(sample), shape) for sample in array], dtype=int
    )

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
585
586
587
588
589
590
591
592
593
594
595
596
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
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
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 the parameters that can be changed without rebuilding the models are accepted: the rates, the radii and the decays. Changing the grid shape or input_len would invalidate the weights, so those raise rather than silently leaving a map whose models do not match its own description.

This is what :meth:set_learning_rate and :meth:set_neighborhood_radius will become; both 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
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
def set_params(self, **params: Any) -> SOM:  # noqa: ANN401
    """Set constructor-level parameters in place and return this map.

    Only the parameters that can be changed without rebuilding the models are accepted: the
    rates, the radii and the decays. Changing the grid shape or ``input_len`` would invalidate
    the weights, so those raise rather than silently leaving a map whose models do not match its
    own description.

    This is what :meth:`set_learning_rate` and :meth:`set_neighborhood_radius` will become; both
    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
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
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.

The file holds the weights as an array and everything else as JSON beside them: the configuration, the seed, the generator's current state, and the last training report. No pickle is involved on either side, so the result is safe to load without executing code.

Saving the generator state as well as the seed is what lets :meth:load_npz resume the same random stream. Re-seeding would restart it, and a resumed run would then silently diverge from an uninterrupted one.

Parameters:

Name Type Description Default
path str | PathLike[str]

Destination file.

required
Source code in src/python_som/_som.py
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
def save_npz(self, path: str | os.PathLike[str]) -> None:
    """Write the models and their provenance to a single ``.npz`` file.

    The file holds the weights as an array and everything else as JSON beside them: the
    configuration, the seed, the generator's current state, and the last training report. No
    pickle is involved on either side, so the result is safe to load without executing code.

    Saving the generator *state* as well as the seed is what lets :meth:`load_npz` resume the
    same random stream. Re-seeding would restart it, and a resumed run would then silently
    diverge from an uninterrupted one.

    :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 produces the same weights as never having stopped, which is the only useful definition of "loaded" for a stochastic process and is what the saved generator state is for.

The four keyword arguments exist for maps trained with a function this package cannot look up by name. Passing one the file did not need is harmless: it takes precedence over the registered function of the same role.

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
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
@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 produces the same weights as never having stopped, which is
    the only useful definition of "loaded" for a stochastic process and is what the saved
    generator state is for.

    The four keyword arguments exist for maps trained with a function this package cannot look
    up by name. Passing one the file did not need is harmless: it takes precedence over the
    registered function of the same role.

    :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
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
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 the neighborhood as a function of sqdist(c, i), "the square of the geometric distance between the nodes c and i in the grid". Vrieze (1995) Fig. 3 plots the "Mexican-hat" lateral interaction against a single axis labelled "Lateral distance", writes the coefficient as h_{i i_c} = 1 / ||i_c - i||, and states that the grid is assumed to be a metric space.

The consequence is that a neighborhood function must depend on the distance between two nodes and not on the two axis offsets separately. The gaussian happens to factor into a product of per-axis terms, but that is a property of the exponential, not of neighborhood functions in general: an outer product of two 1-D Ricker wavelets is positive in the diagonal quadrants where both factors are negative, placing an excitatory lobe exactly where the mexican hat must inhibit.

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: An Introduction to ANN Theory and Practice, 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)).

This is Eq. (5) of Kohonen (2013) with the learning rate factored out, so h(c, c) == 1. Strictly positive everywhere 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
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
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))``.

    This is Eq. (5) of Kohonen (2013) with the learning rate factored out, so ``h(c, c) == 1``.
    Strictly positive everywhere 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.

This is the truncated inner, excitatory lobe of the mexican hat. Vrieze (1995) p. 85: "In Kohonen networks usually only the inner stimulation area is used, i.e., when a neuron i fires, a positive feedback takes place for all neurons i', whose distance to i is smaller than some given number rho", and notes that this flat choice is "just as effective and sometimes even better" than a distance-dependent one.

The metric here is Chebyshev, not Euclidean, so the region is a square rather than a disc: a node is included when max(|dx|, |dy|) <= round(sigma). That matches the pseudo-code in Vrieze's appendix, which computes b = MAX(ABS(i - w_i), ABS(j - w_j)), though Kohonen's phrase "up to a certain radius from the winner" (Section 4.1) reads as Euclidean. The two sources genuinely differ; this implementation follows Vrieze, and the choice is preserved rather than changed so that existing results stay reproducible.

One consequence worth stating, because it is easy to assume otherwise: a Chebyshev ball is not isotropic under the Euclidean metric. On a large enough grid, nodes at equal Euclidean distance from the winner can fall on opposite sides of the boundary. The smallest case is a radius of sqrt(50), where (5, 5) lies inside a sigma = 5 square while (7, 1) lies outside.

Unlike the other neighborhood functions, a radius of zero is admissible: it selects the winner alone, which is well defined, whereas for the gaussian and the mexican hat it is a division 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
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
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.

    This is the truncated inner, excitatory lobe of the mexican hat. Vrieze (1995) p. 85: "In
    Kohonen networks usually only the inner stimulation area is used, i.e., when a neuron i fires, a
    positive feedback takes place for all neurons i', whose distance to i is smaller than some given
    number rho", and notes that this flat choice is "just as effective and sometimes even better"
    than a distance-dependent one.

    **The metric here is Chebyshev, not Euclidean**, so the region is a square rather than a disc:
    a node is included when ``max(|dx|, |dy|) <= round(sigma)``. That matches the pseudo-code in
    Vrieze's appendix, which computes ``b = MAX(ABS(i - w_i), ABS(j - w_j))``, though Kohonen's
    phrase "up to a certain radius from the winner" (Section 4.1) reads as Euclidean. The two
    sources genuinely differ; this implementation follows Vrieze, and the choice is preserved rather
    than changed so that existing results stay reproducible.

    One consequence worth stating, because it is easy to assume otherwise: a Chebyshev ball is not
    isotropic under the Euclidean metric. On a large enough grid, nodes at equal Euclidean distance
    from the winner can fall on opposite sides of the boundary. The smallest case is a radius of
    ``sqrt(50)``, where ``(5, 5)`` lies inside a ``sigma = 5`` square while ``(7, 1)`` lies outside.

    Unlike the other neighborhood functions, a radius of zero is admissible: it selects the winner
    alone, which is well defined, whereas for the gaussian and the mexican hat it is a division 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).

Also known as the Ricker wavelet or the Laplacian of Gaussian. This is the biologically motivated lateral-interaction function: nodes near the winner are excited, nodes past a certain distance are inhibited, and the inhibition vanishes as distance grows further (Vrieze 1995, Fig. 3).

Normalized so h(c, c) == 1. Crosses zero at a radius of sqrt(2) * sigma and reaches its minimum of -exp(-2), about -0.135, at a radius of 2 * sigma.

This is deliberately not the outer product of two 1-D Ricker wavelets. See the module docstring for why that construction is wrong.

Takes negative values, so it cannot be used with batch training; see :data:SIGNED_NEIGHBORHOODS.

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
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
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)``.

    Also known as the Ricker wavelet or the Laplacian of Gaussian. This is the biologically
    motivated lateral-interaction function: nodes near the winner are excited, nodes past a certain
    distance are inhibited, and the inhibition vanishes as distance grows further (Vrieze 1995,
    Fig. 3).

    Normalized so ``h(c, c) == 1``. Crosses zero at a radius of ``sqrt(2) * sigma`` and reaches its
    minimum of ``-exp(-2)``, about -0.135, at a radius of ``2 * sigma``.

    This is deliberately not the outer product of two 1-D Ricker wavelets. See the module docstring
    for why that construction is wrong.

    Takes negative values, so it cannot be used with batch training; see
    :data:`SIGNED_NEIGHBORHOODS`.

    :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), so the shortest way around the torus is used. Both tails must be folded: an offset of -9 on an axis of length 10 represents 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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
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)``,
    so the shortest way around the torus is used. Both tails must be folded: an offset of -9 on an
    axis of length 10 represents 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

offset_span

offset_span(length, *, cyclic)

Every offset any pair of nodes on this axis can have: -(length-1) .. (length-1).

The full-range counterpart of :func:axis_offsets, which gives the offsets from one particular centre. Because a neighborhood depends on the offset alone and never on where the winner sits, one array over this span serves every node -- which is what lets batch training evaluate the neighborhood once per iteration instead of once per node.

The cyclic fold is the same minimum-image convention, applied with the real period length rather than the span's own width. That is the whole reason this cannot be expressed as axis_offsets on a 2*length-1 axis: the fold would then use the wrong period.

Parameters:

Name Type Description Default
length int

Number of nodes along the axis.

required
cyclic bool

Whether the axis wraps around.

required

Returns:

Type Description
NDArray[floating]

Signed offsets, 2 * length - 1 of them, centred on zero.

Source code in src/python_som/_core/_neighborhood.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def offset_span(length: int, *, cyclic: bool) -> npt.NDArray[np.floating]:
    """Every offset any pair of nodes on this axis can have: ``-(length-1) .. (length-1)``.

    The full-range counterpart of :func:`axis_offsets`, which gives the offsets from one particular
    centre. Because a neighborhood depends on the offset alone and never on where the winner sits,
    one array over this span serves every node -- which is what lets batch training evaluate the
    neighborhood once per iteration instead of once per node.

    The cyclic fold is the same minimum-image convention, applied with the real period ``length``
    rather than the span's own width. That is the whole reason this cannot be expressed as
    ``axis_offsets`` on a ``2*length-1`` axis: the fold would then use the wrong period.

    :param length: Number of nodes along the axis.
    :param cyclic: Whether the axis wraps around.
    :return: Signed offsets, ``2 * length - 1`` of them, centred on zero.
    """
    d = np.arange(-(length - 1), length, dtype=float)
    if cyclic:
        d = (d + length / 2) % length - length / 2
    return d

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
113
114
115
116
117
118
119
120
121
122
123
124
125
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))

gaussian_kernel

gaussian_kernel(shape, sigma, cyclic)

Evaluate the gaussian over every offset, to be sliced per node by :func:kernel_view.

Parameters:

Name Type Description Default
shape Grid

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).

Raises:

Type Description
ValueError

If the radius is not a finite positive number.

Source code in src/python_som/_core/_neighborhood.py
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def gaussian_kernel(
    shape: Grid, sigma: float, cyclic: tuple[bool, bool]
) -> npt.NDArray[np.floating]:
    """Evaluate the gaussian over every offset, to be sliced per node by :func:`kernel_view`.

    :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)``.
    :raises ValueError: If the radius is not a finite positive number.
    """
    return _gaussian_profile(
        offset_span(shape[0], cyclic=cyclic[0]), offset_span(shape[1], cyclic=cyclic[1]), sigma
    )

bubble_kernel

bubble_kernel(shape, sigma, cyclic)

Evaluate the bubble over every offset. See :func:gaussian_kernel.

Parameters:

Name Type Description Default
shape Grid

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).

Raises:

Type Description
ValueError

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

Source code in src/python_som/_core/_neighborhood.py
358
359
360
361
362
363
364
365
366
367
368
369
def bubble_kernel(shape: Grid, sigma: float, cyclic: tuple[bool, bool]) -> npt.NDArray[np.floating]:
    """Evaluate the bubble over every offset. See :func:`gaussian_kernel`.

    :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)``.
    :raises ValueError: If the radius is not a finite non-negative number.
    """
    return _bubble_profile(
        offset_span(shape[0], cyclic=cyclic[0]), offset_span(shape[1], cyclic=cyclic[1]), sigma
    )

mexican_hat_kernel

mexican_hat_kernel(shape, sigma, cyclic)

Evaluate the mexican hat over every offset. See :func:gaussian_kernel.

Parameters:

Name Type Description Default
shape Grid

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).

Raises:

Type Description
ValueError

If the radius is not a finite positive number.

Source code in src/python_som/_core/_neighborhood.py
342
343
344
345
346
347
348
349
350
351
352
353
354
355
def mexican_hat_kernel(
    shape: Grid, sigma: float, cyclic: tuple[bool, bool]
) -> npt.NDArray[np.floating]:
    """Evaluate the mexican hat over every offset. See :func:`gaussian_kernel`.

    :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)``.
    :raises ValueError: If the radius is not a finite positive number.
    """
    return _mexican_hat_profile(
        offset_span(shape[0], cyclic=cyclic[0]), offset_span(shape[1], cyclic=cyclic[1]), sigma
    )

kernel_view

kernel_view(kernel, shape, c)

Extract node c's neighborhood from a kernel, as a view rather than a copy.

The kernel is indexed by offset, with offset zero at (shape[0] - 1, shape[1] - 1). Node c sees offsets i - c for each node i, so its neighborhood is the shape-sized block starting at (shape[0] - 1 - c[0], shape[1] - 1 - c[1]).

Returning a view is the point: copying shape[0] * shape[1] floats per node would give back much of what evaluating the kernel once saved. Downstream reads it only, and np.sum and np.einsum are both happy with a non-contiguous view.

Parameters:

Name Type Description Default
kernel NDArray[floating]

Kernel from one of the *_kernel functions.

required
shape Grid

Shape of the network.

required
c Coordinates

Coordinates of the node whose neighborhood is wanted.

required

Returns:

Type Description
NDArray[floating]

A read-only-by-convention view of shape shape.

Source code in src/python_som/_core/_neighborhood.py
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
def kernel_view(
    kernel: npt.NDArray[np.floating], shape: Grid, c: Coordinates
) -> npt.NDArray[np.floating]:
    """Extract node ``c``'s neighborhood from a kernel, as a view rather than a copy.

    The kernel is indexed by offset, with offset zero at ``(shape[0] - 1, shape[1] - 1)``. Node
    ``c`` sees offsets ``i - c`` for each node ``i``, so its neighborhood is the ``shape``-sized
    block starting at ``(shape[0] - 1 - c[0], shape[1] - 1 - c[1])``.

    Returning a view is the point: copying ``shape[0] * shape[1]`` floats per node would give back
    much of what evaluating the kernel once saved. Downstream reads it only, and ``np.sum`` and
    ``np.einsum`` are both happy with a non-contiguous view.

    :param kernel: Kernel from one of the ``*_kernel`` functions.
    :param shape: Shape of the network.
    :param c: Coordinates of the node whose neighborhood is wanted.
    :return: A read-only-by-convention view of shape ``shape``.
    """
    return kernel[
        shape[0] - 1 - c[0] : 2 * shape[0] - 1 - c[0], shape[1] - 1 - c[1] : 2 * shape[1] - 1 - c[1]
    ]

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
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
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) does not prescribe a 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, say, on the order of half of the diameter of the grid, whereafter it is gradually reduced to a fraction of it in about 1000 steps" (Section 4.1).

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, which is the least surprising mapping and the one a reader can check against the source without a lookup table. These names are written into artifacts, so they are public API from 0.4.0 and fixed at 1.0.0.

A decay function is not required to be in here: a caller may pass any callable. What a name buys is the ability to restore it from a file, and the loader says so explicitly 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
30
31
32
33
34
35
36
37
38
39
40
41
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
44
45
46
47
48
49
50
51
52
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
55
56
57
58
59
60
61
62
63
64
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
67
68
69
70
71
72
73
74
75
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
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
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 these cover is still accepted as a plain string, and will be for the whole 0.4.x and 0.5.x series. mode=TrainingMode.BATCH and mode="batch" are interchangeable, compare equal, hash equal, and serialise to the same JSON, because each member is a str.

Both spellings are permanent. 0.5.0 briefly deprecated plain strings and 0.6.0 withdrew that, because every comparable library passes options as strings: scikit-learn (KMeans(init="k-means++")), numpy (np.pad(mode="constant")), scipy (linkage(method="single")), and both SOM peers, minisom and sompy. None of them export enums at all. Being the only library in the ecosystem to reject mode="batch" would cost users more than the consistency was worth.

The enums remain because they cost nothing and some callers prefer them. The type-checking benefit that motivated them is delivered by the Literal unions below rather than by removing anything: mode="bacth" is a type error while mode="batch" is not.

On the base class. enum.StrEnum arrived in Python 3.11 and this package supports 3.10, so :class:_StrEnum reproduces it. A bare class X(str, Enum) is not equivalent: its str() returns 'X.MEMBER' rather than the value, which would put the wrong text into any f-string, filename or log line built from a member. Defining __str__ explicitly makes the behaviour identical on every supported version, which was checked on 3.10, 3.12 and 3.13 rather than assumed.

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
53
54
55
56
57
58
59
60
61
62
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
65
66
67
68
69
70
71
72
73
74
75
76
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
79
80
81
82
83
84
85
86
87
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
90
91
92
93
94
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 three strategies a caller can replace.

A neighborhood, a decay and a distance are all things a user may supply their own version of. Typed as bare Callable[...] aliases, mypy checks little more than the argument count; as Protocols it checks the shape of the call against a named contract, and the error names the protocol rather than printing two structural types side by side.

Every parameter is positional-only (the / in each __call__). Without it, a Protocol requires the names to match as well as the types, so a user's def my_decay(rate, step, total) would fail against a protocol that named them differently. Positional-only says what is actually true: these are called positionally, and only the order and the types matter.

These are structural, so nothing needs to inherit from them. Every function already in the package satisfies its protocol, and so does any existing user-supplied callable with the right signature -- this adds checking, not a requirement.

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 -- the distance between two nodes -- not on the two axis offsets separately. A separable product of per-axis profiles satisfies the signature but is only correct for the gaussian.

Source code in src/python_som/_core/_protocols.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@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 -- the distance between
    two nodes -- not on the two axis offsets separately. A separable product of per-axis profiles
    satisfies the signature but 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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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
57
58
59
60
61
62
63
64
65
66
67
68
69
@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
61
62
63
64
65
66
67
68
69
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 rather than assume one vector.

Source code in src/python_som/_core/_protocols.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
@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 rather than assume one vector.
    """

    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
80
81
82
83
84
85
86
87
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``.
    """
    ...

KernelFunction

Bases: Protocol

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

The kernel form of a :class:NeighborhoodFunction, used by batch training so that the neighborhood is computed once per iteration rather than once per node.

Source code in src/python_som/_core/_protocols.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
@runtime_checkable
class KernelFunction(Protocol):
    """A neighborhood evaluated over every offset at once, independent of any particular winner.

    The kernel form of a :class:`NeighborhoodFunction`, used by batch training so that the
    neighborhood is computed once per iteration rather than once per node.
    """

    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
 98
 99
100
101
102
103
104
105
106
107
108
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. Until 0.4.0 the only way to keep a trained map was pickle, which is arbitrary code execution on load. The point here is not to forbid that but to make the safe path the obvious one.

One file. Everything lives in a single .npz: the models as an array, and the metadata as a JSON string stored alongside them. A separate sidecar was the first design and is worse, because provenance that can be separated from its artifact will be.

What cannot be saved, and what happens instead. A map holds four callables: the neighborhood, two decays and the distance. A callable cannot be written to a file without pickle, so what is stored is its name, resolved on load through the registries in :mod:python_som._core. A map built entirely from the shipped functions round-trips completely. One built with a caller's own function records the name for provenance and refuses to load silently: the loader raises and names the argument to pass it back through.

Security. allow_pickle=False is passed explicitly on load, so a crafted file containing an object array is refused by NumPy rather than executed; strategies resolve only through the registries, so no name from the file is ever imported or evaluated; the metadata is parsed with json.loads. What that buys is "cannot execute code", not "safe to load anything": an .npz is a zip, so a hostile file can still attempt resource exhaustion through decompression. Treat one from an untrusted source the way you would a JPEG, not the way you would a signed archive.

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
 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
125
126
127
128
129
130
131
132
@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
120
121
122
123
124
125
126
127
128
129
130
131
132
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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
@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
82
83
84
85
86
87
88
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".
    """