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^{[l+1]}$ 乘以权重矩阵再乘以激活函数的导数得到。
这个过程的计算量和前向传播相当,但如果没有它,你就得对每个参数单独求导,参数量一多就完全不可行了。反向传播的精妙之处就在于,它复用了中间结果,把复杂度从指数级降到了线性级。
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 numpy==1.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, axis=0, keepdims=True) 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), axis=1)) 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, lr=0.01, momentum=0.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(axis=0)) / (X.std(axis=0) + 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.9,batch size 32,训练 200 轮。这些值是我试出来的,不一定最优,但能稳定收敛。你可以根据实际情况调整。
class NeuralNetwork: def __init__(self, layer_sizes, activation='relu'): self.layers = [] self.activations = [] for i in range(len(layer_sizes) - 1): self.layers.append(LinearLayer(layer_sizes[i], layer_sizes[i+1])) 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:start+batch_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(f"Epoch {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_norm=5.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, eps=1e-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这个函数我用了很多次,每次改反向传播代码之后都会跑一遍验证。虽然麻烦,但能省下大量排查梯度错误的时间。