astrohebbian 0.1.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.
- astrohebbian-0.1.0/LICENSE +21 -0
- astrohebbian-0.1.0/PKG-INFO +150 -0
- astrohebbian-0.1.0/README.md +98 -0
- astrohebbian-0.1.0/astrohebbian/__init__.py +23 -0
- astrohebbian-0.1.0/astrohebbian/benchmark.py +227 -0
- astrohebbian-0.1.0/astrohebbian/causal.py +99 -0
- astrohebbian-0.1.0/astrohebbian/language_model.py +40 -0
- astrohebbian-0.1.0/astrohebbian/model.py +174 -0
- astrohebbian-0.1.0/astrohebbian.egg-info/PKG-INFO +150 -0
- astrohebbian-0.1.0/astrohebbian.egg-info/SOURCES.txt +14 -0
- astrohebbian-0.1.0/astrohebbian.egg-info/dependency_links.txt +1 -0
- astrohebbian-0.1.0/astrohebbian.egg-info/requires.txt +11 -0
- astrohebbian-0.1.0/astrohebbian.egg-info/top_level.txt +1 -0
- astrohebbian-0.1.0/pyproject.toml +41 -0
- astrohebbian-0.1.0/setup.cfg +4 -0
- astrohebbian-0.1.0/tests/test_plugin.py +269 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: astrohebbian
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Standalone Astrocyte-Hebbian spiking linear Transformer components
|
|
5
|
+
Author: Sumith
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/Griffith-7/Snn-Transformer
|
|
29
|
+
Project-URL: Source, https://github.com/Griffith-7/Snn-Transformer
|
|
30
|
+
Project-URL: Issue Tracker, https://github.com/Griffith-7/Snn-Transformer/issues
|
|
31
|
+
Keywords: spiking-neural-networks,transformer,attention,hebbian,snns,pytorch
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Intended Audience :: Science/Research
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
40
|
+
Requires-Python: >=3.10
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
License-File: LICENSE
|
|
43
|
+
Requires-Dist: torch>=2.0
|
|
44
|
+
Provides-Extra: test
|
|
45
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
46
|
+
Provides-Extra: benchmark
|
|
47
|
+
Requires-Dist: torchvision>=0.15; extra == "benchmark"
|
|
48
|
+
Requires-Dist: matplotlib>=3.7; extra == "benchmark"
|
|
49
|
+
Provides-Extra: quality
|
|
50
|
+
Requires-Dist: ruff>=0.6; extra == "quality"
|
|
51
|
+
Dynamic: license-file
|
|
52
|
+
|
|
53
|
+
# Astrocyte-Hebbian Spiking Transformer Plugin
|
|
54
|
+
|
|
55
|
+
A standalone PyTorch plugin containing Astrocyte-Hebbian spiking linear-attention components. This project is intentionally independent from Exact-SNN; no Exact-SNN code, imports, or dependencies are included.
|
|
56
|
+
|
|
57
|
+
## Scope
|
|
58
|
+
|
|
59
|
+
This is a focused model plugin, not a general SNN framework. It provides:
|
|
60
|
+
|
|
61
|
+
- `AstrocyteHebbianAttention`: multi-head linear attention using binary Q/K/V activations.
|
|
62
|
+
- `AstrocyteHebbianBlock`: pre-norm Transformer-style block with a spiking FFN.
|
|
63
|
+
- `AstrocyteHebbianClassifier`: ready-to-train sequence classifier.
|
|
64
|
+
- `spike_fn`: binary Heaviside forward pass with surrogate gradients.
|
|
65
|
+
|
|
66
|
+
The implementation avoids an `N x N` attention matrix by computing the `K^T V` trace. It still uses ordinary dense PyTorch tensors for projections, normalization, residual paths, and training.
|
|
67
|
+
|
|
68
|
+
## What this is (and is not)
|
|
69
|
+
|
|
70
|
+
- **Binary activation spikes**: the inter-layer signals (Q, K, V, and the FFN hidden activation) are binary Heaviside spikes (0/1).
|
|
71
|
+
- **Surrogate gradients**: training uses a fast-sigmoid surrogate gradient through the spike threshold; it is not an exact spike-time gradient library.
|
|
72
|
+
- **Dense PyTorch execution**: forward/backward run on ordinary dense GPU tensors. This is a CPU/GPU software package, not an event-driven neuromorphic-hardware implementation, and reported times/memory are wall-clock/FLOP measurements, not hardware energy.
|
|
73
|
+
- **Full-sequence psMNIST mode**: the core `AstrocyteHebbianClassifier` (and the frozen baseline below) is full-sequence attention over N=784 pixels.
|
|
74
|
+
- **Separate causal LM experimental mode**: a distinct, experimental causal path (`CausalAstrocyteLanguageModel`) is provided for small language-model proof-of-concept work only.
|
|
75
|
+
|
|
76
|
+
This is a **focused model plugin, not a complete SNN framework**.
|
|
77
|
+
|
|
78
|
+
## Install
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install -e .
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
For development and tests:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install -e .[test]
|
|
88
|
+
pytest -q
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
For the optional psMNIST benchmark:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install -e .[benchmark]
|
|
95
|
+
python astrohebbian/benchmark.py
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
For a controlled three-seed summary:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python benchmarks/multi_seed.py --seeds 1 2 3 --output results/multi_seed.json
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
For the small causal language-model proof of concept:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
python benchmarks/lm_prototype.py \
|
|
108
|
+
--data /path/to/pretraining_code.jsonl \
|
|
109
|
+
--max-bytes 10000000 --steps 50 --output results/lm_prototype.json
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The causal LM is a separate experimental path. The full-sequence psMNIST model
|
|
113
|
+
and its baseline remain unchanged.
|
|
114
|
+
|
|
115
|
+
## Example
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import torch
|
|
119
|
+
from astrohebbian import AstrocyteHebbianClassifier
|
|
120
|
+
|
|
121
|
+
model = AstrocyteHebbianClassifier(
|
|
122
|
+
input_dim=1,
|
|
123
|
+
d_model=128,
|
|
124
|
+
seq_len=784,
|
|
125
|
+
num_heads=4,
|
|
126
|
+
v_levels=1,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
pixels = torch.randn(8, 784, 1)
|
|
130
|
+
logits = model(pixels)
|
|
131
|
+
print(logits.shape) # torch.Size([8, 10])
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Results
|
|
135
|
+
|
|
136
|
+
Frozen three-seed psMNIST baseline (N=784, 60k train / 10k test, 6 epochs, RTX 3050, batch 64):
|
|
137
|
+
|
|
138
|
+
| Model | 3-seed mean test acc | Peak VRAM |
|
|
139
|
+
| --- | ---: | ---: |
|
|
140
|
+
| AstroHebbian Pure SNN | **86.82% ± 2.48%** | ~1004 MB |
|
|
141
|
+
| Transformer (dense O(N²)) | 77.80% ± 3.06% | ~1565 MB |
|
|
142
|
+
|
|
143
|
+
The SNN beats the dense baseline by **+9.03 pts accuracy** at **~36% lower peak VRAM**.
|
|
144
|
+
Mean runtime is not a headline figure: seed 2 was a large hardware/runtime outlier,
|
|
145
|
+
so only accuracy and memory are claimed as reliable. Full per-seed data:
|
|
146
|
+
`results/multi_seed.json`; the N-scaling memory crossover is in `results/n_scaling.png`.
|
|
147
|
+
|
|
148
|
+
## Project status
|
|
149
|
+
|
|
150
|
+
This is the clean standalone starting point for production hardening. The current attention is full-sequence rather than causal or streaming. Results are tracked in `docs/benchmark_baseline.md`.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Astrocyte-Hebbian Spiking Transformer Plugin
|
|
2
|
+
|
|
3
|
+
A standalone PyTorch plugin containing Astrocyte-Hebbian spiking linear-attention components. This project is intentionally independent from Exact-SNN; no Exact-SNN code, imports, or dependencies are included.
|
|
4
|
+
|
|
5
|
+
## Scope
|
|
6
|
+
|
|
7
|
+
This is a focused model plugin, not a general SNN framework. It provides:
|
|
8
|
+
|
|
9
|
+
- `AstrocyteHebbianAttention`: multi-head linear attention using binary Q/K/V activations.
|
|
10
|
+
- `AstrocyteHebbianBlock`: pre-norm Transformer-style block with a spiking FFN.
|
|
11
|
+
- `AstrocyteHebbianClassifier`: ready-to-train sequence classifier.
|
|
12
|
+
- `spike_fn`: binary Heaviside forward pass with surrogate gradients.
|
|
13
|
+
|
|
14
|
+
The implementation avoids an `N x N` attention matrix by computing the `K^T V` trace. It still uses ordinary dense PyTorch tensors for projections, normalization, residual paths, and training.
|
|
15
|
+
|
|
16
|
+
## What this is (and is not)
|
|
17
|
+
|
|
18
|
+
- **Binary activation spikes**: the inter-layer signals (Q, K, V, and the FFN hidden activation) are binary Heaviside spikes (0/1).
|
|
19
|
+
- **Surrogate gradients**: training uses a fast-sigmoid surrogate gradient through the spike threshold; it is not an exact spike-time gradient library.
|
|
20
|
+
- **Dense PyTorch execution**: forward/backward run on ordinary dense GPU tensors. This is a CPU/GPU software package, not an event-driven neuromorphic-hardware implementation, and reported times/memory are wall-clock/FLOP measurements, not hardware energy.
|
|
21
|
+
- **Full-sequence psMNIST mode**: the core `AstrocyteHebbianClassifier` (and the frozen baseline below) is full-sequence attention over N=784 pixels.
|
|
22
|
+
- **Separate causal LM experimental mode**: a distinct, experimental causal path (`CausalAstrocyteLanguageModel`) is provided for small language-model proof-of-concept work only.
|
|
23
|
+
|
|
24
|
+
This is a **focused model plugin, not a complete SNN framework**.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install -e .
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For development and tests:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install -e .[test]
|
|
36
|
+
pytest -q
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For the optional psMNIST benchmark:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install -e .[benchmark]
|
|
43
|
+
python astrohebbian/benchmark.py
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
For a controlled three-seed summary:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
python benchmarks/multi_seed.py --seeds 1 2 3 --output results/multi_seed.json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
For the small causal language-model proof of concept:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python benchmarks/lm_prototype.py \
|
|
56
|
+
--data /path/to/pretraining_code.jsonl \
|
|
57
|
+
--max-bytes 10000000 --steps 50 --output results/lm_prototype.json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The causal LM is a separate experimental path. The full-sequence psMNIST model
|
|
61
|
+
and its baseline remain unchanged.
|
|
62
|
+
|
|
63
|
+
## Example
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import torch
|
|
67
|
+
from astrohebbian import AstrocyteHebbianClassifier
|
|
68
|
+
|
|
69
|
+
model = AstrocyteHebbianClassifier(
|
|
70
|
+
input_dim=1,
|
|
71
|
+
d_model=128,
|
|
72
|
+
seq_len=784,
|
|
73
|
+
num_heads=4,
|
|
74
|
+
v_levels=1,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
pixels = torch.randn(8, 784, 1)
|
|
78
|
+
logits = model(pixels)
|
|
79
|
+
print(logits.shape) # torch.Size([8, 10])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Results
|
|
83
|
+
|
|
84
|
+
Frozen three-seed psMNIST baseline (N=784, 60k train / 10k test, 6 epochs, RTX 3050, batch 64):
|
|
85
|
+
|
|
86
|
+
| Model | 3-seed mean test acc | Peak VRAM |
|
|
87
|
+
| --- | ---: | ---: |
|
|
88
|
+
| AstroHebbian Pure SNN | **86.82% ± 2.48%** | ~1004 MB |
|
|
89
|
+
| Transformer (dense O(N²)) | 77.80% ± 3.06% | ~1565 MB |
|
|
90
|
+
|
|
91
|
+
The SNN beats the dense baseline by **+9.03 pts accuracy** at **~36% lower peak VRAM**.
|
|
92
|
+
Mean runtime is not a headline figure: seed 2 was a large hardware/runtime outlier,
|
|
93
|
+
so only accuracy and memory are claimed as reliable. Full per-seed data:
|
|
94
|
+
`results/multi_seed.json`; the N-scaling memory crossover is in `results/n_scaling.png`.
|
|
95
|
+
|
|
96
|
+
## Project status
|
|
97
|
+
|
|
98
|
+
This is the clean standalone starting point for production hardening. The current attention is full-sequence rather than causal or streaming. Results are tracked in `docs/benchmark_baseline.md`.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Standalone Astrocyte-Hebbian spiking Transformer components."""
|
|
2
|
+
|
|
3
|
+
from .causal import CausalAstrocyteHebbianAttention
|
|
4
|
+
from .language_model import CausalAstrocyteLanguageModel
|
|
5
|
+
from .model import (
|
|
6
|
+
AstrocyteHebbianAttention,
|
|
7
|
+
AstrocyteHebbianBlock,
|
|
8
|
+
AstrocyteHebbianClassifier,
|
|
9
|
+
SpikingFFN,
|
|
10
|
+
SurrogateHeaviside,
|
|
11
|
+
spike_fn,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AstrocyteHebbianAttention",
|
|
16
|
+
"AstrocyteHebbianBlock",
|
|
17
|
+
"AstrocyteHebbianClassifier",
|
|
18
|
+
"SpikingFFN",
|
|
19
|
+
"SurrogateHeaviside",
|
|
20
|
+
"spike_fn",
|
|
21
|
+
"CausalAstrocyteHebbianAttention",
|
|
22
|
+
"CausalAstrocyteLanguageModel",
|
|
23
|
+
]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Reproducible psMNIST benchmark for Astrocyte-Hebbian attention."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import torch
|
|
13
|
+
import torch.nn as nn
|
|
14
|
+
import torch.optim as optim
|
|
15
|
+
from torch.utils.data import DataLoader, Subset
|
|
16
|
+
from torchvision import datasets, transforms
|
|
17
|
+
|
|
18
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
19
|
+
DATA_DIR = ROOT / "data"
|
|
20
|
+
CKPT_DIR = ROOT / "checkpoints"
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, str(ROOT))
|
|
23
|
+
from astrohebbian.model import AstrocyteHebbianClassifier # noqa: E402
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TransformerClassifier(nn.Module):
|
|
27
|
+
def __init__(self, d_in, d_model, seq_len, num_classes, num_heads):
|
|
28
|
+
super().__init__()
|
|
29
|
+
self.proj_in = nn.Linear(d_in, d_model)
|
|
30
|
+
self.pos_encoder = nn.Parameter(torch.randn(1, seq_len, d_model) * 0.02)
|
|
31
|
+
encoder_layer = nn.TransformerEncoderLayer(
|
|
32
|
+
d_model=d_model, nhead=num_heads, batch_first=True
|
|
33
|
+
)
|
|
34
|
+
self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=1)
|
|
35
|
+
self.classifier = nn.Linear(d_model, num_classes)
|
|
36
|
+
|
|
37
|
+
def forward(self, x):
|
|
38
|
+
x = self.proj_in(x) + self.pos_encoder[:, : x.shape[1], :]
|
|
39
|
+
return self.classifier(self.transformer(x)[:, -1, :])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def set_seed(seed):
|
|
43
|
+
random.seed(seed)
|
|
44
|
+
np.random.seed(seed)
|
|
45
|
+
torch.manual_seed(seed)
|
|
46
|
+
if torch.cuda.is_available():
|
|
47
|
+
torch.cuda.manual_seed_all(seed)
|
|
48
|
+
torch.backends.cudnn.benchmark = False
|
|
49
|
+
torch.backends.cudnn.deterministic = True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def warmup_cosine(step, warmup_steps, total_steps):
|
|
53
|
+
if step < warmup_steps:
|
|
54
|
+
return step / max(1, warmup_steps)
|
|
55
|
+
progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)
|
|
56
|
+
return 0.5 * (1.0 + math.cos(math.pi * progress))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@torch.no_grad()
|
|
60
|
+
def evaluate(model, test_loader, device, seq_len):
|
|
61
|
+
model.eval()
|
|
62
|
+
correct = 0
|
|
63
|
+
total = 0
|
|
64
|
+
for data, target in test_loader:
|
|
65
|
+
data = data.view(data.size(0), seq_len, 1).to(device)
|
|
66
|
+
target = target.to(device)
|
|
67
|
+
correct += model(data).argmax(dim=1).eq(target).sum().item()
|
|
68
|
+
total += data.size(0)
|
|
69
|
+
return 100.0 * correct / total
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def train_model(model, name, train_loader, test_loader, device, epochs, seq_len):
|
|
73
|
+
print(f"\n--- Training {name} ---", flush=True)
|
|
74
|
+
model = model.to(device)
|
|
75
|
+
optimizer = optim.Adam(model.parameters(), lr=1e-3)
|
|
76
|
+
criterion = nn.CrossEntropyLoss()
|
|
77
|
+
steps_per_epoch = len(train_loader)
|
|
78
|
+
scheduler = optim.lr_scheduler.LambdaLR(
|
|
79
|
+
optimizer,
|
|
80
|
+
lambda step: warmup_cosine(step, steps_per_epoch, steps_per_epoch * epochs),
|
|
81
|
+
)
|
|
82
|
+
if device.type == "cuda":
|
|
83
|
+
torch.cuda.reset_peak_memory_stats(device)
|
|
84
|
+
start_time = time.perf_counter()
|
|
85
|
+
for epoch in range(epochs):
|
|
86
|
+
model.train()
|
|
87
|
+
correct = 0
|
|
88
|
+
total = 0
|
|
89
|
+
for data, target in train_loader:
|
|
90
|
+
data = data.view(data.size(0), seq_len, 1).to(device, non_blocking=True)
|
|
91
|
+
target = target.to(device, non_blocking=True)
|
|
92
|
+
optimizer.zero_grad(set_to_none=True)
|
|
93
|
+
output = model(data)
|
|
94
|
+
loss = criterion(output, target)
|
|
95
|
+
loss.backward()
|
|
96
|
+
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
|
97
|
+
optimizer.step()
|
|
98
|
+
scheduler.step()
|
|
99
|
+
correct += output.argmax(dim=1).eq(target).sum().item()
|
|
100
|
+
total += data.size(0)
|
|
101
|
+
print(
|
|
102
|
+
f"Epoch {epoch + 1}/{epochs} | train acc: {100.0 * correct / total:.2f}%"
|
|
103
|
+
f" | lr: {scheduler.get_last_lr()[0]:.6f}",
|
|
104
|
+
flush=True,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
elapsed = time.perf_counter() - start_time
|
|
108
|
+
peak_memory = (
|
|
109
|
+
torch.cuda.max_memory_allocated(device) / (1024 * 1024)
|
|
110
|
+
if device.type == "cuda"
|
|
111
|
+
else 0.0
|
|
112
|
+
)
|
|
113
|
+
test_accuracy = evaluate(model, test_loader, device, seq_len)
|
|
114
|
+
result = {
|
|
115
|
+
"test_accuracy": test_accuracy,
|
|
116
|
+
"train_time_seconds": elapsed,
|
|
117
|
+
"peak_vram_mb": peak_memory,
|
|
118
|
+
}
|
|
119
|
+
print(
|
|
120
|
+
f"[{name}] Time: {elapsed:.2f}s | VRAM: {peak_memory:.2f} MB"
|
|
121
|
+
f" | TEST acc: {test_accuracy:.2f}%",
|
|
122
|
+
flush=True,
|
|
123
|
+
)
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_parser():
|
|
128
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
129
|
+
parser.add_argument("--seed", type=int, default=42)
|
|
130
|
+
parser.add_argument("--device", choices=("auto", "cpu", "cuda"), default="auto")
|
|
131
|
+
parser.add_argument("--epochs", type=int, default=6)
|
|
132
|
+
parser.add_argument("--batch-size", type=int, default=64)
|
|
133
|
+
parser.add_argument("--test-batch-size", type=int, default=512)
|
|
134
|
+
parser.add_argument("--d-model", type=int, default=128)
|
|
135
|
+
parser.add_argument("--num-heads", type=int, default=4)
|
|
136
|
+
parser.add_argument("--num-layers", type=int, default=1)
|
|
137
|
+
parser.add_argument("--v-levels", type=int, default=1)
|
|
138
|
+
parser.add_argument("--train-size", type=int, default=None)
|
|
139
|
+
parser.add_argument("--test-size", type=int, default=None)
|
|
140
|
+
parser.add_argument("--output", type=Path, default=None, help="Write results to JSON")
|
|
141
|
+
parser.add_argument(
|
|
142
|
+
"--save-checkpoints", action="store_true", help="Save model state dictionaries"
|
|
143
|
+
)
|
|
144
|
+
return parser
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def select_device(requested):
|
|
148
|
+
if requested == "cuda" and not torch.cuda.is_available():
|
|
149
|
+
raise RuntimeError("CUDA was requested but is not available")
|
|
150
|
+
if requested == "auto":
|
|
151
|
+
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
152
|
+
return torch.device(requested)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def main(argv=None):
|
|
156
|
+
args = build_parser().parse_args(argv)
|
|
157
|
+
if args.epochs <= 0 or args.batch_size <= 0 or args.test_batch_size <= 0:
|
|
158
|
+
raise ValueError("epochs and batch sizes must be positive")
|
|
159
|
+
set_seed(args.seed)
|
|
160
|
+
device = select_device(args.device)
|
|
161
|
+
seq_len = 784
|
|
162
|
+
transform = transforms.Compose(
|
|
163
|
+
[transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))]
|
|
164
|
+
)
|
|
165
|
+
train_dataset = datasets.MNIST(str(DATA_DIR), train=True, download=True, transform=transform)
|
|
166
|
+
test_dataset = datasets.MNIST(str(DATA_DIR), train=False, download=True, transform=transform)
|
|
167
|
+
if args.train_size is not None:
|
|
168
|
+
train_dataset = Subset(train_dataset, range(min(args.train_size, len(train_dataset))))
|
|
169
|
+
if args.test_size is not None:
|
|
170
|
+
test_dataset = Subset(test_dataset, range(min(args.test_size, len(test_dataset))))
|
|
171
|
+
generator = torch.Generator().manual_seed(args.seed)
|
|
172
|
+
train_loader = DataLoader(
|
|
173
|
+
train_dataset, batch_size=args.batch_size, shuffle=True,
|
|
174
|
+
generator=generator, pin_memory=device.type == "cuda"
|
|
175
|
+
)
|
|
176
|
+
test_loader = DataLoader(test_dataset, batch_size=args.test_batch_size, shuffle=False)
|
|
177
|
+
|
|
178
|
+
print(f"BENCHMARK | seed: {args.seed} | device: {device}", flush=True)
|
|
179
|
+
print(
|
|
180
|
+
f"psMNIST (N={seq_len}) | train: {len(train_dataset)} | test: {len(test_dataset)}"
|
|
181
|
+
f" | {args.epochs} epochs | warmup+cosine | clip 1.0",
|
|
182
|
+
flush=True,
|
|
183
|
+
)
|
|
184
|
+
results = {
|
|
185
|
+
"config": {
|
|
186
|
+
"seed": args.seed, "device": str(device), "epochs": args.epochs,
|
|
187
|
+
"batch_size": args.batch_size, "test_batch_size": args.test_batch_size,
|
|
188
|
+
"d_model": args.d_model, "num_heads": args.num_heads,
|
|
189
|
+
"num_layers": args.num_layers, "v_levels": args.v_levels,
|
|
190
|
+
"train_size": len(train_dataset), "test_size": len(test_dataset),
|
|
191
|
+
},
|
|
192
|
+
"models": {},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
snn = AstrocyteHebbianClassifier(
|
|
196
|
+
d_model=args.d_model, num_heads=args.num_heads,
|
|
197
|
+
num_layers=args.num_layers, seq_len=seq_len, v_levels=args.v_levels
|
|
198
|
+
)
|
|
199
|
+
results["models"]["AstroHebbian Pure SNN"] = train_model(
|
|
200
|
+
snn, "AstroHebbian Pure SNN", train_loader, test_loader, device, args.epochs, seq_len
|
|
201
|
+
)
|
|
202
|
+
if args.save_checkpoints:
|
|
203
|
+
CKPT_DIR.mkdir(exist_ok=True)
|
|
204
|
+
torch.save(snn.state_dict(), CKPT_DIR / "astrohebbian_pure_snn.pt")
|
|
205
|
+
del snn
|
|
206
|
+
if device.type == "cuda":
|
|
207
|
+
torch.cuda.empty_cache()
|
|
208
|
+
|
|
209
|
+
transformer = TransformerClassifier(1, args.d_model, seq_len, 10, args.num_heads)
|
|
210
|
+
results["models"]["Transformer (dense)"] = train_model(
|
|
211
|
+
transformer, "Transformer baseline", train_loader, test_loader, device, args.epochs, seq_len
|
|
212
|
+
)
|
|
213
|
+
if args.save_checkpoints:
|
|
214
|
+
torch.save(transformer.state_dict(), CKPT_DIR / "transformer_baseline.pt")
|
|
215
|
+
|
|
216
|
+
if args.output is not None:
|
|
217
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
args.output.write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8")
|
|
219
|
+
print(f"Wrote results to {args.output}", flush=True)
|
|
220
|
+
return results
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
main()
|
|
225
|
+
|
|
226
|
+
if __name__ == "__main__":
|
|
227
|
+
main()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Causal, state-updating Astrocyte-Hebbian attention."""
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
import torch.nn as nn
|
|
5
|
+
|
|
6
|
+
from .model import spike_fn
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CausalAstrocyteHebbianAttention(nn.Module):
|
|
10
|
+
"""Causal linear attention with a recurrent decayed Hebbian state.
|
|
11
|
+
|
|
12
|
+
Unlike the full-sequence attention module, each output only reads the
|
|
13
|
+
prefix ending at its current token. The state update is explicit so the
|
|
14
|
+
module can later be adapted to streaming execution.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, d_model=128, num_heads=4, v_levels=1):
|
|
18
|
+
super().__init__()
|
|
19
|
+
if d_model <= 0 or num_heads <= 0 or d_model % num_heads != 0:
|
|
20
|
+
raise ValueError("d_model must be positive and divisible by num_heads")
|
|
21
|
+
if v_levels != 1:
|
|
22
|
+
raise ValueError("the causal prototype currently supports v_levels=1 only")
|
|
23
|
+
self.d_model = d_model
|
|
24
|
+
self.num_heads = num_heads
|
|
25
|
+
self.head_dim = d_model // num_heads
|
|
26
|
+
self.q_proj = nn.Linear(d_model, d_model)
|
|
27
|
+
self.k_proj = nn.Linear(d_model, d_model)
|
|
28
|
+
self.v_proj = nn.Linear(d_model, d_model)
|
|
29
|
+
self.o_proj = nn.Linear(d_model, d_model)
|
|
30
|
+
self.decay_logit = nn.Parameter(torch.empty(d_model).uniform_(5.0, 8.0))
|
|
31
|
+
self.eps = 1e-6
|
|
32
|
+
|
|
33
|
+
def forward(self, x, implementation="recurrent"):
|
|
34
|
+
if x.ndim != 3 or x.shape[-1] != self.d_model:
|
|
35
|
+
raise ValueError(f"x must have shape (batch, sequence, {self.d_model})")
|
|
36
|
+
if x.shape[1] == 0:
|
|
37
|
+
raise ValueError("sequence length must be positive")
|
|
38
|
+
if implementation not in ("recurrent", "parallel"):
|
|
39
|
+
raise ValueError("implementation must be 'recurrent' or 'parallel'")
|
|
40
|
+
batch_size, sequence_length, _ = x.shape
|
|
41
|
+
query = spike_fn(self.q_proj(x)).view(
|
|
42
|
+
batch_size, sequence_length, self.num_heads, self.head_dim
|
|
43
|
+
).transpose(1, 2)
|
|
44
|
+
key = spike_fn(self.k_proj(x)).view(
|
|
45
|
+
batch_size, sequence_length, self.num_heads, self.head_dim
|
|
46
|
+
).transpose(1, 2)
|
|
47
|
+
value = spike_fn((torch.tanh(self.v_proj(x)) + 1.0) / 2.0 - 0.5).view(
|
|
48
|
+
batch_size, sequence_length, self.num_heads, self.head_dim
|
|
49
|
+
).transpose(1, 2)
|
|
50
|
+
decay = torch.sigmoid(self.decay_logit).view(
|
|
51
|
+
1, self.num_heads, self.head_dim
|
|
52
|
+
).to(x.dtype)
|
|
53
|
+
if implementation == "parallel":
|
|
54
|
+
positions = torch.arange(
|
|
55
|
+
sequence_length, device=x.device, dtype=x.dtype
|
|
56
|
+
).view(1, 1, sequence_length, 1)
|
|
57
|
+
inverse_decay = decay.reciprocal()
|
|
58
|
+
powers = inverse_decay.unsqueeze(-2).pow(positions)
|
|
59
|
+
pair = key.unsqueeze(-1) * value.unsqueeze(-2)
|
|
60
|
+
trace = (
|
|
61
|
+
torch.cumsum(pair * powers.unsqueeze(-1), dim=2)
|
|
62
|
+
* decay.unsqueeze(-2).pow(positions).unsqueeze(-1)
|
|
63
|
+
)
|
|
64
|
+
mass = (
|
|
65
|
+
torch.cumsum(key * powers.squeeze(-1), dim=2)
|
|
66
|
+
* decay.unsqueeze(-2).pow(positions)
|
|
67
|
+
)
|
|
68
|
+
numerator = torch.matmul(query.unsqueeze(-2), trace).squeeze(-2)
|
|
69
|
+
denominator = (
|
|
70
|
+
(query * mass).sum(dim=-1, keepdim=True).clamp_min(self.eps)
|
|
71
|
+
)
|
|
72
|
+
output = numerator / denominator
|
|
73
|
+
output = output.transpose(1, 2).contiguous().view(
|
|
74
|
+
batch_size, sequence_length, self.d_model
|
|
75
|
+
)
|
|
76
|
+
return self.o_proj(output)
|
|
77
|
+
|
|
78
|
+
trace = torch.zeros(
|
|
79
|
+
batch_size, self.num_heads, self.head_dim, self.head_dim,
|
|
80
|
+
device=x.device, dtype=x.dtype,
|
|
81
|
+
)
|
|
82
|
+
mass = torch.zeros(
|
|
83
|
+
batch_size, self.num_heads, self.head_dim,
|
|
84
|
+
device=x.device, dtype=x.dtype,
|
|
85
|
+
)
|
|
86
|
+
outputs = []
|
|
87
|
+
for position in range(sequence_length):
|
|
88
|
+
key_position = key[:, :, position].unsqueeze(-1)
|
|
89
|
+
value_position = value[:, :, position].unsqueeze(-2)
|
|
90
|
+
trace = trace * decay.unsqueeze(-1) + key_position * value_position
|
|
91
|
+
mass = mass * decay + key[:, :, position]
|
|
92
|
+
numerator = torch.matmul(query[:, :, position].unsqueeze(-2), trace).squeeze(-2)
|
|
93
|
+
denominator = (
|
|
94
|
+
query[:, :, position] * mass
|
|
95
|
+
).sum(dim=-1, keepdim=True).clamp_min(self.eps)
|
|
96
|
+
outputs.append(numerator / denominator)
|
|
97
|
+
output = torch.stack(outputs, dim=2)
|
|
98
|
+
output = output.transpose(1, 2).contiguous().view(batch_size, sequence_length, self.d_model)
|
|
99
|
+
return self.o_proj(output)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Small causal language-model prototype using the causal attention module."""
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
import torch.nn as nn
|
|
5
|
+
|
|
6
|
+
from .causal import CausalAstrocyteHebbianAttention
|
|
7
|
+
from .model import SpikingFFN
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CausalAstrocyteLanguageModel(nn.Module):
|
|
11
|
+
"""Byte-level causal LM for small proof-of-concept experiments."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self, vocab_size=256, d_model=64, seq_len=128, num_heads=4,
|
|
15
|
+
attention_implementation="parallel",
|
|
16
|
+
):
|
|
17
|
+
super().__init__()
|
|
18
|
+
if vocab_size <= 0 or seq_len <= 0:
|
|
19
|
+
raise ValueError("vocab_size and seq_len must be positive")
|
|
20
|
+
self.seq_len = seq_len
|
|
21
|
+
if attention_implementation not in ("recurrent", "parallel"):
|
|
22
|
+
raise ValueError("attention_implementation must be recurrent or parallel")
|
|
23
|
+
self.attention_implementation = attention_implementation
|
|
24
|
+
self.embedding = nn.Embedding(vocab_size, d_model)
|
|
25
|
+
self.pos_encoder = nn.Parameter(torch.randn(1, seq_len, d_model) * 0.02)
|
|
26
|
+
self.norm1 = nn.LayerNorm(d_model)
|
|
27
|
+
self.attention = CausalAstrocyteHebbianAttention(d_model, num_heads)
|
|
28
|
+
self.norm2 = nn.LayerNorm(d_model)
|
|
29
|
+
self.ffn = SpikingFFN(d_model, expansion=2)
|
|
30
|
+
self.lm_head = nn.Linear(d_model, vocab_size)
|
|
31
|
+
|
|
32
|
+
def forward(self, token_ids):
|
|
33
|
+
if token_ids.ndim != 2 or token_ids.shape[1] > self.seq_len:
|
|
34
|
+
raise ValueError("token_ids must have shape (batch, sequence <= seq_len)")
|
|
35
|
+
hidden = self.embedding(token_ids) + self.pos_encoder[:, : token_ids.shape[1], :]
|
|
36
|
+
hidden = hidden + self.attention(
|
|
37
|
+
self.norm1(hidden), implementation=self.attention_implementation
|
|
38
|
+
)
|
|
39
|
+
hidden = hidden + self.ffn(self.norm2(hidden))
|
|
40
|
+
return self.lm_head(hidden)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Astrocyte-Hebbian spiking linear Transformer components.
|
|
2
|
+
|
|
3
|
+
This package is intentionally standalone. It depends on PyTorch only and does
|
|
4
|
+
not import or depend on Exact-SNN.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
import torch.nn as nn
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SurrogateHeaviside(torch.autograd.Function):
|
|
12
|
+
"""Binary threshold in the forward pass with a fast-sigmoid backward pass."""
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def forward(ctx, input):
|
|
16
|
+
ctx.save_for_backward(input)
|
|
17
|
+
return (input > 0).to(input.dtype)
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def backward(ctx, grad_output):
|
|
21
|
+
(input,) = ctx.saved_tensors
|
|
22
|
+
alpha = 10.0
|
|
23
|
+
sigmoid = torch.sigmoid(alpha * input)
|
|
24
|
+
return grad_output * sigmoid * (1 - sigmoid) * alpha
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
spike_fn = SurrogateHeaviside.apply
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AstrocyteHebbianAttention(nn.Module):
|
|
31
|
+
"""Multi-head spiking linear attention with learnable channel decay."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, d_model=128, num_heads=4, v_levels=1):
|
|
34
|
+
super().__init__()
|
|
35
|
+
if d_model <= 0 or num_heads <= 0 or d_model % num_heads != 0:
|
|
36
|
+
raise ValueError("d_model must be positive and divisible by num_heads")
|
|
37
|
+
if v_levels <= 0:
|
|
38
|
+
raise ValueError("v_levels must be positive")
|
|
39
|
+
|
|
40
|
+
self.d_model = d_model
|
|
41
|
+
self.num_heads = num_heads
|
|
42
|
+
self.head_dim = d_model // num_heads
|
|
43
|
+
self.value_levels = int(v_levels)
|
|
44
|
+
self.q_proj = nn.Linear(d_model, d_model)
|
|
45
|
+
self.k_proj = nn.Linear(d_model, d_model)
|
|
46
|
+
self.v_proj = nn.Linear(d_model, d_model)
|
|
47
|
+
self.o_proj = nn.Linear(d_model, d_model)
|
|
48
|
+
self.register_buffer(
|
|
49
|
+
"thresholds",
|
|
50
|
+
torch.arange(1, self.value_levels + 1, dtype=torch.float32)
|
|
51
|
+
/ (self.value_levels + 1),
|
|
52
|
+
)
|
|
53
|
+
self.decay_logit = nn.Parameter(torch.empty(d_model).uniform_(5.0, 8.0))
|
|
54
|
+
self.eps = 1e-6
|
|
55
|
+
|
|
56
|
+
def forward(self, x):
|
|
57
|
+
if x.ndim != 3:
|
|
58
|
+
raise ValueError("x must have shape (batch, sequence, d_model)")
|
|
59
|
+
batch_size, sequence_length, d_model = x.shape
|
|
60
|
+
if sequence_length == 0:
|
|
61
|
+
raise ValueError("sequence length must be positive")
|
|
62
|
+
if d_model != self.d_model:
|
|
63
|
+
raise ValueError(f"last dimension must be {self.d_model}")
|
|
64
|
+
|
|
65
|
+
query = spike_fn(self.q_proj(x))
|
|
66
|
+
key = spike_fn(self.k_proj(x))
|
|
67
|
+
value_unit = (torch.tanh(self.v_proj(x)) + 1.0) / 2.0
|
|
68
|
+
if self.value_levels == 1:
|
|
69
|
+
value = spike_fn(value_unit - self.thresholds[0])
|
|
70
|
+
else:
|
|
71
|
+
value_spikes = spike_fn(
|
|
72
|
+
value_unit.unsqueeze(-1) - self.thresholds.view(1, 1, 1, -1)
|
|
73
|
+
)
|
|
74
|
+
value = value_spikes.mean(dim=-1)
|
|
75
|
+
|
|
76
|
+
positions = torch.arange(
|
|
77
|
+
sequence_length - 1,
|
|
78
|
+
-1,
|
|
79
|
+
-1,
|
|
80
|
+
device=x.device,
|
|
81
|
+
dtype=x.dtype,
|
|
82
|
+
)
|
|
83
|
+
decay = torch.sigmoid(self.decay_logit).to(x.dtype)
|
|
84
|
+
weights = decay.unsqueeze(0).pow(positions.unsqueeze(1))
|
|
85
|
+
weighted_key = key * weights.unsqueeze(0)
|
|
86
|
+
|
|
87
|
+
query = query.view(
|
|
88
|
+
batch_size, sequence_length, self.num_heads, self.head_dim
|
|
89
|
+
).transpose(1, 2)
|
|
90
|
+
weighted_key = weighted_key.view(
|
|
91
|
+
batch_size, sequence_length, self.num_heads, self.head_dim
|
|
92
|
+
).transpose(1, 2)
|
|
93
|
+
value = value.view(
|
|
94
|
+
batch_size, sequence_length, self.num_heads, self.head_dim
|
|
95
|
+
).transpose(1, 2)
|
|
96
|
+
|
|
97
|
+
key_value_trace = torch.matmul(weighted_key.transpose(-2, -1), value)
|
|
98
|
+
key_mass = weighted_key.sum(dim=-2, keepdim=True).transpose(-2, -1)
|
|
99
|
+
numerator = torch.matmul(query, key_value_trace)
|
|
100
|
+
denominator = torch.matmul(query, key_mass) + self.eps
|
|
101
|
+
output = numerator / denominator
|
|
102
|
+
|
|
103
|
+
output = output.transpose(1, 2).contiguous().view(batch_size, sequence_length, d_model)
|
|
104
|
+
return self.o_proj(output)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SpikingFFN(nn.Module):
|
|
108
|
+
"""Feed-forward network with a binary hidden activation."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, d_model=128, expansion=4):
|
|
111
|
+
super().__init__()
|
|
112
|
+
if d_model <= 0 or expansion <= 0:
|
|
113
|
+
raise ValueError("d_model and expansion must be positive")
|
|
114
|
+
self.fc1 = nn.Linear(d_model, d_model * expansion)
|
|
115
|
+
self.fc2 = nn.Linear(d_model * expansion, d_model)
|
|
116
|
+
|
|
117
|
+
def forward(self, x):
|
|
118
|
+
return self.fc2(spike_fn(self.fc1(x)))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class AstrocyteHebbianBlock(nn.Module):
|
|
122
|
+
"""Pre-norm Transformer block using Astrocyte-Hebbian attention."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, d_model=128, num_heads=4, expansion=4, v_levels=1):
|
|
125
|
+
super().__init__()
|
|
126
|
+
self.norm1 = nn.LayerNorm(d_model)
|
|
127
|
+
self.attention = AstrocyteHebbianAttention(d_model, num_heads, v_levels)
|
|
128
|
+
self.norm2 = nn.LayerNorm(d_model)
|
|
129
|
+
self.ffn = SpikingFFN(d_model, expansion)
|
|
130
|
+
|
|
131
|
+
def forward(self, x):
|
|
132
|
+
x = x + self.attention(self.norm1(x))
|
|
133
|
+
return x + self.ffn(self.norm2(x))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class AstrocyteHebbianClassifier(nn.Module):
|
|
137
|
+
"""Sequence classifier built from standalone Astrocyte-Hebbian blocks."""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
input_dim=1,
|
|
142
|
+
d_model=128,
|
|
143
|
+
seq_len=784,
|
|
144
|
+
num_classes=10,
|
|
145
|
+
num_layers=1,
|
|
146
|
+
num_heads=4,
|
|
147
|
+
v_levels=1,
|
|
148
|
+
):
|
|
149
|
+
super().__init__()
|
|
150
|
+
if seq_len <= 0 or num_classes <= 0 or num_layers <= 0:
|
|
151
|
+
raise ValueError("seq_len, num_classes, and num_layers must be positive")
|
|
152
|
+
if input_dim <= 0 or d_model <= 0 or num_heads <= 0:
|
|
153
|
+
raise ValueError("input_dim, d_model, and num_heads must be positive")
|
|
154
|
+
self.embedding = nn.Linear(input_dim, d_model)
|
|
155
|
+
self.pos_encoder = nn.Parameter(torch.randn(1, seq_len, d_model) * 0.02)
|
|
156
|
+
self.blocks = nn.ModuleList(
|
|
157
|
+
[
|
|
158
|
+
AstrocyteHebbianBlock(
|
|
159
|
+
d_model, num_heads, v_levels=v_levels
|
|
160
|
+
)
|
|
161
|
+
for _ in range(num_layers)
|
|
162
|
+
]
|
|
163
|
+
)
|
|
164
|
+
self.classifier = nn.Linear(d_model, num_classes)
|
|
165
|
+
|
|
166
|
+
def forward(self, x):
|
|
167
|
+
if x.ndim != 3 or x.shape[-1] != self.embedding.in_features:
|
|
168
|
+
raise ValueError("x must have shape (batch, sequence, input_dim)")
|
|
169
|
+
if x.shape[1] > self.pos_encoder.shape[1]:
|
|
170
|
+
raise ValueError("sequence length exceeds configured seq_len")
|
|
171
|
+
x = self.embedding(x) + self.pos_encoder[:, : x.shape[1], :]
|
|
172
|
+
for block in self.blocks:
|
|
173
|
+
x = block(x)
|
|
174
|
+
return self.classifier(x[:, -1, :])
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: astrohebbian
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Standalone Astrocyte-Hebbian spiking linear Transformer components
|
|
5
|
+
Author: Sumith
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/Griffith-7/Snn-Transformer
|
|
29
|
+
Project-URL: Source, https://github.com/Griffith-7/Snn-Transformer
|
|
30
|
+
Project-URL: Issue Tracker, https://github.com/Griffith-7/Snn-Transformer/issues
|
|
31
|
+
Keywords: spiking-neural-networks,transformer,attention,hebbian,snns,pytorch
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Intended Audience :: Science/Research
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
40
|
+
Requires-Python: >=3.10
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
License-File: LICENSE
|
|
43
|
+
Requires-Dist: torch>=2.0
|
|
44
|
+
Provides-Extra: test
|
|
45
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
46
|
+
Provides-Extra: benchmark
|
|
47
|
+
Requires-Dist: torchvision>=0.15; extra == "benchmark"
|
|
48
|
+
Requires-Dist: matplotlib>=3.7; extra == "benchmark"
|
|
49
|
+
Provides-Extra: quality
|
|
50
|
+
Requires-Dist: ruff>=0.6; extra == "quality"
|
|
51
|
+
Dynamic: license-file
|
|
52
|
+
|
|
53
|
+
# Astrocyte-Hebbian Spiking Transformer Plugin
|
|
54
|
+
|
|
55
|
+
A standalone PyTorch plugin containing Astrocyte-Hebbian spiking linear-attention components. This project is intentionally independent from Exact-SNN; no Exact-SNN code, imports, or dependencies are included.
|
|
56
|
+
|
|
57
|
+
## Scope
|
|
58
|
+
|
|
59
|
+
This is a focused model plugin, not a general SNN framework. It provides:
|
|
60
|
+
|
|
61
|
+
- `AstrocyteHebbianAttention`: multi-head linear attention using binary Q/K/V activations.
|
|
62
|
+
- `AstrocyteHebbianBlock`: pre-norm Transformer-style block with a spiking FFN.
|
|
63
|
+
- `AstrocyteHebbianClassifier`: ready-to-train sequence classifier.
|
|
64
|
+
- `spike_fn`: binary Heaviside forward pass with surrogate gradients.
|
|
65
|
+
|
|
66
|
+
The implementation avoids an `N x N` attention matrix by computing the `K^T V` trace. It still uses ordinary dense PyTorch tensors for projections, normalization, residual paths, and training.
|
|
67
|
+
|
|
68
|
+
## What this is (and is not)
|
|
69
|
+
|
|
70
|
+
- **Binary activation spikes**: the inter-layer signals (Q, K, V, and the FFN hidden activation) are binary Heaviside spikes (0/1).
|
|
71
|
+
- **Surrogate gradients**: training uses a fast-sigmoid surrogate gradient through the spike threshold; it is not an exact spike-time gradient library.
|
|
72
|
+
- **Dense PyTorch execution**: forward/backward run on ordinary dense GPU tensors. This is a CPU/GPU software package, not an event-driven neuromorphic-hardware implementation, and reported times/memory are wall-clock/FLOP measurements, not hardware energy.
|
|
73
|
+
- **Full-sequence psMNIST mode**: the core `AstrocyteHebbianClassifier` (and the frozen baseline below) is full-sequence attention over N=784 pixels.
|
|
74
|
+
- **Separate causal LM experimental mode**: a distinct, experimental causal path (`CausalAstrocyteLanguageModel`) is provided for small language-model proof-of-concept work only.
|
|
75
|
+
|
|
76
|
+
This is a **focused model plugin, not a complete SNN framework**.
|
|
77
|
+
|
|
78
|
+
## Install
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install -e .
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
For development and tests:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install -e .[test]
|
|
88
|
+
pytest -q
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
For the optional psMNIST benchmark:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install -e .[benchmark]
|
|
95
|
+
python astrohebbian/benchmark.py
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
For a controlled three-seed summary:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python benchmarks/multi_seed.py --seeds 1 2 3 --output results/multi_seed.json
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
For the small causal language-model proof of concept:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
python benchmarks/lm_prototype.py \
|
|
108
|
+
--data /path/to/pretraining_code.jsonl \
|
|
109
|
+
--max-bytes 10000000 --steps 50 --output results/lm_prototype.json
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The causal LM is a separate experimental path. The full-sequence psMNIST model
|
|
113
|
+
and its baseline remain unchanged.
|
|
114
|
+
|
|
115
|
+
## Example
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import torch
|
|
119
|
+
from astrohebbian import AstrocyteHebbianClassifier
|
|
120
|
+
|
|
121
|
+
model = AstrocyteHebbianClassifier(
|
|
122
|
+
input_dim=1,
|
|
123
|
+
d_model=128,
|
|
124
|
+
seq_len=784,
|
|
125
|
+
num_heads=4,
|
|
126
|
+
v_levels=1,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
pixels = torch.randn(8, 784, 1)
|
|
130
|
+
logits = model(pixels)
|
|
131
|
+
print(logits.shape) # torch.Size([8, 10])
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Results
|
|
135
|
+
|
|
136
|
+
Frozen three-seed psMNIST baseline (N=784, 60k train / 10k test, 6 epochs, RTX 3050, batch 64):
|
|
137
|
+
|
|
138
|
+
| Model | 3-seed mean test acc | Peak VRAM |
|
|
139
|
+
| --- | ---: | ---: |
|
|
140
|
+
| AstroHebbian Pure SNN | **86.82% ± 2.48%** | ~1004 MB |
|
|
141
|
+
| Transformer (dense O(N²)) | 77.80% ± 3.06% | ~1565 MB |
|
|
142
|
+
|
|
143
|
+
The SNN beats the dense baseline by **+9.03 pts accuracy** at **~36% lower peak VRAM**.
|
|
144
|
+
Mean runtime is not a headline figure: seed 2 was a large hardware/runtime outlier,
|
|
145
|
+
so only accuracy and memory are claimed as reliable. Full per-seed data:
|
|
146
|
+
`results/multi_seed.json`; the N-scaling memory crossover is in `results/n_scaling.png`.
|
|
147
|
+
|
|
148
|
+
## Project status
|
|
149
|
+
|
|
150
|
+
This is the clean standalone starting point for production hardening. The current attention is full-sequence rather than causal or streaming. Results are tracked in `docs/benchmark_baseline.md`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
astrohebbian/__init__.py
|
|
5
|
+
astrohebbian/benchmark.py
|
|
6
|
+
astrohebbian/causal.py
|
|
7
|
+
astrohebbian/language_model.py
|
|
8
|
+
astrohebbian/model.py
|
|
9
|
+
astrohebbian.egg-info/PKG-INFO
|
|
10
|
+
astrohebbian.egg-info/SOURCES.txt
|
|
11
|
+
astrohebbian.egg-info/dependency_links.txt
|
|
12
|
+
astrohebbian.egg-info/requires.txt
|
|
13
|
+
astrohebbian.egg-info/top_level.txt
|
|
14
|
+
tests/test_plugin.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
astrohebbian
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "astrohebbian"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Standalone Astrocyte-Hebbian spiking linear Transformer components"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "Sumith" }]
|
|
13
|
+
keywords = ["spiking-neural-networks", "transformer", "attention", "hebbian", "snns", "pytorch"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
23
|
+
]
|
|
24
|
+
dependencies = ["torch>=2.0"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/Griffith-7/Snn-Transformer"
|
|
28
|
+
Source = "https://github.com/Griffith-7/Snn-Transformer"
|
|
29
|
+
"Issue Tracker" = "https://github.com/Griffith-7/Snn-Transformer/issues"
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
test = ["pytest>=7"]
|
|
33
|
+
benchmark = ["torchvision>=0.15", "matplotlib>=3.7"]
|
|
34
|
+
quality = ["ruff>=0.6"]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
include = ["astrohebbian*"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the Astrocyte-Hebbian spiking linear Transformer plugin.
|
|
3
|
+
|
|
4
|
+
This suite mirrors the reference tests from
|
|
5
|
+
`Astrocyte-Hebbian-Spiking-Linear-Transformer-Block/tests/test_astrohebbian.py`
|
|
6
|
+
but is adapted to this plugin's API:
|
|
7
|
+
|
|
8
|
+
* `AstrocyteHebbianBlock` exposes `self.attention` (not `self.attn`).
|
|
9
|
+
* `AstrocyteHebbianAttention` exposes `self.num_heads` / `self.head_dim`
|
|
10
|
+
(not `self.h` / `self.dh`) and keeps the spike thresholds in the
|
|
11
|
+
`self.thresholds` buffer.
|
|
12
|
+
|
|
13
|
+
Run: pytest tests/ -v
|
|
14
|
+
"""
|
|
15
|
+
import torch
|
|
16
|
+
import torch.nn as nn
|
|
17
|
+
|
|
18
|
+
from astrohebbian import (
|
|
19
|
+
AstrocyteHebbianAttention,
|
|
20
|
+
AstrocyteHebbianBlock,
|
|
21
|
+
AstrocyteHebbianClassifier,
|
|
22
|
+
CausalAstrocyteHebbianAttention,
|
|
23
|
+
CausalAstrocyteLanguageModel,
|
|
24
|
+
SpikingFFN,
|
|
25
|
+
spike_fn,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_attention_rejects_empty_or_mismatched_sequences():
|
|
33
|
+
attention = AstrocyteHebbianAttention(d_model=16, num_heads=4)
|
|
34
|
+
for inputs in (torch.randn(2, 0, 16), torch.randn(2, 4, 8)):
|
|
35
|
+
try:
|
|
36
|
+
attention(inputs)
|
|
37
|
+
except ValueError:
|
|
38
|
+
pass
|
|
39
|
+
else:
|
|
40
|
+
raise AssertionError("invalid attention input was accepted")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_classifier_state_round_trip_is_deterministic():
|
|
44
|
+
torch.manual_seed(12)
|
|
45
|
+
model = AstrocyteHebbianClassifier(
|
|
46
|
+
input_dim=1, d_model=16, seq_len=8, num_classes=3, num_heads=4
|
|
47
|
+
).eval()
|
|
48
|
+
inputs = torch.randn(2, 8, 1)
|
|
49
|
+
expected = model(inputs)
|
|
50
|
+
restored = AstrocyteHebbianClassifier(
|
|
51
|
+
input_dim=1, d_model=16, seq_len=8, num_classes=3, num_heads=4
|
|
52
|
+
).eval()
|
|
53
|
+
restored.load_state_dict(model.state_dict())
|
|
54
|
+
assert torch.equal(expected, restored(inputs))
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Surrogate-heavy-side spike function
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
class TestSpikeFn:
|
|
61
|
+
def test_forward_is_binary(self):
|
|
62
|
+
x = torch.randn(64, 32, device=DEVICE) * 3.0
|
|
63
|
+
s = spike_fn(x)
|
|
64
|
+
assert set(s.unique().tolist()) <= {0.0, 1.0}, "spike_fn must output 0/1"
|
|
65
|
+
assert (s == (x > 0).float()).all()
|
|
66
|
+
|
|
67
|
+
def test_backward_is_finite_and_nonzero(self):
|
|
68
|
+
x = torch.randn(64, 32, device=DEVICE, requires_grad=True)
|
|
69
|
+
s = spike_fn(x)
|
|
70
|
+
s.sum().backward()
|
|
71
|
+
assert torch.isfinite(x.grad).all(), "surrogate gradient must be finite"
|
|
72
|
+
assert x.grad.abs().sum() > 0, "surrogate gradient must not vanish everywhere"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Attention mechanism (the core novel contribution)
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
class TestAttention:
|
|
80
|
+
def test_output_shape(self):
|
|
81
|
+
attn = AstrocyteHebbianAttention(d_model=128, num_heads=4).to(DEVICE)
|
|
82
|
+
x = torch.randn(2, 784, 128, device=DEVICE)
|
|
83
|
+
out = attn(x)
|
|
84
|
+
assert out.shape == x.shape
|
|
85
|
+
|
|
86
|
+
def test_no_n_squared_attention_matrix(self):
|
|
87
|
+
"""The whole point: never materialize an N x N attention/score matrix.
|
|
88
|
+
|
|
89
|
+
We assert this empirically by checking that for seq_len N, the only
|
|
90
|
+
large intermediate tensors have size O(N*d) or O(d*d), never O(N*N).
|
|
91
|
+
We instrument by running with an enormous N and confirming no O(N^2)
|
|
92
|
+
allocation dominates memory (would blow up VRAM).
|
|
93
|
+
"""
|
|
94
|
+
attn = AstrocyteHebbianAttention(d_model=64, num_heads=2).to(DEVICE)
|
|
95
|
+
# N = 16k tokens: an O(N^2) matrix would need 16k^2 * 4B = 1 GB just
|
|
96
|
+
# for one float32 attention map. O(N*d) stays at 16k*64*4B = 4 MB.
|
|
97
|
+
x = torch.randn(1, 4000, 64, device=DEVICE)
|
|
98
|
+
# If anything materialized a (4000, 4000) tensor, peak memory would
|
|
99
|
+
# jump. We just assert the forward runs without OOM and is finite.
|
|
100
|
+
out = attn(x)
|
|
101
|
+
assert out.shape == x.shape
|
|
102
|
+
assert torch.isfinite(out).all()
|
|
103
|
+
|
|
104
|
+
def test_q_k_binary_v_binary_at_levels_one(self):
|
|
105
|
+
"""At v_levels=1, V is also binary: the strictest all-spiking regime."""
|
|
106
|
+
attn = AstrocyteHebbianAttention(d_model=128, num_heads=4, v_levels=1).to(DEVICE)
|
|
107
|
+
x = torch.randn(3, 64, 128, device=DEVICE)
|
|
108
|
+
# Reconstruct V from v_proj and the plugin's `self.thresholds` buffer.
|
|
109
|
+
with torch.no_grad():
|
|
110
|
+
pre_q = spike_fn(attn.q_proj(x))
|
|
111
|
+
pre_k = spike_fn(attn.k_proj(x))
|
|
112
|
+
u01 = (torch.tanh(attn.v_proj(x)) + 1.0) / 2.0
|
|
113
|
+
v = spike_fn(u01 - attn.thresholds.item())
|
|
114
|
+
assert set(pre_q.unique().tolist()) <= {0.0, 1.0}
|
|
115
|
+
assert set(pre_k.unique().tolist()) <= {0.0, 1.0}
|
|
116
|
+
assert set(v.unique().tolist()) <= {0.0, 1.0}
|
|
117
|
+
|
|
118
|
+
def test_gradient_flows_to_all_learnable_params(self):
|
|
119
|
+
attn = AstrocyteHebbianAttention(d_model=64, num_heads=2).to(DEVICE)
|
|
120
|
+
x = torch.randn(4, 64, 64, device=DEVICE, requires_grad=True)
|
|
121
|
+
out = attn(x)
|
|
122
|
+
out.sum().backward()
|
|
123
|
+
for name, p in attn.named_parameters():
|
|
124
|
+
assert p.grad is not None, f"no gradient for {name}"
|
|
125
|
+
assert torch.isfinite(p.grad).all(), f"non-finite gradient for {name}"
|
|
126
|
+
|
|
127
|
+
def test_separate_keys_change_output(self):
|
|
128
|
+
"""Sanity: different inputs produce different outputs (no collapse)."""
|
|
129
|
+
attn = AstrocyteHebbianAttention(d_model=64, num_heads=2)
|
|
130
|
+
a = torch.randn(2, 32, 64)
|
|
131
|
+
b = torch.randn(2, 32, 64)
|
|
132
|
+
out_a = attn(a)
|
|
133
|
+
out_b = attn(b)
|
|
134
|
+
assert (out_a - out_b).abs().max() > 1e-6
|
|
135
|
+
|
|
136
|
+
def test_v_levels_multi_level(self):
|
|
137
|
+
"""v_levels>1 quantizes V to L levels (still finite, well-behaved)."""
|
|
138
|
+
for L in (2, 4):
|
|
139
|
+
attn = AstrocyteHebbianAttention(d_model=64, num_heads=2, v_levels=L).to(DEVICE)
|
|
140
|
+
x = torch.randn(2, 32, 64, device=DEVICE)
|
|
141
|
+
out = attn(x)
|
|
142
|
+
assert out.shape == x.shape
|
|
143
|
+
assert torch.isfinite(out).all()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Block / FFN / Classifier
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
class TestBlock:
|
|
151
|
+
def test_block_shape_and_finite(self):
|
|
152
|
+
block = AstrocyteHebbianBlock(d_model=128, num_heads=4).to(DEVICE)
|
|
153
|
+
x = torch.randn(2, 64, 128, device=DEVICE)
|
|
154
|
+
out = block(x)
|
|
155
|
+
assert out.shape == x.shape
|
|
156
|
+
assert torch.isfinite(out).all()
|
|
157
|
+
|
|
158
|
+
def test_ffn_hidden_is_binary(self):
|
|
159
|
+
ffn = SpikingFFN(d_model=64, expansion=4).to(DEVICE)
|
|
160
|
+
x = torch.randn(2, 32, 64, device=DEVICE)
|
|
161
|
+
with torch.no_grad():
|
|
162
|
+
hidden = spike_fn(ffn.fc1(x))
|
|
163
|
+
assert set(hidden.unique().tolist()) <= {0.0, 1.0}
|
|
164
|
+
|
|
165
|
+
def test_stackable_blocks(self):
|
|
166
|
+
"""num_layers>1 should run cleanly (the advertised deep variant)."""
|
|
167
|
+
model = AstrocyteHebbianClassifier(
|
|
168
|
+
d_model=64, num_heads=2, num_layers=2, v_levels=1
|
|
169
|
+
).to(DEVICE)
|
|
170
|
+
x = torch.randn(2, 784, 1, device=DEVICE)
|
|
171
|
+
logits = model(x)
|
|
172
|
+
assert logits.shape == (2, 10)
|
|
173
|
+
assert torch.isfinite(logits).all()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_causal_attention_does_not_read_future_tokens():
|
|
177
|
+
torch.manual_seed(4)
|
|
178
|
+
attention = CausalAstrocyteHebbianAttention(d_model=16, num_heads=4).eval()
|
|
179
|
+
inputs = torch.randn(1, 8, 16)
|
|
180
|
+
changed = inputs.clone()
|
|
181
|
+
changed[:, 7, :] += 50
|
|
182
|
+
first = attention(inputs)[:, :7]
|
|
183
|
+
changed_first = attention(changed)[:, :7]
|
|
184
|
+
assert torch.equal(first, changed_first)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_parallel_causal_attention_matches_recurrent_path():
|
|
188
|
+
torch.manual_seed(6)
|
|
189
|
+
attention = CausalAstrocyteHebbianAttention(d_model=16, num_heads=4).eval()
|
|
190
|
+
inputs = torch.randn(2, 8, 16)
|
|
191
|
+
recurrent = attention(inputs, implementation="recurrent")
|
|
192
|
+
parallel = attention(inputs, implementation="parallel")
|
|
193
|
+
assert torch.allclose(recurrent, parallel, atol=1e-5, rtol=1e-5)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_causal_language_model_trains_on_tiny_batch():
|
|
197
|
+
torch.manual_seed(5)
|
|
198
|
+
model = CausalAstrocyteLanguageModel(vocab_size=32, d_model=16, seq_len=8)
|
|
199
|
+
inputs = torch.randint(0, 32, (2, 8))
|
|
200
|
+
targets = torch.roll(inputs, shifts=-1, dims=1)
|
|
201
|
+
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
|
|
202
|
+
losses = []
|
|
203
|
+
for _ in range(3):
|
|
204
|
+
optimizer.zero_grad(set_to_none=True)
|
|
205
|
+
loss = torch.nn.functional.cross_entropy(
|
|
206
|
+
model(inputs).reshape(-1, 32), targets.reshape(-1)
|
|
207
|
+
)
|
|
208
|
+
loss.backward()
|
|
209
|
+
optimizer.step()
|
|
210
|
+
losses.append(loss.item())
|
|
211
|
+
assert losses[-1] < losses[0]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class TestClassifier:
|
|
215
|
+
def test_classifier_shapes(self):
|
|
216
|
+
model = AstrocyteHebbianClassifier(d_model=128, num_heads=4, num_layers=1).to(DEVICE)
|
|
217
|
+
x = torch.randn(5, 784, 1, device=DEVICE)
|
|
218
|
+
logits = model(x)
|
|
219
|
+
assert logits.shape == (5, 10)
|
|
220
|
+
|
|
221
|
+
def test_training_step_reduces_loss_and_finite_grads(self):
|
|
222
|
+
"""End-to-end: a few optimizer steps should reduce the loss without NaNs."""
|
|
223
|
+
model = AstrocyteHebbianClassifier(
|
|
224
|
+
d_model=64, num_heads=2, num_layers=1, v_levels=1
|
|
225
|
+
).to(DEVICE)
|
|
226
|
+
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
|
|
227
|
+
crit = nn.CrossEntropyLoss()
|
|
228
|
+
x = torch.randn(8, 784, 1, device=DEVICE)
|
|
229
|
+
y = torch.randint(0, 10, (8,), device=DEVICE)
|
|
230
|
+
|
|
231
|
+
losses = []
|
|
232
|
+
for _ in range(3):
|
|
233
|
+
opt.zero_grad()
|
|
234
|
+
out = model(x)
|
|
235
|
+
loss = crit(out, y)
|
|
236
|
+
loss.backward()
|
|
237
|
+
for p in model.parameters():
|
|
238
|
+
assert p.grad is None or torch.isfinite(p.grad).all()
|
|
239
|
+
opt.step()
|
|
240
|
+
losses.append(loss.item())
|
|
241
|
+
assert losses[-1] < losses[0], f"loss did not decrease: {losses}"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# Hebbian trace / key-mass normalization semantics
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
class TestMechanism:
|
|
249
|
+
def test_key_mass_normalization_prevents_blowup(self):
|
|
250
|
+
"""denom = Q . sum_K + eps keeps the output bounded even when the
|
|
251
|
+
raw numerator is large."""
|
|
252
|
+
attn = AstrocyteHebbianAttention(d_model=64, num_heads=2).to(DEVICE)
|
|
253
|
+
# Push k and v to be large/constant
|
|
254
|
+
B, N, d = 2, 64, 64
|
|
255
|
+
x = torch.ones(B, N, d, device=DEVICE)
|
|
256
|
+
out = attn(x)
|
|
257
|
+
assert torch.isfinite(out).all()
|
|
258
|
+
assert out.abs().max() < 100.0, f"key-mass norm let output blow up: {out.abs().max()}"
|
|
259
|
+
|
|
260
|
+
def test_complexity_claim_reading(self):
|
|
261
|
+
"""The trace contract K^T V is (d,d), never (N,N) -- structural check."""
|
|
262
|
+
import inspect
|
|
263
|
+
src = inspect.getsource(AstrocyteHebbianAttention.forward)
|
|
264
|
+
# The plugin computes the trace via `weighted_key.transpose(-2, -1)`
|
|
265
|
+
# times `value` -- a (d,d) product per head, never an (N,N) item.
|
|
266
|
+
assert "matmul(weighted_key.transpose(-2, -1), value)" in src, "must compute K^T V"
|
|
267
|
+
# Ensure there is no softmax / score matrix anywhere in the module
|
|
268
|
+
assert "softmax" not in src
|
|
269
|
+
assert "self_attn" not in src.lower() or "scaled_dot" not in src.lower()
|