ZeroPoint / models /zeropoint.py
Holmes2011's picture
ZeroPoint v0 research prototype: new from-scratch NumPy framework, memory-slot architecture, audited Transformer comparison
5c6c847 verified
Raw History Blame Contribute Delete
11 kB
"""ZeroPoint:分层记忆槽网络。
设计文档见 docs/ZeroPoint.md。核心是 MemoryMixer(代替 Transformer 里的自注意力):
每层有 H 个头,每个头有 M 个记忆槽 S[m](D 维向量),每个槽有自己的保留率 λ[m]。
读到第 t 个字时:
w_t = softmax(x_t Ww) * sigmoid(x_t Wg) 写地址(槽之间竞争)× 写强度
S[m] ← λ[m] · S[m] + w_t[m] · v_t 所有槽先按自己的速度遗忘,再写入
a_t = softmax(x_t Wr) 读地址
r_t = Σ_m a_t[m] · S[m] 从槽里读出
两种等价算法:
forward() 分块并行,训练用。序列切成每块 CHUNK 个字:
块内 r = (A ⊙ 因果掩码) @ v,A[i,j] = Σ_m a_i[m] w_j[m] λ[m]^(i-j)
利用 λ^(i-j) = λ^i · λ^(-j),一次矩阵乘
跨块 r += (a ⊙ λ^(i+1)) @ S_in S_in = 进入这一块时的槽状态
块尾 S_out = λ^c ⊙ S_in + (w ⊙ λ^(c-1-j))ᵀ @ v
λ^(-j) 最大 2^(CHUNK-1),不会溢出,所以序列多长都行
step() 递推形式,推理用:每个字 O(H·M·D),状态大小固定,没有 KV 缓存
槽的初始状态 S0 是可学习参数(不是全 0),这样序列开头读出来的向量不会接近 0、被 LayerNorm 放大噪声。
ema=True(指数滑动平均写入):S[m] ← λ[m]·S[m] + (1-λ[m])·w_t[m]·v_t
槽的值永远落在"写进去过的值"的范围里,不会随序列变长而越积越大。
系统直觉模型要连续跑几天,用这个;实现上只是把写地址 w 乘上 (1-λ),并行和递推两种形式都不用改。
"""
import numpy as np
from zerocg import Tensor, concat
from zerocg import functional as F
from zerocg import nn
from .transformer import sinusoidal_encoding
LAMBDA_MIN = 0.5 # λ = 0.5 + 0.5·sigmoid(θ) ∈ (0.5, 1)
CHUNK = 32 # 分块并行的块长:块内 λ^(-j) 最大 2^31,float32 绰绰有余
def _layernorm_np(x, ln):
mu = x.mean(-1, keepdims=True)
var = ((x - mu) ** 2).mean(-1, keepdims=True)
return (x - mu) / np.sqrt(var + ln.eps) * ln.weight.data + ln.bias.data
def _softmax_np(x):
e = np.exp(x - x.max(-1, keepdims=True))
return e / e.sum(-1, keepdims=True)
def _gelu_np(a):
return 0.5 * a * (1 + np.tanh(np.sqrt(2 / np.pi) * (a + 0.044715 * a ** 3)))
class MemoryMixer(nn.Module):
"""参数里的开关只给消融实验用(experiments/ablation.py),默认值就是 ZeroPoint 本身:
gate 写强度门
write "softmax":写入时槽之间竞争;"sigmoid":每个槽独立决定写不写
init "hier":半衰期按存储层级铺开;"uniform":所有槽一样
learn_lambda 保留率 λ 是否可学
ema 写入按 (1-λ) 缩放(指数滑动平均),长时间运行不漂移
"""
def __init__(self, n_embd, n_head, n_slot, max_half_life=64.0,
gate=True, write="softmax", init="hier", learn_lambda=True, ema=False):
assert n_embd % n_head == 0 and write in ("softmax", "sigmoid") and init in ("hier", "uniform")
self.H, self.M, self.D = n_head, n_slot, n_embd // n_head
self.write_mode = write
self.ema = ema
self.read = nn.Linear(n_embd, n_head * n_slot)
self.write = nn.Linear(n_embd, n_head * n_slot)
self.gate = nn.Linear(n_embd, n_head) if gate else None
self.value = nn.Linear(n_embd, n_embd)
self.out = nn.Linear(n_embd, n_embd)
self.norm = nn.LayerNorm(self.D) # 每个头读出来的向量各自归一化(不同槽的量级差别很大)
# 初始化成"存储层级":槽 0 的半衰期约 1.5 个字(像寄存器),最后一个约 max_half_life 个字(像内存)
if init == "hier":
half_life = np.geomspace(1.5, max_half_life, n_slot)
else: # 对照:所有槽都取层级的几何中点
half_life = np.full(n_slot, np.sqrt(1.5 * max_half_life))
lam = 0.5 ** (1 / half_life)
theta = np.tile(np.log((2 * lam - 1) / (2 - 2 * lam)), (n_head, 1)) # sigmoid 的反函数,(H, M)
self.theta = nn.Parameter(theta) if learn_lambda else Tensor(theta)
self.s0 = nn.Parameter(np.random.randn(n_head, n_slot, self.D) * 0.1) # 可学习的初始状态
self.usage = None # 最近一次 forward 的 (读地址, 写地址) 平均值,各 (H, M),用来查槽有没有塌缩
def retention(self):
"""每个槽的保留率 λ,形状 (H, M)。"""
return F.sigmoid(self.theta) * (1 - LAMBDA_MIN) + LAMBDA_MIN
def forward(self, x):
B, T, C = x.shape
H, M, D = self.H, self.M, self.D
a = F.softmax(self.read(x).reshape(B, T, H, M), axis=-1).transpose(0, 2, 1, 3) # (B,H,T,M)
w = self.write(x).reshape(B, T, H, M)
w = F.softmax(w, axis=-1) if self.write_mode == "softmax" else F.sigmoid(w)
if self.gate is not None:
w = w * F.sigmoid(self.gate(x)).reshape(B, T, H, 1)
w = w.transpose(0, 2, 1, 3) # (B,H,T,M)
v = self.value(x).reshape(B, T, H, D).transpose(0, 2, 1, 3) # (B,H,T,D)
self.usage = (a.data.mean(axis=(0, 2)), w.data.mean(axis=(0, 2)))
lam = self.retention() # (H, M)
if self.ema:
w = w * (1 - lam).reshape(1, H, 1, M)
log_lam = lam.log().reshape(H, 1, M)
S = self.s0.reshape(1, H, M, D) # 进入第一块时的状态(对 batch 广播)
outs = []
for s in range(0, T, CHUNK):
e = min(s + CHUNK, T)
c = e - s
ac, wc, vc = a[:, :, s:e], w[:, :, s:e], v[:, :, s:e]
i = Tensor(np.arange(c, dtype=np.float64).reshape(1, c, 1))
decay = (log_lam * i).exp() # λ^i, (H,c,M)
A = (ac * decay) @ (wc * (log_lam * -i).exp()).transpose(0, 1, 3, 2) # (B,H,c,c)
A = A.masked_fill(np.triu(np.ones((c, c), dtype=bool), 1), 0.0)
outs.append(A @ vc + (ac * decay * lam.reshape(H, 1, M)) @ S) # 块内 + 跨块
if e < T:
tail = (log_lam * Tensor(np.arange(c - 1, -1, -1, dtype=np.float64).reshape(1, c, 1))).exp()
S = (lam ** c).reshape(1, H, M, 1) * S + (wc * tail).transpose(0, 1, 3, 2) @ vc
r = outs[0] if len(outs) == 1 else concat(outs, axis=2)
return self.out(self.norm(r).transpose(0, 2, 1, 3).reshape(B, T, C))
# ---------- 递推形式(numpy,无梯度)----------
def init_state(self, B):
return np.broadcast_to(self.s0.data, (B, self.H, self.M, self.D)).copy()
def step(self, x, S):
"""x: (B, C) 一个字的输入;S: (B, H, M, D) 记忆槽。返回 (输出, 新状态)。"""
B = x.shape[0]
H, M, D = self.H, self.M, self.D
lin = lambda layer, z: z @ layer.weight.data + layer.bias.data # noqa: E731
a = _softmax_np(lin(self.read, x).reshape(B, H, M))
w = lin(self.write, x).reshape(B, H, M)
w = _softmax_np(w) if self.write_mode == "softmax" else F.sigmoid_np(w)
if self.gate is not None:
w = w * F.sigmoid_np(lin(self.gate, x))[:, :, None]
v = lin(self.value, x).reshape(B, H, D)
lam = self.retention().data
if self.ema:
w = w * (1 - lam)[None]
self.last_addr = (a, w) # 诊断用:这一步的读地址和写地址,(B, H, M)
S = lam[None, :, :, None] * S + w[..., None] * v[:, :, None, :]
r = np.einsum("bhm,bhmd->bhd", a, S)
r = _layernorm_np(r, self.norm).reshape(B, H * D)
return lin(self.out, r), S
class Block(nn.Module):
def __init__(self, n_embd, n_head, n_slot, dropout=0.0, mlp_hidden=None, **mixer_opts):
hidden = mlp_hidden or 4 * n_embd
self.ln1 = nn.LayerNorm(n_embd)
self.mix = MemoryMixer(n_embd, n_head, n_slot, **mixer_opts)
self.ln2 = nn.LayerNorm(n_embd)
self.fc1 = nn.Linear(n_embd, hidden)
self.fc2 = nn.Linear(hidden, n_embd)
self.drop = nn.Dropout(dropout)
def forward(self, x):
x = x + self.drop(self.mix(self.ln1(x)))
return x + self.drop(self.fc2(F.gelu(self.fc1(self.ln2(x)))))
def step(self, x, S):
h, S = self.mix.step(_layernorm_np(x, self.ln1), S)
x = x + h
h = _gelu_np(_layernorm_np(x, self.ln2) @ self.fc1.weight.data + self.fc1.bias.data)
return x + h @ self.fc2.weight.data + self.fc2.bias.data, S
class ZeroX(nn.Module):
"""字符级语言模型。默认没有位置编码:位置信息来自各个槽不同的遗忘速度。
pos="sin" 只给消融实验用:额外加上论文的正弦位置编码,看看还有没有用。
mlp_hidden 默认 4 × n_embd;消融实验用它把各变体的参数量对齐。"""
def __init__(self, vocab_size, n_embd=64, n_head=4, n_slot=16, n_layer=2, dropout=0.0,
pos="none", mlp_hidden=None, **mixer_opts):
assert pos in ("none", "sin")
self.pos_kind = pos
self.tok = nn.Embedding(vocab_size, n_embd)
self.blocks = [Block(n_embd, n_head, n_slot, dropout, mlp_hidden, **mixer_opts) for _ in range(n_layer)]
self.ln_f = nn.LayerNorm(n_embd)
self.head = nn.Linear(n_embd, vocab_size)
def forward(self, idx):
x = self.tok(idx)
if self.pos_kind == "sin":
x = x + Tensor(sinusoidal_encoding(idx.shape[1], x.shape[-1]))
for b in self.blocks:
x = b(x)
return self.head(self.ln_f(x))
def init_state(self, B):
return [b.mix.init_state(B) for b in self.blocks]
def step(self, idx_t, states, t=0):
"""idx_t: (B,) 第 t 个字。返回 (logits (B, V), 新状态)。"""
x = self.tok.weight.data[idx_t]
if self.pos_kind == "sin":
x = x + sinusoidal_encoding(t + 1, x.shape[-1])[t]
new = []
for b, S in zip(self.blocks, states):
x, S = b.step(x, S)
new.append(S)
return _layernorm_np(x, self.ln_f) @ self.head.weight.data + self.head.bias.data, new
def forward_recurrent(self, idx):
"""逐字递推跑完整个序列,返回 (B, T, V) 的 logits。"""
idx = np.asarray(idx)
states = self.init_state(idx.shape[0])
out = []
for t in range(idx.shape[1]):
logits, states = self.step(idx[:, t], states, t)
out.append(logits)
return np.stack(out, axis=1)
def state_floats(self):
"""推理时需要保存的状态大小(浮点数个数),和序列长度无关。"""
return sum(b.mix.H * b.mix.M * b.mix.D for b in self.blocks)