MiniMind — 阶段 2.4:SwiGLU 激活函数
RoPE
外积 torch.outer 的规则
回答:是 [[0,1,2],[0,0.1,0.2]]?
接近了!但行列搞反了 😊
outer(a, b) → 结果[i][j] = a[i] × b[j]
a的长度决定行数b的长度决定列数
所以 t 有 3 个元素 → 3 行,freqs 有 2 个元素 → 2 列
结果是 3×2 矩阵:
t = [0, 1, 2 ] # 3 个位置
freqs = [1.0, 0.1] # 2 组频率
torch.outer(t, freqs) =
频率1.0 频率0.1
(快组) (慢组)
位置0 → [ 0×1.0, 0×0.1 ] = [ 0.0, 0.0 ]
位置1 → [ 1×1.0, 1×0.1 ] = [ 1.0, 0.1 ]
位置2 → [ 2×1.0, 2×0.1 ] = [ 2.0, 0.2 ]
记住规则:
每一行 = 一个位置; 每一列 = 一组频率
这个矩阵的物理含义:
结果[m][i] = m × θᵢ = 位置 m 的第 i 组旋转角度
具体来说:
结果[2][0] = 2.0 → 位置 2 的快组旋转了 2.0 弧度(约 115°)
结果[2][1] = 0.2 → 位置 2 的慢组只旋转了 0.2 弧度(约 11°)
看到没?同一个位置,快组转了很多,慢组才转了一点点——秒针和时针!
然后代码接下来做的就是:
freqs_cos = torch.cos(freqs) # 对整个矩阵求 cos
freqs_sin = torch.sin(freqs) # 对整个矩阵求 sin
把角度变成 cos/sin 值,准备好用于旋转。
现在我们有了每个位置每组的旋转角度。
下一步要用这些 cos/sin 去旋转 Q 和 K 向量。
回忆之前学的 2D 旋转公式:
x' = x·cosθ - y·sinθ y' = x·sinθ + y·cosθMiniMind 源码中有一个
rotate_half函数:def rotate_half(x): return torch.cat((-x[..., x.shape[-1] // 2:], x[..., : x.shape[-1] // 2]), dim=-1)假设输入
x = [a, b, c, d](4个元素),rotate_half(x)的输出是什么?
rotate_half 的输出结果
相当于:
function rotateHalf(x: number[]): number[] {
const half = Math.floor(x.length / 2);
// 原后半部分取负 → 放前面
const negSecondHalf = x.slice(half).map(v => -v);
// 原前半部分 → 放后面
const firstHalf = x.slice(0, half);
return [...negSecondHalf, ...firstHalf];
}
假设输入 x = [a, b, c, d](4个元素)
第一步:分成前后两半
前半部分:x[..., : 2] → [a, b]
后半部分:x[..., 2 :] → [c, d]
第二步:后半取负
-x[..., 2:] → [-c, -d]
第三步:拼接(注意顺序!后半在前,前半在后)
torch.cat([-c, -d], [a, b]) → [-c, -d, a, b]
结果
输入:[a, b, c, d]
输出:[-c, -d, a, b]
就这样!把后半取负放前面,前半原样放后面。
为什么叫 “rotate_half”?
因为它是旋转公式的一部分!我们来看完整的旋转操作:
这个AI的回答,但是我想了想,感觉不太对,我认为更合理的解释是它就是旋转了一半,比如说 [2,4,6,8] 旋转出来是 [-6,-8,2,4]
再次查证之后,原因是:将向量的后半部分取负后与前半部分交换拼接
不过下面这个解释也是有意义的,我要保留下,单独做一节
RoPE是怎么做完整旋转的
q_embed = q * cos + rotate_half(q) * sin
假设 q = [a, b, c, d],cos = [cos₁, cos₁, cos₂, cos₂],sin = [sin₁, sin₁, sin₂, sin₂]:
q * cos = [ a·cos₁, b·cos₁, c·cos₂, d·cos₂ ]
rotate_half(q) * sin = [-c·sin₂, -d·sin₂, a·sin₁, b·sin₁ ]
嗯……等一下,这样看起来对不上我们的 2D 旋转公式?
其实 MiniMind 的 rotate_half 方式和”相邻两两配对”方式略有不同。让我换一种方式解释:
2 元素的最简例子
假设 q = [x, y](只有一组,dim=2):
rotate_half([x, y]) = [-y, x]
那么完整旋转:
q * cos + rotate_half(q) * sin
= [x, y] * [cosθ, cosθ] + [-y, x] * [sinθ, sinθ]
= [x·cosθ, y·cosθ ]
+ [-y·sinθ, x·sinθ ]
= [x·cosθ - y·sinθ, y·cosθ + x·sinθ]
对比标准 2D 旋转公式:
x' = x·cosθ - y·sinθ ✅ 完全一致!
y' = x·sinθ + y·cosθ ✅ 完全一致!
完美匹配! 🎉
4 个元素的例子(2 组)
q = [a, b, c, d],分成 2 组:(a, c) 是第 1 组,(b, d) 是第 2 组。
等等……这里的分组方式和”相邻配对”不一样:
MiniMind 的分组方式(前后半配对):
第1组:(a, c) → a 是前半第1个,c 是后半第1个
第2组:(b, d) → b 是前半第2个,d 是后半第2个
另一种常见方式(相邻配对):
第1组:(a, b)
第2组:(c, d)
MiniMind 用的是前后半配对。验证一下:
q = [ a, b, c, d ]
rotate_half(q) = [-c, -d, a, b ]
cos = [cosθ₁, cosθ₂, cosθ₁, cosθ₂ ]
sin = [sinθ₁, sinθ₂, sinθ₁, sinθ₂ ]
q * cos = [ a·cosθ₁, b·cosθ₂, c·cosθ₁, d·cosθ₂ ]
rotate_half(q)*sin = [-c·sinθ₁, -d·sinθ₂, a·sinθ₁, b·sinθ₂ ]
相加 = [ a·cosθ₁ - c·sinθ₁, b·cosθ₂ - d·sinθ₂, c·cosθ₁ + a·sinθ₁, d·cosθ₂ + b·sinθ₂ ]
看第 1 组 (a, c):
a' = a·cosθ₁ - c·sinθ₁ ✅ 标准旋转
c' = c·cosθ₁ + a·sinθ₁ ✅ 标准旋转
看第 2 组 (b, d):
b' = b·cosθ₂ - d·sinθ₂ ✅ 标准旋转
d' = d·cosθ₂ + b·sinθ₂ ✅ 标准旋转
每一组都是标准的 2D 旋转!只是配对方式不同。
RoPE 到底对 Q 和 K 做了什么事?
到此为止,RoPE 的三步我们都看完了:
- 计算不同频率
freqs(时钟的不同指针速度)- 外积得到每个位置每组的旋转角度,再算 cos/sin
- 用
rotate_half+ 乘法完成旋转请用一句话总结:RoPE 到底对 Q 和 K 做了什么事?
回答:计算出一个结果用于表示QK之间的语义关系和位置关系
你的方向是对的!但还可以更精确。我来帮你理清:
这里有两个小问题:
❌ 问题 1:“计算出一个结果”
RoPE 不是在”计算结果”,而是在改造 Q 和 K 本身。
旋转前:Q 和 K 只带有语义信息("这个词是什么意思")
旋转后:Q 和 K 同时带有语义信息 + 位置信息("这个词在第几个位置")
❌ 问题 2:关系不是 RoPE 算的
“语义关系”是后面 Q·K 点积时才算出来的。RoPE 只负责往 Q 和 K 里注入位置信息。
更准确的一句话总结
RoPE 根据每个 token 的位置,把 Q 和 K 向量旋转不同的角度,让位置信息”烙印”进向量里。这样后续 Q·K 做点积时,结果自然就同时包含了语义相似度和相对位置关系。
打个比方:
没有 RoPE 的情况:
Q 和 K 就像没写门牌号的信件 → 点积只能比较"内容像不像"
有 RoPE 的情况:
Q 和 K 就像写了门牌号的信件 → 点积既能比较"内容像不像",又能感知"距离多远"
RoPE 完整流程回顾
┌─────────────────────────────────────────────┐
│ 第一步:预计算频率和角度 │
│ 不同维度组 → 不同频率(秒针/分针/时针) │
│ 每个位置 × 每组频率 → 旋转角度矩阵 │
│ 角度 → cos, sin │
├─────────────────────────────────────────────┤
│ 第二步:旋转 Q 和 K │
│ q_new = q * cos + rotate_half(q) * sin │
│ k_new = k * cos + rotate_half(k) * sin │
│ (每两个元素为一组,做 2D 旋转) │
├─────────────────────────────────────────────┤
│ 第三步:用旋转后的 Q、K 算注意力 │
│ attention = softmax(q_new · k_new^T / √d) │
│ → 自动包含相对位置信息! │
└─────────────────────────────────────────────┘
RoPE 最神奇的数学性质
为什么旋转后点积就包含相对位置了?
位置 m 的 Q 旋转了 m×θ 度
位置 n 的 K 旋转了 n×θ 度
Q·K 点积时,旋转角度的差 = m×θ - n×θ = (m-n)×θ
结果只取决于 (m-n),即相对距离!
和绝对位置无关!
这意味着:
- 位置 3 看位置 1(距离 2)
- 位置 100 看位置 98(距离 2)
两者感受到的位置关系完全一样! 这就是”旋转位置编码”的核心优势。
🎉 RoPE 学习完成!
恭喜你!RoPE 是 Transformer 里最难理解的组件之一,你已经掌握了!
接下来我们要学 SwiGLU 激活函数。
你知道什么是”激活函数”吗?它在神经网络里起什么作用? (如果不知道也没关系,直接说”不知道”,我从零开始讲)
神经网络
神经网络的基本操作是什么?
神经网络最基本的操作就是:
输出 = 输入 × 权重 + 偏置
y = x × w + b
这就是一个线性变换。你可以理解为一条直线:
y
│ /
│ /
│ /
│/
└──────── x
y = x × w + b 里的 b 是什么?
b 是偏置(bias),你可以理解为”起点调整”。
在图形上看
y = x × 2 (没有 b,直线经过原点)
y
│ /
│ /
│/
└──── x
y = x × 2 + 3 (有 b=3,直线往上平移了 3)
y
│ /
│ /
/
│
└──── x ← 起点从 0 变成了 3
b 就是让直线可以上下移动,不被迫经过原点。
在神经网络里
# PyTorch 的线性层
layer = torch.nn.Linear(4, 2) # 4个输入,2个输出
layer.weight # 这就是 w
layer.bias # 这就是 b
b 也是可学习的参数,训练时会自动调整到最合适的值。
偏置 b 在大模型里到底有什么用?
b的意义 = 给每个神经元一个可调节的”门槛 / 默认倾向”。
用一个最直观的例子
假设有一个神经元要学习判断:“这句话是不是在问问题?”
它收到的输入特征可能是:
x₁ = 有没有问号(有=1,没有=0)
x₂ = 有没有疑问词(有=1,没有=0)
没有偏置 b
y = x₁ × 0.5 + x₂ × 0.5
情况1:有问号,有疑问词 → y = 0.5 + 0.5 = 1.0 ✅ 判断正确
情况2:没问号,没疑问词 → y = 0 + 0 = 0.0 ✅ 判断正确
情况3:有问号,没疑问词 → y = 0.5 + 0 = 0.5 🤔 不确定...
现在问题来了——你想设一个”门槛”:y > 0.3 就算问句。
但是没有 b,你没法调整门槛!门槛被固定在 0。
有偏置 b
y = x₁ × 0.5 + x₂ × 0.5 + (-0.3)
↑ 这就是 b
情况1:有问号,有疑问词 → y = 0.5 + 0.5 - 0.3 = 0.7 (正数→是问句)
情况2:没问号,没疑问词 → y = 0 + 0 - 0.3 = -0.3 (负数→不是问句)
情况3:有问号,没疑问词 → y = 0.5 + 0 - 0.3 = 0.2 (正数→勉强算问句)
b = -0.3 相当于设了一个门槛:至少要有 0.3 的信号才能”通过”。
对大模型输出的具体影响
没有 b 的模型(表达能力受限):
→ 所有输入为 0 的时候,输出也只能是 0
→ 模型的"默认状态"被锁死了
→ 某些简单的模式学不到,可能输出更呆板
有 b 的模型:
→ 即使输入特征不明显,b 也能提供一个"默认倾向"
→ 比如某个神经元学到 b = 2.0,意味着"默认就倾向于激活"
→ 模型更灵活,表达能力更强
讲了这么多 b 的好处,现在告诉你一个事实:
MiniMind 和 Llama 的大部分线性层,都不用 b!
⚠️为什么很多大模型的多数线性层不用b
# MiniMind 的代码里你会看到:
nn.Linear(dim, dim, bias=False) # bias=False!
为什么?因为它们有 RMSNorm!
RMSNorm 的效果:自动调整数据的分布(缩放和平移)
→ 相当于替代了 b 的作用
→ b 就变得多余了
→ 去掉 b 还能减少参数量,训练更快
类比:
b = 你自己手动调水温
RMSNorm = 一个恒温器自动帮你调
有了恒温器,你就不需要自己手动调了
一句话总结
现代大模型用 RMSNorm 替代了这个功能,所以
b通常被省略。
你理解 b 的概念就够了,后面学 MiniMind 的时候看到 bias=False 不要奇怪!
线性变换的问题是什么?
问题:多个线性变换叠加还是直线!
第1层:y₁ = x × 2 + 1
第2层:y₂ = y₁ × 3 + 4
合并:y₂ = (x × 2 + 1) × 3 + 4
= x × 6 + 7
还是一条直线!等于只有1层!
不管你叠多少层,线性叠线性 = 还是线性。
这就好比:
你把一张纸放大 2 倍,再放大 3 倍 → 等于直接放大 6 倍 叠 100 层也没用,等于 1 层
激活函数
激活函数的价值是什么?
激活函数 = 在每层之间加一个”扭曲”
第1层:y₁ = x × 2 + 1
激活: y₁ = 扭曲(y₁) ← 加了这个!
第2层:y₂ = y₁ × 3 + 4
这个”扭曲”就是激活函数,它是非线性的,让整个网络能学习弯曲的、复杂的关系。
一张图理解为什么需要激活函数
任务:区分猫和狗的图片
没有激活函数(只有直线):
┌─────────────┐
│ 🐱🐱 / 🐶🐶 │ ← 只能画直线来分割
│ 🐱 / 🐶🐶🐶 │ 如果猫和狗混在一起就分不开
└─────────────┘
有激活函数(可以弯曲):
┌─────────────┐
│ 🐱🐱 ╭──╮ │ ← 可以画弯曲的边界
│ 🐱🐱 │🐶🐶│ │ 再复杂的分布也能分开
│ ╰──╯ │
└─────────────┘
激活函数ReLU的规则
ReLU(x) = max(0, x)
规则超简单:
- 正数 → 不变
- 负数 → 变成 0
输入:[-3, -1, 0, 2, 5]
输出:[ 0, 0, 0, 2, 5]
就这么一个简单的”砍负数”操作,就足以让网络从直线变成任意弯曲的曲线!
激活函数在 Transformer 里的位置
每个 Transformer 层 = 注意力 + FFN(前馈网络)
FFN 的结构:
输入 → 线性变换₁ → 【激活函数】→ 线性变换₂ → 输出
↑
就在这里!
没有激活函数,FFN 的两个线性变换会”坍缩”成一个,等于白叠了一层。
常见激活函数对比
ReLU(x) = max(0, x) 最简单,但负数全变0("死亡"问题)
GELU(x) ≈ x × 概率(x>0) GPT 系列用,更平滑
SiLU(x) = x × sigmoid(x) 也叫 Swish,Llama 系列用
SwiGLU(x) = SiLU(xW₁) × (xW₃) MiniMind 用的!下节重点讲
进化趋势:
ReLU(简单粗暴)→ GELU(平滑)→ SwiGLU(更强大)
✅ 动手实操
打开 Python,感受一下激活函数:
import torch
import torch.nn.functional as F
x = torch.tensor([-3.0, -1.0, 0.0, 1.0, 3.0])
print("原始值:", x)
print("ReLU: ", F.relu(x)) # 砍掉负数
print("SiLU: ", F.silu(x)) # 平滑版本
print("GELU: ", F.gelu(x)) # GPT用的
运行看看三种激活函数对同一组数据的输出有什么区别?
输出:
原始值: tensor([-3., -1., 0., 1., 3.])
ReLU: tensor([0., 0., 0., 1., 3.])
SiLU: tensor([-0.1423, -0.2689, 0.0000, 0.7311, 2.8577])
GELU: tensor([-0.0040, -0.1587, 0.0000, 0.8413, 2.9960])
解读运行结果
- ReLU:粗暴 特点:非黑即白。负数全部归零,正数完全不变。
- SiLU:温柔 特点:温和过渡。负数”压小但不清零”,正数”略微缩小”。
- GELU:两者之间 特点:和 SiLU 类似,但对负数”砍”得更狠一些。
三者对比图
-3 -1 0 1 3
─────────────────────────────────
ReLU: ░░░░░░░░░░░░░░░░░░░░██████████ 负数全砍,非黑即白
SiLU: ██░░░░░░░░░░░░░░░░░░█████████ 负数保留一点,柔和过渡
GELU: ░█░░░░░░░░░░░░░░░░░░█████████ 介于两者之间
░ = 接近0 █ = 有值
看 ReLU 的输出结果后思考:
负数全变成 0 了,那这些信息是不是就”丢失”了? 你觉得这会不会有什么问题?
ReLU 丢失信息有什么问题?
问题确实存在!叫做 “神经元死亡”
假设训练过程中,某个神经元的输出一直是负数:
输入 → 线性变换 → -2.5 → ReLU → 0
输入 → 线性变换 → -1.3 → ReLU → 0
输入 → 线性变换 → -0.8 → ReLU → 0
输出永远是 0 → 梯度永远是 0 → 权重永远不更新 → 这个神经元"死了"!
ReLU 有缺陷,为什么当初还用它?
因为 ReLU 之前的激活函数问题更严重!
激活函数进化史
第一代:Sigmoid(1990 年代)
sigmoid(x) = 1 / (1 + e^(-x))
输出范围:(0, 1)
x = -10 → 0.00005
x = 0 → 0.5
x = 10 → 0.99995
致命问题——梯度消失:
当 x 很大或很小时,sigmoid 的曲线几乎是"平的"
平的 → 梯度接近 0 → 权重几乎不更新
层数一多(10层、20层...):
梯度 = 0.1 × 0.1 × 0.1 × ... ≈ 0.000001
→ 前面的层完全学不动!
第二代:ReLU(2012 年左右)
ReLU(x) = max(0, x)
正数部分梯度永远 = 1,不会消失!
Sigmoid:梯度一层层衰减 → 深层网络训不动
ReLU: 正数梯度恒定为1 → 深层网络也能训!
ReLU 解决了当时最严重的梯度消失问题,一下子让深度网络成为可能。
它的”神经元死亡”问题相比之下是小毛病。
第三代:SiLU(2017) / SwiGLU(2020)
到了大模型时代(几十层甚至上百层),
"神经元死亡"问题变得不可忽视了
→ SiLU / SwiGLU 应运而生
→ 既不会梯度消失,也不会神经元死亡
SiLU 怎么解决神经元死亡?
输入 → 线性变换 → -2.5 → SiLU → -0.02 (还活着!)
输入 → 线性变换 → -1.3 → SiLU → -0.35 (还活着!)
输出不是0 → 梯度不是0 → 权重还能更新 → 神经元"活着"!
这就是为什么 MiniMind 和 Llama 都选择 SiLU 而不是 ReLU。
SiLU 的公式是:
SiLU(x) = x × sigmoid(x)其中 sigmoid(x) 的输出范围是 (0, 1),可以理解为一个”开关的程度”:
- 接近 1 = 完全打开
- 接近 0 = 几乎关闭
那你觉得
x × sigmoid(x)这个公式在做什么事情?
SiLU = “x 自己给自己打分,决定自己保留多少”
SiLU(x) = x × sigmoid(x)
sigmoid 是一个”打分器”,给 x 打一个 0 到 1 之间的分:
x 很大(如 3) → sigmoid(3) ≈ 0.95 → 评价:"很重要!保留 95%"
x 接近 0 → sigmoid(0) = 0.50 → 评价:"不确定,保留 50%"
x 很小(如 -3) → sigmoid(-3) ≈ 0.05 → 评价:"不重要,只保留 5%"
现在想象一下:如果不让 x 自己决定,而是让另一个人 z 来帮 x 决定保留多少,变成:
输出 = x × sigmoid(z)你觉得这样做有什么好处?
回答:更加客观
完全正确!这就是 SwiGLU 的核心思路。我们现在正式讲。
SwiGLU
SwiGLU 的改进了什么
SiLU 是一个固定的激活函数:
SiLU(x) = x × sigmoid(x)
它用同一个数学公式处理所有特征,“过滤规则”是写死的。
SwiGLU 在此基础上加了一路可学习的门控:
# MiniMind 实际代码
output = self.w2(F.silu(self.w1(x)) * self.w3(x))
# ↑ 激活分支(固定规则) ↑ 门控分支(学出来的规则)
多出来的 w3(x) 是一个从数据中训练出来的线性变换,它决定哪些特征该放行、哪些该压制。
‼️解释SwiGLU的代码
# 在 FFN(前馈网络)中:
class FeedForward(nn.Module):
def __init__(self, config):
self.w1 = nn.Linear(dim, hidden_dim, bias=False) # 门控路
self.w3 = nn.Linear(dim, hidden_dim, bias=False) # 内容路
self.w2 = nn.Linear(hidden_dim, dim, bias=False) # 输出投影
def forward(self, x):
# 这一行就是 SwiGLU!
return self.w2(F.silu(self.w1(x)) * self.w3(x))
# ↑ 门控打分 ↑ 原始内容
# └──── 相乘 ────┘
# ↑
# 再通过 w2 输出
就这一行代码!但包含了我们学的所有知识。
相当于
// 平滑激活函数:比 ReLU 更柔和,负值不会被完全丢弃
const silu = (x: number[]) => x.map(v => v / (1 + Math.exp(-v)));
// 线性变换:用权重矩阵 W 把向量 x 投影到新的维度空间
const linear = (x: number[], W: number[][]) =>
W.map(row => row.reduce((s, w, i) => s + w * x[i], 0));
// 创建全零矩阵:用于权重的初始化占位
// !这里的 zeros 只是一个教学占位符,为了让代码能跑通、展示 SwiGLU 的计算流程。真正训练时,绝对不能用全零或全一初始化
// minimind里面 nn.Linear 创建时就自动做了随机初始化,所以你不会在代码里看到显式的初始化步骤。
const zeros = (rows: number, cols: number) =>
Array.from({ length: rows }, () => new Array(cols).fill(0));
// SwiGLU 前馈网络:先升维充分混合特征,再降维回原始大小
class FeedForward {
w1: number[][]; // 门控路权重,dim → hiddenDim
w3: number[][]; // 内容路权重,dim → hiddenDim
w2: number[][]; // 输出投影权重,hiddenDim → dim
constructor(
dim: number, // 模型主维度(输入输出宽度)
hiddenDim: number // 内部临时膨胀的宽度,用完就压回 dim
) {
this.w1 = zeros(hiddenDim, dim); // 升维
this.w3 = zeros(hiddenDim, dim); // 升维
this.w2 = zeros(dim, hiddenDim); // 降维
}
forward(x: number[]): number[] {
const gate = silu(linear(x, this.w1)); // 门控信号:决定"放行多少"
const content = linear(x, this.w3); // 原始内容:实际要传递的信息
const gated = gate.map((g, i) => g * content[i]);// 门控 × 内容:按重要性过滤
return linear(gated, this.w2); // 降维回 dim,输出结果
}
}
平滑激活函数 silu
// 平滑激活函数:比 ReLU 更柔和,负值不会被完全丢弃
// 公式:silu(v) = v × sigmoid(v) = v / (1 + e^(-v))
//
// 示例:silu([2, -1, 0.5])
//
// v = 2 → 2 / (1 + e^-2) = 2 / 1.135 ≈ 1.76 ✅ 正值略缩小
// v = -1 → -1 / (1 + e^1) = -1 / 3.718 ≈ -0.27 ✅ 负值被压制但没丢弃(ReLU 会直接变 0)
// v = 0.5 → 0.5 / (1 + e^-0.5) = 0.5 / 1.607 ≈ 0.31 ✅ 小正值也略缩小
//
// 输出:[1.76, -0.27, 0.31]
//
const silu = (x: number[]) => x.map(v => v / (1 + Math.exp(-v)));
线性变换 liner
// 线性变换:用权重矩阵 W 把向量 x 投影到新的维度空间
// 本质就是矩阵乘法:每一行 W[i] 和 x 做点积,得到输出的第 i 个元素
//
// 示例:linear([2, -1, 0.5], [[0.1, 0.3, -0.2],
// [0.5, -0.1, 0.4]])
//
// x 的维度是 3,W 是 2×3 矩阵 → 输出维度是 2(降维了)
//
// W[0]·x = 0.1×2 + 0.3×(-1) + (-0.2)×0.5 = 0.2 - 0.3 - 0.1 = -0.2
// W[1]·x = 0.5×2 + (-0.1)×(-1) + 0.4×0.5 = 1.0 + 0.1 + 0.2 = 1.3
//
// 输出:[-0.2, 1.3] (3维 → 2维)
//
const linear = (x: number[], W: number[][]) =>
W.map(row => row.reduce((s, w, i) => s + w * x[i], 0));
升维和降维
以 dim=3 hiddenDim=6 举例:
w1: 6×3(升维矩阵) 输入3维 → 输出6维
┌─────┐
│0 0 0│ ← 3列:吃进 dim=3
│0 0 0│
│0 0 0│
│0 0 0│
│0 0 0│
│0 0 0│ ← 6行:吐出 hiddenDim=6
└─────┘
w2: 3×6(降维矩阵) 输入6维 → 输出3维
┌───────────┐
│0 0 0 0 0 0│ ← 6列:吃进 hiddenDim=6
│0 0 0 0 0 0│
│0 0 0 0 0 0│ ← 3行:吐出 dim=3
└───────────┘
记忆口诀:
矩阵的「行数 = 输出维度」,「列数 = 输入维度」。
行多列少 → 升维(6×3:3→6) 行少列多 → 降维(3×6:6→3)
实际倍数关系:
hiddenDim 通常是 dim 的 2~4 倍。用 SwiGLU 时取 ~2.7 倍(因为多了一条路,缩小倍数来平衡参数量)。
为什么倍数关系是 2.7
关键在参数量平衡:
| 模型 | dim | hiddenDim | 倍数 | 说明 |
|---|---|---|---|---|
| 经典 Transformer | 512 | 2048 | 4× | 最传统的比例 |
| Llama | 4096 | 11008 | 2.7× | 用了 SwiGLU,所以缩小倍数 |
| MiniMind | 512 | 1376 | 2.7× | 跟 Llama 一样的策略 |
<TEXT>
普通 FFN(1 条路):
w1: [4d, d] → 参数量 = 4d²
w2: [d, 4d] → 参数量 = 4d²
总计 = 8d²
SwiGLU(2 条路,多了 w3):
w1: [?d, d] → 参数量 = ?d²
w3: [?d, d] → 参数量 = ?d² ← 多出来的
w2: [d, ?d] → 参数量 = ?d²
总计 = 3?d²
要让总参数量大致相等:3 × ?d² ≈ 8d²,解得 ? ≈ 2.67
所以 2.7× 不是拍脑袋定的,是让 SwiGLU 和传统 FFN 参数量持平的数学结果。
✅ 动手实操
用代码模拟 SwiGLU 的过程:
import torch
import torch.nn.functional as F
# 模拟一个输入
x = torch.tensor([1.0, -2.0, 3.0, -0.5])
# 模拟两路权重(简化版,实际是矩阵乘法)
gate = x * 0.8 # 假装这是 W₁ 的输出
content = x * 1.2 # 假装这是 W₃ 的输出
# SwiGLU 的核心计算
gate_score = F.silu(gate) # 门控路打分
output = gate_score * content # 打分 × 内容
print("输入x: ", x)
print("门控路(打分):", gate_score)
print("内容路: ", content)
print("SwiGLU输出: ", output)
运行后观察:正数和负数分别被怎样处理了?
输出:
输入x: tensor([ 1.0000, -2.0000, 3.0000, -0.5000])
门控路(打分): tensor([ 0.5520, -0.2688, 2.2004, -0.1605])
内容路: tensor([ 1.2000, -2.4000, 3.6000, -0.6000])
SwiGLU输出: tensor([0.6624, 0.6450, 7.9214, 0.0963])
运行结果观察:相互之间的大小关系没有变化,而且越大的数放大比例越大,负数转换的值很小
结果是对的,但这里我对AI的结论不太满意,因为以平滑性来说,SiLU也是平滑的。更好的解释是 自适应 机制,另外做了确认
SwiGLU的变化特征
SwiGLU 的核心优势:门控机制让模型能自适应地选择”让哪些特征通过、屏蔽哪些特征”,比固定的激活函数灵活得多。
我们现在知道了 FFN 的结构:
x → W₁(门控) × W₃(内容) → W₂ → 输出你注意到 W₁ 把 dim 维 变成了 hidden_dim 维(变大), W₂ 又把 hidden_dim 变回 dim 维(变小)。
问:为什么要 先变大再变小?
提示:想象你要把一张照片里的所有细节描述出来—— 你会先用很多词展开描述(变大),然后再总结成一句话(变小)。
回答:先展开细节再归纳,比直接总结图片更精准
非常好!补充一下技术细节。
为什么要先升维再降维
MiniMind 中的配置:
dim = 512 # 输入/输出维度
hidden_dim = 1408 # 中间维度(≈ 2.75 倍)
x (512维) → W₁/W₃ → 中间层 (1408维) → W₂ → 输出 (512维)
放大 2.75 倍 缩小回来
512 维的空间太小了,很多复杂关系无法表达
就像:
你要描述一幅画面,只给你 5 个词 → 太粗糙
先给你 14 个词展开描述 → 再精选 5 个词总结 → 精准得多
✅ 动手实操
我们已经把 FFN(前馈网络)的核心搞懂了!现在来验证 MiniMind 的真实代码:
import torch
import torch.nn as nn
import torch.nn.functional as F
# 模拟 MiniMind 的 FeedForward
class FeedForward(nn.Module):
def __init__(self, dim=512, hidden_dim=1408):
super().__init__()
self.w1 = nn.Linear(dim, hidden_dim, bias=False) # 门控路
self.w3 = nn.Linear(dim, hidden_dim, bias=False) # 内容路
self.w2 = nn.Linear(hidden_dim, dim, bias=False) # 输出投影
def forward(self, x):
return self.w2(F.silu(self.w1(x)) * self.w3(x))
# 创建模型
ffn = FeedForward()
# 模拟输入:1个token,512维
x = torch.randn(1, 512)
# 前向传播
output = ffn(x)
print(f"输入形状:{x.shape}") # [1, 512]
print(f"输出形状:{output.shape}") # [1, 512] 形状不变!
print(f"W1 参数量:{512 * 1408 = }") # 自己算一下
print(f"W3 参数量:{512 * 1408 = }")
print(f"W2 参数量:{1408 * 512 = }")
print(f"FFN 总参数量:上面三个加起来")
算一下:一个 FFN 层有多少参数? 这占整个 25.8M 模型的多大比例?
输出:
输入形状:torch.Size([1, 512])
输出形状:torch.Size([1, 512])
W1 参数量:512 * 1408 = 720896
W3 参数量:512 * 1408 = 720896
W2 参数量:1408 * 512 = 720896
FFN 总参数量:上面三个加起来
合计2,162,688,约占 8.4%
对的
一个 FFN 层 ≈ 占模型的 8.4%
但这还没完!关键问题:模型有几层?
MiniMind 最小模型有 8 层 Transformer,每层都有一个 FFN!
8 个 FFN 合计:2,162,688 × 8 = 17,301,504
全部 FFN 占比:17,301,504 ÷ 25,800,000 ≈ 67%
模型 2/3 的参数都在 FFN 里!
那剩下的 33% 在哪?
模型参数分布(大致):
┌────────────────────────────────────────────┐
│ FFN(8层) ≈ 17.3M ≈ 67% ███████████████████░░░░░░░░░░ │
│ Attention(8层) ≈ 4.2M ≈ 16% █████░░░░░░░░░░░░░░░░░░░░░░░ │
│ Embedding + 输出头 ≈ 3.3M ≈ 13% ████░░░░░░░░░░░░░░░░░░░░░░░░ │
│ RMSNorm 等 ≈ 极少 ≈ 1% ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
└────────────────────────────────────────────┘
这就是为什么 MoE(混合专家)要在 FFN 上做文章——因为 FFN 占大头!
(后面阶段 2 学到 MoE 时你会看到:MoE 就是把一个大 FFN 拆成多个小”专家”FFN)
我们已经学了 FFN 的 SwiGLU 结构。
但 Transformer 不只有 FFN,还有注意力机制(Attention)。
你直觉上觉得:
- FFN 负责什么?(提示:刚才的”展开-筛选-压缩”像在做什么?)
- Attention 可能负责什么?(提示:一句话里的每个词需要”看”其他词)
回答:
- FFN 深度加工信息,得到更本质的语义?
- Attention 计算不同Token之间的关联关系程度