1. 把"神经网络"这四个字拆开看
1.1 一次典型的翻车现场
前阵子帮一个朋友看他写的训练脚本,任务不复杂,几百个特征、两个类别,写了个三层的神经网络跑得挺欢。问题是 loss 从第 1 个 epoch 到第 200 个 epoch 基本是一条直线,准确率稳定在 51% 左右——和瞎猜没区别。他试了加层、换激活函数、把 batch size 从 32 改成 512 再改成 8,折腾了一整天。
我打开脚本看了三分钟,问题出在两行上:输入特征没做标准化,其中有几列的量级是另一个几列的几万倍;另外权重初始化用的是np.random.randn(...) * 0.01,配合 tanh,第一层的输出全部挤在 0 附近,梯度传回去基本等于零。
这件事挺有代表性。现在的深度学习工具链太顺手了,model = nn.Sequential(...)、loss.backward()、optimizer.step(),三行代码就能跑起来,所以很多人直接从"调库"这一步入门,跳过了前面所有的为什么。但一旦训练不收敛,脑子里没有那个计算图,就只能靠猜。
这篇内容想做的事很朴素:把神经网络(Neural Network,后文简称 NN)从单个神经元到多层反向传播,完整地拆一遍。不假设你有数学系背景,但也不会为了通俗把关键细节抹掉。看完你至少应该能做到三件事:能手算一个两层网络的前向传播,能自己用 NumPy 从零实现一个可训练的多层全连接网络,能在 loss 不降的时候按一套有条理的顺序去排查,而不是乱改超参。
1.2 神经网络到底是个什么东西
用一句话说清楚:神经网络是一个由大量可调参数组成的复合函数,训练的目标是让这个复合函数的输出尽量贴合数据的真实标签。
这句话里每个词都有实际含义。"复合函数"意味着它是由若干个简单函数(线性变换、非线性激活)串联或者并联堆起来的,中间层的输出并不是最终答案,只是中间结果;"可调参数"指的是那些权重矩阵和偏置向量,它们的值一开始是随机的,靠训练一点点被修正;"贴合"这个词听起来模糊,落到数学上就是让损失函数的值变小。
如果只做线性变换会怎样?假设两层网络,第一层z1 = W1·x + b1,第二层z2 = W2·z1 + b2,不做任何非线性,代进去:z2 = W2·(W1·x + b1) + b2 = (W2·W1)·x + (W2·b1 + b2)。令W' = W2·W1,b' = W2·b1 + b2,那这不就是一个单层线性模型吗?堆一百层也还是它。这一串推导是理解激活函数存在意义的最短路径——非线性是深度的前提,没有非线性,深度就是个摆设。
我习惯用另一种说法给刚入门的人解释:神经网络是在做"特征工程自动化"。传统机器学习里,你得自己琢磨"这列数据取个平方会不会更有区分度""这两个特征相除会不会更好",而神经网络把这件事交给了一层层的线性组合加非线性变换去完成。中间层学到的那些表示,本质上就是模型自己造出来的新特征。
1.3 这一篇的边界,先划清楚
标题里带了个"1",说明这是个系列的开篇,所以有必要把范围说死,免得读者期待落空。
这篇只讲全连接前馈网络(也叫 MLP,多层感知机)。输入是一个向量,层与层之间全连接,信息单向流动,没有循环、没有卷积、没有注意力机制。这是所有现代网络结构的祖先,卷积网络里那个nn.Linear还在做同样的加权求和,Transformer 里的 FFN 层(前馈网络层)本质也是两个全连接中间夹一个激活。
不写的东西包括:卷积神经网络(CNN)、循环神经网络(RNN)、注意力机制、批归一化的完整推导、各种正则化技巧的数学证明。这些留到后面几篇。本篇的重心是"梯度是怎么传到每一层的""为什么这个参数要这样初始化""训练不收敛时该从哪里下手"这三类问题。
需要的前置知识只有两个:矩阵乘法,还有求导的链式法则。链式法则如果只记得一句"外层导数乘内层导数",其实就够了,反向传播整个算法的推导全靠它。
2. 单个神经元:加权求和加一个门
2.1 从线性回归走到感知机只差一步
一个神经元做的事,抽象到极致就是两步:先算加权和,再过一道非线性。
第一步z = w₁x₁ + w₂x₂ + ... + wₙxₙ + b,写成向量形式就是z = wᵀx + b。这里w是权重向量,b是偏置,x是输入。如果到这一步就结束,输出z直接当预测值,那就是线性回归。
第二步a = f(z),f就是激活函数。如果把f设计成一个阶跃函数——大于 0 输出 1,小于等于 0 输出 0,那这个结构就叫感知机(Perceptron),是 1958 年就被提出来的东西。它能做线性二分类,判定边界就是wᵀx + b = 0这个超平面。
感知机当年火过一阵,然后被泼了冷水,原因是它连异或(XOR)都解决不了。异或问题的四个点在平面上是交叉分布的,画不出任何一条直线把两类分开。这个"线性不可分"的限制,直接导致了神经网络研究的第一次低潮。
解决办法说出来其实很朴素:既然一层分不开,那就叠两层。第一层的几个神经元各自切一刀,把平面切成几块;第二层的神经元再把这几块组合起来,就能围出一个多边形区域。这就是"用简单的分界面组合出复杂的分界面"这个核心思想的起点,后面所有的深度网络,本质上都是这个思路的无限放大版。
2.2 偏置项到底在干什么
初学者经常会把偏置项当摆设,觉得+b只是个可有可无的小尾巴,有些代码里甚至默认bias=False。这个理解是有代价的。
回到二维的情况看。如果没有偏置,判定边界是w₁x₁ + w₂x₂ = 0,这是一条必过原点的直线。也就是说,不管你怎么调w,边界只能绕原点转,不能平移。如果数据的两类分布整体偏离原点,比如一类在 (5,5) 附近、一类在 (6,6) 附近,这条过原点的线无论怎么转都分不开它们。
加上偏置就不一样了,w₁x₁ + w₂x₂ + b = 0是一条可以任意平移的直线,b决定了它离原点多远。用一个更直观的比喻:权重决定了"往哪个方向看",偏置决定了"门槛设多高"。一个神经元要判断"这个用户会不会点击",权重决定它关注哪些特征,偏置决定它的整体倾向是保守还是激进。
代码上还有一个细节:很多人习惯把b和w合并成一个向量处理,在x前面拼一个常量 1,即x' = [1, x₁, ..., xₙ],w' = [b, w₁, ..., wₙ],这样w'ᵀx' = b + wᵀx,两者就统一了。这个技巧在推导时能让公式干净不少,但要注意反向传播时那一维的梯度是sum(dz),不是dz,因为常量 1 会把它广播到整个 batch。
2.3 激活函数选型:为什么 ReLU 成了默认答案
激活函数的选择在早期是个大话题,现在工程上已经收敛得差不多了。但"知道结论"和"知道为什么"是两回事,这里把几种常见的选择摆在一起对比。
| 激活函数 | 表达式 | 输出范围 | 优点 | 主要问题 |
|---|---|---|---|---|
| Sigmoid | 1/(1+e⁻ᶻ) | (0,1) | 平滑、可解释为概率 | 两端饱和,梯度最大只有 0.25,深层必消失 |
| Tanh | (eᶻ-e⁻ᶻ)/(eᶻ+e⁻ᶻ) | (-1,1) | 零中心,收敛比 Sigmoid 快 | 仍然两端饱和,梯度照样消失 |
| ReLU | max(0,z) | [0,+∞) | 正区间梯度恒为 1,计算极快 | 负区间梯度为 0,可能"神经元死亡" |
| LeakyReLU | max(αz,z),α 常取 0.01 | (-∞,+∞) | 缓解死亡问题 | 多一个超参,收益视任务而定 |
| GELU | z·Φ(z) | 近似 (-0.17,+∞) | 平滑,在大模型里表现好 | 计算比 ReLU 贵 |
Sigmoid 的梯度问题值得单独说一下。它的导数是σ(z)(1-σ(z)),最大值在z=0处取得,等于 0.25。这个 0.25 看着还行,但反向传播是逐层相乘的:三层就是 0.25³ ≈ 0.0156,十层就是 0.25¹⁰ ≈ 9.5×10⁻⁷。梯度传到浅层时已经小到浮点数都表示不出有效精度了,这就是经典的梯度消失。所以 Sigmoid 现在基本只用在二分类的输出层,中间层不用。
ReLU 能成为默认选择,核心原因是它的导数在正区间恒等于 1,连乘多少层都不会衰减。另外它带来稀疏性——负输入直接输出 0,大约一半的神经元在任一次前向中是不激活的,计算和存储都省。还有一个容易被忽略的点:ReLU 的分段线性结构让它在优化时表现得更像线性模型,不容易出现饱和导致的优化困难。
ReLU 的坑是"死亡神经元"。如果某个神经元的权重被更新到一个让它对所有样本都输出负值的状态,那它的梯度就永久为 0,再也不会被更新,等于这个神经元废了。诱因通常是学习率设得太大,某一次更新把它推出了有效区域。缓解手段有:把学习率调小、改用 LeakyReLU 或者 ELU、或者用更稳的初始化(下一节会讲)。我在实际项目中遇到过一层 128 个神经元里死了将近 40 个的情况,train loss 一直卡在一个不高的位置,换成 LeakyReLU 加上学习率减半之后恢复了。
2.4 手算一遍,把感觉建立起来
光看公式容易飘,拿一组具体数字走一遍。
输入x = [1.0, 2.0],权重w = [0.5, -0.3],偏置b = 0.1。加权和z = 0.5×1.0 + (-0.3)×2.0 + 0.1 = 0.5 - 0.6 + 0.1 = 0.0。过 ReLU,a = max(0, 0.0) = 0.0。这个神经元此刻正好卡在激活边界上,梯度处于不确定状态。
把w改成[0.5, -0.1],z = 0.5 - 0.2 + 0.1 = 0.4,a = 0.4。此时反向传播经过这个神经元时,梯度会被完整地乘以 1 传下去。
把w改成[0.5, -0.8],z = 0.5 - 1.6 + 0.1 = -1.0,a = 0,且梯度在这一路上被彻底截断。假设这个神经元下游的损失对它的输出期望是正的,那它现在就是在"该响的时候没响",而且由于 ReLU 在负区间的导数为 0,它收不到任何纠正信号。这就是死亡过程的具体形态。
再算一个参数量。假设输入是 784 维(一张 28×28 的灰度图展平),第一层 128 个神经元,第二层 10 个神经元。第一层参数784×128 + 128 = 100480,第二层128×10 + 10 = 1290,总共101770个参数。这个数字要记住,因为它是后面所有讨论的基础——初始化的尺度、显存的占用、推理的耗时,全都跟它正相关。
3. 从一层到多层:前向与反向的完整闭环
3.1 层、宽度、深度和参数量
在动手写代码之前,得先把几个术语对齐,不然后面看别人的代码会犯迷糊。
**层数(depth)**通常指带可学习参数的层有几个。一个"三层网络",指的是有两个隐藏层加一个输出层,输入层不算。**宽度(width)**指每层神经元的个数。参数量是所有权重和偏置元素的总数。
这三个量和模型能力的关系不是线性的。加宽度的边际收益递减得比较快,而加深度的收益在早期很明显——因为每多一层,网络能表达的抽象层次就多一级,从边缘到纹理到部件到物体,这是深度网络的核心价值。但深度上去了,梯度消失和退化问题就跟着来了,这也是残差连接被发明出来的原因,那是后面的话题。
一个具体的参数量估算表,方便你在设计网络时心里有数:
| 结构(输入→隐藏→输出) | 第一层参数 | 第二层参数 | 第三层参数 | 合计 |
|---|---|---|---|---|
| 784→64→10 | 50240 | 650 | - | 50890 |
| 784→256→128→10 | 200960 | 32896 | 1290 | 235146 |
| 784→512→256→128→10 | 401920 | 131328 | 32896 | 567434 |
从表里能看出一个规律:参数量主要被第一层吃掉,因为输入维度通常远大于隐藏层宽度。所以在显存吃紧的时候,优先砍第一层的宽度,或者先做降维,比砍后面的层有效得多。
注意:参数量不等于计算量。计算量(FLOPs)还跟 batch size 有关,且全连接层的计算量是
batch × 输入维 × 输出维,跟参数量同一量级但多乘了一个 batch 因子。评估推理延迟时不能只看参数量。
3.2 损失函数:分类任务为什么不用 MSE
损失函数定义了"什么叫错",它直接决定了梯度的形状,选错了训练会很难受。
回归任务用均方误差(MSE)天经地义,L = (1/n)Σ(ŷ - y)²,梯度是2(ŷ-y)/n,干净利落。但分类任务如果照搬 MSE,配上 Softmax 输出,梯度的形式会变成(p - y) · p · (1 - p)这种带两个饱和因子的东西。当模型预测得非常离谱(比如真实类别是 1,模型给出的概率是 0.001)时,p·(1-p)接近 0,梯度也接近 0——模型错得越狠,越学不动。这就是典型的"梯度饱和"陷阱。
交叉熵损失的梯度形式是p - y,不带饱和因子。错得越狠,梯度越大,纠正得越快。这个性质正是分类任务首选交叉熵的核心原因,它不是"惯例",而是梯度性质决定的。
多分类交叉熵的定义是L = -(1/n)Σ log(p_{i,y_i}),其中p_{i,y_i}是第 i 个样本在其真实类别上的预测概率。实现上有个细节必须处理:log(0)会得到-inf,训练直接崩掉。所以要么在log里面加一个极小的eps(常用 1e-12),要么直接用框架里带数值稳定实现的版本。
Softmax 本身也有溢出风险。exp(z)在z超过 88 左右就会溢出成inf(float64 的极限)。解决办法是先把每个样本的z减去该样本的最大值,因为 Softmax 对输入的平移不变——exp(z-c)/Σexp(z-c)和exp(z)/Σexp(z)结果完全一样,但前者把最大的指数控制在 0 附近,不会溢出。这几行代码几乎每份手写实现里都要有:
def softmax(Z): Z = Z - np.max(Z, axis=1, keepdims=True) e = np.exp(Z) return e / np.sum(e, axis=1, keepdims=True)3.3 反向传播:链式法则的高效复用
反向传播这个名字听起来玄,本质就是把链式法则按从后往前的顺序算一遍,并且复用中间结果。
先看一个两层网络的完整前向:
z1 = x·W1 + b1,a1 = ReLU(z1)z2 = a1·W2 + b2,p = Softmax(z2)L = CrossEntropy(p, y)
现在要求∂L/∂W1。按链式法则展开:
∂L/∂W1 = ∂L/∂z2 · ∂z2/∂a1 · ∂a1/∂z1 · ∂z1/∂W1
逐个看。∂L/∂z2在 Softmax 加交叉熵的组合下有个非常简洁的结果:p - onehot(y),再除以 batch size。这不是巧合,是这两个函数在数学上的一对好搭档,推导过程涉及 Softmax 的雅可比矩阵,但最终会大量抵消。
∂z2/∂a1 = W2,因为z2 = a1·W2 + b2对a1求导就是W2(注意转置关系,实际写成dA1 = dZ2 · W2ᵀ)。
∂a1/∂z1是 ReLU 的导数,z1 > 0时为 1,否则为 0,所以是一个掩码操作。
∂z1/∂W1 = x,所以dW1 = xᵀ · dZ1。
把这些串起来,代码只有几行,但关键在于**dZ2算出来后,dA1和dZ1都能复用dZ2的结果**,dW1又复用dZ1。如果每一层都从头展开算一遍,复杂度会爆炸;反向传播把总计算量控制在和前向传播同一个数量级,这是它能work的根本原因。
提示:反向传播里最容易出错的地方是矩阵维度。写代码前先在一张纸上把每层的形状标出来,比如
x: (32, 784)、W1: (784, 128)、z1: (32, 128)、W2: (128, 10)、dZ2: (32, 10)、dW2 = a1ᵀ·dZ2: (128, 32)·(32, 10) = (128, 10),对上了就说明思路是对的。90% 的 shape 报错都能靠这一步提前发现。
3.4 优化器:从 SGD 到 Adam 的取舍
梯度算出来了,怎么用它更新参数是另一门学问。
最朴素的批量梯度下降(BGD)每次用全部样本算梯度,方向准但一轮太慢。随机梯度下降(SGD)每次只用一个样本,更新频繁但噪声大。实践中用的都是 mini-batch,取个折中的 batch size,比如 32、64、128。
纯 SGD 的问题是它对所有参数用同一个学习率,而不同参数的梯度尺度差别可能非常大。动量(Momentum)的思路是引入一个速度项,把历史梯度累积起来,这样在梯度方向一致的维度上加速,在来回震荡的维度上互相抵消。公式是v = βv + (1-β)g、w = w - lr·v,β常取 0.9。
Adam 更进一步,对每个参数分别维护一阶矩(梯度的均值)和二阶矩(梯度平方的均值),然后用二阶矩的平方根去归一化更新步长。效果是每个参数的更新幅度自动被调整到相近的尺度,对学习率的敏感度大大降低。默认参数β1=0.9、β2=0.999、eps=1e-8在绝大多数任务上都能直接开箱用。
Adam 有个偏差修正的细节容易被忽略:因为m和v初始化都是 0,训练初期它们的值会严重偏向 0,所以要做m̂ = m/(1-β1ᵗ)、v̂ = v/(1-β2ᵗ)的修正,t是步数。少了这一步,前几十步的更新会异常地小。
学习率怎么定?经验值是这样的:Adam 起步用 1e-3,SGD 配动量用 0.1 到 0.01 之间。还有一个"线性缩放法则"——batch size 翻倍时学习率也大致翻倍,这个规律在 batch size 从 32 到 1024 的范围内大致成立。但注意这条法则在大 batch 下会失效,需要配合 warmup,也就是前若干个 step 把学习率从很小的值线性升上来。
我个人的习惯是:先用 Adam 1e-3 跑 20 个 epoch 看趋势,如果 loss 稳降但收敛慢,再看要不要换更大的学习率或者换优化器。上来就用复杂的学习率调度策略,往往是在给自己制造变量。
4. 动手实现:从零写一个多层网络
4.1 数据准备与预处理
为了不依赖外部数据集下载,这里用sklearn自带的make_moons生成一个二维的两分类数据集。它有非线性决策边界,线性模型分不开,正好用来验证网络的表达能力。
import numpy as np from sklearn.datasets import make_moons from sklearn.model_selection import train_test_split X, y = make_moons(n_samples=3000, noise=0.25, random_state=42) X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y) # 标准化:必须做,且统计量只能来自训练集 mu = X_train.mean(axis=0) sigma = X_train.std(axis=0) + 1e-8 X_train = (X_train - mu) / sigma X_val = (X_val - mu) / sigma这段代码里有两个细节值得展开。
sigma后面加了1e-8,是防止某一列特征在训练集上恰好是常数,标准差为 0,除零之后整列变成nan,然后梯度全是nan,整个训练报废。这种问题在有真实数据的项目里并不罕见——某列特征在训练集的时间窗口内没变化,就会出现。
标准化的统计量必须只用训练集算,验证集和测试集用训练集的mu和sigma。用全量数据算统计量会造成数据泄漏,验证指标会虚高,上线之后掉点。这是新手最常犯的错误之一,而且它在小数据集上不一定能被察觉。
4.2 初始化:为什么不能全零,为什么不能太大
权重初始化看着是个小事,实际上是决定训练能不能启动的关键。
全零初始化是错的,而且错得彻底。如果同一层所有神经元的权重相同,那它们在前向时输出相同,反向时梯度也相同,更新之后仍然相同。整层神经元永远保持一模一样的参数,等于只有一个神经元在工作。这叫对称性问题。
初始化太大会炸。假设权重服从标准差 1 的正态分布,输入是 784 维,那z = Σwᵢxᵢ的方差大约是 784,标准差接近 28。过完 ReLU 之后输出尺度还是这么大,下一层再乘一遍,几十层下来直接爆成inf。
初始化太小会消失。标准差 0.01 的话,每层的输出方差会逐层缩小约 784 倍(假设输入方差为 1),第三层往后就全乱了。
正确的做法是让每一层的输出方差和输入方差大致相等,这叫方差保持。对于 tanh 和类似激活,Xavier 初始化的标准差是sqrt(2/(fan_in+fan_out));对于 ReLU,He 初始化用sqrt(2/fan_in),那个 2 是因为 ReLU 把负半轴截掉了,相当于把方差砍半,所以要乘 2 补偿。
def init_params(layers, seed=0): """layers 形如 [2, 64, 32, 2]""" rng = np.random.default_rng(seed) params = {} for i in range(1, len(layers)): fan_in, fan_out = layers[i-1], layers[i] params[f'W{i}'] = rng.standard_normal((fan_in, fan_out)) * np.sqrt(2.0 / fan_in) params[f'b{i}'] = np.zeros((1, fan_out)) return params偏置初始化为 0 是完全可以的,因为对称性已经被随机的权重打破了,偏置不需要再引入随机性。
4.3 前向与反向的完整实现
下面这份代码是可以直接跑通的,网络结构是 2 → 64 → 32 → 2。
def forward(X, params, L): cache = {'A0': X} A = X for i in range(1, L + 1): Z = A @ params[f'W{i}'] + params[f'b{i}'] cache[f'Z{i}'] = Z if i < L: A = np.maximum(0, Z) # ReLU else: A = softmax(Z) # 输出层 cache[f'A{i}'] = A return A, cache def backward(cache, params, y, L): n = cache['A0'].shape[0] grads = {} P = cache[f'A{L}'] dZ = P.copy() dZ[np.arange(n), y] -= 1.0 # softmax + 交叉熵的合并梯度 dZ /= n for i in range(L, 0, -1): grads[f'W{i}'] = cache[f'A{i-1}'].T @ dZ grads[f'b{i}'] = dZ.sum(axis=0, keepdims=True) if i > 1: dA = dZ @ params[f'W{i}'].T dZ = dA * (cache[f'Z{i-1}'] > 0) # ReLU 的导数 return gradsdZ[np.arange(n), y] -= 1.0这一行是整份代码里最精妙的地方。它用花式索引一次性把所有样本在其真实类别位置上的元素减 1,得到的就是P - onehot(y),省去了显式构造 onehot 矩阵的过程,也省了内存。
dZ /= n这一步是求平均梯度。漏了它梯度会大 n 倍,学习率得跟着除以 n 才能等效果,很容易让人误以为"这个学习率不行"。
dZ = dA * (cache[f'Z{i-1}'] > 0)里,(Z > 0)产生的是一个布尔数组,和dA相乘时会自动转成 0/1,这就是 ReLU 导数的掩码实现。注意这里用的是Z而不是A,虽然对 ReLU 来说A > 0和Z > 0等价,但用Z语义更清楚。
写完之后一定要做梯度校验,这一步能省掉后面几个小时的调试时间。
def grad_check(params, grads, X, y, L, eps=1e-6): keys = list(params.keys()) for k in keys[:3]: idx = tuple(np.random.randint(0, s) for s in params[k].shape) orig = params[k][idx] params[k][idx] = orig + eps P, _ = forward(X, params, L) loss_p = -np.mean(np.log(P[np.arange(len(y)), y] + 1e-12)) params[k][idx] = orig - eps P, _ = forward(X, params, L) loss_m = -np.mean(np.log(P[np.arange(len(y)), y] + 1e-12)) params[k][idx] = orig num_grad = (loss_p - loss_m) / (2 * eps) ana_grad = grads[k][idx] rel_err = abs(num_grad - ana_grad) / max(1e-8, abs(num_grad) + abs(ana_grad)) print(f"{k}{idx}: numeric={num_grad:.3e} analytic={ana_grad:.3e} rel={rel_err:.2e}")相对误差在 1e-7 量级就算通过。如果某处是 1e-3 甚至更大,说明那一层的反向逻辑有问题,别急着往下训。
4.4 训练循环与 Adam 实现
class Adam: def __init__(self, params, lr=1e-3, b1=0.9, b2=0.999, eps=1e-8): self.lr, self.b1, self.b2, self.eps = lr, b1, b2, eps self.m = {k: np.zeros_like(v) for k, v in params.items()} self.v = {k: np.zeros_like(v) for k, v in params.items()} self.t = 0 def step(self, params, grads): self.t += 1 for k in params: self.m[k] = self.b1 * self.m[k] + (1 - self.b1) * grads[k] self.v[k] = self.b2 * self.v[k] + (1 - self.b2) * (grads[k] ** 2) m_hat = self.m[k] / (1 - self.b1 ** self.t) v_hat = self.v[k] / (1 - self.b2 ** self.t) params[k] -= self.lr * m_hat / (np.sqrt(v_hat) + self.eps)训练主循环:
layers = [2, 64, 32, 2] L = len(layers) - 1 params = init_params(layers, seed=42) opt = Adam(params, lr=3e-3) batch_size = 64 epochs = 150 n = X_train.shape[0] best_val = 0.0 for epoch in range(epochs): perm = np.random.permutation(n) Xs, ys = X_train[perm], y_train[perm] for start in range(0, n, batch_size): xb = Xs[start:start + batch_size] yb = ys[start:start + batch_size] P, cache = forward(xb, params, L) grads = backward(cache, params, yb, L) opt.step(params, grads) Ptr, _ = forward(X_train, params, L) Pva, _ = forward(X_val, params, L) tr_loss = -np.mean(np.log(Ptr[np.arange(n), y_train] + 1e-12)) va_loss = -np.mean(np.log(Pva[np.arange(len(y_val)), y_val] + 1e-12)) tr_acc = (Ptr.argmax(1) == y_train).mean() va_acc = (Pva.argmax(1) == y_val).mean() best_val = max(best_val, va_acc) if epoch % 10 == 0: print(f"ep{epoch:3d} train_loss={tr_loss:.4f} val_loss={va_loss:.4f} " f"train_acc={tr_acc:.4f} val_acc={va_acc:.4f}")跑起来之后,训练集准确率会很快推到 97% 以上,验证集大概在 96% 附近。如果只观察 loss,前 20 个 epoch 下降最快,之后趋于平缓,这时候继续训收益很小。
这里有个容易被忽略的点:验证集的前向不应该在训练循环里每次都跑全量。当验证集很大时,这一部分会显著拖慢训练。更常见的做法是每隔几个 epoch 评估一次,或者用一个固定的子集做监控。
4.5 框架版对照,看看省掉了什么
同样结构的 PyTorch 实现,代码量差了一个数量级。
import torch import torch.nn as nn device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = nn.Sequential( nn.Linear(2, 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 2) ).to(device) opt = torch.optim.Adam(model.parameters(), lr=3e-3) criterion = nn.CrossEntropyLoss() for epoch in range(150): model.train() for xb, yb in train_loader: xb, yb = xb.to(device), yb.to(device) opt.zero_grad() loss = criterion(model(xb), yb) loss.backward() opt.step()框架帮你做的事情包括:自动求导(不用手推导数)、参数和设备管理、数值稳定的损失实现、各种优化器的现成实现、以及底层的向量化加速。
但这也意味着,当训练出问题的时候,你看不到中间过程。这就是为什么我坚持建议每个学深度学习的人,至少要用 NumPy 手写一次完整的前向反向。写过一次,你才知道zero_grad()为什么必须调——因为 PyTorch 的梯度是累加的,不手动清零上一批的梯度会留在.grad里;写过一次,你才知道为什么loss.backward()只能调一次,除非加retain_graph=True,因为计算图默认在反向之后就被释放了。
心得:
opt.zero_grad()放在loss.backward()前后都能work,但放在前面更符合直觉,也避免了某些梯度累积的写法被误改。如果你在做梯度累积(用小的 batch 模拟大的 batch),那就故意不清零,攒够几个 step 再opt.step()加opt.zero_grad()。
5. 训练不收敛时的排查顺序
5.1 loss 一条直线、NaN 和震荡,处理方式完全不同
loss 曲线是训练过程中最重要的信号,三种典型形态对应完全不同的问题。
loss 完全不降,几乎是一条水平线。优先怀疑三件事:学习率太小、梯度根本没传到参数上、数据处理有问题。检查顺序建议是:先打印每个参数的梯度范数(np.linalg.norm(grad)),如果某一层全部是 0 或者精确的 nan,那问题在反向;如果梯度正常但参数几乎没动,那就是学习率太小。数据处理问题包括标签有没有和特征对齐、有没有把 onehot 后的标签直接喂给CrossEntropyLoss(它期望的是类别索引,不是 onehot)。
loss 变成 NaN。最常见的原因是学习率太大导致某一步参数被推到极端值,接着exp溢出或者log(0)。第二个原因是数值稳定处理漏了,Softmax 没减最大值、log没加eps、除法的分母可能为 0。第三类是数据里本身有 nan 或者无穷大,标准化之前没有检查(np.isnan(X).any()是必做的一行)。处理方法:学习率砍到 1/10,加梯度裁剪(grad = clip(grad, -5, 5)),检查数据。
loss 剧烈震荡或者先降后升。学习率偏大是最主要的解释,另一个常见原因是 batch size 太小导致梯度噪声太大。如果某次突然往上升然后又降下来,可能只是碰到了一个难 batch,不用慌。但如果持续往上走,那就要考虑过拟合了,这时候看训练 loss 和验证 loss 的差值,如果训练还在降、验证在升,就是过拟合。
| 现象 | 最可能原因 | 快速验证手段 | 处理方案 |
|---|---|---|---|
| loss 恒定不变 | 学习率过小 / 梯度断裂 | 打印每层梯度范数 | 提高 lr,检查链式相乘是否断了 |
| 第 1 步就 NaN | 输入含 nan / 除零 | np.isfinite(X).all() | 清理数据,加 eps |
| 若干步后 NaN | 学习率过大 / exp 溢出 | 看参数最大绝对值 | lr÷10,加梯度裁剪,softmax 减最大值 |
| 周期性震荡 | batch 太小 / lr 太大 | 增大 batch 观察 | 提高 batch size 或用 lr 衰减 |
| 训练降验证升 | 过拟合 | 算 gap | dropout、weight decay、早停 |
| 训练验证都不降 | 欠拟合 / 特征无效 | 加深加宽试一次 | 换特征,加容量 |
| 准确率卡在类别比例 | 类别不平衡 | 看混淆矩阵 | 重采样或改用加权损失 |
5.2 过拟合与正则化手段
当训练集指标远好于验证集,就是过拟合的信号。全连接网络在参数量相对于数据量偏大时特别容易过拟合,因为它的每个参数都能记住一点训练样本的细节。
**权重衰减(L2 正则)**是最基础的手段,做法是在损失里加λ/2 · Σ||W||²,等价于在梯度里加λW,效果是让权重倾向于变小。权重变小意味着模型的输出对输入的微小变化不那么敏感,泛化能力更强。λ一般从 1e-4 或 1e-5 开始试。
Dropout是训练时以概率p随机把一部分神经元的输出置零,测试时全部保留并且输出乘1-p(或者训练时就除以1-p,叫 inverted dropout,现在更常用)。它的作用机理可以理解为每次都在训练一个不同的子网络,最终是在做集成。常用p=0.5用于隐藏层,p=0.2用于输入层。注意 Dropout 在推理时必须关掉,框架里用model.eval()切换。
早停是最省事也最可靠的一种。记录验证集上的最佳指标,连续若干个 epoch 没有提升就停止训练,并且回滚到最佳的那个 checkpoint。它的逻辑是"训练得越久一定越容易过拟合,那就在过拟合之前停下"。
这三者不冲突,可以叠加用。但别一上来就全开,先看基线表现——有时候数据量足够,加了正则反而让模型欠拟合。
一个特别容易被忽略的过拟合来源是数据泄漏。前面提到的标准化统计量用了全量数据是一种,特征工程里用了未来信息是另一种。这类问题的表现是离线指标漂亮得不像话,一上线就崩。看到验证准确率 99.8% 这种数字,第一反应应该是怀疑而不是高兴。
5.3 超参数速查表
| 超参数 | 常用范围 | 我的默认起点 | 说明 |
|---|---|---|---|
| 隐藏层宽度 | 32 – 1024 | 128 | 第一层宽一点,后面逐层递减 |
| 隐藏层数量 | 1 – 4 | 2 | 全连接网络很少超过 4 层 |
| 学习率(Adam) | 1e-4 – 1e-2 | 3e-3 | 配合小 batch 时更小一些 |
| 学习率(SGD) | 1e-3 – 1e-1 | 1e-2 | 必须配动量 0.9 |
| batch size | 32 – 512 | 64 | 太小梯度噪,太大易过拟合 |
| dropout | 0 – 0.5 | 视情况 0.2 | 数据量小于参数量的 10 倍时开 |
| 权重衰减 | 1e-6 – 1e-3 | 1e-4 | 和 dropout 二选一先试 |
| 训练轮数 | 50 – 500 | 看早停 | 靠验证集决定,不靠预设 |
这张表不是教条,但作为起点能覆盖大部分中小规模任务。有一次我用它给一个表格数据的二分类问题起步,调整了大概四轮就到了满意的效果,比从零盲猜快得多。
5.4 我在实际项目里踩过的坑
坑一:忘了在验证时切 eval 模式。用框架写代码时,model.train()和model.eval()会影响 Dropout 和 BatchNorm 的行为。有一次我忘记切 eval,验证指标一直在抖,看起来像是模型不稳定,排查了一下午才发现问题。这个 bug 的特点是验证 loss 偏高但不算离谱,只有对着代码逐行看才能发现。
坑二:把类别索引和 onehot 搞混。用CrossEntropyLoss时传了 onehot 标签,它会把 shape 是(n, num_classes)的东西当成(n, ...)的索引处理,要么报形状错误,要么算出一个完全不合理的 loss。现在我的习惯是在数据加载的最后一步断言labels.ndim == 1 and labels.dtype == int。
坑三:学习率的量级判断错。从 SGD 换到 Adam 时忘了改学习率,还用 0.1,结果第一步参数就被推出可行域,loss 直接变inf。这类错误的排查成本很低,但发生频率很高。我现在的做法是在配置里把优化器名字和学习率绑在一起定义,换优化器时必须同步改配置,减少手滑。
坑四:权重初始化用了默认的均匀分布。某个框架的某个版本里,Linear层默认初始化的尺度是按 fan_in 算的,但我的网络第一层输入维度特别大,导致第一层输出方差严重偏小。表现是训练前 20 个 epoch 几乎没动静,然后突然开始快速下降。后来换成显式指定 He 初始化就好了。
坑五:只盯着准确率不看 loss。在类别不平衡的数据上,准确率会骗人。90% 的样本都是负类,模型全猜负也是 90% 准确率,但完全没有用。这种情况应该看 AUC、F1 或者每类的 precision/recall,光看准确率会完全错过问题。
最后一提:调试神经网络最有效的手段不是猜超参,而是加可观测性。把每层的输出均值方差、梯度范数、参数更新量的绝对值都打出来,正常训练时这些量是有稳定规律的——比如每层输出方差应该在 1 附近、梯度范数在 1e-3 到 1e-1 之间。哪个量偏离了,问题就在那一层。这套做法比盲目调参高效太多,我在几个项目里把它固化成了训练脚本的默认输出,后来接手别人代码时也是先加这套监控再动别的。