1. 线性变换在神经网络里到底是块什么砖1.1 把一行公式拆开y xWᵀ b 里藏着什么线性变换更准确地说是仿射变换是神经网络里出现频率最高、却又最容易被当成一行代码带过的东西。我见过太多人写模型时对nn.Linear(512, 256)这类调用毫无障碍但一旦被问到这层到底在做什么、参数量怎么算、为什么初始化要用那个分布就答不上来。这篇就把这块砖从里到外敲一遍。先看最朴素的形式。输入一个向量 x长度是 in_features输出一个向量 y长度是 out_features中间隔着一个权重矩阵 W 和一个偏置向量 by x W.T bW 的形状是(out_features, in_features)b 的形状是(out_features,)。为什么框架里存的是(out, in)而不是直觉上的(in, out)然后转置着用纯粹是工程上的历史惯性——这样存前向计算时对权重做转置就能直接跟列向量做乘法在底层 BLAS 调用里切分更顺。你写代码时记住权重矩阵的每一行对应一个输出神经元就不会绕晕。批处理的时候x 变成(B, in_features)乘法结果自然是(B, out_features)b 沿着第 0 维广播。这一步广播很多人没细想过它不是每个样本加一次偏置这种循环语义而是底层直接把 b 复制成(B, out_features)的形状参与运算实际内存里不会真的复制但概念上要清楚否则你在做自定义算子时会算错梯度。再往深一层y xWᵀ b的本质是把输入空间的一组坐标重新投影到另一个坐标系下。W 的每一行是一个探测方向输出向量的第 i 个分量就是输入 x 在这个方向上的投影长度加上偏移。所以一个线性层做的事可以概括成用 out_features 个方向去打量输入把打量结果打包成新特征。这个视角比矩阵乘法有用得多后面讲特征重组、讲低秩分解都要靠它。还有一点必须强调线性变换不会改变数据的线性相关性结构。输入里如果有两个维度完全共线输出的对应维度也一样共线只是被拉伸或旋转了。这直接决定了单个线性层的表达能力上限也是为什么必须往中间插非线性。1.2 为什么非得用线性层来承载特征重组有人会问既然卷积、注意力这些结构本质上也在做加权求和那全连接层存在的意义是什么。我的理解是它们都是线性变换的不同稀疏模式全连接层是其中最暴力、最不挑输入结构的一种。具体对比一下。全连接层对输入没有任何假设每个输出都跟所有输入相连权重矩阵是稠密的参数量是 in×out。卷积层假设输入有空间局部性权重共享参数量只跟卷积核大小和通道数有关。注意力机制则更进一步权重不是学出来的固定值而是根据输入动态算出来的所以叫数据相关的线性变换。这三者的取舍逻辑很清楚当你知道输入的结构先验比如图像相邻像素相关就用稀疏/共享的连接方式省参数当你完全不知道或者需要跨位置混合信息时才动用全连接。这也是为什么视觉骨干网络最后几层通常还是接全连接或者用全局池化 全连接因为到了最后阶段需要的是全局的特征重组而不是局部感受野。我在实际项目里踩过一个坑在一个序列任务里为了增强全局交互我在每个时间步后面加了个大维度全连接层结果参数量直接从百万级涨到千万级训练三个 epoch 就开始过拟合验证集指标不升反降。后来换成沿特征维做的小型线性投影也就是常说的 1x1 卷积或逐位置线性层参数量降了两个数量级效果反而更好。这个教训让我明白一个道理全连接这个词的重点不是全而是混合混合的范围该多大取决于你的数据里哪些维度之间真的需要交互。1.3 它的能力边界秩、可逆性与堆叠的等价性线性层有几个硬性限制理解了这些你就知道什么时候该往上加东西。第一是秩的限制。W 的秩最多是 min(in_features, out_features)。如果 out_features 比 in_features 小那这层就是降维信息必然有损如果反过来虽然有升维但输出的所有向量都落在一个 in_features 维的子空间里多出来的维度是冗余的。这解释了一个常见现象模型中间层宽度加得很大性能却不一定涨因为从上一层的视角看新加的那些维度不是独立的新信息。第二是可逆性。只有当 W 是方阵且满秩时线性变换才可逆。一旦降维你就永远无法从输出精确还原输入。这个性质在做自编码器、做特征压缩时是核心约束——你压缩得越狠重构损失越大别指望一个线性瓶颈能无损保存所有细节。第三是堆叠的等价性。两个连续的线性层y (xW₁ᵀ b₁)W₂ᵀ b₂展开后仍然是x(W₁W₂)ᵀ (b₁W₂ᵀ b₂)也就是一个线性层。这个结论听起来像是废话但它意味着不管你把线性层堆多深只要中间没有非线性整个网络的表达能力就等价于单层。这也是早期神经网络研究走过的一段弯路——堆深度却不加激活函数等于白堆。所以线性的定位很明确它是可学习的特征重组器负责把信息挪到合适的位置、合适的尺度上但制造新的判别边界这件事得交给非线性激活来完成。两者是分工关系不是替代关系。2. 维度与参数量动手算一遍比看十遍推导管用2.1 形状约定把 batch 维当成免费的新手最容易在形状上翻车我的建议是先把约定立死。以二维输入为例形状是(B, D_in)B 是批大小D_in 是特征维度。线性层权重的形状是(D_out, D_in)偏置形状是(D_out,)输出形状是(B, D_out)。这套约定里B 永远在最前面而且参数量跟 B 无关——这是线性层最重要的一个工程性质也是为什么它能高效批处理。但三维、四维输入怎么办比如(B, T, D_in)的序列张量框架的线性层默认是作用在最后一维上的输出(B, T, D_out)中间那维 T 被当成批的一部分处理。这个概念叫把额外的维度折叠进 batch。我从实践中总结的经验是只要线性层不跨维度混合它就不关心前面有几维一旦你想让它跨维度混合比如把 T 个时间步的信息揉在一起就必须先 reshape 成(B, T×D_in)再喂进去否则形状会直接报错。这里有个很多人踩过的细节view和reshape的区别。如果你的张量经过转置等操作变成非连续内存view会报错reshape会先复制一份再变形。多卡训练时这种隐性复制可能导致意料之外的显存峰值。我一般的做法是能不用转置就不用必须转的时候顺手.contiguous()然后把 shape 打印出来确认不用猜。2.2 参数量与显存手算一遍账本来实际算几个数。一个Linear(512, 256)参数量是 512×256 256 131,328约 13 万。看起来不多但注意这只是一层。一个 12 层的 Transformer中间维度 768前馈层通常先把 768 升到 3072 再降回来这两层的参数量是 768×3072×2 ≈ 472 万除以 12 层就是 5600 万占了模型参数的绝大部分。很多人以为注意力是参数大户其实前馈里的两个线性层才是。再看显存。参数量乘上数据类型字节数FP32 下 13 万参数约 524 KBFP16 下约 262 KB。但显存开销远不止权重本身训练时还要存梯度和优化器状态。以常见的 Adam 为例每个参数需要存一份权重、一份梯度、一阶动量、二阶动量FP32 下就是 16 字节/参数。所以 13 万参数的一层训练时实际占用约 2 MB。这个 16 倍的关系建议背下来估算模型能不能塞进显存时特别管用。激活值中间输出的开销反而经常被忽略。一个(B, D_out)的输出张量FP32 下占B × D_out × 4字节。B64、D_out4096 时就是 1 MB一层一层累起来在训练时还要为反向传播保存量级很容易超过权重本身。我做过一个粗略的对比同样一个网络batch 从 8 调到 64权重显存纹丝不动激活显存涨了 8 倍直接触顶。项目计算方式示例D_in512, D_out256, B64, FP32权重参数量D_in × D_out D_out131,328权重显存参数量 × 4 字节约 525 KB单次激活输出显存B × D_out × 4 字节约 65 KB训练态总开销Adam参数量 × 16 字节约 2.1 MB2.3 偏置项到底该不该留biasTrue是默认值但实际项目里我经常把它关掉。原因有三。其一如果线性层后面紧跟归一化层批归一化、层归一化之类偏置的作用会被归一化操作完全抹掉因为归一化会减均值。这种情况下留着偏置纯属浪费参数和一次加法。我在做轻量模型时会习惯性地把归一化前面的偏置全部关掉参数量能省一点推理时也少一次访存。其二如果两个线性层直接相连且中间没有非线性那第二个层的偏置可以和第一个层的偏置合并重复的部分没有意义。当然这种结构本身就不该出现属于设计失误。其三只有在线性层是网络的最后一层或者后面紧跟非线性但输入本身均值不为零的时候偏置才真正发挥作用。比如回归任务的输出头偏置能直接学到目标值的均值偏移这时候必须留。提示不要因为默认是 True就一直开着。在几十层的网络里偏置带来的参数增量虽然不大但每一次多余的加法在高吞吐推理场景下都是实打实的成本。3. 从零实现一个线性层不写一遍永远不算懂3.1 NumPy 版本前向、反向、一次写全我建议每个想真正吃透线性变换的人都手写一遍不看任何自动微分。下面这段是能直接跑的import numpy as np class Linear: def __init__(self, in_features, out_features, biasTrue): # 用缩放后的正态分布初始化后面会解释为什么这么选 limit np.sqrt(6.0 / (in_features out_features)) self.W np.random.uniform(-limit, limit, (out_features, in_features)) self.b np.zeros(out_features) if bias else None self.cache None def forward(self, x): # x: (B, in_features) self.cache x out x self.W.T if self.b is not None: out out self.b return out def backward(self, grad_out): # grad_out: (B, out_features) x self.cache self.grad_W grad_out.T x # (out, in) self.grad_b grad_out.sum(axis0) if self.b is not None else None grad_x grad_out self.W # (B, in) return grad_x三行核心代码值得逐行拆解。grad_W grad_out.T x形状是(out, B) (B, in) (out, in)正好跟 W 对齐这一步把所有样本的梯度累加了起来所以不需要再除以 batch——除不除取决于你的损失函数怎么定义如果损失是求和就用这个如果是求平均就在损失那一步除。grad_b沿 batch 维求和因为前向时 b 被广播了 B 次反向时自然要把 B 份梯度收回来。grad_x grad_out self.W形状(B, out) (out, in) (B, in)这一步是把梯度回传给上一层注意这里用的是 W 而不是 Wᵀ很多人第一次写会搞错推导一下维度就清楚了。我个人的习惯是每写一个自定义算子先在纸上把每个张量的形状写成注释再写代码。这个习惯让我少犯了无数低级错误尤其是在写多头注意力那种多维张量满天飞的地方。3.2 梯度校验数值方法是最靠谱的裁判手写反向传播最大的风险是写错了但看起来能跑。梯度校验是必须做的一步原理很简单用数值微分近似导数跟解析梯度比对。def numerical_grad(f, x, eps1e-6): grad np.zeros_like(x) it np.nditer(x, flags[multi_index]) while not it.finished: idx it.multi_index old x[idx] x[idx] old eps f_plus f(x) x[idx] old - eps f_minus f(x) x[idx] old grad[idx] (f_plus - f_minus) / (2 * eps) it.iternext() return grad关键在 eps 的选取这也是我踩过的坑。eps 太大截断误差会让近似不准eps 太小浮点数的舍入误差会吃掉信号。FP64 下 1e-6 左右比较稳FP32 下建议用 1e-3 到 1e-4。另一个坑是如果你用 FP32 做校验相对误差能到 1e-3 就已经算通过了别拿 1e-8 当标准那是 FP64 的门槛。我见过有人因为 FP32 下校验误差 1e-5 就怀疑自己写错折腾了大半天其实完全没问题。还有一个细节校验前先把权重缩放到较小的量级。W 太大的话前向输出会爆炸数值微分直接被淹没。我一般会把随机初始化的权重乘个 0.1或者干脆用小整数矩阵手工构造算例更容易定位问题。3.3 框架里的对应关系nn.Linear 到底等于什么主流深度学习框架里的nn.Linear(in, out)内部存的就是形状为(out, in)的权重和形状为(out,)的偏置前向等价于x W.T b跟上面手写的一模一样。区别在于框架会自动处理三维及以上的输入默认作用在最后一维并且用高度优化的矩阵乘实现。这里有两个容易混淆的地方。一是F.linear(x, W, b)这种函数式调用它接收的 W 形状是(out, in)而torch.matmul(x, W.T)里 W 是转置过的两者结果一致但用法不同混用会导致形状报错。二是有些框架的老版本里某些层存的是(in, out)转置关系刚好反过来做权重迁移比如从别的框架导入预训练参数时必须核对清楚否则模型能跑但结果全错——这种错误极其隐蔽因为不报错。注意做权重导入时一定要用一个已知输入做前向比对。只核对形状是不够的(512, 256)和(256, 512)在方阵情况下根本看不出差别而那恰恰是最容易出问题的场景。4. 权重初始化线性层里最容易被忽略的一环4.1 为什么全零初始化是灾难如果所有 W 都初始化为 0那么同一层里所有输出神经元的输出完全相同反向传播时它们收到的梯度也完全相同更新之后依然完全相同。这就是所谓的对称性无法打破一层里几百个神经元退化成一个。数学上不是不能训练而是训练出来的模型等价于宽度为 1白白浪费参数。那全初始化为同一个非零常数行不行同样不行对称性还是没破。所以初始化必须引入随机性。但随机性怎么给就有讲究了。4.2 方差的推导为什么是 1/sqrt(fan_in) 这个量级考虑一层线性变换输出第 j 个分量是sum_i x_i * w_ji。假设 x 和 w 独立、均值为 0那么输出的方差是fan_in * Var(x) * Var(w)。如果 Var(w) 不随 fan_in 调整那么 fan_in 越大输出方差越大几十层堆下来直接爆炸。所以要维持方差稳定就得让Var(w) 1 / fan_in。这就是最朴素的方差缩放思路。两个经典方案在此基础上做了修正Xavier 初始化同时考虑前向和反向的方差稳定取2 / (fan_in fan_out)用的是均匀分布或正态分布。Kaiming 初始化针对 ReLU 这类会把一半输入置零的激活函数把方差再翻倍取2 / fan_in因为 ReLU 之后方差减半需要提前补偿。我在实际项目里的选择逻辑很简单后面接 ReLU 系列就用 Kaiming接 tanh/sigmoid 这类对称饱和函数就用 Xavier接归一化层的话两者差别不大。为什么不大了因为归一化层会把输出分布重新标准化初始化的方差偏差被它吞掉了。这也反过来解释了为什么现代网络对初始化不那么敏感——归一化帮了大忙。初始化方式方差设定适用场景注意事项全零0永不使用对称性无法打破小常数随机固定小值快速试验深层网络易消失Xavier2/(fan_infan_out)对称激活函数配 ReLU 时偏小Kaiming2/fan_inReLU 系列配归一化层时差别不大4.3 一次实测对比初始化到底影响多大我做过一个不算严谨但很有说服力的对比同样一个 8 层全连接网络宽度 256输入维度 128任务是一个简单的分类只改初始化方式其他超参完全一致。结果是用固定小常数初始化0.01 量级的那组第 1 个 epoch 损失几乎不动第 3 个 epoch 才开始缓慢下降最终收敛到一个明显更差的点用 Kaiming 的那组第 1 个 epoch 损失就掉得很快。差距在深层网络上更夸张——同样的对比放到 16 层小常数那组直接不收敛损失卡在高位纹丝不动因为信号在传播过程中被逐层压缩到几乎为零。这件事给我的启发是初始化不是调参玄学它是把信号方差维持在一个合理量级的工程手段。你不需要记住每个公式的推导但要记住让每一层的输出方差大致保持稳定这个目标遇到收敛异常时先检查初始化比盲目调学习率有效得多。5. 训练中线性层的常见故障与排查5.1 形状类报错一张速查表顶半天形状错误占了新手调试时间的一大半而且报错信息往往指向底层算子看不出实际问题。我整理了一张常用的对照表遇到报错先对号入座。报错关键词真实原因处理方式mat1 and mat2 shapes cannot be multiplied输入最后一维与 in_features 不匹配打印输入 shape确认是否漏了 flattensize mismatch for weight权重形状与层定义不一致核对 (out, in) 顺序方阵尤其要小心Expected 3D input, got 2D层要求带序列维补一个维度或改用作用在最后一维的写法view size is not compatible张量非连续或元素数对不上改用 reshape或先 contiguousgrad and param shape differ自定义反向传播写错了逐张量核对形状注释再说一个隐蔽的坑多维输入时忘记考虑批维度的语义。比如形状(B, T, D)的张量直接喂给一个期望(N, D_in)的层框架往往不会报错因为它把 B×T 折叠成了 N但你的语义可能已经错了——你以为是每个样本一个向量实际上变成了每个时间步一个向量。这种错误不会崩只会让结果莫名其妙地差。5.2 梯度问题线性层自己很少是元凶但经常是帮凶梯度消失和爆炸根子通常在激活函数和层数上但线性层是放大器。原因前面提过方差沿层数累积。如果每层的输出方差略微大于 120 层之后就是指数级放大略微小于 1就是指数级衰减。排查思路我总结成三步。第一步打印每层输出的统计量均值、标准差、最大绝对值看是从第几层开始失控的。正常情况下这些数值应该在同一量级波动如果某一层突然从 0.5 跳到 50那里就是问题点。第二步打印每层梯度的范数健康训练时各层梯度范数应该相近如果前面几层接近 0 而后面很大就是典型的消失。第三步才是动手改加归一化、换初始化、或者在最严重的地方插入残差连接。残差连接为什么有效从线性变换的角度看特别清楚y xWᵀ b x梯度回传时多了一条恒等路径grad_x grad_out W grad_out那个 grad_out保证了梯度至少有一份原样传回去不会被 W 的连乘压缩到零。这是我理解残差最简单也最本质的角度。5.3 参数量爆炸低秩分解为什么能救场前面算过前馈层的两个线性层占了模型参数的大头。如果想把模型变小最直接的办法就是降低中间维度但这会损失表达能力。折中方案是低秩分解不直接学一个(d, d)的大矩阵而是学两个瘦长的矩阵(d, r)和(r, d)r 远小于 d参数量从 d² 降到 2dr。这个思路的合理性在于很多任务上权重的有效秩远小于理论最大值。换句话说那个大矩阵里绝大部分方向是冗余的真正起作用的只有少数几个。我做过的实验里把中间维度压到原来的 1/4参数量降到 1/4因为两头乘以 r实际是 2dr / d² 2r/d性能只掉了不到一个点推理速度还涨了。微调场景里这个思路更常见冻结原有权重在旁边挂两个小矩阵学习增量参数量能压到千分之一级别。这套做法的前提假设就是增量是低秩的实践中大部分情况成立但不是所有任务都成立——如果你的新任务和原任务的分布差得特别远低秩的容量可能不够这时候还是老老实实全量微调。6. 工程实践矩阵乘的性能账本6.1 为什么小 batch 在加速卡上反而更慢线性层的计算量是B × in × out次乘加理论上 B 越小越快。但实测经常相反B1 时吞吐量远低于 B32。原因在于矩阵乘是访存密集型和计算密集型的分界点小 batch 时算子变成读一次权重只算一次权重读取时间远大于计算时间硬件算力严重闲置。这个现象给出的实操结论是推理时不要盲目把 batch 设成 1适当增大 batch 能显著提升吞吐。但也不是越大越好batch 太大时延迟会上升而且中间激活显存占用线性增长。我一般会做一个小规模的扫描固定输入长度把 batch 从 1 试到 64记录吞吐和延迟找拐点。这个测试十几分钟就能做完收益往往比调模型结构还大。另一个相关技巧是输入维度的对齐。很多矩阵乘实现在某些维度上会走特殊路径维度是特定数值的整数倍时性能明显更好。把维度从 250 改成 256参数量多一点速度可能快不少。这类经验没法从论文里学到只能靠实测积累。6.2 混合精度下线性层需要注意什么半精度训练能省一半显存、提升速度但线性层是数值问题的高发区。核心风险是累加溢出两个 FP16 数相乘的结果在小范围内没问题但几百项求和就可能超出 FP16 的表示范围。常见做法是乘法用半精度、累加用单精度框架里的矩阵乘通常默认这么处理你不需要手动干预但要知道这个机制的存在。真正需要手动处理的是损失缩放。梯度在 FP16 下容易下溢成 0解决办法是先把损失乘一个较大的系数反向传播得到放大后的梯度更新前再除回来。这个系数选小了没用选大了会溢出实践中常用动态调整框架里一般有现成开关。我踩过的一个坑是在混合精度下做权重初始化检查。单精度下正常的初始化转到半精度后如果数值范围不合适前几个 step 直接变成无效值。表现是损失变成 NaN但报错信息往往指向后面的层很难定位。后来我养成的习惯是切精度时先把前向跑一遍逐层打印输出范围确认都在安全区间内再开始训练。6.3 几个压箱底的小技巧最后分享几个在日常工作里反复用到的做法都很琐碎但省下来的时间不少。第一把 reshape 和线性层的顺序想清楚。很多模型的输入是多维的需要先展平再进线性层。展平的顺序决定了特征的排列方式一旦错了模型依然能训练只是学到的东西不是你想要的。我习惯在第一次跑通时打印展平前后的对应关系确认一下。第二权重合并用于推理。如果推理时某个线性层后面紧跟归一化可以把归一化的缩放和平移吸收进线性层的权重和偏置合并成一个等价的线性变换。这样推理时少一次算子调用对小模型的端到端延迟影响不小。合并的数学推导不复杂核心就是线性变换的复合还是线性变换但要注意归一化里如果有统计量相关的项得先把它折叠成常数。第三注意权重的内存布局。有些框架里权重是连续存储的有些是转置存储的做融合算子或者手写核函数时布局不匹配会导致额外的转置开销。我遇到过一个问题同样的算子换个内存布局速度差了接近一倍查了半天以为是并行策略的问题最后发现是权重没转置成期望的形式。第四梯度累积解决显存不足。想要大 batch 的效果但显存放不下可以把一个大 batch 拆成几个小批梯度累加后再更新。这个技巧对线性层特别友好因为它的梯度计算跟你分成几批做完全无关累加结果跟一次性算大 batch 是等价的除了归一化层的统计量会有细微差别。提示梯度累积时别忘了把学习率按累积步数相应调整或者在损失里除以累积步数否则等效学习率会变成原来的若干倍很容易训飞。我自己在这个主题上的一点体会是线性变换看起来是最没有技术含量的东西正因为如此它也是最容易被跳过、最容易被误用的东西。真正把这些细节搞明白之后我再去看任何一个新模型的结构图都会先问三个问题——这一层的输入输出维度是多少、参数量怎么算、它和前后层之间有没有重复的线性结构可以合并。这三个问题答清楚了模型的基本账本也就清楚了剩下的才是训练技巧的事。
阅读完成 · 觉得有帮助?