pt-neural-memory 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pt_neural_memory/__init__.py +72 -0
- pt_neural_memory/addressed_memory.py +474 -0
- pt_neural_memory/addressed_regressor.py +268 -0
- pt_neural_memory/benchmarks/__init__.py +20 -0
- pt_neural_memory/benchmarks/addressed_bench.py +382 -0
- pt_neural_memory/benchmarks/balance_bench.py +301 -0
- pt_neural_memory/benchmarks/capacity_bench.py +235 -0
- pt_neural_memory/benchmarks/export_forgetting.py +309 -0
- pt_neural_memory/benchmarks/forgetting_bench.py +947 -0
- pt_neural_memory/benchmarks/memory_bench.py +305 -0
- pt_neural_memory/benchmarks/scaling_bench.py +136 -0
- pt_neural_memory/benchmarks/training_cost.py +205 -0
- pt_neural_memory/forgetting_data.py +223 -0
- pt_neural_memory/mem_rls.py +319 -0
- pt_neural_memory/memory_sizing.py +341 -0
- pt_neural_memory/py.typed +0 -0
- pt_neural_memory-0.1.0.dist-info/METADATA +698 -0
- pt_neural_memory-0.1.0.dist-info/RECORD +21 -0
- pt_neural_memory-0.1.0.dist-info/WHEEL +5 -0
- pt_neural_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
- pt_neural_memory-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Fast associative memory built from addressed recursive-least-squares cells.
|
|
2
|
+
|
|
3
|
+
The package has three layers:
|
|
4
|
+
|
|
5
|
+
* :class:`RLSMemCell` -- one linear map fitted by the exact recursive-least-
|
|
6
|
+
squares update, which is the Kalman filter for ``value = W key + noise``,
|
|
7
|
+
with its posterior error covariance.
|
|
8
|
+
* :class:`AddressedMemory` -- a bank of independent Kalman filters selected by
|
|
9
|
+
the sign bits of a random projection, so many local maps can be stored and
|
|
10
|
+
read.
|
|
11
|
+
* :class:`AddressedRegressor` -- a random feature map composed with the table,
|
|
12
|
+
which turns the memory into a nonlinear regressor fitted in closed form.
|
|
13
|
+
|
|
14
|
+
A store is an indexed, exact posterior update and a read is a table lookup and
|
|
15
|
+
a matrix-vector product. Stores are no-grad by default;
|
|
16
|
+
``store(..., grad_enabled=True)`` keeps the graph through a write. The feature
|
|
17
|
+
map remains random and untrained, and address selection is discrete.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
21
|
+
|
|
22
|
+
from pt_neural_memory.addressed_memory import (
|
|
23
|
+
AddressedMemory,
|
|
24
|
+
CellRead,
|
|
25
|
+
VerifiedMemory,
|
|
26
|
+
)
|
|
27
|
+
from pt_neural_memory.addressed_regressor import AddressedRegressor, RandomFeatureMap
|
|
28
|
+
from pt_neural_memory.forgetting_data import Task, regression_stream, target_function
|
|
29
|
+
from pt_neural_memory.mem_rls import RLSCellBank, RLSMemCell
|
|
30
|
+
from pt_neural_memory.memory_sizing import (
|
|
31
|
+
ADMISSIBLE_WRITERS_2PCT,
|
|
32
|
+
ADMISSIBLE_WRITERS_5PCT,
|
|
33
|
+
MemorySizing,
|
|
34
|
+
balanced_layout,
|
|
35
|
+
bits_for_capacity,
|
|
36
|
+
capacity,
|
|
37
|
+
pairs_per_cell,
|
|
38
|
+
size_for_budget,
|
|
39
|
+
state_bytes,
|
|
40
|
+
state_parts,
|
|
41
|
+
writers_per_cell,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
__version__ = version("pt-neural-memory")
|
|
46
|
+
except PackageNotFoundError: # running from a source tree without an install
|
|
47
|
+
__version__ = "0.1.0"
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"ADMISSIBLE_WRITERS_2PCT",
|
|
51
|
+
"ADMISSIBLE_WRITERS_5PCT",
|
|
52
|
+
"AddressedMemory",
|
|
53
|
+
"AddressedRegressor",
|
|
54
|
+
"CellRead",
|
|
55
|
+
"MemorySizing",
|
|
56
|
+
"RLSCellBank",
|
|
57
|
+
"RLSMemCell",
|
|
58
|
+
"RandomFeatureMap",
|
|
59
|
+
"Task",
|
|
60
|
+
"VerifiedMemory",
|
|
61
|
+
"__version__",
|
|
62
|
+
"balanced_layout",
|
|
63
|
+
"bits_for_capacity",
|
|
64
|
+
"capacity",
|
|
65
|
+
"pairs_per_cell",
|
|
66
|
+
"regression_stream",
|
|
67
|
+
"size_for_budget",
|
|
68
|
+
"state_bytes",
|
|
69
|
+
"state_parts",
|
|
70
|
+
"target_function",
|
|
71
|
+
"writers_per_cell",
|
|
72
|
+
]
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""Addressed memory: a table of RLS cells selected by projection bits.
|
|
2
|
+
|
|
3
|
+
Each of the ``n_blocks`` blocks owns an independent random projection of the
|
|
4
|
+
key. A key's address in a block is the sign pattern of its projected
|
|
5
|
+
coordinates read as an ``n_bits``-bit integer, so the block has ``2**n_bits``
|
|
6
|
+
cells. A cell is a recursive-least-squares memory cell: it fits the local
|
|
7
|
+
linear map ``value = weights @ key`` from the pairs written to it, and carries
|
|
8
|
+
the exact ridge posterior that ``RLSMemCell`` maintains.
|
|
9
|
+
|
|
10
|
+
``store`` writes each pair not only at the key's address but at every address
|
|
11
|
+
within ``write_radius`` bit flips of it. Radius 1 therefore trains
|
|
12
|
+
``n_blocks * (n_bits + 1)`` cells per pair instead of ``n_blocks``, and lets a
|
|
13
|
+
query whose address was corrupted in one bit still find a cell that was trained
|
|
14
|
+
on it. Because a cell is a regression rather than a mean, the copies are not
|
|
15
|
+
wasted redundancy even on clean keys: each neighbour cell learns its own local
|
|
16
|
+
map from every pair that touches it.
|
|
17
|
+
|
|
18
|
+
``forward`` reads the cell at the query's own address in each block and
|
|
19
|
+
averages the blocks. ``VerifiedMemory`` instead probes the addresses within
|
|
20
|
+
``read_radius`` flips and reads the cell that explains the query best.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from itertools import combinations, product
|
|
27
|
+
|
|
28
|
+
import torch
|
|
29
|
+
from torch import nn
|
|
30
|
+
|
|
31
|
+
from pt_neural_memory.mem_rls import RLSCellBank
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _flip_masks(n_bits: int, radius: int) -> list[tuple[int, ...]]:
|
|
35
|
+
"""Every set of bit positions of size at most ``radius``.
|
|
36
|
+
|
|
37
|
+
Each tuple names one address in the write neighbourhood: the key's own
|
|
38
|
+
address for the empty tuple, otherwise the address reached by flipping
|
|
39
|
+
those bits.
|
|
40
|
+
"""
|
|
41
|
+
return [
|
|
42
|
+
positions
|
|
43
|
+
for size in range(radius + 1)
|
|
44
|
+
for positions in combinations(range(n_bits), size)
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _mask_value(mask: tuple[int, ...]) -> int:
|
|
49
|
+
"""Pack bit positions into an integer XOR mask."""
|
|
50
|
+
value = 0
|
|
51
|
+
for position in mask:
|
|
52
|
+
value |= 1 << position
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AddressedMemory(nn.Module):
|
|
57
|
+
"""``n_blocks`` blocks of ``2**n_bits`` RLS cells, addressed by projection signs.
|
|
58
|
+
|
|
59
|
+
The block projections have orthonormal rows, so for a key with i.i.d.
|
|
60
|
+
standard-normal coordinates each bit is an independent fair coin; the
|
|
61
|
+
address of a random key is uniform on ``2**n_bits`` cells and two random
|
|
62
|
+
keys share a block address with probability ``2**-n_bits``.
|
|
63
|
+
|
|
64
|
+
A cell holds the ridge posterior of every pair written to it: its map is
|
|
65
|
+
the exact least-squares fit ``value = weights @ key`` regularised by
|
|
66
|
+
``rr_lambda``, and its covariance says how well-constrained that map is.
|
|
67
|
+
One cell can therefore recall up to ``key_dim`` independent associations
|
|
68
|
+
exactly, where a cell that stores a mean can recall only one. An unwritten
|
|
69
|
+
cell is the prior and answers zero. Writes are no-grad by default; pass
|
|
70
|
+
``grad_enabled=True`` to ``store`` to keep the graph through the written
|
|
71
|
+
keys and values. Address selection is discrete, so gradients do not flow
|
|
72
|
+
through the choice of cell.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
key_dim: dimension of the keys the memory is addressed with.
|
|
76
|
+
value_dim: dimension of the values stored in a cell.
|
|
77
|
+
n_blocks: number of independent blocks.
|
|
78
|
+
n_bits: address width per block; the block has ``2**n_bits`` cells.
|
|
79
|
+
Capped at 20 so a typo cannot allocate a billion cells, and it must
|
|
80
|
+
not exceed ``key_dim``: the address bits are independent only while
|
|
81
|
+
the projection rows are orthonormal, and a ``key_dim``-dimensional
|
|
82
|
+
space admits at most ``key_dim`` of them.
|
|
83
|
+
write_radius: Hamming radius of the write neighbourhood. ``0`` writes
|
|
84
|
+
only the key's own address; ``1`` also writes every one-bit
|
|
85
|
+
neighbour.
|
|
86
|
+
rr_lambda: ridge prior of every cell; see ``RLSMemCell``.
|
|
87
|
+
seed: seed of the projection generator, not of the data.
|
|
88
|
+
dtype: state dtype, promoted to at least float32.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
projection: torch.Tensor
|
|
92
|
+
cells: RLSCellBank
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
key_dim: int,
|
|
97
|
+
value_dim: int,
|
|
98
|
+
n_blocks: int = 3,
|
|
99
|
+
n_bits: int = 12,
|
|
100
|
+
write_radius: int = 1,
|
|
101
|
+
rr_lambda: float = 1.0,
|
|
102
|
+
seed: int = 0,
|
|
103
|
+
dtype: torch.dtype | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
super().__init__()
|
|
106
|
+
if key_dim < 1 or value_dim < 1:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"key_dim and value_dim must be positive, got {key_dim} and {value_dim}"
|
|
109
|
+
)
|
|
110
|
+
if n_blocks < 1:
|
|
111
|
+
raise ValueError(f"n_blocks must be positive, got {n_blocks}")
|
|
112
|
+
if not 1 <= n_bits <= 20:
|
|
113
|
+
raise ValueError(f"n_bits must lie in [1, 20], got {n_bits}")
|
|
114
|
+
if n_bits > key_dim:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"n_bits must not exceed key_dim, got n_bits={n_bits} and "
|
|
117
|
+
f"key_dim={key_dim}: the address bits are independent only "
|
|
118
|
+
f"while the projection rows are orthonormal, and {key_dim} "
|
|
119
|
+
f"dimensions admit at most {key_dim} of them"
|
|
120
|
+
)
|
|
121
|
+
if not 0 <= write_radius <= n_bits:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"write_radius must lie in [0, {n_bits}], got {write_radius}"
|
|
124
|
+
)
|
|
125
|
+
self.key_dim = key_dim
|
|
126
|
+
self.value_dim = value_dim
|
|
127
|
+
self.n_blocks = n_blocks
|
|
128
|
+
self.n_bits = n_bits
|
|
129
|
+
self.n_cells = 1 << n_bits
|
|
130
|
+
self.write_radius = write_radius
|
|
131
|
+
state_dtype = torch.promote_types(dtype or torch.float32, torch.float32)
|
|
132
|
+
self.register_buffer("projection", self._make_projection(seed, state_dtype))
|
|
133
|
+
self.cells = RLSCellBank(
|
|
134
|
+
key_dim,
|
|
135
|
+
value_dim,
|
|
136
|
+
n_blocks * self.n_cells,
|
|
137
|
+
rr_lambda=rr_lambda,
|
|
138
|
+
dtype=dtype,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _make_projection(self, seed: int, dtype: torch.dtype) -> torch.Tensor:
|
|
142
|
+
"""One ``n_bits x key_dim`` block projection with orthonormal rows."""
|
|
143
|
+
generator = torch.Generator().manual_seed(seed)
|
|
144
|
+
blocks = []
|
|
145
|
+
for _ in range(self.n_blocks):
|
|
146
|
+
raw = torch.randn(
|
|
147
|
+
self.key_dim, self.n_bits, generator=generator, dtype=torch.float64
|
|
148
|
+
)
|
|
149
|
+
orthogonal, _ = torch.linalg.qr(raw)
|
|
150
|
+
blocks.append(orthogonal[:, : self.n_bits].T.contiguous())
|
|
151
|
+
return torch.stack(blocks).to(dtype)
|
|
152
|
+
|
|
153
|
+
def projections(self, key: torch.Tensor) -> torch.Tensor:
|
|
154
|
+
"""Signed projection coordinates, shape ``(batch, n_blocks, n_bits)``.
|
|
155
|
+
|
|
156
|
+
A row has unit norm, so for a key with standard-normal coordinates the
|
|
157
|
+
coordinate is standard normal and its absolute value is the address
|
|
158
|
+
margin: how much key noise that bit tolerates before it flips.
|
|
159
|
+
"""
|
|
160
|
+
self._check_key(key)
|
|
161
|
+
return torch.einsum("bd,nmd->bnm", key, self.projection)
|
|
162
|
+
|
|
163
|
+
def bit_patterns(self, key: torch.Tensor) -> torch.Tensor:
|
|
164
|
+
"""Address bits, shape ``(batch, n_blocks, n_bits)``; ``True`` is 1."""
|
|
165
|
+
return self.projections(key) >= 0
|
|
166
|
+
|
|
167
|
+
def addresses(self, key: torch.Tensor) -> torch.Tensor:
|
|
168
|
+
"""Packed addresses, shape ``(batch, n_blocks)``, dtype int64."""
|
|
169
|
+
bits = self.bit_patterns(key).to(torch.int64)
|
|
170
|
+
powers = 1 << torch.arange(self.n_bits, device=key.device)
|
|
171
|
+
return (bits * powers).sum(dim=-1)
|
|
172
|
+
|
|
173
|
+
def _check_key(self, key: torch.Tensor) -> None:
|
|
174
|
+
if key.dim() != 2 or key.shape[1] != self.key_dim:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"key must have shape (batch, {self.key_dim}), got {tuple(key.shape)}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _flat_indices(self, address: torch.Tensor) -> torch.Tensor:
|
|
180
|
+
"""Flatten ``(block, cell)`` pairs into one index per block row."""
|
|
181
|
+
blocks = torch.arange(self.n_blocks, device=address.device)
|
|
182
|
+
return address + blocks * self.n_cells
|
|
183
|
+
|
|
184
|
+
def _write_addresses(
|
|
185
|
+
self, key: torch.Tensor
|
|
186
|
+
) -> list[tuple[torch.Tensor, torch.Tensor | None]]:
|
|
187
|
+
"""Addresses to write, with an optional per-entry validity mask.
|
|
188
|
+
|
|
189
|
+
``None`` means every row is written; a boolean mask of shape
|
|
190
|
+
``(batch, n_blocks)`` selects the entries of an address tensor that
|
|
191
|
+
are written. The mask is what lets a subclass skip neighbours.
|
|
192
|
+
"""
|
|
193
|
+
base = self.addresses(key)
|
|
194
|
+
return [
|
|
195
|
+
(base ^ _mask_value(mask), None)
|
|
196
|
+
for mask in _flip_masks(self.n_bits, self.write_radius)
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
def store(
|
|
200
|
+
self,
|
|
201
|
+
key: torch.Tensor,
|
|
202
|
+
value: torch.Tensor,
|
|
203
|
+
grad_enabled: bool = False,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Train the write neighbourhood of a batch of associations.
|
|
206
|
+
|
|
207
|
+
Each pair trains the cell at its address and every cell within
|
|
208
|
+
``write_radius`` flips of it, in every block. The update is no-grad by
|
|
209
|
+
default; pass ``grad_enabled=True`` to keep the autograd graph through
|
|
210
|
+
the key and value.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
key: ``(batch, key_dim)`` keys to address.
|
|
214
|
+
value: ``(batch, value_dim)`` values to associate with them.
|
|
215
|
+
grad_enabled: keep the autograd graph for the update.
|
|
216
|
+
"""
|
|
217
|
+
if key.dim() != 2 or value.dim() != 2:
|
|
218
|
+
raise ValueError(
|
|
219
|
+
"key and value must be 2D (batch, features), got "
|
|
220
|
+
f"{tuple(key.shape)} and {tuple(value.shape)}"
|
|
221
|
+
)
|
|
222
|
+
if key.shape[0] != value.shape[0]:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"batch sizes differ: {key.shape[0]} keys, {value.shape[0]} values"
|
|
225
|
+
)
|
|
226
|
+
if key.shape[1] != self.key_dim:
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"key must have shape (batch, {self.key_dim}), got {tuple(key.shape)}"
|
|
229
|
+
)
|
|
230
|
+
if value.shape[1] != self.value_dim:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
f"value must have shape (batch, {self.value_dim}), "
|
|
233
|
+
f"got {tuple(value.shape)}"
|
|
234
|
+
)
|
|
235
|
+
if key.shape[0] == 0:
|
|
236
|
+
return
|
|
237
|
+
with torch.set_grad_enabled(mode=grad_enabled):
|
|
238
|
+
expanded_key = (
|
|
239
|
+
key[:, None, :]
|
|
240
|
+
.expand(-1, self.n_blocks, -1)
|
|
241
|
+
.reshape(-1, self.key_dim)
|
|
242
|
+
)
|
|
243
|
+
expanded_value = (
|
|
244
|
+
value[:, None, :]
|
|
245
|
+
.expand(-1, self.n_blocks, -1)
|
|
246
|
+
.reshape(-1, self.value_dim)
|
|
247
|
+
)
|
|
248
|
+
# One bank call for the whole neighbourhood: the masks name disjoint
|
|
249
|
+
# cells for a given pair, and the bank sequences any repeats itself.
|
|
250
|
+
indices = []
|
|
251
|
+
keys = []
|
|
252
|
+
values = []
|
|
253
|
+
for address, valid in self._write_addresses(key):
|
|
254
|
+
flat = self._flat_indices(address).reshape(-1)
|
|
255
|
+
if valid is None:
|
|
256
|
+
indices.append(flat)
|
|
257
|
+
keys.append(expanded_key)
|
|
258
|
+
values.append(expanded_value)
|
|
259
|
+
else:
|
|
260
|
+
keep = valid.reshape(-1)
|
|
261
|
+
indices.append(flat[keep])
|
|
262
|
+
keys.append(expanded_key[keep])
|
|
263
|
+
values.append(expanded_value[keep])
|
|
264
|
+
self.cells.store(
|
|
265
|
+
torch.cat(indices),
|
|
266
|
+
torch.cat(keys),
|
|
267
|
+
torch.cat(values),
|
|
268
|
+
grad_enabled=grad_enabled,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def read_blocks(self, key: torch.Tensor) -> torch.Tensor:
|
|
272
|
+
"""Per-block cell predictions, shape ``(n_blocks, batch, value_dim)``."""
|
|
273
|
+
self._check_key(key)
|
|
274
|
+
flat = self._flat_indices(self.addresses(key))
|
|
275
|
+
keys = key[:, None, :].expand(-1, self.n_blocks, -1)
|
|
276
|
+
return self.cells.predict(flat, keys).transpose(0, 1)
|
|
277
|
+
|
|
278
|
+
def read_variances(self, key: torch.Tensor) -> torch.Tensor:
|
|
279
|
+
"""Per-block posterior variance of the query, shape ``(n_blocks, batch)``.
|
|
280
|
+
|
|
281
|
+
The cell's own confidence signal: ``‖k‖² / λ`` at an unwritten cell,
|
|
282
|
+
about one at a cell whose map was fitted to this key.
|
|
283
|
+
"""
|
|
284
|
+
self._check_key(key)
|
|
285
|
+
flat = self._flat_indices(self.addresses(key))
|
|
286
|
+
keys = key[:, None, :].expand(-1, self.n_blocks, -1)
|
|
287
|
+
return self.cells.variance(flat, keys).transpose(0, 1)
|
|
288
|
+
|
|
289
|
+
def forward(self, key: torch.Tensor) -> torch.Tensor:
|
|
290
|
+
"""Read the cell at the query address, averaged over blocks."""
|
|
291
|
+
return self.read_blocks(key).mean(dim=0)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass
|
|
295
|
+
class CellRead:
|
|
296
|
+
"""The cell selected in each block, and the evidence for the choice."""
|
|
297
|
+
|
|
298
|
+
values: torch.Tensor
|
|
299
|
+
"""Selected cell prediction per block, shape ``(n_blocks, batch, value_dim)``."""
|
|
300
|
+
scores: torch.Tensor
|
|
301
|
+
"""Posterior variance of the query under the selected cell; lower is better."""
|
|
302
|
+
indices: torch.Tensor
|
|
303
|
+
"""Flat ids of the selected cells, shape ``(n_blocks, batch)``, int64."""
|
|
304
|
+
|
|
305
|
+
def best(self) -> torch.Tensor:
|
|
306
|
+
"""Value of the best-scoring block, shape ``(batch, value_dim)``."""
|
|
307
|
+
pick = self.scores.argmin(dim=0)
|
|
308
|
+
rows = torch.arange(pick.shape[0], device=pick.device)
|
|
309
|
+
return self.values[pick, rows]
|
|
310
|
+
|
|
311
|
+
def mean(self) -> torch.Tensor:
|
|
312
|
+
"""Mean of the selected block values, shape ``(batch, value_dim)``."""
|
|
313
|
+
return self.values.mean(dim=0)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class VerifiedMemory(AddressedMemory):
|
|
317
|
+
"""Addressed memory that probes neighbouring addresses before reading.
|
|
318
|
+
|
|
319
|
+
A read no longer trusts the query's own address alone. It scans the
|
|
320
|
+
addresses within ``read_radius`` bit flips and asks each candidate cell how
|
|
321
|
+
well it explains the query, using the cell's own posterior variance:
|
|
322
|
+
|
|
323
|
+
$$ s(c) = q^\\top P_c q . $$
|
|
324
|
+
|
|
325
|
+
The smallest variance wins in each block, the smallest across blocks is
|
|
326
|
+
returned, and the value is that cell's prediction ``W_c q``. The signal is
|
|
327
|
+
the RLS cell's own uncertainty: a cell whose writers span the query scores
|
|
328
|
+
about one, an unwritten cell scores ``‖q‖² / λ``, and a cell crowded with
|
|
329
|
+
unrelated keys scores ``‖q‖² / (c + λ)``. It therefore prefers crowded
|
|
330
|
+
cells, which is exactly the regime where their map has the information to
|
|
331
|
+
answer; only when the crowding exceeds the key dimension does the
|
|
332
|
+
distinction disappear.
|
|
333
|
+
|
|
334
|
+
Because a write places a copy within ``write_radius`` flips and a read
|
|
335
|
+
searches ``read_radius`` flips, the two radii add: a query survives up to
|
|
336
|
+
``write_radius + read_radius`` corrupted bits. Read redundancy costs
|
|
337
|
+
probes, not cells, so it does not inflate the collision rate the way
|
|
338
|
+
write-side replication does.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
read_radius: Hamming radius searched at read time. With
|
|
342
|
+
``write_radius=1`` the default ``read_radius=1`` corrects two
|
|
343
|
+
corrupted bits.
|
|
344
|
+
protect_bits: if set, a write replicates only the ``protect_bits``
|
|
345
|
+
lowest-margin bits instead of every bit within ``write_radius``.
|
|
346
|
+
Margins are standard normal for these projections, so the lowest
|
|
347
|
+
ones are the bits key noise is most likely to flip.
|
|
348
|
+
seed: seed of the projection generator.
|
|
349
|
+
dtype: state dtype, promoted to at least float32.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
def __init__(
|
|
353
|
+
self,
|
|
354
|
+
key_dim: int,
|
|
355
|
+
value_dim: int,
|
|
356
|
+
n_blocks: int = 3,
|
|
357
|
+
n_bits: int = 12,
|
|
358
|
+
write_radius: int = 1,
|
|
359
|
+
read_radius: int = 1,
|
|
360
|
+
protect_bits: int | None = None,
|
|
361
|
+
rr_lambda: float = 1.0,
|
|
362
|
+
seed: int = 0,
|
|
363
|
+
dtype: torch.dtype | None = None,
|
|
364
|
+
) -> None:
|
|
365
|
+
super().__init__(
|
|
366
|
+
key_dim,
|
|
367
|
+
value_dim,
|
|
368
|
+
n_blocks=n_blocks,
|
|
369
|
+
n_bits=n_bits,
|
|
370
|
+
write_radius=write_radius,
|
|
371
|
+
rr_lambda=rr_lambda,
|
|
372
|
+
seed=seed,
|
|
373
|
+
dtype=dtype,
|
|
374
|
+
)
|
|
375
|
+
if not 0 <= read_radius <= n_bits:
|
|
376
|
+
raise ValueError(
|
|
377
|
+
f"read_radius must lie in [0, {n_bits}], got {read_radius}"
|
|
378
|
+
)
|
|
379
|
+
if protect_bits is not None and not 0 <= protect_bits <= n_bits:
|
|
380
|
+
raise ValueError(
|
|
381
|
+
f"protect_bits must lie in [0, {n_bits}] or be None, "
|
|
382
|
+
f"got {protect_bits}"
|
|
383
|
+
)
|
|
384
|
+
self.read_radius = read_radius
|
|
385
|
+
self.protect_bits = protect_bits
|
|
386
|
+
|
|
387
|
+
def _risky_bits(self, key: torch.Tensor) -> torch.Tensor:
|
|
388
|
+
"""``True`` for the ``protect_bits`` lowest-margin bits of each key."""
|
|
389
|
+
margins = self.projections(key).abs().transpose(0, 1)
|
|
390
|
+
rank = margins.argsort(dim=-1).argsort(dim=-1)
|
|
391
|
+
return (rank < self.protect_bits).transpose(0, 1)
|
|
392
|
+
|
|
393
|
+
def _write_addresses(
|
|
394
|
+
self, key: torch.Tensor
|
|
395
|
+
) -> list[tuple[torch.Tensor, torch.Tensor | None]]:
|
|
396
|
+
base = self.addresses(key)
|
|
397
|
+
masks = _flip_masks(self.n_bits, self.write_radius)
|
|
398
|
+
if self.protect_bits is None:
|
|
399
|
+
return [(base ^ _mask_value(mask), None) for mask in masks]
|
|
400
|
+
risky = self._risky_bits(key)
|
|
401
|
+
plan = []
|
|
402
|
+
for mask in masks:
|
|
403
|
+
valid = None
|
|
404
|
+
if mask:
|
|
405
|
+
valid = torch.stack([risky[..., bit] for bit in mask]).all(dim=0)
|
|
406
|
+
plan.append((base ^ _mask_value(mask), valid))
|
|
407
|
+
return plan
|
|
408
|
+
|
|
409
|
+
def _select(self, key: torch.Tensor, probes: list[torch.Tensor]) -> CellRead:
|
|
410
|
+
"""Score every probe cell, keep the least uncertain one per block."""
|
|
411
|
+
batch = key.shape[0]
|
|
412
|
+
best_score = torch.full(
|
|
413
|
+
(batch, self.n_blocks),
|
|
414
|
+
float("inf"),
|
|
415
|
+
dtype=self.cells.covariance.dtype,
|
|
416
|
+
)
|
|
417
|
+
best_index = torch.zeros(
|
|
418
|
+
(batch, self.n_blocks), dtype=torch.int64, device=key.device
|
|
419
|
+
)
|
|
420
|
+
keys = key[:, None, :].expand(-1, self.n_blocks, -1)
|
|
421
|
+
for address in probes:
|
|
422
|
+
index = self._flat_indices(address)
|
|
423
|
+
score = self.cells.variance(index, keys)
|
|
424
|
+
better = score < best_score
|
|
425
|
+
best_score = torch.where(better, score, best_score)
|
|
426
|
+
best_index = torch.where(better, index, best_index)
|
|
427
|
+
return CellRead(
|
|
428
|
+
values=self.cells.predict(best_index, keys).transpose(0, 1),
|
|
429
|
+
scores=best_score.transpose(0, 1),
|
|
430
|
+
indices=best_index.transpose(0, 1),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def read_ball(self, key: torch.Tensor, radius: int | None = None) -> CellRead:
|
|
434
|
+
"""Verify every cell within ``radius`` flips of the query's address."""
|
|
435
|
+
radius = self.read_radius if radius is None else radius
|
|
436
|
+
if not 0 <= radius <= self.n_bits:
|
|
437
|
+
raise ValueError(
|
|
438
|
+
f"radius must lie in [0, {self.n_bits}], got {radius}"
|
|
439
|
+
)
|
|
440
|
+
base = self.addresses(key)
|
|
441
|
+
probes = [
|
|
442
|
+
base ^ _mask_value(mask)
|
|
443
|
+
for mask in _flip_masks(self.n_bits, radius)
|
|
444
|
+
]
|
|
445
|
+
return self._select(key, probes)
|
|
446
|
+
|
|
447
|
+
def read_guided(
|
|
448
|
+
self, key: torch.Tensor, search_bits: int = 6
|
|
449
|
+
) -> CellRead:
|
|
450
|
+
"""Verify every flip pattern of the least-confident ``search_bits`` bits.
|
|
451
|
+
|
|
452
|
+
Cost is ``2 ** search_bits`` probes per block instead of
|
|
453
|
+
``sum(C(n_bits, j) for j <= radius)``, and it does not assume an upper
|
|
454
|
+
bound on the number of flipped bits: a flip in a bit with a large
|
|
455
|
+
margin is simply not searched.
|
|
456
|
+
"""
|
|
457
|
+
if not 1 <= search_bits <= min(self.n_bits, 12):
|
|
458
|
+
raise ValueError(
|
|
459
|
+
f"search_bits must lie in [1, {min(self.n_bits, 12)}], "
|
|
460
|
+
f"got {search_bits}"
|
|
461
|
+
)
|
|
462
|
+
base = self.addresses(key)
|
|
463
|
+
order = self.projections(key).abs().argsort(dim=-1)[..., :search_bits]
|
|
464
|
+
flip = (1 << order).unsqueeze(2) # (batch, n_blocks, 1, search_bits)
|
|
465
|
+
patterns = torch.tensor(
|
|
466
|
+
list(product((0, 1), repeat=search_bits)), device=base.device
|
|
467
|
+
)
|
|
468
|
+
flips = (flip * patterns).sum(dim=-1) # (batch, n_blocks, 2**search_bits)
|
|
469
|
+
probes = [base ^ flips[..., i] for i in range(patterns.shape[0])]
|
|
470
|
+
return self._select(key, probes)
|
|
471
|
+
|
|
472
|
+
def forward(self, key: torch.Tensor) -> torch.Tensor:
|
|
473
|
+
"""Read the best-verified cell, shape ``(batch, value_dim)``."""
|
|
474
|
+
return self.read_ball(key).best()
|