☰
鱼书代码实战:从MNIST到反向传播的深度学习入门精讲
2026/10/15 20:07:06 网站建设 项目流程

简介:《深度学习入门:基于Python的理论与实现》(鱼书)配套源码包,面向刚接触深度学习、希望结合实践理解理论原理的Python开发者。全书代码按章节划分,从ch01的图形绘制与感知机实验,到ch08深度卷积网络,覆盖MNIST手写数字识别、前向与反向传播、梯度优化器对比、权重初始化、批归一化、过拟合抑制、超参数搜索及卷积核可视化等关键实验,读者可对照书中讲解逐段运行调试,直观观察参数变化对模型效果的影响。压缩包共67个文件,以59个py源码文件为主,附带3个pkl预训练/权重参数、2个md说明文档、2个png图片和1个gitignore,整体仅4.44MB,便于本地复现;common目录集中提供多层网络、优化器、训练器等公共模块,ch04/ch05等章节也可单独抽取学习。目前已有2524位学习者下载,适合入门自学或作为课程实验参考。

1. 为什么「鱼书」代码仍是必修:从手写感知机到可改的模块化骨架

很多自称学过深度学习的从业者,真正的心结是:跑得通框架,却讲不清梯度在哪算、权重凭啥这么更新。《深度学习入门:基于Python的理论与实现》这份随书代码,就是用来治这个心结的。它不依赖任何PyTorch或TensorFlow,全部用NumPy实现了感知机、三层神经网络、误差反向传播和mini-batch训练,代码量小、边界清楚,适合精读和二次开发。能用它的人有两类:一类是刚入门、想把手写实现啃下来的新手,另一类是已会用框架、想拆开黑匣子看内部结构的研究型读者。就算最后不落到书上的网络,拆过这份代码之后再接手工业项目,也不至于对着张量形状发懵。

2. 从MNIST到第一个双层网络:环境与数据流水线

鱼书代码仓库的目录结构不长,大致按章节分目录,数据集统一放在dataset目录,共用工具代码在common目录。想复现手写数字识别,第一步要操作的其实不是训练代码,而是数据集加载器。

2.1 load_mnist 的三个开关:输入张量形状由它们决定

MNIST数据集的文件在外部服务器上,初次运行脚本时联网拉取,之后会以pickle形式缓存到本地。我的习惯不是让脚本盲下,而是先确认缓存文件是否存在,找不到再触发下载。加载的核心调用是这样:

from dataset.mnist import load_mnist # normalize=True 把 0~255 像素压缩到 0~1,收敛更稳 # flatten=True 把 (60000,1,28,28) 压成 (60000, 784) # one_hot_label=True 把 0~9 的整数标签变成 10 维 0/1 向量 (x_train, t_train), (x_test, t_test) = load_mnist( normalize=True, flatten=True, one_hot_label=True ) print(x_train.shape) # (60000, 784) print(t_train.shape) # (60000, 10) print(x_test.shape) # (10000, 784)

为什么要把图片压成一行784个数值?原因在于:后面的Affine层只用一次矩阵乘法就能把“样本数×特征数”变成“样本数×神经元数”,不用手动写多层循环。784就是28×28,这个数字后面会反复出现在权重初始化代码里。

三个开关的取舍逻辑,我按实际场景说明:

  • normalize:保持True。不做归一化时,像素值在0~255区间,和0.01量级的初始权重相乘后,中间层输出动辄几十上百,Sigmoid函数大概率直接饱和,梯度趋近于0。
  • flatten:训练多层感知机必须True,卷积章节需要保留二维结构时才设False。
  • one_hot_label:鱼书交叉熵损失函数默认按独热编码形态处理批次,因此True最省事;设False也能跑,但要额外改写损失函数的标签读取方式。

首次下载如果被网络波动卡住,脚本会抛连接错误。常见做法是预先准备一份mnist.pkl,放进dataset目录,让load_mnist直接走进“pickle缓存已存在”的分支,避免反复超时。

2.2 两层网络类:权重标准差与偏置置零的理由

MNIST准备好之后,核心是构造一个双层网络。鱼书在书中反复用类来组织代码,我把初始化部分写成可直接粘贴的片段:

import numpy as np class TwoLayerNet: def __init__(self, input_size, hidden_size, output_size, weight_init_std=0.01): self.params = {} self.params['W1'] = weight_init_std * np.random.randn(input_size, hidden_size) self.params['b1'] = np.zeros(hidden_size) self.params['W2'] = weight_init_std * np.random.randn(hidden_size, output_size) self.params['b2'] = np.zeros(output_size)

参数含义:

  • input_size=784,对应flatten后的像素数;
  • hidden_size常取50,太小表达能力不足,太大在小数据集上容易过拟合;
  • output_size=10,对应数字0到9;
  • weight_init_std是权重标准差,初始值0.01适合Sigmoid。

偏置为什么直接填零?即使偏置全为0,权重矩阵的随机性也已经打破了神经元之间的对称束缚;偏置在反向传播里只接收梯度,不需要再承担打破对称的职责,所以初始化为零最干净。真正需要小心的是权重标准差:设成0.01,Sigmoid场景下信号幅度适中;如果换成ReLU,这个值有时候会把激活输出压得太小,后面第5章会展开讲。

2.3 训练循环:随机批次、梯度下降、参数更新一步到位

数据加载和网络构造都就绪后,进入最核心的训练循环。鱼书原版代码习惯用迭代次数iters_num来控制训练量,这里给一个可直接运行的小循环:

iters_num = 10000 train_size = x_train.shape[0] batch_size = 100 learning_rate = 0.1 # network 是上面 TwoLayerNet 的实例 for i in range(iters_num): batch_mask = np.random.choice(train_size, batch_size) x_batch = x_train[batch_mask] t_batch = t_train[batch_mask] grad = network.gradient(x_batch, t_batch) for key in ('W1', 'b1', 'W2', 'b2'): network.params[key] -= learning_rate * grad[key]

这段代码里值得琢磨的地方有三个:

  • np.random.choice从60000个样本里随机抽100个编号,每次迭代重新取样,这就是mini-batch梯度下降的随机性来源;
  • 更新时故意不带动量、正则、学习率衰减,只做“参数朝梯度负方向挪一步”,方便你观察最原始的梯度下降行为;
  • learning_rate=0.1是这本书里一个经验上稳健的起点,改到1.0容易发散,改到0.001则收敛极慢。

到这一步,数据、网络、循环三件事已经齐全。很多读者在这之后急着跳去训练,发现识别率迟迟上不去,多半是卡在前向传播的数值处理上,也就是接下来这部分。

3. 前向传播的每个细节:Affine输出、激活函数与数值稳定

前向传播看起来只是矩阵乘法加激活函数,一旦出现形状对不上,报错信息又长又难读。我一般会建议先把单条样本的输入输出形状写在纸上,再去对照代码。

3.1 矩阵形状推导:批次维度为什么永远放在第一维

考虑输入x的形状是(100, 784),权重W1是(784, 50),那么:

# 中间层输出 h1 = np.dot(x, W1) + b1 # (100, 50) a1 = sigmoid(h1) # (100, 50) # 输出层输出 h2 = np.dot(a1, W2) + b2 # (100, 10) y = softmax(h2) # (100, 10)

逻辑说明:

  • 第1层矩阵乘法的核心是x的第二维必须等于W1的第一维,这里784对784;
  • 批次维度100自始至终留在第一维,是因为NumPy的矩阵乘法把第一维当作“样本数”,后续的损失函数也要按批次求平均;
  • b1的shape是(50,),广播机制会自动把它加到100行的每一行上,省去手动复制。

如果报错说“维度对不上”,我的排查顺序是先看x.shape,再看W.shape,最后确认flatten开关有没有生效。曾经有个读者把flatten设成False,x.shape变成(100, 1, 28, 28),矩阵乘法当场报错,这就是最典型的形状事故。

3.2 Softmax与交叉熵的结对使用:防溢出与批次平均

输出层的Softmax常和交叉熵损失函数成对出现,原因与反向传播的简洁性有关。先看函数实现:

def softmax(a): c = np.max(a) # 防溢出:减去最大值 exp_a = np.exp(a - c) return exp_a / np.sum(exp_a) def cross_entropy_error(y, t): if y.ndim == 1: t = t.reshape(1, t.size) y = y.reshape(1, y.size) batch_size = y.shape[0] return -np.sum(t * np.log(y + 1e-7)) / batch_size

逻辑说明:

  • softmax里先减去最大值,是为了避免np.exp(1000)直接算出inf;
  • 交叉熵里加1e-7,是为了防止log(0)出现负无穷;
  • 除以batch_size得到的是平均损失,这能保证不同batch_size下的损失量级一致,便于横向对比。

这两个函数不建议单独拆开改。SoftmaxWithLoss层的反向传播结果正好是y - t,这是一个极其简洁的误差形式。如果把Softmax和损失函数分开实现再自己写反向,很容易在误差传递时多乘或少除一个batch_size,疑难怪错。

3.3 Sigmoid还是ReLU:从数值边界看激活函数的选择

鱼书早期章节用Sigmoid居多,后续才引入ReLU。以下两个实现是常被直接复用的经典写法:

def sigmoid(x): return 1 / (1 + np.exp(-x)) def relu(x): return np.maximum(0, x)

Sigmoid的输出范围是(0,1),平滑且可导,但两侧导数趋近于0,反向传播时误差每经过一层就乘一个小于1的数,网络一深梯度就指数衰减。ReLU在正区间导数为1,负区间直接截断为0,既能缓解梯度消失,又天然带来稀疏性。

实际选型我的经验是:网络只有两三层,Sigmoid调试更直观,损失曲线更平滑;网络超过三层,优先ReLU。但ReLU也有自己的坑:如果某个神经元的输入长期为负,它的梯度永远是0,权重再也不会更新,这就是常说的“神经元死亡”。所以后面很多实现会在初始化时把ReLU的权重标准差调到0.01以上,例如0.05或0.1,让输入正负分布更均衡。

4. 反向传播手把手:梯度计算与收敛性验证

反向传播是这份代码里最值得反复看的部分。鱼书的做法很聪明:先教数值微分,再教误差反向传播,最后用梯度检查确认两种方式算出的梯度接近。

4.1 数值微分:中心差分公式与h的取值逻辑

数值微分不依赖任何推导,直接按导数的定义去算。鱼书里用的是中心差分:

def numerical_gradient(f, x): h = 1e-4 grad = np.zeros_like(x) for idx in range(x.size): tmp_val = x[idx] x[idx] = tmp_val + h fxh1 = f(x) x[idx] = tmp_val - h fxh2 = f(x) grad[idx] = (fxh1 - fxh2) / (2 * h) x[idx] = tmp_val return grad

逻辑说明:

  • 中心差分用“左右各偏离h再取平均”,比单侧差分精度更高;
  • h取1e-4而不是1e-8,是因为浮点数精度有限,h太小会导致舍入误差盖过真实变化量;
  • 循环逐元素扰动参数,每算一个梯度都要完整跑一次前向传播,所以只适合小规模网络或用来做梯度检查,不能用于实际训练。

数值微分最大的缺点是慢。对784×50的权重矩阵做一遍完整梯度,意味着要跑接近四万次前向传播,几分钟都等不完。所以它只在一个场景里真正发挥作用:验证手写的反向传播有没有写错。

4.2 误差反向传播代码骨架:把每层中间结果缓存下来

反向传播的核心技巧是缓存前向传播的中间结果。以Affine层为例,它的反向需要用到输入x和权重W:

class Affine: def __init__(self, W, b): self.W = W self.b = b self.x = None self.dW = None self.db = None def forward(self, x): self.x = x out = np.dot(x, self.W) + self.b return out def backward(self, dout): dx = np.dot(dout, self.W.T) # 误差往上一层传 self.dW = np.dot(self.x.T, dout) # 权重的梯度 self.db = np.sum(dout, axis=0) # 偏置的梯度 return dx

参数说明:

  • forward里把输入x存进self.x,backward时才能计算dW;
  • dx的shape与输入x完全一致,这是“误差逐层回传”的基本保证;
  • db按axis=0求和,是因为偏置广播到了批次中的每一行,求梯度时必须把所有样本的贡献累加回来。

第一个吃透这个类写法的人,基本就能自己写ReLU层、Sigmoid层、Dropout层的反向。缓存输入、计算局部梯度、乘以上游误差,三步套路是一致的。

4.3 梯度检查:怎么判断反向传播写没写错

写反向传播时最容易犯的错是维度搞混或者漏乘某个矩阵。我通常是这样检查的:

from common.gradient import numerical_gradient # 随机挑一个批次 x_batch = x_train[:10] t_batch = t_train[:10] # f 返回损失值 f = lambda W: network.loss(x_batch, t_batch) # 对比数值梯度与反向传播梯度 grad_numerical = numerical_gradient(f, network.params['W1']) grad_backprop = network.gradient(x_batch, t_batch)['W1'] diff = np.abs(grad_numerical - grad_backprop).max() print(diff) # 经验值:小于 1e-8 基本可认为实现正确

判定标准我按经验分级:最大差值小于1e-8,说明反向传播实现正确;在1e-5到1e-8之间,可能是浮点误差,也可以接受;大于1e-4,说明反向传播必然有逻辑错误,不要硬训。常见错误包括忘记除以batch_size、Softmax的误差算错、或者把W.T写成W。

5. 鱼书代码实测中的五个高频坑:现象、根因与补救

理论看完,真正花时间的往往在跑通过程。以下五个坑,是我在复现鱼书代码和帮别人排查时遇到频率最高的,按“现象→原因→解决”的顺序记录。

5.1 MNIST下载卡死与缓存失效

现象:第一次执行load_mnist,脚本长时间停在“Downloading”阶段,最后抛连接超时,后续强制中断还会在dataset目录留下残缺文件。

原因:数据文件托管在外部服务器,网络波动或临时访问限制会导致连接被重置;中断后的残留文件可能让本地校验误判为“已存在”。

解决:先手动确认dataset目录里是否存在mnist.pkl;如果存在但加载报EOFError,直接删掉该文件重下一次。稳妥做法是找一台能稳定访问外网的机器预先下载好mnist.pkl,再拷贝到dataset目录,load_mnist检测到pickle文件就会跳过下载。

5.2 归一化开关忘开:识别率掉到九成以下

现象:同样的网络结构、同样的迭代次数,设置normalize=False后,训练损失下降变慢,最终测试识别率比normalize=True低三到五个百分点。

原因:像素值0~255进入网络后,和0.01量级的初始权重相乘,中间层输出数值偏大,Sigmoid进入饱和区,梯度趋近于零,权重更新变得极其缓慢。

解决:在load_mnist调用里显式传normalize=True。如果因为某种原因必须使用原始像素,至少要做标准归一化:x = (x - x.mean()) / x.std(),这一步在MNIST这种数据上立刻能看到损失曲线变平滑。

5.3 独热编码断开:交叉熵损失的维度崩坏

现象:训练代码不报错,但损失值一直是2.30左右,识别率接近随机水平,训练几千轮也没反应。

原因:one_hot_label=False时,标签shape是(100,),而网络输出的shape是(100, 10)。交叉熵函数里t * np.log(y)执行的是逐元素乘法,标签维度不足时NumPy广播把整数标签当成索引,语义完全错乱,损失计算实际没有对齐类别。

解决:统一使用one_hot_label=True;如果标签必须是整数形式,就得把交叉熵函数改成-np.sum(np.log(y[np.arange(batch_size), t])) / batch_size,用整数索引挑出正确类别的输出概率。两套实现不要混用。

5.4 权重初始化过大加Sigmoid:深层网络的梯度消失

现象:网络增加到四层以上后,训练初期损失几乎不动,梯度检查也很正常,但参数更新后输出层几乎没有变化。

原因:权重初始化标准差设为0.01时,多层Sigmoid逐层压缩信号,前向输出越来越集中在0.5附近,反向传播时每层误差都乘上一个远小于1的导数,权重梯度指数级缩水。

解决:给隐藏层换ReLU,并把权重标准差从0.01调到0.05或0.1;或者保持Sigmoid但缩小初始化标准差到0.001,并配合批归一化使用。鱼书后续章节代码里常见的weight_init_std=0.01只能保证两层网络在Sigmoid下正常,堆深了必须改。

5.5 学习率愣调不降:损失变成NaN的排查

现象:把learning_rate从0.1改成1.0后,运行几个batch,损失打印变成nan,之后全部是nan。

原因:学习率过大会让参数更新步长超过“安全范围”,某一层权重异常增大,矩阵乘法出现极大值,softmax减去max后依然产生数值溢出,进而污染整个反向传播过程。

解决:把学习率降回0.1重新训练。如果0.1仍然发散,优先检查输入数据是否包含非法值,例如NaN或Inf;还可以在每次更新后打印network.params['W1']的最大绝对值,超过100基本就是爆炸前兆。调试时不要只盯损失,看一眼权重统计比猜学习率快得多。

6. 把鱼书代码拆成积木:迁移到自己的项目前的三个准备

代码吃透之后,值得做一步“拆库”动作:把functions、layers、optimizer三块按边界拆开,当成自己的小工具集。

6.1 模块边界怎么切

我一般会这样组织:

  • functions.py:sigmoid、relu、softmax、cross_entropy_error等纯函数;
  • layers.py:Affine、Relu、Sigmoid、SoftmaxWithLoss等带forward/backward的类;
  • optimizer.py:SGD、Momentum、Adam的更新规则。

切分之后,新项目只需要从三个模块里import,不需要每次重写反向传播。

6.2 迁移前先复现一条损失曲线

把鱼书代码接到自己数据集之前,我先用原版MNIST跑2000次迭代,记录损失曲线;如果新数据让损失曲线形态差异过大,先怀疑数据预处理,而不是怀疑网络代码。这套验证逻辑救过我很多次,避免带着有问题的模型去调结构。

接触鱼书之前,我嫌它代码太基础。真到了自己改网络、加模块的时候才发现,最耐用的反而是那些看起来最不起眼的层实现。从那以后,我每次拿到一份新代码,都强制走一遍“先复现损失、再改网络、最后动数据”的顺序,能少熬好几个通宵。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询