1.引言
PyTorch专辑的知识源于2023年阿姆斯特丹大学深度学习课程的PyTorch入门教程,旨在为程序员提供PyTorch基础的简洁介绍,并帮助您配置环境,以便编写自己的神经网络。PyTorch是一个开源的机器学习框架,它允许您自定义神经网络,并有效地进行优化。尽管PyTorch并非唯一的机器学习框架,但它有其独特的优势。其他类似的框架包括TensorFlow、JAX和Caffe。我们选择介绍PyTorch的相关知识,主要是因为它已经非常成熟,拥有庞大的开发者社群(最初由Facebook开发),并且因其灵活性和在研究领域的广泛应用而受到青睐。许多当前的研究论文都使用PyTorch进行代码发布,因此,熟悉PyTorch对您来说将是一个宝贵的技能。与此同时,TensorFlow(由Google开发)通常被认为是一个适合生产环境的深度学习库。然而,一旦您深入理解了一个机器学习框架,学习另一个框架就会变得相对容易,因为它们之间共享许多相同的概念和思想。例如,TensorFlow的第二版在很多方面都受到了PyTorch的启发,使得这两个框架在某些方面更加相似。
我们选择分享此专辑旨在为您提供特别为实践课程设计的基础知识,同时深入理解PyTorch的内部工作原理。在接下来的几周里,我们还将继续通过一系列关于深度学习的博文,探索PyTorch的新特性。
1.1.导入程序包
# 导入标准库
import os # 用于与操作系统交互,如文件路径操作
import math # 提供数学函数和常量
import numpy as np # NumPy库,提供大量的数学函数操作以及高性能的多维数组对象
import time # 提供时间相关的函数
# 导入绘图库
import matplotlib.pyplot as plt # Matplotlib的pyplot模块,提供了类似于MATLAB的绘图系统
%matplotlib inline # 在Jupyter Notebook中使matplotlib绘制的图形能够内嵌显示
from IPython.display import set_matplotlib_formats # IPython的显示模块,用于设置图形的输出格式
set_matplotlib_formats('svg', 'pdf') # 设置图形输出的格式为SVG和PDF,便于导出和高质量打印
from matplotlib.colors import to_rgba # 用于颜色转换的函数,将颜色名称或颜色代码转换为RGBA格式
import seaborn as sns # Seaborn库,基于matplotlib的高级绘图库,提供了更多样化的绘图样式和调色板
sns.set() # 设置Seaborn的默认样式,使其绘制的图形更加美观
# 导入进度条库
from tqdm.notebook import tqdm # tqdm的notebook版本,用于在Jupyter Notebook中显示进度条
# 注意:tqdm中文注释本身是不必要的,因为tqdm本身就是一个进度条工具,这里的注释只是说明其用途
2.Pytorch基础
我们将从回顾PyTorch的非常基础的概念开始。作为先决条件,我们建议您熟悉numpy包,因为大多数机器学习框架都基于非常相似的概念。如果您还不熟悉numpy,不用担心,后续章节将进行详细介绍。
那么,让我们从导入PyTorch开始。该包称为torch,基于其原始框架Torch。作为第一步,我们可以检查其版本:
import torch
print("Using torch", torch.__version__)
Using torch 2.1.0
在编写本文时,最新的版本是2.1。因此,您应该看到输出为"Using torch 2.1.0"或"Using torch 2.0.0",最终可能在Colab上有一些CUDA版本的扩展。如果您使用dl2023环境,您应该看到"Using torch 2.1.0"。通常,建议将PyTorch版本保持更新到最新版本。如果您看到的版本号低于2.0,请确保您已安装正确的环境。如果在本文发布之后,PyTorch 2.2或更新版本,你也不用担心,PyTorch版本之间的接口变化不大,因此所有代码也应该可以与新版本一起运行。
正如在每个机器学习框架中一样,PyTorch提供了诸如生成随机数等随机函数。然而,一个非常好的实践是设置您的代码,以便使用完全相同的随机数进行可重复性。这就是为什么我们在下面设置了一个种子。
torch.manual_seed(42) # 设置种子
<torch._C.Generator at 0x7fa327d6da90>
2.1.张量
张量是PyTorch对numpy数组的等价物,另外还支持GPU加速。名称“tensor”是对您已经知道的概念的概括。例如,向量是一维张量,矩阵是二维张量。在使用神经网络时,我们将使用各种形状和不同维度数量的张量。
您从numpy知道的大多数常用函数也可以在张量上使用。实际上,由于numpy数组与张量非常相似,我们可以将大多数张量转换为numpy数组(反之亦然),但我们不需要经常这样做。
2.1.1.初始化
让我们首先看看以不同方式创建张量。有许多可能的选项,最简单的是调用torch.Tensor并传递所需的形状作为输入参数:
x = torch.Tensor(2, 3, 4)
print(x)
该函数torch.Tensor为所需的张量分配内存,但会重用已经在内存中的任何值。要在初始化期间直接为张量分配值,有许多替代方案,包括:
torch.zeros
: 创建一个填充零的张量torch.ones
: 创建一个填充一的张量torch.rand
: 创建一个在0和1之间均匀采样的随机值张量torch.randn
: 从均值为0,方差为1的正态分布中采样随机值的张量torch.arange
: 创建一个包含值 N , N + 1 , N + 2 , . . . , M N, N+1, N+2, ..., M N,N+1,N+2,...,M的张量torch.Tensor (input list)
: 根据您提供的列表元素创建一个张量
从嵌套列表创建张量
x = torch.Tensor([[1, 2], [3, 4]])
print(x)
tensor([[1., 2.],
[3., 4.]])
创建一个形状为[2, 3, 4]的0到1之间的随机值张量
x = torch.rand(2, 3, 4)
print(x)
您可以像在numpy中一样(x.shape)获取张量的形状,或者使用.size方法:
shape = x.shape
print("Shape:", x.shape)
size = x.size()
print("Size:", size)
dim1, dim2, dim3 = x.size()
print("Size:", dim1, dim2, dim3)
Shape: torch.Size([2, 3, 4])
Size: torch.Size([2, 3, 4])
Size: 2 3 4
2.1.2.张量与Numpy数组的转换
张量可以转换为Numpy数组,反之亦然。要将Numpy数组转换为张量,我们可以使用torch.from_numpy
函数:
np_arr = np.array([[1, 2], [3, 4]])
tensor = torch.from_numpy(np_arr)
print("Numpy 数组:", np_arr)
print("PyTorch 张量:", tensor)
Numpy 数组: [[1 2]
[3 4]]
PyTorch 张量: tensor([[1, 2],
[3, 4]])
要将PyTorch张量转换回Numpy数组,我们可以在张量上使用.numpy()
方法:
tensor = torch.arange(4)
np_arr = tensor.numpy()
print("PyTorch 张量:", tensor)
print("Numpy 数组:", np_arr)
PyTorch 张量: tensor([0, 1, 2, 3])
Numpy 数组: [0 1 2 3]
将张量转换为Numpy需要确保张量位于CPU上,而不是GPU上(关于GPU支持的更多信息将在后面的部分介绍)。如果您有一个在GPU上的张量,您需要先调用.cpu()
方法。因此,您会看到类似np_arr = tensor.cpu().numpy()
的代码行。
2.1.3.操作
大多数在numpy中存在的操作,在PyTorch中也同样存在。可以在PyTorch文档中找到操作的完整列表,但我们将在这里回顾最重要的一些操作。
最简单的操作是将两个张量相加:
x1 = torch.rand(2, 3)
x2 = torch.rand(2, 3)
y = x1 + x2
print("X1", x1)
print("X2", x2)
print("Y", y)
X1 tensor([[0.1053, 0.2695, 0.3588],
[0.1994, 0.5472, 0.0062]])
X2 tensor([[0.9516, 0.0753, 0.8860],
[0.5832, 0.3376, 0.8090]])
Y tensor([[1.0569, 0.3448, 1.2448],
[0.7826, 0.8848, 0.8151]])
调用x1 + x2
会创建一个新的张量,包含两个输入的和。然而,我们也可以进行就地操作,这些操作直接在张量的内存上应用。因此,我们可以在操作前改变x2
的值,而没有机会重新访问操作前x2
的值。下面展示了一个例子:
x1 = torch.rand(2, 3)
x2 = torch.rand(2, 3)
print("X1(之前)", x1)
print("X2(之前)", x2)
x2.add_(x1)
print("X1(之后)", x1)
print("X2(之后)", x2)
X1(之前) tensor([[0.5779, 0.9040, 0.5547],
[0.3423, 0.6343, 0.3644]])
X2(之前) tensor([[0.7104, 0.9464, 0.7890],
[0.2814, 0.7886, 0.5895]])
X1(之后) tensor([[0.5779, 0.9040, 0.5547],
[0.3423, 0.6343, 0.3644]])
X2(之后) tensor([[1.2884, 1.8504, 1.3437],
[0.6237, 1.4230, 0.9539]])
就地操作通常以下划线后缀标记(例如,使用add_
而不是add
)。
另一个常见操作是改变张量的形状。大小为(2,3)的张量可以重新组织为任何其他具有相同元素数量的形状(例如,大小为(6)或(3,2)等)。在PyTorch中,这个操作称为view
:
x = torch.arange(6)
print("X", x)
X tensor([0, 1, 2, 3, 4, 5])
x = x.view(2, 3)
print("X", x)
X tensor([[0, 1, 2],
[3, 4, 5]])
x = x.permute(1, 0) # 交换第0维和第1维
print("X", x)
X tensor([[0, 3],
[1, 4],
[2, 5]])
在神经网络的构建中,矩阵乘法是一项不可或缺的操作。想象一下,我们手头有一个输入向量 x \mathbf{x} x,它通过一个训练得到的权重矩阵 W \mathbf{W} W 被转换。实现矩阵乘法有多种方法和函数,以下是一些常用的方法:
torch.matmul
:对两个张量执行矩阵乘积操作,其行为依据张量的维度而定。若两个输入均为矩阵(即二维张量),它将执行标准的矩阵乘法。对于更高维度的张量,该函数支持广播机制(详见官方文档)。此外,也可以使用a @ b
的形式,与numpy中的操作类似。torch.mm
:对两个矩阵执行矩阵乘法,但不提供广播支持(更多信息见官方文档)。torch.bmm
:支持批次(batch)维度的矩阵乘法。假设第一个张量 T T T 的形状为 ( b × n × m ) (b \times n \times m) (b×n×m),第二个张量 R R R 的形状为 ( b × m × p ) (b \times m \times p) (b×m×p),则输出 O O O 的形状为 ( b × n × p ) (b \times n \times p) (b×n×p),它是通过对 T T T 和 R R R 的子矩阵执行 b b b 次矩阵乘法来计算的: O i = T i @ R i O_i = T_i @ R_i Oi=Ti@Ri。torch.einsum
:使用爱因斯坦求和约定执行矩阵乘法及其他操作(例如乘积的和)。关于爱因斯坦求和的详细解释可以在作业1中找到。
通常情况下,我们会选择使用 torch.matmul
或者 torch.bmm
。下面我们将通过 torch.matmul
来演示一次矩阵乘法。
首先,我们创建一个名为 x
的张量,然后将其转换为一个二维矩阵形式:
x = torch.arange(6)
x = x.view(2, 3)
print("X =", x)
接着,我们定义权重矩阵 W
:
W = torch.arange(9).view(3, 3)
print("W =", W)
然后执行矩阵乘法并打印结果:
h = torch.matmul(x, W)
print("h =", h)
2.1.4.张量索引
在处理张量时,我们经常需要从中选取一部分数据。在PyTorch中,索引的使用方式与numpy一致。以下是一些索引操作的例子:
x = torch.arange(12).view(3, 4)
print("X =", x)
# 打印第二列
print(x[:, 1])
# 打印第一行
print(x[0])
# 打印前两行的最后一列
print(x[:2, -1])
# 打印中间两行的所有数据
print(x[1:3, :])
2.2.动态计算图与反向传播
深度学习项目中选择PyTorch的一个主要原因是其能够自动计算我们所定义函数的梯度/导数。我们将主要使用PyTorch来实现神经网络,神经网络本质上是一些复杂的函数。当我们在函数中使用我们希望学习到的权重矩阵时,这些权重矩阵被称为参数或权重。
如果我们的神经网络输出一个单一的标量值,我们讨论的是取导数;但你会发现我们经常有多个输出变量,在这种情况下我们讨论的是梯度,这是一个更通用的术语。
给定一个输入 x \mathbf{x} x,我们通过操作该输入来定义我们的函数,这通常包括与权重矩阵的矩阵乘法以及与所谓的偏置向量的加法。在操作输入的过程中,我们自动创建了一个计算图。这个图展示了如何从输入得到输出。
PyTorch是一个定义即运行的框架,这意味着我们可以执行操作,PyTorch会为我们跟踪计算图。因此,我们是在操作过程中动态创建计算图。
总结一下:我们所需要做的就是计算输出,然后我们可以要求PyTorch自动获取梯度。
为什么我们需要梯度? 假设我们定义了一个函数,一个神经网络,它应该为输入向量 x \mathbf{x} x 计算某个输出 y y y。然后我们定义了一个误差度量,它告诉我们网络在预测输出 y y y 时与实际值的差距。基于这个误差度量,我们可以使用梯度来更新负责输出的权重 W \mathbf{W} W,以便下次输入 x \mathbf{x} x 时,网络的输出将更接近我们期望的结果。
首先,我们需要指定哪些张量需要计算梯度。默认情况下,创建张量时不会计算梯度。
x = torch.ones((3,))
print(x.requires_grad)
# 为现有张量启用梯度计算
x.requires_grad_(True)
print(x.requires_grad)
为了创建一个计算图,我们考虑以下函数:
y = 1 ℓ ( x ) ∑ i [ ( x i + 2 ) 2 + 3 ] y = \frac{1}{\ell(x)} \sum_i [(x_i + 2)^2 + 3] y=ℓ(x)1i∑[(xi+2)2+3]
这里 ℓ ( x ) \ell(x) ℓ(x) 表示 x x x 中的元素数量,即对求和内的运算取平均。假设 x x x 是我们的参数,我们想要优化输出 y y y。为了实现这一点,我们需要获得梯度 ∂ y ∂ x \frac{\partial y}{\partial \mathbf{x}} ∂x∂y。在这个例子中,我们将使用 x = [ 0 , 1 , 2 ] \mathbf{x} = [0, 1, 2] x=[0,1,2] 作为输入。
x = torch.arange(3, dtype=torch.float32, requires_grad=True)
print("X =", x)
# 构建计算图
a = x + 2
b = a ** 2
c = b + 3
y = c.mean()
print("Y =", y)
通过上述步骤,我们创建了一个计算图,它抽象地展示了操作之间的依赖关系。计算图中的每个节点自动定义了一个函数 grad_fn
来计算相对于其输入的梯度。这就是为什么计算图通常以相反的方向(从结果到输入)来可视化。我们可以通过在最后一个输出上调用 backward()
函数来执行反向传播,从而计算每个设置了 requires_grad=True
的张量的梯度:
y.backward()
print(x.grad)
x.grad
现在将包含梯度
∂
y
∂
x
\frac{\partial y}{\partial \mathbf{x}}
∂x∂y,这个梯度指示了在当前输入
x
=
[
0
,
1
,
2
]
\mathbf{x} = [0, 1, 2]
x=[0,1,2] 下,
x
\mathbf{x}
x 的变化将如何影响输出
y
y
y:
我们也可以通过手工计算来验证这些梯度。我们将使用链式法则来计算梯度,就像PyTorch自动完成的那样:
∂ y ∂ x i = ∂ y ∂ c i ⋅ ∂ c i ∂ b i ⋅ ∂ b i ∂ a i ⋅ ∂ a i ∂ x i \frac{\partial y}{\partial x_i} = \frac{\partial y}{\partial c_i} \cdot \frac{\partial c_i}{\partial b_i} \cdot \frac{\partial b_i}{\partial a_i} \cdot \frac{\partial a_i}{\partial x_i} ∂xi∂y=∂ci∂y⋅∂bi∂ci⋅∂ai∂bi⋅∂xi∂ai
这里我们使用了索引符号,并利用了除了均值操作外,所有操作都不会结合张量中的元素这一事实。偏导数如下:
∂ a i ∂ x i = 1 , ∂ b i ∂ a i = 2 ⋅ a i , ∂ c i ∂ b i = 1 , ∂ y ∂ c i = 1 3 \frac{\partial a_i}{\partial x_i} = 1, \quad \frac{\partial b_i}{\partial a_i} = 2 \cdot a_i, \quad \frac{\partial c_i}{\partial b_i} = 1, \quad \frac{\partial y}{\partial c_i} = \frac{1}{3} ∂xi∂ai=1,∂ai∂bi=2⋅ai,∂bi∂ci=1,∂ci∂y=31
因此,对于输入 x = [ 0 , 1 , 2 ] \mathbf{x} = [0, 1, 2] x=[0,1,2],我们的梯度是 ∂ y ∂ x = [ 4 / 3 , 2 , 8 / 3 ] \frac{\partial y}{\partial \mathbf{x}} = [4/3, 2, 8/3] ∂x∂y=[4/3,2,8/3]。之前的代码应该已经打印出了相同的结果。
2.3.GPU 的支持
PyTorch 的一项核心特性是其对 GPU(图形处理单元)的支持。GPU 能够同时执行成千上万个小型操作,这使得它们非常适合处理神经网络中的大规模矩阵运算。与 CPU 相比,GPU 拥有以下主要区别(来源:Kevin Krewell, 2009):
CPU 和 GPU 各自拥有不同的优势和局限,这也是为什么许多计算机会同时搭载这两种组件,并将它们用于不同类型的任务。如果您对 GPU 不太熟悉,您可以在 NVIDIA 的博客文章 或 Intel 的页面 上获取更多详细信息。
GPU 能够将您的网络训练速度提升高达 100 倍,这对于大型神经网络来说是至关重要的。PyTorch 提供了大量用于支持 GPU 的功能(主要是 NVIDIA 的 GPU,因为它们支持 CUDA 和 cuDNN 库)。首先,我们来检查一下是否有可用的 GPU:
gpu_avail = torch.cuda.is_available()
print(f"GPU 是否可用? {gpu_avail}")
如果您的计算机上安装了 GPU,但是上述命令返回 False
,请确保您安装了正确的 CUDA 版本。dl2023
环境配备了 CUDA 11.8,这是为 Snellius 超级计算机选择的版本。如果您需要,请进行更改(目前在 Colab 上常见的是 CUDA 11.3)。在 Google Colab 上,请确保您已在运行时设置中选择了 GPU(在菜单中,检查 运行时 -> 更改运行时类型
)。
默认情况下,您创建的所有张量都存储在 CPU 上。我们可以通过使用 .to(...)
或 .cuda()
函数将张量推送到 GPU。然而,通常更好的做法是在代码中定义一个指向 GPU(如果有的话)或 CPU 的 device
对象。这样,您可以根据这个设备对象编写代码,允许您在只有 CPU 的系统和带有 GPU 的系统上运行相同的代码。让我们下面来试试:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("使用的设备:", device)
现在,让我们创建一个张量并将其推送到设备:
x = torch.zeros(2, 3)
x = x.to(device)
print("张量 X:", x)
如果您有 GPU,现在您应该能看到打印出的属性 device='cuda:0'
。cuda:0
表示这是您计算机上的第一块 GPU 设备。PyTorch 还支持多 GPU 系统,但这只在您需要训练非常大的网络时才会用到(如果感兴趣,请参考 PyTorch 文档)。我们还可以比较在 CPU 上和 GPU 上执行大型矩阵乘法的运行时间:
x = torch.randn(5000, 5000)
# CPU 版本
start_time = time.time()
_ = torch.matmul(x, x)
end_time = time.time()
print(f"CPU 运行时间:{(end_time - start_time):6.5f}秒")
# GPU 版本
x = x.to(device)
_ = torch.matmul(x, x) # 第一次操作以“预热”GPU
# CUDA 是异步的,所以我们需要使用不同的计时函数
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
start.record()
_ = torch.matmul(x, x)
end.record()
torch.cuda.synchronize() # 等待 GPU 上的所有操作完成
print(f"GPU 运行时间:{0.001 * start.elapsed_time(end):6.5f}秒") # 毫秒转换为秒
根据操作的大小和您系统中的 CPU/GPU,此操作的加速可以超过 50 倍。由于矩阵乘法操作在神经网络中非常常见,我们已经可以看到在 GPU 上训练神经网络的巨大好处。时间估计可能会有一定的波动,因为我们没有多次运行它。您可以自由地扩展这个测试,但运行时间会更长。
当生成随机数时,CPU 和 GPU 之间的种子不是同步的。因此,我们需要单独为 GPU 设置种子以确保代码的可重复性。请注意,由于不同的 GPU 架构,在同一代码在不同的 GPU 上运行时不能保证生成相同的随机数。但我们不希望我们的代码每次在完全相同的硬件上运行时都给我们不同的输出。因此,我们也为 GPU 设置了种子:
# GPU 操作有单独的种子,我们也希望设置
if torch.cuda.is_available():
torch.cuda.manual_seed(42)
torch.cuda.manual_seed_all(42)
# 此外,一些 GPU 上的操作为提高效率而实现为随机的
# 我们要确保所有 GPU 上的操作都是确定性的(如果使用)以实现可重复性
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
3.通过示例学习:连续 XOR 问题
如果我们想在 PyTorch 中构建一个神经网络,我们可以使用带有 requires_grad=True
的 Tensors
来指定所有的参数(权重矩阵、偏置向量),然后让 PyTorch 计算梯度并调整参数。但如果我们有大量参数,事情很快就会变得复杂。在 PyTorch 中,有一个名为 torch.nn
的包,它使构建神经网络变得更加方便。
我们将介绍使用 PyTorch 训练神经网络所需的库和所有额外部分,以一个简单但知名的示例分类器为例:XOR。给定两个二进制输入 x 1 x_1 x1 和 x 2 x_2 x2,如果 x 1 x_1 x1 或 x 2 x_2 x2 为 1 而另一个为 0,则预测标签为 1;否则标签为 0。这个示例因其简单性而变得著名,即单个神经元,即线性分类器,无法学习这个简单函数。
因此,我们将学习如何构建一个小型神经网络来学习这个函数。
为了使其更有趣,我们将 XOR 问题移动到连续空间,并在二进制输入上引入一些高斯噪声。我们期望的连续 XOR 数据集的分离可能如下所示:
3.1.模型定义
torch.nn
包定义了一系列有用的类,如线性网络层、激活函数、损失函数等。您可以在 这里 查看完整列表。如果您需要某个特定的网络层,请先在包的文档中查找,因为包中可能已经包含了该代码。我们在下面导入它:
import torch.nn as nn
除了 torch.nn
,还有 torch.nn.functional
。它包含了在网络层中使用的函数。这与 torch.nn
不同,后者定义了它们作为 nn.Modules
(下面将详细介绍),并且 torch.nn
实际上使用了很多 torch.nn.functional
中的功能。因此,functional
包在许多情况下都很有用,我们在这里也导入它:
import torch.nn.functional as F
3.1.1.nn.Module
在 PyTorch 中,神经网络由模块构建而成。模块可以包含其他模块,神经网络本身也被视为一个模块。模块的基本模板如下:
class MyModule(nn.Module):
def __init__(self):
super().__init__()
# 我的模块的一些初始化
def forward(self, x):
# 执行模块计算的函数。
pass
forward
函数是模块计算发生的地方,当您调用模块(nn = MyModule(); nn(x)
)时将执行它。在 __init__
函数中,我们通常使用 nn.Parameter
创建模块的参数,或定义在 forward
函数中使用的其他模块。反向计算是自动完成的,如果需要,也可以重写。
3.1.2.简单分类器
现在,我们可以利用 torch.nn
包中预定义的模块,并定义我们自己的小型神经网络。我们将使用一个最小的网络,它有一个输入层,一个带有 tanh 激活函数的隐藏层,以及一个输出层。换句话说,我们的网络应该看起来像这样:
输入神经元以蓝色显示,代表数据点的坐标
x
1
x_1
x1 和
x
2
x_2
x2。隐藏神经元(包括 tanh 激活)以白色显示,输出神经元以红色显示。
在 PyTorch 中,我们可以这样定义:
class SimpleClassifier(nn.Module):
def __init__(self, num_inputs, num_hidden, num_outputs):
super().__init__()
# 初始化构建网络所需的模块
self.linear1 = nn.Linear(num_inputs, num_hidden)
self.act_fn = nn.Tanh()
self.linear2 = nn.Linear(num_hidden, num_outputs)
def forward(self, x):
# 执行模型的计算以确定预测
x = self.linear1(x)
x = self.act_fn(x)
x = self.linear2(x)
return x
在这个笔记本的示例中,我们将使用一个非常小的神经网络,它有两个输入神经元和四个隐藏神经元。由于我们执行的是二元分类,我们将使用一个单一的输出神经元。注意,我们还没有在输出上应用 sigmoid 函数。这是因为其他函数,特别是损失函数,在原始输出上计算比在 sigmoid 输出上更有效、更精确。我们将在后面详细讨论原因。
model = SimpleClassifier(num_inputs=2, num_hidden=4, num_outputs=1)
# 打印模块会显示它包含的所有子模块
print(model)
打印模型会列出它包含的所有子模块。模块的参数可以通过使用其 parameters()
函数获得,或者使用 named_parameters()
来为每个参数对象获取名称。对于我们的小型神经网络,我们有以下参数:
for name, param in model.named_parameters():
print(f"参数 {name}, 形状 {param.shape}")
每个线性层都有一个权重矩阵,形状为 [输出, 输入]
,以及一个偏置,形状为 [输出]
。tanh 激活函数没有任何参数。请注意,只有当 nn.Module
对象是直接对象属性时,即 self.a = ...
,参数才会被注册。如果您定义了一个模块列表,那么这些模块的参数将不会为外部模块注册,并且在尝试优化您的模块时可能会导致一些问题。有替代方案,如 nn.ModuleList
、nn.ModuleDict
和 nn.Sequential
,它们允许您拥有不同数据结构的模块。我们将在稍后的一些教程中使用它们,并在那里解释它们。
3.2.数据加载与处理
PyTorch 提供了一系列工具,用于高效地加载和处理训练数据与测试数据,这些工具集中在 torch.utils.data
包中。
import torch.utils.data as data
该数据包定义了两个类,它们是 PyTorch 中处理数据的标准接口:Dataset
和 DataLoader
。Dataset
类提供了一个统一的接口来访问训练或测试数据,而 DataLoader
则确保在训练过程中高效地加载和堆叠数据点,形成批量数据。
3.2.1.数据集类
Dataset
类以自然的方式概括了数据集的基本功能。在 PyTorch 中定义数据集时,我们只需实现两个函数:__getitem__
和 __len__
。__getitem__
函数需要返回数据集中的第 $ i$ 个数据点,而 __len__
函数返回数据集的大小。以 XOR 数据集为例,我们可以这样定义数据集类:
class XORDataset(data.Dataset):
def __init__(self, size, std=0.1):
self.size = size
self.std = std
self.generate_continuous_xor()
def generate_continuous_xor(self):
data = torch.randint(low=0, high=2, size=(self.size, 2), dtype=torch.float32)
label = (data.sum(dim=1) == 1).long()
data += self.std * torch.randn(data.shape)
self.data = data
self.label = label
def __len__(self):
return self.size
def __getitem__(self, idx):
return self.data[idx], self.label[idx]
接下来,我们尝试创建这样一个数据集并进行检查:
dataset = XORDataset(size=200)
print("数据集大小:", len(dataset))
print("第 0 个数据点:", dataset[0])
为了更好地理解数据集,我们可视化这些样本:
def visualize_samples(data, label):
# 这里实现可视化逻辑
# ...
# 可视化数据集样本
visualize_samples(dataset.data, dataset.label)
plt.show()
3.2.2.数据加载器类
DataLoader
类是 PyTorch 中的一个迭代器,它遍历数据集,并支持自动批处理、多进程数据加载等功能。与 Dataset
类不同,我们通常不需要定义自己的 DataLoader
类,只需使用数据集作为输入创建一个实例。此外,我们可以通过以下参数配置 DataLoader
(这里只列出部分参数,完整列表见官方文档):
batch_size
: 每个批次要堆叠的样本数。shuffle
: 如果设置为 True,则数据将以随机顺序返回,这在训练期间引入随机性很重要。num_workers
: 用于数据加载的子进程数量。默认值为 0,意味着数据将在主进程中加载,这可能会减慢加载时间较长的数据集的训练速度。pin_memory
: 如果设置为 True,则数据加载器在返回之前会将张量复制到 CUDA 固定内存中,这可以节省 GPU 上处理大型数据点的时间。drop_last
: 如果设置为 True,在数据集大小不是批量大小的倍数时,将丢弃最后一个较小的批次。
下面创建一个简单的 DataLoader
:
data_loader = data.DataLoader(dataset, batch_size=8, shuffle=True)
data_inputs, data_labels = next(iter(data_loader))
print("数据输入形状:", data_inputs.shape, "\n", data_inputs)
print("数据标签形状:", data_labels.shape, "\n", data_labels)
3.3.模型优化
定义了模型和数据集后,接下来就是准备模型的优化过程了。在训练期间,我们将遵循以下步骤:
- 从
DataLoader
获取一个批次的数据。 - 获得模型对该批次数据的预测。
- 基于预测和标签之间的差异计算损失。
- 反向传播:计算每个参数相对于损失的梯度。
- 根据梯度方向更新模型参数。
我们已经了解了如何在 PyTorch 中执行步骤 1、2 和 4。现在,我们将重点关注步骤 3 和 5。
3.3.1.损失函数模块
通过执行一些张量操作,我们可以计算一个批次的损失,因为这些操作会自动添加到计算图中。例如,在二元分类中,我们可以使用二元交叉熵(BCE),其定义如下:
L B C E = − ∑ i [ y i ⋅ log ( x i ) + ( 1 − y i ) ⋅ log ( 1 − x i ) ] \mathcal{L}_{BCE} = -\sum_i \left[ y_i \cdot \log(x_i) + (1 - y_i) \cdot \log(1 - x_i) \right] LBCE=−i∑[yi⋅log(xi)+(1−yi)⋅log(1−xi)]
这里的
y
y
y 代表标签,
x
x
x 代表预测值,它们都在
[
0
,
1
]
[0, 1]
[0,1] 范围内。不过,PyTorch 已经提供了一系列的预定义损失函数,我们可以直接使用(完整列表见这里)。例如,对于 BCE,PyTorch 提供了 nn.BCELoss()
和 nn.BCEWithLogitsLoss()
两个模块。nn.BCELoss
期望输入 $ x$ 在
[
0
,
1
]
[0, 1]
[0,1] 范围内,即 Sigmoid 函数的输出;而 nn.BCEWithLogitsLoss
则将 Sigmoid 层和 BCE 损失结合在一个类中。由于在损失函数中应用了对数,因此这个版本在数值上更稳定,建议在可能的情况下使用应用于 “logits” 的损失函数(记得在这种情况下不要在模型的输出上应用 Sigmoid 函数!)。因此,对于上面定义的模型,我们使用 nn.BCEWithLogitsLoss
模块:
loss_module = nn.BCEWithLogitsLoss()
3.3.2.随机梯度下降(SGD)
对于参数更新,PyTorch 提供了 torch.optim
包,其中实现了大多数流行的优化器。我们将在课程后面讨论特定的优化器及其差异,但目前我们将使用最简单的优化器:torch.optim.SGD
。SGD 通过将梯度乘以一个小常数(学习率)并从参数中减去它们来更新参数,从而朝着最小化损失的方向缓慢移动。对于像我们的这样的小型网络,学习率的一个好默认值是 0.1。
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
优化器提供了两个有用的函数:optimizer.step()
和 optimizer.zero_grad()
。step
函数根据上述解释基于梯度更新参数。zero_grad()
函数将所有参数的梯度设置为零。这个函数在执行反向传播之前是一个关键步骤,因为它确保梯度不会被累加,而是被重置。
3.4.模型训练
现在,我们已经准备好开始训练模型了。作为第一步,我们创建一个更大的数据集,并指定一个批量大小更大的 DataLoader
。
train_dataset = XORDataset(size=2500)
train_data_loader = data.DataLoader(train_dataset, batch_size=128, shuffle=True)
接下来,我们可以编写一个小型训练函数。记住我们的五个步骤:加载批次、获取预测、计算损失、反向传播和更新参数。此外,我们必须将所有数据和模型参数推送到我们选择的设备上(如果可用,使用 GPU)。
def train_model(model, optimizer, data_loader, loss_module, num_epochs=100):
model.train() # 将模型设置为训练模式
for epoch in tqdm(range(num_epochs)):
for data_inputs, data_labels in data_loader:
data_inputs, data_labels = data_inputs.to(device), data_labels.to(device)
preds = model(data_inputs)
preds = preds.squeeze(dim=1) # 将输出从 [Batch size, 1] 转换为 [Batch size]
loss = loss_module(preds, data_labels.float()) # 计算损失
optimizer.zero_grad() # 清零梯度
loss.backward() # 反向传播
optimizer.step() # 更新参数
训练模型:
train_model(model, optimizer, train_data_loader, loss_module)
3.4.1.保存模型
训练完成后,我们将模型保存到磁盘上,以便我们稍后可以加载相同的权重。为此,我们提取模型的 state_dict
,其中包含所有可学习参数。
state_dict = model.state_dict()
print(state_dict)
使用 torch.save
保存状态字典:
torch.save(state_dict, "our_model.tar")
从状态字典中加载模型,我们使用 torch.load
加载状态字典,并使用 load_state_dict
函数覆盖我们的参数。
state_dict = torch.load("our_model.tar")
new_model = SimpleClassifier(num_inputs=2, num_hidden=4, num_outputs=1)
new_model.load_state_dict(state_dict)
print("原始模型\n", model.state_dict())
print("\n加载的模型\n", new_model.state_dict())
关于在 PyTorch 中保存和加载模型的详细教程
可以在这里找到。
3.5.模型评估
训练完模型后,我们需要在独立的测试集上对其进行评估。由于我们的数据集由随机生成的数据点组成,我们需要首先创建一个测试集和相应的数据加载器。
test_dataset = XORDataset(size=500)
test_data_loader = data.DataLoader(test_dataset, batch_size=128, shuffle=False, drop_last=False)
我们将使用准确率作为评估指标,准确率的计算公式如下:
a c c = 正确预测数 所有预测数 = T P + T N T P + T N + F P + F N acc = \frac{\text{正确预测数}}{\text{所有预测数}} = \frac{TP + TN}{TP + TN + FP + FN} acc=所有预测数正确预测数=TP+TN+FP+FNTP+TN
这里的 TP 代表真正例,TN 代表真负例,FP 代表假正例,FN 代表假负例。
在评估模型时,我们不需要维护计算图,因为我们不打算计算梯度。这减少了所需的内存并加快了模型的评估速度。在 PyTorch 中,我们可以使用 with torch.no_grad(): ...
来停用计算图。记得在执行评估之前将模型设置为评估模式。
def eval_model(model, data_loader):
model.eval() # 将模型设置为评估模式
true_preds, num_preds = 0., 0.
with torch.no_grad():
for data_inputs, data_labels in data_loader:
data_inputs, data_labels = data_inputs.to(device), data_labels.to(device)
preds = model(data_inputs)
preds = preds.squeeze(dim=1)
preds = torch.sigmoid(preds) # 将预测值映射到 0 和 1 之间
pred_labels = (preds >= 0.5).long() # 将预测值二值化
true_preds += (pred_labels == data_labels).sum()
num_preds += data_labels.shape[0]
acc = true_preds / num_preds
print(f"模型的准确率: {100.0 * acc:.2f}%")
eval_model(model, test_data_loader)
如果我们正确地训练了模型,我们应该看到一个接近 100% 的准确率。然而,这仅是因为我们的任务相对简单,在更复杂的任务中,我们通常不会在测试集上获得如此高的分数。
3.5.1.可视化分类边界
为了直观展示模型的学习成果,我们可以对 [ − 0.5 , 1.5 ] [-0.5, 1.5] [−0.5,1.5] 范围内的每个数据点进行预测,并可视化预测的类别。这将展示模型创建的决策边界,以及哪些点被分类为 0 0 0,哪些为 1 1 1。我们将得到一个由蓝色(类别 0)和橙色(类别 1)组成的背景图像。模型不确定的区域将显示为模糊的重叠部分。
@torch.no_grad()
def visualize_classification(model, data, label):
# 这里实现可视化逻辑
# ...
_ = visualize_classification(model, dataset.data, dataset.label)
plt.show()
决策边界可能不会完全与本节开头的图形相同,这可能是由于在 CPU 上运行或不同的 GPU 架构造成的。然而,准确率指标的结果应该是大致相同的。
4.展望
现在已经准备好开始自己的 PyTorch 项目了!在文中,我们学习了如何在 PyTorch 中构建神经网络,以及如何在数据上进行训练和测试。然而,PyTorch 还有许多我们尚未讨论的特性。在接下来的博文系列中,我们将探索越来越多的 PyTorch 功能,以便您熟悉 PyTorch 的高级概念。如果您已经对学习更多的 PyTorch 感兴趣,我们推荐官方的 教程网站,它包含许多关于不同主题的教程。特别是使用 TensorBoard 记录日志是一个好习惯。
5.TensorBoard 日志记录
TensorBoard 是一个广泛用于深度学习模型训练的日志记录和可视化工具。虽然最初是为 TensorFlow 设计的,但它也与 PyTorch 集成,让我们可以轻松地使用它。下面,我们首先导入 TensorBoard。
# 从 PyTorch 导入 TensorBoard 日志记录器
from torch.utils.tensorboard import SummaryWriter
# 为 Jupyter Notebook 加载 tensorboard 扩展,只需在笔记本中启动 TensorBoard
%load_ext tensorboard
如果您想在 Jupyter Notebook 中直接运行 TensorBoard,则需要上述的最后一行。否则,您可以从终端启动 TensorBoard。
PyTorch 的 TensorBoard API 使用起来非常简单。我们通过创建一个新对象 writer = SummaryWriter(...)
来启动日志记录过程,其中我们指定了保存日志文件的目录。利用这个对象,我们可以通过调用如 writer.add_...
风格的函数来记录模型的不同方面。例如,使用 writer.add_graph
函数我们可以在 TensorBoard 中可视化计算图,或者使用 writer.add_scalar
来添加像损失值这样的标量。
接下来,我们将展示如何在我们的初始训练函数中添加 TensorBoard 日志记录器。
def train_model_with_logger(model, optimizer, data_loader, loss_module, val_dataset, num_epochs=100, logging_dir='runs/our_experiment'):
# 创建 TensorBoard 日志记录器
writer = SummaryWriter(logging_dir)
model_plotted = False
# 将模型设置为训练模式
model.train()
# 训练循环
for epoch in tqdm(range(num_epochs)):
epoch_loss = 0.0
for data_inputs, data_labels in data_loader:
# 将输入数据传输到设备(如果使用 GPU,则非常必要)
data_inputs = data_inputs.to(device)
data_labels = data_labels.to(device)
# 对于第一个批次,我们在 TensorBoard 中可视化计算图
if not model_plotted:
writer.add_graph(model, data_inputs)
model_plotted = True
# 运行模型并获取预测
preds = model(data_inputs)
preds = preds.squeeze(dim=1) # 将输出从 [批量大小, 1] 转换为 [批量大小]
# 计算损失
loss = loss_module(preds, data_labels.float())
# 执行反向传播之前,确保梯度清零
optimizer.zero_grad()
loss.backward() # 执行反向传播
# 更新模型参数
optimizer.step()
# 计算损失的运行平均值
epoch_loss += loss.item()
# 将平均损失记录到 TensorBoard
epoch_loss /= len(data_loader)
writer.add_scalar('training_loss', epoch_loss, global_step=epoch + 1)
# 每隔 10 个 epoch 可视化预测并将图形添加到 TensorBoard
if (epoch + 1) % 10 == 0:
fig = visualize_classification(model, val_dataset.data, val_dataset.label)
writer.add_figure('predictions', fig, global_step=epoch + 1)
# 关闭 TensorBoard 日志记录器
writer.close()
使用这种方法,我们将像之前一样训练模型,但这次使用新的模型和优化器。
model = SimpleClassifier(num_inputs=2, num_hidden=4, num_outputs=1).to(device)
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
train_model_with_logger(model, optimizer, train_data_loader, loss_module, val_dataset=dataset)
现在,在 runs/our_experiment
文件夹中的 TensorBoard 文件包含了损失曲线、我们网络的计算图,以及随着 epoch 数增加学习到的预测的可视化。要启动 TensorBoard 可视化器,只需运行以下命令:
%tensorboard --logdir runs/our_experiment
TensorBoard 可视化可以帮助您识别模型可能存在的问题,例如过拟合,并在模型训练时跟踪训练进度,因为日志记录器会自动将所有添加的内容写入日志文件。请随意探索 TensorBoard 的功能,我们将在第 5 个教程及之后的教程中多次使用 TensorBoard。