纯Python从零实现前馈神经网络:手写前向与反向传播
1. 从零搭建前馈神经网络:整体设计思路与方案选型
很多人学神经网络,卡在第一步不是数学,而是"我到底该从哪下手"。上一篇文章我们把神经元、权重、偏置这些概念过了一遍,这一篇直接进入实战:用纯 Python 从零实现一个前馈神经网络,不依赖 PyTorch、TensorFlow 这些框架,把前向传播和反向传播的每一个矩阵运算都亲手写出来。
为什么非要"从零开始"?我自己的体会是,调包能让你快速跑通一个模型,但一旦 loss 不下降、梯度爆炸、维度对不上,你就完全懵了。而手写一遍之后,你会对shape的流转、梯度的来源、学习率的作用有肌肉记忆。这就像学开车,你可以直接上路,但懂离合和变速箱原理的人,遇到坡道起步才不会慌。
这一篇的目标很明确:实现一个支持任意层数、任意激活函数的全连接网络,用 MNIST 手写数字数据集做验证,最终在测试集上跑到 95% 以上的准确率。适合已经会 Python 基础语法、懂一点 numpy、但没亲手写过反向传播的读者。如果你连python安装、python环境变量配置都还没搞定,建议先把环境弄利索——python官网下载装个 3.8 以上的版本,python安装numpy库的方法一条pip install numpy就够,别在这上面纠结太久。
1.1 为什么选纯 numpy 而不是框架
框架的本质是把计算图、自动求导、GPU 加速这些脏活累活封装起来。你写loss.backward()一行就完事,但反向传播到底怎么传的,框架不会告诉你。纯 numpy 实现的好处是:每一行代码你都能追溯到数学公式,每一个梯度你都能手动验证。
代价也很明显——没有 GPU 加速,跑 MNIST 这种小数据集还行,换成 ImageNet 级别的任务就歇菜了。但学习阶段,这个代价完全值得。我建议的路径是:先用 numpy 手写一遍,理解透了,再去用框架,这时候你看框架的文档会有"原来如此"的感觉。
另一个考量是可调试性。框架报错经常是一堆 C++ 堆栈,你根本不知道哪层出了问题。而 numpy 实现里,你可以在任何一步print(x.shape),可以数值梯度校验,可以单步跟踪。这种掌控感是学习阶段最需要的。
1.2 网络结构的整体规划
我们要实现的网络结构是这样的:输入层 784 维(28x28 的图片拉平),中间若干隐藏层,输出层 10 维(对应 0-9 十个数字)。激活函数隐藏层用 ReLU,输出层用 Softmax,损失函数用交叉熵。
为什么这么选?ReLU 计算简单、梯度不饱和,是隐藏层的默认选择;Softmax + 交叉熵的组合,梯度形式极其简洁(就是预测值 - 真实值),推导起来不痛苦。这套组合是业界最经典的分类网络配置,理解了它,你再看 CNN、RNN 就不会觉得突兀。
层数上,我建议先用784 -> 128 -> 10这个两层结构跑通,再逐步加深。别一上来就搞五六层,调试起来会让你怀疑人生。等两层跑通了,改成784 -> 256 -> 128 -> 10,观察准确率的变化,这个过程本身就是很好的实验。
1.3 模块划分与代码组织
整个项目我拆成四个模块:数据加载、网络定义、训练循环、评估与可视化。数据加载负责读 MNIST 并做归一化;网络定义里封装forward和backward;训练循环管学习率、批次、epoch;评估部分算准确率、画 loss 曲线。
这种拆分不是为了好看,而是为了可替换。比如你想换个数据集,只改数据加载模块;想换激活函数,只改网络定义。我见过太多人把所有代码堆在一个文件里,改一处崩一片,最后自己都理不清。
提示:代码组织上,建议每个模块单独一个
.py文件,用import串联。这样调试时能单独测试每个模块,比如先确认数据加载没问题,再测网络前向,最后测反向。
2. 核心细节解析:前向传播与反向传播的数学落地
这一节是全文的重头戏。前向传播好理解,就是矩阵乘法加激活;反向传播才是真正劝退人的地方。我会把每个梯度的推导过程写清楚,并且给出对应的 numpy 代码,让你能一一对应。
2.1 前向传播:从输入到输出的数据流
前向传播的本质是一串矩阵乘法。假设第l层的输入是a[l-1],权重是W[l],偏置是b[l],那么:
z[l] = a[l-1] @ W[l] + b[l] a[l] = activation(z[l])这里的@是矩阵乘法。维度上要特别注意:如果上一层有n个神经元,这一层有m个,那么W[l]的形状是(n, m),b[l]的形状是(m,)。批次维度在最前面,所以实际计算时a[l-1]的形状是(batch_size, n)。
我踩过的第一个坑就是维度搞反。W写成(m, n)的话,矩阵乘法直接报错,或者更坑的是——形状恰好能乘但结果完全错。所以每次写完一层,我都会print一下 shape,确认无误再往下走。
def forward(self, x): self.activations = [x] self.zs = [] a = x for i in range(self.num_layers - 1): z = a @ self.weights[i] + self.biases[i] self.zs.append(z) if i < self.num_layers - 2: a = self.relu(z) else: a = self.softmax(z) self.activations.append(a) return a这段代码里,我把每一层的中间结果都存下来了,因为反向传播要用。这是用空间换时间的典型做法,MNIST 数据量小无所谓,大数据集就要考虑显存了。
2.2 反向传播:链式法则的矩阵形式
反向传播的核心是链式法则。我们从损失函数开始,逐层往回求梯度。交叉熵损失对输出层z的梯度有个非常漂亮的结果:
dz[L] = a[L] - y其中a[L]是 softmax 输出,y是 one-hot 标签。这个结论我建议你亲手推一遍,推完你会发现 Softmax 和交叉熵简直是天生一对,导数形式简洁到不可思议。
有了dz[L],往回传就顺了:
dW[l] = a[l-1].T @ dz[l] / batch_size db[l] = sum(dz[l], axis=0) / batch_size da[l-1] = dz[l] @ W[l].T dz[l-1] = da[l-1] * relu_derivative(z[l-1])注意dW里的.T和除法。.T是因为我们要把批次维度消掉,得到权重的梯度;除以batch_size是因为损失是批次平均,梯度也要平均。这个除法很多人会忘,忘了之后梯度会大batch_size倍,学习率就得相应调小,很容易调崩。
def backward(self, y): m = y.shape[0] dz = self.activations[-1] - y for i in range(self.num_layers - 2, -1, -1): dW = self.activations[i].T @ dz / m db = np.sum(dz, axis=0) / m if i > 0: da = dz @ self.weights[i].T dz = da * self.relu_derivative(self.zs[i-1]) self.dweights[i] = dW self.dbiases[i] = db2.3 激活函数的选择与实现细节
ReLU 的实现是max(0, x),导数在x > 0时为 1,否则为 0。这里有个细节:x = 0时导数取 0 还是 1?实践中取 0 就行,影响微乎其微。
Softmax 要小心数值溢出。直接exp(x)当x很大时会溢出成inf,标准做法是先减去每行的最大值:
def softmax(self, x): x_shifted = x - np.max(x, axis=1, keepdims=True) exp_x = np.exp(x_shifted) return exp_x / np.sum(exp_x, axis=1, keepdims=True)这个keepdims=True很关键,不加的话广播会出错。我当初就是漏了这个参数,调了半天才发现是维度问题。
注意:ReLU 有个"死亡神经元"问题——如果某个神经元的输入长期为负,梯度恒为 0,它就再也活不过来了。缓解办法是用 Leaky ReLU,或者把学习率调小一点。我在 MNIST 上没遇到这个问题,但换成更深网络时确实碰到过。
2.4 权重初始化:别小看这一步
权重初始化对训练影响巨大。全零初始化会让所有神经元对称,等于白搭;初始化太大梯度爆炸,太小梯度消失。经典方案是 He 初始化,专门为 ReLU 设计:
W ~ N(0, sqrt(2 / n_in))其中n_in是输入维度。为什么是 2 而不是 1?因为 ReLU 会把一半的神经元置零,方差要补偿回来。这个推导来自 He 那篇论文,实践中效果确实比简单的0.01 * randn好很多。
self.weights.append(np.random.randn(n_in, n_out) * np.sqrt(2.0 / n_in)) self.biases.append(np.zeros(n_out))偏置初始化为 0 是安全的,因为对称性已经被随机权重打破了。
3. 实操过程:从数据加载到模型训练全流程
理论讲完了,这一节我们动手跑起来。我会把完整流程拆成可复现的步骤,每一步都说明为什么这么做,以及可能踩的坑。
3.1 数据准备与预处理
MNIST 数据集有 60000 张训练图、10000 张测试图,每张 28x28。原始像素值是 0-255 的整数,直接喂给网络会导致梯度很大,所以要归一化到 0-1:
x_train = x_train.astype(np.float32) / 255.0 x_train = x_train.reshape(-1, 784)标签要做 one-hot 编码,因为交叉熵需要:
def one_hot(y, num_classes=10): return np.eye(num_classes)[y]np.eye生成单位矩阵,用标签索引就得到 one-hot 向量,比循环快得多。这个技巧我第一次见的时候觉得很妙,一行顶十行。
数据加载这块,如果你本地没有 MNIST 文件,可以用sklearn.datasets.fetch_openml下载,或者手动下载后解析。我建议第一次跑的时候把数据量缩小,比如只用前 1000 张,确认流程通了再上全量。
3.2 网络类的完整实现
把前面的片段组装起来,网络类大概长这样:
class NeuralNetwork: def __init__(self, layer_sizes): self.num_layers = len(layer_sizes) self.weights = [] self.biases = [] for i in range(self.num_layers - 1): n_in, n_out = layer_sizes[i], layer_sizes[i+1] self.weights.append(np.random.randn(n_in, n_out) * np.sqrt(2.0/n_in)) self.biases.append(np.zeros(n_out))layer_sizes是个列表,比如[784, 128, 10]。这种设计让网络层数完全可配置,想加层就改列表,不用动其他代码。
训练循环里,每个 epoch 把数据打乱,按批次切分,逐批做前向、反向、更新:
for epoch in range(epochs): indices = np.random.permutation(len(x_train)) for start in range(0, len(x_train), batch_size): batch_idx = indices[start:start+batch_size] x_batch, y_batch = x_train[batch_idx], y_train[batch_idx] output = net.forward(x_batch) net.backward(y_batch) net.update(lr)打乱数据很重要,不然模型会学到样本顺序的伪规律。这个坑我在早期项目里踩过,loss 曲线诡异得很,打乱之后立刻正常。
3.3 超参数选择与调优记录
超参数这块,我记录一下自己实测的组合:
| 参数 | 取值 | 说明 |
|---|---|---|
| 学习率 | 0.1 | 太大震荡,太小收敛慢 |
| 批次大小 | 64 | 太小噪声大,太大内存吃紧 |
| 隐藏层 | 128 | 再大提升有限 |
| epoch | 20 | 10 轮后基本收敛 |
| 优化器 | SGD | 先用最朴素的 |
学习率 0.1 是 SGD 在 MNIST 上的经典值。我试过 0.01,收敛太慢;试过 0.5,loss 直接震荡不下降。批次大小 64 是个平衡点,你可以试试 32 和 128,感受一下噪声和速度的权衡。
优化器我故意用最朴素的 SGD,因为加了动量、Adam 之后,你就看不清梯度本身的行为了。等 SGD 跑通了,再换 Adam 对比,你会发现 Adam 收敛快但最终精度未必更高,这个观察很有意思。
3.4 训练过程监控与结果验证
训练时每个 epoch 打印一次 loss 和准确率,这是最基本的监控。loss 曲线应该是平滑下降的,如果出现尖刺,多半是学习率太大;如果几乎不降,检查梯度是否算错。
准确率的计算:
predictions = np.argmax(net.forward(x_test), axis=1) accuracy = np.mean(predictions == y_test_labels)argmax取最大值的索引,就是预测的数字。这个操作在分类任务里到处都是,务必熟练。
我实测下来,784 -> 128 -> 10这个结构跑 20 个 epoch,测试准确率能到 97% 左右。如果只有 90% 出头,多半是某个环节有问题,往下看排查部分。
4. 常见问题与排查技巧实录
这一节是我踩坑的精华。手写神经网络,90% 的时间不是在写代码,而是在 debug。下面这些问题是高频中的高频。
4.1 梯度校验:数值梯度的黄金标准
反向传播写错了,最直接的检测方法就是数值梯度校验。原理很简单:梯度的定义是f(x+h) - f(x-h) / 2h,用这个近似值和你的解析梯度对比,误差应该在 1e-7 量级。
def numerical_gradient(f, x, h=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 + h fx1 = f(x) x[idx] = old - h fx2 = f(x) grad[idx] = (fx1 - fx2) / (2*h) x[idx] = old it.iternext() return grad这个函数跑起来慢,但极其可靠。我建议在正式训练前,用小网络(比如4 -> 3 -> 2)和少量数据做一次校验,确认梯度无误再上大网络。这一步花十分钟,能省你几小时的瞎调。
4.2 常见报错与对应排查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| loss 不下降 | 学习率太小/梯度算错 | 梯度校验,调大学习率 |
| loss 变 NaN | 学习率太大/除零 | 减小学习率,检查 log |
| 准确率卡在 10% | 输出层或损失写错 | 检查 softmax 和 one-hot |
| 维度报错 | 矩阵形状不匹配 | 逐层 print shape |
| 训练快测试差 | 过拟合 | 加正则、减层数 |
准确率卡在 10% 是个经典症状,意味着模型在瞎猜(10 分类随机就是 10%)。这时候八成是 softmax 或交叉熵写错了,或者标签没做 one-hot。
4.3 那些文档不会告诉你的实操心得
第一个心得:先跑通再优化。别一上来就追求 99% 准确率,先用小数据、小网络确认流程无误,再逐步加码。我见过太多人卡在调参上,其实流程本身就有 bug。
第二个心得:打印中间结果。前向传播时打印每层输出的均值和方差,正常情况应该在合理范围。如果某层输出全是 0 或者爆炸到 1e10,问题就在那层。
第三个心得:学习率是最重要的超参数。其他参数调半天,不如把学习率找对。我的做法是从 0.1 开始,如果 loss 震荡就除以 10,如果下降太慢就乘以 3,几次就能找到合适区间。
第四个心得:别迷信深度。MNIST 这种简单任务,两层网络足够。加层不一定提升,反而可能过拟合。深度是为复杂任务准备的,简单任务上深网络是杀鸡用牛刀。
提示:如果你在
python安装或环境配置上遇到问题,比如numpy导入报错,先确认 Python 版本和 pip 是否匹配。python 3.8以上基本没坑,老版本可能缺 wheel。
4.4 从手写实现到框架的过渡建议
手写跑通之后,我建议你立刻用框架复现一遍同样的网络,对比两者的代码量和运行速度。你会发现框架把反向传播、优化器、数据加载全封装了,代码量少一个数量级。
但这时候你已经知道框架在背后做了什么,遇到问题能定位到具体环节。这种"知其所以然"的状态,才是从零开始学习的真正价值。后面你再学 CNN、LSTM、GNN,底层逻辑都是相通的——无非是前向怎么算、反向怎么传、参数怎么更新。
我个人在实际操作中的体会是,手写一遍神经网络,比看十篇教程都管用。那些矩阵维度、梯度公式、数值稳定性问题,只有亲手写过才会真正刻进脑子里。等你哪天看到dz = a - y能立刻反应过来这是 softmax 加交叉熵的梯度,说明你真的懂了。