SONUB
ZH English 한국어 日本語 中文 हिन्दी বাংলা اردو Español Português Français Deutsch Русский العربية Bahasa Indonesia Tiếng Việt ไทย Türkçe

如何开启通知

此浏览器或网址无法使用通知。请使用支持通知的浏览器,通过安全连接访问SONUB。

如果通知被屏蔽,请在浏览器设置中允许通知。

  1. 打开地址栏旁的站点设置或浏览器的站点设置,将此网站的通知设为允许。
  2. 在设备设置中允许浏览器发送通知,并检查勿扰模式或专注模式。
  3. 返回SONUB并刷新页面。通知开启后,铃铛将不再显示。

在iPhone或iPad上,将SONUB添加到主屏幕,从主屏幕打开后允许通知。

登录 退出登录 显示主题 默认主题 深色主题 粉色主题 聊天 1:1 聊天 群聊 论坛 公告 新闻区 建议区 自我介绍 主菜单

现在,让我们打造属于你自己的人工智能吧。

亲手编写一个完整的微型 及其。用 engine.py 和 model.py 搭出、因果和 ,再检查输出形状。

一排词语积木的最后是一个空格。空格上方有三块候选积木,各带一根高低不同的柱子;其中一块被选中,接到这一排的末尾,一个箭头又绕回去,再选下一个。
5

GPT架构与代码

加;与Key计算关联,按混合Value。

只有一头一块,keys只含当前及过去位置,不能偷看未来。、、两层和输出头共同工作。为便于阅读,使用、无参数归一化,不使用。

整体结构——从词元编号到下一个词元的概率

的 对每个输入词元做一次下面的计算。结构和 一样是仅 ,只是 1 个块、1 个头。

  • 用从嵌入表取出(8 格),用位置编号取出位置向量,两者相加。
  • 归一化后用因果混合前面各位置的信息,再加回原向量(残差)。
  • 再次归一化后用 MLP 加工,又加回原向量(残差)。
  • 最后归一化,用(head)给 40 个词元各出一个分数()。
  • 用 softmax 把分数变成概率,就是下一个词元的概率。

真正的 GPT 把中间的块(注意力 + MLP)叠很多次。GPT-2 Small 叠 12 次,GPT-3 叠 96 次。每个块接收前一块生成的向量,再细化一些。块的形状相同,所以想叠几次都可以。

GPT 的一次计算——从词元编号到下一个词元的概率
  1. 词元编号
  2. 嵌入 + 位置
  3. 因果注意力
  4. MLP
  5. 输出层分数
  6. 下一个词元的概率

嵌入——用编号取出表中的一行

嵌入(embedding)是词表数 × 宽度大小的表。XSLM 是 40 行 × 8 格。词元编号是 5,就取出第五行(从 0 数)的 8 个数字。既没有乘法也没有检索,只是查表(lookup)。

为什么不直接用编号而要换成向量?编号只有大小和顺序,装不下"9 号和 23 号相似"这样的关系。8 个数字组成的向量可以在多个方向上相互靠近或远离,能把用法相似的词元的向量挪得更近。

嵌入表也是模型的。一开始是随机数字,训练朝减少的方向修改它。不引入另外做好的。用下面的实验亲眼看看随机开始的 5 号词元向量和位置 0 的向量,并数一数 9 个的大小。

动手验证 lab/embedding.py
from model import GPT

m = GPT(40, 8, 12)
print('token 5   :', [round(v.data, 3) for v in m.weights['token'][5]])
print('position 0:', [round(v.data, 3) for v in m.weights['position'][0]])
for name, matrix in m.weights.items():
    rows, cols = len(matrix), len(matrix[0])
    print(name, rows, 'x', cols, '=', rows * cols)
print('total', len(m.parameters))
cd ~/xslm
mkdir -p lab
micro lab/embedding.py
python3 -m lab.embedding

运行结果

token 5   : [-0.081, 0.024, 0.043, -0.149, 0.005, 0.131, -0.201, -0.032]
position 0: [-0.102, -0.036, 0.089, 0.01, -0.085, -0.084, 0.267, 0.114]
token 40 x 8 = 320
position 12 x 8 = 96
query 8 x 8 = 64
key 8 x 8 = 64
value 8 x 8 = 64
project 8 x 8 = 64
expand 16 x 8 = 128
contract 8 x 16 = 128
head 40 x 8 = 320
total 1248

嵌入空间——越近越相似

嵌入就是用几个数字来写下一个东西或一个词。一个数字就是一条轴。下面的地图用 2 个数字写下东西:速度(横轴)和大小(纵轴)。0 是最慢、最小,1 是最快、最大。牛是 [0.1, 0.8],所以又慢又大。老鹰是 [0.9, 0.4],所以又快又小。

用数字写下来以后,就能用距离量出“有多像”。两点 [x₁, y₁] 和 [x₂, y₂] 之间的是 √((x₁ − x₂)² + (y₁ − y₂)²)。距离越短,就越像。从闹钟 [0, 0] 量起,结果是这样:

  • 自行车 [0.3, 0.3]: √(0.09 + 0.09) = 0.424
  • 牛 [0.1, 0.8]: √(0.01 + 0.64) = 0.806
  • 老鹰 [0.9, 0.4]: √(0.81 + 0.16) = 0.985

所以和闹钟最像的是自行车。在地图上点一个点,那个点就成为起点;点一块空白处,那个位置就成为起点。远近顺序会马上重新排好。

的嵌入也是同样的想法。不同之处有两个:轴不是 2 条,而是几百到几千条(XSLM 是 8 条);轴的意思不是人定的,而是由训练决定的。所以每条轴都没有“速度”这样的名字,但用法相似的词元,它们的向量会聚在一起。

量距离在人工智能服务里到处都在用。会找出和问题的嵌入最近的文档, 再把找到的文档和问题一起交给。像 MariaDB 这样的,也用 VECTOR 列和 VEC_DISTANCE_EUCLIDEAN 函数做同样的计算。下面的实验用 计算同一张地图。

用速度和大小 2 个数字写成的嵌入地图。点一个点或一块空白处,就从那里重新量出远近顺序。

大小 (Size)

1 大0.50 小

0 慢0.51 快

速度 (Speed)

由近到远

起点 闹钟 [0, 0]

  1. 1 闹钟 0.000
  2. 2 自行车 0.424
  3. 3 我 0.680
  4. 4 牛 0.806
  5. 5 大象 0.906
  6. 6 船 0.949
  7. 7 老鹰 0.985
  8. 8 马 0.990
  9. 9 汽车 1.238
  10. 10 火箭 1.372

距离 = √((x₁ − x₂)² + (y₁ − y₂)²)

动手验证 lab/distance.py
import math

# A 2-number embedding: [speed, size]. 0 is the slowest and smallest, 1 the fastest and biggest.
things = {
    'alarm clock': [0.0, 0.0],
    'cow': [0.1, 0.8],
    'bicycle': [0.3, 0.3],
    'eagle': [0.9, 0.4],
    'elephant': [0.1, 0.9],
    'me': [0.2, 0.65],
    'horse': [0.7, 0.7],
    'car': [0.9, 0.85],
    'ship': [0.3, 0.9],
    'rocket': [0.99, 0.95],
}


def distance(a, b):
    return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)


for query in [[0.0, 0.0], [0.1, 0.8], [0.5, 0.5]]:
    print('from', query)
    nearest = sorted(things, key=lambda name: distance(things[name], query))
    for name in nearest[:4]:
        print(f'  {name:12} {distance(things[name], query):.3f}')
cd ~/xslm
mkdir -p lab
micro lab/distance.py
python3 -m lab.distance

运行结果

from [0.0, 0.0]
  alarm clock  0.000
  bicycle      0.424
  me           0.680
  cow          0.806
from [0.1, 0.8]
  cow          0.000
  elephant     0.100
  me           0.180
  ship         0.224
from [0.5, 0.5]
  horse        0.283
  bicycle      0.283
  me           0.335
  eagle        0.412

位置嵌入——告诉模型是第几个位置

注意力混合前面各位置时不考虑顺序。没有位置信息,"dog can run" 和 "run can dog" 的词元向量就是同一组。所以每个位置各有一个要学习的位置向量,加到词元向量上。

XSLM 的位置表是 (12)行 × 8 格。只有 12 行,所以一次最多放入 12 个词元。forward 第一行的 检查这一点,所以 generate 像 ids[-12:] 那样只放入最后 12 个词元。这个限制叫上下文长度()。GPT-2 是 1,024 个词元,最近的模型有几十万个词元。

GPT-2 和 XSLM 为每个位置学习向量。最初的 Transformer 论文用 sin、cos 计算位置向量,最近的模型多用把 Query、Key 按位置旋转的 。方法不同,目的都是让模型知道顺序。

线性层、归一化、ReLU——构成模型的三种计算

engine.py 的三个占了模型计算的大部分。

  • linear(x, W):对矩阵 W 的每一行和 x 做(同一格相乘再全部相加)。W 有 3 行,结果也有 3 格。实验中 x = [1, 2] 和行 [0.5, 0.5] 的内积是 0.5 + 1.0 = 1.5。改变行数就能增加或减少向量的格数。
  • norm(x):减去各格的平均值再除以标准差,调成平均 0、方差 1。[2, 4, 6] 变成 [-1.225, 0, 1.225]。让数字在反复计算中不会变得太大或太小。为了不除以 0,在方差上加 1e-5。
  • relu(v):负数变 0,正数保持不变。

ReLU 这样的非线性函数必不可少。两个线性层连在一起,结果还是等于一个线性层,叠多少层也只能表达类似直线的关系。中间加入会弯折的函数,才能学会复杂的关系。

XSLM 的 linear 没有相加的(bias),norm 没有可学习的缩放和平移值(γ、β)。 的 nn.Linear、nn.LayerNorm 两者都有。

动手验证 lab/linear.py
from engine import linear, norm

x = [1.0, 2.0]
W = [[1.0, 0.0], [0.5, 0.5], [-1.0, 1.0]]
print('linear:', linear(x, W))
print('norm  :', [round(v, 3) for v in norm([2.0, 4.0, 6.0])])
print('relu  :', [max(0.0, v) for v in [-2.0, 0.5, 3.0]])
cd ~/xslm
mkdir -p lab
micro lab/linear.py
python3 -m lab.linear

运行结果

linear: [1.0, 1.5, 1.0]
norm  : [-1.225, 0.0, 1.225]
relu  : [0.0, 0.5, 3.0]

注意力——用 Query、Key、Value 混合前面的位置

注意力决定在前面各位置中参考什么、参考多少,并混合那些信息。每个位置生成三个向量。

  • Query(q):当前位置要找的东西。
  • Key(k):各位置展示的"我这里有这种信息"的特征。
  • Value(v):各位置真正要传递的内容。

计算分四步。

  • 分数:当前位置的 q 和各位置 k 的内积,除以 √宽度(XSLM 是 √8 ≈ 2.83)。
  • 权重:用 softmax 把分数变成总和为 1 的权重。
  • 混合:各位置的 v 乘以权重后相加。
  • 整理:用 project 矩阵把混合结果再变换一次,加回原向量。

写成式子是 softmax(q·kᵀ / √d)·v。下面的实验用手工做的 3 个 key 和一个 query 做这个计算(宽度为 2,所以除以 √2)。query [2, 0] 在找第一个特征,所以有这个特征的第 1、3 个位置各得 0.446,没有的第 2 个位置得 0.108。结果是偏向第 1、3 个 value 的 [6.687, 3.313]。

例如在 "the cat sat because it was tired" 中,it 位置的注意力可以学会给 cat 位置很大的权重。真实模型有多个头(GPT-2 Small 是 12 个),每个头看不同的关系(指代词、紧邻的前一个词、格式标记等)。

动手验证 lab/attention.py
from engine import Value, softmax

keys = [[1, 0], [0, 1], [1, 1]]
values = [[10, 0], [0, 10], [5, 5]]
query = [2, 0]
scores = [sum(q * k for q, k in zip(query, key)) / 2 ** 0.5 for key in keys]
weights = softmax([Value(s) for s in scores])
mixed = [sum(w.data * v[i] for w, v in zip(weights, values)) for i in range(2)]
print('scores ', [round(s, 3) for s in scores])
print('weights', [round(w.data, 3) for w in weights])
print('mixed  ', [round(m, 3) for m in mixed])
cd ~/xslm
mkdir -p lab
micro lab/attention.py
python3 -m lab.attention

运行结果

scores  [1.414, 0.0, 1.414]
weights [0.446, 0.108, 0.446]
mixed   [6.687, 3.313]

因果掩码——不让模型看到未来的词元

训练时一次放入整个片段,让每个位置。如果某个位置能看到自己后面的词元,就等于偷看答案,什么都学不到。所以每个位置只能看自己和前面的位置。这叫因果(causal)注意力。

PyTorch 的实现把分数表对角线以上(未来)填成 −∞,让 softmax 后变成 0(下三角掩码)。XSLM 更简单。从前往后逐个处理词元,keys、values 只积累到目前为止的内容,所以未来的词元还不在列表里。

因此在后面加词元,前面位置的结果不应改变。下面的实验放入 [1, 2] 和 [1, 2, 3],确认前两个位置的分数完全相同。输出 True 就证明没有看未来。

这个 keys、values 列表和真实模型的 是同一个思路。生成时保存前面词元的 key、value,只需计算新词元一个即可。XSLM 的 generate 为了简单,每个词元都重新计算整个上下文。

动手验证 lab/causal.py
from model import GPT

m = GPT(10, 8, 12)
short = m.forward([1, 2])
long = m.forward([1, 2, 3])
same = [[v.data for v in r] for r in short] == [[v.data for v in r] for r in long[:2]]
print('first two positions unchanged:', same)
print('scores at position 3:', [round(v.data, 3) for v in long[2][:4]], '...')
cd ~/xslm
mkdir -p lab
micro lab/causal.py
python3 -m lab.causal

运行结果

first two positions unchanged: True
scores at position 3: [0.159, 0.011, -0.239, 0.428] ...

softmax——把分数变成概率,以及数值稳定

softmax 把分数列表变成 0~1 之间、总和为 1 的概率。对每个分数取 exp,再除以总和。exp 永远是正数,并且把大分数放得更大,保持顺序的同时拉开差距。实验中分数 [2, 1, 0.1] 变成概率 [0.659, 0.242, 0.099]。

问题是 exp 增长得非常快。exp(1000) 超出计算机能表示的范围,会出现 OverflowError(实验中的 math range error)。所以 engine.py 的 softmax 先从所有分数中减去最大的分数(peak)。所有分数减去同一个数,概率不变,而最大值变成 exp(0) = 1,不会溢出。[1000, 999] 也能正确得到 [0.731, 0.269]。

softmax 用在两处:求注意力权重时,以及把输出层分数变成下一个词元概率时。

动手验证 lab/softmax.py
import math
from engine import Value, softmax

probs = softmax([Value(s) for s in [2.0, 1.0, 0.1]])
print([round(p.data, 3) for p in probs], round(sum(p.data for p in probs), 3))
try:
    print([math.exp(s) for s in [1000.0, 999.0]])
except OverflowError as error:
    print('without max:', error)
print('with max:', [round(p.data, 3) for p in softmax([Value(1000.0), Value(999.0)])])
cd ~/xslm
mkdir -p lab
micro lab/softmax.py
python3 -m lab.softmax

运行结果

[0.659, 0.242, 0.099] 1.0
without max: math range error
with max: [0.731, 0.269]

MLP——在每个位置加工信息

如果说注意力决定"看哪里"并收集信息,MLP(多层感知机,也叫 )就负责"怎样改变收集到的东西"。每个位置单独计算,用相同的权重。

  • expand:把 8 格扩到 16 格。
  • ReLU:把负数折成 0。
  • contract:把 16 格缩回 8 格,加回原向量。

先扩再缩,是为了在更宽的空间里有余地单独开关多种特征的组合。大型 GPT 的大部分在这个 MLP 里,一般认为学到的事实等知识主要积累在这里。

XSLM 扩大 2 倍并用 ReLU。GPT-2 扩大 4 倍并用 。GELU 是在 0 附近平滑弯折的函数,在大模型中训练效果稍好一些。XSLM 选了容易手读的 ReLU。

残差连接与前置归一化——叠得再深也能训练

model.py 中 x = [a + b for a, b in zip(x, …)] 这一行就是残差连接(residual connection)。不是用层的结果替换 x,而是加到原来的 x 上。

  • 原信息原样保留,层只需学习要修改的差值。
  • backward 时加法把原样传递,梯度经由捷径一直流到前面的层。叠几十个块,梯度也不会消失。

归一化的位置也很重要。XSLM 像 GPT-2 一样在注意力前、MLP 前、输出层前做 norm(Pre-LN)。只归一化进入层的值,残差通路保持不变,所以深的模型也能稳定训练。最初的 Transformer 是相加之后再归一化(Post-LN)。

一个块写成式子是这样。

  • x = x + Attention(norm(x))
  • x = x + MLP(norm(x))

输出层——为词表中每个词元给出分数(logit)

把经过块的向量最后归一化,再和 head 矩阵(40 行 × 8 格)相乘,词表 40 个词元各得一个分数。这种原始分数叫 logit。logit 可以是负数,总和也不是 1,要经过 softmax 才成为概率。

forward 为每个输入位置返回一个分数列表。放入 2 个词元,就得到 2 × 40 个分数。训练使用所有位置的分数(每个位置都预测下一个词元),生成只用最后一个位置的分数 [-1]。

GPT-2 不单独设输出层矩阵,而是共用词元嵌入表(weight tying)。参数减少,读入词元和输出词元时使用同一个向量。XSLM 为了展示两者作用的区别,分开设置。

自动微分——沿计算图倒着走

要训练,就得知道每个权重稍微改变时损失变化多少(梯度,gradient)。把 1,248 个逐个晃一晃太慢,所以用微分的一次求出。这叫(backpropagation),自动完成这件事的装置叫(autograd)。engine.py 的 Value 正是这个装置。

  • Value 每次计算时,除了结果,还在 parents 里记下(输入, 对该输入的局部导数)。加法是 1,乘法是另一方的值,exp 是结果本身,log 是 1/x,ReLU 正数为 1、否则为 0。
  • backward 从损失出发,设梯度为 1.0,倒着走,把"我的梯度 × 局部导数"加到输入的梯度上(链式法则)。
  • 一个值在多处使用时,把各条的梯度全部相加。所以 grad 不用 =,而用 += 累加。

下面实验中的 y = x² + 2x,手算导数是 2x + 2,在 x = 3 处是 8。autograd 给出的 8.0 和把 x 移动一点点测得的数值导数 8.0 相同。像 c = a·b + a 这样 a 用了两次时,dc/da = b + 1 = 6。两条路径的梯度相加了。

PyTorch 的 loss.backward() 也做同样的事。只是不以单个数字,而以张量为单位记录和计算。

动手验证 lab/grad.py
from engine import Value

x = Value(3)
y = x * x + 2 * x
y.backward()
f = lambda v: v * v + 2 * v
h = 1e-6
print('autograd:', x.grad)
print('numeric :', round((f(3 + h) - f(3 - h)) / (2 * h), 6))
a, b = Value(2), Value(5)
c = a * b + a
c.backward()
print('dc/da =', a.grad, ' dc/db =', b.grad)
cd ~/xslm
mkdir -p lab
micro lab/grad.py
python3 -m lab.grad

运行结果

autograd: 8.0
numeric : 8.0
dc/da = 6.0  dc/db = 2.0

数参数——1,248 个在哪里

参数是训练要修改的每一个数字,也叫权重(weight)。XSLM 把所有参数放在 9 个矩阵里,所有矩阵的格数加起来是 1,248 个。比较下表和前面嵌入实验的输出是否一致。

从表中可以读出几点。

  • 绑定在词表上的 token、head 两张表有 640 个,超过一半。词表变大,这两张表就变大。
  • 注意力 4 个(query、key、value、project)有 256 个,MLP 2 个有 256 个。宽度(width)变 2 倍,这部分变 4 倍。
  • 真实模型中块(注意力 + MLP)叠几十次,占了参数的大部分。

" 模型"的 B 是 billion,即参数有 80 亿个。存储大小是参数数 × 一个数字的大小。32 位浮点数(4 )约 32GB,16 位约 16GB,压到 8 位()约 8GB,4 位约 4GB。还要加上计算中的值和 KV 缓存的。XSLM 的 model.json 是把数字写成文本的 ,所以虽然只有 1,248 个,也有约 35KB。

XSLM 的 9 种权重——以默认资料(词表 40 个)、width 8、context 12 为准
权重名称 形状(行 × 列) 数字个数 作用
token 40 × 8 320 每个词元编号一行向量(词元嵌入)
position 12 × 8 96 每个位置一行向量(位置嵌入)
query 8 × 8 64 当前位置要找的东西(Query)
key 8 × 8 64 各位置展示出的特征(Key)
value 8 × 8 64 各位置要传递的内容(Value)
project 8 × 8 64 再次整理注意力结果
expand 16 × 8 128 MLP 前层——把 8 格扩到 16 格
contract 8 × 16 128 MLP 后层——把 16 格缩回 8 格
head 40 × 8 320 为词表中每个词元给出下一个词元的分数(输出层)

XSLM 省略的部分——与真实 GPT 的差异

XSLM 为了易读,去掉或缩减了几样东西。和 GPT-2 Small 比较如下。

  • 块数:XSLM 1 个,GPT-2 Small 12 个。块叠得越多,越能学会复杂关系。
  • :XSLM 1 个,GPT-2 Small 12 个(每个头宽 768 ÷ 12 = 64 格)。多个头同时看不同的关系。
  • MLP:XSLM 是 2 倍、ReLU,GPT-2 是 4 倍、GELU。
  • bias 和归一化的 γ、β:XSLM 没有,GPT-2 有。
  • dropout:XSLM 没有。训练中随机把一部分值(通常 10%)变成 0,不让模型依赖特定路径,是防止的装置。生成时关闭。
  • 初始值:XSLM 用标准差 0.1,GPT-2 用 0.02 的正态分布。
  • 计算方式:XSLM 逐个数字计算,PyTorch 把(批 B, 长度 T, 宽度 C)形状的张量一次放到 上计算。

省略的都是为了更大、更稳定地训练的装置。给出下一个词元概率的核心计算是一样的。

如果只把嵌入理解成检索用的向量,很容易误以为"GPT 找出和下一个词最近的向量来选择"。实际上是这样。

  • 向量检索(RAG 等):把文档转成向量存起来,找出和问题向量相近的文档取出。结果是原本就有的文档。
  • GPT 的预测:把输入向量在块中计算变换,然后输出层给整个词表打分,再从这个概率中抽一个。结果是通过计算新生成的分布。

注意力也不是检索。它只是在输入句子内部的前面各位置之间计算权重并混合,不查找外部文档。

两者会一起使用。真实服务先用问题做向量检索找出相关文档,再把文档附到里,让 GPT 生成回答(RAG)。检索负责最新信息,GPT 负责组织句子。

动手输入并实验

先写engine.py并检查导数,再写model.py。Value记录局部导数,backward逆序累加影响。

cd ~/xslm
micro engine.py
micro model.py
python3 engine.py
python3 -c "from model import GPT; m=GPT(10, 8, 12); print(len(m.forward([1, 2])), len(m.forward([1, 2])[0]))"
在终端中练习
engine.py需要亲手编写的完整源码

为数值,grad为梯度,parents保存局部导数。softmax把分数变概率。

import math


class Value:
    def __init__(self, data, parents=()):
        self.data = float(data)
        self.grad = 0.0
        self.parents = parents

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data + other.data, ((self, 1), (other, 1)))

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data * other.data,
                     ((self, other.data), (other, self.data)))

    def __pow__(self, power):
        return Value(self.data ** power,
                     ((self, power * self.data ** (power - 1)),))

    def __neg__(self):
        return self * -1

    def __sub__(self, other):
        return self + (-other)

    def __truediv__(self, other):
        return self * (other ** -1)

    __radd__ = __add__
    __rmul__ = __mul__

    def exp(self):
        result = math.exp(self.data)
        return Value(result, ((self, result),))

    def log(self):
        return Value(math.log(self.data), ((self, 1 / self.data),))

    def relu(self):
        return Value(max(0, self.data), ((self, float(self.data > 0)),))

    def backward(self):
        order, seen, stack = [], set(), [(self, False)]
        while stack:
            node, expanded = stack.pop()
            if expanded:
                order.append(node)
            elif node not in seen:
                seen.add(node)
                stack.append((node, True))
                stack.extend((parent, False) for parent, _ in node.parents)
        for node in order:
            node.grad = 0.0
        self.grad = 1.0
        for node in reversed(order):
            for parent, derivative in node.parents:
                parent.grad += derivative * node.grad


def softmax(values):
    peak = max(v.data for v in values)
    exps = [(v - peak).exp() for v in values]
    total = sum(exps)
    return [v / total for v in exps]


def linear(x, weights):
    return [sum(a * b for a, b in zip(x, row)) for row in weights]


def norm(x):
    mean = sum(x) / len(x)
    variance = sum((v - mean) ** 2 for v in x) / len(x)
    return [(v - mean) * (variance + 1e-5) ** -0.5 for v in x]


if __name__ == '__main__':
    x = Value(3)
    y = x * x + 2 * x
    y.backward()
    print('y =', y.data, 'dy/dx =', x.grad)
    assert y.data == 15 and x.grad == 8
model.py需要亲手编写的完整源码

先读shapes再读forward。全部权重参与学习,state与load保存数字。

import random
from engine import Value, linear, norm, softmax


class GPT:
    def __init__(self, vocab_size, width, context, seed=7):
        self.width, self.context = width, context
        rng = random.Random(seed)
        shapes = {
            'token': (vocab_size, width), 'position': (context, width),
            'query': (width, width), 'key': (width, width),
            'value': (width, width), 'project': (width, width),
            'expand': (2 * width, width), 'contract': (width, 2 * width),
            'head': (vocab_size, width),
        }
        self.weights = {
            name: [[Value(rng.gauss(0, 0.1)) for _ in range(cols)]
                   for _ in range(rows)] for name, (rows, cols) in shapes.items()
        }
        self.parameters = [v for matrix in self.weights.values()
                           for row in matrix for v in row]

    def forward(self, ids):
        assert 0 < len(ids) <= self.context
        w, keys, values, output = self.weights, [], [], []
        for position, token in enumerate(ids):
            x = [a + b for a, b in zip(w['token'][token], w['position'][position])]
            h = norm(x)
            q = linear(h, w['query'])
            keys.append(linear(h, w['key']))
            values.append(linear(h, w['value']))
            scores = [sum(a * b for a, b in zip(q, k)) / self.width ** 0.5
                      for k in keys]
            attention = softmax(scores)
            mixed = [sum(p * v[i] for p, v in zip(attention, values))
                     for i in range(self.width)]
            x = [a + b for a, b in zip(x, linear(mixed, w['project']))]
            hidden = [v.relu() for v in linear(norm(x), w['expand'])]
            x = [a + b for a, b in zip(x, linear(hidden, w['contract']))]
            output.append(linear(norm(x), w['head']))
        return output

    def loss(self, inputs, targets):
        logits = self.forward(inputs)
        losses = [-softmax(row)[target].log()
                  for row, target in zip(logits, targets)]
        return sum(losses) / len(losses)

    def state(self):
        return {name: [[v.data for v in row] for row in matrix]
                for name, matrix in self.weights.items()}

    def load(self, state):
        assert set(state) == set(self.weights), 'checkpoint keys'
        for name, matrix in self.weights.items():
            assert len(state[name]) == len(matrix), 'checkpoint rows'
            for row, saved in zip(matrix, state[name]):
                assert len(saved) == len(row), 'checkpoint columns'
                for value, number in zip(row, saved):
                    value.data = float(number)

逐行拆解代码

engine.py Value——一个数字和它的来历

class Value:
    def __init__(self, data, parents=()):
        self.data = float(data)
        self.grad = 0.0
        self.parents = parents

Value 包装一个数字。data 是值,grad 是损失对这个值的导数(初始为 0),parents 是生成这个值的输入以及对这些输入的局部导数的列表。像权重这样一开始就存在的值,parents 是空的。用 float(data) 转换,所以放入整数也按浮点数计算。

engine.py 运算符——一边计算一边记下导数

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data + other.data, ((self, 1), (other, 1)))

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data * other.data,
                     ((self, other.data), (other, self.data)))

    def __pow__(self, power):
        return Value(self.data ** power,
                     ((self, power * self.data ** (power - 1)),))

    def __neg__(self):
        return self * -1

    def __sub__(self, other):
        return self + (-other)

    def __truediv__(self, other):
        return self * (other ** -1)

    __radd__ = __add__
    __rmul__ = __mul__

Python 把 a + b 换成 a.__add__(b) 来。定义了这些特殊方法,Value 之间就能像普通算式一样计算。

  • __add__:结果 = a + b。局部导数 a 侧为 1,b 侧为 1。
  • __mul__:结果 = a × b。局部导数 a 侧为 b 的值,b 侧为 a 的值。
  • __pow__:a 的 n 次方。局部导数为 n × a 的 (n − 1) 次方。
  • __neg__、__sub__、__truediv__:减法换成乘 −1 再相加,除法换成乘以 −1 次方,复用上面三种。不需要新的求导规则。
  • other 不是 Value 时(像 2 这样的普通数),就包装成 Value。
  • __radd__、__rmul__:像 2 * x 这样左边是普通数时调用。sum() 从 0 + 第一个值开始,所以有 __radd__ 才能对 Value 列表求 sum。

engine.py exp、log、relu——函数的导数

    def exp(self):
        result = math.exp(self.data)
        return Value(result, ((self, result),))

    def log(self):
        return Value(math.log(self.data), ((self, 1 / self.data),))

    def relu(self):
        return Value(max(0, self.data), ((self, float(self.data > 0)),))

exp 的导数是它自身,所以直接记下结果值 result。log x 的导数是 1/x。relu 正数时记梯度 1,0 以下记 0。float(self.data > 0) 为真时是 1.0,为假时是 0.0。只要有这三种和四则运算,就能求出整个模型的导数。

engine.py backward——倒着走图收集梯度

    def backward(self):
        order, seen, stack = [], set(), [(self, False)]
        while stack:
            node, expanded = stack.pop()
            if expanded:
                order.append(node)
            elif node not in seen:
                seen.add(node)
                stack.append((node, True))
                stack.extend((parent, False) for parent, _ in node.parents)
        for node in order:
            node.grad = 0.0
        self.grad = 1.0
        for node in reversed(order):
            for parent, derivative in node.parents:
                parent.grad += derivative * node.grad

分两步。

  • 建立顺序(第46~54行):用 stack 深度优先遍历图,生成 order。节点以 (节点, False) 第一次被取出时,在 seen 中标记,重新压入 (节点, True),再压入各父节点。所有父节点处理完后,(节点, True) 被取出时才放进 order,所以在 order 中输入总是排在结果前面(拓扑排序)。
  • 传播梯度(第55~60行):把图中所有节点的 grad 清为 0,损失自身设为 1.0(dL/dL = 1)。倒着走 order(从结果到输入),给每个父节点加上"局部导数 × 我的 grad"。因为倒着走,一个节点的 grad 从所有用处收集完毕之后,才会传给它的父节点。

engine.py softmax——不会溢出的概率

def softmax(values):
    peak = max(v.data for v in values)
    exps = [(v - peak).exp() for v in values]
    total = sum(exps)
    return [v / total for v in exps]

peak 是分数中最大的值(用 .data 取出的普通数)。所有分数减去 peak 再取 exp,最大值变成 1,不会溢出。除以总和 total,就得到总和为 1 的概率。peak 作为普通数相减,不会记录在图中,但所有分数减去同一个数概率不变,所以导数也仍然正确。

engine.py linear、norm——矩阵乘法与归一化

def linear(x, weights):
    return [sum(a * b for a, b in zip(x, row)) for row in weights]


def norm(x):
    mean = sum(x) / len(x)
    variance = sum((v - mean) ** 2 for v in x) / len(x)
    return [(v - mean) * (variance + 1e-5) ** -0.5 for v in x]

linear 对 weights 的每一行(row),与 x 同一格相乘再相加,输出结果。所以 weights 是(输出格数)行 × (输入格数)格,结果的格数等于行数。PyTorch 的 nn.Linear 也按(输出, 输入)的形状保存权重。norm 求出平均 mean 和方差 variance 后,换成(值 − 平均) ÷ √(方差 + 0.00001)。取 -0.5 次方等于除以平方根。

engine.py 自我检查

if __name__ == '__main__':
    x = Value(3)
    y = x * x + 2 * x
    y.backward()
    print('y =', y.data, 'dy/dx =', x.grad)
    assert y.data == 15 and x.grad == 8

直接运行 engine.py,会在 x = 3 处计算 y = x² + 2x 并求导。y = 9 + 6 = 15,dy/dx = 2·3 + 2 = 8 才对,assert 会确认这一点。如果改过引擎,先重新运行这个检查。

model.py 建立 9 种权重

class GPT:
    def __init__(self, vocab_size, width, context, seed=7):
        self.width, self.context = width, context
        rng = random.Random(seed)
        shapes = {
            'token': (vocab_size, width), 'position': (context, width),
            'query': (width, width), 'key': (width, width),
            'value': (width, width), 'project': (width, width),
            'expand': (2 * width, width), 'contract': (width, 2 * width),
            'head': (vocab_size, width),
        }
        self.weights = {
            name: [[Value(rng.gauss(0, 0.1)) for _ in range(cols)]
                   for _ in range(rows)] for name, (rows, cols) in shapes.items()
        }
        self.parameters = [v for matrix in self.weights.values()
                           for row in matrix for v in row]

shapes 为每个矩阵定好(行数, 格数)。字典推导式为每个名称生成 行数 × 格数 个 Value(rng.gauss(0, 0.1))。这是从平均 0、标准差 0.1 的正态分布中抽出的小数。如果全部填 0,所有格都会一样地变化,学不到不同的特征,所以从随机开始。rng 用 seed 创建,所以初始值总是相同。parameters 是把 9 个矩阵的 Value 展开成的一个列表,是训练要修改的全部数字。

model.py forward 开始——词元加位置

    def forward(self, ids):
        assert 0 < len(ids) <= self.context
        w, keys, values, output = self.weights, [], [], []
        for position, token in enumerate(ids):
            x = [a + b for a, b in zip(w['token'][token], w['position'][position])]
            h = norm(x)

assert 把输入长度限制在 1 以上、context 以下。位置表的行数是 context,更长就没有位置向量。keys、values 是用来积累前面各位置 key、value 的列表。每次接收 position(从 0 开始)和 token 编号,把词元向量和位置向量按格相加得到 x。h = norm(x) 是要放进注意力的归一化值(Pre-LN)。

model.py 一个头的因果注意力

            q = linear(h, w['query'])
            keys.append(linear(h, w['key']))
            values.append(linear(h, w['value']))
            scores = [sum(a * b for a, b in zip(q, k)) / self.width ** 0.5
                      for k in keys]
            attention = softmax(scores)
            mixed = [sum(p * v[i] for p, v in zip(attention, values))
                     for i in range(self.width)]
            x = [a + b for a, b in zip(x, linear(mixed, w['project']))]

q 是当前位置的 query,当前位置的 key、value 加到列表末尾。列表中只有到目前为止的位置,所以看不到未来。scores 是 q 和各 key 的内积 ÷ √width,attention 是它的 softmax,mixed 是把各 value 按 attention 权重逐格相加得到的向量。最后一行用 project 变换 mixed,加到 x 上(残差)。

model.py 两层 MLP

            hidden = [v.relu() for v in linear(norm(x), w['expand'])]
            x = [a + b for a, b in zip(x, linear(hidden, w['contract']))]

把 x 归一化,用 expand 扩到 16 格,每格套上 relu 得到 hidden。用 contract 缩回 8 格,加到 x 上(残差)。和注意力不同,它不看其他位置,只加工当前位置的向量。

model.py 输出层分数

            output.append(linear(norm(x), w['head']))
        return output

把 x 最后归一化,与 head 相乘,得到词表数量的分数,按位置积累到 output。循环结束后,output 是(输入长度) × (词表数)的分数表。

model.py loss——每个位置 −log 概率的平均

    def loss(self, inputs, targets):
        logits = self.forward(inputs)
        losses = [-softmax(row)[target].log()
                  for row, target in zip(logits, targets)]
        return sum(losses) / len(losses)

用 forward 得到每个位置的分数,在该位置的 softmax 概率中,对正确答案 target 的概率取 log 再改变符号。把所有位置的损失相加,除以位置数,返回平均值。结果也是 Value,所以可以调用 loss.backward()。

model.py state、load——保存与读取权重

    def state(self):
        return {name: [[v.data for v in row] for row in matrix]
                for name, matrix in self.weights.items()}

    def load(self, state):
        assert set(state) == set(self.weights), 'checkpoint keys'
        for name, matrix in self.weights.items():
            assert len(state[name]) == len(matrix), 'checkpoint rows'
            for row, saved in zip(matrix, state[name]):
                assert len(saved) == len(row), 'checkpoint columns'
                for value, number in zip(row, saved):
                    value.data = float(number)

state 返回一个形状相同、只取出普通数(.data)而不是 Value 的字典。因为 Value 对象带有计算图的连接,无法存成 JSON。load 反过来,把保存的数字放进 Value 的 data。在此之前用 assert 确认矩阵名称、行数、格数与当前模型相同,避免悄悄读错不同大小的检查点。

检查理解

导数检查得到15.0和8.0;形状检查得到2 10。解释为何未来词元不改变先前分数。

遇到问题时

检查矩阵与zip的维度。所有嵌入、注意力、MLP和输出权重都参与反向传播。

常见问题

权重和向量是一回事吗?

区别在于作用和形状。权重(参数)指的是"训练要修改的数字"这一作用,向量指的是数字排成一行的形状。整张嵌入表是权重,从表里取出的一行是词元的向量。计算中产生的 q、k、v 这类向量不是权重,而是当时的中间值。

知识存在模型的哪里?

没有哪个固定格子里放着句子。就像照片不是靠一个、而是靠所有像素的排列构成画面,知识分散在所有权重共同形成的计算习惯里。所以很难只挑出某一个事实来删除或修改。

为什么要除以 √宽度?

宽度为 d 的两个随机向量的内积,d 越大波动越大(标准差是 √d 倍)。分数大时,softmax 几乎只给一个位置 1,梯度流不到其他位置。除以 √d,不管宽度多少,分数的大小都保持相近。

backward 为什么用 stack 而不用递归?

训练一次的计算图有 3 万多个节点长长相连。用递归函数追踪,可能超过 Python 的递归上限(默认 1,000 层)而出现 RecursionError。用 stack 自己管理顺序,图再深也能安全地做出拓扑排序(输入在前的顺序)。

学习指南

人工智能

智能体工具

Vibe 编程

AI 应用

AI证书

AI课堂

IT 基础

聊天

论坛

创作

社区

更多

术语词典

选一个你不认识的词吧,可以看到简单的解释和相关术语。

还没有匹配的术语。

选择一个术语即可查看解释。