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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,4 @@
1
+ numpy>=1.24
2
+
3
+ [test]
4
+ pytest>=7
@@ -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