如果你和我一样,被各种深度学习框架“宠坏”了之后第一次翻开《python神经网络编程》,内心大概会有种微妙的错位感:不用TensorFlow,不用PyTorch,一个手写数字识别网络居然只用numpy就能搭起来,训练一轮还能跑到97%左右的准确率。这本书全名是《Make Your Own Neural Network》,中文版用了《python神经网络编程》这个名字,作者的思路非常朴素——想让你亲手把神经网络的每个零件做出来,而不是像搭积木一样调现成API。
我最初是带着怀疑去读的,因为当时已经被“框架封装越好越省事”的理念洗脑很久。真到自己手写一遍才发现,很多从前以为“框架自动完成”的事情,其实藏着大量的细节:权重矩阵为什么要那样初始化?输入数据为什么要归一化到0.01到1.0之间?标签为什么不用真正的1和0,而用0.99和0.01?这些在标准教程里几乎是一笔带过的东西,恰恰是决定网络能不能训练的命门。这篇文章就围绕这本书的核心程序实现,把所有代码背后的设计逻辑和实操细节都摊开讲一遍。无论你是新手还是有点基础但一直没“亲手造过轮子”的读者,跟着往下走,一定会对神经网络产生完全不同的掌控感。
1. 为什么这本书坚持用纯numpy,而不是直接上框架
1.1 从单个神经元理解“学习”这件事的最小闭环
书里开篇没有一股脑扎进神经网络,而是先从一个神经元讲起。一个神经元做的事情,用公式说就是:把输入信号各自乘上对应的权重,再加一个偏置,最后丢进激活函数。这里的权重是神经网络里最核心的“变量”,训练过程本质上就是在不断调整这些权重,让网络的输出逐渐接近目标值。
这本书选的激活函数是sigmoid:y = 1 / (1 + exp(-x))。选择它有两个原因:一是它把任意输入压缩到0到1之间,天然适合做概率输出;二是它的导数形式特别漂亮,sigmoid(x) * (1 - sigmoid(x)),在反向传播时需要计算梯度,这个性质能省掉大量的麻烦。
书里写得很克制,但代码里一个很容易让初学者困惑的点是:明明讲原理时反复强调偏置bias,程序里却好像从头到尾没看到一个显式的偏置变量。这个问题的答案是,作者在实现时采用了“把偏置并进权重矩阵”的简化策略,用增加一个恒为1的输入节点来承载偏置的学习。不过书附带的完整示例代码里,为了保持最小化,显式的偏置项并没有单独出现。新手如果盯着代码找,很容易卡住。我个人的建议是:第一遍读代码时不用过分纠结偏置去哪儿了,先理解主线流程,也就是“加权求和—过激活—传播下去”,等整体跑通了再回头补偏置的细节,心理负担会小很多。
1.2 三层网络到底在算什么,矩阵形状能说明一切
这本书用的网络结构很固定:输入层784个节点,隐藏层100个节点,输出层10个节点。为什么是784?因为MNIST数据集里的每张手写数字图片都是28×28像素,展开成一维就正好是784个灰度值。为什么是10个输出节点?因为要识别0到9共十个数字,每个节点代表一个数字类别的激活强度。
真正让网络“跑起来”的关键,是把每一层的计算都写成矩阵乘法。输入是一个784维的列向量,第一个权重矩阵的维度是100×784,两者相乘得到100维的隐藏层输入,过sigmoid后继续乘第二个权重矩阵10×100,最终得到10维的输出向量。用代码表示为:
import numpy as np from scipy.special import expit class NeuralNetwork: def __init__(self, input_nodes=784, hidden_nodes=100, output_nodes=10, learning_rate=0.3): self.inodes = input_nodes self.hnodes = hidden_nodes self.onodes = output_nodes self.lr = learning_rate # 权重矩阵按“当前层节点数 × 上一层节点数”来定义 self.w_input_hidden = np.random.normal(0.0, pow(self.hnodes, -0.5), (self.hnodes, self.inodes)) self.w_hidden_output = np.random.normal(0.0, pow(self.onodes, -0.5), (self.onodes, self.hnodes)) self.activation_function = expit def query(self, inputs_list): inputs = np.array(inputs_list, ndmin=2).T hidden_inputs = np.dot(self.w_input_hidden, inputs) hidden_outputs = self.activation_function(hidden_inputs) final_inputs = np.dot(self.w_hidden_output, hidden_outputs) final_outputs = self.activation_function(final_inputs) return final_outputs很多人第一次看这段代码会有一个疑问:为什么权重矩阵不是上一层的784×100,而是100×784?原因很简单——如果定义成100×784,那么直接拿权重矩阵去点乘输入的784维列向量,得到的就是100维的隐藏层结果,完全不用额外转置。矩阵形状一旦和数据流动方向对齐,整个前向传播就是干干净净的两次点积。
1.3 矩阵运算和for循环的差距,比想象中残酷
有人可能会想:既然神经网络就是加权求和,那我用嵌套for循环写不也一样吗?从结果上说,小规模数据确实能跑,但速度差着几个数量级。假设网络是784输入、100隐藏、10输出,一次前向传播要做784乘100次乘法加100乘10次乘法,也就是将近八万次操作。如果训练60000张图片,再迭代多次,纯Python循环的规模会迅速膨胀到无法忍受的程度。
numpy的矩阵乘法底层调用的是高度优化的BLAS库,在普通笔记本上运行一次小批量矩阵乘法的耗时几乎是瞬间完成。书里算法的主旨不在于“拼命优化性能”,而是用矩阵这个数学概念把神经网络的组织方式讲明白,然后顺手获得速度红利。这种“既讲原理又有实战价值”的安排,是这本书相比那些只能告诉你“调用model.fit()”教程的最大优势。
2. MNIST数据集的预处理,比想象中更容易出问题
2.1 CSV格式的手写数字,每一行都是一个样本
MNIST数据集在书里并不是以图片文件夹形式出现,而是打包成CSV文件。每一行代表一张图,行里第一个数字是标签,表示这张图是数字几,剩下的784个数字是28×28灰度图中每个像素的亮度值,范围在0到255之间。训练集有6万行,测试集有1万行。
读取这种数据,不要用pandas,直接用Python内置的csv模块或者open()逐行读取就够了,因为处理的是纯数值文本文件。我见很多人一上来就pd.read_csv(),其实完全没必要,反而引入额外的依赖。书里的读取思路是:先去掉开头的标签,把剩余部分转成numpy数组,作为输入;把标签单独提出来,再转成输出层的目标向量。
import csv def load_data(filename): with open(filename, 'r') as f: reader = csv.reader(f) data = list(reader) return data training_data = load_data('mnist_train.csv')注意读取出来的都是字符串,必须转成float才能进入计算。这一步跑不通的话,后续矩阵运算会直接报类型错误。所以顺手在读取之后加一行类型转换,能省掉很多调试时间。
2.2 归一化:为什么缩放目标不是0到1,而是0.01到1.0
像素值范围是0到255,不能直接喂给网络。最直观的想法是把每个像素除以255,缩放到0到1之间。这本书给出的做法是:
scaled_value = (pixel / 255.0) * 0.99 + 0.01
也就是把范围映射到0.01到1.0,而不是0到1。这个0.01的偏移量背后是有讲究的。原因在于sigmoid函数在两端的导数趋近于0,如果输入值跑到极端位置,梯度会变得非常小,权重更新就会非常缓慢。把输入压缩到带余量的区间,能在一定程度上避免神经元一开始就进入饱和区。
同理,输出层的目标向量也不是非0即1的one-hot编码,而是把0替换成0.01,把1替换成0.99。这个细节我在第一次看代码时根本没注意,后来训练时发现损失降低得特别慢,回头一查才发现自己用了真正的0和1作为目标值。把它们改成0.01和0.99之后,收敛速度肉眼可见地变快了。
归一化操作还有一个常被忽略的好处:它把所有特征拉到同一个量级上,不至于让某一个像素的亮度因为数值过大而在加权求和时“主宰”整层输出。对于后续做扩展实验,比如换数据集、加特征工程,这一条同样适用。
2.3 “独热编码”不是框架专属概念,这里也能看清本质
输出层有10个节点,每个节点对应一个数字。如果一张图是数字5,那目标向量应该长什么样?答案是:第5个位置(从0开始索引)放0.99,其他位置放0.01。这种表示叫one-hot编码,中文也叫独热编码。
初学者最容易犯的错误是把标签直接当作数值来用。比如标签是5,就期望输出层的第5个节点激活,而实际上输出层只是一个10维向量,网络也需要一个10维的目标向量来计算误差。书里训练代码中创建目标向量的逻辑写得非常清楚:
targets = np.zeros(output_nodes) + 0.01 targets[int(label)] = 0.99先全填0.01,再在正确标签位置改成0.99。理解这行代码,比背十遍“one-hot是什么”都有用。
2.4 训练集和测试集划分,别在顺序上栽跟头
MNIST原始数据本身就是打乱过的,所以书里直接用前一部分做训练、后一部分做测试,没有额外shuffle。但如果以后你用自己的数据集复现这套代码,shuffle就非常重要了。假设原始数据里前一千行全是数字0,后一千行全是数字1,直接划分会让模型在训练时只见过一类样本,测试时自然翻车。
稳妥的做法是先把所有样本和对应的标签合在一起做一次随机排列,然后再按比例拆分。这个经验不仅适用于这一本书里的项目,任何数据处理任务里都成立。虽然例子简单,但“顺序偏差”带来的坑,往往比模型本身还难排查。
3. 训练代码拆开看:前向传播与反向传播的维度真相
3.1 训练函数里的三段式,一句话概括就是先算再差最后调
书里的训练函数结构非常清晰,前向传播得到输出,与目标值相减得到误差,然后反向传播计算每层权重应该调整的方向和幅度。整体代码框架如下:
def train(self, inputs_list, targets_list): inputs = np.array(inputs_list, ndmin=2).T targets = np.array(targets_list, ndmin=2).T hidden_inputs = np.dot(self.w_input_hidden, inputs) hidden_outputs = self.activation_function(hidden_inputs) final_inputs = np.dot(self.w_hidden_output, hidden_outputs) final_outputs = self.activation_function(final_inputs) output_errors = targets - final_outputs hidden_errors = np.dot(self.w_hidden_output.T, output_errors) self.w_hidden_output += self.lr * np.dot( (output_errors * final_outputs * (1.0 - final_outputs)), hidden_outputs.T) self.w_input_hidden += self.lr * np.dot( (hidden_errors * hidden_outputs * (1.0 - hidden_outputs)), inputs.T)读这段代码时最容易卡住的是ndmin=2这个参数。它做的事情是把一个一维数组强制变成二维列向量,保证点积的维度匹配。如果不加这个参数,numpy的广播机制很可能会让矩阵乘法结果和你预期的形状不一致,有的维度甚至悄悄“摊平”,报错信息还特别难懂。所以看到ndmin=2时不要觉得多余,它是维持矩阵形状稳定的基础操作。
3.2 隐藏层误差为什么是权重矩阵转置后乘输出层误差
反向传播的核心问题是:输出层的误差是确定的,targets - final_outputs,但隐藏层的误差没有现成的目标值可以比较,怎么办?书中采用的方法是:把输出层误差按连接权重“分配”回隐藏层。
假设输出层第j个节点的误差是e_j,它与隐藏层第i个节点的连接权重是w_ij,那么隐藏层第i个节点分摊到的误差就是所有输出节点误差乘上对应权重后的求和。把所有隐藏节点一起算,正好等价于一次矩阵乘法。这一步在代码里体现为:
hidden_errors = np.dot(self.w_hidden_output.T, output_errors)权重矩阵本来就是10×100,转置变成100×10,再乘输出误差的10维列向量,得到100维的隐藏层误差。这一条公式是整本书中“数学上最优雅”的部分,把误差反向流动描述成前向传播的镜像操作。一旦理解它,再看后续的梯度更新就会觉得水到渠成。
3.3 权重更新公式里,“转置”的位置决定了能不能跑通
权重更新是训练代码中最容易出bug的部分。先看输出层到隐藏层的权重更新:
self.w_hidden_output += self.lr * np.dot( (output_errors * final_outputs * (1.0 - final_outputs)), hidden_outputs.T)括号里是三个形状为(10,1)的列向量逐元素相乘,结果仍是(10,1)。要和hidden_outputs.T点积,其中hidden_outputs的形状是(100,1),转置后是(1,100),两者点积得到10×100的矩阵,正好和w_hidden_output的形状一致。如果忘了转置hidden_outputs,会出现(10,1)点乘(100,1)直接报维度错误,或者numpy自动广播出一个完全错误的结果。
输入层到隐藏层的权重更新也是同一个套路,只不过最后点的不是hidden_outputs.T,而是inputs.T。这三个转置写对,反向传播的代码基本就稳了。我见过太多人在这一步卡住,排查半天发现只是少了个.T。养成习惯:每次写完矩阵运算,先手动核对一次形状。
3.4 为什么用scipy.special.expit,而不是手写1/(1+np.exp(-x))
sigmoid函数单写一行代码就能实现,为什么书里甚至后续代码都推荐用scipy.special.expit?原因是数值稳定性。当x是一个绝对值很大的数时,np.exp(-x)可能因为指数运算溢出而变成inf或nan,但expit在底层用了更稳健的算法,能在极端输入下依然给出接近0或1的结果。
训练过程中,权重和输入的乘积很可能出现比较大的数值,尤其在没有做归一化或者网络层数加深时。如果手写sigmoid在某个节点上算出nan,整个权重矩阵都会在下一轮迭代时被污染成一堆无效数值。所以我的建议非常明确:直接用expit,别自己造轮子。它不只是一个“更好的sigmoid”,而是保证你的训练过程不莫名其妙崩溃的保险丝。
4. 学习率、隐藏节点和epoch的实测调参结论
4.1 学习率怎么选,0.3不是拍脑袋拍出来的
学习率控制着每次权重更新的步长。太小,收敛慢,可能训练个几百轮都看不到明显提升;太大,权重更新幅度过猛,损失函数可能震荡甚至直接发散。书中的代码默认学习率是0.3,这并不代表0.3是唯一正确答案,而是在手写数字识别这类任务上,0.3是一个既稳又快的经验值。
我自己的实测感受是:学习率在0.1到0.5之间都能正常训练,差别主要在收敛速度上。0.1略微保守,前几百次迭代损失下降会慢一些;0.5明显激进,如果数据没做归一化,训练曲线会出现抖动。真正值得注意的是,当网络结构变大或数据量变多时,学习率往往需要整体调小一个数量级。所以看这本书时,不要刻舟求剑地认为0.3万能,要理解它是和当前网络规模、数据分布配套的。
4.2 隐藏层节点数的影响:更多不总是更好
隐藏层节点数直接影响网络表达能力。节点太少,模型学不到足够的特征;节点太多,训练时间直线上升,还可能过拟合。书中推荐的100个隐藏节点是一个很平衡的点,在这个配置下测试集准确率能达到97%左右。
我额外做过几组对比实验,结果符合直觉:
| 隐藏节点数 | 训练集准确率 | 测试集准确率 | 训练耗时(相对) |
|---|---|---|---|
| 10 | 约80% | 约75% | 0.3倍 |
| 100 | 约99% | 约97% | 1倍 |
| 200 | 约99% | 约97% | 2倍左右 |
可以看到,从10个节点升到100个节点,准确率提升非常明显;但从100个节点升到200个节点,测试集准确率几乎没有变化,训练时间却翻倍。这个现象说明手写数字识别这个任务的复杂度有限,100个隐藏节点已经足够容纳数据的分布特征。以后自己面对新数据集时,与其盲目加节点,不如用一组小规模对比实验来确定规模。
4.3 epoch多跑几轮真的更好吗,别被“迭代次数越多越好”骗了
第一次跑通代码的人通常会很兴奋,把训练代码丢进循环里跑几十遍epoch,期待准确率一路暴涨。但现实是,在MNIST上用这本书的网络结构,训练一轮epoch后测试集准确率就能到97%左右,再跑几轮只能提升零点几个百分点,收益极小。
原因在于这个网络结构本身容量有限,它已经“学会了”训练集中绝大多数可泛化的特征。继续跑下去,模型开始过度关注训练集中的个别噪声模式,也就是过拟合。在验证集上的表现甚至会开始轻微下降。我做过一个简单实验:训练到第20个epoch时,测试准确率基本稳定在97.5%上下,不再有什么变化,但训练时间已经翻了很多倍。
对新手来说,正确的做法是先跑1轮epoch,确认整个链路工作正常,再逐步增加epoch观察变化。不要从一开始就把epoch设成几百,否则万一代码里有bug,排查起来会非常痛苦。
4.4 怎么判断模型真的“学到了”而不是“记忆了”
只看准确率数字还不够,我习惯训练后随便挑几张测试集图片,打印网络输出的10维向量,观察“正确类别”的输出值是否明显大于其他类别。如果最大输出值只有0.3,第二第三大值也接近0.3,说明网络在多个类别之间犹豫,即便分类对了,置信度也很低。反之,如果正确类别的输出接近0.9以上,其他类别都接近0.01,才说明网络真的学到了特征。
这一点对理解神经网络非常关键。准确率只是一个宏观指标,输出向量的分布才反映模型对单个样本的把握程度。书里并没有特别强调这个观察方法,但它是我自己调参过程中最有价值的习惯之一。
5. 运行这本书的代码,最容易翻车的几个地方
5.1 环境版本与依赖:别让老代码卡在新时代
这本书最早出版的年代,Python 2和Python 3还在过渡期,书里不少示例代码在语调上也带着Python 2的影子。如果你和我一样直接在最新版Python上跑书里的代码,第一件事就是把环境调成Python 3.8以上,并安装numpy和scipy:
pip install numpy scipy有两个细节值得注意:一是有些老代码里会写xrange(),这是Python 2的遗留产物,在Python 3里一律换成range();二是如果用到绘图部分,matplotlib在新版本里有些API变动,比如scipy.special.expit在目前的主流版本中依然可用,但个别过旧的写法确实会有兼容问题。只要依赖装齐,这个程序的运行环境非常轻量,普通笔记本都能轻松跑起来。
5.2 第一次跑训练,最常见的报错和解法
我在第一次完整跑通这本书代码时,遇到过一个非常典型的报错:维度不匹配。多数情况下是np.dot的两个矩阵形状对不上,或者忘记写ndmin=2导致广播行为奇特。下面整理几个高频报错和排查思路:
ValueError: shapes (100,784) and (100,) not aligned:输入向量形状不对。需要将输入转成列向量,即np.array(inputs_list, ndmin=2).T,让维度和权重矩阵匹配。TypeError: 'numpy.float64' object is not callable:常见于误把某个变量名覆盖了函数名,比如把权重矩阵命名为w_hidden_output,之后又用self.w_hidden_output()调用它。- 训练损失出现
nan:检查是否用了不稳定的sigmoid实现,建议换成scipy.special.expit;另外检查学习率是否过大。
这些坑没有一个是“难度高”的,但它们会消耗大量时间。我自己的习惯是每写一步就打印一次关键矩阵的shape,确认无误后再继续下一步。
5.3 用自己的手写数字测试,书里没教但很值得做
训练好模型之后,拿测试集验证准确率当然很爽,但更让人有成就感的是,拿自己手写的数字去测试。实现思路不复杂:先准备一张28×28的灰度图片,把颜色反转成黑底白字(因为MNIST的样本是黑底白字,而大多数人画图时用的是白底黑字),然后归一化像素值,最后展开成784维数组喂给query()函数,看输出的最大索引是几。
from PIL import Image def prepare_image(image_path): img = Image.open(image_path).convert('L') img = img.resize((28, 28)) pixels = list(img.getdata()) # 反转颜色:MNIST里背景是黑(0),字迹是白(255) pixels = [255 - x for x in pixels] return [(x / 255.0 * 0.99) + 0.01 for x in pixels] result = net.query(prepare_image('my_digit.png')) predicted_digit = np.argmax(result)这一步看似简单,却能验证你对整个流程的理解是否真的扎实。我第一次用自己的手写数字测试时准确率明显低于测试集,原因是我写的数字和MNIST里的字体风格差异较大,而且旋转、粗细、居中程度都和训练样本不完全一致。这个结果本身就是很好的学习素材:它直观地让你体会到数据分布差异对模型泛化能力的影响。
5.4 把训练好的权重保存下来,复用而不是反复重训
每次启动程序都从头训练显然不合理。好在这套网络的“模型”就是两个权重矩阵,保存和加载都非常直接:
np.save('w_input_hidden.npy', net.w_input_hidden) np.save('w_hidden_output.npy', net.w_hidden_output) # 加载时 net.w_input_hidden = np.load('w_input_hidden.npy') net.w_hidden_output = np.load('w_hidden_output.npy')两个npy文件加起来不过几百KB,非常轻量。这个做法虽然简单,却揭示了一个重要事实:神经网络看起来复杂,训练完沉淀下来不过是一堆数字。将来你接触更复杂的框架时,会发现模型加载本质上也是在处理权重参数,原理并无二致。
6. 顺着这本书往下走,还能做什么有意思的扩展
6.1 把偏置显式加回来,观察它对结果的影响
前面提到书的示例代码里把偏置简化掉了。如果你想深入理解偏置的真实作用,可以自己改一版带显式偏置的实现:在每层加权求和后加一个可训练的偏置向量,再进入激活函数。你会看到训练效果通常会变得更好,或者至少在相同节点数下更容易收敛。
这种“故意改坏再改好”的练习,比从头读十遍教科书都管用。它让你亲身感受到每个组件在网络中扮演的角色,而不是停留在“偏置可以平移激活函数”这句话的表面。
6.2 换激活函数、加正则化,让网络更接近现代形态
这本书的核心是历史经典三层网络,但它的框架足够清晰,你完全可以在上面做各种现代化改造:把sigmoid换成ReLU,观察训练速度和收敛性有什么变化;在权重更新时加入L2正则项,看看能不能缓解过拟合;甚至把输出层的softmax加上,让输出向量变成真正的概率分布。
这些改造并不需要引入深度学习框架,只要基于现有numpy代码就能完成。改完你会意识到,那些框架里的高级组件,本质上都是一行一行矩阵运算堆叠出来的。有了这层底层认知,以后即使工作中要直接使用PyTorch或TensorFlow,你也能更快地理解模型内部到底发生了什么。
6.3 用这本书的思路,迁移到其他分类任务上
手写数字识别只是载体。理解了这套流程后,你完全可以拿它去尝试其他小规模分类任务,比如简单的字母识别、鸢尾花分类、甚至一些结构化表格数据的二分类任务。关键点在于调整输入维度、输出层节点数和数据预处理方式。
不过要提前给你打预防针:一旦数据量变大、特征维度变高,这个简单三层网络的性能会很快触顶。到那时,再学卷积神经网络或其他更复杂的结构,就顺理成章了。这本书的价值正是帮你建立“网络结构—数据形态—训练效果”三者之间的直觉,这个直觉在进阶阶段会比单纯的API调用能力重要得多。
我在实际操作中最深的体会是,这本书不适合快读,适合一台电脑摆在旁边逐行敲代码。遇到不懂的矩阵维度问题就自己print出来看,卡住了再回头看原理部分。等你要的东西都亲手跑通、跑出准确率之后,再回头看那些高大上的框架教程,会发现很多概念突然都好理解多了。