fluidlayer 2.0.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.
- fluidlayer/__init__.py +96 -0
- fluidlayer/autodiff.py +472 -0
- fluidlayer/blocks.py +275 -0
- fluidlayer/bpe.py +173 -0
- fluidlayer/data.py +92 -0
- fluidlayer/diffusion.py +264 -0
- fluidlayer/gpt2_model.py +129 -0
- fluidlayer/gpt2_tokenizer.py +127 -0
- fluidlayer/graft.py +741 -0
- fluidlayer/losses.py +86 -0
- fluidlayer/memory.py +525 -0
- fluidlayer/model.py +141 -0
- fluidlayer/optim.py +57 -0
- fluidlayer/py.typed +0 -0
- fluidlayer/sheaf.py +147 -0
- fluidlayer/topology.py +294 -0
- fluidlayer/torch_graft.py +283 -0
- fluidlayer-2.0.0.dist-info/METADATA +376 -0
- fluidlayer-2.0.0.dist-info/RECORD +22 -0
- fluidlayer-2.0.0.dist-info/WHEEL +5 -0
- fluidlayer-2.0.0.dist-info/licenses/LICENSE +21 -0
- fluidlayer-2.0.0.dist-info/top_level.txt +1 -0
fluidlayer/blocks.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fluidlayer.blocks — the FluidLayer block.
|
|
3
|
+
|
|
4
|
+
One block, five stages. Every stage exists to fix a named weakness of the
|
|
5
|
+
naive "replace attention with diffusion" idea:
|
|
6
|
+
|
|
7
|
+
ROUTER top-k content routing -> semantic edges
|
|
8
|
+
TOPOLOGY semantic edges + temporal chain -> the complex
|
|
9
|
+
SHEAF learned signed restriction maps -> content-adaptive geometry
|
|
10
|
+
FREEZE multi-scale band filter bank (exp(-t_j L) - exp(-t_{j+1} L))
|
|
11
|
+
READ channel mixing + ONE nonlinearity + residual + sharp route head
|
|
12
|
+
|
|
13
|
+
Stage notes
|
|
14
|
+
-----------
|
|
15
|
+
* The signed restriction maps make the G-balance phase transition learnable:
|
|
16
|
+
a balanced component freezes onto a non-trivial sign pattern, an unbalanced
|
|
17
|
+
one decays to zero. `tanh` keeps |alpha| <= 1 so L stays bounded and
|
|
18
|
+
gradients cannot explode.
|
|
19
|
+
* The band filter bank (not a single exponential) is what prevents the
|
|
20
|
+
"frozen state has rank c" information loss.
|
|
21
|
+
* The route head is a hard top-k gather (winner-take-all). It is the sharp
|
|
22
|
+
retrieval path the smooth fluid cannot provide -- this is what keeps the
|
|
23
|
+
model from being a pure smoother.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
from .autodiff import Tensor, concat, softmax_rows, softplus
|
|
31
|
+
from .diffusion import (
|
|
32
|
+
band_times,
|
|
33
|
+
cheb_coeffs_exp,
|
|
34
|
+
cheb_coeffs_exp_autodiff,
|
|
35
|
+
power_iteration_lmax,
|
|
36
|
+
spectral_bound_autodiff,
|
|
37
|
+
)
|
|
38
|
+
from .topology import (
|
|
39
|
+
chain_edges,
|
|
40
|
+
connected_components,
|
|
41
|
+
edges_from_topk,
|
|
42
|
+
topo_descriptor,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = ["FluidLayerBlock", "RMSNorm", "softplus"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RMSNorm:
|
|
49
|
+
def __init__(self, d: int, seed: int = 0):
|
|
50
|
+
self.g = Tensor(np.ones(d))
|
|
51
|
+
self.eps = 1e-6
|
|
52
|
+
|
|
53
|
+
def __call__(self, x: Tensor) -> Tensor:
|
|
54
|
+
ms = (x * x).mean()
|
|
55
|
+
inv = (ms + self.eps).pow(-0.5)
|
|
56
|
+
n = x * inv
|
|
57
|
+
return n * self.g
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FluidLayerBlock:
|
|
61
|
+
def __init__(self, d: int, k_neighbors: int = 8, bands: int = 6, cheb_m: int = 12,
|
|
62
|
+
t_min: float = 1e-3, t_max: float = 1e3, edge_hidden: int = 16,
|
|
63
|
+
use_route_head: bool = True, seed: int = 0,
|
|
64
|
+
alpha_min: float = 0.05, alpha_max: float | None = None):
|
|
65
|
+
rng = np.random.default_rng(seed)
|
|
66
|
+
self.d = d
|
|
67
|
+
self.k = int(k_neighbors)
|
|
68
|
+
self.B = int(bands)
|
|
69
|
+
self.m = int(cheb_m)
|
|
70
|
+
# Bands live in SPECTRAL time units alpha = t*lam_max/2 so that the
|
|
71
|
+
# Chebyshev degree m stays sufficient for any graph scale.
|
|
72
|
+
self.alpha_min = alpha_min
|
|
73
|
+
self.alpha_max = alpha_max if alpha_max is not None else 0.75 * self.m
|
|
74
|
+
self.ts = np.geomspace(t_min, t_max, self.B + 1) # fallback / reference
|
|
75
|
+
self.use_route_head = use_route_head
|
|
76
|
+
|
|
77
|
+
s = d ** -0.5
|
|
78
|
+
self.Wq = Tensor(rng.normal(0, s, (d, d)))
|
|
79
|
+
self.Wk = Tensor(rng.normal(0, s, (d, d)))
|
|
80
|
+
self.Wv = Tensor(rng.normal(0, s, (d, d)))
|
|
81
|
+
self.Wo = Tensor(rng.normal(0, s, (d, d)))
|
|
82
|
+
self.Wmix = Tensor(rng.normal(0, s, (d, d)))
|
|
83
|
+
self.Wr = Tensor(rng.normal(0, s, (d, d))) if use_route_head else None
|
|
84
|
+
self.edge1 = Tensor(rng.normal(0, (2 * d) ** -0.5, (2 * d, edge_hidden)))
|
|
85
|
+
self.edge2 = Tensor(rng.normal(0, edge_hidden ** -0.5, (edge_hidden, 1)))
|
|
86
|
+
self.beta_chain = Tensor(np.array([[0.3]])) # order / temporal edges
|
|
87
|
+
# Band gates start NEAR ZERO (softplus(-3) ~ 0.05): the transient is
|
|
88
|
+
# much larger in magnitude than the harmonic component, so starting
|
|
89
|
+
# with the bands "open" would swamp the frozen state.
|
|
90
|
+
self.band_g = Tensor(np.full((self.B, 1), -3.0)) # band gates (softplus)
|
|
91
|
+
self.harm_g = Tensor(np.array([[1.0]])) # gate on the HARMONIC branch
|
|
92
|
+
self.norm = RMSNorm(d)
|
|
93
|
+
self.diag = {}
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------- helpers
|
|
96
|
+
def _laplacian(self, n: int, edges: np.ndarray, alpha: Tensor) -> Tensor:
|
|
97
|
+
"""Differentiable signed sheaf Laplacian L = D_abs - A."""
|
|
98
|
+
m = edges.shape[0]
|
|
99
|
+
if m == 0:
|
|
100
|
+
return Tensor(np.zeros((n, n)))
|
|
101
|
+
idx_uv = (edges[:, 0].astype(np.int64) * n + edges[:, 1].astype(np.int64))
|
|
102
|
+
idx_vu = (edges[:, 1].astype(np.int64) * n + edges[:, 0].astype(np.int64))
|
|
103
|
+
Z = Tensor(np.zeros(n * n))
|
|
104
|
+
# two SEPARATE scatter bases, then added: index_add starts from zeros,
|
|
105
|
+
# so chaining it would silently discard the first contribution.
|
|
106
|
+
A = (Z.index_add(idx_uv, alpha) + Z.index_add(idx_vu, alpha)).reshape(n, n)
|
|
107
|
+
deg = A.abs().sum(axis=1) # (n,)
|
|
108
|
+
diag_idx = np.arange(n) * n + np.arange(n)
|
|
109
|
+
D = Tensor(np.zeros(n * n)).index_add(diag_idx, deg).reshape(n, n)
|
|
110
|
+
return D - A
|
|
111
|
+
|
|
112
|
+
def _harmonic(self, x: Tensor, edges: np.ndarray):
|
|
113
|
+
"""Exact Hodge projection of the graph Laplacian: the PER-COMPONENT MEAN.
|
|
114
|
+
|
|
115
|
+
ker(L) for a graph Laplacian is the space of per-component constants,
|
|
116
|
+
so the frozen state of the diffusion is the component mean. Cost
|
|
117
|
+
O(n + m): union-find for the components, then a scatter-add and a
|
|
118
|
+
gather. This is the branch that lets the block represent a GLOBAL
|
|
119
|
+
aggregate -- the band bank alone only carries the transient.
|
|
120
|
+
"""
|
|
121
|
+
n, d = x.shape[0], x.shape[1]
|
|
122
|
+
labels, ncomp = connected_components(n, edges)
|
|
123
|
+
if ncomp <= 0:
|
|
124
|
+
return Tensor(np.zeros((n, d)))
|
|
125
|
+
sums = Tensor(np.zeros((ncomp, d))).index_add(labels, x)
|
|
126
|
+
ones = Tensor(np.ones(n))
|
|
127
|
+
counts = Tensor(np.zeros(ncomp)).index_add(labels, ones).reshape(ncomp, 1)
|
|
128
|
+
comp_mean = sums / counts
|
|
129
|
+
return comp_mean.gather(labels)
|
|
130
|
+
|
|
131
|
+
def _bands(self, L: Tensor, lam_max: float, X: Tensor, lam_max_t=None):
|
|
132
|
+
"""Chebyshev band filter bank; the basis T_k(Ltilde)X is computed ONCE."""
|
|
133
|
+
n = L.shape[0]
|
|
134
|
+
inv = (2.0 / lam_max) if lam_max_t is None else (2.0 / lam_max_t)
|
|
135
|
+
Y = [X]
|
|
136
|
+
if self.m >= 1:
|
|
137
|
+
Lt = inv * L - Tensor(np.eye(n))
|
|
138
|
+
Y.append(Lt @ X)
|
|
139
|
+
for k in range(2, self.m + 1):
|
|
140
|
+
Y.append(2.0 * (Lt @ Y[k - 1]) - Y[k - 2])
|
|
141
|
+
ts = band_times(lam_max, self.m, self.B, self.alpha_min, self.alpha_max)
|
|
142
|
+
out = []
|
|
143
|
+
for j in range(self.B):
|
|
144
|
+
if lam_max_t is not None:
|
|
145
|
+
a = cheb_coeffs_exp_autodiff(ts[j], lam_max_t, self.m)
|
|
146
|
+
b = cheb_coeffs_exp_autodiff(ts[j + 1], lam_max_t, self.m)
|
|
147
|
+
coef = a - b
|
|
148
|
+
acc = None
|
|
149
|
+
for k in range(self.m + 1):
|
|
150
|
+
if abs(float(coef.data[k])) > 0.0:
|
|
151
|
+
term = Y[k] * coef[k]
|
|
152
|
+
acc = term if acc is None else acc + term
|
|
153
|
+
else:
|
|
154
|
+
a = cheb_coeffs_exp(ts[j], lam_max, self.m)
|
|
155
|
+
b = cheb_coeffs_exp(ts[j + 1], lam_max, self.m)
|
|
156
|
+
coef = a - b
|
|
157
|
+
acc = None
|
|
158
|
+
for k in range(self.m + 1):
|
|
159
|
+
if coef[k] != 0.0:
|
|
160
|
+
term = Y[k] * float(coef[k])
|
|
161
|
+
acc = term if acc is None else acc + term
|
|
162
|
+
out.append(acc)
|
|
163
|
+
self.last_ts = ts
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
# -------------------------------------------------------------- forward
|
|
167
|
+
def __call__(self, x: Tensor):
|
|
168
|
+
n = x.shape[0]
|
|
169
|
+
d = self.d
|
|
170
|
+
xn = self.norm(x)
|
|
171
|
+
|
|
172
|
+
# ---- ROUTER --------------------------------------------------------
|
|
173
|
+
q = xn @ self.Wq
|
|
174
|
+
k = xn @ self.Wk
|
|
175
|
+
scores = (q @ k.T) * (d ** -0.5)
|
|
176
|
+
causal = np.triu(np.ones((n, n), dtype=bool), k=1)
|
|
177
|
+
sc_masked = scores.data - 1e9 * causal
|
|
178
|
+
idx = np.argsort(-sc_masked, axis=1)[:, : self.k] # (n, k) neighbours
|
|
179
|
+
row = np.arange(n)[:, None]
|
|
180
|
+
sel = scores[row, idx] # Tensor (n, k)
|
|
181
|
+
|
|
182
|
+
# ---- TOPOLOGY: semantic edges + temporal chain ----------------------
|
|
183
|
+
sem = edges_from_topk(idx)
|
|
184
|
+
chain = chain_edges(n)
|
|
185
|
+
|
|
186
|
+
# ---- SHEAF: content-adaptive signed restriction maps -----------------
|
|
187
|
+
if sem.shape[0] > 0:
|
|
188
|
+
eu = xn[sem[:, 0]]
|
|
189
|
+
ev = xn[sem[:, 1]]
|
|
190
|
+
feat = concat([eu, ev], axis=1)
|
|
191
|
+
h = (feat @ self.edge1).tanh()
|
|
192
|
+
alpha = (h @ self.edge2).reshape(-1) # (m,) in (-1, 1)
|
|
193
|
+
else:
|
|
194
|
+
alpha = Tensor(np.zeros(0))
|
|
195
|
+
L_sem = self._laplacian(n, sem, alpha)
|
|
196
|
+
|
|
197
|
+
if chain.shape[0] > 0:
|
|
198
|
+
beta = softplus(self.beta_chain) # (1,1) > 0
|
|
199
|
+
a_ch = beta.reshape(1) * Tensor(np.ones(chain.shape[0]))
|
|
200
|
+
L_chain = self._laplacian(n, chain, a_ch)
|
|
201
|
+
else:
|
|
202
|
+
L_chain = Tensor(np.zeros((n, n)))
|
|
203
|
+
L = L_sem + L_chain
|
|
204
|
+
|
|
205
|
+
# Spectral scale. We use a GUARANTEED UPPER BOUND (Gershgorin +
|
|
206
|
+
# smooth max) for the Chebyshev normalisation: an estimate that
|
|
207
|
+
# undershoots lambda_max makes the recurrence explode. The bound is
|
|
208
|
+
# differentiable, so no gradient is lost.
|
|
209
|
+
lam_bound_t = spectral_bound_autodiff(L)
|
|
210
|
+
lam_est = power_iteration_lmax(L.data, iters=40)
|
|
211
|
+
lam_max_t = lam_bound_t
|
|
212
|
+
lam_max = float(lam_max_t.data)
|
|
213
|
+
if lam_max < 1e-8:
|
|
214
|
+
lam_max = 1e-8
|
|
215
|
+
lam_max_t = Tensor(np.array([[lam_max]]))
|
|
216
|
+
|
|
217
|
+
# ---- FREEZE: multi-scale band filter bank --------------------------
|
|
218
|
+
bands = self._bands(L, lam_max, xn, lam_max_t)
|
|
219
|
+
gates = softplus(self.band_g).reshape(self.B)
|
|
220
|
+
z = None
|
|
221
|
+
for j in range(self.B):
|
|
222
|
+
term = bands[j] * gates[j]
|
|
223
|
+
z = term if z is None else z + term
|
|
224
|
+
|
|
225
|
+
# ---- HARMONIC BRANCH: the frozen state (exact Hodge projection) -----
|
|
226
|
+
# For a graph Laplacian, ker(L) = per-component constants, so the
|
|
227
|
+
# Hodge limit of the diffusion is the PER-COMPONENT MEAN. The band
|
|
228
|
+
# bank only carries the transient, so without this branch the block
|
|
229
|
+
# cannot represent a global aggregate at all (measured: sign acc =
|
|
230
|
+
# chance on the global-mean task). Cost: O(n + m) via union-find.
|
|
231
|
+
all_edges = (
|
|
232
|
+
np.concatenate([sem, chain], axis=0)
|
|
233
|
+
if sem.shape[0] or chain.shape[0]
|
|
234
|
+
else np.zeros((0, 2), dtype=np.int64)
|
|
235
|
+
)
|
|
236
|
+
harmonic = self._harmonic(xn, all_edges)
|
|
237
|
+
_, diag_ncomp = connected_components(n, all_edges)
|
|
238
|
+
z = z + self.harm_g * harmonic
|
|
239
|
+
|
|
240
|
+
# ---- READ: mix channels, one nonlinearity, residual -----------------
|
|
241
|
+
z = z @ self.Wmix
|
|
242
|
+
z = z.gelu()
|
|
243
|
+
out = x + z @ self.Wo
|
|
244
|
+
|
|
245
|
+
# ---- sharp retrieval path (winner-take-all over the routed edges) ---
|
|
246
|
+
if self.use_route_head:
|
|
247
|
+
v = xn @ self.Wv
|
|
248
|
+
vg = v.gather_rows(idx) # (n, k, d)
|
|
249
|
+
w = softmax_rows(sel) # (n, k)
|
|
250
|
+
sr = (vg * w[:, :, None]).sum(axis=1) # (n, d)
|
|
251
|
+
out = out + sr @ self.Wr
|
|
252
|
+
|
|
253
|
+
# ---- diagnostics ---------------------------------------------------
|
|
254
|
+
try:
|
|
255
|
+
desc = topo_descriptor(n, sem, np.abs(alpha.data) + 1e-9)
|
|
256
|
+
except Exception:
|
|
257
|
+
desc = np.zeros(9)
|
|
258
|
+
self.diag = {
|
|
259
|
+
"n_edges": int(sem.shape[0]),
|
|
260
|
+
"n_components": int(diag_ncomp),
|
|
261
|
+
"lam_max": float(lam_max),
|
|
262
|
+
"lam_est": float(lam_est),
|
|
263
|
+
"alpha_abs_mean": (
|
|
264
|
+
float(np.abs(alpha.data).mean()) if alpha.data.size else 0.0
|
|
265
|
+
),
|
|
266
|
+
"alpha_neg_frac": (
|
|
267
|
+
float((alpha.data < 0).mean()) if alpha.data.size else 0.0
|
|
268
|
+
),
|
|
269
|
+
"band_energy": np.array(
|
|
270
|
+
[float((band.data**2).sum()) for band in bands]
|
|
271
|
+
),
|
|
272
|
+
"topo": desc,
|
|
273
|
+
"balance_res": float(np.max(np.abs(L.data.sum(axis=1)))),
|
|
274
|
+
}
|
|
275
|
+
return out
|
fluidlayer/bpe.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fluidlayer/bpe.py — a byte-level BPE tokenizer, in pure Python (no downloads).
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS
|
|
5
|
+
---------------
|
|
6
|
+
Every experiment so far ran at the CHARACTER level (vocab 76). That is
|
|
7
|
+
convenient but it is not what a real deployment looks like: real LLM
|
|
8
|
+
tokenizers are subword, with a vocabulary of 32k-256k and a very different
|
|
9
|
+
redundancy structure. The character-level corpus is *maximally* redundant
|
|
10
|
+
(a 12-gram of characters repeats constantly), which flatters a
|
|
11
|
+
content-keyed store. A subword corpus repeats less.
|
|
12
|
+
|
|
13
|
+
The point of this file is therefore not "a tokenizer" -- it is the ability
|
|
14
|
+
to re-run every measurement at a realistic token granularity, on CPU,
|
|
15
|
+
without downloading anything.
|
|
16
|
+
|
|
17
|
+
THE ALGORITHM
|
|
18
|
+
-------------
|
|
19
|
+
1. split the text on a word-ish regex,
|
|
20
|
+
2. count word frequencies,
|
|
21
|
+
3. represent every word as its UTF-8 BYTES (so the tokenizer is lossless
|
|
22
|
+
for any input, including text the training corpus never saw),
|
|
23
|
+
4. repeatedly merge the most frequent adjacent symbol pair,
|
|
24
|
+
5. emit a vocabulary: the 256 byte tokens + every learned merge.
|
|
25
|
+
|
|
26
|
+
`encode` is then exact and reversible (`decode(encode(s)) == s` for any
|
|
27
|
+
string, because the byte fallback is always available).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
from collections import Counter
|
|
36
|
+
|
|
37
|
+
__all__ = ["BPETokenizer", "train_bpe"]
|
|
38
|
+
|
|
39
|
+
_WORD = re.compile(r"\S+|\s+")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BPETokenizer:
|
|
43
|
+
"""A byte-level BPE tokenizer."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, merges: dict, id_bytes: dict):
|
|
46
|
+
# merges: {(a, b): new_id} -- the merge ORDER matters
|
|
47
|
+
# id_bytes: {id: bytes} -- every id, bytes and merged alike
|
|
48
|
+
self.merges = merges
|
|
49
|
+
self.ranks = {p: i for i, p in enumerate(merges.keys())}
|
|
50
|
+
self.id_to_bytes = dict(id_bytes)
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------ size
|
|
53
|
+
@property
|
|
54
|
+
def vocab_size(self) -> int:
|
|
55
|
+
return len(self.id_to_bytes)
|
|
56
|
+
|
|
57
|
+
# --------------------------------------------------------------- encode
|
|
58
|
+
def _word_symbols(self, word: str):
|
|
59
|
+
b = word.encode("utf-8")
|
|
60
|
+
return tuple(b)
|
|
61
|
+
|
|
62
|
+
def _merge_word(self, symbols):
|
|
63
|
+
while len(symbols) > 1:
|
|
64
|
+
best = None
|
|
65
|
+
best_i = -1
|
|
66
|
+
for i in range(len(symbols) - 1):
|
|
67
|
+
r = self.ranks.get((symbols[i], symbols[i + 1]))
|
|
68
|
+
if r is not None and (best is None or r < best):
|
|
69
|
+
best = r
|
|
70
|
+
best_i = i
|
|
71
|
+
if best is None:
|
|
72
|
+
break
|
|
73
|
+
a, b = symbols[best_i], symbols[best_i + 1]
|
|
74
|
+
new = self.merges.get((a, b))
|
|
75
|
+
if new is None:
|
|
76
|
+
break
|
|
77
|
+
symbols = symbols[:best_i] + (new,) + symbols[best_i + 2:]
|
|
78
|
+
return symbols
|
|
79
|
+
|
|
80
|
+
def encode(self, text: str):
|
|
81
|
+
ids = []
|
|
82
|
+
for w in _WORD.findall(text):
|
|
83
|
+
ids.extend(self._merge_word(self._word_symbols(w)))
|
|
84
|
+
return ids
|
|
85
|
+
|
|
86
|
+
def decode(self, ids) -> str:
|
|
87
|
+
out = bytearray()
|
|
88
|
+
for i in ids:
|
|
89
|
+
out += self.id_to_bytes[int(i)]
|
|
90
|
+
return out.decode("utf-8", errors="replace")
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------ io
|
|
93
|
+
def save(self, path: str):
|
|
94
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
95
|
+
json.dump({
|
|
96
|
+
"merges": [[list(k), v] for k, v in self.merges.items()],
|
|
97
|
+
"id_bytes": {str(k): list(v) for k, v in self.id_to_bytes.items()},
|
|
98
|
+
}, f)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def load(path: str) -> BPETokenizer:
|
|
102
|
+
with open(path, encoding="utf-8") as f:
|
|
103
|
+
d = json.load(f)
|
|
104
|
+
merges = {tuple(m[0]): m[1] for m in d["merges"]}
|
|
105
|
+
id_bytes = {int(k): bytes(v) for k, v in d["id_bytes"].items()}
|
|
106
|
+
return BPETokenizer(merges, id_bytes)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def train_bpe(text: str, vocab_size: int = 1024, verbose: bool = True):
|
|
110
|
+
"""Train a byte-level BPE on `text`. Deterministic, no randomness."""
|
|
111
|
+
words = Counter(_WORD.findall(text))
|
|
112
|
+
# each word -> its symbol tuple; count pairs over the whole corpus
|
|
113
|
+
word_syms = {w: tuple(w.encode("utf-8")) for w in words}
|
|
114
|
+
merges: dict = {}
|
|
115
|
+
id_bytes: dict = {i: bytes([i]) for i in range(256)}
|
|
116
|
+
|
|
117
|
+
def recount():
|
|
118
|
+
pairs = Counter()
|
|
119
|
+
for w, s in word_syms.items():
|
|
120
|
+
c = words[w]
|
|
121
|
+
for i in range(len(s) - 1):
|
|
122
|
+
pairs[(s[i], s[i + 1])] += c
|
|
123
|
+
return pairs
|
|
124
|
+
|
|
125
|
+
n_merges = vocab_size - 256
|
|
126
|
+
for step in range(n_merges):
|
|
127
|
+
pairs = recount()
|
|
128
|
+
if not pairs:
|
|
129
|
+
break
|
|
130
|
+
# deterministic tie-break: the highest count, then the lowest ids
|
|
131
|
+
best = max(pairs.items(), key=lambda kv: (kv[1], -kv[0][0], -kv[0][1]))
|
|
132
|
+
pair, cnt = best
|
|
133
|
+
if cnt < 2:
|
|
134
|
+
break
|
|
135
|
+
new_id = 256 + len(merges)
|
|
136
|
+
merges[pair] = new_id
|
|
137
|
+
id_bytes[new_id] = id_bytes[pair[0]] + id_bytes[pair[1]]
|
|
138
|
+
# rewrite every word
|
|
139
|
+
for w in list(word_syms):
|
|
140
|
+
s = word_syms[w]
|
|
141
|
+
out = []
|
|
142
|
+
i = 0
|
|
143
|
+
while i < len(s):
|
|
144
|
+
if i + 1 < len(s) and (s[i], s[i + 1]) == pair:
|
|
145
|
+
out.append(new_id)
|
|
146
|
+
i += 2
|
|
147
|
+
else:
|
|
148
|
+
out.append(s[i])
|
|
149
|
+
i += 1
|
|
150
|
+
word_syms[w] = tuple(out)
|
|
151
|
+
if verbose and step % 100 == 0:
|
|
152
|
+
print(f" merge {step}: {pair} -> {new_id} (count {cnt})")
|
|
153
|
+
return BPETokenizer(merges, id_bytes)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
import argparse
|
|
158
|
+
ap = argparse.ArgumentParser()
|
|
159
|
+
ap.add_argument("--corpus", default="corpus.txt")
|
|
160
|
+
ap.add_argument("--vocab", type=int, default=1024)
|
|
161
|
+
ap.add_argument("--out", default="prior/bpe_1024.json")
|
|
162
|
+
args = ap.parse_args()
|
|
163
|
+
with open(args.corpus, encoding="utf-8", errors="ignore") as f:
|
|
164
|
+
text = f.read()
|
|
165
|
+
tok = train_bpe(text, args.vocab)
|
|
166
|
+
os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)
|
|
167
|
+
tok.save(args.out)
|
|
168
|
+
ids = tok.encode("Alice was beginning to get very tired of sitting by "
|
|
169
|
+
"her sister on the bank, and of having nothing to do.")
|
|
170
|
+
print(f" vocab {tok.vocab_size} tokens {len(ids)} "
|
|
171
|
+
f"-> {ids[:12]} ...")
|
|
172
|
+
print(f" round-trip ok: {tok.decode(ids)[:60]!r}")
|
|
173
|
+
print(f" wrote {args.out}")
|
fluidlayer/data.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fluidlayer.data — tiny corpora + char-level tokenizer.
|
|
3
|
+
|
|
4
|
+
Prefers a real public-domain text (Project Gutenberg). If the sandbox has no
|
|
5
|
+
outbound network access it falls back to a deterministic synthetic corpus so
|
|
6
|
+
that the A/B training run is always reproducible.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
__all__ = ["get_corpus", "CharTokenizer", "batchify", "GUTENBERG_URLS"]
|
|
17
|
+
|
|
18
|
+
GUTENBERG_URLS = [
|
|
19
|
+
"https://www.gutenberg.org/files/11/11-0.txt", # Alice in Wonderland
|
|
20
|
+
"https://www.gutenberg.org/cache/epub/11/pg11.txt",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_corpus(max_chars: int = 120_000, cache: str | None = None) -> str:
|
|
25
|
+
"""Return a text corpus (>= max_chars if possible)."""
|
|
26
|
+
cache = cache or os.path.join(os.path.dirname(__file__), "..", "corpus.txt")
|
|
27
|
+
if os.path.exists(cache):
|
|
28
|
+
with open(cache, encoding="utf-8", errors="ignore") as f:
|
|
29
|
+
t = f.read()
|
|
30
|
+
if len(t) >= 1000:
|
|
31
|
+
return t[:max_chars]
|
|
32
|
+
for url in GUTENBERG_URLS:
|
|
33
|
+
try:
|
|
34
|
+
with urllib.request.urlopen(url, timeout=12) as r:
|
|
35
|
+
raw = r.read().decode("utf-8", errors="ignore")
|
|
36
|
+
start = raw.find("*** START")
|
|
37
|
+
if start >= 0:
|
|
38
|
+
raw = raw[start:]
|
|
39
|
+
end = raw.find("*** END")
|
|
40
|
+
if end > 0:
|
|
41
|
+
raw = raw[:end]
|
|
42
|
+
raw = raw.replace("\r\n", "\n")
|
|
43
|
+
if len(raw) > 2000:
|
|
44
|
+
try:
|
|
45
|
+
os.makedirs(os.path.dirname(os.path.abspath(cache)), exist_ok=True)
|
|
46
|
+
with open(cache, "w", encoding="utf-8") as f:
|
|
47
|
+
f.write(raw)
|
|
48
|
+
except OSError:
|
|
49
|
+
pass
|
|
50
|
+
return raw[:max_chars]
|
|
51
|
+
except Exception:
|
|
52
|
+
continue
|
|
53
|
+
return _synthetic_corpus(max_chars)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _synthetic_corpus(n: int) -> str:
|
|
57
|
+
"""Deterministic fallback: a Zipfian character process with word structure."""
|
|
58
|
+
rng = np.random.default_rng(1234)
|
|
59
|
+
words = ["the", "cat", "sat", "on", "a", "mat", "and", "then", "said", "little",
|
|
60
|
+
"queen", "of", "hearts", "alice", "rabbit", "hole", "tea", "party",
|
|
61
|
+
"night", "tree", "house", "river", "cloud", "stone", "light", "voice"]
|
|
62
|
+
p = np.arange(1, len(words) + 1) ** -1.1
|
|
63
|
+
p /= p.sum()
|
|
64
|
+
out = []
|
|
65
|
+
while sum(len(w) + 1 for w in out) < n:
|
|
66
|
+
k = rng.choice(len(words), size=rng.integers(4, 14), p=p)
|
|
67
|
+
out.extend(words[i] for i in k)
|
|
68
|
+
s = " ".join(out)
|
|
69
|
+
return s[:n]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CharTokenizer:
|
|
73
|
+
def __init__(self, text: str):
|
|
74
|
+
self.chars = sorted(set(text))
|
|
75
|
+
self.stoi = {c: i for i, c in enumerate(self.chars)}
|
|
76
|
+
self.itos = self.chars
|
|
77
|
+
|
|
78
|
+
def encode(self, text: str) -> np.ndarray:
|
|
79
|
+
return np.array([self.stoi[c] for c in text], dtype=np.int64)
|
|
80
|
+
|
|
81
|
+
def decode(self, ids) -> str:
|
|
82
|
+
return "".join(self.itos[int(i)] for i in ids)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def vocab(self) -> int:
|
|
86
|
+
return len(self.chars)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def batchify(tokens: np.ndarray, batch_size: int) -> np.ndarray:
|
|
90
|
+
"""(N,) -> (batch_size, N//batch_size) contiguous split."""
|
|
91
|
+
nb = len(tokens) // batch_size
|
|
92
|
+
return tokens[: nb * batch_size].reshape(batch_size, nb)
|