← 返回学习笔记

MiniMind — 阶段 2.4:SwiGLU 激活函数

AIMiniMind

2026-03-23 · 全文

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 的三步我们都看完了:

  1. 计算不同频率 freqs(时钟的不同指针速度)
  2. 外积得到每个位置每组的旋转角度,再算 cos/sin
  3. 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

关键在参数量平衡

模型dimhiddenDim倍数说明
经典 Transformer5122048最传统的比例
Llama4096110082.7×用了 SwiGLU,所以缩小倍数
MiniMind51213762.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之间的关联关系程度