thermospike 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.
- thermospike-0.1.0/LICENSE +21 -0
- thermospike-0.1.0/PKG-INFO +107 -0
- thermospike-0.1.0/README.md +86 -0
- thermospike-0.1.0/pyproject.toml +34 -0
- thermospike-0.1.0/setup.cfg +4 -0
- thermospike-0.1.0/src/thermospike/__init__.py +32 -0
- thermospike-0.1.0/src/thermospike/diagnostics.py +48 -0
- thermospike-0.1.0/src/thermospike/exact.py +50 -0
- thermospike-0.1.0/src/thermospike/model.py +43 -0
- thermospike-0.1.0/src/thermospike/noise.py +69 -0
- thermospike-0.1.0/src/thermospike/problems.py +40 -0
- thermospike-0.1.0/src/thermospike/samplers.py +128 -0
- thermospike-0.1.0/src/thermospike.egg-info/PKG-INFO +107 -0
- thermospike-0.1.0/src/thermospike.egg-info/SOURCES.txt +16 -0
- thermospike-0.1.0/src/thermospike.egg-info/dependency_links.txt +1 -0
- thermospike-0.1.0/src/thermospike.egg-info/requires.txt +4 -0
- thermospike-0.1.0/src/thermospike.egg-info/top_level.txt +1 -0
- thermospike-0.1.0/tests/test_thermospike.py +170 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The thermospike authors
|
|
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,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: thermospike
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stochastic spiking neurons that sample from Boltzmann distributions, with a count of the random bits and synaptic events each sample costs.
|
|
5
|
+
Author: The thermospike contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/tlcdv/thermospike
|
|
8
|
+
Project-URL: Source, https://github.com/tlcdv/thermospike
|
|
9
|
+
Project-URL: Issues, https://github.com/tlcdv/thermospike/issues
|
|
10
|
+
Keywords: thermodynamic computing,neuromorphic,spiking neural network,p-bit,neural sampling,boltzmann machine
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: numpy>=1.24
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# thermospike
|
|
23
|
+
|
|
24
|
+
Stochastic spiking neurons that sample from Boltzmann distributions. The library counts what each sample costs in random bits, synaptic events and multiply adds.
|
|
25
|
+
|
|
26
|
+
thermospike sits between two fields. Thermodynamic and probabilistic computing use physical noise to sample (p-bits, Gibbs samplers). Neuromorphic engineering uses spikes and event driven synapses. Neural sampling theory (Buesing, Bill, Nessler and Maass, 2011) shows that a network of stochastic spiking neurons with a refractory period samples from the same Boltzmann distribution as a p-bit network. thermospike implements both and measures the difference in cost.
|
|
27
|
+
|
|
28
|
+
## Status
|
|
29
|
+
|
|
30
|
+
Version 0.1.0. Small networks in pure Python and NumPy. Every sampler is checked against the exact distribution, found by enumerating every state. An FPGA reference design is planned and does not exist yet.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install thermospike
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
From source:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
git clone https://github.com/tlcdv/thermospike
|
|
42
|
+
cd thermospike
|
|
43
|
+
pip install -e ".[test]"
|
|
44
|
+
pytest
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Example
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from thermospike import SpikingSampler, NumpyNoise, and_gate, empirical_distribution, exact_distribution, kl_divergence
|
|
51
|
+
|
|
52
|
+
model = and_gate() # C = A AND B, as an energy function
|
|
53
|
+
clamp = {2: 1} # fix the output C to 1 and run the gate backwards
|
|
54
|
+
sampler = SpikingSampler(model, NumpyNoise(seed=0), tau=4, clamp=clamp)
|
|
55
|
+
samples = sampler.sample(20000)
|
|
56
|
+
|
|
57
|
+
print(samples.mean(axis=0)) # A and B are near 1
|
|
58
|
+
print(kl_divergence(empirical_distribution(samples), exact_distribution(model, clamp=clamp)))
|
|
59
|
+
print(sampler.counters) # random bits, spikes, synaptic events, multiply adds
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## What is in the library
|
|
63
|
+
|
|
64
|
+
| Part | Contents |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `BoltzmannMachine` | Binary neurons (0 silent, 1 active), symmetric weights, biases |
|
|
67
|
+
| `PBitSampler` | Sequential Gibbs sampling, one p-bit per neuron |
|
|
68
|
+
| `SpikingSampler` | Neural sampling with an absolute refractory period of `tau` steps. With `tau=1` it is the p-bit sampler |
|
|
69
|
+
| `NumpyNoise`, `LFSRNoise` | Software noise, and a 32 bit LFSR model of the noise a small FPGA would use |
|
|
70
|
+
| `Counters` | Updates, random draws, random bits, spikes, state changes, synaptic events, dense multiply adds |
|
|
71
|
+
| `exact_distribution`, `kl_divergence` | Checks against enumeration for up to 20 neurons |
|
|
72
|
+
| `effective_sample_size`, `integrated_autocorrelation_time` | Mixing diagnostics, so costs are compared per independent sample |
|
|
73
|
+
| `and_gate`, `max_cut`, `random_machine` | Example problems |
|
|
74
|
+
|
|
75
|
+
## First measurement
|
|
76
|
+
|
|
77
|
+
`python examples/compare_cost.py --neurons 8 --sweeps 40000 --seeds 0 1 2` on one random 8 neuron machine (model seed 0, weight scale 1) gave this table. KL is KL(empirical || exact) in nats. With 256 states and 40000 samples, finite sampling alone gives about 0.003, so every row sits near that floor. ESS is the effective sample size, the worst case over every neuron and the energy. Costs are per effective sample.
|
|
78
|
+
|
|
79
|
+
| Sampler | KL mean | ESS mean | Random bits per ESS | Synaptic events per ESS | Dense multiply adds per ESS |
|
|
80
|
+
|---|---|---|---|---|---|
|
|
81
|
+
| p-bit, NumPy noise (32 bit draws) | 0.00260 | 27909 | 367.1 | 11.28 | 80.3 |
|
|
82
|
+
| p-bit, 16 bit LFSR | 0.00290 | 29014 | 176.5 | 10.96 | 77.2 |
|
|
83
|
+
| spiking, tau 2 | 0.00293 | 18400 | 336.4 | 14.51 | 73.6 |
|
|
84
|
+
| spiking, tau 4 | 0.00282 | 9675 | 430.9 | 21.80 | 94.3 |
|
|
85
|
+
| spiking, tau 8 | 0.00345 | 4902 | 644.5 | 31.64 | 141.0 |
|
|
86
|
+
|
|
87
|
+
Per sweep, the spiking sampler draws fewer random numbers, because a refractory neuron draws none. Per effective sample, this saving disappears. The refractory period makes consecutive samples more correlated, and at tau 4 and 8 the spiking sampler costs more than the p-bit sampler on every count. Only tau 2 is close, with 8 percent fewer random bits and multiply adds and 29 percent more synaptic events. The largest saving in this table comes from the noise source. A 16 bit LFSR halves the random bits with no measured loss of quality.
|
|
88
|
+
|
|
89
|
+
This is one small machine, so these are hypotheses. Event counts are not joules. An energy claim needs a hardware cost for each kind of event.
|
|
90
|
+
|
|
91
|
+
## Plan
|
|
92
|
+
|
|
93
|
+
1. Larger sparse networks, more machines per size, and a vectorised sampler.
|
|
94
|
+
2. Spiking variants that keep the per sweep saving without the extra correlation.
|
|
95
|
+
3. An FPGA reference design (Tang Nano 9K) with a host script that checks the hardware samples against the exact distribution.
|
|
96
|
+
4. Comparisons with other libraries on the same problems.
|
|
97
|
+
|
|
98
|
+
Issues and pull requests are welcome.
|
|
99
|
+
|
|
100
|
+
## References
|
|
101
|
+
|
|
102
|
+
- L. Buesing, J. Bill, B. Nessler, W. Maass. Neural Dynamics as Sampling. PLoS Computational Biology, 2011.
|
|
103
|
+
- K. Y. Camsari, R. Faria, B. M. Sutton, S. Datta. Stochastic p-bits for invertible logic. Physical Review X, 2017.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# thermospike
|
|
2
|
+
|
|
3
|
+
Stochastic spiking neurons that sample from Boltzmann distributions. The library counts what each sample costs in random bits, synaptic events and multiply adds.
|
|
4
|
+
|
|
5
|
+
thermospike sits between two fields. Thermodynamic and probabilistic computing use physical noise to sample (p-bits, Gibbs samplers). Neuromorphic engineering uses spikes and event driven synapses. Neural sampling theory (Buesing, Bill, Nessler and Maass, 2011) shows that a network of stochastic spiking neurons with a refractory period samples from the same Boltzmann distribution as a p-bit network. thermospike implements both and measures the difference in cost.
|
|
6
|
+
|
|
7
|
+
## Status
|
|
8
|
+
|
|
9
|
+
Version 0.1.0. Small networks in pure Python and NumPy. Every sampler is checked against the exact distribution, found by enumerating every state. An FPGA reference design is planned and does not exist yet.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
pip install thermospike
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
From source:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
git clone https://github.com/tlcdv/thermospike
|
|
21
|
+
cd thermospike
|
|
22
|
+
pip install -e ".[test]"
|
|
23
|
+
pytest
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Example
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from thermospike import SpikingSampler, NumpyNoise, and_gate, empirical_distribution, exact_distribution, kl_divergence
|
|
30
|
+
|
|
31
|
+
model = and_gate() # C = A AND B, as an energy function
|
|
32
|
+
clamp = {2: 1} # fix the output C to 1 and run the gate backwards
|
|
33
|
+
sampler = SpikingSampler(model, NumpyNoise(seed=0), tau=4, clamp=clamp)
|
|
34
|
+
samples = sampler.sample(20000)
|
|
35
|
+
|
|
36
|
+
print(samples.mean(axis=0)) # A and B are near 1
|
|
37
|
+
print(kl_divergence(empirical_distribution(samples), exact_distribution(model, clamp=clamp)))
|
|
38
|
+
print(sampler.counters) # random bits, spikes, synaptic events, multiply adds
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## What is in the library
|
|
42
|
+
|
|
43
|
+
| Part | Contents |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `BoltzmannMachine` | Binary neurons (0 silent, 1 active), symmetric weights, biases |
|
|
46
|
+
| `PBitSampler` | Sequential Gibbs sampling, one p-bit per neuron |
|
|
47
|
+
| `SpikingSampler` | Neural sampling with an absolute refractory period of `tau` steps. With `tau=1` it is the p-bit sampler |
|
|
48
|
+
| `NumpyNoise`, `LFSRNoise` | Software noise, and a 32 bit LFSR model of the noise a small FPGA would use |
|
|
49
|
+
| `Counters` | Updates, random draws, random bits, spikes, state changes, synaptic events, dense multiply adds |
|
|
50
|
+
| `exact_distribution`, `kl_divergence` | Checks against enumeration for up to 20 neurons |
|
|
51
|
+
| `effective_sample_size`, `integrated_autocorrelation_time` | Mixing diagnostics, so costs are compared per independent sample |
|
|
52
|
+
| `and_gate`, `max_cut`, `random_machine` | Example problems |
|
|
53
|
+
|
|
54
|
+
## First measurement
|
|
55
|
+
|
|
56
|
+
`python examples/compare_cost.py --neurons 8 --sweeps 40000 --seeds 0 1 2` on one random 8 neuron machine (model seed 0, weight scale 1) gave this table. KL is KL(empirical || exact) in nats. With 256 states and 40000 samples, finite sampling alone gives about 0.003, so every row sits near that floor. ESS is the effective sample size, the worst case over every neuron and the energy. Costs are per effective sample.
|
|
57
|
+
|
|
58
|
+
| Sampler | KL mean | ESS mean | Random bits per ESS | Synaptic events per ESS | Dense multiply adds per ESS |
|
|
59
|
+
|---|---|---|---|---|---|
|
|
60
|
+
| p-bit, NumPy noise (32 bit draws) | 0.00260 | 27909 | 367.1 | 11.28 | 80.3 |
|
|
61
|
+
| p-bit, 16 bit LFSR | 0.00290 | 29014 | 176.5 | 10.96 | 77.2 |
|
|
62
|
+
| spiking, tau 2 | 0.00293 | 18400 | 336.4 | 14.51 | 73.6 |
|
|
63
|
+
| spiking, tau 4 | 0.00282 | 9675 | 430.9 | 21.80 | 94.3 |
|
|
64
|
+
| spiking, tau 8 | 0.00345 | 4902 | 644.5 | 31.64 | 141.0 |
|
|
65
|
+
|
|
66
|
+
Per sweep, the spiking sampler draws fewer random numbers, because a refractory neuron draws none. Per effective sample, this saving disappears. The refractory period makes consecutive samples more correlated, and at tau 4 and 8 the spiking sampler costs more than the p-bit sampler on every count. Only tau 2 is close, with 8 percent fewer random bits and multiply adds and 29 percent more synaptic events. The largest saving in this table comes from the noise source. A 16 bit LFSR halves the random bits with no measured loss of quality.
|
|
67
|
+
|
|
68
|
+
This is one small machine, so these are hypotheses. Event counts are not joules. An energy claim needs a hardware cost for each kind of event.
|
|
69
|
+
|
|
70
|
+
## Plan
|
|
71
|
+
|
|
72
|
+
1. Larger sparse networks, more machines per size, and a vectorised sampler.
|
|
73
|
+
2. Spiking variants that keep the per sweep saving without the extra correlation.
|
|
74
|
+
3. An FPGA reference design (Tang Nano 9K) with a host script that checks the hardware samples against the exact distribution.
|
|
75
|
+
4. Comparisons with other libraries on the same problems.
|
|
76
|
+
|
|
77
|
+
Issues and pull requests are welcome.
|
|
78
|
+
|
|
79
|
+
## References
|
|
80
|
+
|
|
81
|
+
- L. Buesing, J. Bill, B. Nessler, W. Maass. Neural Dynamics as Sampling. PLoS Computational Biology, 2011.
|
|
82
|
+
- K. Y. Camsari, R. Faria, B. M. Sutton, S. Datta. Stochastic p-bits for invertible logic. Physical Review X, 2017.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "thermospike"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Stochastic spiking neurons that sample from Boltzmann distributions, with a count of the random bits and synaptic events each sample costs."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "The thermospike contributors" }]
|
|
13
|
+
dependencies = ["numpy>=1.24"]
|
|
14
|
+
keywords = ["thermodynamic computing", "neuromorphic", "spiking neural network", "p-bit", "neural sampling", "boltzmann machine"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Scientific/Engineering",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/tlcdv/thermospike"
|
|
23
|
+
Source = "https://github.com/tlcdv/thermospike"
|
|
24
|
+
Issues = "https://github.com/tlcdv/thermospike/issues"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
test = ["pytest>=7"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
pythonpath = ["src"]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""thermospike: stochastic spiking neurons that sample, with noise as the resource."""
|
|
2
|
+
|
|
3
|
+
from .diagnostics import autocorrelation, effective_sample_size, integrated_autocorrelation_time
|
|
4
|
+
from .exact import all_states, empirical_distribution, exact_distribution, kl_divergence, state_index
|
|
5
|
+
from .model import BoltzmannMachine
|
|
6
|
+
from .noise import LFSRNoise, NoiseSource, NumpyNoise
|
|
7
|
+
from .problems import and_gate, cut_size, max_cut, random_machine
|
|
8
|
+
from .samplers import Counters, PBitSampler, SpikingSampler
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BoltzmannMachine",
|
|
14
|
+
"Counters",
|
|
15
|
+
"LFSRNoise",
|
|
16
|
+
"NoiseSource",
|
|
17
|
+
"NumpyNoise",
|
|
18
|
+
"PBitSampler",
|
|
19
|
+
"SpikingSampler",
|
|
20
|
+
"all_states",
|
|
21
|
+
"and_gate",
|
|
22
|
+
"autocorrelation",
|
|
23
|
+
"cut_size",
|
|
24
|
+
"effective_sample_size",
|
|
25
|
+
"empirical_distribution",
|
|
26
|
+
"exact_distribution",
|
|
27
|
+
"integrated_autocorrelation_time",
|
|
28
|
+
"kl_divergence",
|
|
29
|
+
"max_cut",
|
|
30
|
+
"random_machine",
|
|
31
|
+
"state_index",
|
|
32
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Mixing diagnostics. Consecutive samples of a Markov chain are correlated, so
|
|
2
|
+
a fair cost comparison divides the cost by the effective number of samples."""
|
|
3
|
+
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .model import BoltzmannMachine
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def autocorrelation(x: np.ndarray) -> np.ndarray:
|
|
12
|
+
"""Normalised autocorrelation of a series at every lag, computed with an FFT."""
|
|
13
|
+
x = np.asarray(x, dtype=float) - np.mean(x)
|
|
14
|
+
n = len(x)
|
|
15
|
+
spectrum = np.fft.rfft(x, 2 * n)
|
|
16
|
+
acf = np.fft.irfft(spectrum * np.conj(spectrum))[:n]
|
|
17
|
+
return acf / acf[0]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def integrated_autocorrelation_time(x: np.ndarray) -> float:
|
|
21
|
+
"""1 + 2 * sum of autocorrelations, truncated by Geyer's initial positive sequence.
|
|
22
|
+
|
|
23
|
+
It is 1 for independent samples. A value of 10 means ten correlated samples
|
|
24
|
+
carry about as much information as one independent sample.
|
|
25
|
+
"""
|
|
26
|
+
if np.var(x) == 0:
|
|
27
|
+
raise ValueError("the series is constant, so its autocorrelation time is undefined")
|
|
28
|
+
rho = autocorrelation(x)
|
|
29
|
+
total = 0.0
|
|
30
|
+
for m in range(len(rho) // 2):
|
|
31
|
+
pair = rho[2 * m] + rho[2 * m + 1]
|
|
32
|
+
if pair <= 0:
|
|
33
|
+
break
|
|
34
|
+
total += pair
|
|
35
|
+
return max(1.0, 2.0 * total - 1.0)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def effective_sample_size(samples: np.ndarray, model: Optional[BoltzmannMachine] = None) -> float:
|
|
39
|
+
"""Worst case effective sample size over every neuron that changes, and over the energy if a model is given."""
|
|
40
|
+
series: List[np.ndarray] = [samples[:, k] for k in range(samples.shape[1]) if np.var(samples[:, k]) > 0]
|
|
41
|
+
if model is not None:
|
|
42
|
+
energies = np.array([model.energy(z) for z in samples])
|
|
43
|
+
if np.var(energies) > 0:
|
|
44
|
+
series.append(energies)
|
|
45
|
+
if not series:
|
|
46
|
+
raise ValueError("no series in the samples changes, so nothing can be measured")
|
|
47
|
+
worst = max(integrated_autocorrelation_time(s) for s in series)
|
|
48
|
+
return len(samples) / worst
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Exact distributions by enumeration, to check samplers on small networks."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .model import BoltzmannMachine
|
|
8
|
+
|
|
9
|
+
MAX_EXACT_NEURONS = 20
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def all_states(n: int) -> np.ndarray:
|
|
13
|
+
"""Every binary state of n neurons. Row i is the state with index i."""
|
|
14
|
+
if n > MAX_EXACT_NEURONS:
|
|
15
|
+
raise ValueError(f"enumeration is limited to {MAX_EXACT_NEURONS} neurons")
|
|
16
|
+
index = np.arange(2 ** n)
|
|
17
|
+
return ((index[:, None] >> np.arange(n)) & 1).astype(np.int8)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def state_index(states: np.ndarray) -> np.ndarray:
|
|
21
|
+
"""Index of each state row, with neuron k as bit k."""
|
|
22
|
+
states = np.atleast_2d(states)
|
|
23
|
+
return (states.astype(np.int64) << np.arange(states.shape[1])).sum(axis=1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def exact_distribution(model: BoltzmannMachine, beta: float = 1.0,
|
|
27
|
+
clamp: Optional[Dict[int, int]] = None) -> np.ndarray:
|
|
28
|
+
"""Probability of every state. States that break a clamp get probability 0."""
|
|
29
|
+
states = all_states(model.n)
|
|
30
|
+
energies = np.array([model.energy(z) for z in states])
|
|
31
|
+
logits = -beta * energies
|
|
32
|
+
for k, value in (clamp or {}).items():
|
|
33
|
+
logits[states[:, k] != value] = -np.inf
|
|
34
|
+
logits -= logits.max()
|
|
35
|
+
weights = np.exp(logits)
|
|
36
|
+
return weights / weights.sum()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def empirical_distribution(samples: np.ndarray) -> np.ndarray:
|
|
40
|
+
"""Fraction of samples in every state."""
|
|
41
|
+
counts = np.bincount(state_index(samples), minlength=2 ** samples.shape[1])
|
|
42
|
+
return counts / counts.sum()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def kl_divergence(p: np.ndarray, q: np.ndarray) -> float:
|
|
46
|
+
"""KL(p || q) in nats. Use p for the samples and q for the exact distribution."""
|
|
47
|
+
support = p > 0
|
|
48
|
+
if np.any(q[support] == 0):
|
|
49
|
+
return float("inf")
|
|
50
|
+
return float(np.sum(p[support] * np.log(p[support] / q[support])))
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Boltzmann machines over binary neurons.
|
|
2
|
+
|
|
3
|
+
A state z has one entry per neuron, 1 for active (spiking) and 0 for silent.
|
|
4
|
+
The energy is E(z) = -1/2 z.W.z - b.z and the target distribution is
|
|
5
|
+
p(z) proportional to exp(-beta * E(z)).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Sequence
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BoltzmannMachine:
|
|
14
|
+
"""Symmetric weights with a zero diagonal, and one bias per neuron."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, weights: Sequence[Sequence[float]], biases: Sequence[float]):
|
|
17
|
+
w = np.asarray(weights, dtype=float)
|
|
18
|
+
b = np.asarray(biases, dtype=float)
|
|
19
|
+
if w.ndim != 2 or w.shape[0] != w.shape[1]:
|
|
20
|
+
raise ValueError("weights must be a square matrix")
|
|
21
|
+
if b.shape != (w.shape[0],):
|
|
22
|
+
raise ValueError("biases must have one entry per neuron")
|
|
23
|
+
if not np.allclose(w, w.T):
|
|
24
|
+
raise ValueError("weights must be symmetric")
|
|
25
|
+
if np.any(np.diag(w) != 0):
|
|
26
|
+
raise ValueError("weights must have a zero diagonal")
|
|
27
|
+
self.weights = w
|
|
28
|
+
self.biases = b
|
|
29
|
+
# Number of synapses that leave each neuron. An event driven chip pays
|
|
30
|
+
# this many synaptic updates each time the neuron changes state.
|
|
31
|
+
self.fan_out = np.count_nonzero(w, axis=0)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def n(self) -> int:
|
|
35
|
+
return len(self.biases)
|
|
36
|
+
|
|
37
|
+
def energy(self, z: np.ndarray) -> float:
|
|
38
|
+
z = np.asarray(z, dtype=float)
|
|
39
|
+
return float(-0.5 * z @ self.weights @ z - self.biases @ z)
|
|
40
|
+
|
|
41
|
+
def potential(self, z: np.ndarray, k: int) -> float:
|
|
42
|
+
"""Membrane potential of neuron k, which is minus the energy change of switching it on."""
|
|
43
|
+
return float(self.biases[k] + self.weights[k] @ z)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Noise sources for stochastic neurons.
|
|
2
|
+
|
|
3
|
+
Every source counts the random bits it gives out, so a sampler can report
|
|
4
|
+
the cost of its randomness next to the quality of its samples.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Optional, Protocol
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NoiseSource(Protocol):
|
|
13
|
+
"""A source of uniform numbers on (0, 1) that counts its random bits."""
|
|
14
|
+
|
|
15
|
+
bits_per_draw: int
|
|
16
|
+
bits_used: int
|
|
17
|
+
|
|
18
|
+
def uniform(self) -> float:
|
|
19
|
+
"""Return one uniform number on (0, 1)."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NumpyNoise:
|
|
24
|
+
"""Software noise from the NumPy PCG64 generator."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, seed: Optional[int] = None, bits_per_draw: int = 32):
|
|
27
|
+
self._rng = np.random.default_rng(seed)
|
|
28
|
+
self.bits_per_draw = bits_per_draw
|
|
29
|
+
self.bits_used = 0
|
|
30
|
+
|
|
31
|
+
def uniform(self) -> float:
|
|
32
|
+
self.bits_used += self.bits_per_draw
|
|
33
|
+
return float(self._rng.random())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Galois mask for the maximal length polynomial x^32 + x^22 + x^2 + x + 1.
|
|
37
|
+
LFSR32_TAPS = 0x80200003
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LFSRNoise:
|
|
41
|
+
"""A 32 bit Galois linear feedback shift register, as a cheap FPGA would use.
|
|
42
|
+
|
|
43
|
+
Each draw shifts the register `bits_per_draw` times and reads the output
|
|
44
|
+
bits as an integer, so the resolution of one draw is 2 ** -bits_per_draw.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, seed: int = 1, bits_per_draw: int = 16):
|
|
48
|
+
state = seed & 0xFFFFFFFF
|
|
49
|
+
if state == 0:
|
|
50
|
+
raise ValueError("an LFSR seed must be nonzero in its low 32 bits")
|
|
51
|
+
if not 1 <= bits_per_draw <= 32:
|
|
52
|
+
raise ValueError("bits_per_draw must be between 1 and 32")
|
|
53
|
+
self.state = state
|
|
54
|
+
self.bits_per_draw = bits_per_draw
|
|
55
|
+
self.bits_used = 0
|
|
56
|
+
|
|
57
|
+
def next_bit(self) -> int:
|
|
58
|
+
bit = self.state & 1
|
|
59
|
+
self.state >>= 1
|
|
60
|
+
if bit:
|
|
61
|
+
self.state ^= LFSR32_TAPS
|
|
62
|
+
return bit
|
|
63
|
+
|
|
64
|
+
def uniform(self) -> float:
|
|
65
|
+
value = 0
|
|
66
|
+
for _ in range(self.bits_per_draw):
|
|
67
|
+
value = (value << 1) | self.next_bit()
|
|
68
|
+
self.bits_used += self.bits_per_draw
|
|
69
|
+
return (value + 0.5) / (1 << self.bits_per_draw)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Small example problems written as Boltzmann machines."""
|
|
2
|
+
|
|
3
|
+
from typing import Iterable, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .model import BoltzmannMachine
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def random_machine(n: int, scale: float = 1.0, seed: Optional[int] = None) -> BoltzmannMachine:
|
|
11
|
+
"""Dense random weights and biases, drawn from a normal distribution with this scale."""
|
|
12
|
+
rng = np.random.default_rng(seed)
|
|
13
|
+
upper = np.triu(rng.normal(0.0, scale, (n, n)), k=1)
|
|
14
|
+
return BoltzmannMachine(upper + upper.T, rng.normal(0.0, scale, n))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def and_gate(strength: float = 3.0) -> BoltzmannMachine:
|
|
18
|
+
"""Neurons (A, B, C) with low energy only where C = A AND B.
|
|
19
|
+
|
|
20
|
+
The energy is strength * (3C + AB - 2AC - 2BC). It is 0 on the four rows of
|
|
21
|
+
the truth table and at least `strength` on every other state. Clamp A and B
|
|
22
|
+
to compute C, or clamp C to 1 to run the gate backwards and find A = B = 1.
|
|
23
|
+
"""
|
|
24
|
+
s = strength
|
|
25
|
+
weights = [[0.0, -s, 2 * s],
|
|
26
|
+
[-s, 0.0, 2 * s],
|
|
27
|
+
[2 * s, 2 * s, 0.0]]
|
|
28
|
+
return BoltzmannMachine(weights, [0.0, 0.0, -3 * s])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def max_cut(n: int, edges: Iterable[Tuple[int, int]]) -> BoltzmannMachine:
|
|
32
|
+
"""Lowest energy states are the largest cuts of the graph. Energy = minus the cut size."""
|
|
33
|
+
adjacency = np.zeros((n, n))
|
|
34
|
+
for i, j in edges:
|
|
35
|
+
adjacency[i, j] = adjacency[j, i] = 1.0
|
|
36
|
+
return BoltzmannMachine(-2 * adjacency, adjacency.sum(axis=1))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cut_size(edges: Iterable[Tuple[int, int]], z: np.ndarray) -> int:
|
|
40
|
+
return sum(int(z[i] != z[j]) for i, j in edges)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Samplers that run a Boltzmann machine with stochastic neurons.
|
|
2
|
+
|
|
3
|
+
PBitSampler is sequential Gibbs sampling. Each neuron is a p-bit that turns on
|
|
4
|
+
with probability sigmoid(beta * u), where u is its membrane potential.
|
|
5
|
+
|
|
6
|
+
SpikingSampler is neural sampling with an absolute refractory period
|
|
7
|
+
(Buesing, Bill, Nessler and Maass, PLoS Comput Biol 2011). A neuron that
|
|
8
|
+
spikes stays active for tau steps. Outside that period it spikes with
|
|
9
|
+
probability sigmoid(beta * u - log(tau)). The active neurons are samples from
|
|
10
|
+
the same Boltzmann distribution, and the neuron draws no random bits while
|
|
11
|
+
it is refractory. With tau = 1 it is the p-bit sampler.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Dict, Optional
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from .model import BoltzmannMachine
|
|
21
|
+
from .noise import NoiseSource
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Counters:
|
|
26
|
+
"""Work done by a sampler. These are event counts, not joules."""
|
|
27
|
+
|
|
28
|
+
updates: int = 0 # neuron update steps
|
|
29
|
+
random_draws: int = 0 # uniform numbers drawn
|
|
30
|
+
random_bits: int = 0 # bits behind those draws
|
|
31
|
+
spikes: int = 0 # p-bit: off to on events. Spiking: every spike, also one that extends an active period
|
|
32
|
+
state_changes: int = 0 # on to off and off to on events
|
|
33
|
+
synaptic_events: int = 0 # fan out of every state change (event driven cost)
|
|
34
|
+
dense_multiply_adds: int = 0 # cost of recomputing every potential from scratch
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sigmoid(x: float) -> float:
|
|
38
|
+
if x >= 0:
|
|
39
|
+
return 1.0 / (1.0 + math.exp(-x))
|
|
40
|
+
e = math.exp(x)
|
|
41
|
+
return e / (1.0 + e)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Sampler:
|
|
45
|
+
def __init__(self, model: BoltzmannMachine, noise: NoiseSource, beta: float = 1.0,
|
|
46
|
+
clamp: Optional[Dict[int, int]] = None, initial: Optional[np.ndarray] = None):
|
|
47
|
+
self.model = model
|
|
48
|
+
self.noise = noise
|
|
49
|
+
self.beta = beta
|
|
50
|
+
self.clamp = dict(clamp or {})
|
|
51
|
+
self.z = np.zeros(model.n) if initial is None else np.asarray(initial, dtype=float).copy()
|
|
52
|
+
for k, value in self.clamp.items():
|
|
53
|
+
self.z[k] = value
|
|
54
|
+
self.free = [k for k in range(model.n) if k not in self.clamp]
|
|
55
|
+
self.counters = Counters()
|
|
56
|
+
|
|
57
|
+
def _draw(self) -> float:
|
|
58
|
+
bits_before = self.noise.bits_used
|
|
59
|
+
value = self.noise.uniform()
|
|
60
|
+
self.counters.random_draws += 1
|
|
61
|
+
self.counters.random_bits += self.noise.bits_used - bits_before
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
def _set(self, k: int, value: int) -> None:
|
|
65
|
+
if self.z[k] != value:
|
|
66
|
+
self.counters.state_changes += 1
|
|
67
|
+
self.counters.synaptic_events += int(self.model.fan_out[k])
|
|
68
|
+
self.z[k] = value
|
|
69
|
+
|
|
70
|
+
def _potential(self, k: int) -> float:
|
|
71
|
+
self.counters.dense_multiply_adds += self.model.n - 1
|
|
72
|
+
return self.model.potential(self.z, k)
|
|
73
|
+
|
|
74
|
+
def _update(self, k: int) -> None:
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
|
|
77
|
+
def sweep(self) -> None:
|
|
78
|
+
"""Update every free neuron once, in index order."""
|
|
79
|
+
for k in self.free:
|
|
80
|
+
self.counters.updates += 1
|
|
81
|
+
self._update(k)
|
|
82
|
+
|
|
83
|
+
def sample(self, n_samples: int, burn_in: int = 100) -> np.ndarray:
|
|
84
|
+
"""Run burn_in sweeps, then return the state after each of n_samples sweeps."""
|
|
85
|
+
for _ in range(burn_in):
|
|
86
|
+
self.sweep()
|
|
87
|
+
out = np.empty((n_samples, self.model.n), dtype=np.int8)
|
|
88
|
+
for i in range(n_samples):
|
|
89
|
+
self.sweep()
|
|
90
|
+
out[i] = self.z
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PBitSampler(_Sampler):
|
|
95
|
+
"""Sequential Gibbs sampling with p-bit neurons."""
|
|
96
|
+
|
|
97
|
+
def _update(self, k: int) -> None:
|
|
98
|
+
p_on = sigmoid(self.beta * self._potential(k))
|
|
99
|
+
value = 1 if self._draw() < p_on else 0
|
|
100
|
+
if value == 1 and self.z[k] == 0:
|
|
101
|
+
self.counters.spikes += 1
|
|
102
|
+
self._set(k, value)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SpikingSampler(_Sampler):
|
|
106
|
+
"""Neural sampling with stochastic spiking neurons and a refractory period of tau steps."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, model: BoltzmannMachine, noise: NoiseSource, tau: int = 1, **kwargs):
|
|
109
|
+
if tau < 1:
|
|
110
|
+
raise ValueError("tau must be at least 1")
|
|
111
|
+
super().__init__(model, noise, **kwargs)
|
|
112
|
+
self.tau = tau
|
|
113
|
+
self.log_tau = math.log(tau)
|
|
114
|
+
# Steps left in the active period. A neuron is active while this is 1 or more.
|
|
115
|
+
self.refractory = np.where(self.z == 1, 1, 0)
|
|
116
|
+
|
|
117
|
+
def _update(self, k: int) -> None:
|
|
118
|
+
if self.refractory[k] > 1:
|
|
119
|
+
self.refractory[k] -= 1
|
|
120
|
+
return
|
|
121
|
+
p_spike = sigmoid(self.beta * self._potential(k) - self.log_tau)
|
|
122
|
+
if self._draw() < p_spike:
|
|
123
|
+
self.refractory[k] = self.tau
|
|
124
|
+
self.counters.spikes += 1
|
|
125
|
+
self._set(k, 1)
|
|
126
|
+
else:
|
|
127
|
+
self.refractory[k] = 0
|
|
128
|
+
self._set(k, 0)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: thermospike
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stochastic spiking neurons that sample from Boltzmann distributions, with a count of the random bits and synaptic events each sample costs.
|
|
5
|
+
Author: The thermospike contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/tlcdv/thermospike
|
|
8
|
+
Project-URL: Source, https://github.com/tlcdv/thermospike
|
|
9
|
+
Project-URL: Issues, https://github.com/tlcdv/thermospike/issues
|
|
10
|
+
Keywords: thermodynamic computing,neuromorphic,spiking neural network,p-bit,neural sampling,boltzmann machine
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: numpy>=1.24
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# thermospike
|
|
23
|
+
|
|
24
|
+
Stochastic spiking neurons that sample from Boltzmann distributions. The library counts what each sample costs in random bits, synaptic events and multiply adds.
|
|
25
|
+
|
|
26
|
+
thermospike sits between two fields. Thermodynamic and probabilistic computing use physical noise to sample (p-bits, Gibbs samplers). Neuromorphic engineering uses spikes and event driven synapses. Neural sampling theory (Buesing, Bill, Nessler and Maass, 2011) shows that a network of stochastic spiking neurons with a refractory period samples from the same Boltzmann distribution as a p-bit network. thermospike implements both and measures the difference in cost.
|
|
27
|
+
|
|
28
|
+
## Status
|
|
29
|
+
|
|
30
|
+
Version 0.1.0. Small networks in pure Python and NumPy. Every sampler is checked against the exact distribution, found by enumerating every state. An FPGA reference design is planned and does not exist yet.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install thermospike
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
From source:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
git clone https://github.com/tlcdv/thermospike
|
|
42
|
+
cd thermospike
|
|
43
|
+
pip install -e ".[test]"
|
|
44
|
+
pytest
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Example
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from thermospike import SpikingSampler, NumpyNoise, and_gate, empirical_distribution, exact_distribution, kl_divergence
|
|
51
|
+
|
|
52
|
+
model = and_gate() # C = A AND B, as an energy function
|
|
53
|
+
clamp = {2: 1} # fix the output C to 1 and run the gate backwards
|
|
54
|
+
sampler = SpikingSampler(model, NumpyNoise(seed=0), tau=4, clamp=clamp)
|
|
55
|
+
samples = sampler.sample(20000)
|
|
56
|
+
|
|
57
|
+
print(samples.mean(axis=0)) # A and B are near 1
|
|
58
|
+
print(kl_divergence(empirical_distribution(samples), exact_distribution(model, clamp=clamp)))
|
|
59
|
+
print(sampler.counters) # random bits, spikes, synaptic events, multiply adds
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## What is in the library
|
|
63
|
+
|
|
64
|
+
| Part | Contents |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `BoltzmannMachine` | Binary neurons (0 silent, 1 active), symmetric weights, biases |
|
|
67
|
+
| `PBitSampler` | Sequential Gibbs sampling, one p-bit per neuron |
|
|
68
|
+
| `SpikingSampler` | Neural sampling with an absolute refractory period of `tau` steps. With `tau=1` it is the p-bit sampler |
|
|
69
|
+
| `NumpyNoise`, `LFSRNoise` | Software noise, and a 32 bit LFSR model of the noise a small FPGA would use |
|
|
70
|
+
| `Counters` | Updates, random draws, random bits, spikes, state changes, synaptic events, dense multiply adds |
|
|
71
|
+
| `exact_distribution`, `kl_divergence` | Checks against enumeration for up to 20 neurons |
|
|
72
|
+
| `effective_sample_size`, `integrated_autocorrelation_time` | Mixing diagnostics, so costs are compared per independent sample |
|
|
73
|
+
| `and_gate`, `max_cut`, `random_machine` | Example problems |
|
|
74
|
+
|
|
75
|
+
## First measurement
|
|
76
|
+
|
|
77
|
+
`python examples/compare_cost.py --neurons 8 --sweeps 40000 --seeds 0 1 2` on one random 8 neuron machine (model seed 0, weight scale 1) gave this table. KL is KL(empirical || exact) in nats. With 256 states and 40000 samples, finite sampling alone gives about 0.003, so every row sits near that floor. ESS is the effective sample size, the worst case over every neuron and the energy. Costs are per effective sample.
|
|
78
|
+
|
|
79
|
+
| Sampler | KL mean | ESS mean | Random bits per ESS | Synaptic events per ESS | Dense multiply adds per ESS |
|
|
80
|
+
|---|---|---|---|---|---|
|
|
81
|
+
| p-bit, NumPy noise (32 bit draws) | 0.00260 | 27909 | 367.1 | 11.28 | 80.3 |
|
|
82
|
+
| p-bit, 16 bit LFSR | 0.00290 | 29014 | 176.5 | 10.96 | 77.2 |
|
|
83
|
+
| spiking, tau 2 | 0.00293 | 18400 | 336.4 | 14.51 | 73.6 |
|
|
84
|
+
| spiking, tau 4 | 0.00282 | 9675 | 430.9 | 21.80 | 94.3 |
|
|
85
|
+
| spiking, tau 8 | 0.00345 | 4902 | 644.5 | 31.64 | 141.0 |
|
|
86
|
+
|
|
87
|
+
Per sweep, the spiking sampler draws fewer random numbers, because a refractory neuron draws none. Per effective sample, this saving disappears. The refractory period makes consecutive samples more correlated, and at tau 4 and 8 the spiking sampler costs more than the p-bit sampler on every count. Only tau 2 is close, with 8 percent fewer random bits and multiply adds and 29 percent more synaptic events. The largest saving in this table comes from the noise source. A 16 bit LFSR halves the random bits with no measured loss of quality.
|
|
88
|
+
|
|
89
|
+
This is one small machine, so these are hypotheses. Event counts are not joules. An energy claim needs a hardware cost for each kind of event.
|
|
90
|
+
|
|
91
|
+
## Plan
|
|
92
|
+
|
|
93
|
+
1. Larger sparse networks, more machines per size, and a vectorised sampler.
|
|
94
|
+
2. Spiking variants that keep the per sweep saving without the extra correlation.
|
|
95
|
+
3. An FPGA reference design (Tang Nano 9K) with a host script that checks the hardware samples against the exact distribution.
|
|
96
|
+
4. Comparisons with other libraries on the same problems.
|
|
97
|
+
|
|
98
|
+
Issues and pull requests are welcome.
|
|
99
|
+
|
|
100
|
+
## References
|
|
101
|
+
|
|
102
|
+
- L. Buesing, J. Bill, B. Nessler, W. Maass. Neural Dynamics as Sampling. PLoS Computational Biology, 2011.
|
|
103
|
+
- K. Y. Camsari, R. Faria, B. M. Sutton, S. Datta. Stochastic p-bits for invertible logic. Physical Review X, 2017.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/thermospike/__init__.py
|
|
5
|
+
src/thermospike/diagnostics.py
|
|
6
|
+
src/thermospike/exact.py
|
|
7
|
+
src/thermospike/model.py
|
|
8
|
+
src/thermospike/noise.py
|
|
9
|
+
src/thermospike/problems.py
|
|
10
|
+
src/thermospike/samplers.py
|
|
11
|
+
src/thermospike.egg-info/PKG-INFO
|
|
12
|
+
src/thermospike.egg-info/SOURCES.txt
|
|
13
|
+
src/thermospike.egg-info/dependency_links.txt
|
|
14
|
+
src/thermospike.egg-info/requires.txt
|
|
15
|
+
src/thermospike.egg-info/top_level.txt
|
|
16
|
+
tests/test_thermospike.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
thermospike
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from thermospike import (
|
|
5
|
+
BoltzmannMachine,
|
|
6
|
+
LFSRNoise,
|
|
7
|
+
NumpyNoise,
|
|
8
|
+
PBitSampler,
|
|
9
|
+
SpikingSampler,
|
|
10
|
+
all_states,
|
|
11
|
+
and_gate,
|
|
12
|
+
cut_size,
|
|
13
|
+
effective_sample_size,
|
|
14
|
+
empirical_distribution,
|
|
15
|
+
exact_distribution,
|
|
16
|
+
integrated_autocorrelation_time,
|
|
17
|
+
kl_divergence,
|
|
18
|
+
max_cut,
|
|
19
|
+
random_machine,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# KL(empirical || exact) for 32 states from 40000 correlated samples sits near
|
|
23
|
+
# 0.001 nats from finite sampling alone. A wrong sampler lands far above 0.01.
|
|
24
|
+
KL_LIMIT = 0.01
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ------------------------------------------------------------------ noise
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_lfsr_is_deterministic_and_counts_bits():
|
|
31
|
+
a, b = LFSRNoise(seed=7, bits_per_draw=16), LFSRNoise(seed=7, bits_per_draw=16)
|
|
32
|
+
assert [a.uniform() for _ in range(50)] == [b.uniform() for _ in range(50)]
|
|
33
|
+
assert a.bits_used == 50 * 16
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_lfsr_output_is_close_to_uniform():
|
|
37
|
+
noise = LFSRNoise(seed=12345)
|
|
38
|
+
values = np.array([noise.uniform() for _ in range(20000)])
|
|
39
|
+
assert 0 < values.min() and values.max() < 1
|
|
40
|
+
assert abs(values.mean() - 0.5) < 0.01
|
|
41
|
+
counts, _ = np.histogram(values, bins=10, range=(0, 1))
|
|
42
|
+
assert counts.min() > 1800 and counts.max() < 2200
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_lfsr_rejects_zero_seed():
|
|
46
|
+
with pytest.raises(ValueError):
|
|
47
|
+
LFSRNoise(seed=0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------ model
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_model_rejects_asymmetric_weights():
|
|
54
|
+
with pytest.raises(ValueError):
|
|
55
|
+
BoltzmannMachine([[0, 1], [2, 0]], [0, 0])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_energy_matches_hand_calculation():
|
|
59
|
+
model = BoltzmannMachine([[0, 2], [2, 0]], [1, -1])
|
|
60
|
+
# E = -W01 z0 z1 - b.z
|
|
61
|
+
assert model.energy([1, 1]) == pytest.approx(-2 - 1 + 1)
|
|
62
|
+
assert model.energy([1, 0]) == pytest.approx(-1)
|
|
63
|
+
assert model.potential(np.array([0.0, 1.0]), 0) == pytest.approx(1 + 2)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_exact_distribution_sums_to_one_and_respects_clamp():
|
|
67
|
+
model = random_machine(4, seed=0)
|
|
68
|
+
p = exact_distribution(model, clamp={2: 1})
|
|
69
|
+
assert p.sum() == pytest.approx(1.0)
|
|
70
|
+
assert np.all(p[all_states(4)[:, 2] == 0] == 0)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------ samplers against exact enumeration
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.parametrize("noise", [NumpyNoise(seed=1), LFSRNoise(seed=99)], ids=["numpy", "lfsr"])
|
|
77
|
+
def test_pbit_sampler_matches_exact_distribution(noise):
|
|
78
|
+
model = random_machine(5, seed=3)
|
|
79
|
+
samples = PBitSampler(model, noise).sample(40000)
|
|
80
|
+
kl = kl_divergence(empirical_distribution(samples), exact_distribution(model))
|
|
81
|
+
assert kl < KL_LIMIT
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.mark.parametrize("tau", [2, 4])
|
|
85
|
+
def test_spiking_sampler_matches_exact_distribution(tau):
|
|
86
|
+
model = random_machine(5, seed=3)
|
|
87
|
+
samples = SpikingSampler(model, NumpyNoise(seed=2), tau=tau).sample(40000)
|
|
88
|
+
kl = kl_divergence(empirical_distribution(samples), exact_distribution(model))
|
|
89
|
+
assert kl < KL_LIMIT
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_spiking_sampler_with_tau_one_is_the_pbit_sampler():
|
|
93
|
+
model = random_machine(4, seed=5)
|
|
94
|
+
pbit = PBitSampler(model, NumpyNoise(seed=8)).sample(2000, burn_in=0)
|
|
95
|
+
spiking = SpikingSampler(model, NumpyNoise(seed=8), tau=1).sample(2000, burn_in=0)
|
|
96
|
+
assert np.array_equal(pbit, spiking)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_spiking_sampler_skips_random_draws_while_refractory():
|
|
100
|
+
model = random_machine(5, seed=3)
|
|
101
|
+
pbit = PBitSampler(model, NumpyNoise(seed=1))
|
|
102
|
+
spiking = SpikingSampler(model, NumpyNoise(seed=1), tau=4)
|
|
103
|
+
pbit.sample(5000)
|
|
104
|
+
spiking.sample(5000)
|
|
105
|
+
assert pbit.counters.random_draws == pbit.counters.updates
|
|
106
|
+
assert spiking.counters.random_draws < spiking.counters.updates
|
|
107
|
+
assert spiking.counters.random_bits == 32 * spiking.counters.random_draws
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_synaptic_events_follow_state_changes():
|
|
111
|
+
model = random_machine(5, seed=3)
|
|
112
|
+
sampler = PBitSampler(model, NumpyNoise(seed=4))
|
|
113
|
+
sampler.sample(1000)
|
|
114
|
+
# Dense random weights: every neuron reaches the other four.
|
|
115
|
+
assert sampler.counters.synaptic_events == 4 * sampler.counters.state_changes
|
|
116
|
+
assert sampler.counters.dense_multiply_adds == 4 * sampler.counters.updates
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------ mixing diagnostics
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_autocorrelation_time_of_independent_samples_is_one():
|
|
123
|
+
x = np.random.default_rng(0).normal(size=20000)
|
|
124
|
+
assert integrated_autocorrelation_time(x) == pytest.approx(1.0, abs=0.1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_autocorrelation_time_of_ar1_series_matches_theory():
|
|
128
|
+
# For x[t] = phi * x[t-1] + noise the time is (1 + phi) / (1 - phi) = 19 at phi = 0.9.
|
|
129
|
+
rng = np.random.default_rng(1)
|
|
130
|
+
x = np.zeros(200000)
|
|
131
|
+
for t in range(1, len(x)):
|
|
132
|
+
x[t] = 0.9 * x[t - 1] + rng.normal()
|
|
133
|
+
assert integrated_autocorrelation_time(x) == pytest.approx(19.0, rel=0.15)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_constant_series_is_rejected():
|
|
137
|
+
with pytest.raises(ValueError):
|
|
138
|
+
integrated_autocorrelation_time(np.ones(100))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_effective_sample_size_skips_clamped_neurons():
|
|
142
|
+
model = and_gate()
|
|
143
|
+
samples = PBitSampler(model, NumpyNoise(seed=3), clamp={2: 1}).sample(5000)
|
|
144
|
+
ess = effective_sample_size(samples, model)
|
|
145
|
+
assert 0 < ess <= 5000
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------ example problems
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_and_gate_forward_and_backward():
|
|
152
|
+
model = and_gate(strength=3.0)
|
|
153
|
+
for a in (0, 1):
|
|
154
|
+
for b in (0, 1):
|
|
155
|
+
samples = PBitSampler(model, NumpyNoise(seed=a * 2 + b), clamp={0: a, 1: b}).sample(5000)
|
|
156
|
+
assert samples[:, 2].mean() == pytest.approx(a & b, abs=0.1)
|
|
157
|
+
clamp = {2: 1}
|
|
158
|
+
samples = SpikingSampler(model, NumpyNoise(seed=11), tau=3, clamp=clamp).sample(20000)
|
|
159
|
+
kl = kl_divergence(empirical_distribution(samples), exact_distribution(model, clamp=clamp))
|
|
160
|
+
assert kl < KL_LIMIT
|
|
161
|
+
assert np.mean(np.all(samples == 1, axis=1)) > 0.85
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_annealing_finds_the_maximum_cut_of_a_ring():
|
|
165
|
+
edges = [(i, (i + 1) % 6) for i in range(6)]
|
|
166
|
+
sampler = PBitSampler(max_cut(6, edges), NumpyNoise(seed=0))
|
|
167
|
+
for beta in np.linspace(0.1, 5.0, 200):
|
|
168
|
+
sampler.beta = beta
|
|
169
|
+
sampler.sweep()
|
|
170
|
+
assert cut_size(edges, sampler.z) == 6
|