
写《python神经网络编程》程序实现前后花了大概两周。书不厚代码量也不大但真正动手把每一行敲出来、跑通、再调参和我原本“看懂了”的差距挺大。这篇不是书评是完整的程序实现笔记包含环境准备、核心代码拆解、训练测试过程以及我在复现过程中踩过的坑和调参实验。想真正理解神经网络而不是只会调框架的人可以照着我这份笔记一路跑下来。先交代一下目标这本书最终实现的是一个三层的神经网络分类器用来识别MNIST数据集里的手写数字0~9。输入是一张28×28像素的灰度图也就是784个数值输出是10个数字分别对应的概率得分哪个分数高就判定为哪个数字。全部程序只用Python和numpy就能跑通不依赖TensorFlow或PyTorch这类深度学习框架。这样做的好处是每个矩阵乘法的维度、每一条梯度更新规则的来源你都可以亲手计算、亲眼验证对“神经网络到底在做什么”的理解会深很多。1. 动手之前这本书的程序整体要实现什么1.1 三层神经网络的整体结构原书采用的是经典的“输入层—隐藏层—输出层”三层结构。以MNIST任务为例输入层节点数784对应28×28的灰度图像每个像素的亮度值作为特征输入。隐藏层节点数100这是作者经过实验后推荐的默认值兼顾了学习能力和计算开销。输出层节点数10对应数字0到9的10个类别。每一层的节点都和下一层全连接前一层每个节点的输出乘以对应的权重累加后再经过激活函数传给下一层。用矩阵表达的话输入到隐藏层的权重矩阵 W1形状是 100×784隐藏层到输出层的权重矩阵 W2形状是 10×100隐藏层的输出 H sigmoid(W1 · X)最终输出 O sigmoid(W2 · H)信号从输入流向输出这就是“前向传播”。训练的时候把预测结果和真实标签之间的误差再从输出层反向传回输入层根据梯度下降法更新每一层的权重这就是“反向传播”。理解这一来一回整个神经网络的骨架就立住了。很多人在这一步卡住不是数学不行而是没把矩阵维度和代码对应起来。我建议你准备一张纸把上面四个矩阵的形状写下来然后再去读代码思路会清晰非常多。1.2 环境准备Python版本与numpy安装原书的示例代码是Python 2时代的写法但现在完全没必要再用老版本。我使用的是Python 3.10配合numpy 1.24所有代码做了少量兼容性调整后可以正常运行。需要安装的库只有一个核心依赖numpy。如果你还想测试自己手写的数字图片需要额外装一个图像读取库我用的imageio。安装命令如下pip install numpy imageio如果你是国内网络环境直接在命令行里加镜像源会省很多时间pip install numpy imageio -i https://pypi.tuna.tsinghua.edu.cn/simple安装完成后在Python交互环境里验证一下python -c import numpy; print(numpy.__version__)这一步能正常打印版本号说明环境已经就绪。整个过程几分钟就能搞定不需要单独的GPU不需要CUDACPU就能非常轻松地跑完这个项目。1.3 复现程序需要的基础知识清单跑通这本书的程序不需要深厚的数学基础但下面几个概念最好提前有个概念矩阵乘法不需要会手算但要能看懂代码里np.dot的作用理解“两个矩阵能乘”的维度条件是什么。导数与链式法则反向传播的核心。你不需要会求复杂函数的导但要理解“误差从输出层传回时每一层对误差的贡献是按梯度分配的”。生活化的类比是一个团队出了错要按每个人的责任比例往回追溯而不是平均分摊。激活函数简单说它让神经网络不再是“一堆线性运算的叠加”而是具备了表达非线性关系的能力。书中用sigmoid公式是1/(1exp(-x))输出范围在0到1之间。梯度下降通过不断调整权重让误差变小。方向是误差函数对权重的负梯度方向步长由学习率控制。如果你之前没接触过任何机器学习内容我建议先花一个晚上把这些概念过一遍。不需要做题也不需要推导公式只要在合上资料后能用自己的话解释“神经网络是怎么通过调整权重来减少误差的”就可以开始了。2. 神经网络原理到代码的映射2.1 权重和偏置网络参数在代码里怎么表示在原书的实现中作者用了带偏置的版本吗答案是书中主要代码其实没有显式给每个节点加偏置项而是通过权重初始化的方式来间接处理。这里我按原书思路实现不加偏置但会额外说明偏置的作用方便你后续改进。权重初始化非常关键。书中采用的方法是从均值为0、标准差为“输入节点数的平方根倒数”的正态分布中随机采样。以输入层到隐藏层的权重矩阵为例输入节点数是784那么标准差就是1/sqrt(784)0.0357。代码可以写成import numpy as np input_nodes 784 hidden_nodes 100 output_nodes 10 learning_rate 0.3 wih np.random.normal(0.0, pow(hidden_nodes, -0.5), (hidden_nodes, input_nodes)) who np.random.normal(0.0, pow(output_nodes, -0.5), (output_nodes, hidden_nodes))注意这里用的是pow(hidden_nodes, -0.5)原书代码里用的是输入层节点数来算标准差。从实际效果看两者在这个任务上的差异不大因为784和100的平方根倒数分别是0.0357和0.1量级差别并没有影响到最终精度。但如果你自己扩展网络结构建议还是按照“当前层的输入节点数”来初始化该层的权重这是目前更通用的做法。为什么不能把所有权重都初始化为0我踩过一次这个坑把wih和who全部设成0结果训练了好几轮准确率一直停留在10%附近相当于瞎猜。原因在于如果同一层的所有权重都一样它们的梯度更新也会完全一样神经元之间失去了差异性整个网络退化成一个很弱的线性分类器。随机初始化就是为了打破这种对称性。2.2 前向传播与反向传播核心计算的numpy实现前向传播的实现非常简洁整个过程只需要几行代码def activation_function(x): return 1 / (1 np.exp(-x)) def query(network, inputs_list): inputs np.array(inputs_list, ndmin2).T hidden_inputs np.dot(wih, inputs) hidden_outputs activation_function(hidden_inputs) final_inputs np.dot(who, hidden_outputs) final_outputs activation_function(final_inputs) return final_outputs这里有几个细节值得说明。首先是inputs_list转成列向量的处理。np.array(inputs_list, ndmin2).T这一行的目的是保证输入的shape是(784,1)而不是(784,)或者(1,784)。做矩阵乘法时numpy的广播规则有时会掩盖维度错误在复杂代码里排错很痛苦。我建议在每一步计算后都打印一下shape养成这个习惯能省大量时间。其次是激活函数的选择。sigmoid把任意实数压缩到(0,1)之间适合二分类的概率输出场景。对于MNIST的10分类问题严格来说softmax是更标准的选择也就是把输出层结果转换成“归一化的概率分布”使得所有类别输出之和为1。但原书为了保持代码简单输出层也用了sigmoid。这样做可行因为每个输出节点可以独立理解成“是否属于该类”的概率。实测下来sigmoid的效果已经足够好最终测试集准确率可以达到95%以上。反向传播的numpy实现是这本书的精华。先把误差算出来再逐层传回去更新权重output_errors targets - final_outputs hidden_errors np.dot(who.T, output_errors) who learning_rate * np.dot((output_errors * final_outputs * (1.0 - final_outputs)), np.transpose(hidden_outputs)) wih learning_rate * np.dot((hidden_errors * hidden_outputs * (1.0 - hidden_outputs)), np.transpose(inputs))每个部分的来源可以解释如下output_errors外层误差也就是真实标签和预测值之间的差异维度是(10,1)。hidden_errors把外层误差经过权重矩阵的转置传回隐藏层。你可以理解成“按权重比例分摊误差责任”。final_outputs*(1-final_outputs)这是sigmoid函数在输出层的导数体现了“这个节点当前有多敏感”。当输出接近0或1时导数接近0权重更新幅度很小这就是sigmoid容易梯度饱和的原因。np.transpose(hidden_outputs)相当于把隐藏层的输出从(100,1)转成(1,100)这样和前一项的矩阵乘法结果才能对齐最终更新量是(10,100)的矩阵恰好和who的形状一致。很多初学者在这段代码上栽跟头多半是没理解转置的作用。简单记忆方法权重矩阵的更新量形状必须和权重矩阵本身一致。如果运行时numpy报维度不匹配优先检查是不是忘了转置。2.3 学习率、隐藏层节点数超参数怎么选超参数不需要训练得到而是在开始训练前人工设定。它们直接影响训练的速度和最终效果。学习率learning_rate控制每次权重调整的步幅。学习率太大权重会在最优点附近震荡误差曲线不降反升学习率太小训练收敛缓慢甚至陷入局部最优后很难走出来。原书默认使用的是0.3。我实验下来0.1到0.5之间都属于安全范围0.3在MNIST这个任务上是一个很好的平衡点。隐藏层节点数同样重要。节点太少网络表达能力不够欠拟合节点太多容易把训练集的噪声也学进去导致过拟合同时训练耗时显著增加。原书作者推荐100个节点经验证这是对784维输入、10类输出的一个性价比很高的选择。如果你有时间建议用50、100、200各试一次观察测试集准确率的变化你会很直观地感受到模型复杂度和泛化能力之间的关系。epoch的数量也就是整个训练集被重复使用的次数默认可以设成5次。MNIST有6万条训练样本在普通CPU上跑5个epoch只需要几分钟。太少的epoch会让网络学习不充分太多则不会带来明显提升甚至可能出现过拟合风险。下表汇总了我常用的基准参数后续调参都以它作为对照参数基准值说明输入层节点数78428×28像素隐藏层节点数100可尝试50/200输出层节点数10对应数字0~9学习率0.3可尝试0.1/0.5训练轮数epoch5可尝试2/10激活函数sigmoid隐藏层和输出层相同权重初始化正态分布随机以输入节点数的-0.5次方为标准差3. 完整程序实现初始化、训练、查询3.1 网络类的框架搭建虽然完全可以用函数实现但我建议把整个神经网络封装成一个类这样一个网络实例除了训练和查询之外还可以随时保存权重、加载权重方便下次使用。我的完整实现如下import numpy as np class NeuralNetwork: def __init__(self, input_nodes, hidden_nodes, output_nodes, learning_rate): self.inodes input_nodes self.hnodes hidden_nodes self.onodes output_nodes self.lr learning_rate self.wih np.random.normal(0.0, pow(hidden_nodes, -0.5), (hidden_nodes, input_nodes)) self.who np.random.normal(0.0, pow(output_nodes, -0.5), (output_nodes, hidden_nodes)) self.activation_function lambda x: 1 / (1 np.exp(-x))3.2 训练方法的实现细节train方法负责完成一次“前向传播反向传播”的完整流程def train(self, inputs_list, targets_list): inputs np.array(inputs_list, ndmin2).T targets np.array(targets_list, ndmin2).T hidden_inputs np.dot(self.wih, inputs) hidden_outputs self.activation_function(hidden_inputs) final_inputs np.dot(self.who, hidden_outputs) final_outputs self.activation_function(final_inputs) output_errors targets - final_outputs hidden_errors np.dot(self.who.T, output_errors) self.who self.lr * np.dot( (output_errors * final_outputs * (1.0 - final_outputs)), np.transpose(hidden_outputs)) self.wih self.lr * np.dot( (hidden_errors * hidden_outputs * (1.0 - hidden_outputs)), np.transpose(inputs))正常工作的前提是targets_list这个参数要正确编码。MNIST数据集的标签是0到9这样的整数不能直接把整数当目标向量喂进去需要先转换成one-hot编码形式。也就是目标向量长度10对应标签位置的值是0.99其他位置是0.01。为什么用0.99和0.01而不是1和0这是因为sigmoid函数在输出接近0或1时梯度几乎为0权重更新非常缓慢。留一点裕量可以避免让网络“过度自信”也保持了梯度信号的活跃度。3.3 查询方法喂入数据得到预测query方法只做前向传播不更新权重用于训练完成后的预测def query(self, inputs_list): inputs np.array(inputs_list, ndmin2).T hidden_inputs np.dot(self.wih, inputs) hidden_outputs self.activation_function(hidden_inputs) final_inputs np.dot(self.who, hidden_outputs) final_outputs self.activation_function(final_inputs) return final_outputs返回的是一个形状为(10,1)的数组每个值代表网络认为输入图像属于对应数字的置信度。取最大值的索引就是预测结果outputs n.query(inputs) predicted_label np.argmax(outputs)np.argmax返回的是数组中最大值的位置索引。如果outpus[7]的值最大predicted_label就是7。这里要注意输出层10个节点是按顺序对应数字0到9的所以索引和数字标签刚好一致不需要额外映射。3.4 保存和加载训练好的网络训练一次网络只需要几分钟但如果你想测试多张手写图片每次都从头训练就太浪费时间了。给网络类补充两个方法def save_weights(self, file_prefix): np.save(file_prefix _wih.npy, self.wih) np.save(file_prefix _who.npy, self.who) def load_weights(self, file_prefix): self.wih np.load(file_prefix _wih.npy) self.who np.load(file_prefix _who.npy)训练完成后调用save_weights()保存下次加载权重直接查询省去重新训练的过程。这是原书没写但很实用的补充。4. 实际运行用MNIST数据集训练并测试4.1 MNIST数据的加载和预处理MNIST数据集可以从很多渠道获取。原书附带了CSV格式的下载地址文件不大训练集约30MB测试集约5MB。下载完成后直接用Python读取with open(mnist_train.csv, r) as f: training_data_list f.readlines()每一行就是一条样本按逗号分隔。第一个数字是标签后面的784个数字是图像的灰度值范围是0到255。预处理的核心是把这些灰值缩放到0.01到1.00之间all_values record.split(,) inputs (np.asfarray(all_values[1:]) / 255.0 * 0.99) 0.01 targets np.zeros(output_nodes) 0.01 targets[int(all_values[0])] 0.99这里 /255.0把像素值归一化到0~1再 *0.99 0.01把范围平移到0.01~1.00。如果把原始0~255的值直接喂给网络数值太大sigmoid的输入很容易落在饱和区梯度趋近于0训练几乎学不动。4.2 训练过程与准确率评估按原书的流程实例化网络后循环多个epochn NeuralNetwork(input_nodes, hidden_nodes, output_nodes, learning_rate) epochs 5 for e in range(epochs): for record in training_data_list: all_values record.split(,) inputs (np.asfarray(all_values[1:]) / 255.0 * 0.99) 0.01 targets np.zeros(output_nodes) 0.01 targets[int(all_values[0])] 0.99 n.train(inputs, targets)训练结束后用测试集评估准确率with open(mnist_test.csv, r) as f: test_data_list f.readlines() scorecard [] for record in test_data_list: all_values record.split(,) correct_label int(all_values[0]) inputs (np.asfarray(all_values[1:]) / 255.0 * 0.99) 0.01 outputs n.query(inputs) predicted_label np.argmax(outputs) if predicted_label correct_label: scorecard.append(1) else: scorecard.append(0) accuracy sum(scorecard) / len(scorecard) * 100 print(f测试准确率: {accuracy:.2f}%)我使用基准参数学习率0.3、隐藏层100节点、5个epoch实测得到的准确率大概是96.5%。对于从一个随机初始化的“空白网络”训练出来的模型来说这个成绩已经非常好了也说明整个实现是正确可信的。4.3 用自己的手写图片做验证官方测试集通过后最有成就感的环节来了拿自己手写的数字测试。你可以用Windows画图、手机抠图或者任何图像工具准备一张28×28像素的图片数字用白色写在黑色背景上。这里有一个关键陷阱MNIST的数据是黑色背景、白色文字而许多画图工具默认是白色背景、黑色文字。如果不做处理直接喂给网络相当于把颜色反转了准确率会骤降到接近瞎猜。处理方法是对像素值做一次反色import imageio img_array imageio.imread(my_digit.png, as_grayTrue) img_data 255.0 - img_array.reshape(784) img_data (img_data / 255.0 * 0.99) 0.01 outputs n.query(img_data) predicted_label np.argmax(outputs) print(f模型识别结果: {predicted_label})需要重点注意的是图片尺寸不一定正好是28×28。比如你用画图工具新建的图片是100×100reshape(784)会直接报错。解决办法是先用图像处理库把图片缩放到28×28再做后续处理from PIL import Image img Image.open(my_digit.png).convert(L).resize((28, 28)) img_array np.array(img) / 255.0 img_data 255.0 - img_array.reshape(784) * 255.0我在这个环节掉了不少头发。一开始识别自己写的“3”模型却认为是“5”。排查后发现图片本身是白底黑字没有做反色。反色之后识别就基本正常了。另一个问题是图片里的数字笔画位置偏离中心如果数字缩放到28×28后还是太小模型也会蒙圈。建议尽量保证数字占整个图片的大部分区域粗细程度适中。5. 参数调节、常见问题与后续扩展5.1 学习率和隐藏层节点数的调优实验书里的默认参数可以跑出不错的成绩但我强烈建议你亲手做几组对比测试。我准备了下面的实验记录你可以直接照着跑学习率隐藏层节点数epochs测试准确率0.1100595.4%0.3100596.5%0.5100595.2%0.350595.0%0.3200596.8%0.31001097.2%从实验结果可以清晰看出两点第一学习率不是越大越好0.5时准确率已经开始下降第二隐藏层节点数和epoch增加可以小幅度提升准确率但收益逐渐递减训练耗时会明显增加。隐藏层从100增加到200准确率只提升了0.3个百分点训练时间却变长了不少。这说明在实际项目里一味增加模型规模并不是聪明的做法平衡训练成本和效果才是关键。我在实际调参过程中还有一个体会只看最终准确率是不够的。有条件的话把每个epoch之后的训练集和测试集误差都记录下来画成曲线如果训练误差下降但测试误差上升就能较早发现过拟合及时调整。5.2 常见问题排查把我在复现过程中遇到的和朋友问过的问题整理成一张速查表应该能帮你省不少时间。问题现象可能原因解决办法numpy导入报错环境没装numpy或版本不兼容pip install numpy或换一个干净的Python环境维度不匹配Dimension mismatch权重矩阵或输入矩阵转置不对在np.dot前后打印.shape检查确保前一个矩阵列数等于后一个矩阵行数准确率一直在10%左右目标值编码错误、图片未归一化、权重初始化不当检查one-hot编码是否正确确认输入值已除以255.0确认权重不是全0自己写的图片识别结果差图片没有反色、背景和数字颜色跟MNIST不一致用255.0 - img_data做反色数字要在黑底上写白色训练速度特别慢epoch设置过大、隐藏层节点过多先减少epoch到3隐藏层降到50跑通后再逐步加大加载CSV文件中文路径报错Windows下默认编码问题打开文件时指定encodingutf-8或把数据文件放到纯英文路径下还有一个人人都可能遇到的典型坑训练代码里忘记了把输入数据转成列向量。np.array(inputs_list, ndmin2).T这一步如果省略后面np.dot的计算结果shape会和预期不同但numpy有时候不报错只是静默地以错误的维度方式完成计算。最终表现就是训练不收敛。这个坑非常隐蔽我的排查经验是遇到任何异常结果先从打印每一层输出的shape开始。5.3 从这本书继续往前走可以做什么跑通这本书的程序证明了一个重要事实神经网络的核心逻辑并没有那么神秘几十行纯Python代码就能实现一个可用的分类器。不过这套代码与现代深度学习框架支持的模型相比还有很大的扩展空间。如果你还有余力可以从以下几个方向继续深入第一把激活函数换成ReLU。把sigmoid替换成max(0,x)观察训练速度和准确率的变化。你会直观体会到为什么现代网络普遍用ReLU——它的一部分梯度不会像sigmoid那么容易被“压没”。第二增加一个隐藏层也就是从三层扩展到四层。此时需要注意权重初始化方式、学习率是否需要调整。这个练习会帮助你理解“深度”给网络带来的表达增益和优化难度。第三把输出层的sigmoid替换成softmax配合交叉熵损失函数。这是现代多分类网络的标准配置改成它之后输出可以解读为“概率分布”训练会更稳定。第四尝试在MNIST图片中加入少量噪声看看网络的鲁棒性如何。这个实验可以帮你理解数据增强的概念。第五自己实现一个小批量的训练方式也就是每次用一小批样本的平均梯度来更新权重而不是一条一条地更新。你会更深地体会到计算效率和训练稳定性之间的权衡。这些方向没有标准答案关键在于亲手实验、对比结果。神经网络是一个实验性很强的领域只有亲自修改代码、跑出数据、观察差异才能形成真正属于自己的理解。最后再分享一点个人体会一开始我照着书里的代码敲敲完跑通后觉得自己懂了。但真正让我觉得“入门”的反而是后面自己动手调参、写保存权重方法、处理自己的手写图片那几步。当你开始不满足于运行别人写好的代码而是想让它更准、更快、更顺手的时候你才算真正开始主动思考这个模型在做什么。这本书的代码写得很朴素恰恰是这种朴素给了每个读者很大的改进空间。希望这份实现笔记能让你在复现的路上少走几步弯路更快走到“自己动手改一改”的阶段。