python-som 0.1.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -127,3 +127,6 @@ dmypy.json
127
127
 
128
128
  # Pyre type checker
129
129
  .pyre/
130
+
131
+ # .dump directory
132
+ /.dump
@@ -0,0 +1,85 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
6
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - 2026-07-28
9
+
10
+ ### Added
11
+
12
+ - Mexican hat neighborhood function, selectable with `neighborhood_function='mexicanhat'`
13
+ (contributed by [@Arne49](https://github.com/Arne49) in
14
+ [#2](https://github.com/andremsouza/python-som/pull/2)).
15
+
16
+ Implemented as the isotropic 2-D form `(1 - u) * exp(-u)` over `u = sqdist(c, i) / (2 * sigma^2)`,
17
+ normalized so that `h(c, c) = 1`. It crosses zero at a radius of `sqrt(2) * sigma` and reaches its
18
+ minimum of `-exp(-2)` at a radius of `2 * sigma`.
19
+
20
+ Note that a neighborhood function must depend on the grid distance alone, not on the two axis
21
+ offsets separately, per `sqdist(c, i)` in Kohonen (2013) Eq. (5) and the "lateral distance"
22
+ abscissa of Vrieze (1995) Fig. 3. The gaussian happens to be separable into a product of per-axis
23
+ terms, but that is a property of the exponential rather than of neighborhood functions in general.
24
+
25
+ - `## Neighborhood functions` section in the README, comparing all three and documenting the
26
+ batch-mode restriction below.
27
+ - Test suite for the neighborhood functions, asserting closed-form analytic properties rather than
28
+ golden values.
29
+
30
+ ### Fixed
31
+
32
+ - **Cyclic (toroidal) neighborhoods were only correct in one direction.** The fold-back handled
33
+ offsets above `+length/2` but left those below `-length/2` alone, so a winner in the upper half of
34
+ an axis did not wrap. On a 10x10 toroidal map with sigma=1, a winner at row 0 gave `h[9] = 0.6065`
35
+ while a winner at row 9 gave `h[0] = 0.0` instead of the same 0.6065. Now uses the minimum-image
36
+ convention, which folds both tails.
37
+
38
+ This changes results for any map built with `cyclic_x=True` or `cyclic_y=True`.
39
+
40
+ - A neighborhood radius of zero divided by zero and produced `inf`/`nan` weights instead of raising.
41
+ `_gaussian` and `_mexicanhat` now reject a non-finite or non-positive radius; `_bubble` still
42
+ accepts a radius of zero, which selects the winner alone.
43
+
44
+ - An unknown `neighborhood_function` raised a bare `KeyError`. It now raises `ValueError` listing the
45
+ valid names, matching the behavior of `weight_initialization`.
46
+
47
+ ### Changed
48
+
49
+ - **`requires-python` is now `>=3.10`.** The declared floor of `>=3.9` was never accurate: the module
50
+ uses PEP 604 unions (`int | None`) in runtime-evaluated annotations without
51
+ `from __future__ import annotations`, so importing it on Python 3.9 raises `TypeError`. Python 3.9
52
+ reached end of life in October 2025. Installers will now decline the package on 3.9 rather than
53
+ install something that cannot be imported.
54
+
55
+ - `train(mode='batch')` combined with `neighborhood_function='mexicanhat'` now raises `ValueError`.
56
+ The batch update of Kohonen (2013) Eq. (8) is a weighted mean whose denominator `sum_j n_j * h_ji`
57
+ is only well defined for a non-negative neighborhood function. The mexican hat is signed by
58
+ construction, so the denominator can vanish or turn negative: on a 12x12 grid, 49 of 144
59
+ denominators come out negative, which inverts or explodes the update. This is a property of the
60
+ function itself rather than of this implementation. Use `mode='random'` or `mode='sequential'`.
61
+
62
+ ### Packaging
63
+
64
+ - Restored the `cli` extra (`pip install python-som[cli]`, which pulls in `tqdm` for training
65
+ progress bars). This extra shipped in 0.1.3 on PyPI but was never committed to the repository, so
66
+ releasing from the repository as-is would have silently removed it.
67
+
68
+ ### Note on 0.1.3
69
+
70
+ There is no changelog entry for 0.1.3 because it was published to PyPI from an uncommitted working
71
+ tree and has no corresponding commit. Its `python_som/__init__.py` is byte-identical to the 0.1.2
72
+ tag; the only differences are the version string and the `cli` extra restored above.
73
+
74
+ ## [0.1.3] - 2025-01-14
75
+
76
+ Published to PyPI only. See the note above.
77
+
78
+ ## [0.1.2] - 2025-01-14
79
+
80
+ Earlier releases predate this changelog. See the
81
+ [commit history](https://github.com/andremsouza/python-som/commits/master) for details.
82
+
83
+ [0.2.0]: https://github.com/andremsouza/python-som/releases/tag/v0.2.0
84
+ [0.1.3]: https://pypi.org/project/python-som/0.1.3/
85
+ [0.1.2]: https://pypi.org/project/python-som/0.1.2/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-som
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Python implementation of the Self-Organizing Map
5
5
  Project-URL: Homepage, https://github.com/andremsouza/python-som
6
6
  Project-URL: Issues, https://github.com/andremsouza/python-som/issues
@@ -16,10 +16,12 @@ Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
17
  Classifier: Topic :: Software Development :: Libraries
18
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
- Requires-Python: >=3.9
19
+ Requires-Python: >=3.10
20
20
  Requires-Dist: numpy
21
21
  Requires-Dist: pandas
22
22
  Requires-Dist: scikit-learn
23
+ Provides-Extra: cli
24
+ Requires-Dist: tqdm; extra == 'cli'
23
25
  Description-Content-Type: text/markdown
24
26
 
25
27
  # python-som
@@ -35,7 +37,7 @@ Most features were implemented using NumPy, with Scikit-learn for standardizatio
35
37
  * Linear weight initialization (with PCA)
36
38
  * Automatic selection of map size ratio (with PCA)
37
39
  * Support for cyclic arrays, for toroidal or spherical maps
38
- * Gaussian and Bubble neighborhood functions
40
+ * Gaussian, Bubble and Mexican hat neighborhood functions
39
41
  * Support for custom decay functions
40
42
  * Support for visualization (U-matrix, activation matrix)
41
43
  * Support for supervised learning (label map)
@@ -132,6 +134,22 @@ The following are lists of public methods and functions currently available in t
132
134
  * SOM.train
133
135
  * SOM.weight_initialization
134
136
 
137
+ ## Neighborhood functions
138
+ The `neighborhood_function` parameter selects how the winner's correction is spread over the grid.
139
+ All three are functions of the distance between two nodes in the grid, `sqdist(c, i)` in Eq. (5) of
140
+ Kohonen (2013):
141
+
142
+ | Name | Shape | Notes |
143
+ | --- | --- | --- |
144
+ | `'gaussian'` | `exp(-r² / 2σ²)` | Strictly positive and monotonically decreasing. The default. |
145
+ | `'bubble'` | `1` for `r ≤ σ`, else `0` | The truncated inner lobe of the mexican hat. Vrieze (1995) notes this flat choice is "just as effective and sometimes even better". |
146
+ | `'mexicanhat'` | `(1 - u)·exp(-u)`, `u = r² / 2σ²` | Excitatory near the winner, inhibitory beyond it. Crosses zero at `r = √2·σ`, reaches its minimum of `-e⁻² ≈ -0.135` at `r = 2σ`. |
147
+
148
+ The mexican hat takes negative values, so it **cannot be used with `mode='batch'`**: the batch update
149
+ of Kohonen (2013), Eq. (8), is a weighted mean whose denominator `Σⱼ nⱼ·hⱼᵢ` is not sign-definite for
150
+ a signed neighborhood function. Use `mode='random'` or `mode='sequential'` instead; passing
151
+ `mode='batch'` raises a `ValueError`.
152
+
135
153
  ## References
136
154
  This implemetation was based on the following paper, by Professor Teuvo Kohonen:
137
155
 
@@ -142,4 +160,15 @@ Volume 37,
142
160
  2013,
143
161
  Pages 52-65,
144
162
  ISSN 0893-6080,
145
- https://doi.org/10.1016/j.neunet.2012.09.018.
163
+ https://doi.org/10.1016/j.neunet.2012.09.018.
164
+
165
+ The mexican hat neighborhood function follows the lateral-interaction formulation in:
166
+
167
+ O. J. Vrieze,
168
+ Kohonen network,
169
+ in: Artificial Neural Networks: An Introduction to ANN Theory and Practice,
170
+ Lecture Notes in Computer Science, Volume 931,
171
+ Springer, Berlin, Heidelberg,
172
+ 1995,
173
+ Pages 83-100,
174
+ https://doi.org/10.1007/BFb0027024.
@@ -11,7 +11,7 @@ Most features were implemented using NumPy, with Scikit-learn for standardizatio
11
11
  * Linear weight initialization (with PCA)
12
12
  * Automatic selection of map size ratio (with PCA)
13
13
  * Support for cyclic arrays, for toroidal or spherical maps
14
- * Gaussian and Bubble neighborhood functions
14
+ * Gaussian, Bubble and Mexican hat neighborhood functions
15
15
  * Support for custom decay functions
16
16
  * Support for visualization (U-matrix, activation matrix)
17
17
  * Support for supervised learning (label map)
@@ -108,6 +108,22 @@ The following are lists of public methods and functions currently available in t
108
108
  * SOM.train
109
109
  * SOM.weight_initialization
110
110
 
111
+ ## Neighborhood functions
112
+ The `neighborhood_function` parameter selects how the winner's correction is spread over the grid.
113
+ All three are functions of the distance between two nodes in the grid, `sqdist(c, i)` in Eq. (5) of
114
+ Kohonen (2013):
115
+
116
+ | Name | Shape | Notes |
117
+ | --- | --- | --- |
118
+ | `'gaussian'` | `exp(-r² / 2σ²)` | Strictly positive and monotonically decreasing. The default. |
119
+ | `'bubble'` | `1` for `r ≤ σ`, else `0` | The truncated inner lobe of the mexican hat. Vrieze (1995) notes this flat choice is "just as effective and sometimes even better". |
120
+ | `'mexicanhat'` | `(1 - u)·exp(-u)`, `u = r² / 2σ²` | Excitatory near the winner, inhibitory beyond it. Crosses zero at `r = √2·σ`, reaches its minimum of `-e⁻² ≈ -0.135` at `r = 2σ`. |
121
+
122
+ The mexican hat takes negative values, so it **cannot be used with `mode='batch'`**: the batch update
123
+ of Kohonen (2013), Eq. (8), is a weighted mean whose denominator `Σⱼ nⱼ·hⱼᵢ` is not sign-definite for
124
+ a signed neighborhood function. Use `mode='random'` or `mode='sequential'` instead; passing
125
+ `mode='batch'` raises a `ValueError`.
126
+
111
127
  ## References
112
128
  This implemetation was based on the following paper, by Professor Teuvo Kohonen:
113
129
 
@@ -118,4 +134,15 @@ Volume 37,
118
134
  2013,
119
135
  Pages 52-65,
120
136
  ISSN 0893-6080,
121
- https://doi.org/10.1016/j.neunet.2012.09.018.
137
+ https://doi.org/10.1016/j.neunet.2012.09.018.
138
+
139
+ The mexican hat neighborhood function follows the lateral-interaction formulation in:
140
+
141
+ O. J. Vrieze,
142
+ Kohonen network,
143
+ in: Artificial Neural Networks: An Introduction to ANN Theory and Practice,
144
+ Lecture Notes in Computer Science, Volume 931,
145
+ Springer, Berlin, Heidelberg,
146
+ 1995,
147
+ Pages 83-100,
148
+ https://doi.org/10.1007/BFb0027024.
@@ -3,13 +3,13 @@ requires = ["hatchling"]
3
3
  build-backend = "hatchling.build"
4
4
  [project]
5
5
  name = "python-som"
6
- version = "0.1.2"
6
+ version = "0.2.0"
7
7
  authors = [
8
8
  { name="André Moreira Souza", email="msouza.andre@hotmail.com" },
9
9
  ]
10
10
  description = "Python implementation of the Self-Organizing Map"
11
11
  readme = "README.md"
12
- requires-python = ">=3.9"
12
+ requires-python = ">=3.10"
13
13
  classifiers = [
14
14
  "Development Status :: 3 - Alpha",
15
15
  "Intended Audience :: Developers",
@@ -29,6 +29,11 @@ dependencies = [
29
29
  "scikit-learn",
30
30
  ]
31
31
 
32
+ [project.optional-dependencies]
33
+ cli = [
34
+ "tqdm",
35
+ ]
36
+
32
37
  [project.urls]
33
38
  Homepage = "https://github.com/andremsouza/python-som"
34
- Issues = "https://github.com/andremsouza/python-som/issues"
39
+ Issues = "https://github.com/andremsouza/python-som/issues"
@@ -9,7 +9,7 @@ Features:
9
9
  - Linear weight initialization (with PCA)
10
10
  - Automatic selection of map size ratio (with PCA)
11
11
  - Support for cyclic arrays, for toroidal or spherical maps
12
- - Gaussian and Bubble neighborhood functions
12
+ - Gaussian, Bubble and Mexican Hat neighborhood functions
13
13
  - Support for custom decay functions
14
14
  - Support for visualization (U-matrix, activation matrix)
15
15
  - Support for supervised learning (label map)
@@ -124,7 +124,7 @@ class SOM:
124
124
  - Linear weight initialization (with PCA)
125
125
  - Automatic selection of map size ratio (with PCA)
126
126
  - Support for cyclic arrays, for toroidal or spherical maps
127
- - Gaussian and Bubble neighborhood functions
127
+ - Gaussian, Bubble and Mexican hat neighborhood functions
128
128
  - Support for custom decay functions
129
129
  - Support for visualization (U-matrix, activation matrix)
130
130
  - Support for supervised learning (label map)
@@ -190,7 +190,7 @@ class SOM:
190
190
  variable. May be a predefined one from this package, or a custom function, with the
191
191
  same parameters and return type. Defaults to _asymptotic_decay
192
192
  :param neighborhood_function: str: Neighborhood function name for the training process.
193
- May be either 'gaussian' or 'bubble'.
193
+ May be either 'gaussian', 'bubble' or 'mexicanhat'.
194
194
  :param distance_function: function: Function for calculating distances/dissimilarities
195
195
  between models of the network.
196
196
  May be a predefined one from this package, or a custom function, with the same
@@ -242,10 +242,19 @@ class SOM:
242
242
  self._learning_rate_decay = learning_rate_decay
243
243
  self._neighborhood_radius = float(neighborhood_radius)
244
244
  self._neighborhood_radius_decay = neighborhood_radius_decay
245
- self._neighborhood_function = {
245
+ neighborhood_functions = {
246
246
  "gaussian": self._gaussian,
247
247
  "bubble": self._bubble,
248
- }[neighborhood_function]
248
+ "mexicanhat": self._mexicanhat,
249
+ }
250
+ try:
251
+ self._neighborhood_function = neighborhood_functions[neighborhood_function]
252
+ except KeyError as exc:
253
+ raise ValueError(
254
+ "Invalid value for 'neighborhood_function' parameter. Value should be in "
255
+ + str(list(neighborhood_functions.keys()))
256
+ ) from exc
257
+ self._neighborhood_function_name = neighborhood_function
249
258
  self._distance_function = distance_function
250
259
  self._cyclic = (bool(cyclic_x), bool(cyclic_y))
251
260
  self._neigx, self._neigy = np.arange(self._shape[0]), np.arange(self._shape[1])
@@ -484,6 +493,18 @@ class SOM:
484
493
  elif mode == "sequential":
485
494
  self._train_sequential(data_array, n_iteration, verbose)
486
495
  elif mode == "batch":
496
+ if self._neighborhood_function_name == "mexicanhat":
497
+ # The batch update of Kohonen (2013), Eq. (8), is a weighted mean whose denominator
498
+ # is sum_j n_j * h_ji. That is only well defined for a non-negative neighborhood
499
+ # function. The mexican hat is signed by construction, so the denominator can vanish
500
+ # or turn negative, which makes the update blow up or invert the sign of the
501
+ # correction. Use a stepwise mode instead.
502
+ raise ValueError(
503
+ "The 'mexicanhat' neighborhood function cannot be used with the 'batch'"
504
+ " training mode: the weighted mean of Kohonen (2013), Eq. (8), is undefined"
505
+ " for a neighborhood function that takes negative values, as its denominator"
506
+ " is not sign-definite. Use mode='random' or mode='sequential' instead."
507
+ )
487
508
  self._train_batch(data_array, n_iteration, verbose)
488
509
  else:
489
510
  # Invalid training mode value
@@ -641,7 +662,7 @@ class SOM:
641
662
  def weight_initialization(
642
663
  self,
643
664
  mode: str = "random",
644
- **kwargs: np.ndarray | pd.DataFrame | list | str | int
665
+ **kwargs: np.ndarray | pd.DataFrame | list | str | int,
645
666
  ) -> None:
646
667
  """Function for weight initialization of the self-organizing map.
647
668
 
@@ -758,28 +779,70 @@ class SOM:
758
779
  )
759
780
  self._weights = data_array[sample].reshape(self._weights.shape)
760
781
 
782
+ @staticmethod
783
+ def _axis_offsets(
784
+ coords: np.ndarray, center: int, length: float, cyclic: bool
785
+ ) -> np.ndarray:
786
+ """
787
+ Signed offsets from 'center' to each coordinate along one axis of the grid.
788
+
789
+ When the axis is cyclic, the minimum-image convention is applied, i.e., each offset is
790
+ folded into the interval [-length/2, length/2), so that the shortest way around the torus
791
+ is used. Both tails must be folded: an offset of -9 on an axis of length 10 represents a
792
+ distance of 1, not 9.
793
+
794
+ :param coords: np.ndarray: Coordinates along the axis, i.e., np.arange(length).
795
+ :param center: int: Coordinate of the center (winner node) along the axis.
796
+ :param length: float: Number of nodes along the axis.
797
+ :param cyclic: bool: Whether the axis wraps around.
798
+ :return: np.ndarray: Signed offsets from center to each coordinate.
799
+ """
800
+ d = coords - center
801
+ if cyclic:
802
+ d = (d + length / 2) % length - length / 2
803
+ return d
804
+
805
+ def _sqdist(self, c: tuple[int, ...], sigma: float) -> np.ndarray:
806
+ """
807
+ Squared geometric distance from the center c to every node of the grid.
808
+
809
+ This is the sqdist(c, i) term of Kohonen (2013), Eq. (5). Every neighborhood function must
810
+ be a function of this scalar quantity: the neighborhood describes lateral interaction over
811
+ a metric space, so it depends on the distance between two nodes and not on the individual
812
+ axis offsets. Only the gaussian is separable into a product of per-axis terms, and that is
813
+ a property of the exponential rather than of neighborhood functions in general.
814
+
815
+ :param c: (int, int): Center coordinates.
816
+ :param sigma: float: Spread variable, validated here for all neighborhood functions.
817
+ :return: np.ndarray: Squared distances from c to each node, with the shape of the network.
818
+ :raises ValueError: If sigma is not strictly positive.
819
+ """
820
+ if not np.isfinite(sigma) or sigma <= 0:
821
+ raise ValueError(
822
+ "The neighborhood radius 'sigma' must be a finite positive number, got "
823
+ + str(sigma)
824
+ )
825
+ dx = self._axis_offsets(
826
+ self._neigx, c[0], float(self._shape[0]), self._cyclic[0]
827
+ )
828
+ dy = self._axis_offsets(
829
+ self._neigy, c[1], float(self._shape[1]), self._cyclic[1]
830
+ )
831
+ return np.add.outer(np.power(dx, 2), np.power(dy, 2))
832
+
761
833
  def _gaussian(self, c: tuple[int, ...], sigma: float) -> np.ndarray:
762
834
  """
763
835
  Gaussian neighborhood function, centered in c. Has support for cyclic arrays.
764
836
 
837
+ Implements h_ci = exp(-sqdist(c, i) / (2 * sigma^2)), i.e., Eq. (5) of Kohonen (2013)
838
+ with the learning rate factored out, so that h_cc = 1.
839
+
765
840
  :param c: (int, int): Center coordinates for gaussian function.
766
- :param sigma: float: Spread variable for gaussian function.
841
+ :param sigma: float: Spread variable for gaussian function. Must be positive.
767
842
  :return: np.ndarray: Gaussian, centered in c, over all the weights of the network.
843
+ :raises ValueError: If sigma is not strictly positive.
768
844
  """
769
- # Calculate coefficient with sigma
770
- d = 2 * sigma * sigma
771
- # Calculate vertical and horizontal distances
772
- dx = self._neigx - c[0]
773
- dy = self._neigy - c[1]
774
- # If using cyclic arrays, perform fold back distance
775
- if self._cyclic[0]:
776
- dx[dx > self._shape[0] / 2] -= self._shape[0]
777
- if self._cyclic[1]:
778
- dy[dy > self._shape[1] / 2] -= self._shape[1]
779
- # Calculate gaussian centered in c
780
- ax = np.exp(-np.power(dx, 2) / d)
781
- ay = np.exp(-np.power(dy, 2) / d)
782
- return np.outer(ax, ay)
845
+ return np.exp(-self._sqdist(c, sigma) / (2 * sigma * sigma))
783
846
 
784
847
  def _bubble(self, c: tuple[int, ...], sigma: float) -> np.ndarray:
785
848
  """
@@ -788,10 +851,26 @@ class SOM:
788
851
  The neighbors of c are the nodes in the region of sigma positions in the vertical
789
852
  and horizontal directions around c.
790
853
 
854
+ This is the truncated inner, excitatory lobe of the mexican hat lateral-interaction
855
+ function; cf. Vrieze (1995), p. 85: "In Kohonen networks usually only the inner stimulation
856
+ area is used, i.e., when a neuron i fires, a positive feedback takes place for all neurons
857
+ i', whose distance to i is smaller than some given number rho."
858
+
859
+ Unlike the other neighborhood functions, a radius of zero is admissible here: it selects
860
+ the winner alone, which is well defined, whereas for the gaussian and the mexican hat a
861
+ zero radius is a division by zero.
862
+
791
863
  :param c: (int, int): Center coordinates for gaussian function.
792
- :param sigma: float: Spread variable for gaussian function.
864
+ :param sigma: float: Spread variable for gaussian function. Must be finite and
865
+ non-negative.
793
866
  :return: np.ndarray: Neighborhood matrix, centered in c.
867
+ :raises ValueError: If sigma is negative or not finite.
794
868
  """
869
+ if not np.isfinite(sigma) or sigma < 0:
870
+ raise ValueError(
871
+ "The neighborhood radius 'sigma' must be a finite non-negative number, got "
872
+ + str(sigma)
873
+ )
795
874
  # Convert sigma to integer
796
875
  sigma = int(np.around(sigma))
797
876
 
@@ -813,6 +892,46 @@ class SOM:
813
892
 
814
893
  return np.outer(ax, ay).astype(int)
815
894
 
895
+ def _mexicanhat(self, c: tuple[int, ...], sigma: float) -> np.ndarray:
896
+ """
897
+ Mexican Hat neighborhood function, centered in c. Has support for cyclic arrays.
898
+
899
+ Also known as the Ricker wavelet, or the Laplacian of Gaussian. This is the biologically
900
+ motivated lateral-interaction function: nodes near the winner are excited, nodes beyond a
901
+ certain distance are inhibited, and the inhibition vanishes as the distance grows further
902
+ (Vrieze, 1995, Fig. 3).
903
+
904
+ Implements the isotropic 2-D form as a function of u = sqdist(c, i) / (2 * sigma^2):
905
+
906
+ h_ci = (1 - u) * exp(-u)
907
+
908
+ normalized so that h_cc = 1. It crosses zero at a radius of sqrt(2) * sigma, reaches its
909
+ minimum of -exp(-2) ~= -0.135 at a radius of 2 * sigma, and decays to zero thereafter.
910
+
911
+ Note that this is *not* the outer product of two 1-D Ricker wavelets. Unlike the gaussian,
912
+ the Ricker wavelet is not multiplicatively separable, and such a product is not a function
913
+ of the distance between two nodes: it would be positive in the diagonal quadrants, where
914
+ both 1-D factors are negative, placing a spurious excitatory lobe exactly where the
915
+ function must inhibit. The neighborhood function must depend on the grid distance alone,
916
+ cf. sqdist(c, i) in Kohonen (2013), Eq. (5), and the "lateral distance" abscissa of
917
+ Vrieze (1995), Fig. 3.
918
+
919
+ The '_bubble' neighborhood function is the truncated inner (excitatory) lobe of this same
920
+ lateral-interaction function; cf. Vrieze (1995), p. 85.
921
+
922
+ :param c: (int, int): Center coordinates for mexican hat function.
923
+ :param sigma: float: Spread variable for mexican hat function. Must be positive.
924
+ :return: np.ndarray: Mexican Hat, centered in c, over all the weights of the network.
925
+ :raises ValueError: If sigma is not strictly positive.
926
+
927
+ References:
928
+ O. J. Vrieze, Kohonen network, in: Artificial Neural Networks: An Introduction to ANN
929
+ Theory and Practice, Lecture Notes in Computer Science, vol. 931, Springer, 1995,
930
+ pp. 83-100, https://doi.org/10.1007/BFb0027024.
931
+ """
932
+ u = self._sqdist(c, sigma) / (2 * sigma * sigma)
933
+ return (1.0 - u) * np.exp(-u)
934
+
816
935
  def _data_to_numpy(self, data: np.ndarray | pd.DataFrame | list) -> np.ndarray:
817
936
  """
818
937
  Converts data to numpy array.
@@ -0,0 +1,290 @@
1
+ """Analytic property tests for the neighborhood functions of the self-organizing map.
2
+
3
+ These assert closed-form properties rather than golden values, so they remain meaningful if the
4
+ implementation is rewritten. The central property is *isotropy*: a neighborhood function models
5
+ lateral interaction over the grid, so it must be a function of the distance between two nodes and
6
+ nothing else.
7
+
8
+ References:
9
+ Teuvo Kohonen, Essentials of the self-organizing map, Neural Networks 37 (2013) 52-65,
10
+ https://doi.org/10.1016/j.neunet.2012.09.018 -- Eq. (5), the neighborhood as a function of
11
+ sqdist(c, i).
12
+
13
+ O. J. Vrieze, Kohonen network, in: Artificial Neural Networks: An Introduction to ANN Theory and
14
+ Practice, LNCS 931, Springer, 1995, pp. 83-100, https://doi.org/10.1007/BFb0027024 -- Fig. 3, the
15
+ "Mexican-hat" function of lateral interaction plotted against a single "lateral distance" axis.
16
+ """
17
+
18
+ import numpy as np
19
+ import pytest
20
+
21
+ import python_som
22
+
23
+
24
+ def make_som(
25
+ x=21, y=21, cyclic_x=False, cyclic_y=False, neighborhood_function="gaussian"
26
+ ):
27
+ """Builds a SOM with a fixed seed; only the grid geometry matters for these tests."""
28
+ return python_som.SOM(
29
+ x=x,
30
+ y=y,
31
+ input_len=3,
32
+ neighborhood_function=neighborhood_function,
33
+ cyclic_x=cyclic_x,
34
+ cyclic_y=cyclic_y,
35
+ random_seed=0,
36
+ )
37
+
38
+
39
+ def grid_radius(shape, c):
40
+ """Euclidean distance from c to every node of a non-cyclic grid."""
41
+ dx = np.arange(shape[0]) - c[0]
42
+ dy = np.arange(shape[1]) - c[1]
43
+ return np.sqrt(np.add.outer(dx**2, dy**2))
44
+
45
+
46
+ # --------------------------------------------------------------------------------------------
47
+ # Properties shared by every neighborhood function
48
+ # --------------------------------------------------------------------------------------------
49
+
50
+
51
+ @pytest.mark.parametrize("name", ["gaussian", "bubble", "mexicanhat"])
52
+ def test_shape_matches_the_grid(name):
53
+ som = make_som(neighborhood_function=name)
54
+ assert som._neighborhood_function((10, 10), 3.0).shape == (21, 21)
55
+
56
+
57
+ @pytest.mark.parametrize("name", ["gaussian", "bubble", "mexicanhat"])
58
+ def test_winner_has_maximum_response(name):
59
+ """The winner is maximally excited: h(c, c) is the global maximum."""
60
+ som = make_som(neighborhood_function=name)
61
+ h = som._neighborhood_function((10, 10), 3.0)
62
+ assert h[10, 10] == pytest.approx(h.max())
63
+
64
+
65
+ @pytest.mark.parametrize("name", ["gaussian", "mexicanhat"])
66
+ def test_normalized_to_unity_at_the_winner(name):
67
+ som = make_som(neighborhood_function=name)
68
+ assert som._neighborhood_function((10, 10), 3.0)[10, 10] == pytest.approx(1.0)
69
+
70
+
71
+ @pytest.mark.parametrize("name", ["gaussian", "bubble", "mexicanhat"])
72
+ @pytest.mark.parametrize("sigma", [-1.0, -0.5, float("nan"), float("inf")])
73
+ def test_negative_or_non_finite_sigma_is_rejected(name, sigma):
74
+ """A negative or non-finite radius is meaningless for any neighborhood function."""
75
+ som = make_som(neighborhood_function=name)
76
+ with pytest.raises(ValueError, match="sigma"):
77
+ som._neighborhood_function((10, 10), sigma)
78
+
79
+
80
+ @pytest.mark.parametrize("name", ["gaussian", "mexicanhat"])
81
+ def test_zero_sigma_is_rejected_where_it_divides(name):
82
+ """sigma appears in a denominator, so zero must raise rather than yield inf/nan."""
83
+ som = make_som(neighborhood_function=name)
84
+ with pytest.raises(ValueError, match="sigma"):
85
+ som._neighborhood_function((10, 10), 0.0)
86
+
87
+
88
+ def test_bubble_admits_a_zero_radius():
89
+ """For the bubble a zero radius is well defined: it selects the winner alone."""
90
+ som = make_som(neighborhood_function="bubble")
91
+ h = som._bubble((10, 10), 0.0)
92
+ assert h[10, 10] == 1
93
+ assert h.sum() == 1
94
+
95
+
96
+ @pytest.mark.parametrize("name", ["gaussian", "bubble", "mexicanhat"])
97
+ def test_four_fold_symmetry_about_the_winner(name):
98
+ """h is symmetric under reflection in both axes when the winner is centred."""
99
+ som = make_som(neighborhood_function=name)
100
+ h = som._neighborhood_function((10, 10), 3.0)
101
+ np.testing.assert_allclose(h, h[::-1, :])
102
+ np.testing.assert_allclose(h, h[:, ::-1])
103
+
104
+
105
+ @pytest.mark.parametrize("name", ["gaussian", "bubble", "mexicanhat"])
106
+ def test_is_isotropic(name):
107
+ """h depends only on the grid distance -- the property a separable product violates.
108
+
109
+ Kohonen (2013) Eq. (5) defines the neighborhood over sqdist(c, i); Vrieze (1995) Fig. 3 plots
110
+ it against a single "lateral distance" axis. Nodes equidistant from the winner must therefore
111
+ receive equal responses. An outer product of two 1-D profiles fails this for every profile
112
+ except the gaussian, which is separable only by accident of the exponential.
113
+ """
114
+ som = make_som(neighborhood_function=name)
115
+ h = som._neighborhood_function((10, 10), 3.0)
116
+ r = np.round(grid_radius((21, 21), (10, 10)), 9)
117
+ for radius in np.unique(r):
118
+ responses = h[r == radius]
119
+ np.testing.assert_allclose(responses, responses[0], atol=1e-12)
120
+
121
+
122
+ # --------------------------------------------------------------------------------------------
123
+ # Gaussian
124
+ # --------------------------------------------------------------------------------------------
125
+
126
+
127
+ def test_gaussian_matches_its_closed_form():
128
+ som = make_som()
129
+ sigma = 3.0
130
+ h = som._gaussian((10, 10), sigma)
131
+ expected = np.exp(-(grid_radius((21, 21), (10, 10)) ** 2) / (2 * sigma**2))
132
+ np.testing.assert_allclose(h, expected)
133
+
134
+
135
+ def test_gaussian_is_strictly_positive_and_decreasing():
136
+ som = make_som()
137
+ h = som._gaussian((10, 10), 3.0)
138
+ assert (h > 0).all()
139
+ centre_row = h[10, 10:]
140
+ assert (np.diff(centre_row) < 0).all()
141
+
142
+
143
+ # --------------------------------------------------------------------------------------------
144
+ # Mexican hat -- the properties that define the shape
145
+ # --------------------------------------------------------------------------------------------
146
+
147
+
148
+ def test_mexican_hat_crosses_zero_at_sqrt2_sigma():
149
+ """(1 - u) exp(-u) with u = r^2/(2 sigma^2) vanishes at u = 1, i.e. r = sqrt(2) sigma."""
150
+ som = make_som(x=41, y=41)
151
+ sigma = 4.0
152
+ h = som._mexicanhat((20, 20), sigma)
153
+ r = grid_radius((41, 41), (20, 20))
154
+ inside = r < np.sqrt(2) * sigma - 1e-9
155
+ outside_but_near = (r > np.sqrt(2) * sigma + 1e-9) & (r < 4 * sigma)
156
+ assert (h[inside] > 0).all()
157
+ assert (h[outside_but_near] < 0).all()
158
+
159
+
160
+ def test_mexican_hat_minimum_is_minus_exp_minus_two_at_two_sigma():
161
+ """d/du [(1-u) e^-u] = 0 at u = 2, giving h = -e^-2 at r = 2 sigma."""
162
+ som = make_som(x=41, y=41)
163
+ sigma = 4.0
164
+ h = som._mexicanhat((20, 20), sigma)
165
+ r = grid_radius((41, 41), (20, 20))
166
+ assert h.min() == pytest.approx(-np.exp(-2.0), rel=1e-9)
167
+ assert r.flat[h.argmin()] == pytest.approx(2 * sigma, rel=1e-9)
168
+
169
+
170
+ def test_mexican_hat_is_inhibitory_on_the_diagonal():
171
+ """Regression for the separable outer product.
172
+
173
+ An outer product of two 1-D Ricker wavelets is positive wherever both factors are negative,
174
+ putting a spurious excitatory lobe on the diagonals. Measured on this exact grid, that
175
+ construction gives +0.165 at (c+2s, c+2s) where the isotropic form gives -0.055.
176
+ """
177
+ som = make_som()
178
+ sigma = 3.0
179
+ h = som._mexicanhat((10, 10), sigma)
180
+ assert h[16, 16] < 0
181
+ assert h[16, 16] == pytest.approx(-0.054946916666, rel=1e-9)
182
+ assert h[19, 19] < 0
183
+
184
+
185
+ def test_mexican_hat_has_no_positive_lobe_beyond_the_zero_crossing():
186
+ """Excitation is confined to the inner disc; everything beyond it inhibits or vanishes."""
187
+ som = make_som(x=41, y=41)
188
+ sigma = 4.0
189
+ h = som._mexicanhat((20, 20), sigma)
190
+ r = grid_radius((41, 41), (20, 20))
191
+ assert h[r > np.sqrt(2) * sigma + 1e-9].max() <= 0.0
192
+
193
+
194
+ def test_mexican_hat_differs_from_the_separable_product():
195
+ """Guards against a regression to np.outer of two 1-D wavelets."""
196
+ som = make_som()
197
+ sigma = 3.0
198
+ dx = np.arange(21) - 10
199
+ ax = (1 - dx**2 / sigma**2) * np.exp(-(dx**2) / (2 * sigma**2))
200
+ separable = np.outer(ax, ax)
201
+ h = som._mexicanhat((10, 10), sigma)
202
+ assert not np.allclose(h, separable)
203
+ # The decisive difference: on the diagonal the separable product is excitatory where the
204
+ # isotropic form inhibits, because there both of its 1-D factors are negative.
205
+ assert separable[16, 16] > 0 > h[16, 16]
206
+ assert separable[16, 16] == pytest.approx(0.164840749998, rel=1e-9)
207
+ # The separable product is also anisotropic: (10, 14) and (13, 13) are not equidistant
208
+ # in its value despite both lying at grid radius 4 and 3*sqrt(2) respectively.
209
+ assert separable[10, 6] != pytest.approx(separable[13, 13])
210
+
211
+
212
+ # --------------------------------------------------------------------------------------------
213
+ # Bubble
214
+ # --------------------------------------------------------------------------------------------
215
+
216
+
217
+ def test_bubble_is_the_indicator_of_a_neighbourhood_set():
218
+ """Vrieze (1995) p. 85: N_i = {i' : d(i, i') <= rho}, the truncated inner lobe."""
219
+ som = make_som()
220
+ h = som._bubble((10, 10), 2.0)
221
+ assert set(np.unique(h)) <= {0, 1}
222
+ expected = np.zeros((21, 21), dtype=int)
223
+ expected[8:13, 8:13] = 1
224
+ np.testing.assert_array_equal(h, expected)
225
+
226
+
227
+ # --------------------------------------------------------------------------------------------
228
+ # Cyclic (toroidal) grids
229
+ # --------------------------------------------------------------------------------------------
230
+
231
+
232
+ @pytest.mark.parametrize("name", ["gaussian", "mexicanhat"])
233
+ def test_cyclic_wraps_from_both_edges(name):
234
+ """Regression for the one-sided fold-back.
235
+
236
+ The original fold only handled dx > shape/2, leaving offsets below -shape/2 unfolded. A winner
237
+ at row 0 wrapped correctly (h[9] = 0.6065) while a winner at row 9 did not (h[0] = 0.0).
238
+ """
239
+ som = make_som(x=10, y=10, cyclic_x=True, cyclic_y=True, neighborhood_function=name)
240
+ low = som._neighborhood_function((0, 5), 1.0)[:, 5]
241
+ high = som._neighborhood_function((9, 5), 1.0)[:, 5]
242
+ assert low[9] == pytest.approx(low[1])
243
+ assert high[0] == pytest.approx(high[8])
244
+ np.testing.assert_allclose(high, np.roll(low, 9))
245
+
246
+
247
+ @pytest.mark.parametrize("name", ["gaussian", "mexicanhat"])
248
+ def test_cyclic_response_is_translation_invariant(name):
249
+ """On a torus every node is equivalent, so h depends only on the offset from the winner."""
250
+ som = make_som(x=12, y=12, cyclic_x=True, cyclic_y=True, neighborhood_function=name)
251
+ reference = som._neighborhood_function((0, 0), 2.0)
252
+ for cx in range(12):
253
+ for cy in range(12):
254
+ shifted = som._neighborhood_function((cx, cy), 2.0)
255
+ np.testing.assert_allclose(
256
+ shifted, np.roll(reference, (cx, cy), axis=(0, 1))
257
+ )
258
+
259
+
260
+ def test_non_cyclic_grid_does_not_wrap():
261
+ som = make_som(x=10, y=10)
262
+ h = som._gaussian((0, 5), 1.0)[:, 5]
263
+ assert h[9] < 1e-12
264
+
265
+
266
+ # --------------------------------------------------------------------------------------------
267
+ # Selection and guards at the SOM level
268
+ # --------------------------------------------------------------------------------------------
269
+
270
+
271
+ def test_unknown_neighborhood_function_raises_value_error():
272
+ with pytest.raises(ValueError, match="neighborhood_function"):
273
+ python_som.SOM(x=8, y=8, input_len=3, neighborhood_function="mexican_hat")
274
+
275
+
276
+ def test_batch_training_rejects_the_mexican_hat():
277
+ """Kohonen Eq. (8) divides by sum_j n_j h_ji, which is not sign-definite for a signed h."""
278
+ rng = np.random.default_rng(0)
279
+ data = rng.normal(size=(30, 3))
280
+ som = make_som(x=12, y=12, neighborhood_function="mexicanhat")
281
+ with pytest.raises(ValueError, match="batch"):
282
+ som.train(data, n_iteration=1, mode="batch")
283
+
284
+
285
+ @pytest.mark.parametrize("mode", ["random", "sequential"])
286
+ def test_stepwise_training_accepts_the_mexican_hat(mode):
287
+ rng = np.random.default_rng(0)
288
+ som = make_som(x=12, y=12, neighborhood_function="mexicanhat")
289
+ som.train(rng.normal(size=(30, 3)), n_iteration=30, mode=mode)
290
+ assert np.isfinite(som.get_weights()).all()
File without changes
File without changes
File without changes
File without changes