在上一章,你已经熟悉了一些开源库,它们为你提供了一组强化学习(RL)环境。然而,RL 的最新进展,尤其是与深度学习(DL)的结合,使我们如今能够解决远比以往更具挑战的问题。这在一定程度上要归功于 DL 方法与工具的发展。本章将专注于其中一个工具 PyTorch,它使我们仅用少量 Python 代码就能实现复杂的 DL 模型。
本章并不意图成为一份完整的 DL 手册(该领域广且变化快);但我们将涵盖:
- PyTorch 库的特点与实现细节(假设你已熟悉 DL 基础)
- 构建在 PyTorch 之上的更高层库,用于简化常见 DL 问题
- PyTorch Ignite 库(本章部分示例会用到)
本章所有示例已更新到撰写时的最新版本 PyTorch 2.3.1;与本书第二版所用的 1.3.0 相比有变更。若你仍在使用旧版 PyTorch,建议升级。整章我们都会提到这些差异。
张量(Tensors)
张量是所有 DL 工具包的基本构件。名字听起来有点“玄”,但其核心思想就是:张量是一个多维数组。类比中学数学:单个数字像点(0 维),向量是一维,矩阵是二维;三维数表可想象成数的长方体。更高维的数的集合,我们统称为“张量”。
图 3.1:从单个数字到 n 维张量
需要注意的是,DL 中所说的“张量”与张量微积分/张量代数中的“张量”只有部分相关。在 DL 中,张量就是任意多维数组;而在数学中,张量是向量空间之间的映射,有时可表示为多维数组,但其背后有更多语义。数学家通常不喜欢用严格的数学术语去指代“不同的东西”,所以提前提醒一下!
创建张量
本书几乎处处都要与张量打交道,先掌握一些基础操作,最基本的就是如何创建。方法有多种,选择会影响可读性与性能。
如果你熟悉 NumPy(理应如此),你已知道它的核心用途就是通用地处理多维数组。尽管 NumPy 不把这些数组称作张量,它们本质上就是张量。张量在科学计算中用途极广,例如彩色图像可编码为一个三维张量(宽 × 高 × 颜色通道)。除了维度,张量还由其元素类型刻画。PyTorch 支持 13 种类型:
- 四种浮点类型:16 位、32 位、64 位。16 位又分为两种:
float16(精度位更多)与bfloat16(指数位更大) - 三种复数类型:32 位、64 位、128 位
- 五种整数类型:8 位有符号、8 位无符号、16 位有符号、32 位有符号、64 位有符号
- 布尔类型
另外还有四种“量化数”类型,但它们底层依然使用上面这些类型,只是位表示与解释不同。
不同类型的张量对应不同的类,最常用的有 torch.FloatTensor(32 位浮点)、torch.ByteTensor(8 位无符号整型)与 torch.LongTensor(64 位有符号整型)。其他类型名称可见官方文档。
在 PyTorch 中创建张量的三种方式:
- 调用所需类型的构造函数。
- 让 PyTorch 按给定数据创建,例如
torch.zeros()创建全零张量。 - 把 NumPy 数组或 Python 列表转换为张量。此时张量类型继承自源数组的类型。
示例会话如下:
$ python
>>> import torch
>>> import numpy as np
>>> a = torch.FloatTensor(3, 2)
>>> a
tensor([[0., 0.],
[0., 0.],
[0., 0.]])
这里我们导入 PyTorch 与 NumPy,并创建了一个 3 × 2 的浮点张量。可以看到,PyTorch 现在会用 0 初始化内存——这与早期版本不同。过去仅分配未初始化内存,稍快但不安全(可能引入隐蔽 bug 与安全问题)。不过不要依赖这一行为,因为它可能再次改变(或在不同后端表现不同);务必显式地初始化张量内容。你可以使用构造操作符:
>>> torch.zeros(3, 4)
tensor([[0., 0., 0., 0.],
[0., 0., 0., 0.],
[0., 0., 0., 0.]])
或调用原地修改方法:
>>> a.zero_()
tensor([[0., 0.],
[0., 0.],
[0., 0.]])
张量操作分为两类:原地(inplace)与函数式(functional) 。原地操作名末尾带下划线 _,直接修改张量内容,并返回自身;函数式等价操作则创建副本,对副本应用修改,原张量不变。原地操作通常更省时省内存,但修改共享张量(在多段代码共用时)可能导致隐蔽 bug。
另一种通过构造函数创建张量的方式是提供 Python 可迭代对象(如 list/tuple),其内容将作为新张量的数据:
>>> torch.FloatTensor([[1,2,3],[3,2,1]])
tensor([[1., 2., 3.],
[3., 2., 1.]])
下面从 NumPy 数组创建同样“全零”的张量:
>>> n = np.zeros(shape=(3, 2))
>>> n
array([[0., 0.],
[0., 0.],
[0., 0.]])
>>> b = torch.tensor(n)
>>> b
tensor([[0., 0.],
[0., 0.],
[0., 0.]], dtype=torch.float64)
torch.tensor 接收 NumPy 数组并创建形状一致的张量。上例中,NumPy 默认创建双精度(64 位浮点)的全零数组,因此得到的张量类型是 DoubleTensor(dtype=torch.float64)。在 DL 中通常不需要双精度,而且会带来额外的内存与性能开销。常见做法是使用 32 位浮点,甚至 16 位浮点,已足够。若要创建这类张量,需要在 NumPy 端显式指定类型:
>>> n = np.zeros(shape=(3, 2), dtype=np.float32)
>>> torch.tensor(n)
tensor([[0., 0.],
[0., 0.],
[0., 0.]])
或者,在 torch.tensor 中通过 dtype 指定目标张量类型。注意:dtype 需要的是 PyTorch 的类型,不是 NumPy 的。PyTorch 的类型定义在 torch 包下,例如 torch.float32、torch.uint8 等:
>>> n = np.zeros(shape=(3,2))
>>> torch.tensor(n, dtype=torch.float32)
tensor([[0., 0.],
[0., 0.],
[0., 0.]])
兼容性提示
torch.tensor()与显式 PyTorch 类型指定是在 0.4.0 版本引入的,简化了张量创建。更早的版本推荐使用torch.from_numpy()来转换 NumPy 数组,但它在处理Python 列表与 NumPy 数组混用时存在问题。from_numpy()仍为向后兼容而保留,但已不如更灵活的torch.tensor()推荐。
标量张量(Scalar tensors)
自 0.4.0 版起,PyTorch 支持与标量值对应的零维张量(见图 3.1 左侧)。这类张量常见于某些运算的结果,例如对一个张量求和。此前,这种情形通常用“长度为 1 的一维张量(向量)”来承载。
这种做法虽可行,但不够简洁——取值时需要额外的索引。现在,零维张量已被原生支持,相关函数直接返回它;也可以用 torch.tensor() 创建。若要获取其对应的 Python 标量值,可调用特殊方法 item():
>>> a = torch.tensor([1,2,3])
>>> a
tensor([1, 2, 3])
>>> s = a.sum()
>>> s
tensor(6)
>>> s.item()
6
>>> torch.tensor(1)
tensor(1)
张量操作(Tensor operations)
可用于张量的操作非常多,难以一一列举;通常查阅文档即可:pytorch.org/docs/。需要注意两个位置:
torch包:函数以“张量作参数”的形式提供。- 张量对象的方法:以“被调用张量参与运算”的形式提供。
多数情况下,PyTorch 的张量操作会与 NumPy 的同名/同义函数保持一致。因此,凡是 NumPy 里非特别专用的函数,PyTorch 往往也具备,例如 torch.stack()、torch.transpose()、torch.cat() 等。由于 NumPy 的广泛使用,这让熟悉 NumPy 的读者不看文档也能读懂大部分 PyTorch 代码。
GPU 张量(GPU tensors)
PyTorch 对 CUDA GPU 提供透明支持:同一操作有 CPU 与 GPU 两套实现,系统会根据参与运算的张量所在设备自动选择。
前文提及的每种张量类型都有 CPU 版与GPU 版。区别仅在于:GPU 张量类位于 torch.cuda 命名空间,而 CPU 张量位于 torch。例如,torch.FloatTensor 是驻留在 CPU 内存的 32 位浮点张量,对应的 GPU 版本是 torch.cuda.FloatTensor。
实际上,PyTorch 底层不仅支持 CPU 与 CUDA,它有“后端(backend) ”的概念:即带有内存的抽象计算设备。张量可以分配到某后端的内存,并在其上进行计算。举例,在 Apple 硬件上,PyTorch 支持 Metal Performance Shaders (MPS) ,其后端名为 mps。本章聚焦最常用的 CPU/GPU 后端,但你的 PyTorch 代码通常无需大改即可在更“花哨”的硬件上运行。
在 CPU 与 GPU 之间转换,可使用张量方法 to(device) :把张量拷贝到指定设备(CPU 或 GPU)。若张量已在该设备上,则原样返回。设备可有多种指定方式:其一,直接传字符串,CPU 为 "cpu",GPU 为 "cuda"。GPU 还可在冒号后带设备索引,如 "cuda:1" 表示系统中的第二块 GPU(从 0 开始计数)。
更高效些的做法是使用 torch.device 类来构造设备对象(接收设备名与可选索引),并传给 to()。要查看张量当前所在设备,可读其 device 属性:
>>> a = torch.FloatTensor([2,3])
>>> a
tensor([2., 3.])
>>> ca = a.to('cuda')
>>> ca
tensor([2., 3.], device='cuda:0')
>>> a + 1
tensor([3., 4.])
>>> ca + 1
tensor([3., 4.], device='cuda:0')
>>> ca.device
device(type='cuda', index=0)
to() 与 torch.device 同样是在 0.4.0 中引入的。更早的版本需要分别调用张量方法 cpu() 与 cuda() 在设备间拷贝,这会在代码中增加显式的转换语句。在较新的 PyTorch 中,你可以在程序开头创建一个目标 torch.device,随后对新建的每个张量统一调用 to(device)。旧的 cpu()/cuda() 方法仍然保留——若你想不论原位置如何都强制放到某设备,它们依然便利。
梯度(Gradients)
即便有了对 GPU 的透明支持,如果没有一个“杀手级功能”——梯度的自动计算,这些围绕张量的操作也就谈不上值得了。该能力最早在 Caffe 工具包中实现,此后成为 DL 库的事实标准。
过去,即便是最简单的神经网络(NN),手动计算梯度也极其痛苦、难以调试:你需要为所有函数求导、应用链式法则、再把结果实现出来,还得祈祷每一步都正确。把这当作练习有助于理解 DL 的细枝末节,但在你尝试不同 NN 架构时,一遍遍重复这件事显然不现实。
幸运的是,那些日子已经像用烙铁和真空管编程一样远去了!如今,定义一个上百层的 NN,通常只需把预定义的积木拼起来;即便你要做点“花活”,也只是手写一个变换表达式而已。
所有梯度都会帮你算好,再沿网络反向传播并应用到模型上。要做到这一点,你需要用 DL 库的原语来定义网络结构。图 3.2 概括了优化过程中数据流与梯度流的方向:
图 3.2:数据与梯度在神经网络中的流动
梯度如何计算,会带来根本差异。主流有两种方式:
- 静态计算图(Static graph) :先把计算图预先定义,之后不可更改。DL 库会在执行前对图做处理与优化。典型实现:早期(2.0 之前)的 TensorFlow、Theano 等。
- 动态图(Dynamic graph) :无需预先把“最终将执行的图”完全定义好;你只管在真实数据上执行想要的变换操作。库会记录操作历史,当你请求计算梯度时,它会回放这段历史,为网络参数累积梯度。这也被称为“notebook gradients”,PyTorch、Chainer 等采用此法。
二者各有优劣:静态图通常更快,因为能把所有计算尽量挪到 GPU,减少数据传输开销,同时库也有更大空间来优化计算顺序、甚至剪枝图的一部分。动态图虽然在计算上有更高开销,但给予开发者更大的自由度:比如“这批数据我把这个网络跑两次;那批数据我换个完全不同的模型,并按 batch 均值裁剪梯度”。另外,动态图更自然、也更“Pythonic” :它本质上就是一组 Python 函数,直接调用,让库去施展魔法即可。
自 PyTorch 2.0 起,引入了 torch.compile,通过 JIT 将代码编译为优化后的内核,从而提速——这是对早期 TorchScript 与 FX Tracing 的演进。
从历史视角看颇有趣:原本“路线截然不同”的 TensorFlow(静态图)与 PyTorch(动态图)正逐渐相互融合——如今 PyTorch 有了 compile(),TensorFlow 也支持“急切执行(eager execution)”。
张量与梯度(Tensors and gradients)
PyTorch 的张量内建了梯度计算与跟踪机制:你只需把数据转成张量,并用 torch 提供的张量方法/函数完成计算即可。若需更底层的细节,你也能访问,但多数情况下 PyTorch 会按你预期行事。
每个张量都有一些与梯度相关的属性:
grad:与该张量形状相同的张量,保存已计算的梯度。is_leaf:若该张量由用户直接构造,则为True;若它是某个函数变换的结果(即在计算图中有父节点),则为False。requires_grad:若需要为该张量计算梯度,则为True。对叶子张量来说,这个属性由构造时决定(如torch.zeros()、torch.tensor()等的参数)。构造函数默认requires_grad=False,如需为其计算梯度,需显式指定。
看一个会话来直观理解“梯度—叶子”机制:
>>> v1 = torch.tensor([1.0, 1.0], requires_grad=True)
>>> v2 = torch.tensor([2.0, 2.0])
这里创建了两个张量:第一个需要梯度,第二个不需要。
接着逐元素相加(得向量 [3, 3])、每个元素乘 2、再求和:
>>> v_sum = v1 + v2
>>> v_sum
tensor([3., 3.], grad_fn=<AddBackward0>)
>>> v_res = (v_sum*2).sum()
>>> v_res
tensor(12., grad_fn=<SumBackward0>)
结果是一个值为 12 的零维张量。到目前为止只是简单的代数。下图给出了表达式对应的计算图:
图 3.3:表达式的计算图表示
检查属性可见:v1 与 v2 是叶子节点;除 v2 外,其余变量都需要计算梯度:
>>> v1.is_leaf, v2.is_leaf
(True, True)
>>> v_sum.is_leaf, v_res.is_leaf
(False, False)
>>> v1.requires_grad
True
>>> v2.requires_grad
False
>>> v_sum.requires_grad
True
>>> v_res.requires_grad
True
可以看到,requires_grad 有点**“粘性” :一旦参与计算的某个变量设置为 True,后续节点也会是 True。这很合理——我们通常需要为计算的中间步骤也计算梯度。但“被计算”并不意味着会保存在 .grad 字段里。为节省内存,只有 requires_grad=True 的叶子张量会保存梯度。若你希望在非叶子**节点上也保留梯度,需要调用它们的 retain_grad() 方法,告诉 PyTorch 为该非叶节点也保留梯度。
现在让 PyTorch 为我们的图求梯度:
>>> v_res.backward()
>>> v1.grad
tensor([2., 2.])
调用 backward() 后,PyTorch 会计算 v_res 对图中各变量的数值导数。换言之:v1 的任一元素增加 1,v_res 将增加 2——这正是梯度 [2, 2] 的含义。
如前所述,PyTorch 只为 requires_grad=True 的叶子张量计算梯度。检查 v2 的梯度会得到空:
>>> v2.grad
这在计算与内存效率上至关重要。真实网络可能有百万级参数与海量中间运算。在梯度下降中,我们并不关心每一次中间矩阵乘的梯度;我们只需损失对模型参数(权重)的梯度。当然,如果你想对输入数据计算梯度(例如制作对抗样本或微调预训练词向量),只需在创建该张量时设置 requires_grad=True 即可。
有了这些,你已经具备自己实现 NN 优化器的所有必要要素。接下来的内容将介绍一些便捷函数:它们提供更高层次的网络积木、常用优化算法与损失函数。当然,你完全可以按自己的喜好重新实现这些“铃铛与口哨”。这正是 PyTorch 在 DL 研究者中受欢迎的原因之一——优雅且灵活。
兼容性
在张量中内置梯度计算,是 PyTorch 0.4.0 的一项重大变更。更早版本里,计算图跟踪与梯度累积由一个非常轻量的独立类
Variable负责——它包裹张量并自动保存计算历史,以便反向传播。该类在 2.2.0 仍存在(位于torch.autograd),但已弃用,即将移除,新代码应避免使用。就我个人看,这一变化非常好:Variable的逻辑很薄,却要求开发者额外关注“包/解包”张量;如今梯度成了张量的内建属性,API 更加简洁。
神经网络积木(NN building blocks)
在 torch.nn 包中,你会找到大量预定义类,提供常用的基础功能模块。这些模块都以实践为导向设计(例如支持小批量、合理的默认值、恰当的权重初始化)。所有模块都遵循“可调用”约定:类实例可像函数一样对参数进行调用。比如,Linear 类实现了带可选偏置的前馈层:
>>> l = nn.Linear(2, 5)
>>> v = torch.FloatTensor([1, 2])
>>> l(v)
tensor([-0.1039, -1.1386, 1.1376, -0.3679, -1.1161], grad_fn=<ViewBackward0>)
这里创建了一个随机初始化的前馈层,2 个输入、5 个输出,并把它应用到一个浮点张量上。torch.nn 中的所有类都继承自基类 nn.Module,你也可以基于它实现更高层的 NN 积木。下一节会展示如何自定义模块;在此之前,先看看所有 nn.Module 子类都具备的一些有用方法:
parameters():返回需要计算梯度的所有变量(即模块的权重)的迭代器。zero_grad():将所有参数的梯度清零。to(device):把全部参数移动到指定设备(CPU 或 GPU)。state_dict():返回包含全部模块参数的字典,便于模型序列化。load_state_dict():用给定的 state 字典初始化模块。
完整类列表见官方文档:pytorch.org/docs。
接下来特别提一个很方便的类 Sequential,它能把其它层顺序组合成一条“管线”。示例如下:
>>> s = nn.Sequential(
... nn.Linear(2, 5),
... nn.ReLU(),
... nn.Linear(5, 20),
... nn.ReLU(),
... nn.Linear(20, 10),
... nn.Dropout(p=0.3),
... nn.Softmax(dim=1))
>>> s
Sequential(
(0): Linear(in_features=2, out_features=5, bias=True)
(1): ReLU()
(2): Linear(in_features=5, out_features=20, bias=True)
(3): ReLU()
(4): Linear(in_features=20, out_features=10, bias=True)
(5): Dropout(p=0.3, inplace=False)
(6): Softmax(dim=1)
)
这里我们定义了一个三层的神经网络,输出端做 softmax(沿维度 1) (维度 0 是 batch 维),中间使用 ReLU 非线性,并加入 dropout。推一条数据看看:
>>> s(torch.FloatTensor([[1,2]]))
tensor([[0.0847, 0.1145, 0.1063, 0.1458, 0.0873, 0.1063, 0.0864, 0.0821, 0.0894,
0.0971]], grad_fn=<SoftmaxBackward0>)
可以看到,我们的单样本小批量顺利通过了网络。
自定义层(Custom layers)
上一节简要提到,nn.Module 是 PyTorch 中所有 NN 积木的基类。它不仅仅是个“统一的父类”——通过继承 nn.Module,你可以创建自定义构件,把它们堆叠、复用,并与 PyTorch 框架无缝集成。
nn.Module 为子类提供了相当丰富的功能:
- 跟踪子模块:模块可含有若干子模块(例如两层前馈层组合成一个变换块)。只需把子模块赋给类的字段,即可被自动注册。
- 统一管理参数:可获取全部参数(
parameters())、清零梯度(zero_grad())、设备迁移(to(device))、序列化/反序列化(state_dict()/load_state_dict()),甚至能通过apply()以回调形式对整个模块树做通用变换。 - 前向约定:规定数据的前向变换在
forward()中实现(需要在子类中重写)。 - 还有一些高级功能,如注册钩子以调整变换或梯度流等。
这些能力让我们能把子模型以统一方式嵌入到高层模型中——处理复杂度时尤为有用。无论是一层线性的小模块,还是 1001 层的 ResNet,只要遵循 nn.Module 的约定,就能被以同样的方式管理。这对代码复用与简化(隐藏无关实现细节)非常有帮助。
为了简化自定义模块的编写,PyTorch 作者在设计上做了大量工作与“Python 魔法”。通常我们只需做两件事:注册子模块,并实现 forward() 。
下面把上一节的 Sequential 例子封装成更通用可复用的自定义模块(完整示例见 Chapter03/01_modules.py)。我们的模块类继承自 nn.Module:
class OurModule(nn.Module):
def __init__(self, num_inputs, num_classes, dropout_prob=0.3):
super(OurModule, self).__init__()
self.pipe = nn.Sequential(
nn.Linear(num_inputs, 5),
nn.ReLU(),
nn.Linear(5, 20),
nn.ReLU(),
nn.Linear(20, num_classes),
nn.Dropout(p=dropout_prob),
nn.Softmax(dim=1)
)
构造函数接收三个参数:输入维度、输出维度以及可选的 dropout 概率。首先调用父类构造函数以完成基类初始化。
随后创建一个熟悉的 nn.Sequential 管线并赋给字段 pipe。由于 nn.Sequential 自身也继承自 nn.Module,把它赋给字段即可自动注册为子模块——无需额外调用。构造结束时,所有这些字段都会被注册。若你真的需要,也可通过 add_module() 显式注册(当层数动态、需程序化创建时很有用)。
接着,必须重写 forward,实现数据变换:
def forward(self, x):
return self.pipe(x)
由于该模块只是 Sequential 的一个简易封装,我们只需调用 self.pipe 完成变换。注意:应用模块时,应当把模块当作可调用对象来用(即直接 module(input)),而不要直接调用 nn.Module 的 forward()。原因是 nn.Module 重载了 __call__() ,当我们把实例当作函数调用时,__call__() 会做一些 nn.Module 的“魔法”处理,然后再调用我们的 forward()。若直接调 forward(),会绕开这些机制,可能导致错误。
定义完自定义模块后,试着用一下:
if __name__ == "__main__":
net = OurModule(num_inputs=2, num_classes=3)
print(net)
v = torch.FloatTensor([[2, 3]])
out = net(v)
print(out)
print("Cuda’s availability is %s" % torch.cuda.is_available())
if torch.cuda.is_available():
print("Data from cuda: %s" % out.to('cuda'))
我们创建模块并指定输入/输出维度,然后构造张量并按“可调用”约定喂给模块。接着打印网络结构(nn.Module 重写了 __str__() 与 __repr__(),能把内部结构友好展示),最后打印变换结果。输出类似于:
Chapter03$ python 01_modules.py
OurModule(
(pipe): Sequential(
(0): Linear(in_features=2, out_features=5, bias=True)
(1): ReLU()
(2): Linear(in_features=5, out_features=20, bias=True)
(3): ReLU()
(4): Linear(in_features=20, out_features=3, bias=True)
(5): Dropout(p=0.3, inplace=False)
(6): Softmax(dim=1)
)
)
tensor([[0.3297, 0.3854, 0.2849]], grad_fn=<SoftmaxBackward0>)
Cuda’s availability is False
当然,关于 PyTorch“动态图”的特性,这里仍然全部适用:每个批次都会调用一次 forward();如果你需要基于输入数据做复杂变换(比如分层 softmax、按条件选择不同子网络并做 batch 均值裁剪梯度等),都可以自由编写。模块的参数数量也不限于一个——你完全可以定义多输入、多可选参数的模块,照样没问题。
接下来,我们将熟悉 PyTorch 中两个能极大简化工作的重要组件:损失函数与优化器。
损失函数与优化器(Loss functions and optimizers)
仅有把输入变成输出的网络还不够训练用。我们还需要学习目标:一个接受两项输入(网络输出与期望输出)的函数,并返回单个数值——网络预测与目标的“接近程度”。这个函数叫损失函数(loss function) ,其输出即损失值(loss) 。我们据此计算网络参数的梯度并调整参数,以降低损失,从而让模型在未来表现更好。损失函数与基于梯度“微调网络参数”的方法都极其常见、形态众多,因此在 PyTorch 中占据了重要篇幅。先看损失函数。
损失函数(Loss functions)
损失函数位于 nn 包,均实现为 nn.Module 的子类。通常接受两项参数:网络输出(预测)与期望输出(也称样本的标签)。在撰写时的 PyTorch 2.3.1 中,已有 20+ 种损失函数;当然,你也可自定义任意可优化的函数。
最常用的标准损失包括:
nn.MSELoss:均方误差,回归问题的标准损失。nn.BCELoss/nn.BCEWithLogitsLoss:二元交叉熵。前者期望输入为单个概率(通常来自 Sigmoid 输出),后者期望原始分数并内部施加 Sigmoid,数值上更稳定也更高效,二分类常用。nn.CrossEntropyLoss/nn.NLLLoss:多分类中的“最大似然”准则。前者期望每类原始分数并内部做 LogSoftmax;后者期望对数概率作为输入。
还有许多其它损失可用,你也可通过自定义 Module 来比较输出与目标。下面看优化器。
优化器(Optimizers)
优化器的基本职责是:读取模型参数的梯度,并更新这些参数以降低损失。降低损失相当于把模型推向更接近目标的方向,从而期望带来更好的性能。听起来简单,细节却很多,优化过程本身仍是研究热点。PyTorch 在 torch.optim 中实现了众多常用优化器,最知名的包括:
- SGD:朴素的随机梯度下降,可选 momentum 扩展
- RMSprop:Geoffrey Hinton 提出
- Adagrad:自适应梯度
- Adam:结合了 RMSprop 与 Adagrad 思想,广受欢迎
所有优化器都暴露统一接口,便于更换实验(有时优化方法会显著影响收敛与最终效果)。构造优化器时需要传入一个待更新张量的可迭代对象。常见做法是传入最外层 nn.Module 实例的 parameters() 结果,即所有需要梯度的叶子参数。
训练循环通用模板
for batch_x, batch_y in iterate_batches(data, batch_size=N):
batch_x_t = torch.tensor(batch_x)
batch_y_t = torch.tensor(batch_y)
out_t = net(batch_x_t)
loss_t = loss_function(out_t, batch_y_t)
loss_t.backward()
optimizer.step()
optimizer.zero_grad()
通常会对数据反复多轮迭代(完整遍历一次样本集称为一个 epoch)。数据往往过大,无法一次性放入 CPU/GPU 内存,因此被切分为等长批次。每个批次包含样本与标签,二者都需是张量(第 2–3 行)。
第 4 行把样本送入网络;第 5 行把网络输出与目标标签交给损失函数。损失值衡量了“网络输出相对目标有多坏”。由于网络输入与权重都是张量,网络的全部变换不过是由中间张量串联起来的运算图;损失函数的结果同样是一个标量张量。
计算图中的每个张量都会记录其父节点。因此,为整个网络求梯度,只需对损失张量调用 backward() (第 6 行)。这会回放计算图,给所有 requires_grad=True 的叶子张量计算梯度。通常这些张量就是模型参数,如全连接权重/偏置、卷积核等。梯度会累加到 tensor.grad 字段,因此同一张量可多次参与计算、其梯度会被正确求和(如同一个 RNN 单元反复应用于多步输入)。
loss.backward() 完成后,梯度已就绪,轮到优化器出场:它读取在构造时传入的参数集合上的梯度并应用更新,通过 step() 完成(第 7 行)。
训练循环最后一件事是把梯度清零。可对网络调用 zero_grad(),也可直接对优化器调用(第 8 行),二者效果相同。有时把 zero_grad() 放在循环开始,同样可行。
上述流程极具灵活性,即便在复杂研究中也够用。比如,你可以在同一批数据上,使用两个优化器分别更新不同模型的参数(GAN 训练就是现实例子)。
至此,我们已覆盖训练神经网络所需的 PyTorch 核心功能。本章最后会给出一个中等规模的实战示例来串联这些概念。但在此之前,还需讨论 NN 实践中的一件大事——训练过程的监控。
使用 TensorBoard 进行监控(Monitoring with TensorBoard)
如果你曾自己训练过神经网络,就会知道这件事既痛苦又充满不确定性。我说的不是照着现成教程和 demo 跑、所有超参数都已为你调好的情形,而是拿一份数据从零开始搭建。即便在如今的高级 DL 工具包加持下(例如:恰当的权重初始化、优化器的 beta/gamma 等默认到位、更多细节被封装),你仍需要做出大量决策,也就有大量可能出错的点。结果就是:第一次跑就成功几乎不会发生——习惯就好。
当然,随着经验积累,你会更懂问题可能来自何处。但要做到这点,你得有网络内部发生了什么的输入信息。也就是说,你需要窥视训练过程并观察其动态。哪怕是很小的网络(比如 MNIST 教程用的小模型),也可能有几十万参数,其训练动态相当非线性。
实践中大家形成了一份训练期应监控的要点清单,通常包括:
- 损失值(loss):往往由多个部分构成,如基础损失与正则化项。需要同时监控总体损失与各组件随时间的变化。
- 验证结果:在训练集与测试集上的评估。
- 梯度与权重的统计。
- 网络产生的值:例如分类任务要看预测概率的熵;回归任务可直接观察原始预测。
- 学习率及其他会随时间调整的超参数。
清单可以更长,还会包含领域特定指标:如词向量的投影、音频样例、GAN 生成的图像等。你也可能想监控训练速度相关的值(如每个 epoch 的耗时),以便评估优化效果或定位硬件问题。
简而言之,你需要一个通用方案:能随时间追踪大量数值,并以便于分析的方式展示——最好是为 DL 场景而生(想象把这些统计丢进 Excel……)。幸运的是,确有这样的工具;我们就来看看。
TensorBoard 入门(TensorBoard 101)
本书第一版写作时,NN 监控工具并不多。如今随着更多人和公司投入到 ML/DL,工具也多了起来,比如 MLflow(mlflow.org/)。本书仍以TensorFlow 的 TensorBoard 为主,但你也可以尝试其它替代。
自公开首版起,TensorFlow 就提供了 TensorBoard,专门解决“如何在训练期间/之后观察并分析 NN 的各类特征”的问题。它功能强、通用性好、社区大、界面也很美观:
图 3.4:TensorBoard Web 界面(更清晰版本见书中链接)
从架构看,TensorBoard 是一个 Python Web 服务:你在本机启动它,并指定一个目录——训练过程会把要分析的数值写到那里。然后在浏览器访问它的端口(通常 6006),即可看到实时更新的交互式界面(见图 3.4)。当你的训练在云端远程机器上进行时,这点尤其方便。
起初,TensorBoard 作为 TensorFlow 的一部分发布;后来被移出为独立项目(仍由 Google 维护),拥有独立的软件包名。尽管如此,TensorBoard 仍使用 TensorFlow 的数据格式,因此我们需要在 PyTorch 程序里写出这种格式的数据。早年需要第三方库;如今 PyTorch 已自带此支持(torch.utils.tensorboard 包)。
绘制指标(Plotting metrics)
为了展示 TensorBoard 用起来有多简单,先看一个与 NN 无关的小例子:把一些函数值写入 TensorBoard(完整代码见 Chapter03/02_tensorboard.py)。
导入依赖、创建数据 writer,并定义要可视化的函数:
import math
from torch.utils.tensorboard.writer import SummaryWriter
if __name__ == "__main__":
writer = SummaryWriter()
funcs = {"sin": math.sin, "cos": math.cos, "tan": math.tan}
默认情况下,SummaryWriter 会在 runs 目录下为每次运行创建一个唯一子目录,便于比较不同训练轮次。目录名包含日期时间和主机名。你也可以通过 log_dir 覆盖写入目录;或用 comment 加后缀(如 dropout=0.3、strong_regularisation)来标注实验语义。
接着在角度区间上循环:
for angle in range(-360, 360):
angle_rad = angle * math.pi / 180
for name, fun in funcs.items():
val = fun(angle_rad)
writer.add_scalar(name, val, angle)
writer.close()
这里把角度转为弧度,计算三角函数值。每个值通过 add_scalar 写入(参数依次是名称、数值、步数/迭代计数,第三个参数需为整数)。循环结束后记得 close()。注意:writer 会定期 flush(默认每 2 分钟),即便训练很长也能实时看到曲线。如需手动刷盘,可调用 flush()。
运行后控制台无输出,但在 runs 下会出现新目录与一个文件。启动 TensorBoard 查看结果:
Chapter03$ tensorboard --logdir runs
TensorFlow installation not found - running with reduced feature set.
Serving TensorBoard on localhost; to expose to the network, use a proxy or pass --bind_all
TensorBoard 2.15.1 at http://localhost:6006/ (Press CTRL+C to quit)
若在远程服务器上运行 TensorBoard,请加 --bind_all 以便外部访问。随后打开浏览器访问 http://localhost:6006,你会看到类似下图的页面:
图 3.5:示例产生的曲线图
这些图是可交互的:鼠标悬停查看数值,拖拽选区放大细节;双击图内空白可缩回。多次运行程序后,左侧 Runs 列表会出现多条记录,可任意开关以对比不同实验的动态。除了标量,TensorBoard 还能分析图像、音频、文本、嵌入向量,甚至展示你的网络结构。更多功能见官方文档。
现在,是时候把本章学到的一切串起来,用 PyTorch 处理一个真实的神经网络优化问题了。
在 Atari 图像上训练 GAN(GAN on Atari images)
几乎所有 DL 书都会用 MNIST 来展示深度学习的威力。多年下来,这个数据集对读者而言已近乎“果蝇之于遗传学家”。为打破这个传统、让内容更有趣,本书尽量避开这些走烂的路,用不同的例子来演示 PyTorch。前文我们简要提到过生成对抗网络(GAN) 。本节我们要训练一个 GAN 来生成多种 Atari 游戏的截图。
最简单的 GAN 结构如下:我们有两个神经网络(NN),第一个是“骗子”(生成器,Generator),第二个是“侦探”(判别器,Discriminator)。两个网络相互博弈——生成器试图生成以假乱真的样本,令判别器难以把它们与真实数据区分;判别器则尽力识别伪造样本。随着对抗进行,二者都会进步:生成器生成的样本越来越逼真,判别器区分“真伪”的手段也越来越高明。
GAN 的实际用途包括:提升图像质量、生成逼真的图像、以及特征学习。在我们的例子里,实用性几乎为零,但它能很好地串起我们迄今学到的 PyTorch 知识。
那就开始吧。完整代码见 Chapter03/03_atari_gan.py。这里只展示最关键的代码片段(省略 import 与常量定义)。下列类是对某个 Gym 游戏的包装器:
class InputWrapper(gym.ObservationWrapper):
"""
预处理输入的 numpy 数组:
1. 将图像缩放到预定义大小
2. 将颜色通道轴移到第一维
"""
def __init__(self, *args):
super(InputWrapper, self).__init__(*args)
old_space = self.observation_space
assert isinstance(old_space, spaces.Box)
self.observation_space = spaces.Box(
self.observation(old_space.low), self.observation(old_space.high),
dtype=np.float32
)
def observation(self, observation: gym.core.ObsType) -> gym.core.ObsType:
# 调整图像尺寸
new_obs = cv2.resize(
observation, (IMAGE_SIZE, IMAGE_SIZE))
# 变换 (w, h, c) -> (c, w, h)
new_obs = np.moveaxis(new_obs, 2, 0)
return new_obs.astype(np.float32)
上述类做了几件事:
- 把输入图像从 210×160(Atari 标准分辨率)缩放到 64×64 的正方形;
- 将图像的颜色通道从最后一维移到第一维,以符合 PyTorch 卷积层期望的输入张量形状(通道、高、宽);
- 将图像数据类型从 byte 转为 float。
然后我们定义两个 nn.Module:判别器(Discriminator)与生成器(Generator) 。前者接收缩放后的彩色图像作为输入,经过五层卷积,输出一个经 Sigmoid 非线性后的单个数值。该数值被解释为“输入图像来自真实数据集”的概率。
生成器以一组随机向量(潜变量)为输入,使用“转置卷积”(也称反卷积)把该向量变成与原始分辨率相同的彩色图像。这两个类代码较长且对理解要点帮助不大,故不在此展开,可在完整示例文件中查看。
作为输入,我们使用由随机智能体同时游玩数个 Atari 游戏时截取的屏幕图像。图 3.6 展示了示例输入:
图 3.6:三个 Atari 游戏的截图样例
图像以**批(batch)**形式组合,由下列函数生成:
def iterate_batches(envs: tt.List[gym.Env],
batch_size: int = BATCH_SIZE) -> tt.Generator[torch.Tensor, None, None]:
batch = [e.reset()[0] for e in envs]
env_gen = iter(lambda: random.choice(envs), None)
while True:
e = next(env_gen)
action = e.action_space.sample()
obs, reward, is_done, is_trunc, _ = e.step(action)
if np.mean(obs) > 0.01:
batch.append(obs)
if len(batch) == batch_size:
batch_np = np.array(batch, dtype=np.float32)
# 将输入归一化到 [-1..1]
yield torch.tensor(batch_np * 2.0 / 255.0 - 1.0)
batch.clear()
if is_done or is_trunc:
e.reset()
该函数会在提供的环境列表中无限采样,随机选择动作,并把观测加入 batch。当批大小达到要求时,我们对图像做归一化、转换为 tensor,并由生成器(generator function)产出。对“观测均值非零”的检查是为规避某个游戏中的bug,避免图像闪烁。
接下来是主函数:它准备模型并运行训练循环:
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--dev", default="cpu", help="Device name, default=cpu")
args = parser.parse_args()
device = torch.device(args.dev)
envs = [
InputWrapper(gym.make(name))
for name in ('Breakout-v4', 'AirRaid-v4', 'Pong-v4')
]
shape = envs[0].observation_space.shape
这里解析命令行参数(仅一个可选参数 --dev 指定计算设备),并创建套了包装器的环境池。稍后会把该数组传给 iterate_batches 以生成训练数据。
随后创建两个网络、损失函数与两个优化器,并初始化 TensorBoard 的 SummaryWriter:
net_discr = Discriminator(input_shape=shape).to(device)
net_gener = Generator(output_shape=shape).to(device)
objective = nn.BCELoss()
gen_optimizer = optim.Adam(params=net_gener.parameters(), lr=LEARNING_RATE,
betas=(0.5, 0.999))
dis_optimizer = optim.Adam(params=net_discr.parameters(), lr=LEARNING_RATE,
betas=(0.5, 0.999))
writer = SummaryWriter()
为什么需要两个优化器? 这正是 GAN 的训练方式:在训练判别器时,我们把真实与伪造样本一起喂给它,并配上相应标签(真=1,假=0)。这一轮只更新判别器的参数。
之后,我们再把真实与伪造样本都通过判别器一次,但这次标签全部设为 1,且只更新生成器的权重。第二轮训练生成器,让它学会愚弄判别器,使得假样本更像真样本。
接着定义若干用于累计损失的列表、迭代计数器与真/假标签的张量,并记录当前时间戳,用于每 100 次迭代报告一次耗时:
gen_losses = []
dis_losses = []
iter_no = 0
true_labels_v = torch.ones(BATCH_SIZE, device=device)
fake_labels_v = torch.zeros(BATCH_SIZE, device=device)
ts_start = time.time()
在下面的训练循环起始处,我们生成一个随机潜向量并喂给生成器:
for batch_v in iterate_batches(envs):
# 伪样本,输入 4D:batch, filters, x, y
gen_input_v = torch.FloatTensor(BATCH_SIZE, LATENT_VECTOR_SIZE, 1, 1)
gen_input_v.normal_(0, 1)
gen_input_v = gen_input_v.to(device)
batch_v = batch_v.to(device)
gen_output_v = net_gener(gen_input_v)
然后以两次调用来训练判别器:一次喂真实样本,一次喂伪造样本:
dis_optimizer.zero_grad()
dis_output_true_v = net_discr(batch_v)
dis_output_fake_v = net_discr(gen_output_v.detach())
dis_loss = objective(dis_output_true_v, true_labels_v) + \
objective(dis_output_fake_v, fake_labels_v)
dis_loss.backward()
dis_optimizer.step()
dis_losses.append(dis_loss.item())
注意这里对生成器输出调用了 detach() ,以阻止本轮训练的梯度回流到生成器(detach() 会返回一个与父计算图断开的张量副本)。
接下来训练生成器:
gen_optimizer.zero_grad()
dis_output_v = net_discr(gen_output_v)
gen_loss_v = objective(dis_output_v, true_labels_v)
gen_loss_v.backward()
gen_optimizer.step()
gen_losses.append(gen_loss_v.item())
我们把生成器的输出再次送入判别器,但这一次不阻断梯度,且损失函数的标签为真(1) 。这会推动生成器朝着“让判别器把假样本当真”的方向更新。
训练相关代码到此为止;接下来几行负责报告损失并把图像样本写入 TensorBoard:
iter_no += 1
if iter_no % REPORT_EVERY_ITER == 0:
dt = time.time() - ts_start
log.info("Iter %d in %.2fs: gen_loss=%.3e, dis_loss=%.3e",
iter_no, dt, np.mean(gen_losses), np.mean(dis_losses))
ts_start = time.time()
writer.add_scalar("gen_loss", np.mean(gen_losses), iter_no)
writer.add_scalar("dis_loss", np.mean(dis_losses), iter_no)
gen_losses = []
dis_losses = []
if iter_no % SAVE_IMAGE_EVERY_ITER == 0:
img = vutils.make_grid(gen_output_v.data[:64], normalize=True)
writer.add_image("fake", img, iter_no)
img = vutils.make_grid(batch_v.data[:64], normalize=True)
writer.add_image("real", img, iter_no)
该示例训练过程相对耗时。在 GTX 1080Ti 上,100 次迭代约 2.7 秒。起初,生成的图像完全是随机噪声;经过 1–2 万次迭代后,生成器的能力逐渐增强,输出会越来越像真实截图。
同样值得一提的是软件库的性能提升。在本书第一、二版中,相同示例在相同硬件上要慢得多:在 GTX 1080Ti 上,100 次迭代约 40 秒。而在 PyTorch 2.2.0 上同样的 GPU,只需 2.7 秒完成 100 次迭代。于是,以前需要 3–4 小时 的训练,现在大约 30 分钟就能得到不错的生成结果。
我的实验在 4–5 万次迭代(1080 上约半小时)后得到如下图像:
图 3.7:生成器网络产出的样例图像
可以看到,网络已能较好地复现 Atari 截图。下一节我们将看看如何借助 PyTorch 的增值库 Ignite,进一步简化我们的代码。
PyTorch Ignite
PyTorch 是一个优雅而灵活的库,因而成为成千上万研究者、深度学习爱好者与工业开发者的心头好。但灵活也有代价:为解决问题你往往需要写不少样板代码。有时这反而是优点:当你要实现一种尚未进库的新优化方法或 DL 小技巧时,只需用 Python 把公式写出来,PyTorch 的“魔法”就会替你搞定梯度与反向传播。又比如当你需要在很低的层次上折腾梯度、优化器细节,或数据在网络中的变换方式时,这种灵活性就很重要。
然而在很多常规任务里(比如训练一个图像分类器的监督学习),你并不需要这么多自由度。对这些任务来说,纯 PyTorch 可能显得层次过低,你得一遍遍写相同的代码。下面这份并不完整的清单列出了任何 DL 训练流程中几乎必备、但都需要写额外代码的工作:
- 数据准备与变换、批处理(batch)生成
- 训练指标的计算(loss、accuracy、F1 等)
- 定期在验证/测试集上评估当前模型
- 按迭代步数或当出现新最佳指标时保存检查点(checkpoint)
- 将指标发送到 TensorBoard 等监控工具
- 随时间调整超参数(如学习率的升/降调度)
- 在控制台输出训练进度信息
当然,仅用 PyTorch也能完成以上一切,但往往要写不少代码。因为这些任务在任何 DL 项目里都会反复出现,重复造轮子很快就让人厌倦。常见的解决之道是:把这套功能封装进库,以后复用;如果库是开源且好用(易上手、灵活、实现规范),它就会被越来越多的人采用。这并非 DL 独有,软件业到处如此。
围绕 PyTorch 简化常见任务的库有不少:ptlearn、fastai、ignite 等。“PyTorch 生态项目”的最新列表见:pytorch.org/ecosystem。
从一开始就使用这些高层库很诱人——几行代码就能解决常见问题。但这也有风险:若只会用高层库、不了解底层细节,一旦遇到非标问题就容易卡住。在高度动态的 ML 领域,这种情况并不少见。
本书的重点在于帮你理解 RL 方法、其实现与可用性,所以我们采取渐进式路径:开头用纯 PyTorch实现方法,随后逐步引入高层库。对 RL,我们会使用作者自写的小库 PTAN(github.com/Shmuma/ptan…),第 7 章会介绍。
为减少 DL 样板代码,我们将使用库 PyTorch Ignite:pytorch-ignite.ai。下面先做一个小概览,然后把 Atari GAN 示例用 Ignite 重写看效果。
Ignite 的核心概念
从高层看,Ignite 让你更容易编写 PyTorch 的训练循环。本章前面(“损失函数与优化器”)提到,最小训练循环包括:
- 采样一批训练数据
- 将批数据送入 NN,计算我们要最小化的损失函数(单个标量)
- 对损失做反向传播,得到相对损失的参数梯度
- 让优化器据此更新网络参数
- 重复以上步骤,直到你满意或不想等了
Ignite 的核心是 Engine 类:它在数据源上循环,对每个 batch 调用你提供的处理函数。此外,Ignite 允许你在训练循环的特定时机挂接回调函数,这些时机称为 Events,包括:
- 整个训练过程的开始/结束
- 每个 epoch 的开始/结束(遍历一次数据)
- 每个 batch 处理的开始/结束
除此之外,你还能定义自定义事件,例如每 N 次事件触发一次(如每 100 个 batch 或每第 2 个 epoch 做某些计算)。
一个极简的 Ignite 示例如下:
from ignite.engine import Engine, Events
def training(engine, batch):
optimizer.zero_grad()
x, y = prepare_batch()
y_out = model(x)
loss = loss_fn(y_out, y)
loss.backward()
optimizer.step()
return loss.item()
engine = Engine(training)
engine.run(data)
这段代码并不能直接运行(缺少数据源、模型、优化器的创建等),但展示了 Ignite 的基本用法。Ignite 的主要好处在于:你能很方便地用现成组件扩展训练循环。想要把 loss 做滑动平均并每 100 个 batch 写入 TensorBoard?几行就够。想每 10 个 epoch 做一次验证?写一个测试函数并attach 到 Engine,就会按计划自动调用。
Ignite 的完整功能超出本书范围,详见官网文档:pytorch-ignite.ai。
使用 Ignite 训练 Atari 上的 GAN(GAN training on Atari using Ignite)
为演示 Ignite 的用法,我们把“在 Atari 图像上训练 GAN”的示例改写一下。完整代码见 Chapter03/04_atari_gan_ignite.py。这里仅展示与上一节不同的部分。
首先,导入若干 Ignite 类:
from ignite.engine import Engine, Events
from ignite.handlers import Timer
from ignite.metrics import RunningAverage
from ignite.contrib.handlers import tensorboard_logger as tb_logger
Engine 与 Events 前面已介绍过。ignite.metrics 提供与训练过程性能度量相关的类(如混淆矩阵、precision、recall 等)。本例使用 RunningAverage 对时间序列做平滑处理。上一节里我们用 np.mean() 对损失数组取均值;RunningAverage 则更方便、也更“数学上恰当”。此外,我们从 Ignite 的 contrib 包里导入 TensorBoard logger。还会用到 Timer 处理器,便于计算两次事件间的耗时。
下一步,定义批处理函数:
def process_batch(trainer, batch):
gen_input_v = torch.FloatTensor(BATCH_SIZE, LATENT_VECTOR_SIZE, 1, 1)
gen_input_v.normal_(0, 1)
gen_input_v = gen_input_v.to(device)
batch_v = batch.to(device)
gen_output_v = net_gener(gen_input_v)
# 训练判别器
dis_optimizer.zero_grad()
dis_output_true_v = net_discr(batch_v)
dis_output_fake_v = net_discr(gen_output_v.detach())
dis_loss = objective(dis_output_true_v, true_labels_v) + \
objective(dis_output_fake_v, fake_labels_v)
dis_loss.backward()
dis_optimizer.step()
# 训练生成器
gen_optimizer.zero_grad()
dis_output_v = net_discr(gen_output_v)
gen_loss = objective(dis_output_v, true_labels_v)
gen_loss.backward()
gen_optimizer.step()
if trainer.state.iteration % SAVE_IMAGE_EVERY_ITER == 0:
fake_img = vutils.make_grid(gen_output_v.data[:64], normalize=True)
trainer.tb.writer.add_image("fake", fake_img, trainer.state.iteration)
real_img = vutils.make_grid(batch_v.data[:64], normalize=True)
trainer.tb.writer.add_image("real", real_img, trainer.state.iteration)
trainer.tb.writer.flush()
return dis_loss.item(), gen_loss.item()
该函数接收一个批次数据,完成一次判别器与生成器的更新。它的返回值用于在训练期间进行追踪;这里返回两者的损失值。同时,我们也在函数内保存图像以便在 TensorBoard 中查看。
然后,创建 Engine 实例、挂载所需处理器,并运行训练过程:
engine = Engine(process_batch)
tb = tb_logger.TensorboardLogger(log_dir=None)
engine.tb = tb
RunningAverage(output_transform=lambda out: out[1]).\
attach(engine, "avg_loss_gen")
RunningAverage(output_transform=lambda out: out[0]).\
attach(engine, "avg_loss_dis")
handler = tb_logger.OutputHandler(tag="train", metric_names=[’avg_loss_gen’, ’avg_loss_dis’])
tb.attach(engine, log_handler=handler, event_name=Events.ITERATION_COMPLETED)
timer = Timer()
timer.attach(engine)
上面的代码中,我们用处理函数构造了 engine,并为两路损失各挂载了一个 RunningAverage。被挂载后,每个 RunningAverage 都会产生一个度量(metric) ,在训练期间持续维护。我们将它们命名为 avg_loss_gen(生成器的平滑损失)与 avg_loss_dis(判别器的平滑损失),并在每次迭代后写入 TensorBoard。
同时挂载了一个 timer。这里未传入构造参数,表示使用一个手动控制的计时器(我们会手动调用 reset()),当然它也支持更灵活的配置。
最后,再挂一个事件处理器:每次迭代完成时由 Engine 调用我们的函数:
@engine.on(Events.ITERATION_COMPLETED)
def log_losses(trainer):
if trainer.state.iteration % REPORT_EVERY_ITER == 0:
log.info("%d in %.2fs: gen_loss=%f, dis_loss=%f",
trainer.state.iteration, timer.value(),
trainer.state.metrics[’avg_loss_gen’],
trainer.state.metrics[’avg_loss_dis’])
timer.reset()
engine.run(data=iterate_batches(envs))
它会输出一行日志,包含迭代索引、耗时与平滑后的度量值。最后一行启动引擎;我们把早先定义的批数据生成器作为 data 传入(iterate_batches 是生成器,返回常规的批迭代器,直接传给 run 没问题)。
就这样!运行 Chapter03/04_atari_gan_ignite.py 的效果与上一节示例相同。虽然在这个小例子里不算惊艳,但在实际项目中,Ignite 往往能让代码更简洁、更易扩展。
小结
本章快速回顾了 PyTorch 的功能与特性:从张量与梯度这样的基础,到用模块化积木搭建 NN,并学习如何自定义这些积木;我们讨论了损失函数与优化器,以及如何监控训练动态。最后介绍了 PyTorch Ignite——一个为训练循环提供更高层接口的库。本章目标是为后续内容打下 PyTorch 的快速入门基础。
下一章,我们将正式进入本书的主题:强化学习方法(RL methods) 。