MiniMind — 阶段 2.2:RMSNorm
Weight Tying 到底怎么共享的?
第一步:理解两个层各自长什么样
两个层的本质都是一个矩阵(一张表格):
Embedding 矩阵:形状 [6400, 512]
→ 6400 个字,每个字对应一个 512 维的向量
Output 矩阵: 形状 [6400, 512]
→ 把 512 维的向量,映射回 6400 个字的概率
注意:两个矩阵的形状完全一样!
第二步:看看它们各自怎么学习
Embedding(输入时):根据字的编号,去矩阵里查一行
输入:"天" → 编号 38
Embedding 矩阵(6400行 × 512列):
第 0 行: [0.12, -0.34, 0.56, ...] ← "的"
第 1 行: [0.45, 0.23, 0.11, ...] ← "是"
...
第38行: [0.78, -0.12, 0.93, ...] ← "天" ✅ 取出这一行!
...
输出:[0.78, -0.12, 0.93, ...] (512维向量)
Output(输出时):用向量和矩阵做乘法,算出每个字的分数
输入:[0.31, 0.67, -0.45, ...] (512维向量)
用同一个矩阵做乘法:
和第 0 行点乘 → 得分 2.3 ← "的"
和第 1 行点乘 → 得分 1.1 ← "是"
...
和第38行点乘 → 得分 5.7 ← "天" ✅ 得分最高!
...
输出:预测下一个字是"天"
第三步:所谓”共享”就是——用同一个矩阵!
❌ 不共享:内存中有两个矩阵
┌──────────────────┐ ┌──────────────────┐
│ Embedding 矩阵 │ │ Output 矩阵 │
│ [6400 × 512] │ │ [6400 × 512] │
│ 占 3.3M 参数 │ │ 占 3.3M 参数 │
└──────────────────┘ └──────────────────┘
总共 6.6M 参数
✅ 共享:内存中只有一个矩阵,两层都指向它
┌──────────────────┐
│ 同一个矩阵 │ ← Embedding 用它查行
│ [6400 × 512] │ ← Output 也用它做乘法
│ 占 3.3M 参数 │
└──────────────────┘
总共 3.3M 参数
第四步:代码里怎么写?就一行!
在 MiniMind 的代码中,大概是这样:
class MiniMindForCausalLM:
def __init__(self):
self.model = MiniMindModel(...)
self.lm_head = nn.Linear(512, 6400) # Output 层
# ⭐ 关键一行:让 Output 的权重指向 Embedding 的权重
self.lm_head.weight = self.model.embed_tokens.weight
最后一行就是 weight tying 的全部秘密!
Python 中这样赋值后,lm_head.weight 和 embed_tokens.weight 指向内存中同一块数据。改一个,另一个也跟着变。训练时梯度也会同时更新到这一个矩阵上。
用生活比喻
📖 一本中英词典,你可以:
- 正着查:知道中文 → 找英文(Embedding:字 → 向量)
- 反着查:知道英文 → 找中文(Output:向量 → 字)
共享权重 = 只买一本词典,正反两用 不共享 = 买两本词典,一本中→英,一本英→中
大模型不用 Weight Tying 的原因
原因一:省的参数可以忽略不计(可以看昨天的笔记)
小模型 MiniMind:省下 3.3M / 26M = 12.7% → 很值!
大模型 LLaMA-65B:省下 262M / 65000M = 0.4% → 无所谓
这个好理解,不再重复。
原因二(核心):两层的”目标”不同,强行共享会互相拖后腿
这是最重要的原因。
这里用一个具体例子来说明
场景设定
假设我们的词表只有 4 个字,向量只有 2 维(方便画图理解):
词表:["猫", "狗", "石", "吃"]
矩阵就是 4 行 × 2 列:
第1维 第2维
猫: [ ?, ? ]
狗: [ ?, ? ]
石: [ ?, ? ]
吃: [ ?, ? ]
Embedding 层想要什么?
Embedding 的任务是理解语义。它希望:
"猫"和"狗"都是动物 → 向量要接近
"猫"和"石"毫无关系 → 向量要远离
所以 Embedding 的理想矩阵:
第1维 第2维
猫: [ 0.9, 0.8 ] ← 猫和狗很接近 ✅
狗: [ 0.8, 0.7 ] ←
石: [-0.5, -0.6 ] ← 石头离动物很远 ✅
吃: [ 0.3, -0.2 ]
画成图:
第2维 ↑
1.0 | 🐱猫(0.9,0.8)
0.8 | 🐶狗(0.8,0.7)
0.6 |
0.4 |
0.2 |
0.0 |----------→ 第1维
-0.2 | 🍴吃
-0.4 |
-0.6 | 🪨石
猫和狗紧紧靠在一起,这对 Embedding 来说是好事——语义相近的词应该在一起。
Output 层想要什么?
现在看 Output 怎么学习。假设模型要预测下一个字:
"小明家养了一只___" → 答案应该是"猫"
Transformer 输出了一个向量 v = [0.85, 0.75]
Output 把 v 和矩阵每一行做点乘,算分数:
和"猫"点乘:0.85×0.9 + 0.75×0.8 = 0.765 + 0.60 = 1.365
和"狗"点乘:0.85×0.8 + 0.75×0.7 = 0.680 + 0.525 = 1.205
和"石"点乘:0.85×(-0.5) + 0.75×(-0.6) = -0.425 + (-0.45) = -0.875
和"吃"点乘:0.85×0.3 + 0.75×(-0.2) = 0.255 + (-0.15) = 0.105
结果:
猫: 1.365 ← 最高 ✅
狗: 1.205 ← 非常接近!⚠️ 危险!
石: -0.875
吃: 0.105
⚠️ 问题出现了!
猫的得分:1.365
狗的得分:1.205
差距只有:0.16 ← 太小了!
因为 Embedding 把猫和狗的向量放得很近,导致 Output 层几乎分不清猫和狗!
再看一个更极端的场景:
"这块___头很硬" → 答案是"石"
Transformer 输出 v = [-0.45, -0.55]
和"猫"点乘:-0.45×0.9 + (-0.55)×0.8 = -0.405 + (-0.44) = -0.845
和"狗"点乘:-0.45×0.8 + (-0.55)×0.7 = -0.36 + (-0.385) = -0.745
和"石"点乘:-0.45×(-0.5) + (-0.55)×(-0.6) = 0.225 + 0.33 = 0.555 ✅
和"吃"点乘:-0.45×0.3 + (-0.55)×(-0.2) = -0.135 + 0.11 = -0.025
这里”石”确实最高,但注意——这是因为”石”和其他词本来就离得远。问题只出在语义相近的词之间。
Output 层理想中的矩阵长什么样?
Output 希望每个字都离得远远的,这样才能精确区分:
第1维 第2维
猫: [ 1.0, 0.0 ] ← 猫指向正东
狗: [ 0.0, 1.0 ] ← 狗指向正北(和猫垂直!)
石: [-1.0, 0.0 ] ← 石指向正西
吃: [ 0.0, -1.0 ] ← 吃指向正南
画成图:
第2维 ↑
1.0 | 🐶狗
|
0.0 🪨石──────🐱猫 → 第1维
|
-1.0 | 🍴吃
四个字均匀散开,互相离得最远,这样 Output 层就能精确区分每个字。
直观对比:两层的理想矩阵完全不同!
Embedding 的理想: Output 的理想:
🐱🐶 靠在一起 🐶
(语义相似要近) 🪨 🐱
🍴
🪨 远离 (所有字都要远离)
Embedding 说:"猫和狗应该靠近!它们都是动物!"
Output 说: "猫和狗必须分开!不然我预测会出错!"
共享一个矩阵 → 它们只能妥协 → 两边都做不到最好
为什么小模型能忍,大模型忍不了?
小模型(词表 6400,512维):
512 维空间很"挤",能表达的信息有限
Embedding 和 Output 本来就做不到各自的理想状态
共享反而像一种"约束",防止小模型记住太多噪音(正则化效果)
→ 共享:两边都凑合,但整体更好 ✅
大模型(词表 32000,8192维):
8192 维空间很"宽敞",有足够的自由度
Embedding 可以完美实现"猫狗靠近"
Output 也可以完美实现"猫狗分开"
强行共享 = 强行让它们妥协 = 浪费了宽敞的空间
→ 不共享:两边各自发挥,效果更好 ✅
一句话总结
Embedding 想让相似的词靠近,Output 想让所有词远离。 小模型空间小,妥协无所谓;大模型空间大,各自分开效果更好。
原因三:模型越深,两层的”世界观”差距越大
小模型(8层):
输入 → [8层处理] → 输出
Embedding 和 Output 离得近,看到的东西差不多
共享一个矩阵,勉强能兼顾 ✅
大模型(80层):
输入 → [80层处理] → 输出
经过 80 层变换后,数据的"形态"已经完全不同了
Embedding 和 Output 各自需要的矩阵差距巨大
强行共享 = 两边都做不好 ❌
打个比方:
🏫 小学(8层小模型):语文老师兼数学老师,课程简单,一个人能搞定
🎓 大学(80层大模型):量子物理和古典文学,必须找两个专家,一个人根本兼不了
原因四:实验验证
研究者做过实验对比:
| 模型规模 | 共享权重 | 不共享 | 结论 |
|---|---|---|---|
| 小模型 | 效果好 ✅ | 容易过拟合 | 共享更优 |
| 大模型 | 效果变差 ❌ | 效果更好 ✅ | 不共享更优 |
所以 GPT-2(1.5B,较小)用了共享,而 GPT-3(175B)、LLaMA 系列都不再共享。
知识点 Transformer Decoder-Only:全局鸟瞰
一段文字进入 MiniMind,经过了哪几步,变成了下一个字?
"今天天气" → [ ? ] → "很"
整个过程就 5 步:
文字 "今天天气"
↓ ① Tokenizer:文字变编号
[102, 537, 537, 1893]
↓ ② Embedding:编号变向量
[[0.12, -0.34, ...], [0.45, 0.23, ...], ...]
↓ ③ Transformer Block × 8层:理解和加工
[[0.56, 0.91, ...], [0.33, -0.78, ...], ...]
↓ ④ RMSNorm:稳定数值
[[0.55, 0.90, ...], [0.32, -0.77, ...], ...]
↓ ⑤ lm_head:向量变成6400个字的分数,选最高分
"很"
就这么多。每一步做什么,我们后面逐个拆解。
现在你只需要记住这个流水线顺序:
Tokenizer → Embedding → 8层Block → RMSNorm → lm_head
🤔 思考题
① 和 ② 我们已经学过了(Tokenizer 和 Embedding)。剩下的 ③④⑤ 中,你觉得哪一步是最核心、最复杂的?为什么?
回答:我认为是 Transformer Block,因为它要进行多层次的处理和传递
完全正确!✅
③ Transformer Block 就是整个模型的大脑,其他步骤都是”搬运工”。
那我们现在打开这个大脑,看看一个 Block 里面有什么。
一个 Transformer Block 内部长什么样?
MiniMind 有 8 个 Block,每个 Block 内部结构完全相同:
输入向量
↓
RMSNorm(稳定数值)
↓
Self-Attention(字和字之间互相看)
↓
残差连接(加回原始输入)
↓
RMSNorm(再次稳定数值)
↓
FFN / SwiGLU(独立深度加工)
↓
残差连接(加回上一步输入)
↓
输出向量
总共就 6 个零件,两两一组,分成 3 对:
| 组 | 零件 | 一句话说明 |
|---|---|---|
| 第1对 | RMSNorm + Attention | 先稳定,再让字与字交流 |
| 第2对 | 残差连接 | 把原始信息加回来,防止丢失 |
| 第3对 | RMSNorm + FFN | 先稳定,再让每个字独立思考 |
🤔 思考题
这 6 个零件里有两个核心计算:Attention 和 FFN。
用一个比喻来理解:
- Attention = 开会讨论(大家互相交流信息)
- FFN = 回到工位独立思考(自己消化刚才讨论的内容)
问题:为什么需要先讨论再独立思考,而不是只做其中一个?
回答:因为需要先让字之间相互认识。这里的字是不是token?
严格来说:
- Token 是 Tokenizer 切出来的最小单位
- 字 是我为了方便理解用的通俗说法
在 MiniMind 中,因为词表只有 6400,很多时候一个中文字 = 一个 Token,但不总是。比如:
"今天" → 可能是 1个Token(整词),也可能是 2个Token(逐字)
"hello" → 可能是 1个Token,也可能被切成 "hel" + "lo" 两个Token
所以从现在开始,会统一用 Token 这个准确说法。
回到回答:
因为需要先让 Token 之间相互认识
✅ 对的!Attention 让 Token 之间交换信息,FFN 再对每个 Token 独立加工。缺一不可。
接下来我们进入第一个零件:RMSNorm。
开始前先问你一个问题:
🤔 思考题
假设 Embedding 输出的向量值是这样的:
Token 1: [0.01, 0.02, 0.01] ← 数值很小
Token 2: [150.0, 200.0, 180.0] ← 数值很大
这两个 Token 接下来要一起进入 Attention 做计算。你觉得这会有什么问题?
回答:数值差距很大,所以计算很麻烦?
✅ 回答正确!更准确地说,专业术语叫做数值不稳定(numerical instability)。
具体来说有两个问题:
- 梯度爆炸(gradient explosion):数值太大,训练时参数更新量失控
- 梯度消失(gradient vanishing):数值太小,参数几乎不更新
所以我们需要在计算前把数值归一化(Normalization),拉到一个稳定的范围。
零件 ①:RMSNorm(Root Mean Square Normalization)
它做了什么?
一句话:把每个 Token 的向量缩放到统一的数值范围。
公式:
RMSNorm(x) = x / RMS(x) * γ
其中 RMS(x) = sqrt( mean( x² ) + ε )
用刚才的例子:
Token 2: [150.0, 200.0, 180.0]
① 算平方的均值(mean of squares):
(150² + 200² + 180²) / 3 = (22500 + 40000 + 32400) / 3 = 31633
② 开根号(root):
sqrt(31633) ≈ 177.9
③ 每个值除以它:
[150/177.9, 200/177.9, 180/177.9] ≈ [0.84, 1.12, 1.01]
④ 乘以可学习参数 γ(learnable scale parameter):
γ 初始为 [1, 1, 1],训练中模型自己调整
数值从 [150, 200, 180] → [0.84, 1.12, 1.01],稳定了!
🤔 思考题
你可能听说过另一种归一化方法叫 LayerNorm。它的公式是:
LayerNorm(x) = (x - mean(x)) / std(x) * γ + β
对比 RMSNorm:
RMSNorm(x) = x / RMS(x) * γ
你能看出 RMSNorm 比 LayerNorm 少了什么操作吗?(提示:看公式的差异)
回答:看起来是少了 +β,这里 mean(x) 和 std(x) 是做什么,麻烦解释下
评价:对的,但还少了 均减值的部分(-mean(x))