4、缩放点积的意义:为什么除以√d?数值稳定性与梯度分析。

好,咱们接着聊。前面我们推导了自注意力的核心公式,就是那个 softmax(QK^T / √d)。很多同学第一次看到这个 √d 的时候,心里都会犯嘀咕:这玩意儿是干嘛的?

说实话,我刚开始学 Transformer 的时候,也觉得这个分母有点多余。心想,直接算 softmax(QK^T) 不就行了?多此一举嘛。直到后来我自己动手写了一个简易的注意力模块,训练的时候发现 loss 曲线抖得像心电图……嗯,我才意识到,这个 √d 可不是随便加的。

4.1 点积的“膨胀”问题

我们先来看一个简单的情况。假设 qk 都是 d 维的向量,每个维度独立且服从标准正态分布 N(0, 1)。那么它们的点积:

score = q · k = Σ(q_i * k_i)

这个 score 的期望是 0,但方差是多少?

我们来算一下。每个 q_i * k_i 是两个独立标准正态变量的乘积,它的方差是 1。那么 d 个这样的项加起来,方差就是 d。也就是说,score ~ N(0, d)

这意味着什么?

d 很大的时候,比如 d=512,点积的数值会变得非常大。你想想看,一个标准差就是 √512 ≈ 22.6。也就是说,大部分点积的绝对值会在 20~30 这个量级,甚至更大。

然后我们把这个巨大的数值扔进 softmax 里……

核心问题:softmax 对输入非常敏感。当输入值很大时,softmax 会输出一个近乎 one-hot 的分布——最大的那个值对应的概率接近 1,其他的几乎为 0。

这就麻烦了。注意力机制本来是想让模型“关注”多个位置,结果变成了“只关注一个位置”。梯度也变得非常小,因为 softmax 在极端值区域的梯度几乎为 0。模型根本学不动。

4.2 除以 √d 的数学直觉

既然点积的方差是 d,那我们把每个点积除以 √d,方差就变成了 1。这样,无论 d 取多大,点积的数值范围都稳定在 N(0, 1) 附近。

说白了,就是给点积“降降温”。

我个人的理解是:√d 起到了一个“归一化”的作用。它让 softmax 的输入保持在一个合理的范围内,不会因为维度增加而失控。

一个小技巧:如果你自己实现注意力机制,可以试试不除以 √d,看看 loss 曲线是不是会剧烈震荡。我当年就是这么踩坑的。

4.3 梯度分析:为什么稳定?

我们再来从梯度的角度看看。softmax 的梯度公式是这样的:

∂softmax(z_i) / ∂z_j = softmax(z_i) * (δ_ij - softmax(z_j))

其中 δ_ij 是克罗内克函数(当 i=j 时为 1,否则为 0)。

z 的值很大时,比如某个 z_k 远大于其他值,那么 softmax(z_k) ≈ 1,其他 softmax(z_i) ≈ 0。这时候梯度会怎样?

  • 对于 i = k:梯度 ≈ 1 * (1 - 1) = 0
  • 对于 i ≠ k:梯度 ≈ 0 * (0 - 0) = 0

看到了吗?梯度几乎全为 0!这就是所谓的“梯度消失”。模型参数几乎得不到更新,训练就卡住了。

而除以 √d 之后,z 的值被控制在合理范围内,softmax 的输出不会太极端,梯度就能顺畅地回传。

注意:这里说的“梯度消失”和深层网络中的梯度消失不太一样。这里是 softmax 的“饱和区”导致的梯度消失,而不是链式法则中连乘导致的。

4.4 代码验证:有/无缩放的效果对比

光说不练假把式。我们来写一小段代码,直观感受一下缩放的影响。

import numpy as np

def softmax(x):
    e_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
    return e_x / np.sum(e_x, axis=-1, keepdims=True)

# 模拟一个 batch 的注意力分数
d = 64
batch_size = 4
seq_len = 10

# 随机生成 Q 和 K
Q = np.random.randn(batch_size, seq_len, d)
K = np.random.randn(batch_size, seq_len, d)

# 计算点积
scores = np.matmul(Q, K.transpose(0, 2, 1))  # shape: (batch, seq_len, seq_len)

# 不缩放
attn_no_scale = softmax(scores)

# 缩放
attn_with_scale = softmax(scores / np.sqrt(d))

# 看看分布
print("未缩放 softmax 的最大值:", np.max(attn_no_scale, axis=-1)[0])
print("缩放后 softmax 的最大值:", np.max(attn_with_scale, axis=-1)[0])

运行结果大概是这样:

未缩放 softmax 的最大值: [0.9999 0.9998 0.9999 0.9997 ...]
缩放后 softmax 的最大值: [0.2345 0.1987 0.3124 0.2765 ...]

看到了吗?不缩放的话,注意力几乎变成了 one-hot,模型只能关注到一个位置。缩放之后,注意力分布就平滑多了,模型可以同时关注多个位置。

4.5 为什么是 √d 而不是 d?

有人可能会问:为什么是除以 √d,而不是直接除以 d

因为点积的方差是 d,标准差是 √d。我们要把方差归一化到 1,所以除以标准差 √d。如果除以 d,方差就变成了 1/d,反而把数值压得太小了,softmax 的输出会过于平滑,模型也难以区分不同位置的注意力权重。

嗯,这里有个平衡点。除以 √d 刚好把方差拉到 1,既不会太极端,也不会太平滑。

4.6 实际项目中的经验

我在做多模态模型的时候,遇到过一个问题:视觉特征的维度是 1024,文本特征的维度是 512。如果直接做 cross-attention,点积的方差会非常大。我当时就踩了这个坑,训练了好几天,模型就是不收敛。

后来排查发现,是 √d 中的 d 取错了。我取了视觉特征的维度 1024,但实际上应该取 QK 的投影维度。因为 QK 是经过线性投影得到的,它们的维度是投影后的维度,而不是原始特征的维度。

避坑指南:记住,√d 中的 dQK 的投影维度,不是原始输入维度。很多初学者在这里搞混。

4.7 小结

总结一下,除以 √d 的意义:

  • 数值稳定性:将点积的方差从 d 归一化到 1,避免 softmax 进入饱和区。
  • 梯度健康:防止梯度消失,让模型能够正常训练。
  • 注意力分布合理:让模型能够关注多个位置,而不是只盯着一个。

说白了,这个 √d 就是自注意力机制的“稳定器”。没有它,Transformer 的训练会非常困难。我个人觉得,这是 Transformer 设计中一个非常精巧的细节,值得好好品味。

下一章,我们会深入探讨多头注意力的实现细节。到时候你会发现,每个头里的 √d 计算,用的都是每个头自己的维度 d_k,而不是总的 d_model。嗯,这个细节也很重要,我们到时候再细说。