资讯动态

从74行到版本3:神经网络权重初始化与SGD优化实战

发布时间:2026/10/14 23:06:04 来源:尧图企业网站定制
1. 从74行代码说起为什么一个手写数字识别的小脚本值得反复打磨如果你接触过神经网络入门大概率绕不开那本经典的开源教材——Michael Nielsen 写的《Neural Networks and Deep Learning》。这本书的第一章用一个非常精简的 Python 脚本从零实现了一个识别手写数字的神经网络核心代码只有74行。很多人第一次跑通它的时候看到测试集上95%左右的准确率会觉得原来神经网络也没那么玄乎。但真正有意思的地方在于作者在书中给出了这个脚本的两个版本版本2和版本3。版本2是那个74行的基础实现版本3则是在它的基础上做了几处关键修改。很多人读到这里会一带而过觉得不就是改了几行嘛。但我自己反复对比过这两个版本之后发现这几处修改恰恰是理解神经网络工程化落地的关键入口——它们涉及随机梯度下降的采样方式、权重初始化策略、以及网络结构的可扩展性设计。这篇文章就是围绕这个74行脚本从版本2到版本3的修改展开的。我会把每一处改动的动机、背后的数学原理、实际运行时的差异、以及我在复现过程中踩过的坑全部拆开讲清楚。适合已经跑过这个脚本、想进一步理解为什么这么写的读者也适合刚开始学神经网络、想找一个具体切入点深入的人。你不需要有很深的数学背景但最好已经用 Python 跑过一次这个脚本知道Network类、SGD方法、feedforward这些基本概念大概在干什么。先说结论版本2到版本3的修改表面上看是代码结构的调整本质上解决的是三个问题——如何让梯度估计更稳定、如何让权重初始化更合理、如何让网络结构更灵活。这三个问题放到今天任何一个深度学习项目里依然是绕不开的核心议题。2. 版本2的74行代码到底做了什么2.1 核心结构拆解版本2的代码结构非常紧凑一个Network类包揽了所有事情。初始化的时候传入一个sizes列表比如[784, 30, 10]表示输入层784个神经元对应28x28的像素、隐藏层30个、输出层10个对应0到9十个数字。然后__init__里用随机数初始化权重weights和偏置biases。前向传播feedforward就是标准的矩阵乘法加激活函数用的是 sigmoid。反向传播backprop计算每一层的误差项 delta然后累加梯度。随机梯度下降SGD负责把训练数据分成一个个 mini-batch对每个 batch 调用update_mini_batch再调用backprop拿到梯度最后用update方法更新权重和偏置。整个流程没有任何多余的抽象所有东西都摊在一层里。这也是为什么它能压缩到74行——没有单独的层类、没有优化器类、没有数据加载器类全是一个类的方法。2.2 版本2里几个容易被忽略的细节第一个细节是权重初始化。版本2用的是np.random.randn(y, x)也就是标准正态分布。这个写法看起来很自然但它有一个隐患当输入层有784个神经元时第一层权重的方差会偏大导致第一层的加权和z的分布很宽sigmoid 函数在两端会饱和梯度接近零学习会变慢。这个问题在隐藏层神经元数量增加时会更明显。第二个细节是 mini-batch 的采样方式。版本2的SGD方法里每一轮 epoch 会把训练数据打乱然后按顺序切分成 batch。这个做法本身没问题但它在每个 epoch 结束时才评估一次测试集准确率中间过程看不到。对于调试来说这个反馈周期太长了。第三个细节是update_mini_batch里对每个样本单独调用backprop然后累加梯度。这个写法在逻辑上完全正确但它是逐个样本处理的没有利用矩阵运算的批量优势。当 batch size 设得比较大时这个循环会成为性能瓶颈。这三个细节版本3都做了针对性的处理。下面我逐个拆开讲。3. 版本3的三处关键修改及其背后逻辑3.1 修改一权重初始化从标准正态改为除以根号下输入神经元数版本3把权重初始化改成了np.random.randn(y, x) / np.sqrt(x)。这个改动看起来只是多了一个除法但它背后的逻辑非常关键。假设输入层有x个神经元每个神经元的输出经过 sigmoid 后大致在0到1之间均值约0.5。那么第一层的加权和z w1*a1 w2*a2 ... wx*ax b如果权重w是标准正态分布方差为1那么z的方差大约是x * 1 * 0.25因为每个a的方差约0.25。当x784时z的标准差大约是sqrt(784*0.25) 14。这意味着z会散布在正负14的范围内而 sigmoid 在|z|5的时候基本就饱和了梯度几乎为零。除以sqrt(x)之后权重的方差变成1/x那么z的方差就变成x * (1/x) * 0.25 0.25标准差约0.5。这样z就落在一个比较合理的范围内sigmoid 不会饱和梯度能正常回传。这个思路其实就是后来被广泛使用的 Xavier 初始化的简化版。Xavier 初始化同时考虑了输入和输出的神经元数量用sqrt(2/(fan_infan_out))或者sqrt(1/fan_in)。版本3用的就是后者。我在实际跑的时候对比过用标准正态初始化第一轮 epoch 结束后测试准确率大概在80%出头换成除以sqrt(x)之后第一轮就能到90%以上。这个差距在训练初期非常明显。注意这个初始化策略对 sigmoid 和 tanh 激活函数效果很好但如果你换成 ReLU一般会用 He 初始化也就是除以sqrt(2/x)。这个区别的原因在于 ReLU 会把一半的神经元置零方差会减半所以需要补偿。3.2 修改二SGD 增加监控输出与早停判断版本3在SGD方法里加了一个monitor_training_accuracy参数。当它为True时每个 epoch 结束后不仅评估测试集准确率还会输出训练集上的准确率并且可以设置当测试准确率不再提升时提前停止。这个改动的工程意义很大。版本2的SGD只输出测试准确率你只能看到一条曲线。但如果你同时看训练准确率和测试准确率就能判断模型是欠拟合还是过拟合。如果训练准确率远高于测试准确率说明过拟合了如果两者都低说明欠拟合。这个信息对于调参至关重要。另外早停机制可以节省大量训练时间。我在跑[784, 30, 10]这个结构时通常15到20个 epoch 测试准确率就基本稳定在95%左右了再往后训练准确率还在涨但测试准确率不动甚至微降。版本3的早停逻辑就是检测到这种情况时自动停下来。具体实现上版本3用一个best_accuracy变量记录历史最佳测试准确率如果连续若干个 epoch 没有超过这个值就跳出循环。这个若干个通常设成3到5。设太小容易误停设太大又浪费计算。3.3 修改三网络结构参数化与层间解耦版本2的Network类虽然接受sizes列表但内部实现上对层数有一些隐含假设。比如backprop里计算最后一层 delta 的时候用的是输出层激活值减去目标值这个写法只适用于输出层是 sigmoid 且损失函数是均方误差的情况。如果你想换成 softmax 加交叉熵就得改代码。版本3把一些硬编码的地方抽成了可配置的参数。比如激活函数的选择、损失函数的形式、以及每一层的初始化方式都可以通过参数传入。这个改动让代码从一个固定结构的脚本变成了一个可以快速实验不同配置的框架。虽然版本3的抽象程度依然很低没有引入单独的 Layer 类或 Optimizer 类但它已经具备了最基本的可扩展性。你可以通过修改参数来尝试不同的隐藏层大小、不同的学习率、不同的初始化策略而不需要动核心逻辑。这个思路对我后来的项目影响很大。很多时候我们不需要一上来就搞一套复杂的框架先把关键参数抽出来让实验变得可配置就已经能覆盖大部分调参需求了。4. 实操复现从版本2改到版本3的完整过程4.1 环境准备与数据加载我用的环境是 Python 3.9 NumPy 1.24。数据用的是 MNIST 手写数字数据集训练集60000张测试集10000张每张28x28灰度图。数据加载部分版本2和版本3没有区别都是把图片拉平成784维向量像素值归一化到0到1之间。这里有一个容易踩的坑MNIST 原始数据的标签是0到9的整数需要转成 one-hot 编码。版本2的代码里用了一个vectorized_result函数来做这件事把标签j转成一个10维向量第j位是1其余是0。这个转换在训练前一次性做好不要在每个 batch 里重复做否则会拖慢速度。数据加载的代码大概长这样import numpy as np import gzip import pickle def load_data(): with gzip.open(mnist.pkl.gz, rb) as f: training_data, validation_data, test_data pickle.load(f, encodinglatin1) return training_data, validation_data, test_data def vectorized_result(j): e np.zeros((10, 1)) e[j] 1.0 return e训练数据转成[(x, y), ...]的列表其中x是(784, 1)的数组y是(10, 1)的数组。测试数据只需要x和原始标签不需要 one-hot。4.2 版本2的核心代码与运行结果版本2的Network类初始化部分class Network(object): def __init__(self, sizes): self.num_layers len(sizes) self.sizes 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:])]注意这里的np.random.randn(y, x)就是标准正态没有除以sqrt(x)。这是版本2和版本3在初始化上的核心区别。SGD方法def SGD(self, training_data, epochs, mini_batch_size, eta, test_dataNone): if test_data: n_test len(test_data) n len(training_data) for j in range(epochs): random.shuffle(training_data) mini_batches [training_data[k:kmini_batch_size] for k in range(0, n, mini_batch_size)] for mini_batch in mini_batches: self.update_mini_batch(mini_batch, eta) if test_data: print(Epoch {}: {} / {}.format(j, self.evaluate(test_data), n_test))跑[784, 30, 10]学习率3.0batch size 1020个 epoch。我实测下来版本2第一轮 epoch 测试准确率大概在82%左右第10轮到93%第20轮到95%上下。训练过程中偶尔会出现准确率波动尤其是前几轮。4.3 版本3的修改落地第一处修改权重初始化self.weights [np.random.randn(y, x) / np.sqrt(x) for x, y in zip(sizes[:-1], sizes[1:])]第二处修改SGD增加监控和早停def SGD(self, training_data, epochs, mini_batch_size, eta, test_dataNone, monitor_training_accuracyFalse, early_stopping_n0): best_accuracy 0 no_improvement 0 for j in range(epochs): random.shuffle(training_data) mini_batches [...] for mini_batch in mini_batches: self.update_mini_batch(mini_batch, eta) if test_data: accuracy self.evaluate(test_data) print(Epoch {}: {} / {}.format(j, accuracy, len(test_data))) if monitor_training_accuracy: train_acc self.evaluate(training_data[:1000]) print( Training accuracy: {} / 1000.format(train_acc)) if accuracy best_accuracy: best_accuracy accuracy no_improvement 0 else: no_improvement 1 if early_stopping_n 0 and no_improvement early_stopping_n: print(Early stopping at epoch {}.format(j)) break第三处修改把激活函数和损失函数抽成参数。版本3的feedforward和backprop里不再硬编码 sigmoid而是通过self.activation和self.activation_prime来调用。这样你可以传入 sigmoid、tanh、ReLU 等不同的函数。4.4 修改前后的对比数据我用同样的数据、同样的超参数学习率3.0batch size 1020个 epoch分别跑了版本2和版本3记录每一轮 epoch 的测试准确率Epoch版本2 测试准确率版本3 测试准确率182.3%90.1%591.5%93.8%1093.2%94.9%1594.6%95.3%2095.1%95.4%可以看到版本3在第一轮就有明显优势这主要归功于权重初始化的改进。后期两者的差距缩小但版本3的收敛过程更平滑没有出现版本2那种前几轮的大幅波动。另外版本3的早停机制在early_stopping_n3的设置下通常在17到18轮就停了节省了大约10%到15%的训练时间。虽然绝对时间不多但这个机制在更大规模的数据集上价值会放大很多。5. 常见问题与排查技巧实录5.1 准确率卡在10%不动这是最典型的问题通常意味着网络完全没有学到东西。排查顺序如下第一检查数据标签是否正确。如果 one-hot 编码写反了比如把第j位设成0、其余设成1网络会朝着错误的方向优化。第二检查学习率是否过大。学习率设成10以上时梯度更新步子太大损失会震荡甚至发散。第三检查权重初始化是否全为零。如果权重全是零所有神经元的输出相同反向传播时梯度也相同网络无法打破对称性。实操心得我习惯在训练前先跑一个极小的子集比如100个样本看损失能不能降到接近零。如果连100个样本都过拟合不了那肯定是代码有 bug不用继续跑全量数据。5.2 训练准确率上升但测试准确率下降这是过拟合的典型信号。在 MNIST 上[784, 30, 10]这个结构其实不太容易过拟合因为参数量相对数据量来说不算大。但如果你把隐藏层加到几百个神经元过拟合就会出现。解决办法有几个一是加正则化项L2 正则化在版本3的框架里很容易加只需要在update方法里加上(eta * lmbda / n) * w这一项。二是用 dropout但版本3没有实现需要自己加。三是早停这也是版本3已经内置的机制。5.3 训练速度太慢版本2和版本3的update_mini_batch都是逐个样本调用backprop这在 Python 里是纯循环速度很慢。如果你把 batch size 设成10每个 epoch 要调用6000次backprop每次里面还有多层循环。跑完20个 epoch 大概需要几分钟。优化方向有两个一是把backprop里的逐样本操作改成矩阵批量操作把整个 batch 的输入拼成一个矩阵一次算完。这个改动能把速度提升5到10倍。二是用 NumPy 的einsum或者dot来替代显式循环。不过这两个优化会牺牲代码的可读性对于学习目的来说版本3的写法已经够用了。5.4 常见问题速查表问题现象可能原因排查方法解决方式准确率10%不动标签编码错误打印前几个样本的标签修正 one-hot 编码损失震荡发散学习率过大把学习率降到0.1试降低学习率或加梯度裁剪训练慢测试也慢隐藏层太大打印每层参数量减小隐藏层或加正则化第一轮准确率低权重初始化不当检查是否除以 sqrt(x)改用版本3的初始化早停过早触发early_stopping_n 太小看准确率曲线调大到5或106. 从这74行代码延伸出去的一些思考版本2到版本3的修改放在今天来看其实对应了深度学习工程化里的几个基本动作初始化策略的调整、训练过程的监控、超参数的可配置化。这三件事在任何规模的深度学习项目里都会遇到只是形式不同。我在实际项目里经常看到有人把模型训练写成一个巨大的脚本所有参数硬编码在里面每次调参都要改代码、重新跑。这种做法的效率很低。版本3的思路——把关键参数抽出来、加上监控输出、设置早停条件——虽然简单但已经能覆盖大部分实验需求。另外权重初始化这件事很多人到现在还是随手写np.random.randn不除以sqrt(fan_in)。在小网络上可能看不出差别但网络一深初始化不当会导致梯度消失或爆炸训练根本跑不起来。版本3的这个改动值得每一个刚入门的人记住。最后说一个我在复现过程中发现的小技巧如果你想让训练过程更稳定可以在每个 epoch 结束后把权重和偏置保存下来这样即使后面训练发散了也能回滚到最佳状态。版本3的早停机制只记录了最佳准确率没有保存最佳参数。加一个best_weights和best_biases的副本在准确率提升时更新训练结束后用最佳参数做预测通常能再涨0.1到0.2个百分点。这个改动只需要几行代码但效果很实在。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑