附录 APyTorch 介绍


本附录旨在为您提供必要的技能和知识,以便将深度学习付诸实践,并从零开始实现大型语言模型(LLMs)。PyTorch,一个流行的基于 Python 的深度学习库,将是本书的主要工具。我将指导您设置一个配备 PyTorch 和 GPU 支持的深度学习工作区。


然后你将学习张量的基本概念及其在 PyTorch 中的使用。我们还将深入探讨 PyTorch 的自动微分引擎,这一功能使我们能够方便高效地使用反向传播,这是神经网络训练的一个关键方面。


本附录旨在为那些刚接触 PyTorch 深度学习的人提供入门指导。虽然它从基础开始解释 PyTorch,但并不打算全面覆盖 PyTorch 库。相反,我们将重点介绍实现LLMs所需的 PyTorch 基础知识。如果您已经熟悉深度学习,可以跳过本附录,直接进入第二章。


A.1 什么是 PyTorch?


PyTorch (https://pytorch.org/) 是一个基于 Python 的开源深度学习库。根据 Papers With Code (https://paperswithcode.com/trends),这是一个跟踪和分析研究论文的平台,自 2019 年以来,PyTorch 一直是研究中使用最广泛的深度学习库,差距很大。而根据 Kaggle 数据科学与机器学习调查 2022 (https://www.kaggle.com/c/kaggle-survey-2022),使用 PyTorch 的受访者数量约为 40%,并且每年都在增长。


PyTorch 之所以如此受欢迎的原因之一是它用户友好的界面和高效性。尽管它易于使用,但并不妥协于灵活性,允许高级用户调整模型的低级方面以进行定制和优化。简而言之,对于许多从业者和研究人员来说,PyTorch 在可用性和功能之间提供了恰到好处的平衡。


A.1.1 PyTorch 的三个核心组件


PyTorch 是一个相对全面的库,接近它的一种方法是关注它的三个广泛组成部分,如图 A.1 所示。

figure

图 A.1 PyTorch 的三个主要组件包括一个张量库作为计算的基本构建块,自动微分用于模型优化,以及深度学习实用函数,使得实现和训练深度神经网络模型变得更加容易。


首先,PyTorch 是一个 张量库,它扩展了面向数组的编程库 NumPy 的概念,增加了加速 GPU 计算的功能,从而实现了 CPU 和 GPU 之间的无缝切换。其次,PyTorch 是一个 自动微分引擎,也称为 autograd,它能够自动计算张量操作的梯度,简化了反向传播和模型优化。最后,PyTorch 是一个 深度学习库。它提供了模块化、灵活和高效的构建块,包括预训练模型、损失函数和优化器,用于设计和训练各种深度学习模型,满足研究人员和开发人员的需求。


A.1.2 定义深度学习


在新闻中,LLMs 通常被称为 AI 模型。然而,LLMs 也是一种深度神经网络,而 PyTorch 是一个深度学习库。听起来令人困惑吗?在我们继续之前,让我们花一点时间总结一下这些术语之间的关系。


人工智能 基本上是关于创建能够执行通常需要人类智能的任务的计算机系统。这些任务包括理解自然语言、识别模式和做出决策。(尽管取得了显著进展,人工智能仍然远未达到这种普遍智能的水平。)


机器学习代表了人工智能的一个子领域,如图 A.2 所示,专注于开发和改进学习算法。机器学习背后的关键思想是使计算机能够从数据中学习,并在没有被明确编程执行任务的情况下做出预测或决策。这涉及开发能够识别模式、从历史数据中学习,并随着更多数据和反馈而提高其性能的算法。

figure

图 A.2 深度学习是机器学习的一个子类别,专注于实现深度神经网络。机器学习是人工智能的一个子类别,涉及从数据中学习的算法。人工智能是一个更广泛的概念,指的是机器能够执行通常需要人类智能的任务。


机器学习在人工智能的发展中发挥了重要作用,推动了我们今天看到的许多进步,包括LLMs。机器学习还支持了在线零售商和流媒体服务使用的推荐系统、电子邮件垃圾邮件过滤、虚拟助手中的语音识别,甚至是自动驾驶汽车。机器学习的引入和进步显著增强了人工智能的能力,使其能够超越严格的基于规则的系统,适应新的输入或变化的环境。


深度学习是机器学习的一个子类别,专注于深度神经网络的训练和应用。这些深度神经网络最初受到人脑工作方式的启发,特别是许多神经元之间的相互连接。“深度”在深度学习中指的是人工神经元或节点的多个隐藏层,这使它们能够对数据中的复杂非线性关系进行建模。与擅长简单模式识别的传统机器学习技术不同,深度学习特别擅长处理图像、音频或文本等非结构化数据,因此特别适合LLMs。


典型的预测建模工作流程(也称为监督学习)在机器学习和深度学习中总结在图 A.3 中。

figure

图 A.3 监督学习的预测建模工作流程包括一个训练阶段,在该阶段,模型在训练数据集中对标记示例进行训练。训练好的模型可以用来预测新观察的标签。


使用学习算法,模型在由示例和相应标签组成的训练数据集上进行训练。例如,在电子邮件垃圾邮件分类器的情况下,训练数据集由电子邮件及其“垃圾邮件”和“非垃圾邮件”标签组成,这些标签是由人类识别的。然后,训练好的模型可以用于新的观察(即新的电子邮件),以预测它们的未知标签(“垃圾邮件”或“非垃圾邮件”)。当然,我们还希望在训练和推理阶段之间添加模型评估,以确保模型在实际应用中使用之前满足我们的性能标准。


如果我们训练LLMs来分类文本,那么训练和使用LLMs的工作流程与图 A.3 中所示的类似。如果我们有兴趣训练LLMs来生成文本,这是我们的主要关注点,图 A.3 仍然适用。在这种情况下,预训练期间的标签可以从文本本身派生(第 1 章中介绍的下一个单词预测任务)。在推理过程中,LLM将在给定输入提示的情况下生成全新的文本(而不是预测标签)。


A.1.3 安装 PyTorch


PyTorch 可以像其他任何 Python 库或包一样安装。然而,由于 PyTorch 是一个全面的库,具有 CPU 和 GPU 兼容的代码,因此安装可能需要额外的说明。


例如,PyTorch 有两个版本:一个仅支持 CPU 计算的精简版和一个支持 CPU 和 GPU 计算的完整版。如果您的机器有一个可以用于深度学习的 CUDA 兼容 GPU(理想情况下是 NVIDIA T4、RTX 2080 Ti 或更新版本),我建议安装 GPU 版本。无论如何,在代码终端中安装 PyTorch 的默认命令是:

pip install torch


假设您的计算机支持 CUDA 兼容的 GPU。在这种情况下,它将自动安装支持通过 CUDA 进行 GPU 加速的 PyTorch 版本,前提是您正在使用的 Python 环境中已安装必要的依赖项(如pip)。


要明确安装与 CUDA 兼容的 PyTorch 版本,通常最好指定您希望 PyTorch 兼容的 CUDA 版本。PyTorch 的官方网站(https://pytorch.org)提供了在不同操作系统上安装支持 CUDA 的 PyTorch 的命令。图 A.4 显示了一个命令,该命令还将安装 PyTorch,以及本书可选的torchvisiontorchaudio库。

figure

图 A.4 访问 https://pytorch.org 上的 PyTorch 安装推荐,以自定义并选择适合您系统的安装命令。


我使用的是 PyTorch 2.4.0 作为示例,因此我建议您使用以下命令安装确切版本,以确保与本书的兼容性:

pip install torch==2.4.0


然而,如前所述,考虑到您的操作系统,安装命令可能与此处显示的略有不同。因此,我建议您访问 https://pytorch.org 并使用安装菜单(见图 A.4)选择适合您操作系统的安装命令。请记得在命令中将 torch 替换为 torch==2.4.0


要检查 PyTorch 的版本,请在 PyTorch 中执行以下代码:

import torch
torch.__version__

 这会打印

'2.4.0'


如果您正在寻找有关设置 Python 环境或安装本书中使用的其他库的额外建议和说明,请访问本书的补充 GitHub 存储库,网址为 https://github.com/rasbt/LLMs-from-scratch


安装 PyTorch 后,您可以通过在 Python 中运行以下代码来检查您的安装是否识别了内置的 NVIDIA GPU:

import torch
torch.cuda.is_available()

 这返回

True


如果命令返回 True,那么你就准备好了。如果命令返回 False,你的计算机可能没有兼容的 GPU,或者 PyTorch 无法识别它。虽然本书的前几章不需要 GPU,这些章节主要集中在实现 LLMs 以用于教育目的,但它们可以显著加快与深度学习相关的计算。


如果您无法访问 GPU,有几个云计算提供商可以让用户以每小时的费用运行 GPU 计算。一个流行的类似 Jupyter 笔记本的环境是 Google Colab (https://colab.research.google.com),截至本文撰写时,它提供了有限时间的 GPU 访问。使用运行时菜单,可以选择 GPU,如图 A.5 的屏幕截图所示。

figure

图 A.5 在 Runtime/Change Runtime Type 菜单下为 Google Colab 选择一个 GPU 设备。


A.2 理解张量


张量表示一个数学概念,它将向量和矩阵推广到更高的维度。换句话说,张量是可以通过其阶(或秩)来表征的数学对象,阶提供了维度的数量。例如,标量(仅仅是一个数字)是秩为 0 的张量,向量是秩为 1 的张量,矩阵是秩为 2 的张量,如图 A.6 所示。

figure

图 A.6 不同秩的张量。这里 0D 对应于秩 0,1D 对应于秩 1,2D 对应于秩 2。一个由三个元素组成的三维向量仍然是一个秩 1 张量。


从计算的角度来看,张量作为数据容器。例如,它们保存多维数据,其中每个维度代表不同的特征。像 PyTorch 这样的张量库可以高效地创建、操作和计算这些数组。在这种情况下,张量库的功能类似于数组库。


PyTorch 张量类似于 NumPy 数组,但具有一些对深度学习非常重要的附加功能。例如,PyTorch 增加了一个自动微分引擎,简化了 计算梯度(见 A.4 节)。PyTorch 张量还支持 GPU 计算,以加速深度神经网络的训练(见 A.8 节)。


A.2.1 标量、向量、矩阵和张量


如前所述,PyTorch 张量是用于类数组结构的数据容器。标量是零维张量(例如,仅仅是一个数字),向量是一维张量,矩阵是二维张量。对于高维张量没有特定术语,因此我们通常将三维张量称为 3D 张量,依此类推。我们可以使用 Tensor 类的对象通过 torch.tensor 函数创建,如下所示。


附录 A.1 创建 PyTorch 张量
import torch

tensor0d = torch.tensor(1)     #1

tensor1d = torch.tensor([1, 2, 3])    #2

tensor2d = torch.tensor([[1, 2], 
                         [3, 4]])     #3

tensor3d = torch.tensor([[[1, 2], [3, 4]], 
                         [[5, 6], [7, 8]]])    #4

#1 从 Python 整数创建一个零维张量(标量)


#2 从 Python 列表创建一维张量(向量)


#3 从嵌套的 Python 列表创建一个二维张量


#4 从嵌套的 Python 列表创建一个三维张量


A.2.2 张量数据类型


PyTorch 采用 Python 的默认 64 位整数数据类型。我们可以通过张量的 .dtype 属性访问张量的数据类型:

tensor1d = torch.tensor([1, 2, 3])
print(tensor1d.dtype)

 这会打印

torch.int64


如果我们从 Python 浮点数创建张量,PyTorch 默认创建 32 位精度的张量:

floatvec = torch.tensor([1.0, 2.0, 3.0])
print(floatvec.dtype)

 输出是

torch.float32


这个选择主要是由于精度和计算效率之间的平衡。32 位浮点数为大多数深度学习任务提供了足够的精度,同时消耗的内存和计算资源少于 64 位浮点数。此外,GPU 架构针对 32 位计算进行了优化,使用这种数据类型可以显著加快模型训练和推理的速度。


此外,可以使用张量的 .to 方法来更改精度。以下代码通过将 64 位整数张量转换为 32 位浮点张量来演示这一点:

floatvec = tensor1d.to(torch.float32)
print(floatvec.dtype)

 这返回

torch.float32


有关 PyTorch 中可用的不同张量数据类型的更多信息,请查看官方文档 https://pytorch.org/docs/stable/tensors.html


A.2.3 常见的 PyTorch 张量操作


本书的范围不包括对所有不同的 PyTorch 张量操作和命令的全面覆盖。然而,我会在书中介绍相关操作时简要描述它们。


我们已经介绍了 torch.tensor() 函数来创建新的张量:

tensor2d = torch.tensor([[1, 2, 3], 
                         [4, 5, 6]])
print(tensor2d)

 这会打印

tensor([[1, 2, 3],
        [4, 5, 6]])


此外,.shape 属性使我们能够访问张量的形状:

print(tensor2d.shape)

 输出是

torch.Size([2, 3])


如您所见,.shape 返回 [2,3],这意味着张量有两行三列。要将张量重塑为 3 × 2 的张量,我们可以使用 .reshape 方法:

print(tensor2d.reshape(3, 2))

 这会打印

tensor([[1, 2],
        [3, 4],
        [5, 6]])


然而,请注意,在 PyTorch 中,重塑张量的更常用命令是.view()

print(tensor2d.view(3, 2))

 输出是

tensor([[1, 2],
        [3, 4],
        [5, 6]])


类似于 .reshape.view,在多个情况下,PyTorch 提供了多种语法选项来执行相同的计算。PyTorch 最初遵循原始的 Lua Torch 语法约定,但随后应广大用户的要求,添加了与 NumPy 类似的语法。(.view().reshape() 在 PyTorch 中的细微差别在于它们对内存布局的处理:.view() 要求原始数据是连续的,如果不是则会失败,而 .reshape() 则无论如何都能工作,如有必要会复制数据以确保所需的形状。)


接下来,我们可以使用 .T 来转置一个张量,这意味着沿着它的对角线翻转它。请注意,这与重塑张量类似,正如您可以从以下结果中看到的:

print(tensor2d.T)

 输出是

tensor([[1, 4],
        [2, 5],
        [3, 6]])


最后,在 PyTorch 中,乘以两个矩阵的常用方法是.matmul方法:

print(tensor2d.matmul(tensor2d.T))

 输出是

tensor([[14, 32],
        [32, 77]])


然而,我们也可以采用 @ 运算符,它以更紧凑的方式完成相同的功能:

print(tensor2d @ tensor2d.T)

 这会打印

tensor([[14, 32],
        [32, 77]])


如前所述,我在需要时会介绍额外的操作。对于想要浏览 PyTorch 中所有不同张量操作的读者(我们大多数情况下不需要这些),我建议查看官方文档,网址为 https://pytorch.org/docs/stable/tensors.html


A.3 将模型视为计算图


现在让我们来看看 PyTorch 的自动微分引擎,也称为 autograd。PyTorch 的 autograd 系统提供了在动态计算图中自动计算梯度的函数。


计算图是一个有向图,它允许我们表达和可视化数学表达式。在深度学习的背景下,计算图展示了计算神经网络输出所需的计算序列——我们需要这个来计算反向传播所需的梯度,反向传播是神经网络的主要训练算法。


让我们看一个具体的例子来说明计算图的概念。以下代码实现了一个简单逻辑回归分类器的前向传播(预测步骤),可以看作是一个单层神经网络。它返回一个介于 0 和 1 之间的分数,在计算损失时与真实类别标签(0 或 1)进行比较。


附录 A.2 逻辑回归前向传播
import torch.nn.functional as F     #1

y = torch.tensor([1.0])          #2
x1 = torch.tensor([1.1])    #3
w1 = torch.tensor([2.2])    #4
b = torch.tensor([0.0])            #5
z = x1 * w1 + b                 #6
a = torch.sigmoid(z)               #7
loss = F.binary_cross_entropy(a, y)

#1 这个导入语句是 PyTorch 中的一个常见约定,用于防止代码行过长。

 #2 真实标签
 #3 输入特征
 #4 权重参数
 #5 偏置单元
 #6 净输入
 #7 激活和输出


如果前面的代码中的所有组件对你来说都不太明白,不用担心。这个例子的重点不是实现一个逻辑回归分类器,而是说明我们如何将一系列计算视为计算图,如图 A.7 所示。

figure

图 A.7 逻辑回归的前向传播作为计算图。输入特征 x1 乘以模型权重 w1,在加上偏置后通过激活函数 s。损失通过将模型输出 a 与给定标签 y 进行比较来计算。


实际上,PyTorch 在后台构建了这样的计算图,我们可以利用它来计算损失函数相对于模型参数(这里的w1b)的梯度,以训练模型。


A.4 自动微分变得简单


如果我们在 PyTorch 中进行计算,如果其终端节点之一的 requires_grad 属性设置为 True,它将默认在内部构建计算图。这在我们想要计算梯度时非常有用。训练神经网络时需要梯度,使用流行的反向传播算法,这可以被视为神经网络中微积分的 链式法则 的一种实现,如图 A.8 所示。

figure

图 A.8 计算图中计算损失梯度的最常见方法是从右到左应用链式法则,这也被称为反向模型自动微分或反向传播。我们从输出层(或损失本身)开始,向后遍历网络直到输入层。我们这样做是为了计算损失相对于网络中每个参数(权重和偏置)的梯度,这为我们在训练过程中更新这些参数提供了信息。


偏导数和梯度


图 A.8 显示了偏导数,它们测量一个函数相对于其一个变量变化的速率。一个 梯度 是一个包含多元函数所有偏导数的向量,所谓多元函数是指输入变量超过一个的函数。


如果您对偏导数、梯度或微积分中的链式法则不熟悉或不记得,不用担心。从高层次来看,您在本书中需要知道的是,链式法则是一种在计算图中根据模型参数计算损失函数梯度的方法。这提供了更新每个参数以最小化损失函数所需的信息,损失函数作为使用梯度下降等方法衡量模型性能的代理。我们将在 A.7 节中重新审视在 PyTorch 中实现这个训练循环的计算。


这些与前面提到的 PyTorch 库的第二个组件——自动微分(autograd)引擎有什么关系?PyTorch 的 autograd 引擎通过跟踪对张量执行的每个操作,在后台构建计算图。然后,调用 grad 函数,我们可以计算损失相对于模型参数 w1 的梯度,如下所示。


附录 A.3 通过自动求导计算梯度
import torch.nn.functional as F
from torch.autograd import grad

y = torch.tensor([1.0])
x1 = torch.tensor([1.1])
w1 = torch.tensor([2.2], requires_grad=True)
b = torch.tensor([0.0], requires_grad=True)

z = x1 * w1 + b 
a = torch.sigmoid(z)

loss = F.binary_cross_entropy(a, y)

grad_L_w1 = grad(loss, w1, retain_graph=True)   #1
grad_L_b = grad(loss, b, retain_graph=True)

默认情况下,PyTorch 在计算梯度后会销毁计算图以释放内存。然而,由于我们将很快重用这个计算图,因此我们设置 retain_graph=True,以便它保留在内存中。


根据模型参数得到的损失值为

print(grad_L_w1)
print(grad_L_b)

 这会打印

(tensor([-0.0898]),)
(tensor([-0.0817]),)


在这里,我们一直手动使用 grad 函数,这对于实验、调试和演示概念非常有用。但在实践中,PyTorch 提供了更高级的工具来自动化这个过程。例如,我们可以在损失上调用 .backward,PyTorch 将计算图中所有叶节点的梯度,这些梯度将通过张量的 .grad 属性存储:

loss.backward()
print(w1.grad)
print(b.grad)

 输出是

(tensor([-0.0898]),)
(tensor([-0.0817]),)


我提供了很多信息,你可能会对微积分概念感到不知所措,但不用担心。虽然这些微积分术语是用来解释 PyTorch 的 autograd 组件,但你需要记住的是,PyTorch 通过 .backward 方法为我们处理微积分——我们不需要手动计算任何导数或梯度。


A.5 实现多层神经网络


接下来,我们将重点关注 PyTorch 作为实现深度神经网络的库。为了提供一个具体的例子,让我们看看多层感知器,这是一种完全连接的神经网络,如图 A.9 所示。

figure

图 A.9 一个具有两个隐藏层的多层感知器。每个节点代表相应层中的一个单元。为了说明,每层的节点数量非常少。


在 PyTorch 中实现神经网络时,我们可以子类化 torch.nn.Module 类来定义我们自己的自定义网络架构。这个 Module 基类提供了很多功能,使得构建和训练模型变得更加容易。例如,它允许我们封装层和操作,并跟踪模型的参数。


在这个子类中,我们在__init__构造函数中定义网络层,并指定层在前向方法中的交互方式。前向方法描述了输入数据如何通过网络并形成计算图。相比之下,反向方法通常不需要我们自己实现,它在训练期间用于根据模型参数计算损失函数的梯度(见 A.7 节)。下面的代码实现了一个经典的多层感知器,具有两个隐藏层,以说明Module类的典型用法。


附录 A.4 一个具有两个隐藏层的多层感知器
class NeuralNetwork(torch.nn.Module):
    def __init__(self, num_inputs, num_outputs):    #1
        super().__init__()

        self.layers = torch.nn.Sequential(

            # 1st hidden layer
            torch.nn.Linear(num_inputs, 30),    #2
            torch.nn.ReLU(),               #3

            # 2nd hidden layer
            torch.nn.Linear(30, 20),    #4
            torch.nn.ReLU(),

            # output layer
            torch.nn.Linear(20, num_outputs),
        )

    def forward(self, x):
        logits = self.layers(x)
        return logits           #5

将输入和输出的数量编码为变量,使我们能够对具有不同特征和类别数量的数据集重用相同的代码


#2 线性层将输入节点和输出节点的数量作为参数。


#3 非线性激活函数被放置在隐藏层之间。


#4 一个隐藏层的输出节点数量必须与下一层的输入数量相匹配。


最后一层的输出称为 logits。


我们可以如下实例化一个新的神经网络对象:

model = NeuralNetwork(50, 3)


在使用这个新的 model 对象之前,我们可以在模型上调用 print 来查看其结构的摘要:

print(model)

 这会打印

NeuralNetwork(
  (layers): Sequential(
    (0): Linear(in_features=50, out_features=30, bias=True)
    (1): ReLU()
    (2): Linear(in_features=30, out_features=20, bias=True)
    (3): ReLU()
    (4): Linear(in_features=20, out_features=3, bias=True)
  )
)


请注意,当我们实现 NeuralNetwork 类时,我们使用 Sequential 类。虽然 Sequential 不是必需的,但如果我们有一系列希望按特定顺序执行的层,它可以让我们的生活更轻松,就像这里的情况一样。这样,在 __init__ 构造函数中实例化 self.layers=Sequential(...) 后,我们只需调用 self.layers,而不是在 NeuralNetworkforward 方法中单独调用每一层。


接下来,让我们检查一下这个模型的可训练参数总数:

num_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print("Total number of trainable model parameters:", num_params)

 这会打印

Total number of trainable model parameters: 2213


每个requires_grad=True的参数都被视为可训练参数,并将在训练期间更新(见 A.7 节)。


在我们具有前两个隐藏层的神经网络模型中,这些可训练参数包含在torch.nn.Linear层中。Linear层将输入与权重矩阵相乘并添加偏置向量。这有时被称为前馈层或全连接层。


根据我们在这里执行的print(model)调用,我们可以看到第一个Linear层在层属性中的索引位置为 0。我们可以通过以下方式访问相应的权重参数矩阵:

print(model.layers[0].weight)

 这会打印

Parameter containing:
tensor([[ 0.1174, -0.1350, -0.1227,  ...,  0.0275, -0.0520, -0.0192],
        [-0.0169,  0.1265,  0.0255,  ..., -0.1247,  0.1191, -0.0698],
        [-0.0973, -0.0974, -0.0739,  ..., -0.0068, -0.0892,  0.1070],
        ...,
        [-0.0681,  0.1058, -0.0315,  ..., -0.1081, -0.0290, -0.1374],
        [-0.0159,  0.0587, -0.0916,  ..., -0.1153,  0.0700,  0.0770],
        [-0.1019,  0.1345, -0.0176,  ...,  0.0114, -0.0559, -0.0088]],
       requires_grad=True)


由于这个大矩阵没有完全显示,让我们使用 .shape 属性来显示它的维度:

print(model.layers[0].weight.shape)

 结果是

torch.Size([30, 50])


(同样,您可以通过 model.layers[0].bias 访问偏置向量。)


这里的权重矩阵是一个 30 × 50 的矩阵,我们可以看到 requires_grad 被设置为 True,这意味着它的条目是可训练的——这是 torch.nn.Linear 中权重和偏置的默认设置。


如果您在计算机上执行前面的代码,权重矩阵中的数字可能与所示的不同。模型权重使用小的随机数初始化,每次实例化网络时都会有所不同。在深度学习中,使用小的随机数初始化模型权重是为了在训练过程中打破对称性。否则,节点在反向传播期间将执行相同的操作和更新,这将不允许网络学习从输入到输出的复杂映射。


然而,虽然我们希望继续使用小的随机数作为我们层权重的初始值,但我们可以通过 manual_seed 来为 PyTorch 的随机数生成器设置种子,从而使随机数初始化可重复

torch.manual_seed(123)
model = NeuralNetwork(50, 3)
print(model.layers[0].weight)

 结果是

Parameter containing:
tensor([[-0.0577,  0.0047, -0.0702,  ...,  0.0222,  0.1260,  0.0865],
        [ 0.0502,  0.0307,  0.0333,  ...,  0.0951,  0.1134, -0.0297],
        [ 0.1077, -0.1108,  0.0122,  ...,  0.0108, -0.1049, -0.1063],
        ...,
        [-0.0787,  0.1259,  0.0803,  ...,  0.1218,  0.1303, -0.1351],
        [ 0.1359,  0.0175, -0.0673,  ...,  0.0674,  0.0676,  0.1058],
        [ 0.0790,  0.1343, -0.0293,  ...,  0.0344, -0.0971, -0.0509]],
       requires_grad=True)


现在我们花了一些时间检查NeuralNetwork实例,接下来让我们简要看看它是如何通过前向传播使用的:

torch.manual_seed(123)
X = torch.rand((1, 50))
out = model(X)
print(out)

 结果是

tensor([[-0.1262,  0.1080, -0.1792]], grad_fn=<AddmmBackward0>)


在前面的代码中,我们生成了一个单一的随机训练示例 X 作为玩具输入(请注意,我们的网络期望 50 维特征向量),并将其输入到模型中,返回三个分数。当我们调用 model(x) 时,它将自动执行模型的前向传播。


前向传播是指从输入张量计算输出张量。这涉及将输入数据通过所有神经网络层,从输入层开始,经过隐藏层,最后到达输出层。


这三个返回的数字对应于分配给三个输出节点的分数。请注意,输出张量还包括一个 grad_fn 值。


在这里,grad_fn=<AddmmBackward0> 表示在计算图中计算变量时最后使用的函数。特别是,grad_fn=<AddmmBackward0> 意味着我们正在检查的张量是通过矩阵乘法和加法操作创建的。PyTorch 在反向传播计算梯度时将使用此信息。<AddmmBackward0> 部分的 grad_fn=<AddmmBackward0> 指定了执行的操作。在这种情况下,它是一个 Addmm 操作。Addmm 代表矩阵乘法 (mm) 后跟加法 (Add)。


如果我们只是想使用一个网络而不进行训练或反向传播——例如,如果我们在训练后使用它进行预测——构建这个用于反向传播的计算图可能是浪费的,因为它执行了不必要的计算并消耗了额外的内存。因此,当我们使用模型进行推理(例如,进行预测)而不是训练时,最佳实践是使用torch.no_grad()上下文管理器。这告诉 PyTorch 它不需要跟踪梯度,这可以显著节省内存和计算。

with torch.no_grad():
    out = model(X)
print(out)

 结果是

tensor([[-0.1262,  0.1080, -0.1792]])


在 PyTorch 中,通常的做法是编写模型,使其返回最后一层的输出(logits),而不将其传递给非线性激活函数。这是因为 PyTorch 常用的损失函数将softmax(或用于二分类的sigmoid)操作与负对数似然损失结合在一个类中。这样做的原因是数值效率和稳定性。因此,如果我们想要计算预测的类别成员概率,就必须显式调用softmax函数:

with torch.no_grad():
    out = torch.softmax(model(X), dim=1)
print(out)

 这会打印

tensor([[0.3113, 0.3934, 0.2952]]))


这些值现在可以解释为类成员概率,总和为 1。对于这个随机输入,这些值大致相等,这对于一个未经过训练的随机初始化模型是可以预期的。


A.6 设置高效的数据加载器


在我们训练模型之前,我们需要简要讨论在 PyTorch 中创建高效的数据加载器,这将在训练过程中进行迭代。PyTorch 中数据加载的整体思路如图 A.10 所示。

figure

图 A.10 PyTorch 实现了一个 Dataset 和一个 DataLoader 类。Dataset 类用于实例化定义如何加载每个数据记录的对象。DataLoader 处理数据的洗牌和批次组装。


根据图 A.10,我们将实现一个自定义的 Dataset 类,我们将用它来创建一个训练数据集和一个测试数据集,然后我们将用这些数据集来创建数据加载器。让我们先创建一个简单的玩具数据集,其中包含五个训练示例,每个示例有两个特征。除了训练示例外,我们还创建一个张量,包含相应的类别标签:三个示例属于类别 0,两个示例属于类别 1。此外,我们还制作了一个由两个条目组成的测试集。创建此数据集的代码如下所示。


附录 A.5 创建一个小型玩具数据集
X_train = torch.tensor([
    [-1.2, 3.1],
    [-0.9, 2.9],
    [-0.5, 2.6],
    [2.3, -1.1],
    [2.7, -1.5]
])
y_train = torch.tensor([0, 0, 0, 1, 1])

X_test = torch.tensor([
    [-0.8, 2.8],
    [2.6, -1.6],
])
y_test = torch.tensor([0, 1])


接下来,我们通过从 PyTorch 的 Dataset 父类继承,创建一个自定义数据集类 ToyDataset,如下面的代码所示。


附录 A.6 定义一个自定义 数据集
from torch.utils.data import Dataset

class ToyDataset(Dataset):
    def __init__(self, X, y):
        self.features = X
        self.labels = y

    def __getitem__(self, index):        #1
        one_x = self.features[index]     #1
        one_y = self.labels[index]       #1
        return one_x, one_y              #1

    def __len__(self):
        return self.labels.shape[0]      #2

train_ds = ToyDataset(X_train, y_train)
test_ds = ToyDataset(X_test, y_test)

#1 检索一个数据记录及其对应标签的说明


#2 返回数据集总长度的说明


这个自定义 ToyDataset 类的目的是实例化一个 PyTorch DataLoader。但在我们进入这一步之前,让我们简要回顾一下 ToyDataset 代码的一般结构。


在 PyTorch 中,自定义 Dataset 类的三个主要组件是 __init__ 构造函数、__getitem__ 方法和 __len__ 方法(见列表 A.6)。在 __init__ 方法中,我们设置可以在 __getitem____len__ 方法中访问的属性。这些属性可以是文件路径、文件对象、数据库连接器等。由于我们创建了一个驻留在内存中的张量数据集,我们简单地将 Xy 分配给这些属性,这些属性是我们张量对象的占位符。


__getitem__方法中,我们定义了通过index从数据集中返回一个项目的指令。这指的是与单个训练示例或测试实例对应的特征和类标签。(数据加载器将提供这个index,我们稍后会详细介绍。)


最后,__len__ 方法包含了获取数据集长度的指令。在这里,我们使用张量的 .shape 属性来返回特征数组中的行数。在训练数据集的情况下,我们有五行,我们可以进行双重检查:

print(len(train_ds))

 结果是

5


现在我们已经定义了一个可以用于我们的玩具数据集的 PyTorch Dataset 类,我们可以使用 PyTorch 的 DataLoader 类从中进行采样,如下所示。


附录 A.7 实例化数据加载器
from torch.utils.data import DataLoader


torch.manual_seed(123)

train_loader = DataLoader(
    dataset=train_ds,     #1
    batch_size=2,
    shuffle=True,          #2
    num_workers=0     #3
)

test_loader = DataLoader(
    dataset=test_ds,
    batch_size=2,
    shuffle=False,     #4
    num_workers=0
)

#1 之前创建的 ToyDataset 实例作为数据加载器的输入。


#2 是否对数据进行洗牌


#3 背景进程的数量


#4 不必对测试数据集进行洗牌。


在实例化训练数据加载器后,我们可以对其进行迭代。对test_loader的迭代类似,但为了简洁起见省略了:

for idx, (x, y) in enumerate(train_loader):
    print(f"Batch {idx+1}:", x, y)

 结果是

Batch 1: tensor([[-1.2000,  3.1000],
                 [-0.5000,  2.6000]]) tensor([0, 0])
Batch 2: tensor([[ 2.3000, -1.1000],
                 [-0.9000,  2.9000]]) tensor([1, 0])
Batch 3: tensor([[ 2.7000, -1.5000]]) tensor([1])


根据前面的输出,我们可以看到,train_loader 遍历训练数据集,每个训练示例恰好访问一次。这被称为一个训练周期。由于我们在这里使用 torch.manual_seed(123) 设置了随机数生成器的种子,因此您应该获得完全相同的训练示例洗牌顺序。然而,如果您第二次遍历数据集,您会发现洗牌顺序会发生变化。这是为了防止深度神经网络在训练过程中陷入重复的更新循环。


我们在这里指定了批量大小为 2,但第三个批次只包含一个示例。这是因为我们有五个训练示例,而 5 不能被 2 整除。在实际操作中,作为训练周期最后一个批次的批量显著较小可能会干扰训练过程中的收敛。为防止这种情况,请设置 drop_last=True,这将在每个周期中丢弃最后一个批次,如下所示。


附录 A.8 一个丢弃最后一批的训练加载器
train_loader = DataLoader(
    dataset=train_ds,
    batch_size=2,
    shuffle=True,
    num_workers=0,
    drop_last=True
)


现在,遍历训练加载器时,我们可以看到最后一批被省略了:

for idx, (x, y) in enumerate(train_loader):
    print(f"Batch {idx+1}:", x, y)

 结果是

Batch 1: tensor([[-0.9000,  2.9000],
        [ 2.3000, -1.1000]]) tensor([0, 1])
Batch 2: tensor([[ 2.7000, -1.5000],
        [-0.5000,  2.6000]]) tensor([1, 0])


最后,让我们讨论一下在DataLoader中设置num_workers=0。这个参数在 PyTorch 的DataLoader函数中对于并行加载和预处理数据至关重要。当num_workers设置为 0 时,数据加载将在主进程中完成,而不是在单独的工作进程中完成。这看起来似乎没有问题,但在我们在 GPU 上训练更大的网络时,这可能会导致模型训练期间显著的速度下降。CPU 不仅要专注于深度学习模型的处理,还需要花时间加载和预处理数据。因此,GPU 可能会在等待 CPU 完成这些任务时处于空闲状态。相反,当num_workers设置为大于 0 的数字时,会启动多个工作进程以并行加载数据,从而释放主进程专注于训练模型,更好地利用系统资源(图 A.11)。

figure

图 A.11 在没有多个工作线程的情况下加载数据(设置 num_workers=0)会造成数据加载瓶颈,模型在等待下一个批次加载时处于空闲状态(左)。如果启用多个工作线程,数据加载器可以在后台排队下一个批次(右)。


然而,如果我们处理的是非常小的数据集,将 num_workers 设置为 1 或更大可能并不是必要的,因为总训练时间只需几秒钟的时间。因此,如果您正在处理微小的数据集或交互式环境,例如 Jupyter 笔记本,增加 num_workers 可能不会带来明显的加速。实际上,这可能会导致一些问题。一个潜在的问题是启动多个工作进程的开销,这可能会比实际的数据加载花费更长的时间,当您的数据集很小时。


此外,对于 Jupyter 笔记本,将 num_workers 设置为大于 0 有时会导致不同进程之间资源共享相关的问题,从而导致错误或笔记本崩溃。因此,了解权衡并对 num_workers 参数的设置做出合理决策是至关重要的。正确使用时,它可以成为一个有益的工具,但应根据您的特定数据集大小和计算环境进行调整,以获得最佳结果。


根据我的经验,设置 num_workers=4 通常会在许多真实世界的数据集上实现最佳性能,但最佳设置取决于您的硬件和在 Dataset 类中定义的加载训练示例的代码。


A.7 一个典型的训练循环


现在让我们在玩具数据集上训练一个神经网络。以下代码展示了训练代码。


附录 A.9 在 PyTorch 中进行神经网络训练
import torch.nn.functional as F

torch.manual_seed(123)
model = NeuralNetwork(num_inputs=2, num_outputs=2)    #1
optimizer = torch.optim.SGD(
    model.parameters(), lr=0.5
)            #2

num_epochs = 3
for epoch in range(num_epochs): 

    model.train()
    for batch_idx, (features, labels) in enumerate(train_loader):
        logits = model(features)

        loss = F.cross_entropy(logits, labels)

        optimizer.zero_grad()            #3
        loss.backward()         #4
        optimizer.step()        #5

        ### LOGGING
        print(f"Epoch: {epoch+1:03d}/{num_epochs:03d}"
              f" | Batch {batch_idx:03d}/{len(train_loader):03d}"
              f" | Train Loss: {loss:.2f}")

    model.eval()
    # Insert optional model evaluation code

数据集有两个特征和两个类别。


优化器需要知道哪些参数需要优化。


#3 将上轮的梯度设置为 0,以防止意外的梯度累积


#4 计算给定模型参数的损失梯度


优化器使用梯度来更新模型参数。


运行此代码会产生以下输出:

Epoch: 001/003 | Batch 000/002 | Train Loss: 0.75
Epoch: 001/003 | Batch 001/002 | Train Loss: 0.65
Epoch: 002/003 | Batch 000/002 | Train Loss: 0.44
Epoch: 002/003 | Batch 001/002 | Trainl Loss: 0.13
Epoch: 003/003 | Batch 000/002 | Train Loss: 0.03
Epoch: 003/003 | Batch 001/002 | Train Loss: 0.00


正如我们所看到的,损失在三个周期后达到 0,这表明模型在训练集上收敛。在这里,我们初始化一个具有两个输入和两个输出的模型,因为我们的玩具数据集有两个输入特征和两个类标签需要预测。我们使用了随机梯度下降(SGD)优化器,学习率(lr)为 0.5。学习率是一个超参数,这意味着它是一个可调设置,我们必须根据观察损失进行实验。理想情况下,我们希望选择一个学习率,使得损失在一定数量的周期后收敛——周期数是另一个需要选择的超参数。


在实践中,我们通常使用第三个数据集,即所谓的验证数据集,以找到最佳的超参数设置。验证数据集类似于测试集。然而,我们只希望使用测试集一次,以避免对评估产生偏差,而通常会多次使用验证集来调整模型设置。


我们还引入了新的设置,称为 model.train()model.eval()。正如这些名称所暗示的,这些设置用于将模型置于训练模式和评估模式。这对于在训练和推理期间表现不同的组件是必要的,例如 dropoutbatch normalization 层。由于我们在 NeuralNetwork 类中没有受到这些设置影响的 dropout 或其他组件,因此在我们之前的代码中使用 model.train()model.eval() 是多余的。然而,最好还是包含它们,以避免在更改模型架构或重用代码以训练不同模型时出现意外行为。


如前所述,我们将 logits 直接传递给 cross_entropy 损失函数,该函数会出于效率和数值稳定性的原因在内部应用 softmax 函数。然后,调用 loss.backward() 将计算 PyTorch 在后台构建的计算图中的梯度。optimizer.step() 方法将使用这些梯度来更新模型参数,以最小化损失。在 SGD 优化器的情况下,这意味着将梯度与学习率相乘,并将缩放后的负梯度添加到参数中。


在我们训练好模型后,我们可以用它来进行预测:

model.eval()
with torch.no_grad():
    outputs = model(X_train)
print(outputs)

 结果是

tensor([[ 2.8569, -4.1618],
        [ 2.5382, -3.7548],
        [ 2.0944, -3.1820],
        [-1.4814,  1.4816],
        [-1.7176,  1.7342]])


要获得类别成员概率,我们可以使用 PyTorch 的 softmax 函数:

torch.set_printoptions(sci_mode=False)
probas = torch.softmax(outputs, dim=1)
print(probas)

 这输出

tensor([[    0.9991,     0.0009],
        [    0.9982,     0.0018],
        [    0.9949,     0.0051],
        [    0.0491,     0.9509],
        [    0.0307,     0.9693]])


让我们考虑前面代码输出中的第一行。在这里,第一个值(列)表示训练示例属于类 0 的概率为 99.91%,属于类 1 的概率为 0.09%。(set_printoptions 调用在这里用于使输出更易读。)


我们可以使用 PyTorch 的 argmax 函数将这些值转换为类别标签预测,如果我们设置 dim=1,它将返回每行中最高值的索引位置(设置 dim=0 将返回每列中的最高值):

predictions = torch.argmax(probas, dim=1)
print(predictions)

 这会打印

tensor([0, 0, 0, 1, 1])


请注意,计算 softmax 概率以获得类别标签是没有必要的。我们也可以直接对 logits(输出)应用 argmax 函数:

predictions = torch.argmax(outputs, dim=1)
print(predictions)

 输出是

tensor([0, 0, 0, 1, 1])


在这里,我们计算了训练数据集的预测标签。由于训练数据集相对较小,我们可以通过目测将其与真实训练标签进行比较,发现模型的准确率为 100%。我们可以使用==比较运算符进行双重检查:

predictions == y_train

 结果是

tensor([True, True, True, True, True])


使用 torch.sum,我们可以计算正确预测的数量:

torch.sum(predictions == y_train)

 输出是

5


由于数据集包含五个训练示例,我们有五个正确的预测,预测准确率为 5/5 × 100% = 100%。


为了概括预测准确性的计算,让我们实现一个 compute_accuracy 函数,如下所示。


附录 A.10 计算预测准确性的函数
def compute_accuracy(model, dataloader):

    model = model.eval()
    correct = 0.0
    total_examples = 0

    for idx, (features, labels) in enumerate(dataloader):

        with torch.no_grad():
            logits = model(features)

        predictions = torch.argmax(logits, dim=1)
        compare = labels == predictions       #1
        correct += torch.sum(compare)      #2
        total_examples += len(compare)

    return (correct / total_examples).item()    #3

#1 返回一个布尔值张量,取决于标签是否匹配


#2 求和操作计算 True 值的数量。


#3 正确预测的比例,介于 0 和 1 之间。.item() 返回张量的值作为 Python 浮点数。


代码遍历数据加载器以计算正确预测的数量和比例。当我们处理大型数据集时,由于内存限制,我们通常只能在数据集的一小部分上调用模型。这里的 compute_accuracy 函数是一个通用方法,可以扩展到任意大小的数据集,因为在每次迭代中,模型接收到的数据集块的大小与训练期间看到的批量大小相同。compute_accuracy 函数的内部实现与我们之前在将 logits 转换为类别标签时使用的类似。


我们可以将该函数应用于训练:

print(compute_accuracy(model, train_loader))

 结果是

1.0


同样,我们可以将该函数应用于测试集:

print(compute_accuracy(model, test_loader))

 这会打印

1.0


A.8 保存和加载模型


现在我们已经训练了模型,让我们看看如何保存它,以便以后可以重用。以下是我们在 PyTorch 中保存和加载模型的推荐方法:

torch.save(model.state_dict(), "model.pth")


模型的 state_dict 是一个 Python 字典对象,它将模型中的每一层映射到其可训练参数(权重和偏置)。 "model.pth" 是保存到磁盘的模型文件的任意文件名。我们可以给它任何我们喜欢的名称和文件后缀;然而, .pth.pt 是最常见的约定。


一旦我们保存了模型,就可以从磁盘恢复它:

model = NeuralNetwork(2, 2) 
model.load_state_dict(torch.load("model.pth"))


torch.load("model.pth") 函数读取文件 "model.pth" 并重建包含模型参数的 Python 字典对象,而 model.load_state_dict() 将这些参数应用于模型,有效地恢复了我们保存时的学习状态。


该行 model=NeuralNetwork(2,2) 在您在同一会话中执行此代码时并不是严格必要的,您在该会话中保存了一个模型。然而,我在这里包含它是为了说明我们需要在内存中有一个模型的实例,以应用保存的参数。在这里,NeuralNetwork(2,2) 架构需要与原始保存的模型完全匹配。


A.9 使用 GPU 优化训练性能


接下来,让我们研究如何利用 GPU,这比普通 CPU 加速深度神经网络的训练。首先,我们将了解 PyTorch 中 GPU 计算的主要概念。然后,我们将在单个 GPU 上训练一个模型。最后,我们将研究使用多个 GPU 的分布式训练。


A.9.1 PyTorch 在 GPU 设备上的计算


修改训练循环以可选地在 GPU 上运行相对简单,只需更改三行代码(见 A.7 节)。在进行修改之前,理解 PyTorch 中 GPU 计算的主要概念至关重要。在 PyTorch 中,设备是计算发生和数据存储的地方。CPU 和 GPU 是设备的例子。PyTorch 张量驻留在一个设备上,其操作在同一设备上执行。


让我们看看这在实际中是如何工作的。假设您安装了兼容 GPU 的 PyTorch 版本(请参见 A.1.3 节),我们可以通过以下代码再次确认我们的运行时确实支持 GPU 计算:

print(torch.cuda.is_available())

 结果是

True


现在,假设我们有两个可以相加的张量;这个计算默认将在 CPU 上进行:

tensor_1 = torch.tensor([1., 2., 3.])
tensor_2 = torch.tensor([4., 5., 6.])
print(tensor_1 + tensor_2)

 这输出

tensor([5., 7., 9.])


我们现在可以使用 .to() 方法。这个方法与我们用来改变张量数据类型的方法相同(见 2.2.2),用于将这些张量转移到 GPU 上并在那里执行加法:

tensor_1 = tensor_1.to("cuda")
tensor_2 = tensor_2.to("cuda")
print(tensor_1 + tensor_2)

 输出是

tensor([5., 7., 9.], device='cuda:0')


结果张量现在包含设备信息,device='cuda:0',这意味着张量位于第一个 GPU 上。如果您的机器有多个 GPU,您可以指定要将张量传输到哪个 GPU。您可以通过在传输命令中指明设备 ID 来实现。例如,您可以使用.to("cuda:0").to("cuda:1"),等等。


然而,所有张量必须在同一设备上。否则,计算将失败,其中一个张量位于 CPU 上,另一个位于 GPU 上:

tensor_1 = tensor_1.to("cpu")
print(tensor_1 + tensor_2)

 结果是

RuntimeError      Traceback (most recent call last)
<ipython-input-7-4ff3c4d20fc3> in <cell line: 2>()
      1 tensor_1 = tensor_1.to("cpu")
----> 2 print(tensor_1 + tensor_2)
RuntimeError: Expected all tensors to be on the same device, but found at
least two devices, cuda:0 and cpu!


总之,我们只需要将张量转移到同一个 GPU 设备上,PyTorch 会处理其余的。


A.9.2 单 GPU 训练


现在我们已经熟悉了将张量转移到 GPU 上,我们可以修改训练循环以在 GPU 上运行。这一步只需要更改三行代码,如下所示。


附录 A.11 在 GPU 上的训练循环
torch.manual_seed(123)
model = NeuralNetwork(num_inputs=2, num_outputs=2)

device = torch.device("cuda")      #1
model = model.to(device)          #2

optimizer = torch.optim.SGD(model.parameters(), lr=0.5)

num_epochs = 3

for epoch in range(num_epochs):

    model.train()
    for batch_idx, (features, labels) in enumerate(train_loader):
        features, labels = features.to(device), labels.to(device)   #3
        logits = model(features)
        loss = F.cross_entropy(logits, labels) # Loss function

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        ### LOGGING
        print(f"Epoch: {epoch+1:03d}/{num_epochs:03d}"
              f" | Batch {batch_idx:03d}/{len(train_loader):03d}"
              f" | Train/Val Loss: {loss:.2f}")

    model.eval()
    # Insert optional model evaluation code

#1 定义一个默认使用 GPU 的设备变量


#2 将模型转移到 GPU 上


#3 将数据传输到 GPU 上


运行前面的代码将输出以下内容,类似于在 CPU 上获得的结果(第 A.7 节):

Epoch: 001/003 | Batch 000/002 | Train/Val Loss: 0.75
Epoch: 001/003 | Batch 001/002 | Train/Val Loss: 0.65
Epoch: 002/003 | Batch 000/002 | Train/Val Loss: 0.44
Epoch: 002/003 | Batch 001/002 | Train/Val Loss: 0.13
Epoch: 003/003 | Batch 000/002 | Train/Val Loss: 0.03
Epoch: 003/003 | Batch 001/002 | Train/Val Loss: 0.00


我们可以使用 .to("cuda") 来代替 device=torch.device("cuda")。将张量转移到 "cuda" 而不是 torch.device("cuda") 也可以正常工作,并且更简洁(见 A.9.1 节)。我们还可以修改语句,这样如果没有 GPU,代码也可以在 CPU 上执行。这被认为是共享 PyTorch 代码时的最佳实践:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")


在这里修改的训练循环中,我们可能不会看到加速,因为从 CPU 到 GPU 的内存传输成本。然而,当训练深度神经网络时,特别是LLMs,我们可以期待显著的加速。


A.9.3 多 GPU 训练


分布式训练是将模型训练分配到多个 GPU 和机器上的概念。我们为什么需要这个?即使在单个 GPU 或机器上训练模型是可能的,过程也可能非常耗时。通过将训练过程分布到多个机器上(每台机器可能有多个 GPU),可以显著减少训练时间。这在模型开发的实验阶段尤为重要,因为可能需要进行多次训练迭代来微调模型参数和架构。


让我们从分布式训练的最基本案例开始:PyTorch 的 DistributedDataParallel (DDP) 策略。DDP 通过将输入数据分割到可用设备上,并同时处理这些数据子集来实现并行。


这怎么运作?PyTorch 在每个 GPU 上启动一个单独的进程,每个进程接收并保留模型的副本;这些副本将在训练期间进行同步。为了说明这一点,假设我们有两个 GPU,我们想用它们来训练一个神经网络,如图 A.12 所示。

figure

图 A.12 DDP 中的模型和数据传输涉及两个关键步骤。首先,我们在每个 GPU 上创建模型的副本。然后,我们将输入数据划分为独特的小批量,并将其传递给每个模型副本。


每个 GPU 将接收到模型的副本。然后,在每次训练迭代中,每个模型将从数据加载器中接收一个小批量(或称“批量”)。我们可以使用一个 DistributedSampler 来确保在使用 DDP 时,每个 GPU 将接收到一个不同的、非重叠的批量。


由于每个模型副本将看到不同的训练数据样本,因此模型副本将返回不同的 logits 作为输出,并在反向传播过程中计算不同的梯度。这些梯度在训练期间被平均和同步,以更新模型。通过这种方式,我们确保模型不会发散,如图 A.13 所示。

figure

图 A.13 DDP 中的前向和后向传播在每个 GPU 上独立执行,使用其对应的数据子集。一旦前向和后向传播完成,来自每个模型副本(在每个 GPU 上)的梯度将在所有 GPU 之间同步。这确保每个模型副本具有相同的更新权重。


使用 DDP 的好处在于与单个 GPU 相比,它提供了更快的数据集处理速度。除了 DDP 使用带来的设备之间的轻微通信开销外,理论上使用两个 GPU 可以在一半的时间内处理一个训练周期,而不是仅使用一个 GPU。如果我们有八个 GPU,时间效率将提升到八倍,依此类推。


现在让我们看看这在实践中是如何运作的。为了简洁起见,我专注于需要为 DDP 训练调整的代码核心部分。然而,想要在自己多 GPU 机器或选择的云实例上运行代码的读者,应使用本书 GitHub 仓库中提供的独立脚本,链接为 https://github.com/rasbt/LLMs-from-scratch


首先,我们导入一些额外的子模块、类和函数,以便进行分布式训练 PyTorch,如下所示。


附录 A.12 PyTorch 分布式训练工具
import torch.multiprocessing as mp
from torch.utils.data.distributed import DistributedSampler
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.distributed import init_process_group, destroy_process_group


在我们深入探讨使训练与 DistributedDataParallel 兼容的变化之前,让我们简要回顾一下这些新导入的工具的理由和用法,这些工具是我们在使用 DistributedDataParallel 类时所需的。


PyTorch 的 multiprocessing 子模块包含一些函数,例如 multiprocessing .spawn,我们将使用它来生成多个进程并将一个函数并行应用于多个输入。我们将为每个 GPU 生成一个训练进程。如果我们为训练生成多个进程,我们需要一种方法将数据集分配给这些不同的进程。为此,我们将使用 DistributedSampler


init_process_groupdestroy_process_group 用于初始化和退出分布式训练模式。init_process_group 函数应在训练脚本开始时调用,以为分布式设置中的每个进程初始化一个进程组,而 destroy_process_group 应在训练脚本结束时调用,以销毁给定的进程组并释放其资源。以下列表中的代码演示了如何使用这些新组件来实现我们之前实现的 NeuralNetwork 模型的 DDP 训练。


附录 A.13 使用 DistributedDataParallel 策略进行模型训练
def ddp_setup(rank, world_size):
    os.environ["MASTER_ADDR"] = "localhost"    #1
    os.environ["MASTER_PORT"] = "12345"      #2
    init_process_group(
        backend="nccl",              #3
        rank=rank,                         #4
        world_size=world_size            #5
    )
    torch.cuda.set_device(rank)        #6

def prepare_dataset():
    # insert dataset preparation code 
    train_loader = DataLoader(
        dataset=train_ds,
        batch_size=2,
        shuffle=False,             #7
        pin_memory=True,           #8
        drop_last=True,
        sampler=DistributedSampler(train_ds)    #9
    )    
    return train_loader, test_loader

def main(rank, world_size, num_epochs):       #10
    ddp_setup(rank, world_size)
    train_loader, test_loader = prepare_dataset()
    model = NeuralNetwork(num_inputs=2, num_outputs=2)
    model.to(rank)
    optimizer = torch.optim.SGD(model.parameters(), lr=0.5)
    model = DDP(model, device_ids=[rank])
    for epoch in range(num_epochs):
    for features, labels in train_loader:
            features, labels = features.to(rank), labels.to(rank)      #11
            # insert model prediction and backpropagation code 
            print(f"[GPU{rank}] Epoch: {epoch+1:03d}/{num_epochs:03d}"
                  f" | Batchsize {labels.shape[0]:03d}"
                  f" | Train/Val Loss: {loss:.2f}")

    model.eval()
    train_acc = compute_accuracy(model, train_loader, device=rank)
    print(f"[GPU{rank}] Training accuracy", train_acc)
    test_acc = compute_accuracy(model, test_loader, device=rank)
    print(f"[GPU{rank}] Test accuracy", test_acc)
    destroy_process_group()                      #12

if __name__ == "__main__":
    print("Number of GPUs available:", torch.cuda.device_count())
    torch.manual_seed(123)
    num_epochs = 3
    world_size = torch.cuda.device_count()
    mp.spawn(main, args=(world_size, num_epochs), nprocs=world_size)  #13

#1 主节点地址


机器上的任何空闲端口


#3 nccl 代表 NVIDIA 集体通信库。


#4 排名指的是我们想要使用的 GPU 的索引。


#5 world_size 是要使用的 GPU 数量。


#6 设置当前 GPU 设备,张量将在该设备上分配并执行操作


#7 分布式采样器现在负责洗牌。


#8 在 GPU 上训练时实现更快的内存传输


#9 将数据集拆分为每个进程(GPU)的不同、不重叠的子集


#10 运行模型训练的主要功能


#11 排名是 GPU ID


#12 清理资源分配


#13 使用多个进程启动主功能,其中 nprocs=world_size 意味着每个 GPU 一个进程。


在运行这段代码之前,让我们总结一下它的工作原理,以及之前的注释。我们在底部有一个 __name__=="__main__" 条款,其中包含在我们将代码作为 Python 脚本运行而不是将其作为模块导入时执行的代码。这段代码首先使用 torch.cuda.device_count() 打印可用 GPU 的数量,设置随机种子以确保可重复性,然后使用 PyTorch 的 multiprocessesing.spawn 函数生成新进程。在这里,spawn 函数为每个 GPU 启动一个进程,设置 nproces=world_size,其中世界大小是可用 GPU 的数量。这个 spawn 函数在我们在同一脚本中定义的 main 函数中启动代码,并通过 args 提供一些额外的参数。请注意,main 函数有一个 rank 参数,我们在 mp.spawn() 调用中没有包含它。这是因为 rank,即我们用作 GPU ID 的进程 ID,已经自动传递。


main 函数通过 ddp_setup(我们定义的另一个函数)设置分布式环境,加载训练集和测试集,设置模型,并进行训练。与单 GPU 训练(第 A.9.2 节)相比,我们现在通过 to(rank) 将模型和数据转移到目标设备,这里我们用它来指代 GPU 设备 ID。此外,我们通过 DDP 封装模型,这使得在训练过程中不同 GPU 之间的梯度能够同步。在训练完成并评估模型后,我们使用 destroy_process_group() 来干净地退出分布式训练并释放分配的资源。


我之前提到过,每个 GPU 将接收到不同的训练数据子样本。为了确保这一点,我们在训练加载器中设置了 sampler=DistributedSampler(train_ds)


最后要讨论的函数是 ddp_setup。它设置主节点的地址和端口,以便不同进程之间进行通信,使用 NCCL 后端初始化进程组(专为 GPU 到 GPU 通信设计),并设置 rank(进程标识符)和世界大小(进程总数)。最后,它指定与当前模型训练进程排名对应的 GPU 设备。


在多 GPU 机器上选择可用的 GPU


如果您希望限制在多 GPU 机器上用于训练的 GPU 数量,最简单的方法是使用CUDA_VISIBLE_DEVICES环境变量。为了说明这一点,假设您的机器有多个 GPU,而您只想使用一个 GPU——例如,索引为 0 的 GPU。您可以从终端运行以下代码,而不是pythonsome_script.py

CUDA_VISIBLE_DEVICES=0 python some_script.py


或者,如果你的机器有四个 GPU,而你只想使用第一个和第三个 GPU,你可以使用

CUDA_VISIBLE_DEVICES=0,2 python some_script.py


以这种方式设置 CUDA_VISIBLE_DEVICES 是管理 GPU 分配的一种简单有效的方法,无需修改您的 PyTorch 脚本。


现在让我们运行这段代码,看看它在实践中是如何工作的,通过从终端以脚本的形式启动代码:

python ch02-DDP-script.py


请注意,它应该在单 GPU 和多 GPU 机器上都能工作。如果我们在单个 GPU 上运行此代码,我们应该看到以下输出:

PyTorch version: 2.2.1+cu117
CUDA available: True
Number of GPUs available: 1
[GPU0] Epoch: 001/003 | Batchsize 002 | Train/Val Loss: 0.62
[GPU0] Epoch: 001/003 | Batchsize 002 | Train/Val Loss: 0.32
[GPU0] Epoch: 002/003 | Batchsize 002 | Train/Val Loss: 0.11
[GPU0] Epoch: 002/003 | Batchsize 002 | Train/Val Loss: 0.07
[GPU0] Epoch: 003/003 | Batchsize 002 | Train/Val Loss: 0.02
[GPU0] Epoch: 003/003 | Batchsize 002 | Train/Val Loss: 0.03
[GPU0] Training accuracy 1.0
[GPU0] Test accuracy 1.0


代码输出看起来与使用单个 GPU(第 A.9.2 节)时相似,这是一个很好的合理性检查。


现在,如果我们在一台有两个 GPU 的机器上运行相同的命令和代码,我们应该看到以下内容:

PyTorch version: 2.2.1+cu117
CUDA available: True
Number of GPUs available: 2
[GPU1] Epoch: 001/003 | Batchsize 002 | Train/Val Loss: 0.60
[GPU0] Epoch: 001/003 | Batchsize 002 | Train/Val Loss: 0.59
[GPU0] Epoch: 002/003 | Batchsize 002 | Train/Val Loss: 0.16
[GPU1] Epoch: 002/003 | Batchsize 002 | Train/Val Loss: 0.17
[GPU0] Epoch: 003/003 | Batchsize 002 | Train/Val Loss: 0.05
[GPU1] Epoch: 003/003 | Batchsize 002 | Train/Val Loss: 0.05
[GPU1] Training accuracy 1.0
[GPU0] Training accuracy 1.0
[GPU1] Test accuracy 1.0
[GPU0] Test accuracy 1.0


正如预期的那样,我们可以看到一些批次在第一个 GPU (GPU0) 上处理,其他批次在第二个 GPU (GPU1) 上处理。然而,当打印训练和测试准确率时,我们看到重复的输出行。每个进程(换句话说,每个 GPU)独立打印测试准确率。由于 DDP 将模型复制到每个 GPU,并且每个进程独立运行,如果在测试循环中有打印语句,每个进程都会执行它,从而导致重复的输出行。如果这让你感到困扰,你可以使用每个进程的排名来控制你的打印语句:

if rank == 0:                  #1
    print("Test accuracy: ", accuracy)

#1 仅在第一个过程中打印


这就是分布式训练通过 DDP 工作的简要概述。如果您对更多细节感兴趣,我建议查看官方 API 文档,网址为 https://mng.bz/9dPr

 摘要


  • PyTorch 是一个开源库,具有三个核心组件:张量库、自动微分函数和深度学习工具。

  • PyTorch 的张量库类似于 NumPy 等数组库。

  • 在 PyTorch 的上下文中,张量是类似数组的数据结构,表示标量、向量、矩阵和更高维的数组。

  • PyTorch 张量可以在 CPU 上执行,但 PyTorch 张量格式的一个主要优势是其对 GPU 的支持,以加速计算。

  • PyTorch 中的自动微分(autograd)功能使我们能够方便地使用反向传播训练神经网络,而无需手动推导梯度。

  • PyTorch 中的深度学习工具提供了构建自定义深度神经网络的基础模块。

  • PyTorch 包含 DatasetDataLoader 类,以建立高效的数据加载管道。

  • 在 CPU 或单个 GPU 上训练模型是最简单的。

  • 在 PyTorch 中,如果有多个 GPU 可用,使用 DistributedDataParallel 是加速训练的最简单方法。