简介:面向具备一定机器学习基础、希望系统吃透深度前馈神经网络的学习者,这份PDF围绕模型定义、变量约束、前向传播、反向传播、数据集划分与公式推导展开,并给出Python实现思路,适合用于课程复习、面试准备或手推神经网络时对照查阅。资源包仅含1个PDF文件,大小约1.38MB,篇幅集中,便于在电脑或平板上直接阅读与标注。已有340人学习,说明其在基础神经网络专题中具备一定参考价值。读者可从中获得从线性输出、激活函数、交叉熵损失到代价函数、链式法则求导、梯度下降更新与向量化表达的完整脉络,也能结合代码实现理解训练与评估流程,并顺带接触正则化、学习率调度、早停等提升模型性能的实践方向。
1. 从一次梯度对不上的调试说起:三层网络为什么值得自己手推一遍
前阵子帮人看一个三层的分类网络,训练损失纹丝不动,换成现成框架重写一遍就好了。问题最后落在反向传播里一行不起眼的广播上——dA_prev少乘了一项激活函数导数。这种坑在框架里是黑匣子,在自己写的 NumPy 代码里,把cache打印出来一看就露馅。这份深度前馈神经网络(DFNN)的实现,从多层感知机的定义、变量约定、前向传播、交叉熵代价,一路推到 BP 链式法则的逐项展开,最后落到一份纯 NumPy、不依赖任何深度学习框架的 Python 实现:初始化、线性前向、激活前向、L 层模型前向、代价计算、线性反向、激活反向、L 层模型反向、参数更新,九个函数串成一条完整链路。适合刚学完机器学习基础、想搞明白梯度到底怎么算出来的开发者,也适合平时只用高层 API、模型一不收敛就束手无策的人。
2. 项目全貌与技术选型:一份不依赖框架的 NumPy 实现
2.1 交付物清单与能力边界
这份资源包含两块内容:一块是推导文档,把前向传播、交叉熵代价、反向传播链式法则的每一步都写成了矩阵形式;另一块是配套的 Python 代码,覆盖从参数初始化到参数更新的完整训练闭环。代码规模不大,但每一行都对应一个数学式子,读起来像在核对草稿纸。
| 模块 | 文件形态 | 承担职责 | 是否可独立替换 |
|---|---|---|---|
| 激活函数 | 工具文件 | sigmoid / relu 的正向值与反向导数 | 可,接口保持(A, cache)返回 |
| 校验用例 | 测试文件 | 各函数的形状与数值断言 | 可,仅开发期使用 |
| 主流程 | 训练脚本 | 初始化、前向、代价、反向、更新 | 否,是链路主干 |
| 数据 | h5 文件 | 图像二分类的输入与标签 | 可,换自己的数据集 |
需要提前说清楚它的能力边界:这份代码没有优化器(只有最朴素的批量梯度下降)、没有数据增强、没有学习率调度、没有早停。它是一副骨架,不是一个开箱即用的训练框架。你要拿去做真实任务,得自己往上挂零件;但正因为零件没挂,每个梯度是怎么流回来的,你能看得一清二楚。
2.2 为什么裸写 NumPy 而不是上框架
经常有人问,都什么年代了还手写反向传播。我的答案很直接:手写一遍的价值不在生产,而在排障。框架帮你把梯度算好了,也把错误藏起来了。当 loss 曲线出现玄学抖动,你能动的只有调参和换优化器;而当你自己写过linear_backward,你会本能地去怀疑某一层的dZ是不是少乘了导数、db是不是忘了沿样本维求和。
另一个理由是梯度检验。只有自己实现的参数更新,才能方便地把parameters和grads摊平成一维向量,做那个经典的(J(θ+ε) - J(θ-ε)) / 2ε数值近似对比。没有这一步,反向传播写错了你也只能靠 loss 猜。
代价同样明显:纯 NumPy 版本在中等规模数据上就跑不过 GPU 框架,图像分类这种任务,一轮训练的时间差可能是几十倍。所以合理的用法是——用这份代码把原理吃透,把梯度检验跑通,然后再回到框架里去调你的模型。血泪经验是,先懂再快,顺序反了就是无穷无尽的调参。
2.3 目录结构与运行环境
代码组织成四个文件,职责切得很干净,便于单独替换某一块做实验。
dnn-from-scratch/ ├── dnn_utils.py # 激活函数正反向:sigmoid / relu 及其导数 ├── testCases.py # 各函数的形状断言与期望数值 ├── dnn_app.py # 主流程:初始化 -> 前向 -> 代价 -> 反向 -> 更新 └── data/ └── train_catvnoncat.h5 # 图像二分类数据,输入维度 12288 = 64*64*3dnn_utils.py里的函数签名统一是「输入值 + cache,输出结果 + cache」,前向返回激活值和Z,反向接收dA和缓存的Z,这样正反向可以严格配对。testCases.py只在开发阶段用,它给每个函数塞固定随机种子的小矩阵,断言输出形状和几个关键位置的数值,改代码时先跑它,能挡掉大部分低级错误。dnn_app.py是唯一有副作用的文件,跑起来就开始训练。数据文件按 h5 组织,训练集和测试集的键名常见做法是train_set_x、train_set_y、test_set_x、test_set_y,读进来后把形状从(m, 12288)转成(12288, m),这一步转置忘了,后面所有维度断言都会挂。
依赖只有三个,装完就能跑:
pip install numpy matplotlib h5pyNumPy 建议 1.20 以上。有个细节值得提醒:NumPy 2.x 对np.float、np.int这类别名的移除比较彻底,老代码里如果混用了这些别名会直接报AttributeError,把类型写成np.float64和np.int64就好。matplotlib 只在你想可视化代价曲线和错分样本时用得上。
2.4 一次完整训练的主循环
把九个函数串起来的主流程长这样,它也是整份代码唯一需要你改超参的地方:
import numpy as np def model(X, Y, layer_dims, learning_rate=0.0075, num_iterations=3000, print_cost=False): """ X: 输入,形状 (n_x, m),m 是样本数 Y: 标签,形状 (1, m),二分类取 0/1 layer_dims: 每层单元数,如 [12288, 20, 7, 5, 1] learning_rate: 梯度下降步长 num_iterations: 迭代轮数 """ np.random.seed(1) # 固定种子,保证每次结果可复现 costs = [] parameters = initialize_parameters_deep(layer_dims) for i in range(num_iterations): # 1) 前向:拿到输出与每层 cache AL, caches = L_model_forward(X, parameters) # 2) 代价:交叉熵,只用于观测和梯度检验 cost = compute_cost(AL, Y) # 3) 反向:从 dAL 递推回所有 dW / db grads = L_model_backward(AL, Y, caches) # 4) 更新:最朴素的批量梯度下降 parameters = update_parameters(parameters, grads, learning_rate) if print_cost and i % 100 == 0: print(f"第 {i} 轮,代价 {cost:.6f}") if i % 100 == 0: costs.append(cost) return parameters, costs这段循环里,caches是贯穿始终的关键变量。L_model_forward把每一层的(A_prev, W, b)和激活函数的Z打包成元组塞进列表,L_model_backward再按相反顺序逐层取用。参数更新的顺序必须是「先算完所有层的梯度再统一更新」,如果在循环内部边算边更新,前面的层就会用上后面的层已经改过的参数,梯度就不再是同一个点的梯度,训练会变得不可预测。这一点在批量和随机梯度下降里都是硬规矩。
layer_dims的写法决定了网络深度。比如[12288, 20, 7, 5, 1]表示输入 12288 维、三个隐藏层分别是 20、7、5 个单元、输出 1 个单元,L = len(layer_dims) - 1 = 4。注意L在代码里是「除去输入层之后的层数」,跟文档里的定义保持一致,别在两边混用导致索引错位。
3. 参数初始化与缓存设计:一步错后面全是黑匣子
3.1 两层与 L 层网络的初始化
初始化看着简单,实际上是最容易埋雷的地方。全零初始化会让同一层的所有神经元完全对称,反向传播时拿到一模一样的梯度,网络等于只有一个神经元在工作。所以权重必须随机,偏置可以为零。
先看两层版本,方便理解形状约定:
def initialize_parameters(n_x, n_h, n_y): """ n_x: 输入层单元数,n_h: 隐藏层单元数,n_y: 输出层单元数 返回 parameters 字典,键为 W1/b1/W2/b2 """ np.random.seed(1) W1 = np.random.randn(n_h, n_x) * 0.01 # 小随机数,打破对称 b1 = np.zeros((n_h, 1)) # 偏置零初始化 W2 = np.random.randn(n_y, n_h) * 0.01 b2 = np.zeros((n_y, 1)) # 形状断言:写错维度在这里就炸,别等前向传播 assert W1.shape == (n_h, n_x) assert b1.shape == (n_h, 1) assert W2.shape == (n_y, n_h) assert b2.shape == (n_y, 1) return {"W1": W1, "b1": b1, "W2": W2, "b2": b2}np.random.randn出的是标准正态,乘0.01把初始值压到很小的范围。为什么要小?因为如果初始权重太大,经过多层线性组合后Z的绝对值会迅速膨胀,sigmoid 落进饱和区,导数接近 0,梯度传不回去,训练直接卡死。乘0.01是一种粗暴但有效的控制方式,深层网络里通常会换成sqrt(2/n_prev)这类带缩放的初始化,这份代码保持简单。
L 层版本只是把上面的过程套进循环,用layer_dims列表驱动:
def initialize_parameters_deep(layer_dims): """ layer_dims: 列表,长度 L+1,layer_dims[0] 是输入维度 返回 parameters,键为 W1..WL、b1..bL """ np.random.seed(3) parameters = {} L = len(layer_dims) # 注意这里是层数+1 for l in range(1, L): # 第 l 层的权重形状是 (本层单元数, 上一层单元数) parameters["W" + str(l)] = np.random.randn( layer_dims[l], layer_dims[l - 1]) * 0.01 parameters["b" + str(l)] = np.zeros((layer_dims[l], 1)) assert parameters["W" + str(l)].shape == (layer_dims[l], layer_dims[l - 1]) assert parameters["b" + str(l)].shape == (layer_dims[l], 1) return parameters参数说明上,layer_dims[l]是第 l 层的单元数,layer_dims[0]是输入维度。range(1, L)保证循环从第一层隐藏层开始,最后一层是输出层。形状(layer_dims[l], layer_dims[l-1])直接决定了后面np.dot(W, A_prev)能不能算,A 的形状是(上一层单元数, 样本数),两个矩阵相乘后得到(本层单元数, 样本数),偏置(本层单元数, 1)靠广播自动对齐。这个形状链条一旦记住,90% 的维度报错都能提前规避。
3.2 前向传播的三段式拆分
前向传播被拆成三级,从细到粗:单个线性变换、线性加激活、整网串联。拆开的好处是每一级都能单独测,出错时能立刻定位到是哪一级。
第一级是线性部分,只做Z = W·A_prev + b:
def linear_forward(A, W, b): """ A: 上一层激活值,形状 (上一层单元数, m) W: 本层权重,形状 (本层单元数, 上一层单元数) b: 本层偏置,形状 (本层单元数, 1) 返回 Z 与 cache """ Z = np.dot(W, A) + b cache = (A, W, b) # 反向传播要用到这三个量 return Z, cachecache存的是A、W、b,因为反向时dW = dZ·A^T / m、db = sum(dZ) / m、dA_prev = W^T·dZ全都依赖它们。这里没有存Z,因为线性部分不需要激活导数。
第二级把线性与激活捆在一起:
def linear_activation_forward(A_prev, W, b, activation): """ activation: "sigmoid" 或 "relu" 返回激活后的 A 与双层 cache """ if activation == "sigmoid": Z, linear_cache = linear_forward(A_prev, W, b) A, activation_cache = sigmoid(Z) # 工具函数返回 (A, Z) elif activation == "relu": Z, linear_cache = linear_forward(A_prev, W, b) A, activation_cache = relu(Z) else: raise ValueError("不支持的激活函数: " + activation) cache = (linear_cache, activation_cache) # 嵌套元组 return A, cache这里的cache是个嵌套结构:内层linear_cache是(A_prev, W, b),内层activation_cache实际就是Z。反向时先由dA和Z算出dZ,再由dZ和linear_cache算出dW、db、dA_prev,两步正好对应两个内层。设计成嵌套而不是平铺成一个大元组,是为了反向函数能干净地拆分职责,linear_activation_backward只负责解包,具体计算仍交给两个下层函数。
3.3 cache 里到底存了什么,为什么必须存
反向传播本质上是把前向的计算结果按逆序再走一遍,凡是反向要用到的中间量,前向就必须留下来。具体到这份实现,三个量是刚需:
- 每层的输入
A_prev:算dW时要用dZ·A_prev^T,没有它就得重算一遍前向。 - 每层的权重
W:算dA_prev时要用W^T·dZ。 - 每层的线性输出
Z:算激活导数g'(Z)时要用,sigmoid 的导数是s(1-s),relu 的导数是1{Z>0},都离不开Z。
存与不存是内存和计算的权衡。这份代码把所有层的 cache 都留在内存里,因为数据规模小;真实训练里如果显存吃紧,常见做法是只保留需要反向的层,或者用梯度检查点(checkpointing)把一部分中间结果丢掉、反向时重算。但那是优化手段,先把「该存的都存下来」这件事做对,比省内存重要得多。
3.4 L 层前向的整体组装
有了前两级,整网前向就是一次循环加一次收尾:
def L_model_forward(X, parameters): """ X: 输入,形状 (n_x, m) parameters: 初始化得到的参数字典 返回 AL(输出层激活值)与 caches(每层一个) """ caches = [] A = X L = len(parameters) // 2 # 参数按 W/b 成对出现,层数 = 键数/2 # 前 L-1 层统一用 relu for l in range(1, L): A_prev = A A, cache = linear_activation_forward( A_prev, parameters["W" + str(l)], parameters["b" + str(l)], "relu") caches.append(cache) # 最后一层用 sigmoid,输出落在 (0,1),可直接当概率 AL, cache = linear_activation_forward( A, parameters["W" + str(L)], parameters["b" + str(L)], "sigmoid") caches.append(cache) return AL, cacheslen(parameters) // 2这个写法值得留意。参数字典里每层贡献两个键,所以键数除以 2 就是层数。如果你以后给字典加了别的键(比如缓存动量),这个整除就会算错,稳妥的做法是显式传L进来。至于为什么中间层用 relu、输出层用 sigmoid:relu 在正区间导数恒为 1,能缓解深层网络的梯度消失;sigmoid 把输出压到 0 到 1,配合交叉熵正好是二分类的标准组合。这两者的搭配不是随便定的,换了激活就得同步换损失,否则梯度形式会变。
4. 反向传播链路拆解:从 dA 一路推回 dW、db
4.1 代价函数与输出层梯度
输出层拿到AL后,先算代价。二分类用交叉熵:
def compute_cost(AL, Y): """ AL: 输出层激活值,形状 (1, m),取值在 (0,1) Y: 真实标签,形状 (1, m),取 0 或 1 """ m = Y.shape[1] # 加 1e-8 防止 log(0) 产生 -inf / nan cost = -1.0 / m * np.sum( Y * np.log(AL + 1e-8) + (1 - Y) * np.log(1 - AL + 1e-8)) return np.squeeze(cost) # 从 [[x]] 压成标量np.squeeze那一步别省。如果不压,cost是个形状(1,1)的数组,打印出来是[[0.73]],做数值比较时偶尔会出幺蛾子,尤其在做梯度检验时容易和标量混用报错。
代价对输出的偏导可以直接写出来,这是交叉熵配 sigmoid 的好处——链式展开后g'(z)被约掉,只剩AL - Y的影子:
# L_model_backward 中的第一步 dAL = -(np.divide(Y, AL) - np.divide(1 - Y, 1 - AL))这个式子是dJ/dAL,形状与AL一致。注意np.divide在AL或1-AL接近 0 时会出inf,所以前面log里加的1e-8是必要的,反向这边同理,工程实现里通常会对AL做np.clip(AL, 1e-8, 1-1e-8)。
4.2 linear_backward 与激活反向的配对
反向的最小单元也是两级。先看线性部分:
def linear_backward(dZ, cache): """ dZ: 本层线性输出 Z 的梯度,形状 (本层单元数, m) cache: 前向存下的 (A_prev, W, b) 返回 dA_prev, dW, db """ A_prev, W, b = cache m = A_prev.shape[1] dW = 1.0 / m * np.dot(dZ, A_prev.T) # (本层单元数, 上一层单元数) db = 1.0 / m * np.sum(dZ, axis=1, keepdims=True) # (本层单元数, 1) dA_prev = np.dot(W.T, dZ) # (上一层单元数, m) return dA_prev, dW, db三个式子的形状对得上,这是验算最快的方式。dZ形状(n_l, m),A_prev.T形状(m, n_{l-1}),乘出来正好是W的形状。db是沿样本维求和,keepdims=True保证结果还是二维,否则会变成(n_l,),跟b的(n_l, 1)对不上,更新时广播会把参数悄悄改错形状——这个坑很隐蔽,因为 NumPy 不报错,只是结果不对。
再看激活部分,dZ = dA * g'(Z):
def sigmoid_backward(dA, cache): """cache 就是前向存下的 Z""" Z = cache s = 1.0 / (1 + np.exp(-Z)) return dA * s * (1 - s) # sigmoid 导数 s(1-s) def relu_backward(dA, cache): """relu 在 Z<=0 处导数为 0,在 Z>0 处导数为 1""" Z = cache dZ = np.array(dA, copy=True) dZ[Z <= 0] = 0 return dZsigmoid_backward里重新算了一遍s,没有复用前向的A,严格来说有点浪费。更好的写法是把A也存进activation_cache,反向时直接用dA * A * (1 - A),少一次exp。relu_backward用副本再置零,避免原地修改污染上游传下来的dA,这个习惯要多层链式调用时尤其重要,改错一次整条链的梯度都会串味。
两者组合起来就是对外的统一接口:
def linear_activation_backward(dA, cache, activation): """ dA: 本层激活输出的梯度 cache: 前向存的 (linear_cache, activation_cache) """ linear_cache, activation_cache = cache if activation == "relu": dZ = relu_backward(dA, activation_cache) elif activation == "sigmoid": dZ = sigmoid_backward(dA, activation_cache) else: raise ValueError("不支持的激活函数: " + activation) dA_prev, dW, db = linear_backward(dZ, linear_cache) return dA_prev, dW, db顺序是「先过激活的导数,再过线性的导数」,反过来写不会报错但结果全错,而且错得很像那么回事——loss 会降,只是降到一个明显偏高的平台。这类错误最难查,只能靠梯度检验兜底。
4.3 L_model_backward 的循环结构
整网反向是一个从输出层往回走的循环,边界处理是重点:
def L_model_backward(AL, Y, caches): """ AL: 输出层激活值 Y: 标签 caches: 前向得到的列表,长度 L 返回 grads 字典,含 dA1..dA(L-1)、dW1..dWL、db1..dbL """ grads = {} L = len(caches) m = AL.shape[1] Y = Y.reshape(AL.shape) # 防止标签形状是 (m,) 导致广播错位 # 输出层:sigmoid + 交叉熵 dAL = -(np.divide(Y, AL) - np.divide(1 - Y, 1 - AL)) current_cache = caches[L - 1] grads["dA" + str(L - 1)], grads["dW" + str(L)], grads["db" + str(L)] = \ linear_activation_backward(dAL, current_cache, "sigmoid") # 隐藏层:从 L-1 倒着走到 0 for l in reversed(range(L - 1)): current_cache = caches[l] dA_prev_temp, dW_temp, db_temp = linear_activation_backward( grads["dA" + str(l + 1)], current_cache, "relu") grads["dA" + str(l)] = dA_prev_temp grads["dW" + str(l + 1)] = dW_temp grads["db" + str(l + 1)] = db_temp return grads索引是这段代码最容易翻车的地方。caches[l]对应第 l+1 层(列表从 0 开始,层号从 1 开始),所以grads["dW" + str(l+1)]这个+1不能丢。reversed(range(L-1))生成的是L-2, L-3, ..., 0,配合l+1正好覆盖第L-1到第 1 层。dA只存到L-1,因为第 L 层的dA就是dAL,已经被用掉了,不需要再存。把这几条索引关系在纸上画一遍,比盯着代码看十分钟有效。
4.4 参数更新与梯度检验
更新是最直白的一步,但对齐不能错:
def update_parameters(parameters, grads, learning_rate): """ 批量梯度下降:theta = theta - alpha * dtheta 必须等所有梯度算完之后再统一更新 """ L = len(parameters) // 2 for l in range(L): parameters["W" + str(l + 1)] -= learning_rate * grads["dW" + str(l + 1)] parameters["b" + str(l + 1)] -= learning_rate * grads["db" + str(l + 1)] return parameters梯度检验的思路是把所有参数和梯度摊平成一维向量,然后对每一个分量做中心差分:
def gradient_check_n(parameters, gradients, X, Y, epsilon=1e-7): """ 数值梯度 vs 解析梯度,看相对误差 相对误差 < 1e-7 基本可以放心,> 1e-3 说明反向写错了 """ params_values = np.concatenate( [parameters[k].ravel() for k in parameters]) # 摊平 grad_values = np.concatenate( [gradients["d" + k].ravel() for k in parameters]) num_grad = np.zeros(params_values.shape) for i in range(params_values.size): theta_plus = params_values.copy() theta_plus[i] += epsilon theta_minus = params_values.copy() theta_minus[i] -= epsilon # 分别回填参数、重算前向代价,得到数值梯度 num_grad[i] = (cost_with(theta_plus) - cost_with(theta_minus)) / (2 * epsilon) diff = np.linalg.norm(num_grad - grad_values) / ( np.linalg.norm(num_grad) + np.linalg.norm(grad_values)) return diffepsilon取1e-7量级是经验值:太小会被浮点精度吞掉,太大截断误差明显。这个检验必须关掉 dropout、关掉正则,并且只在极小的网络上跑,因为参数量一大就要算两倍参数量的前向,慢得离谱。我的习惯是先让网络只有[4, 3, 1]这种规模,把检验跑绿,再去扩层数。顺序反了,几十万个参数跑一次检验能等到天亮。
5. 常见问题排查:梯度对不上、损失不降的 5 个现场
5.1 现象:第一次迭代代价就是 nan
原因通常是log里出现了 0。交叉熵的log(AL)在AL恰好等于 0 或 1 时会出-inf,随后被求和变成nan;而AL会取到 0 或 1,往往是因为 sigmoid 的输入Z绝对值太大,浮点数直接饱和。
解决办法分两层:表达式里给AL加保护,np.clip(AL, 1e-8, 1 - 1e-8)或者log(AL + 1e-8);更根本的是查初始化,如果W没乘0.01而是用了randn原始值,第一层的Z就能到几十,必然饱和。先修初始化,再谈数值保护。
5.2 现象:梯度检验相对误差在 1e-3 以上
原因集中在反向传播的三处:dA_prev忘了乘激活导数、dW少除了一次m、db求和时没加keepdims。还有一种隐蔽情况是参数更新写在了梯度计算中间,导致检验用的grads已经不是当前参数点的梯度。
解决方式是逐层隔离。先把L_model_backward里某一层的dA打印出来,和手工用(J(θ+ε) - J(θ-ε))/2ε对单个参数算出来的值比。从最后一层往前一层一层验,哪一层开始对不上,问题就在那一层的linear_activation_backward里。这个过程琐碎但不会骗人,比盯着 loss 猜快得多。
5.3 现象:AssertionError,维度不匹配
原因基本是形状约定没统一。最常见的两种:一是数据读进来没转置,(m, n_x)直接喂给np.dot(W, X),W是(n_h, n_x),乘不了;二是Y的形状是(m,)而AL是(1, m),广播后变成一个(m, m)的怪物,后面所有计算都错但不会立刻报错。
解决办法是在L_model_forward开头加一句assert X.shape[0] == parameters["W1"].shape[1],把输入维度与第一层权重对齐这件事显式化;再在L_model_backward开头加Y = Y.reshape(AL.shape),强制统一。断言不花钱,能省下的调试时间是以小时计的。
5.4 现象:代价在降,但准确率一直卡在 0.5 附近
原因通常不在梯度,而在评估环节。一种是把AL > 0.5的判断写成了AL > 0,全部预测为正类;另一种是标签编码和预测阈值不匹配,数据里的标签是1/2而不是0/1,交叉熵算出来是负数,训练方向整体偏掉。
解决办法是先查标签取值范围,np.unique(Y)看一眼,不是{0, 1}就手动映射。再查预测阈值,二分类就是0.5,别用别的。最后单独算一次训练集准确率,如果训练集都上不去,说明模型容量或学习率有问题,跟泛化无关,别急着上正则。
5.5 现象:训练集 99%,测试集 60%
原因就是过拟合加上数据量太小。深层网络在小样本上参数量远超样本数,几轮就能把训练集背下来。另一个常见诱因是学习率偏大,模型在最优解附近来回跳,训练集因为反复见过样本而表现好,测试集就原形毕露。
解决办法按性价比排序:先加 L2 正则或 dropout,改动小见效快;再考虑减少层数或隐藏单元数;数据层面做翻转、裁剪这类增强;最后才是加数据。反过来做也行,但成本高。这里有个容易被忽略的点:加了正则之后,代价函数里多了一项,梯度检验也必须把这一项算进去,否则检验会一直报错,让人误以为是自己把反向写坏了。
6. 二次开发:把这份骨架改成你自己的实验台
这份代码最大的价值是可改。想换激活函数,改动点只有三处:在工具文件里加一个前向(A, cache)和反向(dA, cache)的函数对,在linear_activation_forward和linear_activation_backward里加分支,在L_model_forward的循环里把"relu"换成新名字。tanh 就是个好练习,它的导数是1 - A^2,写完拿梯度检验验一遍,误差能到1e-7量级就说明接对了。
想加 L2 正则,代价函数末尾加lambd / (2m) * sum(W^2),反向里dW加lambd / m * W,注意只对W加,不对b加。想加 dropout,就在每层前向之后生成一个D = (np.random.rand(A.shape) < keep_prob)的掩码,A = A * D / keep_prob,反向时把同一个D作用到dA上再除keep_prob,掩码必须跟着 cache 一起传下去,这一点忘了是最常见的翻车点。
验证改造是否正确,我一般走三步:先用[4, 3, 1]这种迷你网络跑梯度检验,相对误差小于1e-7;再把学习率调到0.0075左右跑 2500 轮,看训练集代价是否单调下降到0.1以下;最后对比训练集和测试集准确率,差距超过 15 个百分点就回头查正则强度。下面这张表是我常用的对照,改完代码后对着看一眼就知道有没有跑偏:
| 检查项 | 合格线 | 不合格时的第一嫌疑 |
|---|---|---|
| 梯度检验相对误差 | < 1e-7 | 反向里少了激活导数 |
| 首轮代价 | 0.6 ~ 0.7 之间 | 初始化尺度不对 |
| 2500 轮训练集代价 | < 0.1 | 学习率过大或过小 |
| 训练集准确率 | > 0.98 | 层数或单元数不够 |
| 训练/测试差距 | < 0.15 | 过拟合,加正则或加数据 |
还有个小技巧:把print_cost打开的间隔从 100 改成 10,前期能看到代价的下降趋势是不是平滑。如果出现锯齿状上下大幅跳动,多半是学习率偏大,先降到0.001试试;如果曲线是一条几乎水平的直线,先看是不是梯度检验都没过,别急着调别的。这套判断顺序我踩过太多次坑才固定下来,先验梯度、再看曲线、最后调超参,顺序反了就是在黑暗里换灯泡。
从那以后我每次改完反向传播,都强制先跑一遍梯度检验再去看 loss 曲线,这个习惯帮我省了至少两三个通宵。希望帮到你。
本文还有配套的精品资源,点击获取