fastersom 0.2.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastersom
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Fast batch self-organising maps (SOM) in PyTorch for GPU and CPU
5
5
  Keywords: self-organizing map,som,kohonen,clustering,pytorch,gpu,spatial omics
6
6
  Author: Darius Schaub
@@ -30,9 +30,9 @@ Description-Content-Type: text/markdown
30
30
 
31
31
  A small, fast batch self-organising map (SOM) in PyTorch: a reimplementation of Kohonen's batch map with a
32
32
  rectangular grid, gaussian neighbourhood and exponentially decaying neighbourhood width. Its defaults reproduce the
33
- maps of [spatiomic](https://github.com/complextissue/spatiomic)'s `so.dimension.som`. Data stays on the device (CUDA,
34
- MPS or CPU) and the per-epoch update uses per-node sums, so an epoch costs one BMU search plus a (nodes x nodes) matrix
35
- product.
33
+ batch-trained maps of [XPySom](https://github.com/Manciukic/xpysom) with a gaussian neighbourhood of initial width
34
+ half the grid. Data stays on the device (CUDA, MPS or CPU) and the per-epoch update uses per-node sums, so an epoch
35
+ costs one BMU search plus a (nodes x nodes) matrix product.
36
36
 
37
37
  ## Install
38
38
 
@@ -72,21 +72,33 @@ Options:
72
72
  (`SOM(init=som.weights_).fit(X)`); with `n_epochs=0`, `fit` keeps them unchanged. The sigma schedule restarts at
73
73
  `sigma_initial`, which with the default largely retrains the map; to continue training instead, pass a small
74
74
  `sigma_initial`, e.g. the previous map's `sigma_final`.
75
- - `sigma_initial` and `sigma_final` set the neighbourhood width schedule; the defaults reproduce spatiomic.
76
- - `batch_size` bounds memory (about `batch_size x n_nodes` floats) without changing the algorithm.
75
+ - `sigma_initial` and `sigma_final` set the neighbourhood width schedule; the defaults reproduce XPySom's.
76
+ - `batch_size` bounds memory (about `batch_size x n_nodes` floats) without changing the algorithm. On CUDA and MPS
77
+ it defaults to the largest batch that still helps speed and fits in `memory_fraction` (default 0.9 on CUDA, 0.5
78
+ on MPS) of the free GPU memory, so it rarely needs tuning.
77
79
 
78
80
  Fitted maps pickle without device tensors and load on the configured device if available, else on the default one,
79
81
  so a map fitted on a GPU loads on a CPU-only machine. `BatchSOM` is an alias for `SOM`.
80
82
 
81
83
  ## Benchmark
82
84
 
83
- 480,000 x 34 pixels, 30 x 30 nodes, 10 epochs, fit time only (mean of seeds 0-2):
84
-
85
- | implementation | hardware | fit time | quantization error |
86
- | ---------------------------- | ----------------- | -------- | ------------------ |
87
- | fastersom | NVIDIA A100 | 0.085 s | 0.169 |
88
- | spatiomic (CuPy) | NVIDIA A100 | 0.46 s | 0.169 |
89
- | spatiomic (NumPy) | 256-core CPU | 40 s | 0.169 |
85
+ 480,000 x 34 pixels, 30 x 30 nodes, 10 epochs, mean of 3 seeds; peak GPU memory includes the data
86
+ ([`benchmarks/memory.py`](https://github.com/dschaub95/fastersom/blob/main/benchmarks/memory.py)).
87
+
88
+ | implementation | hardware | batch size | fit time | peak GPU memory | quantization error |
89
+ | ---------------------------------------------------- | ------------ | ----------------- | -------- | --------------- | ------------------ |
90
+ | fastersom | NVIDIA A100 | default (298,261) | 0.12 s | 1150 MiB | 0.168 |
91
+ | fastersom | NVIDIA A100 | 32,768 | 0.12 s | 204 MiB | 0.168 |
92
+ | fastersom | NVIDIA A100 | 8,096 | 0.20 s | 118 MiB | 0.168 |
93
+ | [XPySom](https://github.com/Manciukic/xpysom) (CuPy) | NVIDIA A100 | 8,096 | 0.46 s | 313 MiB | 0.169 |
94
+ | XPySom (CuPy) | NVIDIA A100 | 65,536 | 0.41 s | 2088 MiB | 0.169 |
95
+ | [torchsom](https://github.com/michelin/TorchSOM) | NVIDIA A100 | 1,024 | 21.7 s | 44 MiB | 0.175 |
96
+ | fastersom | 256-core CPU | default (8,192) | 1.8 s | - | 0.169 |
97
+ | XPySom (NumPy) | 256-core CPU | 8,096 | 40.6 s | - | 0.168 |
98
+ | torchsom | 256-core CPU | 1,024 | 39.2 s | - | 0.176 |
99
+
100
+ At equal GPU memory fastersom is about 4x faster than XPySom, and about 20x faster on the CPU. torchsom updates the map
101
+ per mini-batch, so it needs small batches and is far slower.
90
102
 
91
103
  ## Algorithm
92
104
 
@@ -2,9 +2,9 @@
2
2
 
3
3
  A small, fast batch self-organising map (SOM) in PyTorch: a reimplementation of Kohonen's batch map with a
4
4
  rectangular grid, gaussian neighbourhood and exponentially decaying neighbourhood width. Its defaults reproduce the
5
- maps of [spatiomic](https://github.com/complextissue/spatiomic)'s `so.dimension.som`. Data stays on the device (CUDA,
6
- MPS or CPU) and the per-epoch update uses per-node sums, so an epoch costs one BMU search plus a (nodes x nodes) matrix
7
- product.
5
+ batch-trained maps of [XPySom](https://github.com/Manciukic/xpysom) with a gaussian neighbourhood of initial width
6
+ half the grid. Data stays on the device (CUDA, MPS or CPU) and the per-epoch update uses per-node sums, so an epoch
7
+ costs one BMU search plus a (nodes x nodes) matrix product.
8
8
 
9
9
  ## Install
10
10
 
@@ -44,21 +44,33 @@ Options:
44
44
  (`SOM(init=som.weights_).fit(X)`); with `n_epochs=0`, `fit` keeps them unchanged. The sigma schedule restarts at
45
45
  `sigma_initial`, which with the default largely retrains the map; to continue training instead, pass a small
46
46
  `sigma_initial`, e.g. the previous map's `sigma_final`.
47
- - `sigma_initial` and `sigma_final` set the neighbourhood width schedule; the defaults reproduce spatiomic.
48
- - `batch_size` bounds memory (about `batch_size x n_nodes` floats) without changing the algorithm.
47
+ - `sigma_initial` and `sigma_final` set the neighbourhood width schedule; the defaults reproduce XPySom's.
48
+ - `batch_size` bounds memory (about `batch_size x n_nodes` floats) without changing the algorithm. On CUDA and MPS
49
+ it defaults to the largest batch that still helps speed and fits in `memory_fraction` (default 0.9 on CUDA, 0.5
50
+ on MPS) of the free GPU memory, so it rarely needs tuning.
49
51
 
50
52
  Fitted maps pickle without device tensors and load on the configured device if available, else on the default one,
51
53
  so a map fitted on a GPU loads on a CPU-only machine. `BatchSOM` is an alias for `SOM`.
52
54
 
53
55
  ## Benchmark
54
56
 
55
- 480,000 x 34 pixels, 30 x 30 nodes, 10 epochs, fit time only (mean of seeds 0-2):
56
-
57
- | implementation | hardware | fit time | quantization error |
58
- | ---------------------------- | ----------------- | -------- | ------------------ |
59
- | fastersom | NVIDIA A100 | 0.085 s | 0.169 |
60
- | spatiomic (CuPy) | NVIDIA A100 | 0.46 s | 0.169 |
61
- | spatiomic (NumPy) | 256-core CPU | 40 s | 0.169 |
57
+ 480,000 x 34 pixels, 30 x 30 nodes, 10 epochs, mean of 3 seeds; peak GPU memory includes the data
58
+ ([`benchmarks/memory.py`](https://github.com/dschaub95/fastersom/blob/main/benchmarks/memory.py)).
59
+
60
+ | implementation | hardware | batch size | fit time | peak GPU memory | quantization error |
61
+ | ---------------------------------------------------- | ------------ | ----------------- | -------- | --------------- | ------------------ |
62
+ | fastersom | NVIDIA A100 | default (298,261) | 0.12 s | 1150 MiB | 0.168 |
63
+ | fastersom | NVIDIA A100 | 32,768 | 0.12 s | 204 MiB | 0.168 |
64
+ | fastersom | NVIDIA A100 | 8,096 | 0.20 s | 118 MiB | 0.168 |
65
+ | [XPySom](https://github.com/Manciukic/xpysom) (CuPy) | NVIDIA A100 | 8,096 | 0.46 s | 313 MiB | 0.169 |
66
+ | XPySom (CuPy) | NVIDIA A100 | 65,536 | 0.41 s | 2088 MiB | 0.169 |
67
+ | [torchsom](https://github.com/michelin/TorchSOM) | NVIDIA A100 | 1,024 | 21.7 s | 44 MiB | 0.175 |
68
+ | fastersom | 256-core CPU | default (8,192) | 1.8 s | - | 0.169 |
69
+ | XPySom (NumPy) | 256-core CPU | 8,096 | 40.6 s | - | 0.168 |
70
+ | torchsom | 256-core CPU | 1,024 | 39.2 s | - | 0.176 |
71
+
72
+ At equal GPU memory fastersom is about 4x faster than XPySom, and about 20x faster on the CPU. torchsom updates the map
73
+ per mini-batch, so it needs small batches and is far slower.
62
74
 
63
75
  ## Algorithm
64
76
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastersom"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Fast batch self-organising maps (SOM) in PyTorch for GPU and CPU"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastersom"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Fast batch self-organising maps (SOM) in PyTorch for GPU and CPU"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Darius Schaub", email = "schaub.darius@gmail.com" }]
@@ -51,8 +51,6 @@ def _is_available(device: torch.device) -> bool:
51
51
  class SOM:
52
52
  """Batch self-organising map on a rectangular grid with a gaussian neighbourhood.
53
53
 
54
- The defaults reproduce the maps of spatiomic's ``so.dimension.som``.
55
-
56
54
  Args:
57
55
  n_rows: Number of grid rows.
58
56
  n_cols: Number of grid columns.
@@ -79,8 +77,11 @@ class SOM:
79
77
  batch_size: Samples processed at once in BMU search, aggregation, predict and transform. Affects memory
80
78
  (about batch_size x n_nodes floats) and speed only. Each epoch's update is the same up to float
81
79
  summation order, but over several epochs such rounding differences (as between devices) can flip
82
- near-tied BMUs, so maps agree in quality rather than bit for bit. Defaults to 65536 on GPU and 8192
83
- on CPU.
80
+ near-tied BMUs, so maps agree in quality rather than bit for bit. Defaults to 8192 on CPU and on GPUs
81
+ to 2^28 / n_nodes (where speed levels off), fewer if that does not fit in memory_fraction of the free GPU
82
+ memory (on MPS, of the memory torch recommends; 65536 before torch 2.5).
83
+ memory_fraction: Fraction of the free GPU memory the default batch_size may use, in (0, 1]. Defaults to 0.9
84
+ on CUDA and 0.5 on MPS, whose memory the system and other apps share.
84
85
 
85
86
  Attributes:
86
87
  weights_: Node weights, shape (n_rows * n_cols, n_features), row-major (node = row * n_cols + col).
@@ -100,6 +101,7 @@ class SOM:
100
101
  random_state: int | None = None,
101
102
  device: str | torch.device | None = None,
102
103
  batch_size: int | None = None,
104
+ memory_fraction: float | None = None,
103
105
  ):
104
106
  self.n_rows = n_rows
105
107
  self.n_cols = n_cols
@@ -111,6 +113,7 @@ class SOM:
111
113
  self.random_state = random_state
112
114
  self.device = device
113
115
  self.batch_size = batch_size
116
+ self.memory_fraction = memory_fraction
114
117
 
115
118
  @classmethod
116
119
  def from_weights(cls, weights: ArrayLike | torch.Tensor, n_rows: int, n_cols: int, **kwargs) -> "SOM":
@@ -130,6 +133,7 @@ class SOM:
130
133
 
131
134
  def __setstate__(self, state: dict) -> None:
132
135
  """Restores a pickled map on the configured device if available (cpu, cuda, mps), else the default one."""
136
+ state.setdefault("memory_fraction", None) # pickled before 0.3
133
137
  self.__dict__.update(state)
134
138
  if hasattr(self, "weights_"):
135
139
  self._setup(fall_back=True)
@@ -145,24 +149,29 @@ class SOM:
145
149
  raise ValueError(f"{name} must be an integer >= {minimum}, got {getattr(self, name)!r}")
146
150
  if self.batch_size is not None and not _is_int(self.batch_size, 1):
147
151
  raise ValueError(f"batch_size must be None or an integer >= 1, got {self.batch_size!r}")
152
+ if self.memory_fraction is not None and not 0 < self.memory_fraction <= 1:
153
+ raise ValueError(f"memory_fraction must be None or in (0, 1], got {self.memory_fraction!r}")
148
154
  self._device = torch.device(self.device) if self.device is not None else _default_device()
149
155
  if fall_back and not _is_available(self._device):
150
156
  self._device = _default_device()
151
- self._batch_size = self.batch_size or (8192 if self._device.type == "cpu" else 1 << 16)
152
157
  self._sigma0 = ceil(min(self.n_rows, self.n_cols) / 2) / 2 if self.sigma_initial is None else self.sigma_initial
153
158
  self._sigma1 = 0.1 * self._sigma0 if self.sigma_final is None else self.sigma_final
154
159
  if not all(isfinite(sigma) and sigma > 0 for sigma in (self._sigma0, self._sigma1)):
155
160
  raise ValueError("sigma_initial and sigma_final must be finite and > 0")
156
161
  self._coords = torch.cartesian_prod(torch.arange(self.n_rows), torch.arange(self.n_cols))
157
162
  self._coords = self._coords.to(self._device, torch.float32)
158
- self._grid_sq_dist = torch.cdist(self._coords, self._coords) ** 2
159
163
 
160
164
  def _as_tensor(self, X: ArrayLike | torch.Tensor) -> torch.Tensor:
161
- return torch.as_tensor(
162
- X.detach() if isinstance(X, torch.Tensor) else np.ascontiguousarray(X),
163
- dtype=torch.float32,
164
- device=self._device,
165
- )
165
+ if isinstance(X, torch.Tensor):
166
+ X = X.detach()
167
+ elif hasattr(X, "__dlpack__") and not isinstance(X, np.ndarray): # e.g. CuPy, without leaving the GPU
168
+ try:
169
+ X = torch.from_dlpack(X)
170
+ except RuntimeError: # torch cannot import negative strides
171
+ X = torch.from_dlpack(X.copy())
172
+ else: # numpy refuses to export read-only arrays through DLPack
173
+ X = np.ascontiguousarray(X)
174
+ return torch.as_tensor(X, dtype=torch.float32, device=self._device)
166
175
 
167
176
  def _check_X(self, X: ArrayLike | torch.Tensor) -> torch.Tensor:
168
177
  data = self._as_tensor(X)
@@ -171,10 +180,25 @@ class SOM:
171
180
  data = data.reshape(-1, data.shape[-1])
172
181
  if hasattr(self, "n_features_in_") and data.shape[1] != self.n_features_in_:
173
182
  raise ValueError(f"X has {data.shape[1]} features, but SOM was fitted with {self.n_features_in_}")
174
- if not torch.isfinite(data).all():
183
+ if not torch.isfinite(torch.stack(torch.aminmax(data))).all(): # unlike isfinite(data), without a copy of X
175
184
  raise ValueError("X contains NaN or infinite values")
176
185
  return data
177
186
 
187
+ def _batch_size_for(self, data: torch.Tensor) -> int:
188
+ if self.batch_size is not None:
189
+ return self.batch_size
190
+ if self._device.type == "cuda":
191
+ cached = torch.cuda.memory_reserved(self._device) - torch.cuda.memory_allocated(self._device)
192
+ free, fraction = torch.cuda.mem_get_info(self._device)[0] + cached, 0.9
193
+ elif self._device.type == "mps" and hasattr(torch.mps, "recommended_max_memory"): # torch >= 2.5
194
+ free, fraction = torch.mps.recommended_max_memory() - torch.mps.current_allocated_memory(), 0.5
195
+ else:
196
+ return 8192 if self._device.type == "cpu" else 1 << 16
197
+ fraction = fraction if self.memory_fraction is None else self.memory_fraction
198
+ n_nodes = self.n_rows * self.n_cols
199
+ # BMU search holds at most about 2 floats per sample and node, plus copies of the batch
200
+ return max(1, min((1 << 28) // n_nodes, int(fraction * free) // (8 * (n_nodes + data.shape[1]))))
201
+
178
202
  def _check_fitted(self) -> None:
179
203
  if not hasattr(self, "weights_"):
180
204
  raise RuntimeError("This SOM instance is not fitted yet, call fit() first")
@@ -233,11 +257,14 @@ class SOM:
233
257
  def _scores(self, batch: torch.Tensor, weights: torch.Tensor) -> torch.Tensor:
234
258
  """Per-node score to minimise.
235
259
 
236
- ||w||^2 - 2 x.w (euclidean, ||x||^2 dropped), the L1 distance (manhattan) or -x.w / ||w|| of the (for
237
- correlation centred) vectors (cosine, correlation).
260
+ ||w - c||^2 - 2 (x - c).(w - c) with c the mean node (euclidean, ||x - c||^2 dropped; centring keeps a large
261
+ offset from cancelling), the L1 distance (manhattan) or -x.w / ||w|| of the (for correlation centred) vectors
262
+ (cosine, correlation).
238
263
  """
239
264
  if self.metric == "euclidean":
240
- return torch.addmm((weights**2).sum(1), batch, weights.T, alpha=-2)
265
+ centre = weights.mean(0)
266
+ weights = weights - centre
267
+ return torch.addmm((weights**2).sum(1), batch - centre, weights.T, alpha=-2)
241
268
  if self.metric == "manhattan":
242
269
  return torch.cdist(batch, weights, p=1)
243
270
  weights = self._centre(weights)
@@ -248,14 +275,15 @@ class SOM:
248
275
  """Distances from the scores of any number of nodes per sample, for every metric but euclidean."""
249
276
  if self.metric == "manhattan":
250
277
  return scores
251
- return (1 + scores / self._centre(batch).norm(dim=1, keepdim=True)).clamp(0, 2)
278
+ # in place, as transform's scores hold a batch x nodes matrix; callers pass scores they no longer need
279
+ return scores.div_(self._centre(batch).norm(dim=1, keepdim=True)).add_(1).clamp_(0, 2)
252
280
 
253
281
  def _bmus(
254
282
  self, data: torch.Tensor, weights: torch.Tensor, return_distance: bool = True
255
283
  ) -> tuple[torch.Tensor, torch.Tensor | None]:
256
284
  """BMU and, if return_distance, distance to it per sample; -1 and NaN for samples without a BMU."""
257
285
  bmus, distances = [], []
258
- for batch in data.split(self._batch_size):
286
+ for batch in data.split(self._batch_size_for(data)):
259
287
  score, bmu = self._scores(batch, weights).min(1)
260
288
  bmus.append(bmu)
261
289
  if not return_distance:
@@ -277,8 +305,9 @@ class SOM:
277
305
  """Fits the map, starting from the initialisation given by init.
278
306
 
279
307
  Args:
280
- X: Samples, shape (n_samples, n_features) or (..., n_features); numpy array, array-like or torch tensor.
281
- Any real values are fine; data is copied to the device once and stays there.
308
+ X: Samples, shape (n_samples, n_features) or (..., n_features); numpy array, array-like, torch tensor
309
+ or DLPack array such as CuPy. Any real values are fine; data is copied to the device once and stays
310
+ there.
282
311
  y: Ignored.
283
312
 
284
313
  Returns:
@@ -289,19 +318,21 @@ class SOM:
289
318
  self.__dict__.pop(attr, None)
290
319
  data = self._check_X(X)
291
320
  weights = self._init_weights(data)
292
- n_nodes = weights.shape[0]
321
+ n_nodes, batch_size = weights.shape[0], self._batch_size_for(data)
293
322
  for t in range(self.n_epochs):
294
323
  sigma = self._sigma0 * (self._sigma1 / self._sigma0) ** (t / self.n_epochs)
295
324
  sums, counts = torch.zeros_like(weights), weights.new_zeros(n_nodes)
296
- for batch in data.split(self._batch_size):
325
+ for batch in data.split(batch_size):
297
326
  bmus = self._scores(batch, weights).argmin(1)
298
327
  keep = self._has_bmu(batch) # samples without a BMU do not contribute
299
328
  if keep is not None:
300
329
  batch, bmus = batch[keep], bmus[keep]
301
330
  sums.index_add_(0, bmus, batch)
302
331
  counts += torch.bincount(bmus, minlength=n_nodes)
303
- h = torch.exp(-self._grid_sq_dist / (2 * sigma**2))
332
+ # in place, so large maps hold one nodes x nodes matrix at a time
333
+ h = torch.cdist(self._coords, self._coords).square_().mul_(-0.5 / sigma**2).exp_()
304
334
  numerator, denominator = h @ sums, h @ counts
335
+ del h # before the next epoch's BMU search and neighbourhood matrix
305
336
  weights = torch.where(denominator[:, None] > 0, numerator / denominator[:, None], weights)
306
337
  self._set_weights(weights)
307
338
  self.labels_ = self._bmus(data, weights, return_distance=False)[0].cpu().numpy()
@@ -352,7 +383,12 @@ class SOM:
352
383
  """
353
384
  self._check_fitted()
354
385
  data = self._check_X(X)
355
- return torch.cat([self._distances(batch) for batch in data.split(self._batch_size)]).cpu().numpy()
386
+ batch_size = self._batch_size_for(data)
387
+ # on the CPU, as it can exceed device memory
388
+ distances = torch.empty(len(data), len(self._weights), dtype=torch.float32, device="cpu")
389
+ for batch, rows in zip(data.split(batch_size), distances.split(batch_size), strict=True):
390
+ rows.copy_(self._distances(batch))
391
+ return distances.numpy()
356
392
 
357
393
  def fit_transform(self, X: ArrayLike | torch.Tensor, y: object = None) -> np.ndarray:
358
394
  """Fits the map and returns transform(X)."""
@@ -376,7 +412,7 @@ class SOM:
376
412
  nodes = torch.cat(
377
413
  [
378
414
  self._scores(batch, self._weights).topk(2, dim=1, largest=False).indices
379
- for batch in data.split(self._batch_size)
415
+ for batch in data.split(self._batch_size_for(data))
380
416
  ]
381
417
  )
382
418
  offset = (self._coords[nodes[:, 0]] - self._coords[nodes[:, 1]]).abs().amax(1)
File without changes