首页 / 资讯中心 / 文章详情

从零手写三层前馈神经网络:前向传播与反向传播全解析

从零手写三层前馈神经网络:前向传播与反向传播全解析 ★ FEATURED ARTICLE
1. 为什么第十三篇才轮到前馈网络先聊清楚它的位置写这个系列到现在前十二篇基本都是围绕环境搭建、数据准备、损失函数、优化思路这些外围功夫打转。很多读者后台问我到底什么时候才真正开始写神经网络本身这一篇我就直接跨进核心把全系列最关键的一块拼图补上——从零手写一个完整的前馈神经网络并且把反向传播的每一行计算都落到实处。之所以叫前馈Feedforward是因为数据从输入层进入之后只能单向地流过隐藏层、到达输出层不存在回路、不存在反馈。这是整个神经网络家族最基础的骨架后面你见到的卷积网络、循环网络、LSTM、图神经网络本质上都是在前馈骨架上做了不同的结构改造。你可以把前馈网络理解成一个函数拟合器给它一堆输入它输出一个预测训练的过程就是不断调整内部参数让预测越来越接近真实答案。用生活化的方式理解前馈网络像一条流水线。原材料输入特征从入口进去经过一道道工序隐藏层的加权求和与激活每个工序有自己的操作参数权重和偏置最后在出口得到一个成品预测值。如果成品不合格loss很高质检员反向传播会把误差责任沿着流水线倒推回去告诉每一道工序该往哪个方向调整各自的操作参数。这个倒推调整的循环就是训练的本质。这一篇适合谁如果你已经会Python基础语法、装好了NumPy也大概知道权重偏置这些名词但一直没亲手实现过那么这篇文章就是为你准备的。我会尽量不跳步从数学推导到代码实现让你看完之后能独立写出一个能跑的、可以解决真实分类问题的前馈网络。2. 必须搞懂的底层数学前向传播和反向传播到底在算什么2.1 从单个神经元到矩阵运算为什么必须用矩阵很多初学者第一次看到神经网络公式时容易卡在为什么代码里全是一堆dot()操作这个问题上。原因很简单单个神经元处理一个样本是一回事处理批量样本是另一回事而矩阵运算能一次性完成后者。单个神经元的计算可以写成$$z \sum_{i1}^{n} w_i x_i b$$其中 $x_i$ 是输入特征$w_i$ 是这个神经元对每个特征的权重$b$ 是偏置。然后通过激活函数得到输出 $a \sigma(z)$。但实际训练时我们不会一次只看一个样本。假设一个批次有 $m$ 个样本每个样本有 $n$ 个特征输入矩阵 $X$ 的形状就是 $(m, n)$。权重矩阵 $W$ 的形状是 $(n, hidden_size)$偏置 $b$ 的形状是 $(hidden_size,)$。那么这一层所有样本的加权输入可以一次性算出来$$Z X \cdot W b$$最终的输出形状就是 $(m, hidden_size)$。这一下子就把循环遍历每个样本、每个神经元的复杂度压缩成了几次矩阵乘法。GPU为什么能加速神经网络本质上就是在并行做这种大规模矩阵乘法。在我实际写代码的过程中最需要提醒自己的一个点就是时刻盯住张量的形状。前向传播时每一层的输入输出形状必须和下一层的权重矩阵维度完全匹配否则矩阵乘法直接报错。这个习惯能帮你省掉无数debug时间。2.2 反向传播的链式法则误差是怎么倒推的反向传播Backpropagation本质上就是微积分里的链式法则只不过应用在了复合函数上。神经网络的正向过程可以看作一个超长的复合函数$$loss L(a^{(L)}, y)$$其中 $a^{(L)}$ 是由前面的每一层逐步计算出来的。如果我们要更新第 $l$ 层的权重 $W^{(l)}$需要计算损失对它的偏导数 $\frac{\partial L}{\partial W^{(l)}}$。根据链式法则$$\frac{\partial L}{\partial W^{(l)}} \frac{\partial L}{\partial z^{(l)}} \cdot \frac{\partial z^{(l)}}{\partial W^{(l)}}$$这里 $\frac{\partial z^{(l)}}{\partial W^{(l)}}$ 很好算就是当前层的输入 $a^{(l-1)}$。真正麻烦的是 $\frac{\partial L}{\partial z^{(l)}}$——它需要从输出层往输入方向逐层递推出来。定义一个误差项 $\delta^{(l)} \frac{\partial L}{\partial z^{(l)}}$那么相邻两层之间的关系是$$\delta^{(l)} \delta^{(l1)} \cdot W^{(l1)^T} \odot \sigma(z^{(l)})$$其中 $\odot$ 表示逐元素相乘$\sigma$ 是激活函数的导数。也就是说这一层的误差项等于上一层误差项乘以权重矩阵的转置再乘以当前层激活函数的导数。这就是反向传播这个名字的由来——误差从输出层开始像涟漪一样一层一层往输入方向传回去。很多资料把反向传播讲得很玄其实你只需要抓住两点一是它算的就是损失对每个参数的偏导数二是这个偏导数可以借助每层的误差项高效地递推计算而不是从头对每一个参数单独求导。想明白这两点后面代码里每一行你都能对上号。2.3 为什么不直接求导而要设计这种递推有人可能会问神经网络参数那么多我能不能直接对每个参数写一个导数公式当然能写问题是参数之间共享了很多中间计算结果。假设网络有3层、每层100个神经元权重大约3万个如果对每个权重都从输出一路求导到底计算量会爆炸。反向传播的聪明之处在于它把中间层的误差项 $\delta$ 缓存下来每一层的梯度都可以复用下一层已经算好的$\delta$整体只做一次前向、一次反向就搞定所有参数的梯度。这个思路和动态规划很像——把一个大问题分解成有依赖关系的子问题子问题的解被多次复用。实际训练中batch size通常是32、64甚至更大反向传播的矩阵形式让这种递推可以一次处理整个批次这才是GPU加速的前提。3. 手写一个三层的MLP代码逐行拆解3.1 网络结构与参数初始化我为什么选三层为了演示我实现一个输入层、一个隐藏层、一个输出层的三层网络。以经典的MNIST手写数字识别为例图片是28x28的灰度图展平后得到784个输入特征隐藏层设64个神经元输出层设10个神经元对应0-9十个数字。初始化有一个非常关键的细节权重不能全零初始化。如果所有权重是相同的值反向传播时每个神经元会收到完全相同的梯度导致所有神经元学习到同样的特征网络就废了——这叫对称失效问题。常见的做法是用小随机数初始化让每个神经元起点不同。对于激活函数为ReLU的层我习惯用He初始化即权重从均值为0、标准差为 $\sqrt{2 / n_{in}}$ 的正态分布中采样对于Sigmoid/ Tanh做激活的层则用Xavier初始化标准差是 $\sqrt{1 / n_{in}}$。下面是初始化代码import numpy as np def initialize_parameters(input_size, hidden_size, output_size): # He初始化适合ReLU激活 W1 np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size) b1 np.zeros((hidden_size,)) # Xavier初始化适合输出层softmax前的线性加权 W2 np.random.randn(hidden_size, output_size) * np.sqrt(1.0 / hidden_size) b2 np.zeros((output_size,)) return {W1: W1, b1: b1, W2: W2, b2: b2}偏置初始化为零是常见做法实践中没有问题因为即使偏置全零权重也会打破对称性。注意randn生成的是标准正态分布随机数再乘以缩放系数是为了控制激活值的方差避免过深网络中梯度消失或爆炸。3.2 前向传播从输入到预测的完整路径前向传播分几步走隐藏层线性加权$z_1 X \cdot W_1 b_1$隐藏层激活$a_1 ReLU(z_1)$这里ReLU就是 $max(0, z_1)$输出层线性加权$z_2 a_1 \cdot W_2 b_2$输出层Softmax$\hat{y} softmax(z_2)$把输出变成概率分布为什么要用Softmax而不是直接输出原始分数因为多分类问题我们希望网络输出的是每个类别的置信度Softmax能把一组实数压缩到0到1之间并且求和为1让输出可以解释为概率。代码实现def relu(z): return np.maximum(0, z) def softmax(z): # 减去最大值是为了数值稳定性防止exp溢出 shifted z - np.max(z, axis1, keepdimsTrue) exp_z np.exp(shifted) return exp_z / np.sum(exp_z, axis1, keepdimsTrue) def forward(X, params): W1, b1 params[W1], params[b1] W2, b2 params[W2], params[b2] z1 X.dot(W1) b1 a1 relu(z1) z2 a1.dot(W2) b2 a2 softmax(z2) cache {z1: z1, a1: a1, z2: z2, a2: a2} return a2, cache这里的cache字典很重要反向传播需要用到前向传播的中间结果比如 $a_1$、$z_1$把它缓存下来可以避免反向时重复计算这个设计在几乎所有深度学习框架里都能看到影子。3.3 损失函数的选择交叉熵为什么配Softmax多分类任务最常用的损失是交叉熵损失。对于单个样本如果真实标签是 $y$一个one-hot向量预测概率是 $\hat{y}$损失定义为$$L -\sum_{k1}^{C} y_k \log(\hat{y}_k)$$为什么选交叉熵而不是均方误差因为在Softmax输出层之后交叉熵的梯度形式极其简洁。可以推导一下假设真实类别是 $i$那么损失简化为 $L -\log(\hat{y}_i)$。对 $z_2$ 求梯度结果恰好是 $\hat{y} - y$——预测概率减去真实one-hot标签这个结论很多人第一次看到都会觉得太巧了。但这不是巧合是Softmax和交叉熵在数学上天然配套。均方误差配合Softmax会导致梯度表达式里有 $\hat{y}(1-\hat{y})$ 这种因子当预测接近0或1时梯度会变得很小学习速度会慢得让人抓狂。批量样本的损失取平均值代码很简单def compute_loss(y_pred, y_true): m y_true.shape[0] # 防止log(0)取极小值 log_likelihood -np.log(y_pred[range(m), y_true] 1e-8) loss np.sum(log_likelihood) / m return loss注意y_true这里用的是整数索引而不是one-hot编码这样能省内存代码也更简洁。3.4 反向传播梯度递推的完整代码现在到了整篇文章最关键的部分。根据前面的推导输出层误差项 $\delta_2 \hat{y} - y$然后往隐藏层传播隐藏层误差项$\delta_1 (\delta_2 \cdot W_2^T) \odot ReLU(z_1)$梯度$\frac{\partial L}{\partial W_2} a_1^T \cdot \delta_2$$\frac{\partial L}{\partial b_2} \sum_{batch} \delta_2$梯度$\frac{\partial L}{\partial W_1} X^T \cdot \delta_1$$\frac{\partial L}{\partial b_1} \sum_{batch} \delta_1$ReLU的导数很简单输入大于0时为1否则为0。def relu_derivative(z): return (z 0).astype(float) def backward(X, y_true, y_pred, cache, params): m X.shape[0] # one-hot 编码真实标签 y_onehot np.zeros_like(y_pred) y_onehot[range(m), y_true] 1 # 输出层误差 dZ2 y_pred - y_onehot dW2 cache[a1].T.dot(dZ2) / m db2 np.sum(dZ2, axis0) / m # 隐藏层误差 dA1 dZ2.dot(params[W2].T) dZ1 dA1 * relu_derivative(cache[z1]) dW1 X.T.dot(dZ1) / m db1 np.sum(dZ1, axis0) / m grads {dW1: dW1, db1: db1, dW2: dW2, db2: db2} return grads关键点所有的梯度都除以批次大小m这是求平均的思想。在梯度下降中梯度的绝对值受批次大小影响除以m后参数更新步长就和batch size无关了调学习率时心智负担会小很多。3.5 参数更新与完整训练循环拿到梯度之后就是最朴素的梯度下降更新$$W W - \eta \cdot dW$$完整训练循环包括打乱数据顺序、划分batch、前向计算损失、反向计算梯度、更新参数。打乱数据顺序很重要因为如果每轮batch里的数据分布都固定模型可能学到顺序而不是特征。def train(X_train, y_train, X_val, y_val, hidden_size64, learning_rate0.1, epochs50, batch_size32): input_size X_train.shape[1] output_size len(np.unique(y_train)) params initialize_parameters(input_size, hidden_size, output_size) num_samples X_train.shape[0] num_batches int(np.ceil(num_samples / batch_size)) for epoch in range(epochs): # 打乱数据 permutation np.random.permutation(num_samples) X_shuffled X_train[permutation] y_shuffled y_train[permutation] epoch_loss 0.0 for i in range(num_batches): start i * batch_size end min(start batch_size, num_samples) X_batch X_shuffled[start:end] y_batch y_shuffled[start:end] # 前向 y_pred, cache forward(X_batch, params) # 反向 grads backward(X_batch, y_batch, y_pred, cache, params) # 更新 params[W1] - learning_rate * grads[dW1] params[b1] - learning_rate * grads[db1] params[W2] - learning_rate * grads[dW2] params[b2] - learning_rate * grads[db2] batch_loss compute_loss(y_pred, y_batch) epoch_loss batch_loss if epoch % 10 0: train_acc compute_accuracy(X_train, y_train, params) val_acc compute_accuracy(X_val, y_val, params) print(fEpoch {epoch}: loss {epoch_loss / num_batches:.4f}, ftrain_acc {train_acc:.4f}, val_acc {val_acc:.4f}) return params def predict(X, params): y_probs, _ forward(X, params) return np.argmax(y_probs, axis1) def compute_accuracy(X, y, params): preds predict(X, params) return np.mean(preds y)到这里一个能完成多分类任务的完整神经网络就写完了。从代码量来看不到一百行但它的计算流程和深度学习框架底层的实现逻辑是一致的。你去看PyTorch的nn.Linear、nn.ReLU、nn.CrossEntropyLoss它们拼起来干的事情就是这里的手写代码。4. 训练与调试中的那些坑从崩坏到收敛的实战经验4.1 梯度检查如何确认反向传播写对了手写反向传播最容易出错而且错误往往很隐蔽——loss在下降但下降得很慢或者反而上升。我强烈建议你在正式训练之前先做一次数值梯度检查。原理是用导数的定义近似真实梯度$$g_{num} \approx \frac{J(\theta \epsilon) - J(\theta - \epsilon)}{2\epsilon}$$然后和反向传播算出的梯度对比如果两者相对误差小于 $10^{-6}$ 级别就可以基本放心如果误差很大多半是反向传播某一步写错了。def gradient_check(X, y, params, epsilon1e-7): # 对W2的某个元素做数值梯度检查 i, j 0, 0 params_plus params.copy() params_minus params.copy() params_plus[W2][i, j] epsilon params_minus[W2][i, j] - epsilon y_pred_plus, _ forward(X, params_plus) y_pred_minus, _ forward(X, params_minus) loss_plus compute_loss(y_pred_plus, y) loss_minus compute_loss(y_pred_minus, y) grad_num (loss_plus - loss_minus) / (2 * epsilon) _, grads backward(X, y, predict_proba(X, params), forward_cache(X, params), params) grad_backprop grads[dW2][i, j] # 相对误差 denom np.max([abs(grad_num), abs(grad_backprop), 1e-8]) relative_error abs(grad_num - grad_backprop) / denom return relative_error数值梯度检查只适合在少量数据、小网络上做因为它每次要对每个参数做两次前向传播代价很高。但它的价值在于一旦发现反向传播没问题你的代码就有了百分之百的置信基础后续调参时出了问题就能排除梯度实现了。4.2 学习率过大导致的loss震荡一个非常典型的翻车现场我刚开始手写神经网络时犯过一个非常典型的错误选了一个看起来合理的学习率0.5训练到一半loss开始来回震荡根本降不下来。原因其实很好理解学习率过大时参数更新步长超过损失函数允许的范围每一步都在越过最优点来回跳动loss自然无法收敛。解决办法也不是死记某个固定值而是用学习率衰减策略——训练初期用较大的学习率快速下降后期用小学习率精细收敛。一个非常实用的方式是每隔若干epoch把学习率乘以0.95或者使用Step Decaylr initial_lr * (0.95 ** (epoch // step_size))我在手写代码中最常用的法则是初始学习率先试0.1loss不降或震荡就除以10降到0.01、0.001直到找到一版稳定的值。很多时候你不需要复杂算法粗暴地对学习率做指数搜索0.1、0.01、0.001反而最快。经验心得与其纠结学习率数值本身不如把训练曲线画出来。loss曲线是陡降后平稳还是急剧下降到负值、还是来回震荡能一眼看出问题出在学习率、初始化还是数据预处理。4.3 数据标准化一个容易被忽略但决定性极强的细节输入数据不做标准化经常会导致训练收敛极慢或者梯度爆炸。以MNIST为例像素值在0-255之间和网络初始权重均值0、方差很小相乘之后加权求和很容易落在激活函数的饱和区。解决方式很简单把输入归一化到0-1之间最好再减去均值、除以标准差让数据分布接近标准正态X_train X_train.astype(np.float32) / 255.0 X_val X_val.astype(np.float32) / 255.0 mean X_train.mean(axis0) std X_train.std(axis0) 1e-8 X_train (X_train - mean) / std X_val (X_val - mean) / std注意均值和标准差只能从训练集计算验证集和测试集必须复用训练集的统计量。这听起来是常识但我在给人review代码时见过很多次把验证集也独立标准化的做法这会引入数据泄漏导致验证集评估结果虚高。4.4 隐藏层神经元数量与过拟合的权衡隐藏层神经元数量到底设多少合适这是一个发生过很多争论的问题。神经元个数太少网络表达力不足loss降不下去神经元个数太多网络会把训练集的细节和噪声一起记住导致过拟合——训练集准确率很高验证集准确率反而下降。对于手写的这个三层网络一个判断方法是直接对比训练集和验证集准确率。如果两者差距越来越大就说明过拟合正在发生。缓解手段有很多最常见的是L2正则化和早停Early Stopping。L2正则化的意义是限制权重不要过大使网络更愿意保持简单# 在损失中加入L2正则项 lambda_reg 0.01 reg_loss lambda_reg * (np.sum(params[W1]**2) np.sum(params[W2]**2)) total_loss data_loss reg_loss梯度里也要对应加上 $2\lambda W$ 这一项。早停更简单质朴每次epoch结束后看验证集准确率如果连续多个epoch不再提升就终止训练并保存验证集表现最好的参数快照。4.5 为什么我的网络不工作先看损失再谈一切调试时我养成了一个习惯永远先看loss曲线的形态再决定往哪个方向调。如果loss一开始就很高而且完全不下降多半是数据没标准化、学习率太小、或者损失函数实现有bug如果loss一路下降最后停在较高位置可能网络表达能力不够考虑加宽/加深如果loss突然出现NaN几乎可以确定是学习率过大导致梯度爆炸或是对数计算里出现了0如果训练loss很低但验证集准确率上不去过拟合加正则或数据增强很多新手一上来就调网络结构、换优化器这是最没有效率的做法。神经网络训练是一个有因果关系链条的系统先排除最简单的数据管线和梯度正确性问题再看模型容量和正则化才是正确的debug顺序。5. 用MNIST实测这一套手写网络的表现与分析5.1 实验环境与数据加载为了验证手写实现的正确性和实用性我在MNIST数据集上跑了一组实验。MNIST是70000张28x28的手写数字灰度图分成60000张训练、10000张测试每个像素取值0-255。这是一个非常经典的benchmark拿它验证手写网络再合适不过。数据加载我用的是最简单直接的方式——直接用download也好从本地目录读取也好关键是处理成NumPy数组。以下面的加载方式为例def load_mnist_from_csv(train_path, test_path): train_data np.loadtxt(train_path, delimiter,, dtypenp.float32) test_data np.loadtxt(test_path, delimiter,, dtypenp.float32) X_train train_data[:, 1:] / 255.0 y_train train_data[:, 0].astype(int) X_test test_data[:, 1:] / 255.0 y_test test_data[:, 0].astype(int) # 拆出一部分做验证集 X_val X_train[:5000] y_val y_train[:5000] X_train X_train[5000:] y_train y_train[5000:] return X_train, y_train, X_val, y_val, X_test, y_test如果手边有现成的Fashion-MNIST或者CIFAR-10一样的代码流程也可以套用。实际上我用这个网络跑Fashion-MNIST也拿到了约86%左右的准确率这在从零手写的范畴里已经是很能说明问题的表现了。5.2 不同超参数组合下的结果对比我固定了训练轮数为50、batch size为32只改动隐藏层大小和学习率测了四组组合隐藏层大小学习率训练准确率(%)测试准确率(%)备注320.0196.194.7收敛稳定耗时短640.0197.396.0更宽的网络略好640.199.296.8收敛更快后期略震荡1280.0198.096.5接近该结构上限从表中可以看出网络宽度从32到128确实带来了准确率提升但幅度在逐渐缩小。隐藏层128个神经元、学习率0.01这个组合下测试准确率约为96.5%左右。而到96.5%之后想再涨到99%以上靠加深加宽三层MLP就不太现实了需要换成卷积网络去利用图像的空间局部性。个人体会96%左右的手写MLP准确率其实是这个结构在MNIST上一个非常真实的天花板。看到这个数字不用气馁恰恰说明手写实现没有bug因为再想往上走不是调参的问题而是模型结构的问题。5.3 隐藏层把图像变成了什么从784维到64维再到10维很多人好奇图像进入神经网络到输出高维度向量的过程到底是什么感觉。以我们的网络为例一张28x28的图展平后是784维向量经过第一层线性变换和ReLU后变成64维再经过第二层变成10维。注意这个过程中数字的含义变了784维是像素亮度64维是抽象特征10维是类别得分。我可以做一个非常直观的验证实验把隐藏层输出的64维向量看成新的特征送入一个线性分类器你会发现它比直接用原始像素训练的线性分类器准确率高出一大截。这说明隐藏层学习到的特征表达比原始输入更利于分类——这恰恰是神经网络表示学习能力的体现。我实际做过对比原始像素直接喂逻辑回归MNIST准确率约92%而用隐藏层64维特征喂逻辑回归准确率约94-95%。可不要小看这2-3个百分点它证明了中间层起到的作用不是简单的降维而是把图像信息重组成了更线性可分的表达形式。5.4 从手写网络到框架的思维迁移把这一套手写实现跑通之后你会发现再去读PyTorch或TensorFlow的代码会轻松很多。框架里的Linear层就是X.dot(W) bReLU就是np.maximum(0, z)CrossEntropyLoss就是-log(softmax(z))而.backward()本质上做的就是我们的backward函数。区别只在于框架自动实现了反向传播你不需要手写梯度框架内部做了大量数值稳定性和性能优化你不需要担心exp溢出、不需要手写worker并行。但如果你只有会调框架API的能力而没有懂底层层面的计算的能力遇到深层次bug时会非常被动——比如梯度莫名变成NaN、显存不够、某个层参数不更新这时候能回到手动计算梯度的层面做推理才是真正的调试高手。我认为每一位想认真做深度学习的读者都值得花一个下午把这种手写MLP从头到尾过一遍。这不是在重复造轮子而是在给自己补上计算的直觉。6. 手写网络的一次完整复查常见错因与自查清单6.1 我犯过的最蠢的三个错误第一忘记在relu_derivative里把布尔矩阵转成浮点类型。Python里(z 0)返回的是bool数组bool和float做乘法时会自动转但如果直接把它存下来再参与其它数值运算某些场景下会因为类型问题踩坑。建议一开始就.astype(float)。第二Softmax里忘记减去最大值。输入稍微大一点比如超过30np.exp(30)就是天文数字数值上很容易溢出。几乎所有教科书都会强调减去最大值等价变换的理由——Softmax里每一项都除以同一个常数概率值不变但数值稳定性大幅提升。第三误把每个batch的平均梯度当成整个epoch的梯度。如果batch size不同学习率不变的情况下每个batch的平均梯度量级基本一致但这不等于accumulate所有梯度再除以总样本数。两种操作最终结果不会严格一样实践中平均梯度更常用也更稳定。6.2 一份可以反复对照的自查清单我整理了一份我自己每次手写网络都要过一遍的检查清单供你参考[ ] 权重的形状是否和网络结构完全匹配画出每个张量的形状并逐一标注[ ] 初始化是否避免了全零是否根据激活函数选择了合适的初始化方式[ ] 输入数据是否做了归一化训练/验证集是否存在信息泄漏[ ] Softmax之前是否做了最大值平移的数值稳定处理[ ] 损失函数中是否防止了log(0)加一个极小值epsilon[ ] 反向传播的梯度形状是否和前向传播的权重形状一致[ ] 是否用数值梯度检查验证了反向传播的正确性建议至少验证一个参数[ ] 学习率是否过大导致loss震荡是否做了衰减[ ] 训练/验证准确率的差距是否提示过拟合[ ] loss曲线形是否出现NaN6.3 下一步我可以往哪里扩展如果这篇手写MLP你已经完全跑通我建议你按下面的顺序做扩展加深把网络改成4层、5层甚至更深观察深层网络在MNIST上的表现这里你能直观体会到梯度消失问题对Sigmoid/ReLU的影响差异换激活把ReLU换成Sigmoid对比收敛速度和最终准确率这是一个很有教育意义的实验加正则实现Dropout和BatchNorm观察它们对过拟合的抑制效果理解现代深度学习为什么离不开这些组件换数据集用Fashion-MNIST或CIFAR-10需要把图片展平测试你的网络泛化能力自建数据用sklearn生成一些二维的螺旋分类数据把训练过程中每一层的特征表达用散点图可视化你会对网络在做什么有更直觉的理解我在做了加深和换激活这两个实验之后才对为什么要用ReLU而不是Sigmoid有了切身的体会Sigmoid在深层网络里梯度衰减太快前面几层几乎学不到东西而ReLU就能很好地缓解这个问题。纸上读一百遍不如亲眼看一遍loss曲线的差异来得实在。7. 最后再分享一点实操体会手写神经网络的整个过程最宝贵的不是那96%的准确率而是你亲手把前向传播、反向传播、梯度下降这三件事从公式变成了可运行代码之后脑子里形成的完整计算图直觉。我后来调试框架代码时很多问题一眼就能定位到是梯度传播断在了哪个环节哪一层的数值溢出导致NaN得益于这段手写经历打下的底子。如果一定要给一个优先级建议先把这一次手写的三层MLP玩透再去碰卷积网络和循环网络。卷积只是在前馈骨架上引入了局部连接和权值共享循环网络只是在前馈骨架上引入了跨时间步的反馈——理解了前馈骨架这些高级结构对你来说都只是在熟悉的地基上盖新楼。如果训练中遇到任何loss异常、梯度爆炸、代码报错的情况欢迎回来对照这份自查清单逐项排查。神经网络是经验科学靠的是大量试错中积累出的直觉。踩过这些坑之后我相信你再看任何深度学习资料都会有一种原来不过如此的踏实感。
阅读完成 · 觉得有帮助?
咨询建站