ksom 0.9.1__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.
ksom-0.9.1/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ *~
2
+ __pycache__
ksom-0.9.1/LICENCE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Mathieu d'Aquin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ksom-0.9.1/PKG-INFO ADDED
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.3
2
+ Name: ksom
3
+ Version: 0.9.1
4
+ Summary: Self Organising Maps in pytorch
5
+ Project-URL: Homepage, https://github.com/mdaquin/ksom
6
+ Project-URL: Issues, https://github.com/mdaquin/ksom/issues
7
+ Author-email: Mathieu d'Aquin <mathieu.daquin.w+ksom@gmail.com>
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+
14
+ # KSOM - Simple, but relatively efficient, pytorch-based self organising map
15
+
16
+ This is a simple implementation of self-organising map training in python, using pytorch for efficiency. Check ``test-img.py`` for a simple use case creating a square color map of an image. To use, try
17
+ ```
18
+ pip install -r requirements.txt
19
+ ```
20
+ and then
21
+ ```
22
+ python test/test_img.py chica.jpg 6
23
+ ```
24
+ This shoud show an updating 6x6 map of the colours in the ``chica.jpg`` image. Change the image or the size of the map by changing the corresponding parameters.
25
+
26
+ Another example is included in the ``test_cheese.py`` creating a map of cheeses based on various binary attributes.
ksom-0.9.1/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # KSOM - Simple, but relatively efficient, pytorch-based self organising map
2
+
3
+ This is a simple implementation of self-organising map training in python, using pytorch for efficiency. Check ``test-img.py`` for a simple use case creating a square color map of an image. To use, try
4
+ ```
5
+ pip install -r requirements.txt
6
+ ```
7
+ and then
8
+ ```
9
+ python test/test_img.py chica.jpg 6
10
+ ```
11
+ This shoud show an updating 6x6 map of the colours in the ``chica.jpg`` image. Change the image or the size of the map by changing the corresponding parameters.
12
+
13
+ Another example is included in the ``test_cheese.py`` creating a map of cheeses based on various binary attributes.
ksom-0.9.1/chica.jpg ADDED
Binary file
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ksom"
7
+ version = "0.9.1"
8
+ authors = [
9
+ { name="Mathieu d'Aquin", email="mathieu.daquin.w+ksom@gmail.com" },
10
+ ]
11
+ description = "Self Organising Maps in pytorch"
12
+ readme = "README.md"
13
+ requires-python = ">=3.8"
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/mdaquin/ksom"
22
+ Issues = "https://github.com/mdaquin/ksom/issues"
Binary file
@@ -0,0 +1,217 @@
1
+ # Self-organising map pytroch model.
2
+ # Author: Mathieu d'Aquin
3
+
4
+ import torch
5
+ from torch.nn import CosineSimilarity
6
+ from torchmetrics.functional import pairwise_cosine_similarity
7
+ import math
8
+ import numpy as np
9
+
10
+ def euclidean_distance(x,y):
11
+ """returns a distance matrix between the elements of
12
+ the tensor x and the ones of the tensor y"""
13
+ return torch.cdist(x,y,2)
14
+
15
+ def cosine_distance(x,y):
16
+ """returns a distance matrix between the elements of
17
+ the tensor x and the ones of the tensor y"""
18
+ return 1 - pairwise_cosine_similarity(x,y)
19
+
20
+ # TODO: #13 this does not seem to work...
21
+ def nb_ricker(node, dims, coord, nb):
22
+ """
23
+ Ricker wavelet (mexican hat) neighborhood weights between between node (x,y)
24
+ and all the coordinates in the tensor coord ([(x,y)]) assuming
25
+ it follow the dimensions in dims (height, width).
26
+ nb is the neighborhood radius (i.e. the distance after which
27
+ the function returns 0).
28
+ """
29
+ # if nb < 1.0: nb = 1.0 # seem to make no difference...
30
+ nodes = node.repeat(dims[0]*dims[1], 1)
31
+ dist = torch.nn.functional.pairwise_distance(nodes, coord)
32
+ dist[int(node[0]*dims[0])+node[1]%dims[0]] = 0.0
33
+ FM = (math.sqrt(6)/(math.pi*(2*nb)))/math.sqrt(3)
34
+ fbit = (1-2*math.pi**2*FM**2*dist**2)
35
+ sbit = -math.pi**2*FM**2*dist**2
36
+ return fbit*torch.exp(sbit)
37
+
38
+ def nb_gaussian(node, dims, coord, nb):
39
+ """
40
+ Gaussian neighborhood weights between between node (x,y)
41
+ and all the coordinates in the tensor coord ([(x,y)]) assuming
42
+ it follow the dimensions in dims (height, width).
43
+ nb is the neighborhood radius (i.e. the distance after which
44
+ the function returns 0).
45
+ """
46
+ # exp(-(x/(nb/2))**2)
47
+ nodes = node.repeat(dims[0]*dims[1], 1)
48
+ dist = torch.nn.functional.pairwise_distance(nodes, coord)
49
+ dist[int(node[0]*dims[0])+node[1]%dims[0]] = 0.0
50
+ ret = torch.exp(-(dist/(nb/2))**2)
51
+ ret[ret < 0] = 0.0
52
+ return ret
53
+
54
+ def nb_linear_o(node1, node2, nb):
55
+ """deprecated non-batch versoin of the linear
56
+ neighborhood function"""
57
+ if node1[0] == node2[0] and node1[1] == node2[1]: return 1.0
58
+ dist = euclidean_distance(node1.view(-1, 2).to(torch.float32),
59
+ node2.view(-1, 2).to(torch.float32))[0][0]
60
+ return max(0,1-(dist/nb))
61
+
62
+ def nb_linear(node, dims, coord, nb):
63
+ """linear neighborhood distances between node (x,y)
64
+ and all the coordinates in the tensor coord ([(x,y)]) assuming
65
+ it follow the dimensions in dims (height, width).
66
+ nb is the neighborhood radius (i.e. the distance after which
67
+ the function returns 0)."""
68
+ nodes = node.repeat(dims[0]*dims[1], 1)
69
+ dist = torch.nn.functional.pairwise_distance(nodes, coord)
70
+ dist[int(node[0]*dims[0])+node[1]%dims[0]] = 0.0
71
+ ret = 1-(dist/nb)
72
+ ret[ret < 0] = 0.0
73
+ return ret
74
+
75
+ class SOM(torch.nn.Module):
76
+ """
77
+ Model for a self-organising map (Kohonen map), which is trained by adding data points that will make the map adapt. The general principle for the training is that each point is matched to the closest unit (using the euclidean distance). The weights of this best matching unit (BMU) are then updated to move closer to the input data point (to a rate dependent on the learning rate, alpha). Neighboring units are also updated, to a lesser extent, depending on the neighborhood function and radius. The learning rate and the neighborhood radius decay over time (provided input points).
78
+
79
+ Parameters:
80
+ -----------
81
+
82
+ xs: vertical size of the map
83
+
84
+ ys: horizontal size of the map
85
+
86
+ dist (default: ksom.euclidean_distance): the fonction to identify the best matching unit (BMU). This takes two 2D vectors and returns a 2D matrix of the distances between them. Two options are available as part of ksom: euclidean_distance and consine_distance.
87
+
88
+ zero_init (default: False): Whether the map is initialised randomly, or by zero vectors.
89
+
90
+ alpha_init (default: 1e-3): initial value of learning rate.
91
+
92
+ alpha_drate (default: 1e-6): decay rate of the learning rate. The learning rate (alpha) decreases linearly based on this rate for each new data point, until it reaches the valyue of alpha_drate.
93
+
94
+ neightborgood_init (default: half of the smallest dimension of the map): the initial radius of the neighborhood.
95
+
96
+ neighborhood_fct (default: ksom.nb_gaussian, other values: nb_linear, nb_ricker): the function to get the neighborhood rate depending on the neighborhood rate and the euclidean distance between units.
97
+
98
+ neighborhood_drate (default: 1e-6): rate of decay of the neigtborhood radius. This will decrease linearly from neighborhood_init to neighborhood_drate depending on neighborhood_drate.
99
+
100
+ Example:
101
+ --------
102
+
103
+ See https://github.com/mdaquin/KSOM/blob/main/test_img.py for an example of the use of KSOM to build a map of the pixel colors of an image.
104
+ """
105
+
106
+ def __init__(self, xs, ys, dim,
107
+ dist=euclidean_distance, zero_init=False, sample_init=None,
108
+ alpha_init=1e-2, alpha_drate=1e-6,
109
+ neighborhood_init=None, neighborhood_fct=nb_gaussian, neighborhood_drate=1e-6,
110
+ minval=None, maxval=None, device="cpu"):
111
+ if type(xs) != int or type(ys) != int or type(dim) != int: raise TypeError("size and dimension of SOM should be int")
112
+ if alpha_init <= alpha_drate: raise ValueError("Decay rate of learning rate (alpha_drate) should be smaller than initial value (alpha_init)")
113
+ if neighborhood_init is None: self.neighborhood_init = min(xs,ys)/2 # start with half the map
114
+ else: self.neighborhood_init = neighborhood_init
115
+ if self.neighborhood_init <= neighborhood_drate: raise ValueError("Neighborhood radius decay rate should (neighborhood_drate) should be smaller than initial value (neighborhood_init)")
116
+ super(SOM, self).__init__()
117
+ self.somap = torch.randn(xs*ys, dim).to(device)
118
+ if minval is not None and maxval is not None:
119
+ self.somap = (self.somap + minval) * (maxval - minval)
120
+ self.minval = minval
121
+ self.maxval = maxval
122
+ if zero_init: self.somap = torch.zeros((xs*ys, dim), dtype=torch.float).to(device)
123
+ if sample_init is not None:
124
+ if sample_init.shape == self.somap.shape: self.somap = sample_init
125
+ else: raise ValueError("Number of samples provided for initialisation should be the same as number of cells in map;")
126
+ self.xs = xs
127
+ self.ys = ys
128
+ self.dim = dim
129
+ self.dist = dist
130
+ self.step = 0
131
+ self.neighborhood_drate = neighborhood_drate
132
+ self.neighborhood_fct = neighborhood_fct
133
+ self.alpha_init = alpha_init
134
+ self.alpha_drate = alpha_drate
135
+ lx = torch.arange(xs).repeat(ys).view(-1, ys).T.reshape(xs*ys)
136
+ ly = torch.arange(ys).repeat(xs)
137
+ self.coord = torch.stack((lx,ly), -1).to(device)
138
+
139
+ def to(self, device):
140
+ super(SOM, self).to(device)
141
+ self.somap = self.somap.to(device)
142
+ self.coord = self.coord.to(device)
143
+
144
+ def forward(self, x):
145
+ """
146
+ Identifies the best matching unit for the data points in x in the current map.
147
+
148
+ Parameter:
149
+ ----------
150
+
151
+ x: 2D tensor (N, dim) corresponding to N data points of dimension dim.
152
+
153
+ Returns:
154
+ --------
155
+
156
+ Returns a tuple including the coordinates of the bmu in the current map and the distance matrix used to find it.
157
+ """
158
+ if type(x) != torch.Tensor: raise TypeError("x should be a tensor of shape (N,dim)")
159
+ if len(x.size()) != 2: raise ValueError("x should be a tensor of shape (N,dim)")
160
+ if x.size()[1] != self.dim: raise ValueError("x should be a tensor of shape (N,dim)")
161
+ dists = self.dist(self.somap, x)
162
+ bmu_ind = dists.min(dim=0).indices
163
+ bmu_ind_x = (bmu_ind/self.xs).to(torch.int32)
164
+ bmu_ind_y = bmu_ind%self.xs
165
+ return torch.stack((bmu_ind_x, bmu_ind_y), -1), dists
166
+
167
+ def __1DIndexTo2DIndex(self, ind):
168
+ return torch.Tensor([int(ind/self.xs), ind%self.xs])
169
+
170
+ def add(self, x):
171
+ """
172
+ Add the data points in x to the current map and update the map to those points (training).
173
+
174
+ Parameters:
175
+ -----------
176
+
177
+ x: 2D tensor (N, dim) corresponding to N data points of dimension dim.
178
+
179
+ Returns:
180
+ --------
181
+
182
+ Returns the euclidean distance of the SOM's matrix before and after training. Gives an indication of the impact of the added training points on the map.
183
+ """
184
+ if type(x) != torch.Tensor: raise TypeError("x should be a tensor of shape (N,dim)")
185
+ if len(x.size()) != 2: raise ValueError("x should be a tensor of shape (N,dim)")
186
+ if x.size()[1] != self.dim: raise ValueError("x should be a tensor of shape (N,dim)")
187
+ prev_som = self.somap.clone().detach()
188
+ count = 0
189
+ for x_k in x:
190
+ if x_k.isnan().any() or x_k.isinf().any(): continue # do not try to add vector containing nans !
191
+ count+=1
192
+ # decreases linearly...
193
+ nb = max(self.neighborhood_drate, self.neighborhood_init - (self.step*self.neighborhood_drate))
194
+ alpha = max(self.alpha_drate, self.alpha_init - (self.step*self.alpha_drate))
195
+ self.step += 1
196
+ bmu = self(x_k.view(-1, x_k.size()[0]))[0][0]
197
+ theta = self.neighborhood_fct(bmu, (self.xs, self.ys), self.coord, nb)
198
+ ntheta = theta.view(-1, theta.size(0)).T
199
+ # TODO: print(ntheta) prob is that neg ones get to quickly neg and then go to infinity...
200
+ self.somap = self.somap + ntheta*(alpha*(x_k-self.somap))
201
+ if torch.isnan(self.somap).any() or torch.isinf(self.somap).any():
202
+ print("*** Nan! ***")
203
+ print("bmu", bmu)
204
+ print("theta", theta.min(), theta.max(), theta.mean(), theta.isnan().any())
205
+ print("ntheta", ntheta.min(), ntheta.max(), ntheta.mean(), ntheta.isnan().any())
206
+ print("alpha", alpha)
207
+ print("x_k", x_k.min(), x_k.max(), x_k.mean(), x_k.isnan().any())
208
+ # old non batch (slow) version
209
+ # batch here means calculating the whole map at once,
210
+ # not having a batch of values treated at once.
211
+ # for i, w_i in enumerate(self.somap):
212
+ # i2 = self.__1DIndexTo2DIndex(i)
213
+ # theta = self.neighborhood_fct(bmu, i2, nb)
214
+ # self.somap[i] = w_i + theta*alpha*(x_k-w_i)
215
+ # print("o nsomap", self.somap)
216
+ # wij' = wij + ( n_fct(bmu,ni,nb(s)) * alpha(s) * (x_k - wij) )
217
+ return float(torch.nn.functional.pairwise_distance(prev_som, self.somap).mean()), count
@@ -0,0 +1,217 @@
1
+ # Self-organising map pytroch model.
2
+ # Author: Mathieu d'Aquin
3
+
4
+ import torch
5
+ from torch.nn import CosineSimilarity
6
+ from torchmetrics.functional import pairwise_cosine_similarity
7
+ import math
8
+ import numpy as np
9
+
10
+ def euclidean_distance(x,y):
11
+ """returns a distance matrix between the elements of
12
+ the tensor x and the ones of the tensor y"""
13
+ return torch.cdist(x,y,2)
14
+
15
+ def cosine_distance(x,y):
16
+ """returns a distance matrix between the elements of
17
+ the tensor x and the ones of the tensor y"""
18
+ return 1 - pairwise_cosine_similarity(x,y)
19
+
20
+ # TODO: #13 this does not seem to work...
21
+ def nb_ricker(node, dims, coord, nb):
22
+ """
23
+ Ricker wavelet (mexican hat) neighborhood weights between between node (x,y)
24
+ and all the coordinates in the tensor coord ([(x,y)]) assuming
25
+ it follow the dimensions in dims (height, width).
26
+ nb is the neighborhood radius (i.e. the distance after which
27
+ the function returns 0).
28
+ """
29
+ # if nb < 1.0: nb = 1.0 # seem to make no difference...
30
+ nodes = node.repeat(dims[0]*dims[1], 1)
31
+ dist = torch.nn.functional.pairwise_distance(nodes, coord)
32
+ dist[int(node[0]*dims[0])+node[1]%dims[0]] = 0.0
33
+ FM = (math.sqrt(6)/(math.pi*(2*nb)))/math.sqrt(3)
34
+ fbit = (1-2*math.pi**2*FM**2*dist**2)
35
+ sbit = -math.pi**2*FM**2*dist**2
36
+ return fbit*torch.exp(sbit)
37
+
38
+ def nb_gaussian(node, dims, coord, nb):
39
+ """
40
+ Gaussian neighborhood weights between between node (x,y)
41
+ and all the coordinates in the tensor coord ([(x,y)]) assuming
42
+ it follow the dimensions in dims (height, width).
43
+ nb is the neighborhood radius (i.e. the distance after which
44
+ the function returns 0).
45
+ """
46
+ # exp(-(x/(nb/2))**2)
47
+ nodes = node.repeat(dims[0]*dims[1], 1)
48
+ dist = torch.nn.functional.pairwise_distance(nodes, coord)
49
+ dist[int(node[0]*dims[0])+node[1]%dims[0]] = 0.0
50
+ ret = torch.exp(-(dist/(nb/2))**2)
51
+ ret[ret < 0] = 0.0
52
+ return ret
53
+
54
+ def nb_linear_o(node1, node2, nb):
55
+ """deprecated non-batch versoin of the linear
56
+ neighborhood function"""
57
+ if node1[0] == node2[0] and node1[1] == node2[1]: return 1.0
58
+ dist = euclidean_distance(node1.view(-1, 2).to(torch.float32),
59
+ node2.view(-1, 2).to(torch.float32))[0][0]
60
+ return max(0,1-(dist/nb))
61
+
62
+ def nb_linear(node, dims, coord, nb):
63
+ """linear neighborhood distances between node (x,y)
64
+ and all the coordinates in the tensor coord ([(x,y)]) assuming
65
+ it follow the dimensions in dims (height, width).
66
+ nb is the neighborhood radius (i.e. the distance after which
67
+ the function returns 0)."""
68
+ nodes = node.repeat(dims[0]*dims[1], 1)
69
+ dist = torch.nn.functional.pairwise_distance(nodes, coord)
70
+ dist[int(node[0]*dims[0])+node[1]%dims[0]] = 0.0
71
+ ret = 1-(dist/nb)
72
+ ret[ret < 0] = 0.0
73
+ return ret
74
+
75
+ class SOM(torch.nn.Module):
76
+ """
77
+ Model for a self-organising map (Kohonen map), which is trained by adding data points that will make the map adapt. The general principle for the training is that each point is matched to the closest unit (using the euclidean distance). The weights of this best matching unit (BMU) are then updated to move closer to the input data point (to a rate dependent on the learning rate, alpha). Neighboring units are also updated, to a lesser extent, depending on the neighborhood function and radius. The learning rate and the neighborhood radius decay over time (provided input points).
78
+
79
+ Parameters:
80
+ -----------
81
+
82
+ xs: vertical size of the map
83
+
84
+ ys: horizontal size of the map
85
+
86
+ dist (default: ksom.euclidean_distance): the fonction to identify the best matching unit (BMU). This takes two 2D vectors and returns a 2D matrix of the distances between them. Two options are available as part of ksom: euclidean_distance and consine_distance.
87
+
88
+ zero_init (default: False): Whether the map is initialised randomly, or by zero vectors.
89
+
90
+ alpha_init (default: 1e-3): initial value of learning rate.
91
+
92
+ alpha_drate (default: 1e-6): decay rate of the learning rate. The learning rate (alpha) decreases linearly based on this rate for each new data point, until it reaches the valyue of alpha_drate.
93
+
94
+ neightborgood_init (default: half of the smallest dimension of the map): the initial radius of the neighborhood.
95
+
96
+ neighborhood_fct (default: ksom.nb_gaussian, other values: nb_linear, nb_ricker): the function to get the neighborhood rate depending on the neighborhood rate and the euclidean distance between units.
97
+
98
+ neighborhood_drate (default: 1e-6): rate of decay of the neigtborhood radius. This will decrease linearly from neighborhood_init to neighborhood_drate depending on neighborhood_drate.
99
+
100
+ Example:
101
+ --------
102
+
103
+ See https://github.com/mdaquin/KSOM/blob/main/test_img.py for an example of the use of KSOM to build a map of the pixel colors of an image.
104
+ """
105
+
106
+ def __init__(self, xs, ys, dim,
107
+ dist=euclidean_distance, zero_init=False, sample_init=None,
108
+ alpha_init=1e-2, alpha_drate=1e-6,
109
+ neighborhood_init=None, neighborhood_fct=nb_gaussian, neighborhood_drate=1e-6,
110
+ minval=None, maxval=None, device="cpu"):
111
+ if type(xs) != int or type(ys) != int or type(dim) != int: raise TypeError("size and dimension of SOM should be int")
112
+ if alpha_init <= alpha_drate: raise ValueError("Decay rate of learning rate (alpha_drate) should be smaller than initial value (alpha_init)")
113
+ if neighborhood_init is None: self.neighborhood_init = min(xs,ys)/2 # start with half the map
114
+ else: self.neighborhood_init = neighborhood_init
115
+ if self.neighborhood_init <= neighborhood_drate: raise ValueError("Neighborhood radius decay rate should (neighborhood_drate) should be smaller than initial value (neighborhood_init)")
116
+ super(SOM, self).__init__()
117
+ self.somap = torch.randn(xs*ys, dim).to(device)
118
+ if minval is not None and maxval is not None:
119
+ self.somap = (self.somap + minval) * (maxval - minval)
120
+ self.minval = minval
121
+ self.maxval = maxval
122
+ if zero_init: self.somap = torch.zeros((xs*ys, dim), dtype=torch.float).to(device)
123
+ if sample_init is not None:
124
+ if sample_init.shape == self.somap.shape: self.somap = sample_init
125
+ else: raise ValueError("Number of samples provided for initialisation should be the same as number of cells in map;")
126
+ self.xs = xs
127
+ self.ys = ys
128
+ self.dim = dim
129
+ self.dist = dist
130
+ self.step = 0
131
+ self.neighborhood_drate = neighborhood_drate
132
+ self.neighborhood_fct = neighborhood_fct
133
+ self.alpha_init = alpha_init
134
+ self.alpha_drate = alpha_drate
135
+ lx = torch.arange(xs).repeat(ys).view(-1, ys).T.reshape(xs*ys)
136
+ ly = torch.arange(ys).repeat(xs)
137
+ self.coord = torch.stack((lx,ly), -1).to(device)
138
+
139
+ def to(self, device):
140
+ super(SOM, self).to(device)
141
+ self.somap = self.somap.to(device)
142
+ self.coord = self.coord.to(device)
143
+
144
+ def forward(self, x):
145
+ """
146
+ Identifies the best matching unit for the data points in x in the current map.
147
+
148
+ Parameter:
149
+ ----------
150
+
151
+ x: 2D tensor (N, dim) corresponding to N data points of dimension dim.
152
+
153
+ Returns:
154
+ --------
155
+
156
+ Returns a tuple including the coordinates of the bmu in the current map and the distance matrix used to find it.
157
+ """
158
+ if type(x) != torch.Tensor: raise TypeError("x should be a tensor of shape (N,dim)")
159
+ if len(x.size()) != 2: raise ValueError("x should be a tensor of shape (N,dim)")
160
+ if x.size()[1] != self.dim: raise ValueError("x should be a tensor of shape (N,dim)")
161
+ dists = self.dist(self.somap, x)
162
+ bmu_ind = dists.min(dim=0).indices
163
+ bmu_ind_x = (bmu_ind/self.xs).to(torch.int32)
164
+ bmu_ind_y = bmu_ind%self.xs
165
+ return torch.stack((bmu_ind_x, bmu_ind_y), -1), dists
166
+
167
+ def __1DIndexTo2DIndex(self, ind):
168
+ return torch.Tensor([int(ind/self.xs), ind%self.xs])
169
+
170
+ def add(self, x):
171
+ """
172
+ Add the data points in x to the current map and update the map to those points (training).
173
+
174
+ Parameters:
175
+ -----------
176
+
177
+ x: 2D tensor (N, dim) corresponding to N data points of dimension dim.
178
+
179
+ Returns:
180
+ --------
181
+
182
+ Returns the euclidean distance of the SOM's matrix before and after training. Gives an indication of the impact of the added training points on the map.
183
+ """
184
+ if type(x) != torch.Tensor: raise TypeError("x should be a tensor of shape (N,dim)")
185
+ if len(x.size()) != 2: raise ValueError("x should be a tensor of shape (N,dim)")
186
+ if x.size()[1] != self.dim: raise ValueError("x should be a tensor of shape (N,dim)")
187
+ prev_som = self.somap.clone().detach()
188
+ count = 0
189
+ for x_k in x:
190
+ if x_k.isnan().any() or x_k.isinf().any(): continue # do not try to add vector containing nans !
191
+ count+=1
192
+ # decreases linearly...
193
+ nb = max(self.neighborhood_drate, self.neighborhood_init - (self.step*self.neighborhood_drate))
194
+ alpha = max(self.alpha_drate, self.alpha_init - (self.step*self.alpha_drate))
195
+ self.step += 1
196
+ bmu = self(x_k.view(-1, x_k.size()[0]))[0][0]
197
+ theta = self.neighborhood_fct(bmu, (self.xs, self.ys), self.coord, nb)
198
+ ntheta = theta.view(-1, theta.size(0)).T
199
+ # TODO: print(ntheta) prob is that neg ones get to quickly neg and then go to infinity...
200
+ self.somap = self.somap + ntheta*(alpha*(x_k-self.somap))
201
+ if torch.isnan(self.somap).any() or torch.isinf(self.somap).any():
202
+ print("*** Nan! ***")
203
+ print("bmu", bmu)
204
+ print("theta", theta.min(), theta.max(), theta.mean(), theta.isnan().any())
205
+ print("ntheta", ntheta.min(), ntheta.max(), ntheta.mean(), ntheta.isnan().any())
206
+ print("alpha", alpha)
207
+ print("x_k", x_k.min(), x_k.max(), x_k.mean(), x_k.isnan().any())
208
+ # old non batch (slow) version
209
+ # batch here means calculating the whole map at once,
210
+ # not having a batch of values treated at once.
211
+ # for i, w_i in enumerate(self.somap):
212
+ # i2 = self.__1DIndexTo2DIndex(i)
213
+ # theta = self.neighborhood_fct(bmu, i2, nb)
214
+ # self.somap[i] = w_i + theta*alpha*(x_k-w_i)
215
+ # print("o nsomap", self.somap)
216
+ # wij' = wij + ( n_fct(bmu,ni,nb(s)) * alpha(s) * (x_k - wij) )
217
+ return float(torch.nn.functional.pairwise_distance(prev_som, self.somap).mean()), count
@@ -0,0 +1,114 @@
1
+ import math
2
+ import time
3
+ import pandas as pd
4
+ import pygame
5
+ from sklearn.decomposition import PCA
6
+ import ksom
7
+ import torch
8
+ import sys
9
+
10
+ def remspace(x): return x.replace(", ", ",") if type(x) == str else x
11
+
12
+ def onehotencode(df, col):
13
+ return pd.concat([df, df[col].apply(remspace).str.get_dummies(sep=",")], axis=1).drop(col, axis=1)
14
+
15
+ def findLabel(i, map, labels):
16
+ idx = abs(map.mean(dim=0)-map[i]).argmax()
17
+ lab = labels[idx]
18
+ if map.mean(dim=0)[idx] > map[i][idx]: lab = "not "+lab
19
+ return lab
20
+
21
+ def display(map, xoffset=0, labels=None, label_offset=0):
22
+ if map.shape[1] > 3:
23
+ pca = PCA(n_components=3)
24
+ somap = pca.fit_transform(map)
25
+ else: somap = map
26
+ for event in pygame.event.get():
27
+ if event.type == pygame.QUIT:
28
+ pygame.quit()
29
+ sys.exit()
30
+ unit = int(screen_size/smodel.xs)
31
+ for i,cs in enumerate(somap):
32
+ x = int(i/smodel.xs)
33
+ y = i%smodel.xs
34
+ x = (x*unit)+xoffset
35
+ y = y*unit
36
+ try :
37
+ color = (max(min(255, int(cs[0]*255)), 0),
38
+ max(min(255, int(cs[1]*255)), 0),
39
+ max(min(255, int(cs[2]*255)), 0))
40
+ except:
41
+ print(cs*255)
42
+ sys.exit(-1)
43
+ pygame.draw.rect(surface,
44
+ color,
45
+ pygame.Rect(x, y, unit, unit))
46
+ if labels is not None:
47
+ lab = findLabel(i, map, labels)
48
+ cp = surface.get_at((int(x+label_offset+unit/20),y+int(unit/5)))
49
+ cl = (200, 200, 200)
50
+ if cp[0] > 100 : cl = (0, 0, 0)
51
+ texts = font.render(lab, False, cl)
52
+ surface.blit(texts, (x+label_offset+unit/20,y+int(unit/5)))
53
+
54
+ pygame.display.flip()
55
+ pygame.display.update()
56
+
57
+ df = pd.read_csv("https://raw.githubusercontent.com/rfordatascience/tidytuesday/refs/heads/main/data/2024/2024-06-04/cheeses.csv", index_col=0)
58
+ df = df.drop(["url", "producers", "alt_spellings", "synonyms", "fat_content", "calcium_content", "vegetarian", "vegan"], axis=1)
59
+ print(df.columns)
60
+ df = onehotencode(df, "milk")
61
+ df = onehotencode(df, "country")
62
+ df = onehotencode(df, "region")
63
+ df = onehotencode(df, "family")
64
+ df = onehotencode(df, "type")
65
+ df = onehotencode(df, "texture")
66
+ df = onehotencode(df, "rind")
67
+ df = onehotencode(df, "color")
68
+ df = onehotencode(df, "flavor")
69
+ df = onehotencode(df, "aroma")
70
+
71
+ #print(df.aroma.apply(remspace).str.get_dummies(sep=","))
72
+ #df["vegetarian"] = df.vegetarian.apply(lambda x: int(x) if type(x)==bool else x)
73
+ #df["vegan"] = df.vegan.apply(lambda x: int(x) if type(x)==bool else x)
74
+ df = df.dropna()
75
+
76
+ screen_size=600 # size of screen
77
+ pygame.init()
78
+ surface = pygame.display.set_mode((screen_size*2,screen_size))
79
+
80
+ NBEPOCH = 10
81
+ BATCHSIZE = 100
82
+ SOMSIZE = 6
83
+ DIST = ksom.cosine_distance
84
+
85
+ pygame.font.init()
86
+ font = pygame.font.SysFont('Courrier',int((screen_size/6)/5))
87
+
88
+ smodel = ksom.SOM(SOMSIZE, SOMSIZE,
89
+ len(df.T),
90
+ zero_init=True,
91
+ # sample_init=torch.Tensor(df.sample(SOMSIZE*SOMSIZE).to_numpy()),
92
+ dist=DIST)
93
+
94
+ for epoch in range(NBEPOCH):
95
+ for b in range(math.ceil(len(df)/BATCHSIZE)):
96
+ dist,count = smodel.add(torch.Tensor(df.iloc[b*BATCHSIZE:(b+1)*BATCHSIZE].to_numpy()))
97
+ print(f"{epoch+1:02d}.{b:02d}: distance {dist:.4f} out of {count} objects")
98
+ freqmap = torch.zeros(SOMSIZE*SOMSIZE)
99
+ bmu,dists = smodel(torch.Tensor(df.to_numpy()))
100
+ for i in bmu: freqmap[i[0]*SOMSIZE+i[1]] += 1
101
+ freqmap = (freqmap - freqmap.min())/(freqmap.max()-freqmap.min())
102
+ freqmap = freqmap.view(len(freqmap), 1).repeat((1,3))
103
+ display(freqmap, xoffset=screen_size)
104
+ display(smodel.somap, labels=list(df.columns), label_offset=screen_size)
105
+
106
+ # continue to keep the display alive
107
+ while True:
108
+ for event in pygame.event.get():
109
+ if event.type == pygame.QUIT:
110
+ pygame.quit()
111
+ sys.exit()
112
+ time.sleep(0.1)
113
+ pygame.display.flip()
114
+ pygame.display.update()
@@ -0,0 +1,30 @@
1
+ import torch
2
+ from ksom import euclidean_distance, cosine_distance
3
+ from scipy.spatial import distance
4
+
5
+ som_size = 10
6
+ dim = 128
7
+ batch_size = 256
8
+
9
+ som = torch.randn(som_size*som_size, 128)
10
+ batch = torch.randn(batch_size, dim)
11
+
12
+ euc1 = euclidean_distance(batch, som)
13
+ euc2 = []
14
+
15
+ cos1 = cosine_distance(batch, som)
16
+ cos2 = []
17
+
18
+ for s in batch:
19
+ seuc = []
20
+ scos = []
21
+ for c in som:
22
+ seuc.append(distance.euclidean(s, c))
23
+ scos.append(distance.cosine(s, c))
24
+ euc2.append(torch.Tensor(seuc))
25
+ cos2.append(torch.Tensor(scos))
26
+ euc2 = torch.cat(euc2).view(batch_size, som_size*som_size)
27
+ cos2 = torch.cat(cos2).view(batch_size, som_size*som_size)
28
+
29
+ print("euclidean distance:", float(abs(euc1-euc2).mean()))
30
+ print("cosine distance:", float(abs(cos1-cos2).mean()))
@@ -0,0 +1,91 @@
1
+ import sys
2
+
3
+ if len(sys.argv) < 3:
4
+ print("No image or map size provided, default parameters (chica.jpg, 6) will be used.")
5
+ img = "chica.jpg"
6
+ som_size = 6
7
+ else:
8
+ img = sys.argv[1]
9
+ if not sys.argv[2].isnumeric():
10
+ print("Second argument should be a number.")
11
+ sys.exit(-1)
12
+ som_size = int(sys.argv[2])
13
+ disp = True
14
+ if len(sys.argv) >=4: disp = sys.argv[3] != "nodisplay"
15
+
16
+ from PIL import Image
17
+ from torchvision import transforms
18
+ from ksom import SOM, cosine_distance, nb_linear, nb_gaussian, nb_ricker
19
+ if disp: import pygame
20
+ import torch
21
+ import time
22
+
23
+ # init display screen and function to display map
24
+ if disp:
25
+ screen_size=600 # size of screen
26
+ pygame.init()
27
+ surface = pygame.display.set_mode((screen_size,screen_size))
28
+
29
+ def display(smodel):
30
+ for event in pygame.event.get():
31
+ if event.type == pygame.QUIT:
32
+ pygame.quit()
33
+ sys.exit()
34
+ unit = int(screen_size/som_size)
35
+ for i,cs in enumerate(smodel.somap):
36
+ x = int(i/som_size)
37
+ y = i%som_size
38
+ x = x*unit
39
+ y = y*unit
40
+ try :
41
+ color = (max(min(255, int(cs[0]*255)), 0),
42
+ max(min(255, int(cs[1]*255)), 0),
43
+ max(min(255, int(cs[2]*255)), 0))
44
+ except:
45
+ print(cs*255)
46
+ sys.exit(-1)
47
+ pygame.draw.rect(surface,
48
+ color,
49
+ pygame.Rect(x, y, unit, unit))
50
+ pygame.display.flip()
51
+ pygame.display.update()
52
+
53
+ # open image, transform into tensor, and create shuffle index
54
+ im= Image.open(img)
55
+ x= transforms.ToTensor()(im)
56
+ x = x[:-1] if x.size(0) == 4 else x # remove alpha layer if there is one
57
+ x = x.view(-1, x.size()[1]*x.size()[2]).transpose(0,1)
58
+ perm = torch.randperm(x.size(0))
59
+
60
+ # init SOM model
61
+ samples = x[perm[-(som_size*som_size):]]
62
+ smodel = SOM(som_size, som_size, 3, sample_init=samples, # zero_init=False,
63
+ dist=cosine_distance,
64
+ alpha_init=0.01, alpha_drate=1e-7,
65
+ neighborhood_fct=nb_gaussian, neighborhood_init=som_size, neighborhood_drate=0.0001)
66
+
67
+ device = "cpu"
68
+ if torch.cuda.is_available():
69
+ device = "cuda:0"
70
+ x = x.to(device)
71
+ smodel.to(device)
72
+ print("Running on CUDA")
73
+
74
+ # train (1 pass through all the pixels) by batches of 1000 pixels
75
+ for i in range(int(x.size()[0]/1000)):
76
+ idx = perm[i*1000:(i+1)*1000]
77
+ time1 = time.time()
78
+ dist,count = smodel.add(x[idx])
79
+ print(f"{(i+1):06d}K - {dist:.4f} - {(time.time()-time1)*1000:05.2f}ms")
80
+ if disp: display(smodel)
81
+
82
+ # continue to keep the display alive
83
+ if display:
84
+ while True:
85
+ for event in pygame.event.get():
86
+ if event.type == pygame.QUIT:
87
+ pygame.quit()
88
+ sys.exit()
89
+ time.sleep(0.1)
90
+ pygame.display.flip()
91
+ pygame.display.update()
@@ -0,0 +1,62 @@
1
+ import sys
2
+ import torch
3
+ import time
4
+
5
+ if len(sys.argv) < 4:
6
+ print("Provide the name of the neighborhood function to test and the size (N) of map (NxN) and radius of neighborhood function, using default params for now (ricker,20,6)")
7
+ sys.argv.append("ricker")
8
+ sys.argv.append("20")
9
+ sys.argv.append("6")
10
+ if not sys.argv[2].isnumeric():
11
+ print("Second argument should be a number.")
12
+ sys.exit(-1)
13
+ if not sys.argv[3].isnumeric():
14
+ print("Third argument should be a number.")
15
+ sys.exit(-1)
16
+
17
+ from ksom import SOM, nb_linear, nb_gaussian, nb_ricker
18
+
19
+ if sys.argv[1] == "linear":
20
+ fct = nb_linear
21
+ elif sys.argv[1] == "gaussian":
22
+ fct = nb_gaussian
23
+ elif sys.argv[1] == "ricker":
24
+ fct = nb_ricker
25
+ else:
26
+ print("Neighboorhood function should be linear, gaussian or ricker")
27
+ sys.exit(-1)
28
+
29
+ import pygame
30
+
31
+ # init display screen and function to display map
32
+ screen_size=600 # size of screen
33
+ pygame.init()
34
+ surface = pygame.display.set_mode((screen_size,screen_size))
35
+
36
+ def display(distmatrix):
37
+ unit = int(screen_size/som_size)
38
+ for i,cs in enumerate(distmatrix):
39
+ x = int(i/som_size)
40
+ y = i%som_size
41
+ x = x*unit
42
+ y = y*unit
43
+ color = (max(min(255, int(-cs*255)), 0),
44
+ max(min(255, int(cs*255)), 0),
45
+ max(min(255, int(cs*255)), 0))
46
+ pygame.draw.rect(surface,
47
+ color,
48
+ pygame.Rect(x, y, unit, unit))
49
+ pygame.display.flip()
50
+
51
+ # init SOM model
52
+ som_size = int(sys.argv[2]) # size of som (square, so som_size x som_size)
53
+ smodel = SOM(som_size, som_size, 3)
54
+
55
+ node = (int(som_size/2), int(som_size/2))
56
+
57
+ distmtx = fct(torch.tensor(node), (som_size, som_size), smodel.coord, int(sys.argv[3]))
58
+ display(distmtx)
59
+
60
+ # continue to keep the display alive
61
+ while True: time.sleep(10)
62
+