1. 从74行代码说起:为什么版本迭代比从零写更值得琢磨
很多人学神经网络,第一反应是找一份“能跑就行”的代码,跑通MNIST手写数字识别,看到准确率上去了,就觉得自己会了。但真正拉开差距的,往往不是“从零写一个网络”,而是“看懂别人为什么要把一段已经能跑的代码改掉”。Michael Nielsen那本《Neural Networks and Deep Learning》第一章里,有一段大约74行的Python代码,用来在MNIST数据集上识别手写数字。这段代码有两个版本:版本2和版本3。表面上看,版本3只是把版本2里的一些写法“整理”了一下,但如果你逐行对比,会发现这次修改背后藏着几个非常关键的工程判断——它们直接决定了这段代码能不能从“教学玩具”变成“可扩展的实验骨架”。
我自己第一次读这段代码的时候,也觉得版本2已经够用了:能加载数据、能前向传播、能反向传播、能跑mini-batch随机梯度下降,准确率也能到90%以上。那为什么还要改?后来在反复调试和扩展这个网络的过程中,我才慢慢意识到,版本2到版本3的修改,本质上是在解决三个问题:代码的可复用性、网络结构的可配置性、以及后续章节扩展的平滑性。这三个问题,恰恰是很多初学者在写自己的第一个神经网络时最容易忽略的。
这篇文章不会只给你一份“修改后的代码”,而是会带你走一遍我当初理解这次修改的完整思路:版本2到底哪里别扭、版本3为什么那样改、每个改动背后的工程逻辑是什么、以及如果你自己要写一个类似的网络,应该从这次修改里抄走哪些习惯。适合已经跑过MNIST、但对自己代码结构不太满意、想往“能改、能扩、能调”方向走的人。如果你还没跑过这段代码,也没关系,我会把关键上下文补全,让你能跟上每一步的判断。
2. 版本2的74行里,藏着哪些“能跑但不好改”的写法
2.1 网络参数被硬编码在初始化里
版本2最明显的一个特征,是网络的结构参数直接写死在Network.__init__里。比如层数、每层神经元数量、权重和偏置的初始化方式,都是固定的。你如果想改成“三层网络”或者“每层50个神经元”,就得手动去改好几处地方。这种写法在教学场景下没问题,因为读者只需要理解前向和反向的公式;但一旦你想做实验,比如比较不同隐藏层大小对准确率的影响,就会非常痛苦。
我当初就踩过这个坑:想把隐藏层从30改成100,结果改完发现权重矩阵的维度对不上,报错信息又不够直观,排查了半天才发现是某一处np.random.randn的参数没同步改。这种“改一处、崩三处”的体验,是版本2最让人难受的地方。
2.2 权重和偏置的初始化分散在各处
版本2里,权重和偏置的初始化逻辑是分散的:biases和weights分别用列表推导式生成,但生成方式写死在__init__里。这意味着如果你想换一种初始化策略,比如从标准正态分布换成Xavier初始化,就得把整个初始化段落重写。更麻烦的是,版本2没有把“层尺寸”抽象成一个独立的参数列表,导致初始化和后续的前向传播之间缺少一个清晰的契约。
从工程角度看,这是一个典型的“隐式契约”问题:初始化和前向传播都默认知道网络结构,但这个结构没有被显式表达出来。一旦结构变了,两边都得改,而且很容易漏改。
2.3 前向传播和反向传播的代码耦合度偏高
版本2的feedforward和backprop方法里,循环的边界、列表的索引方式、以及中间变量的命名,都跟具体的网络层数绑定得比较紧。比如backprop里用zip(self.biases, self.weights)来遍历,这本身没问题,但feedforward里对activations的处理方式,让它在扩展到多层时不够直观。如果你想把激活函数从sigmoid换成ReLU,或者想加入dropout,就会发现改动点非常多。
我后来在版本2的基础上尝试加一个“打印每层激活值均值”的调试功能,结果发现要在好几个地方插入代码,而且很容易破坏原有的逻辑。这种“牵一发而动全身”的感觉,说明代码的模块边界不够清晰。
2.4 数据加载和网络定义混在一起
版本2的74行里,数据加载部分(读取MNIST、预处理)和网络定义部分是混在同一个脚本里的。这在教学时方便读者一次性看到全貌,但如果你想复用这个网络去跑别的数据集,或者想把数据加载换成自己的格式,就得把代码拆开。更关键的是,数据加载里的很多细节(比如把像素值归一化到0-1、把标签转成one-hot)都是硬编码的,没有抽成独立的函数。
这些写法单独看都不算“错”,甚至在教学语境下是合理的。但当你把这段代码当作一个“起点”而不是“终点”时,就会觉得处处受限。版本3的修改,正是针对这些受限点来的。
3. 版本3到底改了什么:逐项拆解与工程动机
3.1 把网络结构抽象成sizes列表
版本3最核心的改动,是引入了一个sizes列表来表示网络结构。比如sizes = [784, 30, 10]表示输入层784个神经元、隐藏层30个、输出层10个。这个改动看起来简单,但它把“网络结构”从一个隐式约定变成了一个显式参数。初始化时,self.weights和self.biases都基于sizes生成;前向传播时,循环也基于sizes的长度。这样一来,你想改网络结构,只需要改sizes一个地方。
这个改动的工程价值在于:它建立了一个清晰的契约。初始化和前向传播都依赖同一个sizes,不再各自维护一套隐含的维度信息。我在自己的实验里,经常用这个特性来快速切换网络结构,比如sizes = [784, 100, 50, 10],只需要改一行,其他代码完全不用动。
3.2 权重初始化改用更明确的列表推导
版本3里,self.weights和self.biases的初始化被改写成更明确的列表推导形式,并且直接基于sizes。比如self.biases = [np.random.randn(y, 1) for y in sizes[1:]],self.weights = [np.random.randn(y, x) for x, y in zip(sizes[:-1], sizes[1:])]。这种写法不仅更紧凑,而且把“每一层的偏置是列向量、每一层的权重是矩阵”这个约定表达得更清楚。
对比版本2,版本3的初始化代码更容易被验证:你可以直接打印[w.shape for w in self.weights],然后跟sizes对照,一眼就能看出维度对不对。这种“可验证性”在调试时非常有用。
3.3 前向传播的循环写法更统一
版本3的feedforward方法里,对激活值的处理更统一:先取activation = a,然后对每一层的权重和偏置做循环,每次都用sigmoid(np.dot(w, activation) + b)更新。这种写法比版本2更接近“逐层传递”的直觉,也更容易插入调试语句。比如你想看每一层的输出均值,只需要在循环里加一行print(np.mean(activation)),不会影响其他逻辑。
这个改动看起来只是“写法更顺”,但它实际上降低了后续扩展的认知负担。当你想加一层、换激活函数、或者插入batch normalization时,统一的循环结构会让你少改很多地方。
3.4 反向传播的索引方式更清晰
版本3的backprop方法里,对nabla_b和nabla_w的初始化方式跟版本2类似,但在循环里对z和activation的处理更清晰。它先计算最后一层的误差delta,然后反向逐层计算。这种写法跟前向传播的循环结构形成了对称,读起来更容易在脑子里建立“正向-反向”的对应关系。
我在教别人读这段代码时,通常会建议他们先把feedforward和backprop的循环结构画出来,然后对照着看每一层的activation、z、delta是怎么对应的。版本3的写法让这种对应关系更明显,减少了“读着读着就迷路”的情况。
3.5 数据加载被拆成独立函数
版本3把数据加载和预处理拆成了独立的函数,比如load_data和load_data_wrapper。这样网络定义部分就不再依赖具体的数据格式,你可以把load_data_wrapper换成自己的数据加载逻辑,只要返回的格式符合预期(训练输入、训练输出、测试输入、测试输出),网络就能跑。
这个改动对后续实验非常重要。比如你想在自己的手写数字数据集上测试,或者想把输入从784维改成其他维度,只需要改数据加载部分,网络部分完全不用动。这种“数据与模型分离”的思想,是版本3最值得抄走的工程习惯之一。
4. 从版本2到版本3,我踩过的三个真实坑
4.1 改sizes之后忘了同步检查权重维度
版本3引入sizes之后,我一度以为“改一行就能换结构”,结果有一次把sizes从[784, 30, 10]改成[784, 100, 10],跑起来直接报维度不匹配。排查后发现,是我在某个实验分支里手动改过self.weights的初始化,但没有基于sizes,导致新旧代码混在一起。这个坑让我意识到:抽象成sizes只是第一步,关键是所有依赖网络结构的地方都必须统一从sizes推导。后来我养成了一个习惯:每次改完sizes,先打印[w.shape for w in net.weights]和[b.shape for b in net.biases],确认维度跟sizes一致,再开始训练。
4.2 反向传播里delta的维度对不上
版本3的backprop里,最后一层的delta计算方式是(outputs - y),然后逐层反向传播。我当初在改激活函数时,把sigmoid换成了ReLU,结果忘了改delta的计算方式,导致梯度完全不对,准确率一直上不去。后来对照版本3的写法,才发现delta的计算跟激活函数的导数强相关。这个坑的教训是:版本3的代码结构虽然更清晰,但它仍然假设激活函数是sigmoid。如果你想换激活函数,必须同时改前向传播和反向传播里的导数计算,不能只改一处。
4.3 数据加载函数的返回值顺序搞反
版本3把数据加载拆成load_data_wrapper之后,返回的是(training_data, validation_data, test_data)。我当初在写自己的加载函数时,不小心把验证集和测试集的顺序搞反了,结果训练时用测试集做验证,评估时又用验证集做测试,导致准确率看起来“异常高”。这个坑很隐蔽,因为代码不会报错,只是结果不对。后来我养成了一个习惯:在数据加载函数里加一行打印,输出每个集合的样本数量和第一个样本的标签,确认顺序和内容都符合预期。
5. 如果你要自己改这段代码,建议从这几个点入手
5.1 先把sizes作为唯一的结构入口
不管你后续想加层、改宽度、还是换激活函数,都建议先把sizes作为网络结构的唯一入口。所有跟维度相关的代码,都从sizes推导,不要在其他地方硬编码数字。这样做的好处是,你改结构时只需要改一个地方,而且不容易漏改。我在自己的实验里,甚至会写一个check_dimensions函数,在初始化后自动检查每一层的权重和偏置维度是否跟sizes一致,不一致就直接报错。
5.2 把前向传播和反向传播写成对称结构
版本3的feedforward和backprop在循环结构上是对称的:前向是从第一层到最后一层,反向是从最后一层到第一层。这种对称性不仅让代码更好读,也让你在调试时更容易定位问题。比如你发现某一层的梯度异常,可以对照前向传播里同一层的激活值,看看是不是前向传播就已经出了问题。我在改代码时,会尽量保持这种对称性,比如前向用for w, b in zip(self.weights, self.biases),反向也用类似的遍历方式。
5.3 数据加载和模型定义彻底分离
版本3把数据加载拆成独立函数之后,模型定义部分就不再依赖具体的数据格式。这个习惯非常值得保留。我后来在做自己的项目时,会把数据加载写成独立的模块,返回统一的格式(比如(X_train, y_train, X_val, y_val, X_test, y_test)),然后模型部分只依赖这个格式。这样我想换数据集时,只需要改数据加载模块,模型代码完全不用动。另外,数据预处理(比如归一化、one-hot编码)也建议放在数据加载模块里,不要混进模型代码。
5.4 加一个简单的“梯度检查”步骤
版本3的代码里没有梯度检查,但我在自己改代码时,养成了一个习惯:在正式训练之前,先用数值梯度检查一下反向传播算出来的梯度对不对。具体做法是,取一个很小的网络(比如[784, 5, 10]),随机选几个权重,用数值方法计算梯度,然后跟backprop算出来的梯度对比。如果相对误差在1e-6以下,就说明反向传播没问题。这个步骤虽然多花几分钟,但能帮你避免很多“训练不收敛”的坑。
5.5 把超参数集中管理
版本3的代码里,学习率、mini-batch大小、训练轮数这些超参数是写在训练循环里的。我在自己的实验里,会把它们集中到一个配置字典里,比如config = {'eta': 3.0, 'batch_size': 10, 'epochs': 30},然后在训练时从配置里读取。这样做的好处是,你想做超参数对比实验时,只需要改配置字典,不用在代码里到处找。另外,建议把每次实验的配置和结果记录下来,方便后续分析。
6. 版本3之后还能怎么扩:几个我实际试过的方向
6.1 换激活函数:从sigmoid到ReLU
版本3的代码默认用sigmoid,但你可以很容易地把它换成ReLU。需要改的地方有两处:前向传播里的激活函数,以及反向传播里delta的计算。具体来说,前向传播里把sigmoid(z)换成np.maximum(0, z),反向传播里把sigmoid_prime(z)换成(z > 0).astype(float)。改完之后,你会发现训练速度明显变快,但准确率可能会略有波动。我在MNIST上试过,ReLU版本的收敛速度比sigmoid快不少,但最终准确率差不多。
6.2 加一层隐藏层:从两层到三层
版本3的sizes列表让加层变得非常简单。比如从[784, 30, 10]改成[784, 100, 50, 10],只需要改sizes,其他代码完全不用动。但要注意,加层之后,反向传播的循环会自动适应新的层数,因为它是基于self.weights和self.biases的长度来遍历的。我在试三层网络时,发现训练时间明显变长,但准确率提升有限。后来我意识到,对于MNIST这种相对简单的任务,两层网络已经够用了,加层更多是为了理解“深度”带来的影响。
6.3 换优化器:从SGD到动量法
版本3用的是mini-batch随机梯度下降。如果你想换成动量法,可以在update_mini_batch里加一个速度变量。具体做法是,维护一个跟self.weights和self.biases同形状的速度列表,每次更新时用v = mu * v - eta * nabla,然后w = w + v。这个改动不大,但能让你直观感受到动量法对收敛速度的影响。我在试的时候,发现动量法在初期收敛更快,但需要调整mu和eta的配合。
6.4 加正则化:L2和dropout
版本3的代码里没有正则化,但你可以很容易地加上L2正则化:在update_mini_batch里,把nabla_w加上(eta * lmbda / n) * w,其中lmbda是正则化系数。dropout稍微麻烦一点,需要在前向传播时随机屏蔽一些神经元,并在反向传播时对应地调整梯度。我在试L2正则化时,发现它能明显减少过拟合,尤其是在隐藏层神经元比较多的时候。
6.5 把训练过程可视化
版本3的代码只输出每个epoch的准确率,但你可以加一些可视化,比如每层的权重分布、激活值分布、梯度范数等。我后来用matplotlib画了每层权重的直方图,发现有些层的权重在训练初期就饱和了,这帮我定位到了初始化的问题。可视化不一定非要很复杂,哪怕只是打印每层权重的均值和方差,也能帮你发现很多异常。
7. 我个人在实际操作中的体会
这段74行代码从版本2到版本3的修改,表面上是“代码整理”,实际上是一次工程思维的升级。版本2教会你“神经网络怎么算”,版本3教会你“神经网络怎么组织”。后者在真实项目里更重要,因为真实项目里你永远在改结构、换数据、调超参数,如果代码组织得不好,每次改动都是一次折磨。
我自己的习惯是,每次拿到一段“能跑”的代码,先不急着跑,而是花十分钟看看它的结构:哪些地方是硬编码的、哪些地方是耦合的、哪些地方是隐式约定。然后问自己:如果我要改这里,需要动几处?如果答案超过两处,就说明这里值得抽象。版本3的sizes列表、独立的数据加载函数、对称的前向反向结构,都是这种思考的产物。
另外,我强烈建议你在改这段代码时,保持一个“最小可运行”的版本。每次只改一个地方,改完立刻跑一遍,确认没问题再改下一个。我当初贪心,一次性把激活函数、网络结构、数据加载全改了,结果报错时根本不知道是哪个改动引起的。后来学乖了,每次只动一个变量,虽然慢一点,但排查成本低很多。
最后,不要小看“打印维度”这个习惯。我在调试神经网络时,最常用的调试手段就是打印每一层的输入输出维度、权重的形状、梯度的范数。这些信息看起来简单,但能帮你快速定位大部分维度不匹配和梯度异常的问题。版本3的代码结构让这些打印更容易插入,这也是它比版本2更适合做实验的原因之一。