如果你也翻过几页机器学习教材,又因为满屏的矩阵符号和“从零开始搭建PyTorch”的标题而被劝退,那这本《Python神经网络编程》可能就是你一直在找的那本书。作者塔里克·拉希德(Tariq Rashid)的写作思路非常明确:不依赖任何深度学习框架,只用Python基础库加NumPy和SciPy,亲手写一个能识别手写数字的神经网络。我读完之后最大的感受是,它把“反向传播”这个劝退大户,拆成了连初中生都能看懂的直观步骤。这篇文章就是我的完整读书笔记,包含对每一章核心内容的拆解、我复现代码时踩过的坑,以及做完调参实验之后的一些真实体会。无论你是刚入门Python的开发者,还是想深入理解深度学习底层原理的学习者,这篇文章都应该能帮到你。
1. 这本书是什么,以及为什么我推荐每个入门者都读一遍
1.1 一本不依赖任何框架的神经网络入门书
市面上讲神经网络的资料成千上万,但绝大多数要么是纯理论PPT,要么是某个框架的API教程。这本书走的是第三条路:让你从最底层亲手构建一个完整的神经网络。全书的核心代码大概只有200行左右,你不需要安装TensorFlow,不需要装PyTorch,只要有一个能跑NumPy的Python环境就足够了。
书的正文分成三大块:第一部分解释神经网络的工作原理,包括权重、激活函数、误差和梯度下降这些核心概念;第二部分进入实战,用MNIST手写数字数据集训练一个识别器,并给出了完整代码;第三部分是对模型的改进建议和更深入的思考。整本书读下来,你会发现自己不光是“会调库”,而是真的能理解每一行代码在数学上对应什么操作。
作者在书里反复强调一个观点:不要盲目复制别人的代码,自己动手写一遍,才能真正建立直觉。这句话贯穿全书。每章后面的练习题也都是围绕这个目标设计的,比如“如果只有两个输入节点,分类结果会怎样变化”“试试把学习率调成1.0会发生什么”这类实操性问题,而不是空泛的思考题。
1.2 正确的读法:先看思路,再敲代码,最后补数学
我自己的阅读过程比较波折,第一遍通读用了大约三个晚上,第二遍对照代码逐行运行又花了一个周末,第三遍回头自己推导公式时才觉得真正读透了。总结下来,这本书的正确打开方式可以分为三步。
第一步是通读,不需要在数学公式上死磕,只要在脑子里建立起“神经网络是什么、训练在做什么”的整体画面。第二步是照书敲代码,注意,是所有代码,一个字符都不跳。敲的过程中你会自然遇见很多“哦原来这里要这样处理”的细节。第三步才是回头补数学,把书里的加权求和、sigmoid导数、误差反向传播这些公式亲手推一遍,你会发现它们没有想象中复杂。
有一点必须提醒:第二章是全书的心脏,一定不能跳读。书里的神经网络最核心的“训练”逻辑全部集中在这里,第三章的MNIST实战只是套用一个已经构建好的框架。如果第二章只是草草翻过,后面大概率会卡住。
2. 神经网络的骨架:输入层、隐藏层、输出层到底在算什么
2.1 一次前向传播的计算过程
我最初看书名以为会涉及很多晦涩的数学推导,结果第二章开头就用一个“2-3-2”的小网络,把整个计算过程写成了表格,手把手教你算了一遍。这个设计真的很聪明:先用最简单、规模最小的网络建立直觉,再推广到真正的784-100-10结构。
所谓前向传播,就是把输入信号从输入层逐层向后传,直到输出层的过程。以MNIST手写数字为例:输入层有784个节点,对应一张28x28灰度图的每个像素值;隐藏层可以选100个节点;输出层有10个节点,分别代表数字0到9。每一层之间的连接都有权重,信号从上一层传到下一层时,先做加权求和,再经过激活函数,得到这一层节点的输出。
用矩阵写更简洁。设输入向量为I,输入层到隐藏层的权重矩阵为W,那么隐藏层的输入就是X = W · I,经过sigmoid激活函数后得到隐藏层输出H = sigmoid(X)。同理,输出层的输入就是W2 · H,再经过一次激活就得到最终输出。书中用矩阵形式写出来之后,整个前向传播就三行代码的事。实际敲的时候我踩过一次比较经典的坑:矩阵点乘时维度对不上。这里记住一个规律,前一层有多少个节点,权重矩阵的列数就是多少;后一层有多少个节点,权重矩阵的行数就是多少。方向写反,NumPy会直接报错。
2.2 sigmoid函数在干什么:压缩、光滑、求导简单
为什么每层都要跟一个激活函数?如果只是多层线性变换的叠加,那不管堆多少层,本质上还是线性变换,表达能力非常有限。sigmoid函数S(x) = 1/(1+e^(-x))在这里承担的任务是:把任意大小的加权求和结果压缩到(0,1)区间,同时给网络引入非线性,让它能拟合更复杂的模式。
书里给了很多直观理解的方式。你可以把sigmoid想象成一根平滑的S形管道:非常大的正数进去,出口接近1;非常大的负数进去,出口接近0;0进去,出口正好是0.5。这种平滑特性非常关键,因为我们后面要用梯度信息调整权重,如果激活函数是阶跃函数,那除了台阶处以外的所有位置梯度都是0,网络根本学不动。
还有一个特别实用的原因:sigmoid的导数形式极其简洁。对sigmoid求导,得到的恰好是O(1-O),其中O是sigmoid的输出值。这意味着我们在反向传播时甚至不需要重新算复杂的导函数,只要拿着当前节点的输出值,一减一乘就拿到了导数。书里把这个性质称为“服务梯度下降的完美激活函数”,这个说法很贴切。
2.3 权重初始化的坑:全零初始化的对称性问题
很多第一次接触神经网络的人会想:既然权重是要学出来的,那干脆全部初始化为0好了。书里专门解释了为什么这是个大坑。如果同一层所有节点的初始权重完全一样,那它们接收到的输入相同、输出相同、误差也相同,更新后权重依然相同。也就是说,这一层无论设置多少个节点,实际上都退化成了一个节点,网络失去了表达能力。
正确的做法是随机初始化。书中的实现方式是使用正态分布生成一组随机权重,均值设为0,标准差设为1除以传入节点数的平方根。这样设置的目的是控制初始加权求和的规模,避免输入值太大直接进入sigmoid的饱和区。饱和区附近导数接近0,梯度几乎消失,训练一开始就会卡死。后来我在实验里也验证了这一点:把初始权重标准差调大到几倍,网络准确率立刻下降,而且训练初期的收敛速度明显变慢。
3. 反向传播没想象中难——书里的推导路径其实是这样的
3.1 误差分派:一个最朴素的“错多少,按比例摊”
反向传播是很多人的噩梦,但这本书的推导路径真的把门槛降到了很低。它的核心思路从“误差”两个字开始:输出层的每个节点都会算出一个输出值,和目标值一比较,就得到该节点的误差。问题是,输出层的误差只能告诉我们“这两个节点错了”,但应该怪谁呢?是怪我前面某个隐藏层节点,还是怪某个具体权重?
书里的处理方法是足够朴素的:按连接权重的比例把误差回传。如果输出层节点a和隐藏层节点b之间的权重比较大,那b就要为a的误差承担更多责任;反之承担少一些。这就是“反向传播”四个字最本质的含义——沿着连接的反方向,把误差摊还给每个节点。
我当时看完这个解释,立刻觉得之前的恐惧很没必要。整个推导过程没有复杂的公式,只需要做几组简单的乘法。书中甚至专门列了一张表,一步一步用手算的方式演示了误差如何在2-3-2网络里从输出层传回隐藏层。你可以完全跟着算一遍,整个过程不超过十分钟,但收获非常大。
3.2 梯度下降:沿着最陡的下坡迈步
知道每个节点“错了多少”还不够,我们更需要知道“该往哪个方向调整权重”。这个概念被无数资料包装得高深莫测,但书里用了一个非常好的比喻:想象你站在山坡上,雾气很浓,只能感受到脚底下的坡度。梯度下降做的就是:感受当前点的最陡下坡方向,然后朝那个方向迈一小步。反复迈步,最终就能走下山谷,也就是找到让误差最小的权重组合。
每次走多长,就是学习率。书里强调了一件事:步长太大可能跳过最低点,步长太小又走得极慢甚至卡在半路。训练开始前先猜测一个学习率,再根据效果调整,是最常见的做法。这个我后面实验部分会详细说。
权重更新公式最终呈现为:新权重 = 旧权重 - 学习率 × 当前点的梯度。为什么是减号?因为梯度本身指向函数值上升最快的方向,我们要找的是误差变小的方向,所以必须反着走。这个“为什么是减号”书里专门标注了,非常贴心。
3.3 权重更新公式逐项拆解
完整写出来,输出层到隐藏层的权重更新是这样的:
新权重 = 旧权重 - 学习率 × 误差 × sigmoid导数 × 当前节点的输入
每一项都有明确的含义。误差表示这个权重连接造成的错误有多大,误差越大,更新幅度越大;sigmoid导数表示当前神经元处于什么状态,如果输出接近0或1,导数接近0,更新就会很慢;最后乘上的输入信号,表示来自前一层节点的信号有多强,信号越强的连接越需要为误差负责。
我用一个生活化的类比理解它:一位老师给一份试卷打回修改意见,权重更新就是学生根据每道错题的难度、自己的薄弱程度和这道题占的分数比例,决定在哪个知识点上花更多时间。误差代表丢了多少分,导数代表这个知识点还有多大提升空间,输入信号代表这道题是否值得重点投入。三者相乘,就是一个合理的“整改力度”。这样拆开看,反向传播就不再是玄学了。
4. 从零写一个手写数字识别器:核心代码与数据处理细节
4.1 一个极简的神经网络类
书里给出的代码框架非常精简。我用Python重新整理后,核心就是一个类,包含初始化、训练、查询三个方法:
import numpy as np import scipy.special 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: scipy.special.expit(x) 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.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)) def query(self, inputs_list): inputs = np.array(inputs_list, ndmin=2).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初始化方法里有两个容易忽略的细节。一是权重矩阵的形状:wih的形状是(隐藏层节点数, 输入层节点数),who的形状是(输出层节点数, 隐藏层节点数),方向反了就会在np.dot时报错。二是np.random.normal的标准差用了pow(self.hnodes, -0.5),也就是1/sqrt(隐藏层节点数),这正是前面第2章讲过的控制初始信号规模的技巧。
train方法的前半段和query完全一样,都是前向传播;后半段才是核心,分成计算输出误差、把误差回传到隐藏层、逐层更新权重三步。这段代码的写法非常规整,每一行都能和上一章的数学公式对应起来。我在自己的电脑上手动输入这段代码时,遇到最多的错误就是漏写.T转置,导致矩阵乘法维度不匹配。如果你想完全复现,建议对照着书里对每个数组.shape的标注,先打印出每个中间变量的形状。
4.2 MNIST数据解析与归一化:灰度、CSV、标签
MNIST是一个经典的手写数字数据集,包含60000张训练图片和10000张测试图片,每张都是28x28的灰度图。官方提供的CSV格式很简单:每行第一个数字是图片的标签,后面784个数是像素灰度值,范围0到255。
数据进入网络之前需要做两件事。第一是归一化。书里没有直接用像素值除以255完事,而是做了一个小偏移:像素灰度除以255再乘以0.99,最后再加上0.01。也就是说,原本的0变成0.01,原本的255变成1.0。为什么要留这0.01?因为sigmoid在极端靠近0和1的位置导数非常小,而输入为0还会让梯度更新时乘上一个0,导致该条连接永远无法更新。这个细节书里特意加粗提醒,值得留意。
第二是目标值的编码。输出层的10个节点代表数字0到9,训练时目标向量只有目标数字对应位置为0.99,其余全部填充0.01。这里同样不直接用1和0,背后的原因和输入归一化是一致的:sigmoid的输出永远到不了1,也到不了0,如果目标设为1和0,网络就必须把权重推向无穷大,才能无限逼近这个目标值。书里将0.99和0.01称为“目标值的软边界”,这个说法很形象。
数据处理部分的参考代码大致是这样:
import numpy as np data_file = open("mnist_train.csv") data_list = data_file.readlines() data_file.close() for record in 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)测试阶段则是对测试数据的每一行做同样的预处理,用query方法拿到输出,再用np.argmax选出最大的输出索引,和真实标签比较。
4.3 实测结果:训练一轮和训练五轮的差距
我用默认参数跑了一遍:784个输入节点、100个隐藏节点、10个输出节点、学习率0.3。只训练1个epoch时,测试集准确率大概在93%左右;训练5个epoch后,准确率能到97%附近。这个提升幅度非常说明问题:即便同样一套网络结构,多轮迭代能让权重持续微调,准确率也顺势上去。
我顺手把训练轮数拉到10、20、50,发现准确率不再单调上升,而是稳定在97%到97.5%之间,个别时候还有轻微回落。这个现象就是后面要说的过拟合前兆:网络在训练集上越来越好,但对测试集的泛化能力几乎到顶了。书中也提到,继续增加训练轮数的边际收益很小,真正值得关注的是数据质量、网络结构和学习率这三者的配合。
5. 入门之后的事:学习率、隐藏层节点数和我的调参实验
5.1 学习率对比:0.01学不动,0.3刚好,1.0会震荡
书里提到学习率是“需要亲手试出来的参数”,我于是做了一个小实验,保持其他条件不变,只改学习率。结果如下:
| 学习率 | 训练5个epoch后的测试集准确率 | 直观感受 |
|---|---|---|
| 0.01 | 约89% | 收敛极慢,明显欠拟合 |
| 0.1 | 约96% | 表现正常,略微保守 |
| 0.3 | 约97% | 效果最好,推荐值 |
| 1.0 | 约95%,训练曲线明显震荡 | 步子太大,损失值上下跳动 |
学习率太小的问题很好理解:权重每次只挪一点点,5个epoch根本不够它收敛到位。学习率太大会导致步子跨过最优点又弹回来,损失值震荡,甚至越震荡越偏离。从这个表可以直观看出,0.3确实是这本书默认值背后比较稳健的选择。
5.2 隐藏层节点数:3到200的变化
隐藏层节点数决定这个三层的网络能拟合多复杂的函数。我把隐藏层节点数分别设成3、10、100、200,固定学习率0.3、训练5个epoch:
| 隐藏层节点数 | 测试集准确率 | 单个epoch耗时(粗略) |
|---|---|---|
| 3 | 约82% | 很短 |
| 10 | 约94% | 较短 |
| 100 | 约97% | 中等 |
| 200 | 约97%,几乎无提升 | 明显变长 |
节点数太少,网络能区分的模式太少,实际表现接近一个线性分类器;节点数到100之后,准确率基本进入平台期,再翻倍只会增加训练时间,对结果帮助不大。这个实验直接帮我校准了“模型容量”这个概念——并非容量越大越好,够用且不拖慢训练才是更实际的选择。
5.3 自己画图的识别实验:预处理才是重头戏
训练完模型后,书里鼓励读者去做一件有意思的事:用自己的笔迹测试模型。我也试了,发现真正的难点不在网络,而在图像预处理。MNIST是黑底白字,画布是28x28;而我自己用绘图工具画的图通常是白底黑字、尺寸也大得多。
处理流程大致是这几步:用PIL把图片读取为灰度图,缩放到28x28,反色,再按同样的公式归一化到0.01到1.0之间。核心代码片段:
from PIL import Image import numpy as np img = Image.open("my_digit_7.png").convert("L") img = img.resize((28, 28)) img_array = np.asarray(img) # 如果原图是白底黑字,需要反色成黑底白字 img_array = 255 - img_array inputs = (img_array / 255.0 * 0.99) + 0.01 outputs = n.query(inputs) print(np.argmax(outputs))我实测的结果是:写得很规整的数字,识别基本正确;字迹潦草、位置偏移、笔画太细的,识别率掉得很快。这说明训练数据里手写风格的分布与我的笔迹存在明显差异,也直观体会到数据预处理和输入分布一致性有多重要。如果你也想复现这个实验,建议把图片居中裁剪,让数字主体尽量占满28x28的中央区域,否则模型很容易把大片空白当背景学进去。
6. 把这本书读薄又读厚:边界认知与下一步学习路径
6.1 这本书没讲什么,以及为什么没讲也是优点
读完后我去翻了目录,确认这本书确实没有讲卷积神经网络,没有讲Dropout、批归一化、各种优化器,也没有任何关于PyTorch或TensorFlow的API。初看会觉得内容不够“现代”,但正是这种克制让它成为一本优秀的入门书。
原因是,它把“神经网络是什么”这个核心问题,用最少的依赖完整回答了。如果你先接触的是PyTorch,你会把网络定义成一堆层和张量操作,很容易忽略每个层里的权重到底在做什么;而这本书记录的是手工更新权重的那一瞬间,让你真正看见“学习”发生在哪里。有了这个底层画面,再去学任何框架都只是换一套语法。
6.2 读完可以做的三个小扩张
如果不想读完就停在97%的准确率,我有三个方向可以推荐,难度递增,但都建立在书里的知识之上。
第一个是给网络加上第二个隐藏层,变成四层结构。这需要自己处理两层之间的误差回传,把书里的推导逻辑多套一层就行。第二个是把sigmoid换成ReLU,观察训练速度的变化。ReLU的导数更简单、计算更快,换过去之后你会发现同样的epoch数下,收敛特性有明显不同。第三个是换数据集,把MNIST换成Fashion-MNIST或CIFAR-10的简化版,看看同一个全连接网络在不同数据上的表现有什么差异。这三个实验做完,你很容易意识到“网络结构设计”为什么是深度学习里如此关键的话题。
6.3 关于“写出来才算真懂”的阅读建议
书的后记里有一句话让我印象很深,大意是“读完代码和真正写出代码之间的距离,比想象中要大得多”。我这次的体验也完全如此。前两遍看书,我自认为逻辑都清楚;直到把代码里的每一个shape、每一步矩阵乘法都对上公式之后,才敢说我真正读完了这本书。
所以我最后的建议很简单:无论是学习哪本技术书,都不要只看作者的结论。拿到代码之后,先原样运行一遍,再故意改坏一个参数,比如把学习率调成0.005,或者把权重初始化标准差放大十倍,观察模型的反应。这是个特别好的“排错练习”,它能帮助你真正建立对参数和结构的直觉。毕竟,神经网络不是靠读懂的,是靠一遍遍写和改,才慢慢长在脑子里的。