很多人第一次被线性变换"坑"到,是在一次平平无奇的调试里:明明堆了三层全连接,训练曲线却跟单层的一模一样,损失降到的位置几乎分毫不差。当时我以为是学习率的问题,换了三组超参,又怀疑数据管道有 bug,最后把权重打印出来做了个乘法才发现——三层矩阵乘下来,等价于一个新矩阵,中间那两次"加深"根本不存在。这件事让我意识到,神经网络里的线性变换看着是最简单的一行公式,实际上埋着最多的坑:形状对不上、参数量算错、初始化写反、精度溢出、转置约定不统一,每一个都能让你安静地调上一整天。这篇内容我想把线性变换从"一行 y = Wx + b"讲到能落地复现的程度,包括它在全连接、卷积、嵌入、注意力里的四种形态,参数量和计算量的手算过程,初始化的方差推导,反向传播的梯度形状自检,以及工程上几个真会出事的细节。不管你是刚写完第一个全连接层的新手,还是已经在调大模型显存的老手,下面这些内容应该都能对得上你的某段记忆。
1. 从一次"堆了三层却没变强"的调试说起
1.1 y = Wx + b 的三重身份:查表、投影、坐标变换
同一个式子,在不同的人眼里是完全不同的东西。做数值计算的人看它是矩阵乘法,做几何的人看它是仿射变换,做工程的人看它是一次"加权求和加偏置"。这三种视角没有高下之分,但你用哪一种视角去理解,直接决定了你能不能预判它的行为。
先说几何视角。一个 n_in 维向量 x 乘上 n_out × n_in 的矩阵 W,本质是把 x 从 n_in 维空间映射到 n_out 维空间。如果 n_out < n_in,这是一次压缩投影,信息一定会有损失;如果 n_out > n_in,这是一次升维展开,原本在低维空间里线性不可分的东西,升维之后有可能变得可分。很多教程里讲"升维解决非线性问题",说的就是这个动作,只不过单靠线性变换升维还不够,后面必须接非线性。
再说查表视角。这一层很多人没意识到:嵌入层(Embedding)本质上就是一个 one-hot 向量乘一个大矩阵。词表大小 50000、隐藏维度 4096,那个嵌入矩阵就是 50000 × 4096,输入一个词的 one-hot 向量(长度 50000,只有一个位置是 1),乘出来的结果就是矩阵对应的那一行。所以嵌入层不需要真的做矩阵乘法,直接按索引取行就行——这也是为什么框架里的嵌入层实现是"查表"而不是"矩阵乘",省掉了 99.99% 的无效计算。理解这一点,你才能明白为什么嵌入层的参数量等于词表大小乘以维度,也才能明白为什么词表一大,模型参数就爆炸。
第三个视角是加权求和。y 的第 i 个分量 y_i = Σ_j W_ij · x_j + b_i,也就是"用 W 的第 i 行去和输入做点积"。这个视角在解释特征重要性时特别有用:W 的第 i 行就是第 i 个输出特征的"检测器",它的方向决定了这个神经元对什么样的输入模式响应最强。训练的过程,就是让这些检测器慢慢转向数据里真正有判别力的方向。
这三种视角要能自由切换,因为你在不同场景下需要的是不同的那一个。算参数量的时候用查表视角最直观,调初始化的时候用几何视角(保持方差)最有效,排查"某个特征没学到"的时候用加权求和视角最容易定位。
1.2 偏置项 b 到底在补什么
新手最常见的一个疑问是:既然 W 已经能做各种变换了,为什么还要加一个 b?去掉它行不行?
从几何上讲,没有 b 的线性变换一定把原点映射到原点。输入全零,输出必然全零。这意味着你所有的决策边界都必须经过原点,模型的表达能力被硬性砍掉一大块。举个二维的例子:你想用一条直线区分两类点,如果这条线必须过原点,那你只能表示"过原点的直线",斜率一种一个;加上 b 之后,这条线可以平移到平面上任意位置,斜率加截距两个自由度都齐了。
从统计上讲,b 让每个输出神经元的激活值可以整体平移。如果某一类样本的特征均值本身就偏大(比如某个传感器读数普遍在 100 左右而不是 0 附近),没有 b 的话,这个神经元要花很大的权重去"抵消"这个偏移,等于把表达能力浪费在了补偿数据分布上。加上 b,这一步平移直接由偏置承担,权重可以专心学真正的模式。
注意:b 虽然只有一个向量,参数量是 n_out,看着微不足道,但在数据没有做归一化的时候,它承担的"校准"作用非常关键。反过来说,如果你在两层线性之间加了归一化层,归一化会把偏置吸收掉(因为减均值的操作会把常数偏移消掉),这时候前一层的 b 就是纯粹的多余参数,可以安全删掉。这也是很多推理优化里"bias 折叠"能省参数的原因。
实测里有个细节:某些框架的线性层默认bias=True,而你在做权重融合或者手写推理引擎的时候,如果忘了这个默认值,输出的数值会整体偏移一点,误差不大但会让精度指标莫名其妙掉零点几个点,而且极难定位。我的习惯是:凡是手写算子,先把 bias 是否存在这件事在代码里显式断言一遍。
1.3 手算一遍形状、参数量与计算量
这一节我希望你能养成"先算后写"的习惯。很多人写模型是边写边跑,报错了再改,其实只要在下笔前花两分钟手算,大部分维度错误根本不会发生。
给定输入 x 的形状是 (batch, n_in),权重 W 的形状是 (n_out, n_in),偏置 b 的形状是 (n_out,),输出 y 的形状是 (batch, n_out)。乘法发生的条件是内维对齐:x 的最后一维 n_in 必须等于 W 的最后一维 n_in。
参数量 = n_out × n_in + n_out。"加 n_out"那一项就是 bias。当 n_in 和 n_out 都很大时,bias 可以忽略,但在小模型里它占比不小,比如 n_in=n_out=16 时,权重 256 个,偏置 16 个,占了 6%。
计算量(前向)大约是 2 × batch × n_in × n_out 次浮点运算,乘一次加一次各算一次。为什么这个数字重要?因为它直接决定了你的算力预算。以一个 4096 → 4096 的线性层为例,单条样本的参数量是 4096×4096+4096 ≈ 16.8M,前向 FLOPs 约 33.6M。如果你有 32 层这样的结构,单是这些线性层就是 537M 参数。这个量级意味着什么?fp32 存一份要 2.1GB,fp16 要 1.1GB,光权重就吃掉这么多显存,还没算激活值和优化器状态。
再看一个更极端的例子:词表 50000、隐藏维度 4096 的嵌入层,参数量 204.8M,fp32 下约 819MB。这就是为什么很多模型在做词表裁剪、权重共享(输入嵌入和输出投影共用同一个矩阵)——省下来的显存是实打实的。
| 结构 | 输入维度 | 输出维度 | 参数量 | 前向 FLOPs(单样本) |
|---|---|---|---|---|
| 小全连接 | 256 | 256 | 65,792 | 约 131K |
| 中等全连接 | 1024 | 1024 | 1,049,600 | 约 2.1M |
| 大全连接 | 4096 | 4096 | 16,781,312 | 约 33.6M |
| 词表嵌入 | 50000(one-hot) | 4096 | 204,800,000 | 查表,实际约 0 |
这张表最后一行值得多看一眼:理论上它是 50000×4096 的矩阵乘法,实际实现是 O(1) 的取行操作。这就是"理解本质"带来的优化空间——如果你把它当成矩阵乘去实现,性能会差好几个数量级。
2. 纯线性堆叠为什么会塌缩成一层
2.1 矩阵乘法的结合律是"罪魁祸首"
回到开头那个调试故事。两层线性,第一层 W1(形状 h × n),第二层 W2(形状 m × h),中间没有激活:
y = W2(W1 x + b1) + b2 = (W2 W1) x + (W2 b1 + b2)
括号一展开就露馅了:W2 W1 是一个 m × n 的矩阵,W2 b1 + b2 是一个 m 维向量。所以这两层等价于一个 m × n 的线性层,参数量从 h(n+m) 变成了 mn。如果 h 比 n 和 m 都大(这通常是设计意图,中间层要"宽"一点),那么你白付了参数和算力,换来的表达能力是零。
这不是"近似相等",是数学上严格相等。你可以写个几行的小脚本验证:随机生成 W1、W2、b1、b2,随机生成 x,分别算两层的输出和合并后单层的输出,差值应该在浮点误差量级(1e-6 左右)。我第一次做这个验证的时候是有点震惊的——意味着我以前写的那些"多层感知机"如果没有激活函数,全是自欺欺人。
那为什么加上激活函数就不塌缩了?因为激活函数是非线性的,它不满足 f(Wx) = W f(x),括号展不开。整个网络因此变成了分段线性的函数逼近器,每一段对应一种激活模式。这才是"深度"真正带来表达能力的来源,深度本身不是,非线性的复合才是。
这里有个容易混淆的点:ReLU 是分段线性的,那堆很多层 ReLU 会不会也塌缩?不会。虽然每个 ReLU 在不同区域是线性的,但不同样本落在不同的激活区域,整体函数是分段的,段数随深度指数增长。理论上 n 个神经元的 ReLU 网络能切出 2^n 个线性区域(实际远达不到,但增长趋势成立)。所以"ReLU 是线性的所以没用"这个说法是错的,错在把"分段线性"当成了"线性"。
2.2 激活函数的位置比种类更重要
我在带人的时候发现一个现象:大家特别纠结选 ReLU 还是 GELU 还是 SiLU,但很少人关心激活放在哪。实际上后者对结果的影响往往更大。
标准位置是线性层之后:h = act(Wx + b)。这个顺序的合理性在于,线性层先把输入投影到一个新的表示空间,激活再对这个表示做非线性裁剪。如果反过来写h = W·act(x) + b,你相当于先对原始输入做非线性,再线性组合——表达能力会受到输入维度限制,而且第一层的激活作用在原始特征上,往往没什么意义(原始特征本身可能就有负值,ReLU 直接砍掉一半信息)。
还有一个常被忽略的位置是"线性层内部"。比如 GLU(门控线性单元)变体:把一个线性层的输出拆成两半,一半过激活函数当作门,另一半原样保留,两者逐元素相乘。这种做法让线性变换的输出自己控制自己的通过率,在实践中效果比单纯的激活好,代价是多了一倍的输出维度(因为要拆两半)。很多现代结构用的就是这类门控设计。
再说一个实操细节:激活函数前的那次线性变换,输出范围会直接影响激活的工作区间。如果 W 初始化得太大,线性输出动辄上百,ReLU 之后全是正值,梯度就只剩单边;如果太小,输出挤在零附近,ReLU 大量失活,梯度直接归零。这就是下一节要讲的初始化问题的真正动机——不是为了"好看",是为了让激活函数工作在它的有效区间里。
2.3 一个反例:为什么残差连接改变了游戏规则
如果把线性层和激活交替堆叠,理论上表达能力够了,但实际训练会碰到退化问题:层数一多,梯度传不回去,深层的效果反而不如浅层。
残差连接y = x + F(x)里,那个+ x是一个恒等映射,它本身不是线性变换(严格说是单位矩阵的线性变换),但它给了梯度一条高速公路。反向传播时,梯度可以沿恒等路径原样流回去,不需要穿过一层层的权重矩阵连乘。
这里有个数学上的细节值得说清楚:如果没有残差,n 层网络的梯度里会带一个 W_n · W_{n-1} · ... · W_1 的连乘项。这些矩阵的奇异值如果普遍小于 1,连乘之后指数衰减;普遍大于 1,指数爆炸。残差把这个连乘变成了 (I + W_n)(I + W_{n-1})... 的形式,只要 W 的谱范数不大,每一项都接近 I,连乘就稳定得多。
提示:判断一个深层网络能不能训起来,看一眼它的梯度路径上有多少个"纯矩阵连乘"就知道了。残差、归一化、门控,本质上都是在给这条路径加"短路",减少连乘的长度。
顺带说一句,残差连接对线性层的初始化策略也有影响。经典做法是把残差分支最后一层的权重初始化得偏小(接近零),这样初始状态下整个块接近恒等映射,训练前期相当于在训一个浅网络,后面再慢慢让残差分支发挥作用。这个技巧在很深的网络里几乎是标配,不加的话前期 loss 会抖得厉害。
3. 同一个线性变换的四种伪装形态
3.1 全连接层:最直白的那一种
全连接层是所有线性变换里最没有遮掩的形态:输入一个向量,输出一个向量,中间一个稠密矩阵。它的特点是没有对输入做任何结构假设——每个输出都和每个输入相连,"全连接"这个名字就是这么来的。
这种无假设的特性带来两个后果。好处是通用,你不需要知道输入的各维之间有什么关系,它自己去学。坏处是参数量随维度平方增长,而且它对输入的排列顺序完全不敏感——你把输入的第 1 维和第 100 维互换,只要训练时权重也跟着换,结果一样。对于图像这种有明显空间结构的输入,这个特性是浪费:相邻像素的关系和相隔很远的像素的关系被一视同仁地对待了。
所以全连接层在现代结构里的位置通常是"最后一步":前面用卷积或者注意力提取出结构化特征,最后用一两层全连接把特征映射到类别数或者输出维度。放在中间做特征变换的全连接层,一般出现在特征维度本来就不大的场景,或者作为"过渡层"做维度对齐。
维度对齐这个用途值得单独提一下。多模态模型里经常要把不同来源的特征拼在一起,比如一路是 768 维、一路是 512 维,要融合就得先投影到同一个维度。这时候用的就是线性层,而且这种投影层通常不带激活函数,因为它只是为了对齐空间,不是为了引入非线性。
3.2 卷积的 im2col 视角:它一直是线性变换
很多人学卷积的时候觉得它是另一种东西,跟线性层并列。其实卷积就是线性变换,只不过权重有约束(局部连接、空间共享)。
把输入的一个局部窗口(比如 3×3×C_in)展平成一个向量,然后和卷积核展平后的向量做点积,得到输出的一个位置——这就是一次标准的线性变换。对整个输入做滑动窗口,每个窗口都乘同一个权重矩阵,这就是"权重共享"。用 im2col 的方式实现,就是把所有窗口展平成一个大矩阵,形状是 (输出位置数, C_in×K×K),然后一次性做矩阵乘法。
| 形态 | 等效线性变换的矩阵形状 | 约束 |
|---|---|---|
| 全连接 | (n_out, n_in) | 无约束,稠密 |
| 卷积 | (C_out, C_in×K×K) | 权重在空间上共享,感受野局部 |
| 分组卷积 | (C_out/g, C_in/g×K×K) 复制 g 份 | 通道间稀疏,块对角 |
| 深度可分离 | 逐通道 (1, K×K) + 逐点 (C_out, C_in) | 分解为两步 |
这张表能解释很多"为什么":为什么分组卷积参数少(矩阵变成块对角,非对角块全是零,不用存),为什么深度可分离卷积更省(把一个大矩阵分解成两个更瘦的矩阵,参数量从 C_out×C_in×K² 降到 C_in×K² + C_out×C_in)。
理解"卷积是线性变换"这件事的实际价值在于:你可以在框架里用全连接层实现卷积,也可以用卷积层实现全连接(把输入当成 1×1 的空间尺寸)。调试的时候如果怀疑某个卷积实现有问题,用手写的矩阵乘法版本对照一下,往往能很快定位。
3.3 嵌入矩阵:one-hot 的线性变换,但别真去做乘法
前面提过,嵌入层是 one-hot 向量乘矩阵。这里再往下挖一层:既然它是线性变换,那它就有线性变换的所有局限——嵌入矩阵学到的表示,任意两个词向量的差是一个固定方向,意味着"词向量之间的类比关系"本质上是线性结构。经典的那个"国王 - 男人 + 女人 ≈ 女王"的例子,成立的前提是这种线性结构在训练中被捕捉到了。
但也正因为它是纯线性的,嵌入层单独用的时候表达能力有限。一个词的表示完全由它在矩阵里的那一行决定,跟上下文无关。要让它"看懂上下文",后面必须接非线性的结构(注意力或者前馈层)。这就是为什么早期用静态词向量 + 简单分类器的方案,效果天花板明显低于上下文相关的方案。
实操上有个坑:嵌入层的梯度是稀疏的。一个 batch 里只出现了一小部分词,其他词的梯度是零(或者说不参与更新)。如果你的优化器带动量或者二阶统计,稀疏梯度会让这些统计量失真。有些框架提供了稀疏梯度模式,打开之后只更新被激活的行,能省不少算力,但要确认优化器支持。我踩过的坑是:打开稀疏模式之后,动量项的表现和稠密模式对不上,同一个模型两种模式的收敛曲线差异挺明显,最后是关掉稀疏模式、改用稠密更新才对齐的。
3.4 注意力里的 Q/K/V 投影:三个线性变换撑起半边天
注意力机制看着复杂,拆开看核心就是三次线性变换加一次加权求和。输入 X(形状 序列长度 × d_model),分别乘三个矩阵 W_q、W_k、W_v,得到 Q、K、V,形状都是 序列长度 × d_head。
参数量算一下:假设 d_model = 768,单头情况下三个矩阵各 768×768,加起来约 1.77M。多头的话,如果每个头的维度是 d_model/h,h 个头加起来的总维度还是 d_model,参数量不变。所以"多头"不增加参数,它增加的是并行的子空间数量——每个头在自己的低维子空间里做一次独立的线性投影,学不同的关系模式。
这里有个常被忽略的点:Q 和 K 的投影矩阵不需要是方阵。你可以让 d_head 比 d_model/h 小,做降维;也可以做大,做升维。降维能省算力,因为注意力的计算量是 O(序列长度² × d_head),d_head 减半,注意力那部分计算量就减半。这就是为什么很多高效注意力变体会在 Q/K 上做低秩投影。
注意:W_q 和 W_k 在数学形式上完全对称,但它们的梯度行为不一样。因为 Q 乘 K 转置之后要过 softmax,softmax 的梯度会让 K 那一侧的更新更"平滑"(被归一化了),而 Q 那一侧保留了更多的原始尺度信息。有些实现里会对 Q 和 K 用不同的初始化缩放,就是这个原因。这个细节在论文里往往不写,但在小模型上实测会有可感知的差异。
4. 参数量、显存与初始化:上线前必须算清楚的三笔账
4.1 参数账:词表嵌入层吃掉多少
假设你要做一个中等规模的模型,配置是 12 层、隐藏维度 768、词表 50000。粗略算一下:
- 每层注意力:Q/K/V/O 四个 768×768 矩阵,约 2.36M
- 每层前馈:768→3072→768 两个矩阵,约 4.72M
- 每层合计约 7.08M,12 层约 85M
- 嵌入层:50000×768 = 38.4M
- 输出投影(如果不共享权重):768×50000 = 38.4M
总计约 162M 参数。其中嵌入和输出投影加起来 76.8M,占了 47%。这个比例很说明问题:在小模型里,词表相关的线性层往往比主干还占地方。如果做权重共享(输入嵌入和输出投影用同一个矩阵),能直接省掉 38.4M,接近总参数的四分之一。
存下来要多少显存?fp32 是 162M×4 = 648MB。训练的时候还要存梯度(又一份 648MB)、优化器状态(Adam 存一阶和二阶动量,两份 648MB,如果不用混合精度还要存 fp32 的主权重副本,再加一份)。粗算下来训练时显存占用是参数量的 16 倍左右,也就是 10GB 上下,还没算激活值。这就是为什么同样的模型,推理能在消费级显卡上跑,训练就不行。
4.2 初始化账:为什么不能全零,缩放因子怎么来的
全零初始化的问题很好理解:所有神经元的权重一样,梯度也一样,更新之后还是一样,整个层退化成"一个神经元复制 n 份"。这叫对称性没打破。
那小一点的随机数行不行?比如全部用 N(0, 0.01)?这就是经典的"小随机数陷阱"。前向传播的时候,如果每一层的输出方差都比输入小一点,层数一多,信号就衰减到零附近;反向传播的梯度同理,传几层就没了。反之如果初始化太大,方差逐层放大,几层之后就溢出成 inf 或者 NaN。
正确的做法是让每一层的输出方差约等于输入方差。推导一下:设输入 x 的各分量独立、均值 0、方差 σ_x²,权重 W 的元素独立、均值 0、方差 σ_w²,且 W 与 x 独立。那么输出的第 i 个分量
y_i = Σ_j W_ij x_j
它的方差是 Var(y_i) = n_in · σ_w² · σ_x²(因为 n_in 个独立项相加,每项的方差是 σ_w²·σ_x²)。要让 Var(y) = Var(x),需要 σ_w² = 1/n_in,也就是 W 的标准差取 1/√n_in。这就是最基础的缩放原则。
Xavier 初始化考虑得更细一点:它同时要求反向传播时梯度方差也不变,两个条件一综合,得到 σ_w² = 2/(n_in + n_out)。如果用的是均匀分布 U(-a, a),方差是 a²/3,令 a²/3 = 2/(n_in+n_out),解出 a = √(6/(n_in+n_out))。
He 初始化是给 ReLU 专门调的。因为 ReLU 会把负的一半砍掉,输出的方差大约减半,所以需要把权重的方差翻倍补偿,得到 σ_w² = 2/n_in。
| 初始化方法 | 权重标准差 | 适用激活 | 依据 |
|---|---|---|---|
| 简单缩放 | 0.01 固定 | 无(错误做法) | 无理论依据,靠运气 |
| Xavier 均匀 | √(6/(n_in+n_out)) | tanh、sigmoid | 前向反向方差都守 |
| Xavier 正态 | √(2/(n_in+n_out)) | tanh、sigmoid | 同上,正态版本 |
| He 正态 | √(2/n_in) | ReLU 及其变体 | 补偿 ReLU 砍掉的一半 |
提示:偏置一般初始化为 0,这是安全的。但如果你的激活是 ReLU 并且网络很深,把偏置初始化成一个很小的正数(比如 0.01)能让更多神经元在初期保持激活,梯度不至于一开始就大面积断流。这个技巧不常用,但在某些难训的结构上确实有效。
4.3 精度账:半精度下的溢出与累加误差
线性变换是矩阵乘法,矩阵乘法的中间累加过程在低精度下很容易出问题。
先说溢出。fp16 能表示的最大值约 65504。如果你有一个 4096 维的点积,每个元素的量级是 1,那么结果量级是 64 左右,没问题。但如果权重初始化偏大,或者某一层输入的量级到了 100,点积结果可能上万,再乘几层就溢出成 inf 了。inf 一旦出现,反向传播会把它变成 NaN,整个训练直接崩。
解决办法是混合精度训练:权重和激活用 fp16 存(省显存、快),但矩阵乘法的累加在 fp32 里做,算完再转回去。主流框架都有对应的自动机制,你需要做的是确认它真的在起作用(比如看有没有梯度缩放,因为 fp16 的梯度很容易下溢成 0,需要先放大再缩小)。
再说累加误差。fp16 的尾数只有 10 位,能精确表示的整数只到 2048。当累加结果超过这个范围,小的加数会被"吃掉"。一个 4096 维的点积,如果前 2048 项累加到了 2048,后面那些量级是 1 的项就加不进去了。这也是为什么累加必须用 fp32。
还有个容易忽略的点是归一化层之前的线性层。归一化会减去均值、除以标准差,如果线性层的输出本身就已经在溢出边缘,减均值这一步可能引入更大的误差。经验做法是在线性层之后、归一化之前,检查一下输出的数值范围,正常应该在个位数到几十之间,如果到了几百上千,说明初始化或者学习率有问题。
5. 反向传播里的线性层:梯度形状与病态矩阵
5.1 三个梯度的推导与形状自检
线性层 y = Wx + b 在反向传播时要算三个梯度:对 W、对 b、对输入 x。
已知上游传来的梯度 dL/dy,形状和 y 一样是 (batch, n_out):
- dL/db = dL/dy 在 batch 维度上求和,形状 (n_out,)
- dL/dW = (dL/dy)^T · x,形状 (n_out, batch) × (batch, n_in) = (n_out, n_in),和 W 一致
- dL/dx = dL/dy · W,形状 (batch, n_out) × (n_out, n_in) = (batch, n_in),和 x 一致
这三个式子的形状自检是最实用的调试手段。任何一次手写反向传播,写完先不看数值,先对形状。形状一致再跑数值梯度检验(数值梯度的思路是:把某个参数微调一个极小的量,看 loss 变化多少,和解析梯度对比,相对误差应该在 1e-6 量级)。
这里有个容易写错的地方:dL/dW 里到底是 (dL/dy)^T · x 还是 x^T · dL/dy。取决于你的约定。如果 x 的形状是 (batch, n_in),那必须是前者,因为矩阵乘法要求内维对齐:(n_out, batch) 乘 (batch, n_in)。如果写成后一种,形状是 (n_in, batch) 乘 (batch, n_out) = (n_in, n_out),正好是 W 的转置,某些情况下不会报错(如果 n_in = n_out),但数值全错。这是最阴险的一类 bug:形状对得上,数值是错的。
5.2 条件数、病态与"梯度消失"的真实来源
矩阵的条件数定义为最大奇异值除以最小奇异值。条件数越大,矩阵越"病态",意味着输入的微小扰动会被放大成输出的巨大变化。
放到线性层里,病态的 W 会带来两个后果。前向上,输入的一点点噪声被放大,输出不稳定;反向上,梯度经过 W^T 时被同样的比例放大或缩小,导致某些方向的梯度爆炸、某些方向消失。很多人说的"梯度消失",如果排除了激活函数饱和的原因,剩下的很大一部分就是权重矩阵本身病态了。
什么情况下 W 会病态?最常见的是输入特征高度相关(多重共线性)。比如你有两个特征,本质上测的是同一个东西,只是单位不同,那么 W 对应的两列会几乎线性相关,矩阵接近奇异。这时候训练会变得很不稳定,loss 可能在某几个值之间反复跳。
缓解手段有几种。一是数据层面做去相关(主成分分析、白化);二是加正则化,L2 正则相当于给 W^T W 加一个 λI,把最小奇异值抬起来,条件数自然下降;三是用归一化层,把每层的输入尺度标准化,间接改善条件数。第三种在实际中最常用,因为它不依赖你对手头数据的先验理解。
注意:条件数不是越小越好。条件数等于 1 的矩阵是正交矩阵(或者正交矩阵的倍数),它保长度、保角度,但表达能力受限——你只能做旋转和反射,不能做拉伸。适度的条件数意味着网络学到了"某些方向比另一些方向更重要",这是有用的信息。所以在做权重分析的时候,看到条件数是几百甚至上千,不用急着"修正",先看看任务指标是不是正常。
5.3 低秩分解:把一个大矩阵拆成两个瘦矩阵
如果一个大矩阵是(近似)低秩的,那么它可以分解成两个瘦矩阵的乘积:W(n_out × n_in)≈ B(n_out × r)· A(r × n_in),其中 r 远小于 n_in 和 n_out。
参数量从 n_out × n_in 降到 r(n_out + n_in)。举个数:n_in = n_out = 4096,r = 8,原参数 16.8M,分解后 8×(4096+4096) = 65.5K,差了 256 倍。计算量同理大幅下降。
这种技巧有两个典型用途。一是微调大模型的时候冻结原权重,只训练分解出来的两个小矩阵,这样显存和算力需求都降下来了,而且因为原模型被冻结,不会出现灾难性遗忘。二是推理加速,如果权重矩阵本身学出来就接近低秩(这在大模型里很常见),可以在部署前做一次分解,用两个小矩阵代替一个大矩阵,精度损失很小。
选择 r 的方法:从一个小值开始(比如 4 或 8),逐步增大,观察验证集指标。经验上,r 取到原维度的 1% 到 5% 通常能覆盖大部分收益。如果想更科学一点,对原矩阵做奇异值分解,看奇异值的衰减曲线——如果前 r 个奇异值占了总和的 90% 以上,那 r 就够用了。
有个细节要提醒:分解之后的两个矩阵不能都初始化为零,否则梯度也是零,永远学不动。标准做法是其中一个用随机初始化(或者高斯),另一个初始化为零,这样初始状态下 ΔW = 0(保持原模型行为),但有梯度可以流动。这个设计是低秩微调能生效的关键,写反了或者两个都置零,你会看到 loss 一动不动,还以为是学习率的问题。
6. 工程细节与排错清单
6.1 转置约定不一致导致的静默错误
不同的代码库对权重的存储约定不一样。有的存成 (n_out, n_in),前向时算 x·W^T;有的存成 (n_in, n_out),前向时算 W·x。从数学上都对,但混用就会出问题。
危险的地方在于:如果 n_in = n_out,转置之后形状完全一样,形状检查通不过去。这时候你会得到一个能跑、能收敛、但精度莫名低一截的模型。我曾经用一个预训练权重做迁移,加载之后指标比重训练还差,查了两天才发现是权重矩阵需要转置——因为源库和目标库的约定不同,而两个矩阵正好都是 768×768,加载时的形状校验形同虚设。
防这类问题的做法有三条。第一,加载权重之后做个一致性检查:如果可能的话,用同一个输入分别跑源实现和目标实现,对比输出的数值。第二,在代码里把约定写死并加注释,不要依赖"我记得是这样"。第三,如果做的是跨库迁移,先拿一层做单层对比,确认数值一致再整体加载。
6.2 推理时的 bias 融合与量化
部署阶段最常见的两个优化都和线性层有关。
第一个是归一化层的折叠。如果一个线性层后面跟一个批归一化,推理时可以把它整层吸收进线性层:原来的 y = γ(Wx + b - μ)/σ + β,可以改写成 y = W'x + b',其中 W' = γW/σ(按通道逐行缩放),b' = γ(b - μ)/σ + β。这样推理时就少了一次归一化的计算和内存访问。注意这个折叠只在推理时成立,训练时归一化的统计量在变,折进去就错了。
第二个是权重量化。把 fp32 的权重压成 int8,存储直接省到四分之一。做法是先统计权重的最大值,算出缩放因子 s = max(|W|)/127,量化时 w_int8 = round(W/s),反量化时 W ≈ w_int8 × s。计算的时候可以把权重反量化回 fp16 再做矩阵乘(叫 weight-only 量化),也可以让激活也量化、用整数运算(叫全量化,速度和精度都要另算)。
| 优化手段 | 收益 | 代价 | 适用阶段 |
|---|---|---|---|
| 归一化折叠 | 减少算子数、减少内存访问 | 只推理可用 | 部署 |
| 权重 int8 量化 | 显存降到 1/4,带宽压力小 | 精度可能掉零点几个点 | 部署 |
| 低秩分解 | 参数和计算量降一到两个数量级 | 需要重新训练分解矩阵 | 微调、部署 |
| 权重共享(嵌入/输出) | 省一半词表相关参数 | 表达能力略降 | 训练前设计 |
量化的坑主要在离群值上。权重分布通常近似正态,但总有少数几个值特别大。如果按全局最大值算缩放因子,大部分值会被压到很窄的区间里,精度损失严重。解决办法是分组量化(每 64 或 128 个元素一组,各自算缩放因子),或者对离群值单独处理。实测下来分组量化对精度的保护很明显,分组粒度越细越好,但元数据开销也越大,128 是个比较平衡的选择。
6.3 一张排查表
最后把线性变换相关的常见问题整理成一张表。这张表是我自己遇到问题时会先扫一遍的,大部分情况能直接定位。
| 现象 | 最可能的原因 | 怎么确认 |
|---|---|---|
| 加层数没效果,loss 曲线和浅层一样 | 层间缺激活函数 | 打印中间层输出,看是否共线 |
| 训练一开始就 NaN | 初始化太大导致溢出 | 统计每层输出的最大值,超过 1e4 就要警惕 |
| loss 卡住不降,梯度接近零 | 初始化太小或激活全灭 | 看每层激活值的均值,接近零说明大面积失活 |
| 精度比预期低一截,找不到原因 | 权重转置约定不一致 | 单层数值对比,加载权重后跑一次前向核对 |
| 显存不够,但模型参数量明明不大 | 优化器状态和激活值没算进去 | 用 16 倍参数量粗估训练显存 |
| 同样的模型换个框架结果不同 | 初始化方式或数值精度不同 | 固定随机种子,逐层对比输出 |
| 微调时 loss 完全不动 | 低秩分解的两个矩阵都初始化为零 | 检查其中一个是否随机初始化 |
这张表里最后一行值得多说一句。低秩微调的标准做法是 A 随机初始化、B 置零,这样初始增量是零但有梯度。如果两个都置零,梯度也是零(因为 dL/dA 里带 B,dL/dB 里带 A,都是零),整个模块就死了。我第一次用这种方案的时候就是这么写的,跑了一晚上指标纹丝不动,第二天早上才发现问题。这种"能跑但学不动"的 bug 比直接报错难查得多,因为它不给你任何错误信号。
我个人在实际操作中的体会是:线性变换这一层,最值得花时间的是"先算清楚再写"。参数量、计算量、形状、初始化缩放,这四个数在下笔前手算一遍,能省掉后面 80% 的调试时间。剩下 20% 是数值精度和转置约定这类"形状对但数值错"的问题,只能靠单层对比和数值梯度检验来兜底。这个习惯看起来笨,但它是我见过最省时间的做法——比起在几百层网络里找一个维度错误,两分钟的手算实在太划算了。