从零实现Python三层神经网络:手写数字识别与反向传播实战
发布时间:2026/9/15 17:51:00 作者:尧图编辑部 阅读量:1,286

这本书我前后翻了三遍也照着书里的逻辑重写过好几版程序。老实说网上能直接下载的“Python神经网络编程源码”非常多但我强烈建议你亲手把代码一行行敲出来。不是因为我保守而是这本书的精髓不在那个训练好的网络本身而在从零搭起一个三层神经网络、看着误差一点点变小、最后准确率冲到95%以上的这个过程。如果你正卡在“装了Python但不知道下一步干什么”“跑别人的代码跑不通”“不知道神经网络到底是怎么训练的”这些问题上这篇就是我复现这本书的完整记录环境怎么配、核心代码怎么理解、调参时哪些值真的管用、哪些坑我替你踩过了。1. 为什么这本书值得一行行敲出来1.1 别人的教程在教调库这本书在教造轮子现在随便搜一篇神经网络入门十有八九是“用PyTorch实现MNIST分类”代码很短效果很好但你也说不清反向传播到底是怎么把误差传回上一层的。你可以把这理解为“开自动挡”和“拆发动机”的区别自动挡开久了当然也能上路但如果发动机亮灯你完全不知道问题出在哪。《Python神经网络编程》走的完全是另一条路。它从头到尾不用任何深度学习框架只靠Python自带的列表、数学库、numpy就能构建出一个能识别手写数字的三层神经网络。整个过程可以拆成三块权重矩阵的初始化网络一开始什么都不懂权重是随机的必须小而分散。前向传播输入一张28x28的图片像素值经过权重和激活函数逐层计算最后得到10个输出值。反向传播更新权重把输出和真实标签的误差一层层传回去按梯度方向微调权重。这三件事每一件都对应几行看得见、改得动的代码。你不需要背公式因为公式会在你调试准确率的时候以很痛的方式让你记住。我之前带过一个只会用框架的同事他第一次看这本书的代码时问“这也没调用model.fit啊它怎么训练”——这就是这本书最大的价值让你知道fit背后发生了什么。1.2 动手前先配好环境Python、numpy与编辑器开始复现前先把环境收拾利索否则后面全是“环境报错”在拖后腿。这本书的代码只需要三个东西一个能运行的Python环境3.8以上就行不必追求最新numpy用于矩阵运算这是核心依赖一个编辑器建议VS Code或者PyCharm选你顺手的就行Python的安装这里多说一句。Windows用户最常见的坑是装完了命令行里输入python却提示“Python was not found; run without arguments to install from the Microsoft Store”这通常是安装时没勾选“Add Python to PATH”。解决办法很直接重新运行安装包勾上那一项或者自己去系统环境变量里把Python的路径加进去。Linux/macOS用户一般自带Python 3但要注意别把系统Python搞乱了建议用虚拟环境。装numpy的方法就更简单了pip install numpy matplotlib如果下载慢加国内源地址比如清华源pip install numpy matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple装完验证一下python -c import numpy; print(numpy.__version__)能输出版本号就说明环境OK了。编辑器配置方面VS Code装好Python扩展后要在命令面板里选对解释器CtrlShiftP输入Python: Select Interpreter否则你装好的numpy在编辑器里照样import不进来。PyCharm则在创建项目时直接选已有的解释器路径即可。这一步卡住的人非常多但都属于一次性问题配好之后就不用再动了。2. 三层神经网络的核心骨架权重、激活与误差传播2.1 输入层、隐藏层、输出层的维度与初始化这本书的网络结构非常朴素输入层784个节点隐藏层100个节点输出层10个节点。为什么是784因为MNIST的手写数字图片是28x28像素把二维像素一行行拉平就是784个数值。为什么输出是10个因为要识别0到9十个数字。这里最需要搞懂的是权重矩阵的形状。我用代码定义网络类的时候会把两个权重矩阵写清楚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(self.hnodes, -0.5), (self.hnodes, self.inodes) ) self.who np.random.normal( 0.0, pow(self.onodes, -0.5), (self.onodes, self.hnodes) ) self.activation_function lambda x: 1 / (1 np.exp(-x))重点看这两行self.wih输入层到隐藏层的权重形状是(100, 784)也就是100行784列。行数对应隐藏层节点列数对应输入层节点。self.who隐藏层到输出层的权重形状是(10, 100)10行100列。初始化用的是正态分布随机数中心是0.0标准差是pow(self.hnodes, -0.5)也就是1除以根号下隐藏层节点数。为什么要用这么小的随机值如果用固定值或太大了经过sigmoid激活函数后所有节点输出会非常接近网络就失去了“不同节点学不同特征”的能力。你可以理解为一群人刚进公司能力不能一模一样否则谁去学什么特征都分不清。2.2 前向传播信号是怎么流动的前向传播就是把输入数据从输入层一级一级往后传。先看查询推理部分的代码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第一行np.array(inputs_list, ndmin2).T是很多新手看不懂的地方。它的作用是把一个长度为784的一维列表变成784行1列的列向量。为什么要转成列向量因为wih是100行784列矩阵乘法要求“前一个矩阵的列数”等于“后一个矩阵的行数”所以输入必须是784行1列乘出来才是100行1列正好对应100个隐藏层节点。sigmoid激活函数的作用是给网络的线性变换增加非线性。如果去掉它多层矩阵乘法无论堆多少层本质上都等价于一次线性变换永远学不会复杂的分类边界。生活里打个比方只允许你“按比例放大缩小”的流水线永远不可能把一堆数字分成十堆必须引入一些非线性的“弯折”sigmoid就是干这个的。2.3 反向更新误差是怎么“往回传”的训练部分比查询多了一步算误差然后按误差反向修正权重。核心代码是这样的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) )output_errors是输出层每个节点的误差等于目标值减去实际输出。比如真实标签是7目标数组里第7个位置是0.99其他位置是0.01网络输出层第7个节点本来就是0.5左右那么误差就是正的权重会朝“让这个输出变大”的方向调整。hidden_errors则用self.who.T把输出层的误差“按权重比例”分配到隐藏层。这里的逻辑是输出层的某个节点误差大说明它对应的连接权重所连接的隐藏层节点也有责任责任大小按权重分配。这一点不理解也能跑通代码但建议死磕一下反向传播的精髓就在这一行。更新公式中final_outputs * (1.0 - final_outputs)是sigmoid函数的导数它表示“这个节点当前的敏感程度”。当节点输出接近0或1时导数值接近0网络更新会变慢这就是前面初始化权重不能太大的原因之一。3. 手写数字识别完整实现从CSV到准确率3.1 数据准备MNIST的读取与归一化书里提供了两个CSV文件mnist_train.csv和mnist_test.csv。训练集有6万条记录测试集有1万条。每条记录的第一列是数字标签0到9后面紧跟784个像素值范围是0到255。读取CSV有两种常见姿势一种是直接用csv模块一种是pandas。考虑到这本书的代码风格我建议用csv更轻量import csv with open(mnist_train.csv) as f: data_list list(csv.reader(f)) with open(mnist_test.csv) as f: test_data_list list(csv.reader(f))注意csv.reader读出来的元素全是字符串直接拿去算数会报错或者得到奇怪结果。所以读取后必须做类型转换用np.asfarray把字符串列表转成浮点数组这一步非常关键新手最容易忽略。像素值也不是直接丢给网络的要先归一化。书里的做法是x / 255.0 * 0.99 0.01。为什么不是直接除255得到0到1的区间因为sigmoid函数在0和1附近输出饱和梯度极小训练慢。所以把像素范围压到0.01到0.99之间保留一点“余地”。这个细节看起来不起眼但对训练速度影响很大。标签也要做类似处理。输出层10个节点真实标签是5目标数组就应该是第5个位置是0.99其余位置是0.01不能直接丢一个整数5进去。我在代码里这样初始化目标值targets np.zeros(output_nodes) 0.01 targets[int(record[0])] 0.99这就是机器学习里常说的one-hot编码只不过书里为了配合sigmoid把0换成0.01把1换成0.99。3.2 训练与测试代码跑通完整流程完整的数据处理和训练循环可以这样组织epochs 5 output_nodes 10 for epoch in range(epochs): for record in data_list: all_values np.asfarray(record[1:]) scaled_inputs all_values / 255.0 * 0.99 0.01 targets np.zeros(output_nodes) 0.01 targets[int(record[0])] 0.99 n.train(scaled_inputs, targets)这段代码的含义是把6万条样本逐条丢进网络每条都做一次前向传播和反向更新。epoch5意味着把整个训练集重复喂5遍。很多人会问训练集要不要打乱顺序。书里没有打乱影响不大但我实测下来打乱后收敛稍快一点点。你可以自己加上random.shuffle(data_list)试试。测试部分则是把测试集逐条前向传播取输出层最大值对应的索引作为预测值再跟真实标签比较scorecard [] for record in test_data_list: all_values np.asfarray(record[1:]) scaled_inputs all_values / 255.0 * 0.99 0.01 outputs n.query(scaled_inputs) label np.argmax(outputs) correct int(record[0]) scorecard.append(1 if label correct else 0) accuracy sum(scorecard) / len(scorecard) print(faccuracy {accuracy:.4f})np.argmax(outputs)返回输出数组中最大值所在的下标这个下标就是网络认为的“正确答案”。跑完这一套常规参数下准确率大约在95%上下运气好接近97%。第一次看到这个数字从自己手写的程序里跳出来那种成就感比直接用框架调用高太多了。3.3 调参实测学习率、隐藏层节点与epoch怎么配代码跑通后你肯定会忍不住改参数。我把我实测过的几组配置放在这里数值是大概区间不同的随机初始化会有波动但趋势是一致的学习率隐藏层节点epoch测试集准确率示意0.011005约90%0.11005约95%0.31005约94%0.310010约96%0.12005约96%几个规律非常明显学习率太小0.01权重每次只挪一点点5轮根本不够吃透数据学习率太大1.0以上权重震荡准确率反而不升反降。0.1到0.3这个区间是最稳的。隐藏层节点从100加到200准确率有小幅提升但训练时间几乎翻倍。加到1000以后准确率不会继续涨反而有过拟合风险。对MNIST这种任务来说100到200个隐藏层节点性价比最高。epoch不是越大越好。前5轮准确率上得飞快5到10轮缓慢爬升再往后就基本到头了甚至会出现过拟合。初学者可以每个epoch结束后打印一次测试集准确率画成曲线看趋势。说到画曲线有个非常关键的小细节如果你把每个epoch的准确率画成折线图横坐标x轴只有5个点不会有什么问题。但如果你想记录训练过程中每条样本的误差几千个点挤在一起横坐标会“python画图横坐标太密集”标签全叠成一团黑。这时候不让它自动排刻度就行plt.xticks(range(0, len(errors), 500))或者用plt.locator_params(axisx, nbins10)把横轴刻度压到10个以内图立刻清爽很多。4. 复现路上的问题排查与避坑实录4.1 准确率卡在10%左右先检查数据预处理我第一次跑通代码时准确率是9.8%跟瞎猜一样。当时第一反应是网络结构写错了后来一步步排查发现是数据归一化那行写成了all_values / 255.0忘记加* 0.99 0.01导致所有输入都落在0到1区间。这听起来问题不大但sigmoid在0附近输出接近0.5所有节点都在“起跑线原地踏步”误差梯度极小网络基本学不动。如果你也遇到准确率怎么都上不去先别怀疑反向传播公式检查三件事输入有没有归一化到0.01到0.99区间标签是不是one-hot编码权重初始化是不是全部相同或者都是零。前两个是数据问题最后一个会直接让网络失去“对称破缺”的能力所有隐藏层节点学成一模一样的东西。我好几次排查完才发现是CSV里第一列标签忘转成int导致int()转换在字符串上失败了程序虽然不报错但标签一直取错位。这类问题用print(record[0])打印前几条数据就能暴露出来。4.2 矩阵维度对不上让shape说话numpy报错里最常见的就是“operands could not be broadcast together with shapes”或者“dot product shape mismatch”。新手看到一长串报错就懵其实解决思路就一个打印每一层的shape。我写代码有个习惯在关键节点加临时调试输出print(inputs shape:, inputs.shape) print(wih shape:, self.wih.shape) print(hidden_outputs shape:, hidden_outputs.shape)一旦你看到inputs是(784,)而不是(784,1)你就知道问题出在少了ndmin2或者漏了.T。矩阵乘法的维度规则非常死板前一个矩阵的列数必须等于后一个矩阵的行数。你完全可以靠这个规则反推权重矩阵该建多大输入784个值隐藏层100个节点那么输入到隐藏层的权重必然是(100, 784)反过来写一定报错。另外提醒一句np.dot和np.multiply完全是两回事。np.dot是矩阵乘法np.multiply或*是逐元素相乘。反向传播公式里好几处需要逐元素乘激活函数导数如果写成矩阵乘法结果不仅维数乱含义也全错了。4.3 用自己的手写图测不准问题在预处理很多人跑通MNIST后会兴致勃勃地拿Windows画图写个数字存成图片丢给网络测结果发现准确率惨不忍睹。这不是网络退化而是你的图片和MNIST数据集的风格差太远了。MNIST的数字是28x28、黑底白字、数字基本居中你自己画的图往往是白底黑字、几百像素、数字位置歪歪扭扭。解决思路是把图片重构成MNIST的样式转成灰度图把白底黑字反色成黑底白字缩放到28x28比例尽量不变形将像素值归一化到0.01到0.99。读取图片可以用Pillow也可以用OpenCV。很多人卡在python下载cv2这一步其实就是一条命令的事pip install opencv-python pillow处理示例用Pillow更直观from PIL import Image, ImageOps import numpy as np img Image.open(my_digit.png).convert(L) img ImageOps.invert(img) img img.resize((28, 28), Image.Resampling.LANCZOS) arr np.asarray(img, dtypenp.float32) / 255.0 * 0.99 0.01如果这样测还是不准可以再做一个环节把图片像素值的统计信息均值、方差和MNIST样本对比一下很多时候是图片数字的粗细分野差太大或者缩放后数字边缘被切掉了。图像预处理对这个网络的准确率影响比调学习率还明显。4.4 训练跑得太慢先跑通再优化默认的逐样本循环确实不快尤其是epoch调大、隐藏层节点调多之后6万条样本要前向反向各算一遍Python的循环开销不容小觑。我第一次把隐藏层加到500、epoch设成20泡了杯咖啡回来还没跑完。但这本书的设计初衷就是“小而清楚”不建议一开始就追求速度。想提速有两条温和路径减少数据量比如只取前1万条做训练快速验证代码逻辑把隐藏层降回100epoch保持在5先看准确率趋势。等你确定程序没问题了再想优化不迟。真正想提速可以把逐样本循环改成小批量mini-batch矩阵运算一次性喂几百张图片让numpy在底层做并行计算。不过这会偏离书里的代码结构改起来需要你对矩阵shape有更完整的把握。我的建议是先把原版跑得滚瓜烂熟再动手术。4.5 常见问题速查表问题可能原因排查与解决准确率总是在10%附近数据未归一化、标签未one-hot、权重初始化全零检查数据预处理三个环节打印样本像素范围报错维度不匹配权重矩阵行列写反或输入不是列向量打印各矩阵shape按前矩阵列数后矩阵行数检查自己的图片识别极差图片底色、方向、大小与MNIST不一致灰度化、反色、缩放28x28、归一化训练越跑越慢逐样本循环开销大先减少数据量跑通再考虑小批量优化准确率停在80%上不去学习率过大或过小试试学习率0.1隐藏层100左右epoch 5输出层全是0.5左右权重初始化太大或输入未归一化检查权重标准差、归一化区间5. 跑通之后这份代码还能怎么玩5.1 换一个自己的数据集试试MNIST的效果再惊艳始终是别人处理好的标准数据集。真正让代码内化成你自己能力的是换一个数据集重新走一遍。我自己试过用这张网络识别“X和O”的简单几何图形、识别三种不同类型的树叶轮廓。做法不复杂收集几十张图片二值化、缩放、拉平成向量做个最小规模的CSV文件然后套用同样的训练代码。结果当然不可能和MNIST的95%相比因为样本量太小、类别太简单但你会对“模型能力取决于数据质量”这句话有直观感知。自己造数据时要注意一个细节类别标签要从0开始连续编码比如三分类就用0、1、2不要用1、5、9这种有空洞的编号。否则输出层目标数组的维度对不上或者某些节点永远学不到有效信号。5.2 把隐藏层权重可视化看看网络到底学了什么这本书还有一个很容易被忽略的玩法把训练好的第一层权重矩阵self.wih每一行单独拿出来还原成28x28的热力图。你会直观地看到每个隐藏层节点到底在响应什么样的笔画和轮廓。代码很简单import matplotlib.pyplot as plt fig, axes plt.subplots(10, 10, figsize(10, 10)) for i in range(100): ax axes[i // 10][i % 10] weight_row n.wih[i].reshape(28, 28) ax.imshow(weight_row, cmapgray) ax.axis(off) plt.show()跑完之后你会看到这些权重图长得有点像“笔画碎片”有的节点对横线敏感有的节点对竖线敏感有的节点对某个弧线敏感。这意味着网络不是死记硬背每张图而是从数据中提炼出了一套“偏旁部首级”的特征表达。直观感受到这一点比看十遍理论书都管用。我在训练后也试着把不同学习率下训练出来的权重图放在一起对比学习率太小的权重图非常模糊说明它没走多远学习率合适的权重图轮廓清晰学习率过大的权重图反倒出现很多噪点这就是“震荡”留下的痕迹。5.3 从手写网络到深度学习框架的进阶路线这本书的代码跑通之后你就拥有了一个完整理解神经网络内部机制的基石。下一步往哪走我给你一条比较顺的路线先用PyTorch或TensorFlow实现同一个MNIST分类任务和手写版本进行“效果对比”。你会发现框架做了大量自动求导的繁重工作但你已经知道它背后在算什么。然后学习卷积神经网络CNN理解卷积核和池化在图像任务中为什么有效。能把“全连接层的权重可视化”的思路迁移到卷积核上。接着建议动手迁移学习用预训练模型微调自己的数据集。这时候你已经能看懂网络各层的输入输出shape不会被各种奇怪的参数吓住。我自己走完这条路线后最深的体会是框架只是加速器真正决定你能不能解决问题的是脑子里对数据流和权重更新的建模能力。这本书用极小的体量把网络最本质的骨架给你夯实了。最后再分享一个我自己的小习惯写这类从零实现的代码时给每个矩阵的shape写注释比如# wih: (hidden_nodes, input_nodes)。当时觉得多此一举后来再回头调试发现这几行注释帮我省了至少一半的时间。跑通一次不算什么能在半年后还能一眼看懂的代码才是真正属于自己的资产。