神经网络不收敛?从张量形状到反向传播的数学排查指南
简介:《Math for Deep Learning》(2022)是面向深度学习实践者与自学者的数学基础读物,针对“会调库但讲不清原理”的痛点,系统梳理理解神经网络所需的线性代数、概率统计、微积分与优化方法,并延伸至CNN、RNN、LSTM等模型背后的数学机制。压缩包内仅含1个PDF文件,约7.71MB,便于在电脑或平板上直接阅读与检索。已有83人学习。全书强调从基础概念逐步过渡到复杂模型,读者可据此补齐公式推导、梯度下降、模型评估等关键知识;对具备Python基础的读者,也可将抽象数学与代码实现相互印证,适合作为课程辅助、面试复习或项目攻关时的案头参考。
1. 神经网络跑不起来的时候,问题几乎总能化简成一个数学对象
用 python 搭一个两层网络、跑一遍梯度下降,如今十几行代码就够了,框架把矩阵乘法、反向传播和参数更新全藏进了黑盒子里。但黑盒子只在顺利时可靠:损失曲线横着不动、验证集准确率刚好等于随机猜测、换个批大小就崩,这类玄学时刻,会调包和懂原理的人立刻分成两类。深度学习数学要解决的就是这件事——把“模型不 work”从凭感觉猜,变成一条能推导的因果链。形状对不上、梯度爆炸、学习率失效,每个现象背后都对应一个确定的数学对象:张量的形状接口约定、雅可比矩阵的谱半径、梯度的方差。搞懂它们不是为了手推公式好看,而是让你只看一眼 loss 曲线,就能猜到是哪一层出了问题。
2. 张量与矩阵乘法:一次前向传播在内存里到底发生了什么
前向传播常被一句话带过——“输入乘权重加偏置”。可真要追问下去:为什么(32, 784) @ (784, 128)比手写双重循环快两个数量级?为什么形状从(784,)改成(1, 784)会让程序从报错变成静默算错?为什么卷积核是四阶张量而不是二阶?这些问题的答案不在框架文档里,在内存布局和线性代数的读法里。
2.1 形状是唯一的契约:阶数、数据类型与批次维的位置
标量是 0 阶,向量 1 阶,矩阵 2 阶,再往上统一叫张量。这个阶数不是学术分类,它直接对应内存里的一段连续字节加上一组步长(stride)。一个形状为(32, 784)的float32数组,在内存里就是 32×784 个 4 字节连续排列,第 0 维的步长是 784、第 1 维的步长是 1。所谓“批量维度放第 0 维”,本质是让同一列特征在相邻样本间跨度最大,避免逐样本取值时反复跳页,这是缓存友好的默认约定。
类型同样是接口约定的一部分。训练默认float32,因为 GPU 的算力峰值和显存带宽都按 32 位设计的;float64只在做数值验证、算特征值或求逆这类需要精度的场合才用。混合精度训练则是在矩阵乘法用 16 位、累加用 32 位,靠的是同一套形状接口约定下的类型提升规则。
注意:形状不匹配时框架会报错,但“能跑通”不代表形状是对的。
(32, 784)与(784,)相加会沿最后一维广播,结果仍是(32, 784);而(32, 784)与(32,)相加同样合法,却是把(32,)当成最后一维去对齐——这在特征维恰好等于批大小时会静默算错,是排查时最费时间的一类。
2.2 矩阵乘法的两种读法:行点积与列线性组合
同一个X @ W,至少有两种理解方式,缺一不可。
按行读:输出的第 i 行第 j 个元素,是 X 的第 i 行与 W 的第 j 列的点积。也就是说,每个输出特征是输入 784 个维度的一个加权和,权重就是 W 的一列。参数量公式由此而来:784 × 128 = 100352个权重加 128 个偏置。
按列读:输出等于 W 的各列按输入分量的加权求和,即y = x_1·W[:,0] + x_2·W[:,1] + …。这个视角直接给出一个结论——无论输入是什么,输出永远落在 W 的列空间里。列空间的维数最多是 min(784, 128) = 128,这就是低秩分解能压缩模型的根本原因:如果 128 维里有 100 维对最终 loss 贡献极小,把这些方向砍掉,输出几乎不变。奇异值分解和主成分分析之所以在深度学习里反复出现,就是因为它们能把这个“哪些方向重要”的问题变成一个可计算的特征值问题。
import numpy as np rng = np.random.default_rng(0) # 一个 batch:32 个样本,每个 784 维(展平后的灰度图) X = rng.standard_normal((32, 784), dtype=np.float32) # 全连接层:784 -> 128 W = (rng.standard_normal((784, 128), dtype=np.float32) * 0.01) b = np.zeros(128, dtype=np.float32) H = X @ W + b print(H.shape, H.dtype) # (32, 128) float32 print(W.nbytes / 1024, 'KB') # 392.0 KB,权重占的显存这段代码里的@不是语法糖,它调用底层 BLAS 的分块矩阵乘法:把大矩阵切成能塞进 L1/L2 缓存的小块,块内做寄存器累加,块外循环搬运。理论复杂度都是 O(n³),但分块版本的内存访问次数少一个数量级,这就是“同样的数学、不同的速度”的全部秘密。手写三层 for 循环慢,不是 python 慢,是每一轮内层循环都在等内存。
2.3 广播与 einsum:省掉循环之后,内存里发生了什么
广播的实质是“步长置零”。当(32, 128)加(128,)时,numpy 不会真的复制 32 份偏置向量,而是把偏置的批量维步长设为 0,读同一个地址 32 次。理解这一点,就能理解为什么广播既是便利也是陷阱:它永远从最后一维往前对齐,对齐成功就静默执行,不成功才报错。
# 多头注意力的打分矩阵:8 个头,序列长 16,每头 64 维 Q = rng.standard_normal((8, 16, 64), dtype=np.float32) K = rng.standard_normal((8, 64, 16), dtype=np.float32) S = np.einsum('btd,bdk->btk', Q, K) # (8, 16, 16) S2 = np.matmul(Q, K) # 同一件事,交给 BLAS print(np.allclose(S, S2, atol=1e-5)) # Trueeinsum的字符串就是索引记法:btd,bdk->btk表示对 d 这个被消去的维度求和,同时每个 b 独立计算,不正是在批量维度上做批处理。它在语义上完全等价于matmul,但性能通常略逊——matmul能直接下沉到 BLAS 的高度优化内核,而einsum需要先解析索引表达式再决定走哪条路径。写原型用einsum图省事可以,写进训练循环要换回matmul,这是踩过一次损失曲线莫名其妙变慢之后形成的习惯。卷积同理:(B, C, H, W)四阶张量的卷积,工程上大多被展开成一次大的矩阵乘法(im2col),所以真正跑的还是 BLAS。
3. 概率与统计:损失函数为什么长成那个样子
框架给了你一堆 loss,交叉熵、均方误差、KL 散度、对比损失。它们的公式差别很大,来源却是同一个——最大似然。搞清楚这条推导路径,你就能在遇到自定义损失时自己写对梯度,而不是照着别人的代码抄一遍再怀疑。
3.1 交叉熵不是拍脑袋:从伯努利似然到负对数似然
二分类的标签 y 取 0 或 1,模型输出 p 是“为正类”的概率。把这一次观测的似然写成p^y · (1-p)^(1-y),这个式子在 y=1 时退化成 p,在 y=0 时退化成 1-p,正好符合直觉。假设 N 个样本独立,总似然是连乘,连乘会下溢,取对数变连加,再取负号把“最大化似然”翻成“最小化损失”,得到L = -Σ[y·log p + (1-y)·log(1-p)]。这就是二元交叉熵,没有任何额外假设。
多分类只是把伯努利换成 categorical 分布,一个样本只在正确类别上取 1,于是L = -log p_y。softmax 的作用是把任意实数 logits 映射成概率单纯形上的一个点,它本身不是损失的一部分,只是为了让log p有定义。
def softmax(z): # 减去每行最大值:exp 的比值不变,但避免了 exp(1000) 溢出 z = z - z.max(axis=1, keepdims=True) e = np.exp(z) return e / e.sum(axis=1, keepdims=True) def cross_entropy_grad(logits, y): """返回 (loss, dlogits)。dlogits 的形状与 logits 完全一致。""" n = logits.shape[0] p = softmax(logits) # 只取正确类别的概率,加 1e-12 防止 log(0) loss = -np.log(p[np.arange(n), y] + 1e-12).mean() dlogits = p.copy() dlogits[np.arange(n), y] -= 1.0 # one-hot 减概率 dlogits /= n # 因为 loss 取了 mean return loss, dlogits最后两行是整段代码的价值所在:p - onehot(y)这个形式极度简洁,且数值稳定——当模型自信地预测错误时,p 接近 0、onehot 是 1,梯度接近 -1,是满幅度的修正信号;当模型自信且正确时,梯度接近 0,不再浪费更新。如果你手写的损失在分母上多做了一次归一化,或者把 mean 写成了 sum,梯度尺度会差 N 倍,表现就是“学习率怎么调都不对”。
3.2 批大小、方差与中心极限定理:梯度噪声从哪来
小批量梯度和全量梯度的关系,是一个统计学问题。设单样本梯度是随机变量 g,全量梯度是它的期望 μ,批大小 B 的批梯度就是 B 个样本的均值。中心极限定理告诉我们:只要单样本梯度的方差 σ² 有限,批梯度就近似服从均值 μ、方差 σ²/B 的正态分布。这条结论解释了三件事。
第一,批大小翻倍,梯度噪声降到原来的 1/√2,而不是 1/2。这就是为什么小批量训练的曲线总是毛糙的,而全批量训练的曲线光滑得像用尺子画的。
第二,噪声并非全是坏事。适量的梯度噪声能让模型跳出尖锐的局部极小,这也是小批量往往比全批量泛化更好的经典解释之一(当然这个说法有争议,工程上确实也观测到过反例——这属于典型的血泪经验:别把教科书结论当铁律用在你的任务上)。
第三,学习率需要跟批大小联动。梯度方差缩小 k 倍,等效于每步走得更“准”,可以把步长放大 k 倍保持相同的更新幅度,这就是线性缩放规则和经验上的平方根缩放规则。没有这个意识,换个批大小就重新调一遍学习率,属于在跟统计学较劲。
3.3 Pearson 与 Spearman:判断指标相关性时的两个陷阱
做实验时总要回答“这两个指标是不是一致”。Pearson 相关系数度量线性关系,Spearman 度量单调关系,两者的差距往往暴露数据里的异常值。如果模型 A 在 99% 的样本上比 B 差一点点,但在一两个样本上极端好,Pearson 会被这两个点拽高,Spearman 不会,因为它只看秩。
更隐蔽的陷阱是显著性检验的假设。t 检验要求样本近似正态、方差齐性,而准确率这类有界指标在小样本上明显偏离正态;Mann-Whitney U 检验放宽了分布假设但仍要求样本独立,如果用同一批验证样本反复评估多个模型,独立性就没了,p 值会系统性偏小。读到“显著提升”时,先问样本量多少、检验用了什么、有没有做多重比较校正,这三句话能挡掉大部分站不住的结论。
4. 矩阵微积分:反向传播到底在算什么东西
反向传播被讲成“链式法则”,这话没错但太轻。真正让人卡住的是布局约定、张量维度的对齐、以及为什么反向模式比前向模式便宜。这三件事任意一件没搞清,手写梯度时就只能靠打印形状碰运气。
4.1 布局约定:分子布局与分母布局为什么让人抓狂
标量对向量求导,结果是一个和自变量同形状的向量,这没争议。但向量对向量求导,结果是一个矩阵,那么这个矩阵的第 i 行第 j 列到底表示“输出 i 对输入 j”还是反过来?分子布局把输出维度放行,分母布局把输入维度放行,两者互为转置。同一本教材里混用,读者就会觉得公式对不上。
工程上的解法是绕开记法、只看形状。规则只有一条:梯度的形状必须和对应参数的形状完全一致。这条规则的威力在于它是可验证的——写完反向代码,把每个梯度和对应参数做一次assert grad.shape == param.shape,凡是能过这一关的实现,布局上就不会错。这比记清楚雅可比矩阵转不转置实在得多。
4.2 雅可比、海森与链式法则的矩阵形式
雅可比矩阵是向量函数的全部一阶偏导,形状是(输出维, 输入维)。一层全连接y = xW,x 是 D 维、y 是 H 维,那 ∂y/∂x 就是(H, D)的矩阵,恰好是 Wᵀ。所以反向传播里dh = dlogits @ W.T这一行不是凭感觉写的,它就是在乘雅可比的转置。
海森矩阵是标量函数的二阶偏导,形状(n, n),n 是参数个数。它对判断驻点的性质有决定性作用:特征值全正才是极小,有正有负就是鞍点。深度学习里的参数动辄百万,海森矩阵存都存不下,因此牛顿法这类二阶方法在主流训练里基本见不到——不是理论不好,是内存装不下。理解这一点,就理解了为什么整个领域被迫在一阶方法上反复迭代。
4.3 反向模式自动微分:为什么它比前向便宜一个数量级
自动微分不是数值微分(不靠(f(x+h)-f(x))/h,没有截断误差和相消误差),也不是符号微分(不生成表达式树,不会表达式爆炸)。它把计算过程记成一张有向无环图,每个节点存局部导数,然后沿图两个方向之一累积。
前向模式对每个输入维度都要跑一遍,成本是 O(n),n 是输入维数。反向模式对每个输出维度跑一遍,成本是 O(m),m 是输出维数。损失函数是标量,m=1,反向模式一次就能拿到全部梯度,成本只有一次前向的常数倍。神经网络训练恰好是“百万输入、一个输出”的结构,反向模式简直是为它量身定做的:这不是巧合,而是设计者刻意选择的计算方向。
def forward(x, W1, b1, W2, b2): z1 = x @ W1 + b1 h = np.maximum(z1, 0.0) # ReLU logits = h @ W2 + b2 return z1, h, logits def backward(x, y, z1, h, logits, W1, W2): n = x.shape[0] p = softmax(logits) dlogits = p.copy() dlogits[np.arange(n), y] -= 1.0 dlogits /= n dW2 = h.T @ dlogits # (H, N) @ (N, C) -> (H, C) db2 = dlogits.sum(axis=0) # (C,) dh = dlogits @ W2.T # (N, C) @ (C, H) -> (N, H) dz1 = dh * (z1 > 0) # ReLU 的导数就是一个 0/1 掩码 dW1 = x.T @ dz1 # (D, N) @ (N, H) -> (D, H) db1 = dz1.sum(axis=0) # (H,) for g, p_ in [(dW1, W1), (db1, b1), (dW2, W2), (db2, b2)]: assert g.shape == p_.shape, (g.shape, p_.shape) return dW1, db1, dW2, db2注意dz1 = dh * (z1 > 0)这一行:ReLU 的导数在正区间是 1、负区间是 0,用一个布尔掩码相乘就完成了逐元素求导,不需要任何 if 分支。这是“逐元素操作的导数仍是逐元素操作”的一个具体例子,也是为什么框架里逐元素算子能高度向量化的原因。另外注意所有梯度都是通过转置后的前向张量相乘得到的,这就是 4.2 里说的雅可比转置在实际代码里的样子——你从来不需要显式构造那个矩阵,只需要把矩阵乘的顺序摆对。
那组assert是我建议每个手写反向的人都加上的,它把“布局记错”这类错误从“训练不收敛”变成“程序直接崩在出问题的那一行”,排查成本差了两个数量级。
5. 排查清单:五个“数学没错但就是不收敛”的现场
公式推对了、代码跑通了、loss 还是不动,这时候最需要的是一套按顺序排除的清单。下面五条都是我在实际调试里反复遇到的,按“现象 → 原因 → 解决”记录。
5.1 前向与数据阶段的三个现场
现场一:训练 loss 平稳下降,验证集准确率始终约等于 1/类别数。原因通常不在模型,在数据对齐——打乱时只 shuffle 了特征没 shuffle 标签,或者特征和标签用了两个独立的随机数生成器,导致同一样本的特征配到了别人的标签。模型仍然能学,但它学的是“输入到随机标签”的映射,训练集能背下来,验证集必然等于瞎猜。解决方式是把特征和标签打包成一个索引数组,用同一个随机排列同时索引;或者干脆用一个 rng 贯穿整个流程。
现场二:第一个 batch 的 loss 就是 nan。八成是exp溢出或者log(0)。softmax 前不减最大值,logits 里只要有一个超过 88,exp在 float32 下就变 inf,inf/inf 是 nan。解决方式是 softmax 内部统一减行最大值(不改变数学结果,只改变数值路径),log 里加1e-12兜底。
现场三:loss 对某些 batch 剧烈抖动,整体趋势还在降。常见原因是输入没有归一化,某一维的取值范围是另外几维的几千倍,导致该维度上的梯度尺度远超其他维度,优化器在参数空间里走出一条锯齿路。解决方式是逐特征标准化(减均值除标准差),并把极值截断到合理分位。归一化不是“预处理的小技巧”,它直接改变梯度的条件数。
5.2 反向与优化阶段的另外两个现场
现场四:训练时指标很好,切换到推理模式立刻崩。出现这个现象基本可以锁定归一化层的统计量。训练时用的是当前 batch 的均值方差,推理时应该用累积的滑动统计,如果滑动统计没被更新(或者更新系数设得太小)而训练轮数又不够,推理时拿到的就是一个几乎等于初始值的统计量,输出自然全乱。排查方式是打印滑动均值,看它有没有随训练轮数向 batch 均值靠拢;同时确认推理前确实切换了模式。
现场五:学习率必须调到 1e-6 才不炸,但收敛慢得无法接受。两个高频原因:一是损失对 batch 求和而不是求平均,梯度随批大小线性放大,批大小从 32 换成 256,等效学习率放大 8 倍,直接进入发散区;二是初始化方差过大,第一层的激活值分布范围很宽,反向时逐层放大。解决方式是损失统一用 mean,批大小变化时按线性规则同步缩放学习率,再加一道梯度范数裁剪做保险。裁剪本身不解决根因,但它能把“偶尔炸一次”变成“稳定但略慢”,给你留出定位根因的时间。
6. 三种微分路径的取舍,以及为什么框架最终都选了反向模式
把数值微分、符号微分、自动微分摆在一起看,取舍就非常清楚了。数值微分只需一次函数求值,实现最简单,但步长 h 太小会因浮点相消丢精度、太大又引入截断误差,而且每个参数都要单独跑一次前向,百万参数就是百万次前向。符号微分能得到精确表达式,但表达式长度会随计算图规模指数膨胀,一个深度网络的反向表达式可能比整个模型还大。自动微分的反向模式把成本压到一次前向的常数倍,精度是机器精度,代价是需要记录计算图、占用额外内存。
| 方式 | 单次梯度成本 | 精度 | 是否支持控制流 | 典型用途 |
|---|---|---|---|---|
| 数值微分 | O(n) 次前向 | 受步长影响 | 支持 | 梯度校验、单元测试 |
| 符号微分 | 表达式膨胀 | 精确 | 受限 | 推导公式、小规模 |
| 自动微分·前向 | O(n) 次 | 机器精度 | 支持 | 输入维远小于输出维 |
| 自动微分·反向 | O(1) 次前向量级 | 机器精度 | 支持 | 神经网络训练 |
这张表也解释了为什么二阶方法在工程上退场:海森矩阵的规模是参数量的平方,就算用反向模式算海森-向量积,也需要额外一次反向传播,而收益(更快的收敛)在大规模非凸问题上并不稳定。于是整个领域退回到一阶方法加自适应步长,用动量、二阶矩估计这些手段去近似曲率信息。这不是理论上的最优解,是在内存和算力约束下的务实选择。
另一个值得琢磨的取舍是“手推公式还是交给框架”。手推的好处是你能写出针对特定结构的融合算子,省掉中间张量的显存;代价是任何一处布局写反都会变成沉默的错误。我的做法是两边都走一遍:先用框架的自动微分算出梯度,再用手写版本对比数值,误差在 1e-6 量级才算通过。这个流程慢,但它把“梯度对不对”这个最贵的假设变成了可验证的事实。
关于验证方法,还有一个成本极低但极其有效的做法:把完整模型退化成一层、两个样本、固定随机种子,然后对着纸上的公式逐元素核对。维度一多,人眼就看不出对错;维度降到 2×2,任何符号错误都藏不住。这属于后悔药——等你在大模型上发现不收敛再回头查,代价是几天;在玩具规模上先验证,代价是半小时。
从那以后,我每次写自定义损失或者自定义算子,都强制先跑一遍数值梯度校验,哪怕它看起来再显然。希望帮到你。
本文还有配套的精品资源,点击获取