ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

NumPy手写全连接神经网络:从零实现反向传播与梯度下降

NumPy手写全连接神经网络:从零实现反向传播与梯度下降 1. 为什么还要手写神经网络1.1 从调包侠到造轮子的分水岭刚入门深度学习那会儿我和大多数人一样import torch三行代码搭一个全连接网络model.fit()一跑准确率就上去了。爽是真爽但心里始终有个疙瘩反向传播到底在算什么梯度是怎么从输出层一层层传回输入层的loss.backward()那一行背后究竟发生了什么后来有一次我试图把一个自定义的损失函数塞进训练流程里结果梯度死活对不上调了整整两天才发现是自己对链式法则的理解有偏差。那一刻我意识到光会调库是不够的你得知道轮子是怎么造的才能在轮子出问题的时候修得了它。这就是我决定用 NumPy 从零手写一个全连接神经网络的原因。NumPy 是 Python 生态里最基础的数值计算库它提供了高效的矩阵运算能力但没有自动求导、没有计算图、没有优化器所有东西都得你自己来。正因为如此用它来实现神经网络你会被迫搞清楚每一个矩阵乘法的维度、每一次梯度更新的方向、每一个激活函数的导数形式。这篇文章适合谁看如果你已经会用 PyTorch 或 TensorFlow 搭模型但想真正搞懂底层原理那这篇就是写给你的。如果你刚开始学深度学习想找一个能跑通的、不依赖任何深度学习框架的完整实现这篇也适合你。前提是你得会一点 Python知道什么是矩阵乘法至于 NumPy会基本的数组操作就够了不会的部分我在文中会随时解释。整个项目最终会实现一个支持任意层数、任意神经元数量的全连接网络用反向传播算法训练能完成分类和回归任务。代码量不大核心逻辑大概两百行左右但每一行都值得你反复琢磨。1.2 全连接网络的核心结构长什么样在动手写代码之前先把我们要造的东西画清楚。一个全连接网络说白了就是一堆矩阵乘法加上非线性激活函数的堆叠。输入数据经过第一层权重矩阵的变换加上偏置再过激活函数变成第一层的输出这个输出又作为第二层的输入重复同样的操作直到最后一层输出预测结果。用数学语言描述就是对于第 $l$ 层$Z^{[l]} W^{[l]} \cdot A^{[l-1]} b^{[l]}$然后 $A^{[l]} g(Z^{[l]})$其中 $g$ 是激活函数。输入 $A^{[0]}$ 就是你的原始数据 $X$输出 $A^{[L]}$ 就是网络的预测值。这里有几个关键维度需要记住假设输入有 $n$ 个样本每个样本 $d$ 维特征那么 $X$ 的形状是 $(n, d)$。第一层有 $h_1$ 个神经元那么 $W^{[1]}$ 的形状是 $(d, h_1)$$b^{[1]}$ 的形状是 $(h_1,)$。经过运算后 $Z^{[1]}$ 和 $A^{[1]}$ 的形状都是 $(n, h_1)$。这个维度关系贯穿整个网络搞错了就会报各种形状不匹配的错。我见过太多人在这一步翻车包括我自己。所以后面写代码的时候我会在关键位置打印形状来验证这个习惯救了我无数次。1.3 反向传播到底在传什么反向传播这个名字听起来很玄乎但它的本质就是链式法则的高效应用。你想网络的输出是一个关于所有参数的复合函数损失函数又是输出的函数那损失对某个参数的梯度就得一层层往回乘偏导数。具体来说假设损失函数是 $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 L}{\partial Z^{[l]}}$ 又可以继续往前传变成 $\frac{\partial L}{\partial A^{[l]}} \cdot \frac{\partial A^{[l]}}{\partial Z^{[l]}}$。所以反向传播的核心就是维护一个“误差项” $\delta^{[l]} \frac{\partial L}{\partial Z^{[l]}}$然后从最后一层往前逐层计算。最后一层的 $\delta^{[L]}$ 取决于损失函数的形式中间的 $\delta^{[l]}$ 则由后一层的 $\delta^{[l1]}$ 乘以权重矩阵再乘以激活函数的导数得到。这个过程的计算量和前向传播相当但如果没有它你就得对每个参数单独求导参数量一多就完全不可行了。反向传播的精妙之处就在于它复用了中间结果把复杂度从指数级降到了线性级。2. 环境准备与基础工具选型2.1 NumPy 安装与常见坑NumPy 的安装本身不复杂pip install numpy一行命令就搞定了。但我在实际使用中遇到过不少让人抓狂的问题这里集中说一下。最常见的就是ModuleNotFoundError: No module named numpy。明明在终端里pip install成功了一到 PyCharm 里运行就报这个错。原因几乎都是解释器选错了。PyCharm 默认可能用的是系统 Python 或者另一个虚拟环境而你安装 NumPy 的那个环境根本不是它。解决办法很简单打开 PyCharm 的设置找到 Project Interpreter确认它指向的是你安装了 NumPy 的那个解释器。如果你用的是虚拟环境记得先激活虚拟环境再安装。还有一种情况是版本冲突。比如你之前装过旧版本的 NumPy新装的包和它不兼容。这时候可以先用pip list看看当前装了哪些版本必要时pip uninstall numpy卸载干净再重装。我一般会指定版本安装比如pip install numpy1.26.0这样比较可控。如果你用的是 Anaconda那更简单conda install numpy就行conda 会自动处理依赖关系。不过要注意conda 环境和 pip 环境有时候会打架尽量在同一个环境里只用一种包管理工具。2.2 为什么不用 PyTorch 而选 NumPy这个问题我被问过很多次。用 PyTorch 搭网络十行代码就能跑起来何必费劲用 NumPy 手写答案很简单学习目的不同。PyTorch 帮你把反向传播、梯度更新、计算图全都封装好了你只需要定义前向传播剩下的它自动搞定。这当然高效但也意味着你失去了对底层细节的掌控。当你遇到梯度爆炸、梯度消失、自定义层不工作的时候如果你不理解反向传播的原理就只能靠猜和试。NumPy 则完全不同。它只提供矩阵运算、数学函数这些基础工具没有任何深度学习相关的封装。你得自己定义权重初始化、自己实现前向传播、自己推导反向传播公式、自己写梯度下降。这个过程很痛苦但走完一遍之后你对神经网络的理解会发生质变。而且NumPy 的实现还有一个好处它足够透明。每一行代码在做什么你都能看得清清楚楚。没有隐藏的魔法没有自动求导的黑箱。这对于调试和理解来说价值巨大。当然如果你是要做实际项目那还是老老实实用 PyTorch。手写 NumPy 版本适合学习和理解原理不适合生产环境。这一点得说清楚免得有人拿它去跑大规模任务然后回来骂我。2.3 项目文件结构规划虽然是个小项目但文件结构还是得规划一下不然代码一多就乱了。我的习惯是分成三个文件network.py核心网络类的定义包括初始化、前向传播、反向传播、参数更新。activations.py激活函数及其导数的实现比如 ReLU、Sigmoid、Tanh、Softmax。train.py训练脚本负责加载数据、实例化网络、跑训练循环、打印损失和准确率。这样分的好处是职责清晰激活函数可以单独测试网络类可以单独调试训练脚本只关心流程控制。如果你只想快速跑通也可以把所有代码写在一个文件里但后期维护会麻烦一些。我还会在项目根目录放一个requirements.txt写上numpy和matplotlib方便复现环境。matplotlib 主要用来画损失曲线和决策边界不是必须的但有的话调试起来更直观。3. 核心模块的逐行实现3.1 激活函数非线性从哪来如果网络里只有矩阵乘法那不管叠多少层最终等价于一个线性变换。这样的网络再深也没用因为线性模型的表达能力太有限了。激活函数的作用就是引入非线性让网络能拟合复杂的函数关系。我实现了四个激活函数ReLU、Sigmoid、Tanh 和 Softmax。每个都需要两个方法前向计算和导数计算。ReLU 是最常用的定义为 $f(x) \max(0, x)$。它的导数在 $x 0$ 时是 1在 $x \leq 0$ 时是 0。实现起来很简单但要注意在 $x 0$ 处的处理实践中一般取 0 就行。ReLU 的优点是计算快、不容易梯度消失缺点是可能出现“神经元死亡”的问题也就是某些神经元永远输出 0梯度永远是 0再也活不过来。Sigmoid 定义为 $f(x) \frac{1}{1 e^{-x}}$导数可以用自身表示$f(x) f(x)(1 - f(x))$。它的输出范围是 $(0, 1)$适合二分类的输出层。但用在隐藏层时容易导致梯度消失因为当输入很大或很小时导数都趋近于 0。Tanh 定义为 $f(x) \frac{e^x - e^{-x}}{e^x e^{-x}}$导数 $f(x) 1 - f(x)^2$。输出范围是 $(-1, 1)$比 Sigmoid 好一些因为它是零中心的但仍然有梯度消失的问题。Softmax 比较特殊它把一组实数变成概率分布$f(x_i) \frac{e^{x_i}}{\sum_j e^{x_j}}$。它通常用在多分类的输出层配合交叉熵损失使用。Softmax 的导数是一个雅可比矩阵但在实际实现中我们一般不单独计算它的导数而是把它和交叉熵损失合并在一起简化梯度计算。import numpy as np class ReLU: def forward(self, x): self.input x return np.maximum(0, x) def backward(self, grad): return grad * (self.input 0) class Sigmoid: def forward(self, x): self.output 1 / (1 np.exp(-np.clip(x, -500, 500))) return self.output def backward(self, grad): return grad * self.output * (1 - self.output) class Tanh: def forward(self, x): self.output np.tanh(x) return self.output def backward(self, grad): return grad * (1 - self.output ** 2)注意 Sigmoid 里我用了np.clip来防止指数溢出。当 $x$ 是很大的负数时$e^{-x}$ 会变成无穷大导致计算结果出错。裁剪到 $[-500, 500]$ 是个实用的技巧不影响正常范围内的精度。3.2 权重初始化别让网络一开始就死了权重初始化看起来是个小问题但它对训练的影响非常大。如果权重全初始化为 0那所有神经元的输出都一样反向传播时梯度也一样网络永远学不到东西。如果权重初始化得太大前向传播时激活值会爆炸梯度也会爆炸太小则激活值趋近于 0梯度消失。常用的初始化方法有几种。Xavier 初始化适合 Sigmoid 和 Tanh 激活函数它让权重的方差为 $\frac{2}{n_{in} n_{out}}$其中 $n_{in}$ 和 $n_{out}$ 分别是输入和输出的神经元数量。He 初始化适合 ReLU方差为 $\frac{2}{n_{in}}$因为 ReLU 会把一半的神经元置零所以需要更大的方差来补偿。我一般用 He 初始化因为 ReLU 是我的默认激活函数。实现起来就是np.random.randn(n_in, n_out) * np.sqrt(2.0 / n_in)。这里的randn生成标准正态分布乘以标准差就得到了指定方差的正态分布。偏置通常初始化为 0这没什么问题因为权重已经打破了对称性。注意初始化时一定要用随机数不要用全零或全一。我见过有人为了“简单”把权重全设为 0.1结果训练半天损失不降排查了好久才发现是初始化的问题。3.3 前向传播数据怎么流过网络前向传播的逻辑很直接对于每一层先算线性变换 $Z A_{prev} \cdot W b$再过激活函数 $A g(Z)$。把每一层的中间结果缓存下来因为反向传播的时候要用。class LinearLayer: def __init__(self, n_in, n_out): self.W np.random.randn(n_in, n_out) * np.sqrt(2.0 / n_in) self.b np.zeros((1, n_out)) self.dW None self.db None self.input None def forward(self, x): self.input x return x self.W self.b def backward(self, grad): self.dW self.input.T grad self.db np.sum(grad, axis0, keepdimsTrue) return grad self.W.T这里有几个细节值得说。self.input缓存了输入因为计算dW需要它。db的计算用了np.sum沿 batch 维度求和因为偏置对每个样本的贡献是相同的梯度需要累加。返回的梯度grad self.W.T是传给前一层的形状和前一层的输出一致。维度验证假设输入x形状是(batch, n_in)W形状是(n_in, n_out)那么x W形状是(batch, n_out)加上b形状(1, n_out)广播后仍是(batch, n_out)结果正确。反向传播时grad形状是(batch, n_out)self.input.T形状是(n_in, batch)相乘得到(n_in, n_out)和W形状一致。grad self.W.T形状是(batch, n_in)和输入一致。完美。3.4 损失函数衡量预测有多离谱分类任务用交叉熵损失回归任务用均方误差。这里重点说交叉熵因为它和 Softmax 的配合有个很漂亮的简化。交叉熵的定义是 $L -\frac{1}{n} \sum_{i} \sum_{c} y_{ic} \log(\hat{y}_{ic})$其中 $y$ 是真实标签的 one-hot 编码$\hat{y}$ 是 Softmax 输出的概率。如果直接对 Softmax 的输出求导会得到一个雅可比矩阵计算量大。但如果把 Softmax 和交叉熵合并梯度就变成了 $\hat{y} - y$非常简洁。这就是为什么深度学习框架里 Softmax 和交叉熵总是成对出现。我在实现的时候把 Softmax 放在网络最后一层损失函数直接接收 Softmax 的输出和真实标签反向传播时梯度就是(softmax_output - y) / batch_size。class CrossEntropyLoss: def forward(self, y_pred, y_true): y_pred np.clip(y_pred, 1e-12, 1.0) self.y_pred y_pred self.y_true y_true return -np.mean(np.sum(y_true * np.log(y_pred), axis1)) def backward(self): return (self.y_pred - self.y_true) / self.y_true.shape[0]np.clip是为了防止log(0)出现负无穷。虽然 Softmax 的输出理论上不会精确为 0但浮点运算可能会产生极小的值裁剪一下更安全。3.5 反向传播误差如何逐层回传反向传播是整个网络最核心的部分。我把它拆成两步先算输出层的梯度再逐层往前传。输出层的梯度取决于损失函数。如果是交叉熵加 Softmax梯度就是y_pred - y_true。如果是均方误差梯度是2 * (y_pred - y_true) / n。这个梯度就是最后一层的“误差项” $\delta^{[L]}$。然后从最后一层往前遍历对于每一层先用激活函数的backward把误差项传过激活函数得到对线性输出的梯度再用线性层的backward计算权重和偏置的梯度并把误差项继续往前传。def backward(self, loss_grad): grad loss_grad for layer, activation in zip(reversed(self.layers), reversed(self.activations)): grad activation.backward(grad) grad layer.backward(grad)这个循环的顺序很关键先过激活函数再过线性层。因为前向传播时是先线性后激活反向传播就要反过来。我一开始写反了梯度怎么都对不上后来画了个计算图才理清楚。3.6 参数更新梯度下降的几种变体最简单的参数更新就是批量梯度下降$W W - \eta \cdot dW$其中 $\eta$ 是学习率。但纯批量梯度下降收敛慢容易陷入局部最优。实践中常用的是带动量的梯度下降或 Adam 优化器。动量法的思想是让更新方向不仅取决于当前梯度还取决于之前的更新方向相当于给梯度加了个“惯性”。这样在梯度方向一致的维度上加速在梯度方向震荡的维度上抑制震荡。实现起来就是维护一个速度变量 $v$每次更新 $v \beta v (1 - \beta) dW$然后 $W W - \eta v$。Adam 更复杂一些它同时维护梯度的一阶矩和二阶矩估计并做偏差校正。虽然代码多了几行但收敛速度和稳定性通常更好。我在项目里实现了这两种你可以根据任务选择。class SGDMomentum: def __init__(self, lr0.01, momentum0.9): self.lr lr self.momentum momentum self.velocities {} def update(self, layer, layer_id): if layer_id not in self.velocities: self.velocities[layer_id] { W: np.zeros_like(layer.W), b: np.zeros_like(layer.b) } v self.velocities[layer_id] v[W] self.momentum * v[W] - self.lr * layer.dW v[b] self.momentum * v[b] - self.lr * layer.db layer.W v[W] layer.b v[b]学习率的选择是个经验活。太大了会震荡甚至发散太小了收敛慢。我一般从 0.01 开始试如果损失震荡就降到 0.001如果下降太慢就升到 0.05。配合动量的话学习率可以稍微大一点。4. 完整训练流程与实操记录4.1 数据准备从原始数据到网络输入我用的是经典的鸢尾花数据集和手写数字数据集来测试。鸢尾花数据集有 150 个样本4 个特征3 个类别适合快速验证。手写数字数据集有 1797 个样本64 个特征10 个类别稍微复杂一些。数据预处理包括两步归一化和 one-hot 编码。归一化是把每个特征缩放到均值为 0、方差为 1 的范围这样梯度下降收敛更快。one-hot 编码是把类别标签变成向量比如类别 2 变成[0, 0, 1]。def normalize(X): return (X - X.mean(axis0)) / (X.std(axis0) 1e-8) def one_hot(y, n_classes): return np.eye(n_classes)[y]np.eye生成单位矩阵用类别索引去取对应的行就得到了 one-hot 向量。这个技巧很常用比循环赋值快得多。数据还要划分训练集和测试集一般是 80/20 或 70/30。我用np.random.permutation打乱索引然后按比例切分。注意打乱之前要固定随机种子不然每次跑的结果都不一样没法对比。4.2 网络搭建层数、神经元与超参数选择网络结构的选择没有固定公式得根据任务复杂度来。鸢尾花数据集比较简单我用一个隐藏层10 个神经元就够了。手写数字复杂一些我用两个隐藏层每层 64 个神经元。超参数方面学习率 0.01动量 0.9batch size 32训练 200 轮。这些值是我试出来的不一定最优但能稳定收敛。你可以根据实际情况调整。class NeuralNetwork: def __init__(self, layer_sizes, activationrelu): self.layers [] self.activations [] for i in range(len(layer_sizes) - 1): self.layers.append(LinearLayer(layer_sizes[i], layer_sizes[i1])) if i len(layer_sizes) - 2: self.activations.append(ReLU() if activation relu else Tanh()) else: self.activations.append(Softmax())layer_sizes是一个列表比如[4, 10, 3]表示输入 4 维隐藏层 10 个神经元输出 3 类。最后一层用 Softmax因为是多分类任务。4.3 训练循环前向、反向、更新训练循环的骨架很固定取一个 batch 的数据前向传播算预测值算损失反向传播算梯度更新参数。重复这个过程直到遍历完所有数据算一个 epoch。跑多个 epoch观察损失和准确率的变化。for epoch in range(n_epochs): indices np.random.permutation(n_samples) for start in range(0, n_samples, batch_size): batch_idx indices[start:startbatch_size] X_batch X_train[batch_idx] y_batch y_train[batch_idx] y_pred net.forward(X_batch) loss criterion.forward(y_pred, y_batch) grad criterion.backward() net.backward(grad) optimizer.update(net) if epoch % 10 0: y_pred_full net.forward(X_train) train_loss criterion.forward(y_pred_full, y_train) train_acc accuracy(y_pred_full, y_train) print(fEpoch {epoch}: loss{train_loss:.4f}, acc{train_acc:.4f})我习惯每 10 个 epoch 打印一次训练损失和准确率这样能看出收敛趋势。如果损失不降反升那多半是学习率太大了如果损失降得很慢可能是学习率太小或者网络容量不够。4.4 结果可视化损失曲线与决策边界光看数字不够直观我一般会画两张图损失曲线和决策边界。损失曲线用 matplotlib 画横轴是 epoch纵轴是损失值。正常的曲线应该是一条下降的曲线最后趋于平缓。如果曲线震荡剧烈说明学习率偏大如果曲线几乎不降说明学习率偏小或者初始化有问题。决策边界图适合二维特征的数据。对于鸢尾花数据集我取前两个特征在平面上画网格对每个网格点预测类别然后用不同颜色填充。这样能直观地看到网络学到的分类边界是什么样的。import matplotlib.pyplot as plt plt.plot(losses) plt.xlabel(Epoch) plt.ylabel(Loss) plt.title(Training Loss Curve) plt.show()matplotlib 在 PyCharm 里显示有时候会有点问题如果plt.show()不弹窗可以试试在设置里把 SciView 关掉或者用plt.savefig()保存成图片再打开。5. 踩坑记录与排查技巧5.1 梯度消失与梯度爆炸梯度消失和梯度爆炸是训练深层网络时最常见的问题。梯度消失表现为靠近输入层的参数几乎不更新损失降不下去梯度爆炸表现为损失变成 NaN 或者剧烈震荡。诊断方法很简单在反向传播的时候打印每一层梯度的范数。如果从后往前梯度范数急剧减小那就是梯度消失如果急剧增大那就是梯度爆炸。解决梯度消失的办法有用 ReLU 代替 Sigmoid/Tanh用 He 初始化加 Batch Normalization或者用残差连接。解决梯度爆炸的办法有梯度裁剪也就是把梯度范数限制在一个阈值内降低学习率用更小的初始化方差。我在手写数字数据集上就遇到过梯度爆炸损失跑到 NaN。后来加了梯度裁剪把每层梯度的范数限制在 5 以内问题就解决了。def clip_gradients(layers, max_norm5.0): for layer in layers: norm np.linalg.norm(layer.dW) if norm max_norm: layer.dW layer.dW * max_norm / norm5.2 损失不下降的几种可能损失不下降的原因很多我按排查顺序列一下第一检查数据。标签和特征是不是对上了有没有 NaN 或 Inf归一化做了没有我有一次忘了归一化特征值范围从 0 到 1000学习率 0.01 根本走不动损失一直卡在高位。第二检查初始化。权重是不是全零了方差是不是太大或太小打印一下初始权重的均值和标准差正常应该在 0 附近标准差在 0.1 左右。第三检查学习率。太大导致震荡太小导致停滞。可以试试跑几个不同的学习率看哪个收敛最好。第四检查反向传播。梯度公式推导对不对维度有没有搞错可以拿一个简单的例子手动算一遍梯度和代码的输出对比。第五检查激活函数。Sigmoid 在深层网络里容易饱和换成 ReLU 试试。5.3 过拟合与正则化手段如果训练集准确率很高但测试集准确率很低那就是过拟合了。过拟合的本质是模型把训练数据的噪声也学进去了泛化能力差。解决过拟合的手段有几种。最简单的是增加数据量但很多时候数据就那么多没法增。其次是降低模型复杂度减少层数或神经元数量。再就是加正则化L2 正则化在损失函数里加上权重平方和的惩罚项Dropout 在训练时随机丢弃一部分神经元。我在这个项目里实现了 L2 正则化因为它的实现最简单只需要在计算损失和梯度的时候加上正则项。Dropout 稍微复杂一些需要在训练时随机置零并缩放测试时不做处理。def l2_regularization(layers, lambda_reg): reg_loss 0 for layer in layers: reg_loss 0.5 * lambda_reg * np.sum(layer.W ** 2) return reg_losslambda_reg控制正则化强度一般取 0.001 到 0.01。太大了会导致欠拟合太小了没效果。5.4 常见报错速查表报错信息可能原因解决方法ValueError: shapes not aligned矩阵维度不匹配打印每层输入输出形状检查权重矩阵维度RuntimeWarning: overflow指数运算溢出对输入做 clip或用 log-sum-exp 技巧RuntimeWarning: invalid value出现 NaN 或 Inf检查学习率是否过大加梯度裁剪ModuleNotFoundError: numpy解释器选错确认 PyCharm 解释器指向正确的环境损失变成 NaN梯度爆炸或 log(0)加梯度裁剪clip 概率值准确率不涨学习率太小或初始化差调大学习率换 He 初始化这张表是我踩坑踩出来的基本覆盖了 90% 的常见问题。遇到报错先查表查不到再逐步排查。6. 从手写实现到框架使用的衔接6.1 对照 PyTorch 理解自动求导手写了一遍反向传播之后再回头看 PyTorch 的autograd会有一种豁然开朗的感觉。PyTorch 的计算图本质上就是记录前向传播的每一步操作反向传播时沿着图反向遍历用链式法则自动计算梯度。loss.backward()那一行等价于我在backward方法里做的所有事情。optimizer.step()等价于我的参数更新。optimizer.zero_grad()等价于我把dW和db清零。理解了这些对应关系再用 PyTorch 就不是黑箱了。6.2 手写实现能帮你避开的坑用框架的时候很多错误你根本不知道从哪查。比如自定义损失函数梯度不对框架不会告诉你哪里错了只会给你一个错误的训练结果。但如果你手写过反向传播你就知道梯度应该长什么样能快速定位问题。再比如你想实现一个新的层或者新的激活函数框架里没有现成的你得自己写。这时候如果你理解前向和反向的对应关系写起来就很快。如果只会调包那就只能干瞪眼。6.3 后续可以扩展的方向这个项目虽然简单但扩展空间很大。你可以加卷积层把全连接网络变成卷积神经网络可以加循环层处理序列数据可以加 Batch Normalization加速训练可以加 Dropout抑制过拟合。每一个扩展都需要你理解对应的前向和反向公式但有了这个项目的基础学起来会快很多。我后来加了一个简单的卷积层虽然性能不如框架但跑通了之后对卷积的理解深刻了不少。最后分享一个小技巧如果你在调试反向传播可以用数值梯度来验证。数值梯度的原理是对每个参数加一个很小的扰动计算损失的变化除以扰动值得到梯度的近似值。虽然慢但很准。把数值梯度和反向传播算出来的梯度对比如果相对误差在 1e-6 以内说明反向传播实现正确。def numerical_gradient(f, x, eps1e-5): grad np.zeros_like(x) it np.nditer(x, flags[multi_index]) while not it.finished: idx it.multi_index old x[idx] x[idx] old eps f_plus f(x) x[idx] old - eps f_minus f(x) grad[idx] (f_plus - f_minus) / (2 * eps) x[idx] old it.iternext() return grad这个函数我用了很多次每次改反向传播代码之后都会跑一遍验证。虽然麻烦但能省下大量排查梯度错误的时间。
返回列表