简介:这是一份关于深度前馈神经网络(DFNN)的专题讲解,面向机器学习初学者、神经网络入门者以及需要系统梳理前向/反向传播推导过程的读者。内容从基本概念切入,依次覆盖网络定义、变量约束、前向传播、反向传播与数据集划分,并结合交叉熵损失、梯度下降等关键技术说明其数学原理与参数更新逻辑;同时提供基于Python的实现思路,帮助读者将公式转化为可运行代码。资源共1个文件,类型为PDF,压缩包大小1.38MB,内容组织紧凑,便于按章节浏览或打印阅读。已有340人学习浏览,适合作为深度学习课程辅助材料或自学笔记。文档末尾附带GitHub仓库链接,对应完整的Coursera框架代码与数据,读者可对照公式推导逐一实验,深入理解网络如何通过前向计算与链式法则完成参数优化。
1. 深度前馈神经网络:为什么必须亲手推公式和用 Python 重写一遍
做机器学习的人大多经历过这种尴尬:模型在框架里能跑,准确率也不算差,可一换数据集就崩,翻遍配置也找不到原因。深度前馈神经网络,也就是常说的多层感知机,是所有神经网络里最基础的骨架,卷积网络、Transformer 里的前馈层都从它演变而来。大部分人能调通 sklearn 或 PyTorch 的 MLP,却说不清参数更新时每一层到底发生了什么。这个系列第一篇,就从前向传播、反向传播的公式推导开始,再用 Python 的 NumPy 从零把网络写出来,把黑匣子拆开。适合已经跑过基础机器学习代码、想真正掌握模型内部机制并能动手复现的人。
2. 前向传播与反向传播:先把数学记号立住,再谈代码
2.1 从单神经元到层的向量化
一个深度前馈神经网络由若干“层”串联组成。每一层先做线性变换,再做非线性激活。为了支持批量训练,不能写 for 循环逐样本算,而是把一批样本放进同一个矩阵。我常用的记号如下:输入矩阵X形状为(N, D),N 是样本数,D 是特征数;第l层的权重W[l]形状为(d_in, d_out),偏置b[l]形状为(1, d_out)。于是第l层的线性输出是:
$$Z^{[l]} = A^{[l-1]} W^{[l]} + b^{[l]}$$
其中A[0] = X。这个约定的好处是代码和公式一一对应,反向传播时也不需要来回转置。每一层接着做激活:
$$A^{[l]} = \phi^{[l]}\left(Z^{[l]}\right)$$
隐藏层常用 ReLU,输出层则根据任务选 sigmoid、softmax 或不激活。只要把这一组公式从第一层一路算到最后一层,就是前向传播。
选择矩阵布局时有个容易踩的坑:如果W被定义成(d_out, d_in),那么前向写成X @ W.T + b,看起来差别不大,但链式法则里的维度推导会绕。我一般统一用(d_in, d_out)布局,代码里A @ W + b更直观。
2.2 输出层与损失函数:为什么分类不用均方误差
输出层的激活函数直接对应损失函数。回归问题输出层一般不激活,用均方误差;二分类输出层用 sigmoid,配合二元交叉熵;多分类用 softmax,配合交叉熵。很多人为了“统一”喜欢在分类任务里也用均方误差,收效往往很差,原因在于交叉熵的梯度形式更利于学习。
以二分类为例,单样本损失为:
$$\mathcal{L} = -\left[y \ln \hat{y} + (1-y)\ln(1-\hat{y})\right]$$
其中 $\hat{y} = \sigma(z)$,$z$ 是输出层预激活值。计算对 $z$ 的梯度:
$$\frac{\partial \mathcal{L}}{\partial z} = \hat{y} - y$$
中间过程省略后只剩这一项,极其简洁。再看回归的均方误差 $\mathcal{L} = \frac12(\hat{y}-y)^2$,如果输出层不激活,则:
$$\frac{\partial \mathcal{L}}{\partial z} = \hat{y} - y$$
两种任务的出发点都是“预测减真值”。这个式子会成为反向传播的初始梯度,顺着它往回传,每一层都能得到明确的前进方向。多分类 softmax 交叉熵的梯度也是y_pred - y_onehot,形式一致,只是向量维数不同。
2.3 反向传播推导:链式法则的三条主线
整个反向传播只需要记住三条主线。定义 $dZ^{[l]} = \partial \mathcal{L} / \partial Z^{[l]}$,$dA^{[l]} = \partial \mathcal{L} / \partial A^{[l]}$。
第一条:输出层的起点。二分类 sigmoid 加交叉熵,直接有 $dZ^{[L]} = \hat{y} - y$。有了它,就能计算最后一层权重的梯度:
$$dW^{[L]} = \frac{1}{N}A^{[L-1]T} dZ^{[L]},\qquad db^{[L]} = \frac{1}{N}\sum_{n=1}^{N} dZ^{[L]}$$
第二条:跨层传播。在隐藏层,激活函数产生的梯度要乘进链式法则:
$$dZ^{[l]} = dA^{[l]} \odot \phi'^{[l]}\left(Z^{[l]}\right)$$
ReLU 的导数是阶跃函数:Z > 0处为 1,否则为 0。sigmoid 的导数可以用a * (1 - a)直接得到。注意元素乘不要写成矩阵乘。
第三条:把误差传到上一层:
$$dA^{[l-1]} = dZ^{[l]} W^{[l]T}$$
这里权重矩阵要转置,维度才能对上。于是循环反向计算,最终得到每一层的W和b梯度。有了梯度,参数更新就是标准梯度下降:
$$W^{[l]} \leftarrow W^{[l]} - \alpha \frac{\partial \mathcal{L}}{\partial W^{[l]}}$$
2.4 梯度消失与初始化:为什么 ReLU 和 He 初始化经常一起出现
早期神经网络用 sigmoid 或 tanh 做隐藏层,在深层网络里误差反向传播时每一层都要乘一个小于 1 的导数,连乘之后梯度指数级衰减。这个问题叫梯度消失。深度前馈神经网络里最常见的组合是 ReLU 加 He 初始化。ReLU 在正区间的导数为 1,缓解梯度连乘缩水;He 初始化把权重标准差设成sqrt(2 / fan_in),让前向传播的方差保持稳定。
如果初始化所有参数一样,哪怕是 0,那么同一层每个神经元的梯度完全一样,所有隐藏单元就会学到同一组特征,网络等于只有一条通路。这也是为什么要用随机初始化,而不是直接“把权重设成一个固定的值”。
3. 用 NumPy 写一个隐藏层可配置的深度前馈网络:从类定义到训练循环
3.1 初始化:给每一层一个公平且不朴素的起点
这里的网络类核心实现不依赖框架,只用 NumPy 的矩阵运算。代码里把两层隐藏层作为默认示例,但layer_sizes改成任何长度都可以,比如[2, 16, 8, 1]就是一个两隐藏层网络。
import numpy as np def relu(x): return np.maximum(0, x) def sigmoid(x): # 防止 exp 溢出,clip 到合理范围 return 1.0 / (1.0 + np.exp(-np.clip(x, -700, 700))) class MLP: def __init__(self, layer_sizes, seed=42): rng = np.random.default_rng(seed) self.params = {} self.L = len(layer_sizes) - 1 for l in range(1, self.L + 1): fan_in = layer_sizes[l - 1] fan_out = layer_sizes[l] # He 初始化:适合 ReLU 隐藏层 self.params["W" + str(l)] = rng.normal( 0.0, np.sqrt(2.0 / fan_in), size=(fan_in, fan_out) ) self.params["b" + str(l)] = np.zeros((1, fan_out))初始化里用了default_rng,这是新版本 NumPy 推荐的做法,比之前的np.random.seed更隔离,多线程环境下不容易互相干扰。He 初始化的标准差随fan_in增大而变小,所以层越宽,初始权重绝对值越小。
这里没有给隐藏层加正则化,只做了最基础的随机初始化。后面第 4 章会提到 L2 和 Dropout 怎么扩展。
3.2 前向传播:把每一层的 Z 和 A 缓存下来
前向传播逻辑就是把公式翻译成矩阵运算。为了反向传播,需要把每一层的Z和A都保存在caches字典里。
def forward(self, X): A = X caches = {"A0": A} for l in range(1, self.L + 1): W = self.params["W" + str(l)] b = self.params["b" + str(l)] Z = A @ W + b if l == self.L: A = sigmoid(Z) else: A = relu(Z) caches["Z" + str(l)] = Z caches["A" + str(l)] = A return A, cachessigmoid里用np.clip(x, -700, 700)是为了防止极端值让exp溢出成inf。网络训练早期如果学习率太大,预激活值可能到几百,不 clip 的话损失会直接变成 NaN。ReLU 不需要类似处理,它对负数直接取 0。
返回的caches至关重要。反向传播要使用每一层的Z计算激活函数导数,也要使用上一层的A计算权重梯度。如果不缓存,要么重新跑一次前向,要么在反向时额外保存,代码会复杂得多。
3.3 损失函数与反向传播:从输出层梯度一路传回第一层
损失函数用带eps的二元交叉熵。反向传播的起点是y_pred - y_true,这正是第 2 章推导的结果。
def compute_loss(self, y_true, y_pred): eps = 1e-12 return -np.mean( y_true * np.log(y_pred + eps) + (1 - y_true) * np.log(1 - y_pred + eps) ) def backward(self, X, y_true, y_pred, caches): grads = {} m = X.shape[0] # 输出层:sigmoid + 二元交叉熵 的梯度简化形式 dZ = y_pred - y_true.reshape(-1, 1) for l in range(self.L, 0, -1): A_prev = caches["A" + str(l - 1)] grads["W" + str(l)] = A_prev.T @ dZ / m grads["b" + str(l)] = np.sum(dZ, axis=0, keepdims=True) / m if l > 1: dA = dZ @ self.params["W" + str(l)].T # ReLU 导数:Z > 0 处为 1,其余为 0 dZ = dA * (caches["Z" + str(l - 1)] > 0) return grads反向循环从最后一层开始,一路算到第一层。grads["W"] = A_prev.T @ dZ / m对应公式 $dW^{[l]} = \frac{1}{N}A^{[l-1]T}dZ^{[l]}$。求均值是因为损失函数对整批样本取了平均。np.sum(dZ, axis=0, keepdims=True) / m是偏置梯度,形状保持(1, d_out)。
if l > 1这一段是跨层传播。dA先通过当前层权重转置传回去,再乘以上一层Z的 ReLU 导数,得到上一层的dZ。如果换成 tanh 或 sigmoid 隐藏层,只要把(caches["Z"] > 0)换成对应的导数表达式即可。
3.4 训练循环:小批量梯度下降与收敛判断
训练循环做的事是:打乱样本顺序,按批切分,对每个 batch 前向、反向、更新参数。下面这个实现没有造优化器,直接用最朴素的梯度下降。
def train(self, X, y, epochs=500, learning_rate=0.05, batch_size=64, print_every=100): rng = np.random.default_rng(0) losses = [] for epoch in range(epochs): indices = rng.permutation(X.shape[0]) for start in range(0, X.shape[0], batch_size): batch_idx = indices[start:start + batch_size] X_batch = X[batch_idx] y_batch = y[batch_idx] y_pred, caches = self.forward(X_batch) grads = self.backward(X_batch, y_batch, y_pred, caches) for l in range(1, self.L + 1): self.params["W" + str(l)] -= learning_rate * grads["W" + str(l)] self.params["b" + str(l)] -= learning_rate * grads["b" + str(l)] if epoch % print_every == 0: y_pred_full, _ = self.forward(X) loss = self.compute_loss(y, y_pred_full) acc = ((y_pred_full > 0.5).reshape(-1) == y).astype(int).mean() losses.append(loss) print(f"epoch {epoch:4d} loss {loss:.4f} acc {acc:.4f}") return lossesrng.permutation是每次 epoch 重新打乱数据的关键。如果不打乱,模型会持续按固定顺序见到样本,优化过程容易产生周期性波动。batch_size决定一次更新用多少样本计算梯度:批越小,梯度噪声越大,但训练可能更容易跳出局部坑;批越大,梯度更平滑,但每个 epoch 的更新次数变少。
这里的losses只在print_every的整数倍时记录,用于画损失曲线。更精细的记录应该在每个 epoch 结束都保存一次,这样曲线更完整。
3.5 在二维数据集上跑通最小示例
用 scikit-learn 的make_moons生成一个二分类数据集,做标准化后按 8:2 切分。神经网络对输入缩放很敏感,标准化几乎是必须的。
from sklearn.datasets import make_moons from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split X, y = make_moons(n_samples=1000, noise=0.2, random_state=0) X = StandardScaler().fit_transform(X) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) model = MLP([2, 32, 16, 1], seed=42) _ = model.train( X_train, y_train, epochs=300, learning_rate=0.05, batch_size=64, print_every=50 ) y_test_pred, _ = model.forward(X_test) test_acc = ((y_test_pred > 0.5).reshape(-1) == y_test).astype(int).mean() print("test acc:", test_acc)这个网络结构是[2, 32, 16, 1],输入 2 个特征,第一隐层 32 个神经元,第二隐层 16 个神经元,输出层 1 个神经元。make_moons是典型的二分类非线性边界,两层隐藏层足够把两个月亮形状分开。
如果你跑出来测试精度在 0.95 以上,说明前向和反向的实现基本正确。如果不高,优先检查输入是否标准化、学习率是否过大导致 NaN。
4. 超参数与设计选择:网络形状、学习率、批大小、正则化一次说清
4.1 深度与宽度:先想清楚你拥有多少数据
深度前馈神经网络的“深度”不是越多越好。数据量只有几千条时,三层网络往往比八层网络更好,因为深层模型的参数量大,更容易把训练集背下来,却学不到泛化规律。常见做法是先用两到四层隐藏层起步,每层 32~128 个神经元,看训练损失能不能降下去。如果训练损失降不动,再考虑加宽度或加层数;如果训练损失很低但验证损失高,则优先降模型容量或加正则化,而不是继续加深。
宽度和深度有个经验分工:宽度增加让每一层能存储更多特征组合,深度增加让网络能表达更抽象的函数。但深度的收益在数据量不够时会迅速变成过拟合的代价。
4.2 学习率、批大小与损失曲线
学习率和批大小是调参时最先要看的一组参数。下面是我常用的判断表:
| 参数 | 常见范围 | 调参信号 | 处理方向 |
|---|---|---|---|
| 学习率 | 0.001 ~ 0.1 | 损失一直不变 | 增大学习率 |
| 学习率 | 0.001 ~ 0.1 | 损失抖动剧烈或变 NaN | 减小学习率 |
| 批大小 | 16 ~ 256 | 损失曲线太毛糙 | 增大批大小 |
| 批大小 | 16 ~ 256 | 验证精度不涨 | 减小批大小 |
| 隐藏层宽度 | 16 ~ 256 | 训练损失高 | 加宽 |
| 层数 | 2 ~ 4 | 训练损失高但增加层数无效 | 检查数据质量和特征 |
损失曲线是调参的核心参考。正常的训练是损失先快速下降,之后变缓。如果曲线像锯齿一样来回震荡,通常不是“不收敛”,而是学习率偏大或批大小偏小。把学习率变成learning_rate * 0.5再跑几个 epoch,很多振荡会消失。
4.3 激活函数:ReLU 的“死神经元”问题与 Leaky ReLU
ReLU 最大的隐忧是负数区域梯度为 0。一旦某个神经元的预激活长时间为负,它接收到的梯度始终为 0,权重永远不再更新,这个神经元就“死”了。深层网络里如果初始化不好或学习率太大,可能出现大量死亡神经元,网络实际容量骤减。
解决方式有三种。第一种是改用 Leaky ReLU,负数区间仍然保留一个小梯度,比如0.01 * x;第二种是先用较小学习率预热;第三种是检查初始化,确保W的标准差不要过大。在代码里换 Leaky ReLU 很简单,把relu函数替换掉即可。
我一般在隐藏层首选 ReLU,但如果网络很深且遇到训练损失停滞,会换成 Leaky ReLU 试试。输出层仍然保持 sigmoid,不要动。
4.4 正则化:L2、Dropout 与提前停止
深度前馈神经网络最常见的失败模式是过拟合。L2 正则化在损失函数里加一项 $\lambda \sum_l |W^{[l]}|^2$,反向传播时权重梯度要额外加上lambda * W。对应的更新公式变成:
$$W^{[l]} \leftarrow W^{[l]} - \alpha \left(\frac{\partial \mathcal{L}}{\partial W^{[l]}} + \lambda W^{[l]}\right)$$
Dropout 实现起来比 L2 麻烦一点。前向传播时以概率p随机把部分神经元的输出置 0,反向传播时同一位置的梯度也要置 0。训练时用遮罩,预测时不再遮罩,但隐藏层输出要乘1-p保持尺度一致。
不过对于小型演示数据集,早期最好先不加正则化,纯粹看模型能不能在训练集上把损失降下去。如果原本训练损失都降不下去,加再强的正则化也只会更差。
4.5 输入归一化:最容易忽略却不做必翻车的步骤
第 3 章里我用了StandardScaler,这一步不是可有可无。如果特征的量纲差异大,比如一个特征在 0~1 之间,另一个在 0~10000 之间,损失函数的等高线会变成狭长形状,梯度下降容易来回震荡。标准化之后特征均值为 0、方差为 1,训练会平稳很多。
标准化还要注意:必须用训练集拟合StandardScaler,再对测试集做同样的transform,不能把测试集混在一起 fit。这是数据泄漏的经典错误,会让评估结果虚高。
5. 深度前馈神经网络避坑指南:五个常见问题的现象、原因、解决顺序
5.1 损失不降:学习率太小还是数据没归一化
现象:训练几个 epoch 后,损失一直停在一个较大的值附近,下降幅度很小。原因:要么是学习率太小,要么是输入特征没有做归一化,导致梯度方向不稳定。解决:先检查输入数据是否标准化,再尝试把学习率从 0.01 提高到 0.1。如果损失仍不动,需要怀疑是不是网络太深,梯度已经消失。我自己曾在一个四层网络上遇到损失不动,调了两天学习率,最后发现是忘了标准化,特征里有一个维度的方差特别大。
5.2 损失变成 NaN:梯度爆炸的三种来源
现象:训练到某个 epoch,损失突然变成NaN,之后再也恢复不了。原因:常见三种,一是学习率过大,梯度更新越过了数值边界;二是输出层sigmoid的exp溢出;三是深层网络中梯度连乘导致累加值太大。解决:先把学习率降到 0.001,看是不是立刻正常;再把sigmoid里的输入做clip;如果前两者都无效,可以在反向传播后对梯度做裁剪,把梯度的 L2 范数限制在 5.0 以内。实践中梯度爆炸多出现在网络层数较多或初始化不当时。
5.3 训练集很准、测试集崩盘:过拟合不只是数据量的问题
现象:训练准确率逼近 1.0,测试准确率却比训练低十几个点。原因:模型容量超出数据能支撑的范围,深度前馈神经网络把训练样本的噪声也学进去了。解决:优先加 L2 正则化或 Dropout,同时做早停。早停的做法是每个 epoch 后记录验证损失,连续多个 epoch 不下降就停止训练,把参数回滚到验证损失最低的点。这招看似简单,却是防止过拟合最有效的后悔药。
5.4 多个隐藏单元输出相同:初始化失效的对称权重陷阱
现象:打印隐藏层的输出,发现很多神经元的值完全相同,网络实际在重复计算同一个特征。原因:初始权重的对称性没被打破,同一层各神经元的学习路径完全一样。解决:用随机初始化并保证不同神经元的种子不同。标准做法是第 3 章里的 He 初始化或 Xavier 初始化,并且偏置全部初始化为 0 时也要确认权重矩阵不是常数。这个坑在手动实现里特别典型,框架里一般不容易遇到,但理解它有助于排查自己的权重赋值代码。
5.5 分类永远预测多数类:正负样本不平衡和偏置初始化
现象:测试集里多数类占比 80%,模型直接把所有样本预测成多数类,损失依然不高。原因:交叉熵在类别不平衡时对多数类更友好,网络找到的“最优解”就是全预测多数类。解决:先给输出层偏置一个先验值,比如正类占比是 20%,就把输出偏置初始化为log(0.2 / 0.8);更可靠的是用加权损失,给少数类样本的损失乘一个较大权重。深度前馈神经网络本身不解决类别不平衡,需要从目标和评测指标上做处理。
6. 用数值梯度检查验证反向传播:30 行代码让网络不再像黑匣子
手动实现了反向传播之后,最大的疑问是“梯度到底算对没有”。这时候数值梯度检查就是唯一可信的验证方式。它的原理很简单:对某个权重参数加上一个极小扰动 $\epsilon$,用损失函数的变化近似梯度:
$$g_{num} \approx \frac{\mathcal{L}(W+\epsilon) - \mathcal{L}(W-\epsilon)}{2\epsilon}$$
然后和反向传播得到的梯度比较。两者相对误差小于 $10^{-6}$,基本说明实现正确。检查一个小规模权重:
def numerical_gradient(model, X, y, param_name, idx, eps=1e-5): original = model.params[param_name][idx] model.params[param_name][idx] = original + eps y_pred, _ = model.forward(X) loss_plus = model.compute_loss(y, y_pred) model.params[param_name][idx] = original - eps y_pred, _ = model.forward(X) loss_minus = model.compute_loss(y, y_pred) model.params[param_name][idx] = original return (loss_plus - loss_minus) / (2 * eps)使用时取一个很小的网络,比如[2, 3, 1],然后比较每个权重位置的数值梯度和反向传播梯度。如果相对误差在1e-6量级,反向传播公式基本没有写错。若误差较大,优先检查矩阵转置方向、求和维度和激活函数导数。
我现在每写一个自定义层,都会先跑数值梯度检查,再丢进训练里。看损失曲线只能知道网络没有坏,看不出梯度细节;数值梯度检查让我能定位到具体是哪个权重位置出了问题。这个习惯帮我避开了无数次调参玄学,也让我对反向传播的理解更扎实。希望帮到你。
本文还有配套的精品资源,点击获取