python-som 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

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.
python_som/__init__.py CHANGED
@@ -1,944 +1,58 @@
1
- """This module contains the implementation of the 2D self-organizing map.
1
+ """Python implementation of Kohonen's 2-D self-organizing map.
2
2
 
3
- Most features were implemented using NumPy, with Scikit-learn for standardization and PCA.
3
+ The public surface is :class:`SOM`, plus the decay, distance and neighborhood functions that can be
4
+ passed to it or used directly.
4
5
 
5
- Features:
6
- - Stepwise and batch training
7
- - Random weight initialization
8
- - Random sampling weight initialization
9
- - Linear weight initialization (with PCA)
10
- - Automatic selection of map size ratio (with PCA)
11
- - Support for cyclic arrays, for toroidal or spherical maps
12
- - Gaussian, Bubble and Mexican Hat neighborhood functions
13
- - Support for custom decay functions
14
- - Support for visualization (U-matrix, activation matrix)
15
- - Support for supervised learning (label map)
16
- - Support for NumPy arrays, Pandas DataFrames and regular lists of values
6
+ >>> import numpy as np, python_som
7
+ >>> data = np.random.default_rng(0).normal(size=(150, 4))
8
+ >>> som = python_som.SOM(x=10, y=10, input_len=4, random_seed=0)
9
+ >>> som.weight_initialization(mode="linear", data=data)
10
+ >>> error = som.train(data, n_iteration=100, mode="batch")
17
11
 
18
12
  Reference:
19
- Teuvo Kohonen,
20
- Essentials of the self-organizing map,
21
- Neural Networks,
22
- Volume 37,
23
- 2013,
24
- Pages 52-65,
25
- ISSN 0893-6080,
26
- https://doi.org/10.1016/j.neunet.2012.09.018.
13
+ Teuvo Kohonen, Essentials of the self-organizing map, Neural Networks 37 (2013) 52-65,
14
+ ISSN 0893-6080, https://doi.org/10.1016/j.neunet.2012.09.018
27
15
  """
28
16
 
29
- # %%
30
- from collections import Counter
31
- from typing import Callable
32
-
33
- import numpy as np
34
- import pandas as pd
35
- import sklearn # type: ignore
36
- import sklearn.decomposition # type: ignore
37
- import sklearn.preprocessing # type: ignore
38
-
39
- try:
40
- import tqdm
41
-
42
- TQDM_AVAILABLE = True
43
- except ImportError:
44
- TQDM_AVAILABLE = False
45
-
46
-
47
- # %%
48
- def _asymptotic_decay(x: float, t: int, max_t: int) -> float:
49
- """
50
- Asymptotic decay function. Can be used for both the learning_rate or the neighborhood_radius.
51
-
52
- :param x: float: Initial x parameter
53
- :param t: int: Current iteration
54
- :param max_t: int: Maximum number of iterations
55
- :return: float: Current state of x after t iterations
56
- """
57
- return x / (1 + t / (max_t / 2))
58
-
59
-
60
- def _linear_decay(x: float, t: int, max_t: int) -> float:
61
- """
62
- Linear decay function. Can be used for both the learning_rate or the neighborhood_radius.
63
-
64
- :param x: float: Initial x parameter
65
- :param t: int: Current iteration
66
- :param max_t: int: Maximum number of iterations
67
- :return: float: Current state of x after t iterations
68
- """
69
- return x * (1.0 - t / max_t)
70
-
71
-
72
- def _exponential_decay(x: float, t: int, max_t: int, factor: float = 2.0) -> float:
73
- """
74
- Exponential decay function. Can be used for both the learning_rate or the neighborhood_radius.
75
-
76
- :param x: float: Initial x parameter
77
- :param t: int: Current iteration
78
- :param max_t: int: Maximum number of iterations
79
- :param factor: float: Exponential decay factor. Defaults to 2.0.
80
- :return: float: Current state of x after t iterations
81
- """
82
- return x * (1 - (factor / max_t)) ** t
83
-
84
-
85
- def _inverse_decay(x: float, t: int, max_t: int) -> float:
86
- """
87
- Inverse decay function. Can be used for both the learning_rate or the neighborhood_radius.
88
-
89
- :param x: float: Initial x parameter
90
- :param t: int: Current iteration
91
- :param max_t: int: Maximum number of iterations
92
- :return: float: Current state of x after t iterations
93
- """
94
- return (max_t / 100) * x / ((max_t / 100) + t)
95
-
96
-
97
- def _euclidean_distance(a: np.ndarray, b: np.ndarray) -> np.ndarray:
98
- """This function calculates the euclidean distances between the elements of the last dimension
99
- of a and b.
100
-
101
- The parameters a and b may be n-dimensional, but their shapes must be capable of broadcasting
102
-
103
- The shape of the output complies to the first n-1 dimensions of the parameter with the highest
104
- dimensionality.
105
-
106
- :param a: array-like: list or numpy array of values. a must not be a scalar value.
107
- :param b: array-like: list or numpy array of values. b must not be a scalar value.
108
- :return: array-like: An array of euclidean distances between a and b.
109
- """
110
- return np.linalg.norm(np.subtract(a, b), ord=2, axis=-1)
111
-
112
-
113
- class SOM:
114
- """Implementation of the 2D self-organizing map, with support for NumPy arrays and
115
- Pandas DataFrames.
116
-
117
- Most features were implemented using NumPy, with Scikit-learn for standardization and
118
- PCA operations.
119
-
120
- Features:
121
- - Stepwise and batch training
122
- - Random weight initialization
123
- - Random sampling weight initialization
124
- - Linear weight initialization (with PCA)
125
- - Automatic selection of map size ratio (with PCA)
126
- - Support for cyclic arrays, for toroidal or spherical maps
127
- - Gaussian, Bubble and Mexican hat neighborhood functions
128
- - Support for custom decay functions
129
- - Support for visualization (U-matrix, activation matrix)
130
- - Support for supervised learning (label map)
131
- - Support for NumPy arrays, Pandas DataFrames and regular lists of values
132
-
133
- Reference:
134
- Teuvo Kohonen,
135
- Essentials of the self-organizing map,
136
- Neural Networks,
137
- Volume 37,
138
- 2013,
139
- Pages 52-65,
140
- ISSN 0893-6080,
141
- https://doi.org/10.1016/j.neunet.2012.09.018.
142
- """
143
-
144
- def __init__(
145
- self,
146
- x: int | None,
147
- y: int | None,
148
- input_len: int,
149
- learning_rate: float = 0.5,
150
- learning_rate_decay: Callable[[float, int, int], float] = _asymptotic_decay,
151
- neighborhood_radius: float = 1.0,
152
- neighborhood_radius_decay: Callable[
153
- [float, int, int], float
154
- ] = _asymptotic_decay,
155
- neighborhood_function: str = "gaussian",
156
- distance_function: Callable[
157
- [np.ndarray, np.ndarray],
158
- np.ndarray,
159
- ] = _euclidean_distance,
160
- cyclic_x: bool = False,
161
- cyclic_y: bool = False,
162
- random_seed: int | None = None,
163
- data: np.ndarray | pd.DataFrame | list | None = None,
164
- ) -> None:
165
- """
166
- Constructor for the self-organizing map class.
167
-
168
- :param x: int or NoneType: X dimension of the self-organizing map, i.e.,
169
- number of rows of the matrix of weights.
170
- x should be larger than 0.
171
- If x is None and 'data' is provided in kwargs, its value will be automatically
172
- selected using PCA of 'data'. Either x or y should be different than None.
173
- :param y: int or NoneType: Y dimension of the self-organizing map, i.e.,
174
- number of columns of the matrix of weights.
175
- y should be larger than 0.
176
- If y is None and 'data' is provided in kwargs, its value will be automatically
177
- selected using PCA of 'data'. Either x or y should be different than None.
178
- :param input_len: int: Number of features of the training dataset, i.e.,
179
- number of elements of each node of the network.
180
- :param learning_rate: float: Initial learning rate for the training process.
181
- Should be a positive floating point value.
182
- Defaults to 0.5.
183
- Note: The value of the learning_rate is irrelevant for the 'batch' training mode.
184
- :param learning_rate_decay: function: Decay function for the learning_rate variable.
185
- May be a predefined one from this package, or a custom function, with the same
186
- parameters and return type. Defaults to _asymptotic_decay.
187
- :param neighborhood_radius: float: Initial neighborhood radius for the training process.
188
- Defaults to 1.
189
- :param neighborhood_radius_decay: function: Decay function for the neighborhood_radius
190
- variable. May be a predefined one from this package, or a custom function, with the
191
- same parameters and return type. Defaults to _asymptotic_decay
192
- :param neighborhood_function: str: Neighborhood function name for the training process.
193
- May be either 'gaussian', 'bubble' or 'mexicanhat'.
194
- :param distance_function: function: Function for calculating distances/dissimilarities
195
- between models of the network.
196
- May be a predefined one from this package, or a custom function, with the same
197
- parameters and return type. Defaults to _euclidean_distance.
198
- :param cyclic_x: bool: Boolean value activate/deactivate cyclic arrays in the x direction,
199
- i.e, between the first and last rows of the weight matrix.
200
- Defaults to False.
201
- :param cyclic_y: bool: Boolean value activate/deactivate cyclic arrays in the y direction,
202
- i.e, between the first and last columns of the weight matrix.
203
- Defaults to False.
204
- :param random_seed: int or None: Seed for NumPy random value generator. Defaults to None.
205
- :param data: array-like: dataset for performing PCA.
206
- Required when either x or y is None, for determining map size.
207
- """
208
- # Verifying map dimensions (initializing automatically, if a dataset is provided)
209
- if (x, y) == (None, None):
210
- raise ValueError("At least one of the dimensions (x, y) must be specified")
211
- if x is None or y is None:
212
- # If a dataset was given through **kwargs, select missing dimension with PCA
213
- # The ratio of the (x, y) sizes will comply roughly with the ratio of
214
- # the two largest principal components
215
- if data is None:
216
- raise ValueError(
217
- "If one of the dimensions is not specified,"
218
- "a dataset must be provided for automatic size initialization."
219
- )
220
- # Convert data to numpy array
221
- if isinstance(data, pd.DataFrame):
222
- data_array = data.to_numpy()
223
- else:
224
- data_array = np.array(data)
225
- # Perform PCA w/ sklearn
226
- data_array = sklearn.preprocessing.StandardScaler().fit_transform(
227
- data_array
228
- )
229
- pca = sklearn.decomposition.PCA(n_components=2)
230
- pca.fit(data_array)
231
- ratio = pca.explained_variance_[0] / pca.explained_variance_[1]
232
- # Update missing size variable
233
- if x is None:
234
- x = y // ratio
235
- if y is None:
236
- y = x // ratio
237
-
238
- # Initializing private variables
239
- self._shape = (np.uint(x), np.uint(y))
240
- self._input_len = np.uint(input_len)
241
- self._learning_rate = float(learning_rate)
242
- self._learning_rate_decay = learning_rate_decay
243
- self._neighborhood_radius = float(neighborhood_radius)
244
- self._neighborhood_radius_decay = neighborhood_radius_decay
245
- neighborhood_functions = {
246
- "gaussian": self._gaussian,
247
- "bubble": self._bubble,
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
258
- self._distance_function = distance_function
259
- self._cyclic = (bool(cyclic_x), bool(cyclic_y))
260
- self._neigx, self._neigy = np.arange(self._shape[0]), np.arange(self._shape[1])
261
-
262
- # Seed numpy random generator
263
- if random_seed is None:
264
- self._random_seed = np.random.randint(np.iinfo(np.int32).max)
265
- else:
266
- self._random_seed = int(random_seed)
267
- np.random.seed(self._random_seed)
268
-
269
- # Random weight initialization
270
- self._weights = np.random.standard_normal(
271
- size=(self._shape[0], self._shape[1], self._input_len)
272
- )
273
-
274
- def get_shape(self) -> tuple[np.uint, np.uint]:
275
- """
276
- Gets the shape of the network.
277
-
278
- :return: tuple(int, int): Shape of the network.
279
- """
280
- return self._shape
281
-
282
- def get_weights(self) -> np.ndarray:
283
- """
284
- Gets the weight matrix of the network.
285
-
286
- :return: np.ndarray: Weight matrix of the network.
287
- """
288
- return self._weights
289
-
290
- def set_learning_rate(self, learning_rate: float) -> None:
291
- """
292
- Sets the learning_rate member of the SOM.
293
-
294
- :param learning_rate: float: New value for learning_rate of an instance of the SOM.
295
- """
296
- self._learning_rate = float(learning_rate)
297
-
298
- def set_neighborhood_radius(self, neighborhood_radius: float) -> None:
299
- """
300
- Sets the neighborhood_radius member of the SOM.
301
-
302
- :param neighborhood_radius: float: New value for neighborhood_radius of a SOM instance.
303
- """
304
- self._neighborhood_radius = float(neighborhood_radius)
305
-
306
- def activate(self, x: np.ndarray) -> np.ndarray:
307
- """
308
- Calculates distances between an instance x and the weights of the network.
309
-
310
- :param x: array-like: Instance to be compared with the weights of the network.
311
- :return: np.ndarray: Distances between x and each weight of the network.
312
- """
313
- return self._distance_function(x, self._weights)
314
-
315
- def winner(self, x: np.ndarray) -> tuple[int, ...]:
316
- """
317
- Calculates the best-matching unit of the network for an instance x
318
-
319
- :param x: array-like: Instance to be compared with the weights of the network.
320
- :return: (int, int): Index of the best-matching unit of x.
321
- """
322
- activation_map = self.activate(x)
323
- min_index = tuple(
324
- map(int, np.unravel_index(activation_map.argmin(), activation_map.shape))
325
- )
326
- return min_index
327
-
328
- def quantization(self, data: np.ndarray | pd.DataFrame | list) -> np.ndarray:
329
- """
330
- Calculates distances from each instance of 'data' to each of the weights of the network.
331
-
332
- :param data: array-like: Dataset to be compared with the weights of the network.
333
- Expected shape is (n_samples, n_features).
334
- :return: np.ndarray: array of lists of distances from each instance of the dataset
335
- to each weight of the network.
336
- """
337
- # Convert data to numpy array
338
- data_array = self._data_to_numpy(data)
339
- return np.array(
340
- [
341
- (self._distance_function(i, self._weights[self.winner(i)]))
342
- for i in data_array
343
- ]
344
- )
345
-
346
- def quantization_error(self, data: np.ndarray | pd.DataFrame | list) -> float:
347
- """Calculates average distance of the weights of the network to their assigned instances.
348
- This error is a quality measure for the training process.
349
-
350
- :param data: array-like: Dataset to be compared with the weights of the network.
351
- :return: float: Quantization error.
352
- """
353
- quantization = self.quantization(data)
354
- return quantization.mean()
355
-
356
- def distance_matrix(self, normalize: bool = False) -> np.ndarray:
357
- """
358
- Calculates U-matrix of the current state of the network,
359
- i.e., the matrix of distances between each node and its neighbors.
360
- Has support for cyclic arrays
361
-
362
- :param normalize: bool: Activate to normalize the U-matrix between 0 and 1.
363
- Defaults to False.
364
- :return: np.ndarray: U-matrix of the current state of the network.
365
- """
366
- um = np.zeros(shape=self._shape)
367
- it = np.nditer(um, flags=["multi_index"])
368
- distances = np.zeros(self._shape + self._shape)
369
- for i in range(self._shape[0]):
370
- for j in range(self._shape[1]):
371
- distances[i, j] = self._distance_function(
372
- self._weights[i, j], self._weights
373
- )
374
-
375
- while not it.finished:
376
- update_matrix = self._bubble(it.multi_index, 1)
377
- um[it.multi_index] = np.sum(update_matrix * distances[it.multi_index])
378
- it.iternext()
379
- if normalize:
380
- # Normalize U-matrix between 0 and 1
381
- um = (um - np.min(um)) / (np.max(um) - np.min(um))
382
- return um
383
-
384
- def activation_matrix(self, data: np.ndarray | pd.DataFrame | list) -> np.ndarray:
385
- """Calculates the activation matrix of the network for a dataset.
386
-
387
- I.e., for each node, the count of instances that have been assigned to in,
388
- in the current state.
389
-
390
- :param data: array-like: Dataset to be compared with the weights of the network.
391
- :return: np.ndarray: Activation matrix.
392
- """
393
- # Convert data to numpy array
394
- data_array = self._data_to_numpy(data)
395
-
396
- activation_matrix = np.zeros(self._shape)
397
- for i in data_array:
398
- activation_matrix[self.winner(i)] += 1
399
- return activation_matrix
400
-
401
- def winner_map(self, data: np.ndarray | pd.DataFrame | list) -> dict:
402
- """
403
- Calculates, for each node (i, j) of the network,
404
- the list of all instances from 'data' that has been assigned to it.
405
-
406
- :param data: array-like: Dataset to be compared with the weights of the network.
407
- :return: dict: Winner map.
408
- """
409
- # Convert data to numpy array
410
- data_array = self._data_to_numpy(data)
411
- winner_map: dict[tuple[int, ...], list] = {
412
- tuple(index): [] for index in np.ndindex(self._shape)
413
- }
414
- for i in data_array:
415
- winner_map[self.winner(i)].append(i)
416
- return winner_map
417
-
418
- def label_map(
419
- self,
420
- data: np.ndarray | pd.DataFrame | list,
421
- labels: np.ndarray | pd.DataFrame | list,
422
- ) -> dict[tuple[int, ...], Counter]:
423
- """Calculates, for each node (i, j) of the network, the frequency of each label...
424
-
425
- from 'labels' corresponding to its respective instance from 'data' that has been assigned
426
- to this node.
427
-
428
- :param data: array-like: Dataset to be compared with the weights of the network.
429
- :param labels: array-like: Labels corresponding to the indices of 'data'.
430
- :return: dict: Label map.
431
- """
432
- # Convert data to numpy array
433
- data_array = self._data_to_numpy(data)
434
- # Convert labels to numpy array
435
- if isinstance(labels, pd.DataFrame):
436
- labels = labels.to_numpy()
437
- else:
438
- labels = np.array(labels)
439
-
440
- winner_map: dict[tuple[int, ...], list] = {
441
- tuple(index): [] for index in np.ndindex(self._shape)
442
- }
443
- label_count_map: dict[tuple[int, ...], Counter] = {
444
- tuple(index): Counter() for index in np.ndindex(self._shape)
445
- }
446
- for i, instance in enumerate(data_array):
447
- winner = self.winner(instance)
448
- winner_map[winner].append(labels[i])
449
- label_count_map[winner].update([labels[i]])
450
- return label_count_map
451
-
452
- def train(
453
- self,
454
- data: np.ndarray | pd.DataFrame | list,
455
- n_iteration: int | None = None,
456
- mode: str = "random",
457
- verbose: bool = False,
458
- ) -> float:
459
- """
460
- Trains the self-organizing map, with the dataset 'data', and a certain number of iterations.
461
-
462
- :param data: array-like: Dataset for training.
463
- :param n_iteration: int or None: Number of iterations of training.
464
- If None, defaults to 1000 * len(data) for stepwise training modes,
465
- or 10 * len(data) for batch training mode.
466
- :param mode: str: Training mode name. May be either 'random', 'sequential', or 'batch'.
467
- For 'batch' mode, a much smaller number of iterations is needed,
468
- but a higher computation power is required for each individual iteration.
469
- :param verbose: bool: Activate to print useful information to the terminal/console, e.g.,
470
- the progress of the training process
471
- :return: float: Quantization error after training
472
- """
473
- # Convert data to numpy array for training
474
- data_array = self._data_to_numpy(data)
475
-
476
- # If no number of iterations is given, select automatically
477
- if n_iteration is None:
478
- n_iteration = {"random": 1000, "sequential": 1000, "batch": 10}[mode] * len(
479
- data_array
480
- )
481
-
482
- if verbose:
483
- print(
484
- "Training with",
485
- n_iteration,
486
- "iterations.\nTraining mode:",
487
- mode,
488
- sep=" ",
489
- )
490
-
491
- if mode == "random":
492
- self._train_random(data_array, n_iteration, verbose)
493
- elif mode == "sequential":
494
- self._train_sequential(data_array, n_iteration, verbose)
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
- )
508
- self._train_batch(data_array, n_iteration, verbose)
509
- else:
510
- # Invalid training mode value
511
- raise ValueError(
512
- "Invalid value for 'mode' parameter. Value should be in "
513
- + str(["random", "sequential", "batch"])
514
- )
515
-
516
- # Compute quantization error
517
- q_error = self.quantization_error(data_array)
518
- if verbose:
519
- print("Quantization error:", q_error, sep=" ")
520
- return q_error
521
-
522
- def _train_random(
523
- self, data_array: np.ndarray, n_iteration: int, verbose: bool
524
- ) -> None:
525
- if TQDM_AVAILABLE and verbose:
526
- iterator: enumerate | tqdm.tqdm = tqdm.tqdm(
527
- np.random.choice(
528
- len(data_array),
529
- size=n_iteration,
530
- replace=(n_iteration > len(data_array)),
531
- ),
532
- total=n_iteration,
533
- desc="Training",
534
- )
535
- else:
536
- iterator = enumerate(
537
- np.random.choice(
538
- len(data_array),
539
- size=n_iteration,
540
- replace=(n_iteration > len(data_array)),
541
- )
542
- )
543
-
544
- for it, i in iterator: # type: ignore
545
- # Calculating decaying alpha and sigma parameters for updating weights
546
- alpha = self._learning_rate_decay(self._learning_rate, it, n_iteration)
547
- sigma = self._neighborhood_radius_decay(
548
- self._neighborhood_radius, it, n_iteration
549
- )
550
-
551
- # Finding winner node (best-matching unit)
552
- winner = self.winner(data_array[i])
553
-
554
- # Updating weights, based on current neighborhood function
555
- self._weights += (
556
- alpha
557
- * self._neighborhood_function(winner, sigma)[..., None]
558
- * (data_array[i] - self._weights)
559
- )
560
-
561
- # Print progress, if verbose is activated and tqdm is not available
562
- if verbose and not TQDM_AVAILABLE:
563
- print(
564
- "Iteration:",
565
- it,
566
- "/",
567
- n_iteration,
568
- sep=" ",
569
- end="\r",
570
- flush=True,
571
- )
572
-
573
- def _train_sequential(
574
- self, data_array: np.ndarray, n_iteration: int, verbose: bool
575
- ) -> None:
576
- if TQDM_AVAILABLE and verbose:
577
- iterator: enumerate | tqdm.tqdm = tqdm.tqdm(
578
- enumerate(data_array),
579
- total=n_iteration,
580
- desc="Training",
581
- )
582
- else:
583
- iterator = enumerate(data_array)
584
-
585
- for it, i in iterator: # type: ignore
586
- # Calculating decaying alpha and sigma parameters for updating weights
587
- alpha = self._learning_rate_decay(self._learning_rate, it, n_iteration)
588
- sigma = self._neighborhood_radius_decay(
589
- self._neighborhood_radius, it, n_iteration
590
- )
591
-
592
- # Finding winner node (best-matching unit)
593
- winner = self.winner(i)
594
-
595
- # Updating weights, based on current neighborhood function
596
- self._weights += (
597
- alpha
598
- * self._neighborhood_function(winner, sigma)[..., None]
599
- * (i - self._weights)
600
- )
601
-
602
- # Print progress, if verbose is activated and tqdm is not available
603
- if verbose and not TQDM_AVAILABLE:
604
- print(
605
- "Iteration:",
606
- it,
607
- "/",
608
- n_iteration,
609
- sep=" ",
610
- end="\r",
611
- flush=True,
612
- )
613
-
614
- def _train_batch(
615
- self, data_array: np.ndarray, n_iteration: int, verbose: bool
616
- ) -> None:
617
- if TQDM_AVAILABLE and verbose:
618
- iterator: range | tqdm.tqdm = tqdm.tqdm(
619
- range(n_iteration), total=n_iteration, desc="Training"
620
- )
621
- else:
622
- iterator = range(n_iteration)
623
-
624
- for it in iterator:
625
- # Calculating decaying sigma
626
- sigma = self._neighborhood_radius_decay(
627
- self._neighborhood_radius, it, n_iteration
628
- )
629
-
630
- # For each node, create a list of instances associated to it
631
- winner_map = self.winner_map(data_array)
632
-
633
- # Calculate the weighted average of all instances in the neighborhood of each node
634
- new_weights = np.zeros(self._weights.shape)
635
- for i in winner_map.keys():
636
- neig = self._neighborhood_function(i, sigma)
637
- upper, bottom = np.zeros(self._input_len), 0.0
638
- for j in winner_map.keys():
639
- upper += neig[j] * np.sum(winner_map[j], axis=0)
640
- bottom += neig[j] * len(winner_map[j])
641
-
642
- # Only update if there is any instance associated with the winner node
643
- # or its neighbors
644
- if bottom != 0:
645
- new_weights[i] = upper / bottom
646
-
647
- # Update all nodes concurrently
648
- self._weights = new_weights
649
-
650
- # Print progress, if verbose is activated and tqdm is not available
651
- if verbose and not TQDM_AVAILABLE:
652
- print(
653
- "Iteration:",
654
- it,
655
- "/",
656
- n_iteration,
657
- sep=" ",
658
- end="\r",
659
- flush=True,
660
- )
661
-
662
- def weight_initialization(
663
- self,
664
- mode: str = "random",
665
- **kwargs: np.ndarray | pd.DataFrame | list | str | int,
666
- ) -> None:
667
- """Function for weight initialization of the self-organizing map.
668
-
669
- Calls other methods for each initialization mode.
670
-
671
- :param mode: str: Initialization mode. May be either 'random', 'linear', or 'sample'.
672
- Note: Each initialization method may require multiple additional arguments in kwargs.
673
- :param kwargs:
674
- For 'random' initialization mode, 'sample_mode': str may be provided to determine
675
- the sampling mode. 'sample_mode' may be either 'standard_normal' (default) or 'uniform'
676
- For 'random' and 'sample' modes, 'random_seed': int may be provided for the random
677
- value generator. For 'sample' and 'linear' modes, 'data': array-like must be provided
678
- for sampling/PCA.
679
- """
680
- modes: dict[str, Callable[..., None]] = {
681
- "random": self._weight_initialization_random,
682
- "linear": self._weight_initialization_linear,
683
- "sample": self._weight_initialization_sample,
684
- }
685
- try:
686
- modes[mode](**kwargs)
687
- except KeyError as exc:
688
- raise ValueError(
689
- "Invalid value for 'mode' parameter. Value should be in "
690
- + str(modes.keys())
691
- ) from exc
692
-
693
- def _weight_initialization_random(
694
- self, sample_mode: str = "standard_normal", random_seed: int | None = None
695
- ) -> None:
696
- """Random initialization method.
697
-
698
- Assigns weights from a random distribution defined by 'sample_mode'.
699
-
700
- :param sample_mode: str: Distribution for random sampling.
701
- May be either 'uniform' or 'standard_normal'.
702
- Defaults to 'standard_normal'.
703
- :param random_seed: int or None: Seed for NumPy random value generator. Defaults to None.
704
- """
705
- sample_modes = {
706
- "uniform": np.random.random,
707
- "standard_normal": np.random.standard_normal,
708
- }
709
-
710
- # Seed numpy random generator
711
- if random_seed is None:
712
- random_seed = np.random.randint(np.iinfo(np.int32).max)
713
- else:
714
- random_seed = int(random_seed)
715
- np.random.seed(random_seed)
716
-
717
- # Initialize weights randomly
718
- try:
719
- self._weights = sample_modes[sample_mode](size=self._weights.shape)
720
- except KeyError as exc:
721
- raise ValueError(
722
- "Invalid value for 'sample_mode' parameter. Value should be in "
723
- + str(sample_modes.keys())
724
- ) from exc
725
-
726
- def _weight_initialization_linear(
727
- self, data: np.ndarray | pd.DataFrame | list
728
- ) -> None:
729
- """Linear initialization method.
730
-
731
- Assigns weights spanning the hyperplane formed by the two first principal
732
- components of 'data'.
733
-
734
- This is the recommended initialization method, as it may lead to a faster convergence.
735
- Unlike other initialization modes, this method is deterministic based on the input dataset.
736
-
737
- :param data: array-like: Dataset for weight initialization w/ PCA.
738
- """
739
- # Convert data to numpy array for training
740
- data_array = self._data_to_numpy(data)
741
-
742
- # Perform PCA w/ sklearn
743
- data_array = sklearn.preprocessing.StandardScaler().fit_transform(data_array)
744
- pca = sklearn.decomposition.PCA(n_components=2)
745
- pca.fit(data_array)
746
- # Initialize weights spanning first 2 principal components of data
747
- for i, c1 in enumerate(np.linspace(-1, 1, num=self._shape[0])):
748
- for j, c2 in enumerate(np.linspace(-1, 1, num=self._shape[1])):
749
- self._weights[i, j] = (
750
- c1 * pca.explained_variance_[0] + c2 * pca.explained_variance_[1]
751
- )
752
-
753
- def _weight_initialization_sample(
754
- self,
755
- data: np.ndarray | pd.DataFrame | list,
756
- random_seed: int | None = None,
757
- ) -> None:
758
- """Initialization method. Assigns weights to random samples from an input dataset.
759
-
760
- :param data: Dataset for weight initialization/sampling.
761
- :param random_seed: int or None: Seed for NumPy random value generator. Defaults to None.
762
- """
763
- # Seed numpy random generator
764
- if random_seed is None:
765
- random_seed = np.random.randint(np.iinfo(np.int32).max)
766
- else:
767
- random_seed = int(random_seed)
768
- np.random.seed(random_seed)
769
-
770
- # Convert data to numpy array for training
771
- data_array = self._data_to_numpy(data)
772
-
773
- # Assign weights to random samples from dataset
774
- sample_size: np.uint = self._shape[0] * self._shape[1]
775
- sample = np.random.choice(
776
- len(data_array),
777
- size=int(sample_size),
778
- replace=bool(sample_size > len(data_array)),
779
- )
780
- self._weights = data_array[sample].reshape(self._weights.shape)
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
-
833
- def _gaussian(self, c: tuple[int, ...], sigma: float) -> np.ndarray:
834
- """
835
- Gaussian neighborhood function, centered in c. Has support for cyclic arrays.
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
-
840
- :param c: (int, int): Center coordinates for gaussian function.
841
- :param sigma: float: Spread variable for gaussian function. Must be positive.
842
- :return: np.ndarray: Gaussian, centered in c, over all the weights of the network.
843
- :raises ValueError: If sigma is not strictly positive.
844
- """
845
- return np.exp(-self._sqdist(c, sigma) / (2 * sigma * sigma))
846
-
847
- def _bubble(self, c: tuple[int, ...], sigma: float) -> np.ndarray:
848
- """
849
- Bubble neighborhood function, centered in c. Has support for cyclic arrays.
850
-
851
- The neighbors of c are the nodes in the region of sigma positions in the vertical
852
- and horizontal directions around c.
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
-
863
- :param c: (int, int): Center coordinates for gaussian function.
864
- :param sigma: float: Spread variable for gaussian function. Must be finite and
865
- non-negative.
866
- :return: np.ndarray: Neighborhood matrix, centered in c.
867
- :raises ValueError: If sigma is negative or not finite.
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
- )
874
- # Convert sigma to integer
875
- sigma = int(np.around(sigma))
876
-
877
- # Calculate vertical and horizontal regions
878
- ax = np.logical_and(self._neigx >= c[0] - sigma, self._neigx <= c[0] + sigma)
879
- ay = np.logical_and(self._neigy >= c[1] - sigma, self._neigy <= c[1] + sigma)
880
-
881
- # Calculate cyclic regions
882
- if self._cyclic[0]:
883
- if c[0] - sigma < 0:
884
- ax[int(c[0] - sigma) :] = True
885
- if c[0] + sigma >= self._shape[0]:
886
- ax[: int((c[0] + sigma) % self._shape[0] + 1)] = True
887
- if self._cyclic[1]:
888
- if c[1] - sigma < 0:
889
- ay[int(c[1] - sigma) :] = True
890
- if c[1] + sigma >= self._shape[1]:
891
- ay[: int((c[1] + sigma) % self._shape[1] + 1)] = True
892
-
893
- return np.outer(ax, ay).astype(int)
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
-
935
- def _data_to_numpy(self, data: np.ndarray | pd.DataFrame | list) -> np.ndarray:
936
- """
937
- Converts data to numpy array.
938
-
939
- :param data: array-like: Dataset for training.
940
- :return: np.ndarray: Numpy array of the dataset.
941
- """
942
- if isinstance(data, pd.DataFrame):
943
- return data.to_numpy()
944
- return np.array(data)
17
+ from __future__ import annotations
18
+
19
+ from ._decay import (
20
+ asymptotic_decay,
21
+ exponential_decay,
22
+ inverse_decay,
23
+ linear_decay,
24
+ )
25
+ from ._distance import euclidean_distance
26
+ from ._neighborhood import (
27
+ NEIGHBORHOOD_FUNCTIONS,
28
+ SIGNED_NEIGHBORHOODS,
29
+ bubble,
30
+ gaussian,
31
+ mexican_hat,
32
+ )
33
+ from ._som import SOM
34
+
35
+ __all__ = [
36
+ "NEIGHBORHOOD_FUNCTIONS",
37
+ "SIGNED_NEIGHBORHOODS",
38
+ "SOM",
39
+ "asymptotic_decay",
40
+ "bubble",
41
+ "euclidean_distance",
42
+ "exponential_decay",
43
+ "gaussian",
44
+ "inverse_decay",
45
+ "linear_decay",
46
+ "mexican_hat",
47
+ ]
48
+
49
+ __version__ = "0.3.0"
50
+
51
+ # Backwards-compatible aliases. Before 0.3.0 these functions lived in this module under
52
+ # underscore-prefixed names; they were private by convention but reachable, and the README listed
53
+ # them. Keep them working rather than breaking imports silently.
54
+ _asymptotic_decay = asymptotic_decay
55
+ _linear_decay = linear_decay
56
+ _exponential_decay = exponential_decay
57
+ _inverse_decay = inverse_decay
58
+ _euclidean_distance = euclidean_distance