简介:这份代码包完整收录《深度学习入门:基于Python的理论与实现》一书各章示例程序,面向希望边读边敲代码、快速上手深度学习基础实现的初学者。内容从第1章的图像加载与函数绘制、第2章的感知机逻辑门,逐步推进到第3章的MNIST手写数字识别及激活函数对比,再到第4章神经网络学习与梯度实现;第5章包含误差反向传播与梯度确认;第6章覆盖权重初始化、Batch Norm、Dropout、超参数优化及多种优化器对比;第7、8章则提供简单卷积网络、深度卷积网络和半精度训练等进阶实现。资源共67个文件,以59个Python脚本为主体,辅以3个pkl已训练模型参数、2个md说明文档和2个png图像文件,压缩包约4.44MB,目录按章节与common模块组织,便于对照原书逐章运行。目前已有2534人学习下载,适合作为入门深度学习课程、自学或备课的随书代码伴侣。
1. 鱼书《深度学习入门》代码:一本老书,为什么到现在还值得拆
最近我把鱼书《深度学习入门:基于Python的理论与实现》的配套代码,在两个不同 numpy 版本的环境里完整跑了一遍。跑完之后结论很直接:这本老书的代码,依然是深度学习 python 入门最好的一份最小实现素材。它不依赖 TensorFlow 或 PyTorch,前向传播、反向传播、梯度下降、CNN 的 im2col 全部用最朴素的 numpy 写出来,几百行代码就能看到一个数字图像分类任务从随机权重走到接近 95% 精度的全过程。如果你正卡在“理论都懂、代码不会写”,或者想找一份能改、能跑、能对照书里公式的基线代码,这份资源就是现成的作业模板。
2. 跑通环境与数据加载:numpy 版本、MNIST 数据与第一次推理
2.1 代码结构:按章节拆好的最小实现
打开资源包,第一件事不是读 README,而是先把目录结构摸清楚。这本书的配套代码按 ch01 到 ch08 的顺序组织,每个章节文件夹里都是相对独立的小脚本。我第一次跑的时候直接进 ch04 找训练脚本,结果因为缺了前面的目录上下文,光 import 路径就错了两回。下面是最核心的目录结构,你拿到资源后先对照检查一遍:
book-code/ ├── dataset/ # 公共数据集与加载工具 │ ├── mnist.py # load_mnist 的封装,核心入口 │ └── mnist.pkl # 序列化缓存,首次下载后生成 ├── ch03/ │ ├── neuralnet_mnist.py # 三层神经网络推理与精度验证 │ └── sigmoid.py ├── ch04/ │ ├── two_layer_net.py # 两层网络类:loss 与数值梯度 │ └── train_neuralnet.py # mini-batch 训练主循环 ├── ch05/ │ ├── layers.py # ReLU、Sigmoid、Affine、Softmax 层 │ └── train_neuralnet.py # 用反向传播优化的版本 ├── ch06/ │ ├── optimizer_compare_mnist.py # 四种优化器对比 │ └── weight_init_compare.py # 权重初始化对比 └── ch07/ ├── simple_convnet.py ├── im2col.py └── train_convnet.py这段目录信息不是摆设,它体现了作者的编排思路:dataset 是公共模块,ch03 以前的章节用不到数据集,ch04 开始做训练,ch07 才进入卷积网络。实际使用中,如果你的 IDE 没把项目根目录加进 PYTHONPATH,直接从 ch04 目录里运行 python train_neuralnet.py,大概率会报 ModuleNotFoundError。这个问题后面避坑章节会专门展开。
2.2 环境检查:先过 numpy 版本这一关
这本书写成时 numpy 还在 1.24 之前,很多代码里出现 np.float、np.int 这类 dtype 别名。我第一次在新环境执行 ch03 示例脚本,第一行随机权重初始化就炸了:AttributeError: module 'numpy' has no attribute 'float'。所以拿到代码后的第一步,不是装依赖,而是先确认当前环境能不能跑这些脚本:
python -c "import sys, numpy; print(sys.version); print(numpy.__version__)"如果输出的是 numpy 2.x,你可以选择把脚本里所有 np.float 和 np.int 全局替换成 float 和 int,也可以锁定 numpy 1.23.5。我个人习惯建一个干净的 Python 3.10 或 3.11 虚拟环境,然后装依赖。这本书只依赖 numpy 和 matplotlib,没有深度学习框架,这反而是最大优势:跑不动大模型的机器也能轻松带起来,排错范围也被压缩到最小。
pip install numpy matplotlib依赖越少,出问题的地方就越少。全书一共就用两个第三方库,如果装完后还有 import 失败,基本就是当前 shell 没有激活正确的虚拟环境,或者 pip 装到了系统级环境导致版本冲突。装完依赖后,建议先进入 dataset 目录手动执行一次 python mnist.py,确认数据能加载,再继续往下走。
2.3 load_mnist 的三个参数:决定后续所有实验的数据形态
load_mnist 是整本书出现频率最高的函数,它在 dataset/mnist.py 里,默认行为是检查本地 mnist.pkl,不存在就尝试下载原始数据并序列化。理解它的三个参数,基本等于理解了后续所有数据流向:
from dataset.mnist import load_mnist (x_train, t_train), (x_test, t_test) = load_mnist( normalize=True, flatten=True, one_hot_label=False, ) print("train:", x_train.shape, t_train.shape) print("test: ", x_test.shape, t_test.shape) print("pixel range:", x_train.min(), x_train.max()) print("labels example:", t_train[:8])正常输出是 train: (60000, 784) 和 (60000,),test: (10000, 784) 和 (10000,),像素范围 0.0 到 1.0。normalize=True 把 0 到 255 的灰度值统一缩放到 0 到 1,避免像素值过大导致梯度震荡;flatten=True 把 28x28 的二维图像拉平成 784 维向量,方便全连接层直接做矩阵乘法;one_hot_label=False 表示标签仍是 0 到 9 的整数。早期我图省事把 one_hot_label 设成 False,结果在写交叉熵时得自己处理索引取值,反而多写了代码。如果你接下来要做损失函数推导,建议直接设 True,两种形态在后面的章节都会用到。
mnist.pkl 的缓存机制值得留意:第一次加载可能触发下载,一旦 pkl 生成,后续加载都从本地文件恢复。对离线环境,你只需要提前把 mnist.pkl 放进 dataset 目录,函数检测到文件存在就会跳过下载。这个特性在避坑章会再展开。
2.4 加载后的形状自检:把问题挡在训练之前
数据加载完,不要急着跑训练,先用几个断言确认形态没有偏差。很多奇怪的训练问题,源头只是数据 shape 不符合网络预期:
assert x_train.shape == (60000, 784) assert t_train.shape == (60000,) assert x_test.shape == (10000, 784) assert t_test.shape == (10000,) assert x_train.min() >= 0.0 and x_train.max() <= 1.0第一条断言检查训练样本是否被 flatten,第二条检查标签是否一维整数数组,最后一条检查 normalize 是否真的生效。如果某条断言失败,回去检查 load_mnist 的参数配置,而不是继续带着坏数据跑模型。这样的自检成本极低,能省下后面无数排查时间。
做完自检,可以先不做训练,拿随机初始化的网络跑一次推理,预期精度在 0.10 左右:十个类别均衡分布,随机猜就是十分之一。如果你跑出来精度明显偏离这个值,说明权重初始化或数据加载有异常,先回头看 2.3 的参数。
3. 手写三层神经网络的推理:forward 与 softmax 的抄作业路径
3.1 网络骨架:参数初始化与两层隐层的 forward
在 ch03 里,作者用字典存参数,用一连串 numpy 运算完成推理。很多先学框架的人第一次看这段代码会不习惯,因为 PyTorch 里一个 nn.Sequential 就完事了。但恰恰是这种“裸写”让你看清每一层发生了什么。下面是干净版的三层网络初始化与推理:
import numpy as np def sigmoid(x): return 1 / (1 + np.exp(-x)) def softmax(a): c = np.max(a) exp_a = np.exp(a - c) return exp_a / np.sum(exp_a) def init_network(): network = {} network['W1'] = np.random.randn(784, 100) * 0.01 network['b1'] = np.zeros(100) network['W2'] = np.random.randn(100, 100) * 0.01 network['b2'] = np.zeros(100) network['W3'] = np.random.randn(100, 10) * 0.01 network['b3'] = np.zeros(10) return network def predict(network, x): W1, W2, W3 = network['W1'], network['W2'], network['W3'] b1, b2, b3 = network['b1'], network['b2'], network['b3'] a1 = np.dot(x, W1) + b1 z1 = sigmoid(a1) a2 = np.dot(z1, W2) + b2 z2 = sigmoid(a2) y = softmax(np.dot(z2, W3) + b3) return y这段代码的维度变化是 784 -> 100 -> 100 -> 10,两个隐层都接 sigmoid,输出层用 softmax 转成概率分布。乘 0.01 的权重初始化是关键:它让激活值落在 sigmoid 的线性区附近,如果标准差设成 1,深层网络的输出会很快饱和,训练时梯度小到几乎不更新。偏置全部初始化为 0 在只有两层隐层的网络里没有问题,无需担心对称性问题。
实际写代码时最容易错的一步是忘记加偏置项。有人会把 b 直接写进 np.dot 之外就完事,后果是当输入 x 的维度不是 (batch, 784) 时,广播规则会给出一个看着正常但结果错误的值。我习惯在关键层后面打印 shape,确认每一层输出维度对得上,再继续往下写。
3.2 softmax 数值稳定与 argmax 精度统计
softmax 实现里减最大值这一刀容易被忽略:exp(a) 在大数值下会溢出,减掉数组最大值后再算指数,值域就安全了。虽然 MNIST 输入已经归一化到 0 到 1,前向传播后 logits 不会太大,但养成减最大值的习惯,迁移到其他任务时能救命。
def softmax(a): c = np.max(a, axis=1, keepdims=True) exp_a = np.exp(a - c) return exp_a / np.sum(exp_a, axis=1, keepdims=True)注意这里有三个改动:axis=1 是对每个样本独立求最大值,keepdims=True 是为了保住维度用于广播。如果你直接 np.max(a),得到的是整个 batch 的全局最大值,数学上不算错,但会让每行概率的分布被一个极端样本带偏。精度统计用 argmax 和均值比较:
y = predict(network, x_test) pred = np.argmax(y, axis=1) acc = np.mean(pred == t_test) print("accuracy:", acc)axis=1 表示在类别维上取最大值的索引,pred 的 shape 是 (10000,)。和 t_test 逐位比较后取 mean,就得到测试集上的准确率。随机权重下这个数字稳定在 0.098 到 0.11 之间,因为十个类均衡分布,瞎猜也能中十分之一。
3.3 推理脚本改造成可视化版本:把黑匣子变成能看到的图
书里的 neuralnet_mnist.py 只输出精度,没有可视化。我一般在跑通后加一段 matplotlib 代码,把 28x28 的输入图像和预测结果一起打印出来,让“输入一张图、输出十个概率”的过程变得直观:
import matplotlib.pyplot as plt def visualize_prediction(network, x_test, t_test, index=0): y = predict(network, x_test[index:index + 1]) pred = np.argmax(y, axis=1)[0] plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False plt.imshow(x_test[index].reshape(28, 28), cmap='gray') plt.title(f"真值: {t_test[index]} 预测: {pred}") plt.show() visualize_prediction(network, x_test, t_test, index=0)这里 index 切片 x_test[index:index+1] 是为了保持 batch 维,直接传 x_test[0] 会让 np.dot 少一个维度,矩阵乘法直接报错。rcParams 两行配置解决中文标题方块和负号显示异常两个问题。初期跑这种可视化脚本,比只看 loss 曲线更能建立“模型到底在认什么”的直觉。
4. 反向传播与训练循环:损失、梯度与优化器的更新链路
4.1 交叉熵损失:数值下限与 mini-batch 平均
第 3 章只做推理,第 4 章开始训练,训练第一步是明确“模型错得有多远”。书里的交叉熵实现有两个细节值得抄:一是只取正确类别对应的 log 概率,二是除以 batch_size 求平均。实现如下:
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(np.log(y[np.arange(batch_size), t] + 1e-7)) / batch_size这里 y 是 softmax 的输出概率矩阵,t 是整数标签。y[np.arange(batch_size), t] 的写法等价于“选每一行里标签对应的那个概率”,比用循环快得多。加 1e-7 是为防止概率为 0 时 log(0) 变成负无穷,导致 loss 显示为 inf。如果你把 one_hot_label 设成 True,代码就要改成 t 和 y 逐位相乘再求和,两种写法结果一致,但整数标签版本更适合推导梯度。
这个函数返回的是每个样本损失的平均值,而不是总和。损失求平均后,梯度大小与 batch_size 解耦,你用 batch_size=100 和 batch_size=10000 训练时,学习率量级不需要大幅调整。这是很多初学框架的人容易忽略的点:PyTorch 默认的 CrossEntropyLoss 同样是平均损失,如果手动实现时忘了除以 batch_size,loss 曲线会随 batch 大小漂移。
4.2 梯度来源:数值梯度验证与反向传播提速
ch04 的 two_layer_net.py 里梯度来自数值微分:对每个参数做微小扰动,用中心差分近似导数。这段代码逻辑简单,但时间开销非常大,两层网络在 MNIST 上跑 10000 次迭代需要算上万次前向传播,一小时都跑不完。作者的思路是先用数值梯度验证实现正确,再在 ch05 换成反向传播提速。所以我的建议是:拿到这份资源,ch04 只要看懂数值梯度怎么写,后续跑实验直接跳到 ch05/train_neuralnet.py。
def numerical_gradient(f, x): h = 1e-4 grad = np.zeros_like(x) for i in range(x.size): tmp = x[i] x[i] = tmp + h fxh1 = f(x) x[i] = tmp - h fxh2 = f(x) grad[i] = (fxh1 - fxh2) / (2 * h) x[i] = tmp return gradh 取 1e-4 是经验值:太大梯度近似误差大,太小浮点数舍入误差大。中心差分比单侧差分误差低一个数量级,但计算量翻倍。数值梯度的唯一用途是验证反向传播算出的梯度,你在复现时如果发现两者相差超过 1e-4 量级,说明某个层的反向推导写错了,别急着调学习率。
4.3 训练主循环:迭代次数、学习率、批大小的一组安全参数
训练循环本身不复杂,但参数组合非常敏感。下面这段来自 ch04/ch05 的核心循环,是这份资源里最值得抄的作业:
iters_num = 10000 train_size = x_train.shape[0] batch_size = 100 learning_rate = 0.1 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] loss = network.loss(x_batch, t_batch) if i % 1000 == 0: print(f"iter {i}, loss {loss:.4f}")learning_rate=0.1 在 MNIST 归一化后的数据上是安全值,loss 会在 10000 次迭代内从约 0.6 附近降到 0.2 附近。如果你把 learning_rate 调到 1.0,loss 大概率一开始就震荡到 2 以上,这是最常见的训练翻车现场。batch_size=100 是书中所有章节的统一配置,配合 mini-batch 随机抽取,每个 epoch 大约相当于 600 次迭代。如果机器内存吃紧,可以把 batch_size 降为 50,但学习率也要适当下调,否则单批梯度噪声变大,收敛曲线更抖。
打印间隔在书里是 1000 次一次。实际调试时我一般改成 100 次打印一次,方便尽早发现 loss 突变,正式训练再改回 1000。另外有个容易误判的细节:np.random.choice 每次都随机抽取,没有先 shuffle 再切分,这意味着同一个样本在一个 epoch 里可能被抽中多次,也可能一次都没抽中。这正是 mini-batch SGD 的常见做法,体现的是“用随机性近似全样本梯度”的思想,别当成 bug。
4.4 优化器与权重初始化:书中自带的对比实验可以直接复用
如果只看 SGD 更新,你可能会误以为所有神经网络都这么训练。ch06 的 optimizer_compare_mnist.py 把四种优化器放在同一个问题上做对比,是快速建立优化器直觉的最好入口:
| 优化器 | 核心思路 | 典型学习率 | 适用场景 |
|---|---|---|---|
| SGD | 沿梯度反方向更新 | 0.01~0.1 | 代码最简单,适合验证 |
| Momentum | 累积速度,减轻震荡 | 0.01 | 收敛更平稳 |
| AdaGrad | 按历史梯度平方缩放 | 0.01 | 稀疏梯度场景 |
| Adam | 一阶矩加二阶矩自适应 | 0.001~0.01 | 默认选择,鲁棒性高 |
在同样的两层网络上,SGD 收敛最慢,Adam 通常最早把 loss 降到 0.3 以下。不过第一遍跑我建议不要直接换 Adam:先用 SGD 手动走一遍“前向计算、求梯度、参数更新”的完整链路,对梯度的理解会深很多;等后面调参时,再切 Adam 省时间。
权重初始化在 ch06/weight_init_compare.py 里有类似对比。随机权重标准差从 0.01 到 1 的变化,在深层网络上会造成激活值要么集中在 0 附近,要么饱和到 0/1,梯度要么消失要么爆炸。书中推荐的 Xavier 和 He 初始化各有适用激活函数:sigmoid 配 Xavier,ReLU 配 He。第 3 章里乘 0.01 之所以可行,是因为隐层只有两层;换个十层网络再用 0.01 初始化,输出层大概率什么都学不会。复现一遍这套对比实验,比背十遍理论都管用。
5. 避坑与排查:五个踩得最深的坑
5.1 numpy 版本差异导致变量初始化即崩溃
现象:运行 ch03 或 ch04 任意脚本,在构建网络参数时报 AttributeError: module 'numpy' has no attribute 'float',或类似的 np.int 报错。
原因:这本书的代码写成时 numpy 还存在 np.float、np.int 这些 dtype 别名,numpy 1.24 起正式移除,统一改用 Python 原生 float/int 或 np.float64/np.int64。
解决:全局搜索 np.float 和 np.int,替换为 float 和 int。如果是随机权重初始化里写了 np.float(0.01),直接写成 0.01 即可。我一般会在项目根目录执行一次批量替换,再逐个脚本验证没有遗漏。如果不想改动源码,也可以把 numpy 锁到 1.23.5,但这个旧版本对 Python 3.11+ 的兼容性不稳定,不推荐长期使用。
5.2 load_mnist 卡在下载阶段不动
现象:第一次运行 mnist.py,终端长时间停在下载状态,进度条不动或下载失败退出。
原因:mnist.py 内部在找不到本地 mnist.pkl 时会尝试从网络获取原始数据集,这一步对网络环境敏感,容易超时。
解决:离线或弱网环境,直接把 mnist.pkl 文件放到 dataset 目录,load_mnist 检测到本地文件存在后跳过下载,整个加载过程在本地完成。这也是我的习惯做法:任何教材的配套数据集,都先单独准备好再跑代码,避免把第一次运行的体验浪费在网络等待上。这一步不做,后面所有训练脚本都启动不了。
5.3 matplotlib 图表中文变方块、负号显示成横杠
现象:plt.title 里的中文字符显示成一排方块,坐标轴的负号显示成类似横杠的符号而不是标准减号。
原因:matplotlib 默认字体不包含中文字形,且默认启用了 Unicode 负号,但某些字体不支持该映射。
解决:在绘图脚本开头加入两行配置:
plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = FalseLinux 服务器没有 SimHei 时,可以换成 Noto Sans CJK SC 或系统中已安装的中文字体名。这两行配置从第 3 章以后的所有图表都会用到,建议统一放在一个公共配置函数里,而不是每个脚本复制粘贴。
5.4 im2col 卷积展开内存暴涨
现象:ch07 的训练脚本按默认 batch_size 跑没问题,把 batch_size 调大后内存占用飙升,甚至出现 MemoryError,进程被杀。
原因:im2col 会把每个输入图像块复制展开成一个大矩阵,卷积的矩阵乘变快了,但中间矩阵大小是 (N * OH * OW, C * FH * FW)。拿 28x28 单通道输入、5x5 卷积核来算,单个样本展开约 19600 个元素,10000 个样本就是约 1.96 亿个 float64,占用内存约 1.6GB,这还没算后续矩阵乘法的中间结果。
解决:严格按书中默认 batch_size=100 运行;如果验证阶段需要全样本一次性前向,改为分块推理。理解 im2col 公式后,也可以自己用 np.lib.stride_tricks.as_strided 实现更省内存的版本,但新手阶段直接用书里的现成实现就好。
5.5 loss 不降或震荡:先检查数据归一化和学习率
现象:训练跑了 5000 次迭代,loss 停留在 0.6 到 0.8 之间震荡,偶尔跳到 2 以上,测试精度始终没有明显增长。
原因:排在前两位的是输入没有归一化到 0 到 1,以及学习率设置过大。如果 normalize=False,像素值是 0 到 255,权重和偏置的梯度数量级被放大,同样的学习率下 loss 曲线会剧烈震荡;学习率过大也会导致参数更新越过最优区域。
解决:先把 normalize 改成 True,确认 x_train.min() 和 max() 分别在 0 和 1 附近;然后把 learning_rate 从 0.1 降到 0.01,观察 loss 是否平滑下降。如果还不行,固定随机种子排除随机性干扰,再逐个排查预处理和超参数。这个方法能解决我遇到过的八成训练不收敛问题。
6. 把鱼书代码改成可复用训练模板:从脚本到能换数据集的函数
最后一个技巧,把之前所有循环封装成可复用的训练函数,下次换个数据集,不用重写主流程:
def train(network, x_train, t_train, iters=10000, batch_size=100, lr=0.1, log_interval=500): history = [] for i in range(iters): mask = np.random.choice(x_train.shape[0], batch_size) x_batch, t_batch = x_train[mask], t_train[mask] grads = network.gradient(x_batch, t_batch) for key in network.params: network.params[key] -= lr * grads[key] if i % log_interval == 0: loss = network.loss(x_batch, t_batch) history.append((i, loss)) print(f"iter {i}: loss {loss:.4f}") return history封装后,只需要换掉 x_train、t_train 和网络结构,同样的训练流程就能用在其他图像分类数据上。history 列表留出来画 loss 曲线,不用再从 print 输出里抠数据。我个人会把这份代码存成模板,连书里的注释一起保留,新项目需要做基线实验时直接复制过来改参数。
我在跑这个模板时习惯固定随机种子,做过一次基准验证:两层网络 100 节点隐层,SGD、lr=0.1、10000 次迭代,loss 降到约 0.24,测试精度约 95.7%;同样参数换成 Adam,收敛明显更快。这次验证让我确认了这份代码的适用边界是“两层网络加 MNIST”,一旦换更复杂的数据集,必须配合归一化、权重初始化和优化器调整,不能直接套参数。
从那以后,我每次拿到一本新书的配套代码,都强制先跑通环境再做一行代码改造,花 20 分钟做一次基线验证,再决定要不要深入读源码。这个习惯让我少走了很多弯路,希望也能帮到你。
本文还有配套的精品资源,点击获取