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.
@@ -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,11 @@
1
+ torch>=2.0
2
+
3
+ [benchmark]
4
+ torchvision>=0.15
5
+ matplotlib>=3.7
6
+
7
+ [quality]
8
+ ruff>=0.6
9
+
10
+ [test]
11
+ pytest>=7
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()