我习惯把一门课的笔记拆成两半:一半是能背下来的定义,另一半是能跑起来的代码。《神经网络与深度学习》第一章笔记如果只抄公式,过两周基本就只剩一堆希腊字母;但如果把第一章的几个核心概念——神经元模型、前向传播、损失函数、梯度下降、反向传播——用几十行代码串起来跑一遍,理解会牢固得多。这篇就按这个思路整理,把我自己在啃第一章时踩过的坑、想通的点、以及一套可以照着复现的最小实现写清楚。适合刚翻开这本书、被链式法则劝退过的朋友,也适合已经会调库、但说不清loss.backward()到底在算什么的人。
1. 第一章值得单独做笔记的理由
1.1 这一章到底在讲什么
很多人以为第一章是“绪论”,翻两页就过去了,实际上它承担的是整本书的符号系统搭建工作。它要做的事情有两件:把生物神经元的抽象成可计算的数学模型,以及把“学习”这件事翻译成一个可以求最小值的最优化问题。这两件事一旦搭好,后面卷积网络、循环网络、注意力机制,本质上都是在这个骨架上换零件。
具体到内容,第一章通常覆盖这么几条线:神经元的前向计算(加权求和加偏置,再过激活函数);网络结构(输入层、隐藏层、输出层,全连接的含义);激活函数的作用与常见选型(sigmoid、tanh、ReLU 及其变体);损失函数的定义(均方误差、交叉熵);梯度下降的基本形式(批量、随机、小批量);反向传播的链式法则推导;以及一段简短的发展脉络,讲清楚感知机为什么在异或问题上卡住,多层结构又是怎么绕开这个限制的。
我在第一次读的时候犯过一个错:把这些当成并列的知识点去背。后来才发现它们是串在一条链上的——网络定义的是函数形式,损失函数定义的是“好不好”的度量,梯度下降定义的是“怎么改”,反向传播定义的是“改多少”。这四件事少一个,整条链路就断了。理解这一点之后,再看后面的章节,会发现几乎每一章都是在替换其中某一环:换函数形式就是换网络结构,换度量就是换损失,换更新规则就是换优化器,换求导方式就是换自动微分实现。
1.2 初学者最容易走偏的三个认知
第一个偏差是把神经网络想得太神秘,觉得它是一种“会自己学习的黑箱”。实际拆开看,它在训练阶段做的事情非常朴素:拿一批数据算出一个预测值,跟真实标签比一比得到一个数(损失),然后沿着让这个数变小的方向,把每个参数挪一小步。整个过程没有任何“理解”的成分,只有数值优化。
第二个偏差是认为激活函数可有可无,反正都是套一层。这个问题我在给 A 同学讲的时候特别明显:他写了一个三层网络,每层之间只做矩阵乘法,训练了几百轮,准确率一直卡在一条水平线上。原因很简单,多个线性变换复合起来还是线性变换,三层和一层没有区别,网络再深也表达不了曲线边界。非线性不是锦上添花,它是多层结构存在的理由。
第三个偏差是跳过手推反向传播,直接上框架。这样做的代价是,一旦训练不收敛,你手上没有任何可排查的抓手。loss 不降的时候,你不知道是前向算错了、梯度传错了、还是学习率选错了。手推一遍两层网络的反向传播,虽然只有半小时,但它会变成你后面所有调试工作的参照系。
2. 从生物神经元到人工神经元的降维过程
2.1 神经元模型的数学骨架
生物神经元的工作过程可以粗略概括为:树突接收来自其他神经元的信号,胞体把这些信号按突触强度加权汇总,累加结果超过某个阈值就产生电脉冲,沿轴突传给下一个神经元。人工神经元保留了这个结构里最关键的三个动作:加权、求和、非线性映射。
写成式子就是,对一个输入向量 x = (x₁, x₂, ..., xₙ),先算 z = w₁x₁ + w₂x₂ + ... + wₙxₙ + b,再算 a = f(z)。w 是权重,b 是偏置,f 是激活函数。权重决定每个输入的重要性,偏置决定这个神经元“有多容易被激活”,激活函数决定输出以什么形式往下传。
这里有个容易被忽略的细节:偏置 b 的作用等价于把激活阈值平移。如果没有 b,那么当所有输入都是 0 时,输出恒为 f(0),这个神经元就失去了在“静默输入”下仍然可以输出非零值的能力。举个具体场景,如果某一类样本的所有特征都接近 0,没有偏置的网络很难把它们和“没有输入”区分开。所以哪怕教科书上的公式看起来只是多加了一项,它的表达力意义并不小。
再往下就是网络的组织方式。把若干神经元并排放在一层,每个神经元的输入都是上一层的全部输出,这就是全连接层。输入层只负责接数据,不算“层”;隐藏层是中间加工环节;输出层的形态由任务决定——回归任务通常一个标量,二分类一个概率值,多分类则是每个类别一个分数。
2.2 激活函数选型背后的真实考量
第一章会列出好几个激活函数,很多人背完值域和导数就完了,但真正决定选型的是梯度行为。
sigmoid 的表达式是 1/(1+e⁻ᶻ),值域 (0,1),导数形式很漂亮:σ(z)(1-σ(z))。它的最大导数值只有 0.25,而且当 z 的绝对值稍微大一点,导数就迅速贴近 0。这意味着在深层网络里,梯度每穿过一层就要乘上一个不超过 0.25 的因子,五层之后量级就掉到千分之一以下。这就是梯度消失的直观来源。另外 sigmoid 的输出恒为正,不以 0 为中心,会让下一层权重的梯度方向出现同号偏移,收敛变慢。
tanh 把值域拉到 (-1,1),输出零中心,导数最大值为 1,比 sigmoid 好一些,但两端饱和的问题依旧存在。
ReLU 定义为 max(0, z),正区间导数恒为 1,负区间导数为 0。它在正区间完全避免了梯度衰减,计算也只是一次比较,所以成了隐藏层的默认选择。代价是“死亡 ReLU”:如果一个神经元的输入长期落在负区间,梯度一直是 0,参数再也更新不动,这个神经元就废了。常见诱因是学习率太大,一次性把大量神经元的偏置推到很负的位置。
针对这个问题有几个变体:Leaky ReLU 在负区间给一个小斜率(比如 0.01),保证梯度不为零;ELU 在负区间用指数形式平滑过渡;GELU 用高斯误差函数做门控,在 Transformer 类结构里更常见。第一章通常不会展开这些,但知道它们为什么存在,比记住名字有用。
我自己的一条经验是:隐藏层先用 ReLU 起步,如果观察到训练中有明显比例(超过三成)的神经元输出恒为 0,再考虑换 Leaky ReLU 或把学习率降一半。输出层则跟着任务走:二分类配 sigmoid,多分类配 softmax,回归直接线性输出。这个组合不是规定,但它是踩坑概率最低的起点。
2.3 符号约定与张量形状
第一章的公式之所以看着晕,很大一部分原因是符号约定不统一。有的书写列向量,有的书写行向量,有的把样本维度放前面,有的放后面。我建议在做笔记的时候强制固定一套自己的约定,并且写进笔记开头。
我用的约定是这样的:用 X 表示一个批次的输入,形状是 (m, n),m 是样本数,n 是特征维数,每一行是一个样本;W 的形状是 (n, h),h 是这一层的神经元个数;b 的形状是 (1, h),靠广播机制加到每一行上;这一层的输出 A 形状是 (m, h)。整套记法一句话概括:样本在行,特征在列,权重左边接输入维度,右边接输出维度。
固定这套约定之后,参数量就能一眼算出来:一个从 n 维到 h 维的全连接层,参数是 n×h + h 个。比如输入 2 维、隐藏层 8 个神经元、输出 1 个值,整个网络的参数量是 2×8 + 8 + 8×1 + 1 = 33。这个数字在调试时很有用——如果打印出来的参数量对不上,八成是某一维写反了。
3. 前向传播:把一次计算完整手推一遍
3.1 一个两输入一输出的算例
光看公式没有手感,拿具体数字走一遍。假设一个神经元有两个输入 x₁ = 0.5,x₂ = -0.3,权重 w₁ = 0.2,w₂ = -0.4,偏置 b = 0.1。
先算加权和:z = 0.5×0.2 + (-0.3)×(-0.4) + 0.1 = 0.1 + 0.12 + 0.1 = 0.32。
再过 sigmoid:σ(0.32) = 1/(1+e⁻⁰·³²) ≈ 1/(1+0.726) ≈ 0.5793。
注意这里有个细节:x₂ 是负数,w₂ 也是负数,负负得正,这一项对 z 的贡献是正的。这说明权重的符号和输入的符号会共同决定贡献方向,不能只看权重绝对值大小判断“重要性”。
如果换成 ReLU,输出就是 max(0, 0.32) = 0.32。同样的输入,两种激活函数给出的结果差别不大,但导数完全不同:sigmoid 在这一点的导数是 0.5793×(1-0.5793) ≈ 0.2437,ReLU 的导数是 1。这个差别在单层看不出什么,堆到十层就是量级差异。
3.2 矩阵形式与批量计算
单个样本手推是为了理解,实际实现一定是批量算的,因为逐样本循环在 Python 里慢得离谱。把上面的过程写成矩阵形式就是 Z = XW + b,A = f(Z)。
假设一个批次有 4 个样本,每个样本 2 个特征,隐藏层 3 个神经元。那么 X 是 (4,2),W 是 (2,3),XW 得到 (4,3),b 是 (1,3),广播之后仍然加到每一行上得到 (4,3),最后逐元素过激活函数。整个过程一次矩阵乘法搞定,底层会走到 BLAS 库,速度差出几十倍。
这里有个新手常犯的错误:把 b 的形状写成 (m, 1) 或者 (3,),前者会导致广播出错或者算出完全错误的结果,后者虽然大多数情况下能跑通,但一旦涉及更高维的张量就会出问题。养成写 (1, h) 的习惯,让形状显式携带“这是一组偏置”的信息,调试的时候打印 shape 一眼就能看出来对不对。
批量计算还有一个隐含的好处:它顺便把梯度的估计变得更稳。单个样本算出来的梯度噪声很大,一个批次平均之后方向更接近真实梯度,这也是后面小批量梯度下降能work的原因之一。
3.3 手推这一步到底值不值
有人会问,既然框架一行model(x)就完事了,为什么要手推。我的答案很直接:手推的价值不在于算得快,而在于建立“每一步的形状和数值大概是什么样”的直觉。
举个例子。有一次我写的一个网络 loss 一直不降,打印出来发现第一层的输出全是 0。如果我没有手推过,可能会怀疑学习率、怀疑数据、怀疑损失函数。但因为我知道正常情况下 z 的分布应该大致在 [-3, 3] 这个量级,输出全 0 说明 z 全是负数,问题就锁定在初始化——权重初始化得太小,加上输入没有归一化,所有 z 都落在负半轴,ReLU 全部被截断。改一下初始化尺度,问题当场消失。
这种定位速度,只能靠对数值量级的熟悉度换来,而这个熟悉度只能靠手推和打印中间变量慢慢积累。
4. 损失函数与梯度下降:学习到底在优化什么
4.1 损失函数的选择逻辑
损失函数回答的问题是:给定一个预测和一个真实标签,用一个数来衡量“差多远”。这个数必须满足两个条件——预测越接近真实值越小,并且对参数可导。
第一章重点讲两个。均方误差 MSE 是 (1/m)Σ(ŷ - y)²,直观,梯度是 2(ŷ - y)/m,误差越大梯度越大。它在回归任务里很自然,但配 sigmoid 用在分类上会有个隐患:当预测严重错误时(比如真实标签是 1,预测是 0.01),sigmoid 已经饱和,导数接近 0,梯度反而变小,学习变慢。这就是所谓的“学习停滞”。
交叉熵的形式是 -[y·log(ŷ) + (1-y)·log(1-ŷ)],配 sigmoid 使用时,把两者的导数乘起来,sigmoid 的导数项正好被约掉,对 z 的梯度化简成 (ŷ - y)/m。这个形式非常干净:误差有多大,梯度就有多大,不存在饱和导致的停滞。这是分类任务几乎都用交叉熵的核心原因,不是因为它的公式更“高级”,而是因为它的梯度性质配合 sigmoid 恰好抵消了衰减。
多分类则用 softmax 加交叉熵,结论类似,对 logits 的梯度同样化简成 (ŷ - y)/m。
有个实现层面的坑要提:算 log(ŷ) 时如果 ŷ 恰好是 0,会得到负无穷,整个 loss 变成 nan。解决办法是在取对数前加一个极小值 eps,比如 1e-8,或者直接用框架里带数值稳定处理的版本。我自己在早期写代码时因为这个 nan 排查了一个下午,最后发现只是某次前向输出恰好饱和到 0。
4.2 梯度下降的三种形态与取舍
梯度下降的核心动作是一句话:参数沿着梯度的反方向挪一步,步长由学习率控制。写成式子就是 w ← w - lr · ∂L/∂w。反方向是因为梯度指向函数上升最快的方向,我们要最小化,所以往反方向走。
按每次用多少数据算梯度,分成三种。批量梯度下降用全部样本,梯度方向准,但每次更新都要扫一遍全量数据,数据量大时慢得没法用。随机梯度下降每次只用一个样本,更新极快,但方向抖动剧烈,loss 曲线像心电图。小批量梯度下降取中间,每次用 32 到 256 个样本,既保留了向量化计算的效率,梯度又不至于太抖。实践里说的“SGD”通常指的就是小批量版本。
批量大小的选择有实际考量。太小(比如 8)梯度噪声大,训练不稳定;太大(比如 4096)单次更新慢,而且泛化性能有时反而下降,因为梯度的噪声本身就有一点正则化效果。我一般的起点是 64 或 128,如果显存吃紧就往下调,如果训练曲线抖得厉害就往上调。
学习率的量级也和批量有关。批量变大时梯度估计更准,可以用更大的学习率;批量变小时要相应减小,否则容易一步跨过最优点。
4.3 学习率调参的实操手感
学习率是第一章里唯一一个“没有公式可推、只能靠试”的超参数,也是实际训练中影响最大的一个。我给自己的判断标准是看 loss 曲线的形状。
学习率过大,典型表现是 loss 剧烈震荡,甚至几百步之后突然变成 nan。原因是一步跨得太远,参数被推到激活函数的饱和区,梯度变成 0 或者数值溢出。遇到这种情况,直接把学习率除以 10 再看。
学习率过小,loss 曲线平滑下降但斜率很缓,训练几百轮下降的幅度还不如别人几十轮。这种情况可以乘 3 或乘 10 试探,观察曲线是否还保持平滑。
一个比较稳的做法是先做一次“学习率扫描”:从 1e-5 开始,每个量级跑一两百步,记录 loss 的下降速度,选下降最快且不发散的那个量级,再在这个量级内部细调。比如扫描下来 1e-2 表现最好,那就在 3e-3 到 3e-2 之间再试两三个值。
还有个细节:第一章的示例通常用固定学习率,但实际训练里更常见的是衰减策略,比如每过若干轮乘 0.1,或者用余弦退火。前期大步走,后期小步微调,收敛曲线会更漂亮。第一部分先不引入这些,等把基础流程跑通了再加。
5. 反向传播:链式法则的工程化表达
5.1 用计算图的视角理解求导
反向传播这个名字听起来吓人,本质就是把复合函数求导的链式法则按计算顺序倒着走一遍。用计算图的视角看会清楚很多:前向传播时,每个运算是一个节点,数据从输入流向输出;反向传播时,梯度从输出端流回输入端,每经过一个节点就乘以该节点的局部导数。
举个最简单的例子。假设 L = (a - y)²,a = f(z),z = wx + b。求 ∂L/∂w 的过程是:先算 ∂L/∂a = 2(a-y),再算 ∂a/∂z = f'(z),再算 ∂z/∂w = x,三者相乘就是结果。每一步都只关心自己这一个节点的导数,不需要关心上游下游是什么,这是计算图能把复杂求导拆解掉的关键。
这种“局部导数相乘”的设计,正是所有自动微分框架的底层逻辑。框架在前向时记录每个运算,反向时按相反顺序调用每个运算的梯度函数,把梯度一路乘回去。理解这一点之后,看到loss.backward()就不会觉得它是在变魔术了。
5.2 两层网络的反向传播手推
拿一个具体的两层网络走一遍。结构是 2 维输入、2 个隐藏神经元、1 个输出,隐藏层用 sigmoid,输出层也用 sigmoid,损失用交叉熵。
前向部分: Z1 = XW1 + b1,A1 = σ(Z1),Z2 = A1W2 + b2,A2 = σ(Z2),L = -(y·log A2 + (1-y)·log(1-A2))。
反向部分,从输出端开始: 第一步,交叉熵配 sigmoid,对 Z2 的梯度直接是 A2 - y(单个样本,不做平均)。这一步的推导值得自己动手写一遍,因为 sigmoid 的导数和 log 的导数会互相抵消,结果是所有推导里最漂亮的一处化简。
第二步,对 W2 和 b2 求导:∂L/∂W2 = A1ᵀ · (A2 - y),∂L/∂b2 = A2 - y。形状上 A1 是 (m,2),(A2-y) 是 (m,1),乘出来是 (2,1),和 W2 的形状对得上。
第三步,把梯度传回隐藏层:∂L/∂A1 = (A2 - y) · W2ᵀ,形状是 (m,2)。
第四步,穿过 sigmoid:∂L/∂Z1 = ∂L/∂A1 ⊙ σ'(Z1),其中 σ'(Z1) = A1 ⊙ (1 - A1),逐元素相乘。
第五步,对 W1 和 b1 求导:∂L/∂W1 = Xᵀ · ∂L/∂Z1,∂L/∂b1 = ∂L/∂Z1 按样本维度求和。
整个过程中最容易出错的是第四步的逐元素相乘。矩阵乘法是交叉求和,逐元素相乘是同位相乘,两者混用是新手最常见的 bug 来源。我的经验是:只要公式里出现“每个元素各自乘一个系数”,就一定是 ⊙;只要出现下标求和,就一定是矩阵乘法。写代码时用*和@区分开,出错概率会低很多。
5.3 梯度消失的第一次直观感受
把上面的推导往深了推一层,就能直观看到梯度消失是怎么发生的。每穿过一个 sigmoid 层,梯度就要乘上一个 σ'(Z) 的因子,而这个因子的最大值是 0.25。三层隐藏层,梯度至少乘上 0.25³ ≈ 0.0156;五层就是约 0.001。
这意味着靠近输入端的层,梯度比输出端小了三个数量级,参数几乎不动。表现出来就是:输出层附近的权重更新明显,前面的层几乎没变化,loss 下降到某个值之后就卡住。
第一章通常会给两个缓解方向。一是换激活函数,用 ReLU 把正区间的导数固定在 1,从源头避免衰减。二是改初始化,让每一层的输出方差保持稳定。Xavier 初始化把权重方差设成 2/(n_in + n_out),适合 tanh 和 sigmoid;He 初始化设成 2/n_in,适合 ReLU 系列。这两个公式不是拍脑袋来的,推导的依据就是让前向的方差逐层保持不变,反向的梯度方差也保持不变。
我在实验里验证过一次:同样一个五层网络,用 0.01 的固定标准差初始化,训练十轮后第一层的权重几乎没变;换成 He 初始化,第一层的权重更新幅度和其他层在一个量级上。这个对比比看十遍公式印象深得多。
6. 第一章的落地实验:不调库写一个两层网络
6.1 数据与网络结构设计
实验目标很明确:用纯 NumPy 实现一个两层全连接网络,完成一个二分类任务,把前面几节的概念全部串起来。不用任何深度学习框架,就是为了让每个矩阵乘法和每次梯度更新都暴露在眼皮底下。
数据我用了两类二维点,一类围绕 (-1,-1),一类围绕 (1,1),各 200 个,标准差 0.6。选这个数据的原因是它足够简单,能快速验证流程正确性;同时又需要一条斜线才能分开,能体现网络确实在学东西。如果要增加难度,可以把两类中心拉近到 (0,0) 和 (0.8,0.8),重叠区域变大,就更能看出隐藏层神经元数量的影响。
网络结构定为 2-8-1:输入 2 维,隐藏层 8 个神经元用 ReLU,输出 1 个值用 sigmoid。参数量 33 个,训练数据 400 条,全批量训练几千轮,在普通笔记本上跑完不到一秒。
选 8 个隐藏神经元是有考虑的。太少(比如 2 个)表达力不够,决策边界不够弯;太多(比如 64 个)在这个任务上纯属浪费,还会增加过拟合的风险。第一章的习题里经常出现“隐藏层单元数怎么定”的问题,标准答案是“没有定论,从任务复杂度估一个起点再调”,我一般按输入维度的 2 到 4 倍起步。
6.2 完整代码实现
先写数据生成和参数初始化。初始化的尺度用 He 的方案,因为隐藏层是 ReLU。
import numpy as np np.random.seed(7) def make_data(n=400): half = n // 2 x1 = np.random.randn(half, 2) * 0.6 + np.array([-1.0, -1.0]) x2 = np.random.randn(half, 2) * 0.6 + np.array([1.0, 1.0]) X = np.vstack([x1, x2]) y = np.hstack([np.zeros(half), np.ones(half)]).reshape(-1, 1) idx = np.random.permutation(n) return X[idx], y[idx] def init_params(n_in=2, n_hidden=8, n_out=1): W1 = np.random.randn(n_in, n_hidden) * np.sqrt(2.0 / n_in) b1 = np.zeros((1, n_hidden)) W2 = np.random.randn(n_hidden, n_out) * np.sqrt(2.0 / n_hidden) b2 = np.zeros((1, n_out)) return {"W1": W1, "b1": b1, "W2": W2, "b2": b2}接着是前向传播。中间结果全部存进 cache,反向传播要用。
def sigmoid(z): return 1.0 / (1.0 + np.exp(-z)) def forward(params, X): Z1 = X @ params["W1"] + params["b1"] A1 = np.maximum(0.0, Z1) Z2 = A1 @ params["W2"] + params["b2"] A2 = sigmoid(Z2) cache = (X, Z1, A1, Z2, A2) return A2, cache损失函数用交叉熵,注意加 eps 防止 log(0)。
def bce_loss(y, yhat, eps=1e-8): yhat = np.clip(yhat, eps, 1 - eps) return -np.mean(y * np.log(yhat) + (1 - y) * np.log(1 - yhat))反向传播严格按 5.2 节的推导写,每一步都注释了对应的公式来源。
def backward(params, cache, y): X, Z1, A1, Z2, A2 = cache m = X.shape[0] # 交叉熵 + sigmoid 化简后的结果 dZ2 = (A2 - y) / m dW2 = A1.T @ dZ2 db2 = np.sum(dZ2, axis=0, keepdims=True) # 梯度传回隐藏层,再过 ReLU dA1 = dZ2 @ params["W2"].T dZ1 = dA1 * (Z1 > 0) dW1 = X.T @ dZ1 db1 = np.sum(dZ1, axis=0, keepdims=True) return {"W1": dW1, "b1": db1, "W2": dW2, "b2": db2}训练循环就是反复做四件事:前向、算损失、反向、更新。顺便每隔一段打印一次当前状态,方便观察。
def train(X, y, epochs=3000, lr=0.3, log_every=300): params = init_params() history = [] for epoch in range(epochs): yhat, cache = forward(params, X) loss = bce_loss(y, yhat) grads = backward(params, cache, y) for k in params: params[k] -= lr * grads[k] history.append(loss) if epoch % log_every == 0: acc = float(((yhat > 0.5).astype(float) == y).mean()) print(f"epoch {epoch:>4d} | loss {loss:.4f} | acc {acc:.2%}") return params, history跑起来的样子大概是这样:
epoch 0 | loss 0.7123 | acc 47.75% epoch 300 | loss 0.1834 | acc 92.25% epoch 600 | loss 0.0912 | acc 96.50% epoch 900 | loss 0.0587 | acc 97.75% epoch 1200 | loss 0.0421 | acc 98.25% epoch 1500 | loss 0.0324 | acc 98.50% epoch 1800 | loss 0.0261 | acc 98.75% epoch 2100 | loss 0.0217 | acc 98.75% epoch 2400 | loss 0.0185 | acc 99.00% epoch 2700 | loss 0.0161 | acc 99.00%6.3 结果解读与几组对照实验
第一轮 loss 是 0.7123,接近 ln2 ≈ 0.693 加上一点随机误差,说明初始状态下网络对两类样本给出的概率都在 0.5 附近,完全没有区分能力——这正是随机初始化应该有的样子。如果第一轮 loss 就是 0.2 或者 1.5,那基本可以判定前向或者损失写错了。
到 600 轮左右 loss 降到 0.09,准确率 96.5%,之后下降明显变缓。这是很典型的收敛曲线形状:先快速下降,再缓慢逼近。继续训练到 3000 轮能到 99% 左右,剩下的一两个点基本都是两类分布重叠区域里的样本,属于数据本身的噪声,再怎么训练也分不开。
做几组对照会更有意思。把隐藏层神经元从 8 个减到 2 个,其他不变,最终准确率大概掉到 95% 附近,loss 曲线后段明显更抖。这说明 2 个隐藏单元的表达能力对这条斜线边界来说刚好够用,但没有余量。
把学习率从 0.3 改成 3.0,第一轮之后 loss 直接跳到 1.2 以上,之后来回震荡,几百轮都不下降。原因是步子太大,参数被推到 ReLU 的大负区间,大量神经元死掉。这个现象和 4.3 节描述的完全一致,实测一次比看十遍文字印象深刻。
把初始化尺度从 sqrt(2/n_in) 改成 0.01,前几百轮 loss 几乎不动。打印 Z1 会发现大部分元素都是很小的负数,ReLU 输出接近全 0,梯度自然也是 0。这就是 3.3 节提到的那次排查场景,只不过是在自己写的代码里重演一遍。
7. 常见问题与排查速查
7.1 概念层面的高频疑问
问得最多的一个问题是:为什么非要用激活函数,直接用线性变换叠起来不行吗。答案在 1.2 节说过,这里再补一个更具体的解释。假设两层都是线性的,第一层输出 A1 = XW1,第二层输出 A2 = A1W2,合起来就是 A2 = X(W1W2),而 W1W2 本身就是一个矩阵。也就是说,两层线性网络完全等价于一层线性网络,参数再多也突破不了线性函数的表达能力。加一个非线性,整个函数空间立刻从“所有直线”扩展到“所有连续函数的一个稠密子集”。
第二个问题是通用近似定理怎么理解。定理说的是:一个包含足够多神经元的单隐层网络,可以以任意精度逼近任意连续函数。这句话经常被误读成“一层就够了,不需要深层”。但它只保证存在性,不保证可学习性——它没告诉你需要多少个神经元,也没告诉你梯度下降能不能找到那组参数。实际中同样的表达能力,深层网络能用远少于浅层的参数实现,这才是深度学习实际有效的工程原因。
第三个问题关于偏置是否必要。前面 2.1 节提过,偏置的作用是平移激活阈值。一个具体的例子:如果输入特征全部经过归一化,均值是 0,那么没有偏置的神经元在初始权重下输出会集中在 0 附近,ReLU 会截掉一半,有效神经元数量直接减半。加了偏置,网络可以把工作点挪到合适的位置。
7.2 实操层面的问题速查
下表是我自己整理的一份排查清单,按“现象—可能原因—验证方法”组织,遇到问题从上往下对照。
| 现象 | 常见原因 | 快速验证 |
|---|---|---|
| loss 完全不降,一条水平线 | 学习率过小、初始化尺度过小、标签与预测对不上 | 把学习率乘 10 试;打印第一层输出看是否全 0 |
| loss 剧烈震荡后变 nan | 学习率过大、log(0)、exp 溢出 | 学习率除以 10;检查是否加了 eps |
| 准确率长期卡在 50% | 数据标签错位、网络没有非线性、输入未归一化 | 用最简单的逻辑回归跑一遍对比;检查激活函数是否漏写 |
| 训练集很好,验证集很差 | 过拟合、训练验证分布不一致 | 加正则、减小网络、检查数据切分是否随机 |
| 梯度全是 0 | ReLU 死亡、参数初始化过大 | 统计每层输出为 0 的比例,超过 30% 就调整 |
| 形状报错 | 广播方向错误、矩阵乘法维度不匹配 | 在每个运算前打印 shape,对照 2.3 节的约定 |
| 同一份代码两次结果差很多 | 没有固定随机种子、数据打乱方式不同 | 在开头固定 seed,检查 shuffle 是否也受控 |
除了表格里的,还有两条不在文档里但很实用的经验。
第一条是关于打印中间变量的粒度。我一般会在第一次跑通的时候,在 forward 里打印每层输出的均值、标准差和最小值最大值,确认量级合理。正常情况下,隐藏层经过 ReLU 后均值应该在 0.1 到 1 之间,标准差在 1 附近;输出层的值应该分布在 0.2 到 0.8 之间。如果某一层的标准差是 0.001 或者 50,那这一层肯定有问题。
第二条是关于“先过拟合一个小批量”。这是我从第一章的调试习惯里留下来的:在正式训练之前,先拿 20 条数据训练到 loss 接近 0。如果连 20 条都拟合不了,说明代码有 bug,跟数据规模、泛化能力都没关系。这个检查能在几分钟内排除掉绝大部分实现错误,比盲目调参高效得多。
还有一个容易被忽略的点是数据归一化。第一章的示例数据往往已经处理好了,但自己找的数据经常量纲差好几个数量级。特征尺度差太大时,梯度在不同方向上的量级也会差很多,学习率很难同时适配。做法很简单,每个特征减均值除标准差,几行代码的事,但对收敛速度的影响可能是几倍的差距。
我在实际操作中的体会是,第一章的价值不在于那些公式本身,而在于它给了你一套可以自己验证的参照系。后面无论用多复杂的框架、多新的结构,判断“这个实现对不对”的方法始终没变:看第一轮 loss 是不是合理、看中间层输出的量级、看能不能过拟合一个小批量、看 loss 曲线的形状是否符合预期。这套方法我用了很久,从最简单的两层网络到后来的各种结构,一直管用。如果非要给刚开始读这本书的人一个建议,那就是别急着往后翻,把第一章那个几十行的 NumPy 实现亲手敲一遍,把几个对照实验都跑一遍,后面会省下很多返工的工夫。