polystep 0.7.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.
Files changed (52) hide show
  1. polystep/__init__.py +176 -0
  2. polystep/_compiled.py +334 -0
  3. polystep/_step_blockwise.py +994 -0
  4. polystep/_step_momentum.py +128 -0
  5. polystep/_step_monolithic.py +1070 -0
  6. polystep/adaptive_subspace.py +762 -0
  7. polystep/api.py +307 -0
  8. polystep/ask_tell.py +164 -0
  9. polystep/benchmarks/__init__.py +62 -0
  10. polystep/benchmarks/baselines.py +604 -0
  11. polystep/benchmarks/rl/__init__.py +13 -0
  12. polystep/benchmarks/rl/cartpole.py +260 -0
  13. polystep/benchmarks/rl/gym_evaluator.py +304 -0
  14. polystep/benchmarks/rl/metrics.py +53 -0
  15. polystep/benchmarks/rl/policies.py +133 -0
  16. polystep/benchmarks/utils.py +1262 -0
  17. polystep/blockwise.py +549 -0
  18. polystep/cma.py +400 -0
  19. polystep/cma_subspace.py +404 -0
  20. polystep/cost_nn.py +633 -0
  21. polystep/costs.py +86 -0
  22. polystep/dynamics.py +138 -0
  23. polystep/epsilon.py +217 -0
  24. polystep/geometry.py +410 -0
  25. polystep/hybrid_subspace.py +1335 -0
  26. polystep/layers/__init__.py +16 -0
  27. polystep/layers/attention.py +243 -0
  28. polystep/layers/rnn.py +276 -0
  29. polystep/objectives/__init__.py +25 -0
  30. polystep/objectives/base.py +77 -0
  31. polystep/objectives/synthetic.py +232 -0
  32. polystep/optimizer.py +1405 -0
  33. polystep/projection/__init__.py +5 -0
  34. polystep/projection/sparse.py +291 -0
  35. polystep/py.typed +0 -0
  36. polystep/quadratic_model.py +285 -0
  37. polystep/solver.py +654 -0
  38. polystep/solvers/__init__.py +34 -0
  39. polystep/solvers/_prelude.py +129 -0
  40. polystep/solvers/base.py +77 -0
  41. polystep/solvers/greedy.py +159 -0
  42. polystep/solvers/kl_softmax.py +200 -0
  43. polystep/solvers/sinkhorn.py +522 -0
  44. polystep/solvers/softmax.py +151 -0
  45. polystep/solvers/tempered_softmax.py +97 -0
  46. polystep/subspace.py +694 -0
  47. polystep/transform.py +365 -0
  48. polystep-0.7.0.dist-info/METADATA +285 -0
  49. polystep-0.7.0.dist-info/RECORD +52 -0
  50. polystep-0.7.0.dist-info/WHEEL +5 -0
  51. polystep-0.7.0.dist-info/licenses/LICENSE +191 -0
  52. polystep-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1262 @@
1
+ """Benchmark utilities: data loaders, model factories, metrics, and output formatting.
2
+
3
+ Shared utilities for all benchmark scripts:
4
+ - Data loading (MNIST, CIFAR-10, N-MNIST, DVS-Gesture) without torchvision dependency
5
+ - Model factories for consistent architectures (MLP, SNN with LIF neurons)
6
+ - Evaluation and metrics utilities
7
+ - Environment info collection
8
+ - JSON output and console table formatting
9
+
10
+ SNN utilities require snnTorch for optimal experience:
11
+ pip install snntorch
12
+
13
+ If snnTorch is not installed, falls back to:
14
+ - Pure PyTorch LIF neurons (truly non-differentiable)
15
+ - Synthetic spike data for neuromorphic datasets
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import gzip
21
+ import json
22
+ import os
23
+ import pickle
24
+ import platform
25
+ import struct as pystruct
26
+ import tarfile
27
+ from collections import defaultdict
28
+ from dataclasses import dataclass, asdict, field
29
+ from datetime import datetime
30
+ from typing import Any, Dict, List, Optional, Tuple
31
+ from urllib.request import urlretrieve
32
+
33
+ import numpy as np
34
+ import torch
35
+ import torch.nn as nn
36
+ from torch.utils.data import DataLoader, TensorDataset
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Standard validation seeds
41
+ # ---------------------------------------------------------------------------
42
+
43
+ # Benchmark validation seeds (3 seeds). Paper experiments use 5 seeds
44
+ # defined in paper/experiments/common.py instead.
45
+ SEEDS = [42, 123, 456]
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # MNIST data loading (no torchvision required)
50
+ # ---------------------------------------------------------------------------
51
+
52
+ MNIST_URL = "https://storage.googleapis.com/cvdf-datasets/mnist/"
53
+ MNIST_FILES = {
54
+ "train_images": "train-images-idx3-ubyte.gz",
55
+ "train_labels": "train-labels-idx1-ubyte.gz",
56
+ "test_images": "t10k-images-idx3-ubyte.gz",
57
+ "test_labels": "t10k-labels-idx1-ubyte.gz",
58
+ }
59
+
60
+
61
+ def download_file(url: str, filepath: str) -> None:
62
+ """Download a file from URL if not already present."""
63
+ if not os.path.exists(filepath):
64
+ print(f" Downloading {os.path.basename(filepath)}...")
65
+ urlretrieve(url, filepath)
66
+
67
+
68
+ def _download_mnist(data_dir: str) -> None:
69
+ """Download MNIST dataset if not already present."""
70
+ os.makedirs(data_dir, exist_ok=True)
71
+ for name, filename in MNIST_FILES.items():
72
+ filepath = os.path.join(data_dir, filename)
73
+ download_file(MNIST_URL + filename, filepath)
74
+
75
+
76
+ def _load_mnist_images(filepath: str) -> np.ndarray:
77
+ """Load MNIST images from gzipped IDX file."""
78
+ with gzip.open(filepath, "rb") as f:
79
+ _magic, num, rows, cols = pystruct.unpack(">IIII", f.read(16))
80
+ images = np.frombuffer(f.read(), dtype=np.uint8)
81
+ images = images.reshape(num, 1, rows, cols)
82
+ return images.astype(np.float32) / 255.0
83
+
84
+
85
+ def _load_mnist_labels(filepath: str) -> np.ndarray:
86
+ """Load MNIST labels from gzipped IDX file."""
87
+ with gzip.open(filepath, "rb") as f:
88
+ _magic, _num = pystruct.unpack(">II", f.read(8))
89
+ labels = np.frombuffer(f.read(), dtype=np.uint8)
90
+ return labels.astype(np.int64)
91
+
92
+
93
+ def get_mnist_loaders(
94
+ data_dir: str = "/tmp/mnist",
95
+ batch_size: int = 512,
96
+ normalize: bool = True,
97
+ max_train: int = 0,
98
+ max_test: int = 0,
99
+ ) -> Tuple[DataLoader, DataLoader]:
100
+ """Load MNIST train/test as PyTorch DataLoaders.
101
+
102
+ Args:
103
+ data_dir: Directory to store/load MNIST data
104
+ batch_size: Batch size for training
105
+ normalize: Whether to normalize with MNIST mean/std
106
+ max_train: Maximum training samples (0=full dataset)
107
+ max_test: Maximum test samples (0=full dataset)
108
+
109
+ Returns:
110
+ Tuple of (train_loader, test_loader)
111
+ """
112
+ _download_mnist(data_dir)
113
+
114
+ train_images = _load_mnist_images(os.path.join(data_dir, MNIST_FILES["train_images"]))
115
+ train_labels = _load_mnist_labels(os.path.join(data_dir, MNIST_FILES["train_labels"]))
116
+ test_images = _load_mnist_images(os.path.join(data_dir, MNIST_FILES["test_images"]))
117
+ test_labels = _load_mnist_labels(os.path.join(data_dir, MNIST_FILES["test_labels"]))
118
+
119
+ if normalize:
120
+ mean, std = 0.1307, 0.3081
121
+ train_images = (train_images - mean) / std
122
+ test_images = (test_images - mean) / std
123
+
124
+ if max_train > 0:
125
+ train_images = train_images[:max_train]
126
+ train_labels = train_labels[:max_train]
127
+ if max_test > 0:
128
+ test_images = test_images[:max_test]
129
+ test_labels = test_labels[:max_test]
130
+
131
+ train_ds = TensorDataset(torch.from_numpy(train_images), torch.from_numpy(train_labels))
132
+ test_ds = TensorDataset(torch.from_numpy(test_images), torch.from_numpy(test_labels))
133
+
134
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=0)
135
+ test_loader = DataLoader(test_ds, batch_size=256, shuffle=False, num_workers=0)
136
+ return train_loader, test_loader
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # CIFAR-10 data loading (no torchvision required)
141
+ # ---------------------------------------------------------------------------
142
+
143
+ CIFAR10_URL = "https://www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz"
144
+ CIFAR10_FILENAME = "cifar-10-python.tar.gz"
145
+
146
+
147
+ def _download_cifar10(data_dir: str) -> None:
148
+ """Download CIFAR-10 dataset if not already present."""
149
+ os.makedirs(data_dir, exist_ok=True)
150
+ tar_path = os.path.join(data_dir, CIFAR10_FILENAME)
151
+ extracted_dir = os.path.join(data_dir, "cifar-10-batches-py")
152
+
153
+ if not os.path.exists(extracted_dir):
154
+ download_file(CIFAR10_URL, tar_path)
155
+ print(f" Extracting {CIFAR10_FILENAME}...")
156
+ with tarfile.open(tar_path, "r:gz") as tar:
157
+ tar.extractall(data_dir)
158
+
159
+
160
+ def _load_cifar10_batch(filepath: str) -> Tuple[np.ndarray, np.ndarray]:
161
+ """Load a single CIFAR-10 batch file."""
162
+ with open(filepath, "rb") as f:
163
+ batch = pickle.load(f, encoding="bytes")
164
+ # Data is stored as (num_samples, 3072) where 3072 = 3*32*32
165
+ # Reshape to (num_samples, 3, 32, 32)
166
+ images = batch[b"data"].reshape(-1, 3, 32, 32).astype(np.float32) / 255.0
167
+ labels = np.array(batch[b"labels"], dtype=np.int64)
168
+ return images, labels
169
+
170
+
171
+ def get_cifar10_loaders(
172
+ data_dir: str = "/tmp/cifar10",
173
+ batch_size: int = 512,
174
+ normalize: bool = True,
175
+ max_train: int = 0,
176
+ max_test: int = 0,
177
+ ) -> Tuple[DataLoader, DataLoader]:
178
+ """Load CIFAR-10 train/test as PyTorch DataLoaders.
179
+
180
+ Args:
181
+ data_dir: Directory to store/load CIFAR-10 data
182
+ batch_size: Batch size for training
183
+ normalize: Whether to normalize with CIFAR-10 mean/std
184
+ max_train: Maximum training samples (0=full dataset)
185
+ max_test: Maximum test samples (0=full dataset)
186
+
187
+ Returns:
188
+ Tuple of (train_loader, test_loader)
189
+ """
190
+ _download_cifar10(data_dir)
191
+
192
+ batch_dir = os.path.join(data_dir, "cifar-10-batches-py")
193
+
194
+ # Load all training batches
195
+ train_images_list = []
196
+ train_labels_list = []
197
+ for i in range(1, 6):
198
+ batch_path = os.path.join(batch_dir, f"data_batch_{i}")
199
+ images, labels = _load_cifar10_batch(batch_path)
200
+ train_images_list.append(images)
201
+ train_labels_list.append(labels)
202
+
203
+ train_images = np.concatenate(train_images_list, axis=0)
204
+ train_labels = np.concatenate(train_labels_list, axis=0)
205
+
206
+ # Load test batch
207
+ test_path = os.path.join(batch_dir, "test_batch")
208
+ test_images, test_labels = _load_cifar10_batch(test_path)
209
+
210
+ if normalize:
211
+ # CIFAR-10 normalization values (per channel)
212
+ mean = np.array([0.4914, 0.4822, 0.4465]).reshape(1, 3, 1, 1)
213
+ std = np.array([0.2470, 0.2435, 0.2616]).reshape(1, 3, 1, 1)
214
+ train_images = (train_images - mean) / std
215
+ test_images = (test_images - mean) / std
216
+
217
+ if max_train > 0:
218
+ train_images = train_images[:max_train]
219
+ train_labels = train_labels[:max_train]
220
+ if max_test > 0:
221
+ test_images = test_images[:max_test]
222
+ test_labels = test_labels[:max_test]
223
+
224
+ train_ds = TensorDataset(
225
+ torch.from_numpy(train_images.astype(np.float32)),
226
+ torch.from_numpy(train_labels),
227
+ )
228
+ test_ds = TensorDataset(
229
+ torch.from_numpy(test_images.astype(np.float32)),
230
+ torch.from_numpy(test_labels),
231
+ )
232
+
233
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=0)
234
+ test_loader = DataLoader(test_ds, batch_size=256, shuffle=False, num_workers=0)
235
+ return train_loader, test_loader
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # SST-2 data loading (HuggingFace with simple tokenization)
240
+ # ---------------------------------------------------------------------------
241
+
242
+ # Check for HuggingFace datasets at module load time
243
+ try:
244
+ from datasets import load_dataset as _hf_load_dataset
245
+
246
+ HAS_DATASETS = True
247
+ except ImportError:
248
+ HAS_DATASETS = False
249
+
250
+
251
+ def _simple_tokenize(
252
+ texts: List[str],
253
+ vocab: Dict[str, int],
254
+ max_seq_len: int,
255
+ pad_idx: int = 0,
256
+ unk_idx: int = 1,
257
+ ) -> Tuple[np.ndarray, np.ndarray]:
258
+ """Tokenize texts using simple word-to-index mapping.
259
+
260
+ Args:
261
+ texts: List of text strings to tokenize.
262
+ vocab: Vocabulary mapping word -> index.
263
+ max_seq_len: Maximum sequence length (pad/truncate to this).
264
+ pad_idx: Index for padding token.
265
+ unk_idx: Index for unknown token.
266
+
267
+ Returns:
268
+ Tuple of (input_ids, attention_mask) as numpy arrays.
269
+ """
270
+ input_ids = []
271
+ attention_masks = []
272
+
273
+ for text in texts:
274
+ # Simple word tokenization: lowercase, split on whitespace/punctuation
275
+ words = text.lower().replace(",", " ").replace(".", " ").replace("!", " ").replace("?", " ").split()
276
+
277
+ # Convert to indices
278
+ ids = [vocab.get(w, unk_idx) for w in words]
279
+
280
+ # Truncate if needed
281
+ if len(ids) > max_seq_len:
282
+ ids = ids[:max_seq_len]
283
+
284
+ # Create attention mask (1 for real tokens)
285
+ mask = [1] * len(ids)
286
+
287
+ # Pad if needed
288
+ padding_len = max_seq_len - len(ids)
289
+ if padding_len > 0:
290
+ ids = ids + [pad_idx] * padding_len
291
+ mask = mask + [0] * padding_len
292
+
293
+ input_ids.append(ids)
294
+ attention_masks.append(mask)
295
+
296
+ return np.array(input_ids, dtype=np.int64), np.array(attention_masks, dtype=np.int64)
297
+
298
+
299
+ def _build_vocab(texts: List[str], vocab_size: int) -> Dict[str, int]:
300
+ """Build vocabulary from texts.
301
+
302
+ Args:
303
+ texts: List of text strings.
304
+ vocab_size: Maximum vocabulary size (including special tokens).
305
+
306
+ Returns:
307
+ Dictionary mapping word -> index.
308
+ """
309
+ # Count word frequencies
310
+ word_counts: Dict[str, int] = defaultdict(int)
311
+ for text in texts:
312
+ words = text.lower().replace(",", " ").replace(".", " ").replace("!", " ").replace("?", " ").split()
313
+ for w in words:
314
+ word_counts[w] += 1
315
+
316
+ # Sort by frequency and take top vocab_size - 2 (reserve 0=PAD, 1=UNK)
317
+ sorted_words = sorted(word_counts.items(), key=lambda x: -x[1])
318
+ vocab = {"<PAD>": 0, "<UNK>": 1}
319
+
320
+ for word, _ in sorted_words[: vocab_size - 2]:
321
+ vocab[word] = len(vocab)
322
+
323
+ return vocab
324
+
325
+
326
+ def get_sst2_loaders(
327
+ max_train: int = 0,
328
+ max_test: int = 0,
329
+ max_seq_len: int = 64,
330
+ batch_size: int = 32,
331
+ vocab_size: int = 10000,
332
+ data_dir: str = "/tmp/sst2",
333
+ ) -> Tuple[DataLoader, DataLoader, int, str]:
334
+ """Load SST-2 sentiment dataset as PyTorch DataLoaders.
335
+
336
+ Uses HuggingFace datasets with simple word-to-index tokenization.
337
+ Raises error if HuggingFace datasets is not installed (no synthetic fallback).
338
+
339
+ Args:
340
+ max_train: Maximum training samples (0=all available).
341
+ max_test: Maximum test samples (0=all available).
342
+ max_seq_len: Maximum sequence length for padding/truncation.
343
+ batch_size: Batch size for DataLoaders.
344
+ vocab_size: Maximum vocabulary size for tokenization.
345
+ data_dir: Directory for caching HuggingFace datasets.
346
+
347
+ Returns:
348
+ Tuple of (train_loader, test_loader, actual_vocab_size, data_type).
349
+ DataLoaders yield (input_ids, attention_mask, labels) tuples.
350
+ data_type is "real" for HuggingFace data.
351
+
352
+ Raises:
353
+ ImportError: If HuggingFace datasets is not installed.
354
+ """
355
+ if not HAS_DATASETS:
356
+ raise ImportError("HuggingFace datasets required for SST-2 benchmark. Install with: pip install datasets")
357
+
358
+ print(" Loading SST-2 from HuggingFace datasets...")
359
+ dataset = _hf_load_dataset("glue", "sst2", cache_dir=data_dir)
360
+
361
+ # Get train/validation splits (SST-2 test set has no labels)
362
+ train_texts = dataset["train"]["sentence"]
363
+ train_labels = dataset["train"]["label"]
364
+ test_texts = dataset["validation"]["sentence"]
365
+ test_labels = dataset["validation"]["label"]
366
+
367
+ # Apply max samples
368
+ if max_train > 0:
369
+ train_texts = train_texts[:max_train]
370
+ train_labels = train_labels[:max_train]
371
+ if max_test > 0:
372
+ test_texts = test_texts[:max_test]
373
+ test_labels = test_labels[:max_test]
374
+
375
+ print(f" Building vocabulary from {len(train_texts)} training samples...")
376
+ vocab = _build_vocab(train_texts, vocab_size)
377
+ actual_vocab_size = len(vocab)
378
+ print(f" Vocabulary size: {actual_vocab_size}")
379
+
380
+ # Tokenize
381
+ print(f" Tokenizing with max_seq_len={max_seq_len}...")
382
+ train_ids, train_mask = _simple_tokenize(train_texts, vocab, max_seq_len)
383
+ test_ids, test_mask = _simple_tokenize(test_texts, vocab, max_seq_len)
384
+ train_labels_arr = np.array(train_labels, dtype=np.int64)
385
+ test_labels_arr = np.array(test_labels, dtype=np.int64)
386
+
387
+ # Convert to tensors and create DataLoaders
388
+ train_ds = TensorDataset(
389
+ torch.from_numpy(train_ids),
390
+ torch.from_numpy(train_mask),
391
+ torch.from_numpy(train_labels_arr),
392
+ )
393
+ test_ds = TensorDataset(
394
+ torch.from_numpy(test_ids),
395
+ torch.from_numpy(test_mask),
396
+ torch.from_numpy(test_labels_arr),
397
+ )
398
+
399
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=0)
400
+ test_loader = DataLoader(test_ds, batch_size=batch_size, shuffle=False, num_workers=0)
401
+
402
+ data_type = "real"
403
+ print(f" Train: {len(train_ds)} samples, Test: {len(test_ds)} samples")
404
+ print(f" Data type: {data_type} (HuggingFace SST-2)")
405
+ return train_loader, test_loader, actual_vocab_size, data_type
406
+
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # Model factories
410
+ # ---------------------------------------------------------------------------
411
+
412
+
413
+ class MNISTNet(nn.Module):
414
+ """Two-layer MLP for MNIST digit classification.
415
+
416
+ Architecture: 784 -> hidden -> 10
417
+ Uses ReLU activation, ~101K params with hidden=128.
418
+ """
419
+
420
+ def __init__(self, hidden: int = 128):
421
+ super().__init__()
422
+ self.flatten = nn.Flatten()
423
+ self.fc1 = nn.Linear(784, hidden)
424
+ self.relu = nn.ReLU()
425
+ self.fc2 = nn.Linear(hidden, 10)
426
+
427
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
428
+ x = self.flatten(x)
429
+ x = self.relu(self.fc1(x))
430
+ return self.fc2(x)
431
+
432
+
433
+ class CIFAR10Net(nn.Module):
434
+ """Standard small CNN for CIFAR-10 classification (vmap-compatible).
435
+
436
+ Architecture: 3xConv2d + MaxPool -> FC(128) -> FC(10)
437
+ No BatchNorm (incompatible with vmap). ~189K parameters.
438
+ """
439
+
440
+ def __init__(self):
441
+ super().__init__()
442
+ self.features = nn.Sequential(
443
+ nn.Conv2d(3, 32, 3, padding=1),
444
+ nn.ReLU(),
445
+ nn.MaxPool2d(2),
446
+ nn.Conv2d(32, 64, 3, padding=1),
447
+ nn.ReLU(),
448
+ nn.MaxPool2d(2),
449
+ nn.Conv2d(64, 64, 3, padding=1),
450
+ nn.ReLU(),
451
+ nn.MaxPool2d(2),
452
+ )
453
+ self.classifier = nn.Sequential(
454
+ nn.Flatten(),
455
+ nn.Linear(64 * 4 * 4, 128),
456
+ nn.ReLU(),
457
+ nn.Linear(128, 10),
458
+ )
459
+
460
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
461
+ return self.classifier(self.features(x))
462
+
463
+
464
+ def create_model(
465
+ dataset_name: str,
466
+ hidden: int = 128,
467
+ **kwargs,
468
+ ) -> nn.Module:
469
+ """Factory function to create models for different datasets.
470
+
471
+ Args:
472
+ dataset_name: One of 'mnist', 'cifar10'
473
+ hidden: Hidden layer size
474
+ **kwargs: Additional model-specific arguments
475
+
476
+ Returns:
477
+ nn.Module: The created model
478
+
479
+ Raises:
480
+ ValueError: If dataset_name is not recognized
481
+ """
482
+ dataset_name = dataset_name.lower()
483
+
484
+ if dataset_name == "mnist":
485
+ return MNISTNet(hidden=hidden)
486
+ elif dataset_name == "cifar10":
487
+ # CIFAR10Net has a fixed architecture; ``hidden`` is ignored.
488
+ return CIFAR10Net()
489
+ else:
490
+ raise ValueError(f"Unknown dataset: {dataset_name}. Supported: mnist, cifar10")
491
+
492
+
493
+ # ---------------------------------------------------------------------------
494
+ # Evaluation utilities
495
+ # ---------------------------------------------------------------------------
496
+
497
+
498
+ @torch.no_grad()
499
+ def evaluate_accuracy(model: nn.Module, dataloader: DataLoader) -> float:
500
+ """Compute classification accuracy on a DataLoader.
501
+
502
+ Args:
503
+ model: The model to evaluate
504
+ dataloader: DataLoader with (inputs, labels) or (inputs, attention_mask, labels)
505
+
506
+ Returns:
507
+ Accuracy as a float between 0 and 1
508
+ """
509
+ model.eval()
510
+ device = next(model.parameters()).device
511
+ correct = 0
512
+ total = 0
513
+
514
+ for batch in dataloader:
515
+ if len(batch) == 2:
516
+ inputs, targets = batch
517
+ inputs, targets = inputs.to(device), targets.to(device)
518
+ outputs = model(inputs)
519
+ elif len(batch) == 3:
520
+ # SST-2 format: (input_ids, attention_mask, labels)
521
+ input_ids, attention_mask, targets = batch
522
+ input_ids = input_ids.to(device)
523
+ attention_mask = attention_mask.to(device)
524
+ targets = targets.to(device)
525
+ outputs = model(input_ids, attention_mask=attention_mask)
526
+ else:
527
+ raise ValueError(f"Unexpected batch format with {len(batch)} elements")
528
+
529
+ preds = outputs.argmax(dim=-1)
530
+ correct += (preds == targets).sum().item()
531
+ total += targets.size(0)
532
+
533
+ model.train()
534
+ return correct / total if total > 0 else 0.0
535
+
536
+
537
+ # ---------------------------------------------------------------------------
538
+ # BenchmarkResult dataclass
539
+ # ---------------------------------------------------------------------------
540
+
541
+
542
+ @dataclass
543
+ class BenchmarkResult:
544
+ """Single optimizer run result.
545
+
546
+ Attributes:
547
+ optimizer: Name of the optimizer (e.g., 'polystep', 'adam', 'cmaes')
548
+ seed: Random seed used
549
+ final_accuracy: Accuracy at end of training
550
+ best_accuracy: Best accuracy achieved during training
551
+ final_loss: Loss at end of training (None for optimizers that don't compute loss)
552
+ wall_time_seconds: Total wall clock time in seconds
553
+ peak_gpu_memory_mb: Peak GPU memory usage in MB
554
+ total_steps: Total optimization steps/iterations
555
+ function_evals: Total function evaluations (steps * popsize for ES)
556
+ convergence_epoch: Epoch when target accuracy first reached (None if never)
557
+ epoch_logs: List of per-epoch metrics dicts
558
+ """
559
+
560
+ optimizer: str
561
+ seed: int
562
+ final_accuracy: float
563
+ best_accuracy: float
564
+ final_loss: Optional[float]
565
+ wall_time_seconds: float
566
+ peak_gpu_memory_mb: float
567
+ total_steps: int
568
+ function_evals: int
569
+ convergence_epoch: Optional[int]
570
+ epoch_logs: List[Dict[str, Any]] = field(default_factory=list)
571
+
572
+ def to_dict(self) -> Dict[str, Any]:
573
+ """Convert to dictionary for JSON serialization."""
574
+ return asdict(self)
575
+
576
+
577
+ # ---------------------------------------------------------------------------
578
+ # Environment info collection
579
+ # ---------------------------------------------------------------------------
580
+
581
+
582
+ def get_environment_info() -> Dict[str, Any]:
583
+ """Collect environment info for reproducibility.
584
+
585
+ Returns:
586
+ Dict with torch_version, cuda_version, gpu_model, python_version, platform
587
+ """
588
+ info = {
589
+ "torch_version": torch.__version__,
590
+ "python_version": platform.python_version(),
591
+ "platform": platform.system(),
592
+ "platform_release": platform.release(),
593
+ }
594
+
595
+ if torch.cuda.is_available():
596
+ info["cuda_version"] = torch.version.cuda
597
+ info["gpu_model"] = torch.cuda.get_device_name(0)
598
+ info["gpu_count"] = torch.cuda.device_count()
599
+ else:
600
+ info["cuda_version"] = None
601
+ info["gpu_model"] = None
602
+ info["gpu_count"] = 0
603
+
604
+ return info
605
+
606
+
607
+ # ---------------------------------------------------------------------------
608
+ # Output utilities
609
+ # ---------------------------------------------------------------------------
610
+
611
+
612
+ def compute_summary_stats(results: List[BenchmarkResult]) -> Dict[str, Dict[str, float]]:
613
+ """Compute summary statistics grouped by optimizer.
614
+
615
+ Args:
616
+ results: List of BenchmarkResult objects
617
+
618
+ Returns:
619
+ Dict mapping optimizer names to stats dicts with mean_accuracy, std_accuracy, etc.
620
+ """
621
+ by_optimizer: Dict[str, List[BenchmarkResult]] = defaultdict(list)
622
+ for r in results:
623
+ by_optimizer[r.optimizer].append(r)
624
+
625
+ summary = {}
626
+ for optimizer, runs in by_optimizer.items():
627
+ accs = [r.best_accuracy for r in runs]
628
+ times = [r.wall_time_seconds for r in runs]
629
+ mems = [r.peak_gpu_memory_mb for r in runs]
630
+ evals = [r.function_evals for r in runs]
631
+
632
+ summary[optimizer] = {
633
+ "mean_accuracy": float(np.mean(accs)),
634
+ "std_accuracy": float(np.std(accs)),
635
+ "min_accuracy": float(np.min(accs)),
636
+ "max_accuracy": float(np.max(accs)),
637
+ "mean_time": float(np.mean(times)),
638
+ "std_time": float(np.std(times)),
639
+ "mean_memory_mb": float(np.mean(mems)),
640
+ "mean_function_evals": float(np.mean(evals)),
641
+ "num_runs": len(runs),
642
+ }
643
+
644
+ return summary
645
+
646
+
647
+ def save_results_json(
648
+ results: List[BenchmarkResult],
649
+ output_dir: str,
650
+ benchmark_name: str,
651
+ config: Optional[Dict[str, Any]] = None,
652
+ ) -> str:
653
+ """Save benchmark results to JSON file.
654
+
655
+ Args:
656
+ results: List of BenchmarkResult objects
657
+ output_dir: Directory to save results
658
+ benchmark_name: Name of the benchmark (e.g., 'mnist', 'cifar10')
659
+ config: Optional benchmark configuration dict
660
+
661
+ Returns:
662
+ Path to the saved JSON file
663
+ """
664
+ os.makedirs(output_dir, exist_ok=True)
665
+
666
+ timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
667
+ filename = f"{benchmark_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
668
+ filepath = os.path.join(output_dir, filename)
669
+
670
+ output = {
671
+ "benchmark": benchmark_name,
672
+ "timestamp": timestamp,
673
+ "environment": get_environment_info(),
674
+ "config": config or {},
675
+ "results": [r.to_dict() for r in results],
676
+ "summary": compute_summary_stats(results),
677
+ }
678
+
679
+ with open(filepath, "w") as f:
680
+ json.dump(output, f, indent=2)
681
+
682
+ return filepath
683
+
684
+
685
+ def format_results_table(results: List[BenchmarkResult]) -> str:
686
+ """Format results as a console table using tabulate.
687
+
688
+ Args:
689
+ results: List of BenchmarkResult objects
690
+
691
+ Returns:
692
+ Formatted table string
693
+ """
694
+ try:
695
+ from tabulate import tabulate
696
+ except ImportError:
697
+ # Fallback to simple formatting if tabulate not installed
698
+ return _format_results_simple(results)
699
+
700
+ headers = ["Optimizer", "Mean Acc", "Std", "Mean Time", "Peak Mem (MB)", "Func Evals"]
701
+ rows = []
702
+
703
+ # Group by optimizer
704
+ by_opt: Dict[str, List[BenchmarkResult]] = defaultdict(list)
705
+ for r in results:
706
+ by_opt[r.optimizer].append(r)
707
+
708
+ for opt, runs in sorted(by_opt.items()):
709
+ mean_acc = sum(r.best_accuracy for r in runs) / len(runs)
710
+ std_acc = (sum((r.best_accuracy - mean_acc) ** 2 for r in runs) / len(runs)) ** 0.5
711
+ mean_time = sum(r.wall_time_seconds for r in runs) / len(runs)
712
+ mean_mem = sum(r.peak_gpu_memory_mb for r in runs) / len(runs)
713
+ total_evals = sum(r.function_evals for r in runs) // len(runs) # Average
714
+
715
+ rows.append(
716
+ [
717
+ opt,
718
+ f"{mean_acc * 100:.1f}%",
719
+ f"+/-{std_acc * 100:.1f}%",
720
+ f"{mean_time:.1f}s",
721
+ f"{mean_mem:.0f}",
722
+ f"{total_evals:,}",
723
+ ]
724
+ )
725
+
726
+ return tabulate(rows, headers=headers, tablefmt="github")
727
+
728
+
729
+ def _format_results_simple(results: List[BenchmarkResult]) -> str:
730
+ """Simple fallback table formatting without tabulate."""
731
+ lines = []
732
+ lines.append("| Optimizer | Mean Acc | Std | Mean Time | Peak Mem (MB) | Func Evals |")
733
+ lines.append("|-----------|----------|-----|-----------|---------------|------------|")
734
+
735
+ # Group by optimizer
736
+ by_opt: Dict[str, List[BenchmarkResult]] = defaultdict(list)
737
+ for r in results:
738
+ by_opt[r.optimizer].append(r)
739
+
740
+ for opt, runs in sorted(by_opt.items()):
741
+ mean_acc = sum(r.best_accuracy for r in runs) / len(runs)
742
+ std_acc = (sum((r.best_accuracy - mean_acc) ** 2 for r in runs) / len(runs)) ** 0.5
743
+ mean_time = sum(r.wall_time_seconds for r in runs) / len(runs)
744
+ mean_mem = sum(r.peak_gpu_memory_mb for r in runs) / len(runs)
745
+ total_evals = sum(r.function_evals for r in runs) // len(runs)
746
+
747
+ lines.append(
748
+ f"| {opt:<9} | {mean_acc * 100:>6.1f}% | +/-{std_acc * 100:.1f}% | "
749
+ f"{mean_time:>8.1f}s | {mean_mem:>13.0f} | {total_evals:>10,} |"
750
+ )
751
+
752
+ return "\n".join(lines)
753
+
754
+
755
+ # ---------------------------------------------------------------------------
756
+ # snnTorch availability check
757
+ # ---------------------------------------------------------------------------
758
+
759
+ _HAS_SNNTORCH = False
760
+ try:
761
+ import snntorch as snn
762
+
763
+ _HAS_SNNTORCH = True
764
+ except ImportError:
765
+ pass
766
+
767
+
768
+ # ---------------------------------------------------------------------------
769
+ # Pure-PyTorch LIF neuron (fallback when snnTorch unavailable)
770
+ # ---------------------------------------------------------------------------
771
+
772
+
773
+ class LIFNeuron(nn.Module):
774
+ """Leaky Integrate-and-Fire neuron with hard threshold spike.
775
+
776
+ This is truly non-differentiable: the spike function has zero
777
+ gradient almost everywhere, making backpropagation useless.
778
+ polystep sidesteps this entirely with gradient-free optimization.
779
+
780
+ Args:
781
+ beta: Membrane decay factor (0 < beta < 1). Higher values = longer memory.
782
+ threshold: Spike threshold for membrane potential.
783
+ """
784
+
785
+ def __init__(self, beta: float = 0.95, threshold: float = 1.0):
786
+ super().__init__()
787
+ self.beta = beta
788
+ self.threshold = threshold
789
+
790
+ def forward(self, x: torch.Tensor, mem: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
791
+ """One timestep of LIF dynamics.
792
+
793
+ Args:
794
+ x: Input current, shape (batch, features).
795
+ mem: Membrane potential, shape (batch, features).
796
+
797
+ Returns:
798
+ (spike, new_mem): Binary spikes and updated membrane.
799
+ """
800
+ mem = self.beta * mem + x
801
+ # This is THE non-differentiable operation: d(spike)/d(mem) = 0
802
+ # almost everywhere. Backpropagation gives zero gradients through
803
+ # this line. polystep never differentiates through it.
804
+ spike = (mem >= self.threshold).float()
805
+ mem = mem * (1.0 - spike) # Reset after spike
806
+ return spike, mem
807
+
808
+
809
+ # ---------------------------------------------------------------------------
810
+ # SNN models
811
+ # ---------------------------------------------------------------------------
812
+
813
+
814
+ class SpikingNet(nn.Module):
815
+ """SNN with LIF neurons for classification.
816
+
817
+ Uses snnTorch.Leaky if available, falls back to pure PyTorch LIF neurons.
818
+
819
+ Architecture: Linear -> LIF -> Linear -> LIF
820
+ Output: mean spike rate over num_steps timesteps.
821
+
822
+ Why gradient-free for SNNs?
823
+ SNNs use hard threshold spikes: d(spike)/d(membrane) = 0.
824
+ Backpropagation gives zero gradients through spikes.
825
+ Surrogate gradients are an approximation hack.
826
+ polystep needs NO gradients -- only forward passes!
827
+
828
+ Args:
829
+ input_dim: Input dimension (flattened).
830
+ hidden: Number of hidden neurons.
831
+ output: Number of output classes.
832
+ beta: Membrane decay factor (0.9-0.99 typical).
833
+ num_steps: Number of timesteps for spike integration.
834
+ use_snntorch: Use snnTorch if available (default: True).
835
+
836
+ Example:
837
+ >>> model = SpikingNet(input_dim=32*32*2, hidden=128, output=10, num_steps=25)
838
+ >>> x = torch.randn(32, 25, 2, 32, 32) # (batch, time, polarity, H, W)
839
+ >>> out = model(x) # (batch, output) spike rates
840
+ """
841
+
842
+ def __init__(
843
+ self,
844
+ input_dim: int = 784,
845
+ hidden: int = 128,
846
+ output: int = 10,
847
+ beta: float = 0.95,
848
+ num_steps: int = 25,
849
+ use_snntorch: bool = True,
850
+ ):
851
+ super().__init__()
852
+ self.input_dim = input_dim
853
+ self.hidden = hidden
854
+ self.output_dim = output
855
+ self.num_steps = num_steps
856
+ self.beta = beta
857
+ self.use_snntorch = use_snntorch and _HAS_SNNTORCH
858
+
859
+ self.fc1 = nn.Linear(input_dim, hidden)
860
+ self.fc2 = nn.Linear(hidden, output)
861
+
862
+ if self.use_snntorch:
863
+ self.lif1 = snn.Leaky(beta=beta)
864
+ self.lif2 = snn.Leaky(beta=beta)
865
+ else:
866
+ self.lif1 = LIFNeuron(beta=beta)
867
+ self.lif2 = LIFNeuron(beta=beta)
868
+
869
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
870
+ """Forward pass: process spike data over multiple timesteps.
871
+
872
+ Supports two input formats:
873
+ 1. Temporal spike data: (num_steps, batch, ...) or (batch, num_steps, ...)
874
+ 2. Static input: (batch, ...) - presented at each timestep
875
+
876
+ Args:
877
+ x: Input tensor. If temporal, shape is (time, batch, ...) or (batch, time, ...)
878
+ If static, shape is (batch, input_dim) or (batch, channels, H, W).
879
+
880
+ Returns:
881
+ Spike rates, shape (batch, output_dim). Values in [0, 1].
882
+ """
883
+ # Detect input format and normalize to (num_steps, batch, features)
884
+ if x.dim() >= 3 and x.shape[0] == self.num_steps:
885
+ # Temporal format: (num_steps, batch, ...)
886
+ num_steps = x.shape[0]
887
+ batch = x.shape[1]
888
+ x_seq = x.reshape(num_steps, batch, -1) # (T, B, features)
889
+ elif x.dim() >= 3 and x.shape[1] == self.num_steps:
890
+ # Alternate temporal: (batch, num_steps, ...)
891
+ batch = x.shape[0]
892
+ num_steps = x.shape[1]
893
+ x_seq = x.reshape(batch, num_steps, -1).permute(1, 0, 2) # (T, B, features)
894
+ else:
895
+ # Static input: repeat across timesteps
896
+ batch = x.shape[0]
897
+ num_steps = self.num_steps
898
+ x_flat = x.reshape(batch, -1)
899
+ x_seq = x_flat.unsqueeze(0).expand(num_steps, -1, -1) # (T, B, features)
900
+
901
+ # Initialize membrane potentials
902
+ if self.use_snntorch:
903
+ mem1 = self.lif1.init_leaky()
904
+ mem2 = self.lif2.init_leaky()
905
+ else:
906
+ mem1 = torch.zeros(batch, self.hidden, device=x.device, dtype=x.dtype)
907
+ mem2 = torch.zeros(batch, self.output_dim, device=x.device, dtype=x.dtype)
908
+
909
+ total_spikes = torch.zeros(batch, self.output_dim, device=x.device, dtype=x.dtype)
910
+
911
+ for t in range(num_steps):
912
+ cur1 = self.fc1(x_seq[t])
913
+ spk1, mem1 = self.lif1(cur1, mem1)
914
+ cur2 = self.fc2(spk1)
915
+ spk2, mem2 = self.lif2(cur2, mem2)
916
+ total_spikes = total_spikes + spk2
917
+
918
+ return total_spikes / num_steps # Spike rate in [0, 1]
919
+
920
+
921
+ # ---------------------------------------------------------------------------
922
+ # N-MNIST data loading
923
+ # ---------------------------------------------------------------------------
924
+
925
+
926
+ def get_nmnist_loaders(
927
+ data_dir: str = "/tmp/nmnist",
928
+ num_steps: int = 25,
929
+ batch_size: int = 64,
930
+ max_train: int = 0,
931
+ max_test: int = 0,
932
+ ) -> Tuple[DataLoader, DataLoader]:
933
+ """Load N-MNIST neuromorphic dataset.
934
+
935
+ N-MNIST is a neuromorphic version of MNIST created by displaying MNIST
936
+ digits on a monitor and recording with a DVS (Dynamic Vision Sensor) camera.
937
+ It contains ON and OFF polarity events encoding digit patterns.
938
+
939
+ Uses snnTorch spikevision if available, falls back to synthetic spike data.
940
+
941
+ Args:
942
+ data_dir: Directory to store/load N-MNIST data.
943
+ num_steps: Number of timesteps for spike binning.
944
+ batch_size: Batch size for DataLoader.
945
+ max_train: Maximum training samples (0=full dataset, ~60K).
946
+ max_test: Maximum test samples (0=full dataset, ~10K).
947
+
948
+ Returns:
949
+ Tuple of (train_loader, test_loader).
950
+ Data format: (batch, num_steps, 2, 34, 34) for snnTorch
951
+ (batch, num_steps, 2, 32, 32) for synthetic
952
+ Label format: (batch,) with values 0-9.
953
+
954
+ Note:
955
+ With snnTorch, the actual data shape depends on snnTorch version.
956
+ The SpikingNet model handles both formats automatically.
957
+
958
+ Example:
959
+ >>> train_loader, test_loader = get_nmnist_loaders(max_train=1000)
960
+ >>> for data, labels in train_loader:
961
+ ... # data: (batch, time, polarity, H, W)
962
+ ... pass
963
+ """
964
+ if _HAS_SNNTORCH:
965
+ try:
966
+ return _load_nmnist_snntorch(data_dir, num_steps, batch_size, max_train, max_test)
967
+ except Exception as e:
968
+ print(f" snnTorch N-MNIST loading failed: {e}")
969
+ print(" Falling back to synthetic spike data...")
970
+
971
+ return _generate_synthetic_nmnist(num_steps, batch_size, max_train, max_test)
972
+
973
+
974
+ def _load_nmnist_snntorch(
975
+ data_dir: str,
976
+ num_steps: int,
977
+ batch_size: int,
978
+ max_train: int,
979
+ max_test: int,
980
+ ) -> Tuple[DataLoader, DataLoader]:
981
+ """Load N-MNIST using snnTorch spikevision."""
982
+ from snntorch.spikevision import spikedata
983
+
984
+ print(f" Loading N-MNIST from snnTorch (data_dir={data_dir})...")
985
+
986
+ # snnTorch NMNIST returns data in format (time, polarity, H, W)
987
+ train_ds = spikedata.NMNIST(
988
+ root=data_dir,
989
+ train=True,
990
+ num_steps=num_steps,
991
+ dt=1000, # 1ms time bins
992
+ )
993
+ test_ds = spikedata.NMNIST(
994
+ root=data_dir,
995
+ train=False,
996
+ num_steps=num_steps,
997
+ dt=1000,
998
+ )
999
+
1000
+ # Apply sample limits using Subset
1001
+ if max_train > 0 and len(train_ds) > max_train:
1002
+ train_ds = torch.utils.data.Subset(train_ds, range(max_train))
1003
+ if max_test > 0 and len(test_ds) > max_test:
1004
+ test_ds = torch.utils.data.Subset(test_ds, range(max_test))
1005
+
1006
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=0)
1007
+ test_loader = DataLoader(test_ds, batch_size=batch_size, shuffle=False, num_workers=0)
1008
+
1009
+ return train_loader, test_loader
1010
+
1011
+
1012
+ def _generate_synthetic_nmnist(
1013
+ num_steps: int,
1014
+ batch_size: int,
1015
+ max_train: int,
1016
+ max_test: int,
1017
+ ) -> Tuple[DataLoader, DataLoader]:
1018
+ """Generate synthetic N-MNIST-like spike data.
1019
+
1020
+ Creates synthetic spike patterns for digits 0-9. Each digit has a
1021
+ distinct activation pattern simulating rate-coded neural spikes.
1022
+ """
1023
+ print(" Generating synthetic N-MNIST data...")
1024
+
1025
+ num_train = max_train if max_train > 0 else 5000
1026
+ num_test = max_test if max_test > 0 else 1000
1027
+ height, width = 32, 32
1028
+ num_classes = 10
1029
+
1030
+ def generate_digit_spikes(num_samples: int, seed: int) -> Tuple[torch.Tensor, torch.Tensor]:
1031
+ rng = torch.Generator().manual_seed(seed)
1032
+
1033
+ # Create templates for each digit - different spatial patterns
1034
+ templates = torch.zeros(num_classes, 2, height, width)
1035
+ for c in range(num_classes):
1036
+ # Each digit activates different regions
1037
+ y_start = (c // 2) * (height // 5)
1038
+ x_start = (c % 5) * (width // 5)
1039
+ y_end = min(y_start + height // 4, height)
1040
+ x_end = min(x_start + width // 4, width)
1041
+ templates[c, 0, y_start:y_end, x_start:x_end] = 0.8 # ON polarity
1042
+ templates[c, 1, y_start:y_end, x_start:x_end] = 0.3 # OFF polarity
1043
+
1044
+ labels = torch.randint(0, num_classes, (num_samples,), generator=rng)
1045
+
1046
+ # Generate spike data: (samples, time, polarity, H, W)
1047
+ data = torch.zeros(num_samples, num_steps, 2, height, width)
1048
+ for i, label in enumerate(labels):
1049
+ template = templates[label.item()]
1050
+ # Spike with probability proportional to template
1051
+ for t in range(num_steps):
1052
+ noise = torch.rand(2, height, width, generator=rng)
1053
+ spikes = (noise < template).float()
1054
+ data[i, t] = spikes
1055
+
1056
+ return data, labels
1057
+
1058
+ train_data, train_labels = generate_digit_spikes(num_train, seed=42)
1059
+ test_data, test_labels = generate_digit_spikes(num_test, seed=123)
1060
+
1061
+ train_ds = TensorDataset(train_data, train_labels)
1062
+ test_ds = TensorDataset(test_data, test_labels)
1063
+
1064
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=0)
1065
+ test_loader = DataLoader(test_ds, batch_size=batch_size, shuffle=False, num_workers=0)
1066
+
1067
+ print(f" Generated {num_train} train, {num_test} test samples (synthetic)")
1068
+ return train_loader, test_loader
1069
+
1070
+
1071
+ # ---------------------------------------------------------------------------
1072
+ # DVS-Gesture data loading
1073
+ # ---------------------------------------------------------------------------
1074
+
1075
+
1076
+ def get_dvs_gesture_loaders(
1077
+ data_dir: str = "/tmp/dvs_gesture",
1078
+ num_steps: int = 50,
1079
+ batch_size: int = 16,
1080
+ max_train: int = 0,
1081
+ max_test: int = 0,
1082
+ synthetic: bool = False,
1083
+ ) -> Tuple[DataLoader, DataLoader]:
1084
+ """Load DVS-Gesture neuromorphic dataset.
1085
+
1086
+ DVS-Gesture contains 11 hand gestures recorded with a DVS camera.
1087
+ The dataset is ~1.5GB, so fallback to synthetic is supported.
1088
+
1089
+ Uses snnTorch spikevision if available, falls back to synthetic spike data.
1090
+
1091
+ Args:
1092
+ data_dir: Directory to store/load DVS-Gesture data.
1093
+ num_steps: Number of timesteps for spike binning.
1094
+ batch_size: Batch size for DataLoader.
1095
+ max_train: Maximum training samples (0=full dataset, ~1077).
1096
+ max_test: Maximum test samples (0=full dataset, ~264).
1097
+ synthetic: Force synthetic data (skip download attempt).
1098
+
1099
+ Returns:
1100
+ Tuple of (train_loader, test_loader).
1101
+ Data format: (batch, num_steps, 2, 128, 128) or downsampled.
1102
+ Label format: (batch,) with values 0-10 (11 gesture classes).
1103
+
1104
+ Gesture classes:
1105
+ 0: hand_clapping, 1: right_hand_wave, 2: left_hand_wave,
1106
+ 3: right_arm_cw, 4: right_arm_ccw, 5: left_arm_cw,
1107
+ 6: left_arm_ccw, 7: arm_roll, 8: air_drums,
1108
+ 9: air_guitar, 10: other_gestures
1109
+
1110
+ Example:
1111
+ >>> train_loader, test_loader = get_dvs_gesture_loaders(synthetic=True)
1112
+ >>> for data, labels in train_loader:
1113
+ ... # data: (batch, time, polarity, H, W)
1114
+ ... pass
1115
+ """
1116
+ if synthetic:
1117
+ return _generate_synthetic_dvs_gesture(num_steps, batch_size, max_train, max_test)
1118
+
1119
+ if _HAS_SNNTORCH:
1120
+ try:
1121
+ return _load_dvs_gesture_snntorch(data_dir, num_steps, batch_size, max_train, max_test)
1122
+ except Exception as e:
1123
+ print(f" snnTorch DVS-Gesture loading failed: {e}")
1124
+ print(" Falling back to synthetic spike data...")
1125
+
1126
+ return _generate_synthetic_dvs_gesture(num_steps, batch_size, max_train, max_test)
1127
+
1128
+
1129
+ def _load_dvs_gesture_snntorch(
1130
+ data_dir: str,
1131
+ num_steps: int,
1132
+ batch_size: int,
1133
+ max_train: int,
1134
+ max_test: int,
1135
+ ) -> Tuple[DataLoader, DataLoader]:
1136
+ """Load DVS-Gesture using snnTorch spikevision."""
1137
+ from snntorch.spikevision import spikedata
1138
+
1139
+ print(f" Loading DVS-Gesture from snnTorch (data_dir={data_dir})...")
1140
+ print(" Note: First download is ~1.5GB and may take several minutes.")
1141
+
1142
+ train_ds = spikedata.DVSGesture(
1143
+ root=data_dir,
1144
+ train=True,
1145
+ num_steps=num_steps,
1146
+ dt=1000,
1147
+ )
1148
+ test_ds = spikedata.DVSGesture(
1149
+ root=data_dir,
1150
+ train=False,
1151
+ num_steps=num_steps,
1152
+ dt=1000,
1153
+ )
1154
+
1155
+ if max_train > 0 and len(train_ds) > max_train:
1156
+ train_ds = torch.utils.data.Subset(train_ds, range(max_train))
1157
+ if max_test > 0 and len(test_ds) > max_test:
1158
+ test_ds = torch.utils.data.Subset(test_ds, range(max_test))
1159
+
1160
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=0)
1161
+ test_loader = DataLoader(test_ds, batch_size=batch_size, shuffle=False, num_workers=0)
1162
+
1163
+ return train_loader, test_loader
1164
+
1165
+
1166
+ def _generate_synthetic_dvs_gesture(
1167
+ num_steps: int,
1168
+ batch_size: int,
1169
+ max_train: int,
1170
+ max_test: int,
1171
+ ) -> Tuple[DataLoader, DataLoader]:
1172
+ """Generate synthetic DVS-Gesture-like spike data.
1173
+
1174
+ Creates synthetic gesture patterns. Each gesture has a distinct
1175
+ spatio-temporal activation pattern.
1176
+ """
1177
+ print(" Generating synthetic DVS-Gesture data...")
1178
+
1179
+ num_train = max_train if max_train > 0 else 500
1180
+ num_test = max_test if max_test > 0 else 100
1181
+ height, width = 32, 32 # Downsampled from 128x128 for efficiency
1182
+ num_classes = 11
1183
+
1184
+ def generate_gesture_spikes(num_samples: int, seed: int) -> Tuple[torch.Tensor, torch.Tensor]:
1185
+ rng = torch.Generator().manual_seed(seed)
1186
+
1187
+ labels = torch.randint(0, num_classes, (num_samples,), generator=rng)
1188
+ data = torch.zeros(num_samples, num_steps, 2, height, width)
1189
+
1190
+ for i, label in enumerate(labels):
1191
+ c = label.item()
1192
+ # Each gesture has different temporal pattern
1193
+ for t in range(num_steps):
1194
+ # Spatial pattern varies by gesture class
1195
+ phase = (t / num_steps + c / num_classes) * 2 * 3.14159
1196
+ center_y = int((height // 2) + (height // 4) * np.sin(phase * (c % 3 + 1)))
1197
+ center_x = int((width // 2) + (width // 4) * np.cos(phase * (c % 4 + 1)))
1198
+
1199
+ # Create activation region around center
1200
+ y_start = max(0, center_y - height // 8)
1201
+ y_end = min(height, center_y + height // 8)
1202
+ x_start = max(0, center_x - width // 8)
1203
+ x_end = min(width, center_x + width // 8)
1204
+
1205
+ # Spike probability in active region
1206
+ prob = 0.3 + 0.3 * np.sin(phase)
1207
+ noise = torch.rand(y_end - y_start, x_end - x_start, generator=rng)
1208
+ spikes = (noise < prob).float()
1209
+
1210
+ data[i, t, c % 2, y_start:y_end, x_start:x_end] = spikes
1211
+
1212
+ return data, labels
1213
+
1214
+ train_data, train_labels = generate_gesture_spikes(num_train, seed=42)
1215
+ test_data, test_labels = generate_gesture_spikes(num_test, seed=123)
1216
+
1217
+ train_ds = TensorDataset(train_data, train_labels)
1218
+ test_ds = TensorDataset(test_data, test_labels)
1219
+
1220
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=0)
1221
+ test_loader = DataLoader(test_ds, batch_size=batch_size, shuffle=False, num_workers=0)
1222
+
1223
+ print(f" Generated {num_train} train, {num_test} test samples (synthetic gestures)")
1224
+ return train_loader, test_loader
1225
+
1226
+
1227
+ # ---------------------------------------------------------------------------
1228
+ # SNN evaluation utilities
1229
+ # ---------------------------------------------------------------------------
1230
+
1231
+
1232
+ @torch.no_grad()
1233
+ def evaluate_snn_accuracy(model: nn.Module, dataloader: DataLoader, scale_output: float = 10.0) -> float:
1234
+ """Compute classification accuracy for SNN models.
1235
+
1236
+ Args:
1237
+ model: SNN model that outputs spike rates.
1238
+ dataloader: DataLoader with (spike_data, labels).
1239
+ scale_output: Scale factor for spike rates before argmax.
1240
+ SNNs output rates in [0,1], scaling helps with CrossEntropyLoss.
1241
+
1242
+ Returns:
1243
+ Accuracy as a float between 0 and 1.
1244
+ """
1245
+ model.eval()
1246
+ device = next(model.parameters()).device
1247
+ correct = 0
1248
+ total = 0
1249
+
1250
+ for batch in dataloader:
1251
+ data, targets = batch
1252
+ data = data.to(device)
1253
+ targets = targets.to(device)
1254
+
1255
+ # SNN outputs spike rates
1256
+ outputs = model(data) * scale_output
1257
+ preds = outputs.argmax(dim=-1)
1258
+ correct += (preds == targets).sum().item()
1259
+ total += targets.size(0)
1260
+
1261
+ model.train()
1262
+ return correct / total if total > 0 else 0.0