一份PyTorch入门教程:构建和训练你的第一个模型

社区文章 发布于2025年1月20日

这篇博客文章是我最初的“通过示例理解PyTorch:一步一步的教程”的稍作更新版本。

它也是我的系列书籍《PyTorch深度学习逐步指南:初学者入门》第一卷的初始章节的摘要版本。

引言

PyTorch是发展最快的深度学习框架,Fast.ai在其MOOC课程“面向程序员的深度学习”及其中也使用了它。

PyTorch也非常符合Pythonic风格,这意味着,如果你已经是Python开发者,使用它会感觉更自然。此外,根据Andrej Karpathy的说法,使用PyTorch甚至可能改善你的健康状况 :-)

动机

很多PyTorch教程,它的文档也相当完整和全面。那么,为什么你还要继续阅读这份逐步教程呢?

嗯,尽管可以找到关于PyTorch所有功能的资料,但我缺少一种结构化、渐进式并从第一性原理出发的方法来学习它。

在这篇文章中,我将引导你了解PyTorch为何能让在Python中构建深度学习模型变得如此简单直观——自动求导、动态计算图、模型类等——我还会向你展示如何避免一些常见的陷阱和错误

此外,由于这篇文章相当,我建立了一个目录,以便于导航,如果你想将其作为迷你课程,可以逐个主题地学习。

目录

一个简单的回归问题

大多数教程都会从一些美观的图像分类问题开始,以说明如何使用PyTorch。这看起来很酷,但我认为它会分散你对主要目标的注意力:PyTorch是如何工作的

因此,在本教程中,我将坚持一个简单而熟悉的问题:具有单一特征x的线性回归!没有比这更简单的了……

y=a+bx+ϵ \Large y = a + bx + \epsilon

数据生成

我们开始生成一些合成数据:我们首先为特征x创建一个包含100个点向量,然后使用a = 1b = 2和一些高斯噪声创建我们的标签。

接下来,我们将合成数据分割训练集和验证集,打乱索引数组,并使用前80个打乱的点进行训练。

import numpy as np

# Data Generation
np.random.seed(42)
x = np.random.rand(100, 1)
y = 1 + 2 * x + .1 * np.random.randn(100, 1)

# Shuffles the indices
idx = np.arange(100)
np.random.shuffle(idx)

# Uses first 80 random indices for train
train_idx = idx[:80]
# Uses the remaining indices for validation
val_idx = idx[80:]

# Generates train and validation sets
x_train, y_train = x[train_idx], y[train_idx]
x_val, y_val = x[val_idx], y[val_idx]

image/png

我们知道a = 1b = 2,但现在让我们看看通过使用梯度下降训练集中的80个点,我们能多接近真实值……

梯度下降

如果您熟悉梯度下降的内部工作原理,请随意跳过本节。完整解释梯度下降如何工作超出了本文的范围,但我将涵盖计算它所需的五个基本步骤

步骤0:随机初始化

在我们的例子中,我们使用我们试图估计的模型本身生成了数据,但在现实世界中,情况绝非如此:我们对实际参数的值一无所知。

所以,如果我们从头开始训练一个模型,并且对它的参数应该是什么样子一无所知,那么随机猜测是一个很好的起点。让我们从正态分布中抽取两个值,并将这些值分配给我们的参数ab

a=0.49671415b=0.1382643 \Large \begin{aligned} a &= 0.49671415 \\ b &= -0.1382643 \end{aligned}

步骤1:进行预测(即前向传播)

第一步是使用模型(仍是随机的)和我们的特征x进行预测

yi^=a+bxi \Large \hat{y_i} = a + bx_i

你猜怎么着?这些初始预测非常糟糕。但话说回来,你对一个随机模型能有什么期望呢?当然是随机预测。

这些预测有多糟糕?”你问。这就是损失会告诉我们的。

步骤2:计算损失

对于回归问题,损失均方误差(MSE)给出,即标签(y)和预测(a + bx)之间所有平方差的平均值。

值得一提的是,如果我们使用训练集中的所有点(N)来计算损失,我们执行的是批量梯度下降。如果我们每次使用单个点,那将是随机梯度下降。任何介于1和N之间(不包括1和N)的数目(n)都表示小批量梯度下降。

MSE=1Ni=1N(yiyi^)2=1Ni=1N(yiabxi)2 \Large \begin{aligned} MSE &= \frac{1}{N}\sum_{i=1}^N(y_i-\hat{y_i})^2 \\ &= \frac{1}{N}\sum_{i=1}^N(y_i-a-bx_i)^2 \end{aligned}

高值意味着预测非常糟糕低值意味着预测良好零损失则意味着完美模型,但这永远不会发生。

步骤3:计算梯度(即反向传播)

梯度偏导数——为什么是偏导数?因为它相对于(w.r.t.)单个参数进行计算。我们有两个参数,ab,所以我们必须计算两个偏导数。

导数告诉你,当你稍微改变某个时,一个给定量改变多少。在我们的例子中,当我们改变两个参数中的每一个时,我们的MSE损失会改变多少?

下面方程的最右边部分是你在简单线性回归的梯度下降实现中通常会看到的部分。在中间步骤中,我向你展示了链式法则应用后出现的所有元素,这样你就知道最终表达式是如何得到的。

δMSEδa=δMSEδyi^δyi^δa=1Ni=1N2(yiabxi)(1)=21Ni=1N(yiyi^)δMSEδb=δMSEδyi^δyi^δb=1Ni=1N2(yiabxi)(xi)=21Ni=1Nxi(yiyi^) \Large \begin{aligned} \frac{\delta{MSE}}{\delta{a}} &= \frac{\delta{MSE}}{\delta{\hat{y_i}}} \cdot \frac{\delta{\hat{y_i}}}{\delta{a}} \\ &= \frac{1}{N}\sum_{i=1}^N2(y_i-a-bx_i)\cdot(-1) \\ &=-2\frac{1}{N}\sum_{i=1}^N(y_i-\hat{y_i}) \end{aligned} \\ \Large \begin{aligned} \frac{\delta{MSE}}{\delta{b}} &= \frac{\delta{MSE}}{\delta{\hat{y_i}}} \cdot \frac{\delta{\hat{y_i}}}{\delta{b}} \\ &= \frac{1}{N}\sum_{i=1}^N2(y_i-a-bx_i)\cdot(-x_i) \\ &= -2\frac{1}{N}\sum_{i=1}^Nx_i(y_i-\hat{y_i}) \end{aligned}

梯度,尤其是其符号,包含了我们可以用来最小化损失的宝贵信息。请记住,低损失意味着良好预测

正梯度意味着,通过增加相应参数的值,我们会增加损失。另一方面,负梯度意味着,如果我们增加参数的值损失会减少。因此,如果我们想最小化损失,我们必须

  • 增加具有负梯度参数的值,以及
  • 减少具有正梯度参数的值。

换句话说,我们需要反转梯度的符号。

步骤4:更新参数

在最后一步,我们使用梯度来更新参数。由于我们正在尝试最小化损失,我们反转梯度的符号进行更新。

还有一个参数需要考虑:学习率,用希腊字母eta(看起来像字母n)表示,它是我们对参数更新梯度应用的乘法因子

a=aηδMSEδab=bηδMSEδb \Large \begin{aligned} a &= a - \eta\frac{\delta{MSE}}{\delta{a}} \\ b &= b - \eta\frac{\delta{MSE}}{\delta{b}} \end{aligned}

如何选择学习率?这是一个独立的话题,超出了本文的范围。

步骤5:重复执行!

现在我们使用更新后的参数返回到步骤1并重新开始这个过程。

每个点都已被用于计算损失时,一个周期就完成了。对于批量梯度下降,这很简单,因为它使用所有点来计算损失——一个周期一次更新相同。对于随机梯度下降,一个周期意味着N次更新,而对于小批量(大小为n),一个周期N/n次更新

一遍又一遍地重复这个过程,进行多个周期,简而言之,就是训练一个模型。

Numpy中的线性回归

是时候使用纯Numpy实现我们的线性回归模型了。

等等……我以为这个教程是关于PyTorch的!

是的,没错,但这有两个目的:首先,介绍我们任务的结构,它将基本保持不变;其次,向你展示主要的痛点,这样你就能充分体会到PyTorch能多大程度地简化你的工作 :-)

要训练一个模型,有两个初始化步骤

  • 参数/权重(我们只有两个,ab)的随机初始化——第3行和第4行;
  • 超参数(在本例中,只有学习率训练轮数)的初始化——第9行和第11行;

请务必始终初始化你的随机种子,以确保你的结果具有可复现性。像往常一样,随机种子是42,这是你能选择的随机种子中最不随机的一个 :-)

每个 epoch 有四个训练步骤

  1. 计算模型的预测——这是前向传播——第15行;
  2. 使用预测和标签以及适用于当前任务的损失函数计算损失——第18行和第20行;
  3. 计算每个参数的梯度——第23行和第24行;
  4. 更新参数——第27行和第28行;

请记住,如果你使用批量梯度下降(我们的示例使用了),你将不得不编写一个内部循环来对每个单独的点(随机)n个点(小批量)执行四个训练步骤。我们稍后会看到一个使用小批量的示例。

# Initializes parameters "a" and "b" randomly
np.random.seed(42)
a = np.random.randn(1)  # Line 3
b = np.random.randn(1)  # Line 4

print(a, b)

# Sets learning rate
lr = 1e-1               # Line 9
# Defines number of epochs
n_epochs = 1000         # Line 11

for epoch in range(n_epochs):
    # Step 1: Computes our model's predicted output
    yhat = a + b * x_train      # Line 15
    # Step 2
    # How wrong is our model? That's the error! 
    error = (y_train - yhat)    # Line 18
    # It is a regression, so it computes mean squared error (MSE)
    loss = (error ** 2).mean()  # Line 20
    # Step 3
    # Computes gradients for both "a" and "b" parameters
    a_grad = -2 * error.mean()             # Line 23
    b_grad = -2 * (x_train * error).mean() # Line 24
    # Step 4
    # Updates parameters using gradients and the learning rate
    a = a - lr * a_grad   # Line 27
    b = b - lr * b_grad   # Line 28
    
print(a, b)

# Sanity Check: do we get the same results as our gradient descent?
from sklearn.linear_model import LinearRegression
linr = LinearRegression()
linr.fit(x_train, y_train)
print(linr.intercept_, linr.coef_[0])

为了确保我们的代码没有错误,我们可以使用 Scikit-Learn 的线性回归来拟合模型并比较系数。

# a and b after initialization
[0.49671415] [-0.1382643]
# a and b after our gradient descent
[1.02354094] [1.96896411]
# intercept and coef from Scikit-Learn
[1.02354075] [1.96896447]

它们匹配到小数点后6位——我们有了一个使用Numpy的完全可用的线性回归实现

是时候使用 PyTorch了 :-)

PyTorch

首先,我们需要介绍一些**基本概念**,如果你在正式建模之前没有充分理解它们,可能会让你感到困惑。

在深度学习中,我们随处可见**张量(tensors)**。嗯,Google 的框架叫做 *TensorFlow* 也并非没有道理!*那么,张量到底是什么呢?*

张量

在 *Numpy* 中,你可能有一个**三维数组**,对吧?从技术上讲,那就是一个**张量**。

**标量**(一个单独的数字)有**零**个维度,**向量有一个**维度,**矩阵有两个**维度,而**张量有三个或更多**维度。就是这样!

但是,为了简单起见,通常也称向量和矩阵为张量——所以,从现在开始,**所有东西都是标量或张量。**

来源:Karl Stratos 的 Linear Dogs

加载数据、设备和 CUDA

你问:“*我们如何将 Numpy 数组转换为 PyTorch 张量呢?*” 这就是 from_numpy() 的作用。不过,它返回的是一个**CPU 张量**。

“*但我想用我的高级 GPU……*” 你说。别担心,这就是 to() 的作用。它将你的张量发送到你指定的任何**设备**,包括你的**GPU**(通常称为 cudacuda:0)。

“*如果我希望在没有 GPU 的情况下代码回退到 CPU 怎么办?*” 你可能在想…… PyTorch 再次为你提供了支持——你可以使用 cuda.is_available() 来查明你是否有可用的 GPU,并据此设置你的设备。

你还可以使用 float() 轻松将其**转换**为较低精度(32 位浮点数)。

import torch
import torch.optim as optim
import torch.nn as nn
from torchviz import make_dot

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

# Our data was in Numpy arrays, but we need to transform them into PyTorch's Tensors
# and then we send them to the chosen device
x_train_tensor = torch.from_numpy(x_train).float().to(device)
y_train_tensor = torch.from_numpy(y_train).float().to(device)

# Here we can see the difference - notice that .type() is more useful
# since it also tells us WHERE the tensor is (device)
print(type(x_train), type(x_train_tensor), x_train_tensor.type())

如果你比较这两个变量的**类型**,你会得到你期望的结果:第一个是 numpy.ndarray,第二个是 torch.Tensor

但你漂亮的张量“住在”哪里呢?在你的 CPU 还是你的 GPU 上?你无法判断……但如果你使用 PyTorch 的 type(),它会显示其**位置**——本例中是 torch.cuda.FloatTensor,一个 GPU 张量。

我们也可以反过来操作,使用 numpy() 将张量转换回 Numpy 数组。这应该像 x_train_tensor.numpy() 一样简单,但是……

TypeError: can't convert CUDA tensor to numpy. Use Tensor.cpu() to copy the tensor to host memory first.

不幸的是,Numpy**无法**处理 GPU 张量……你首先需要使用 cpu() 将它们转换为 CPU 张量。

创建参数

一个用于**数据**的 *张量*(比如我们刚刚创建的那些)与一个用作(*可训练的*)**参数/权重**的**张量**有什么区别呢?

后者张量需要**计算其梯度**,以便我们可以**更新**它们的值(即参数的值)。这就是 requires_grad=True 参数的作用。它告诉 PyTorch 我们希望它为我们计算梯度。

你可能想为参数创建一个简单的张量,然后像我们处理数据一样,将其发送到你选择的设备,对吧?别急……

# FIRST
# Initializes parameters "a" and "b" randomly, ALMOST as we did in Numpy
# since we want to apply gradient descent on these parameters, we need
# to set REQUIRES_GRAD = TRUE
a = torch.randn(1, requires_grad=True, dtype=torch.float)
b = torch.randn(1, requires_grad=True, dtype=torch.float)
print(a, b)

第一段代码为我们的参数创建了两个不错的张量,包含梯度等所有信息。但它们是**CPU**张量。

tensor([-0.5531], requires_grad=True)
tensor([-0.7314], requires_grad=True)

让我们尝试将它们发送到 GPU

# SECOND
# But what if we want to run it on a GPU? We could just send them to device, right?
a = torch.randn(1, requires_grad=True, dtype=torch.float).to(device)
b = torch.randn(1, requires_grad=True, dtype=torch.float).to(device)
print(a, b)
# Sorry, but NO! The to(device) "shadows" the gradient...

在第二段代码中,我们尝试了**天真**地将它们发送到 GPU 的方法。我们成功地将它们发送到了另一个设备,但我们不知何故**“丢失”了梯度**……

tensor([0.5158], device='cuda:0', grad_fn=<CopyBackwards>)
tensor([0.0246], device='cuda:0', grad_fn=<CopyBackwards>)

如果我们尝试先将它们发送到 GPU 呢?

# THIRD
# We can either create regular tensors and send them to the device (as we did with our data)
a = torch.randn(1, dtype=torch.float).to(device)
b = torch.randn(1, dtype=torch.float).to(device)
# and THEN set them as requiring gradients...
a.requires_grad_()
b.requires_grad_()
print(a, b)

在第三段中,我们**首先**将张量发送到**设备**,**然后**使用 requires_grad_() 方法将其 requires_grad 原位设置为 True

tensor([-0.8915], device='cuda:0', requires_grad=True)
tensor([0.3616], device='cuda:0', requires_grad=True)

在 PyTorch 中,所有**以下划线 (_) 结尾**的方法都会进行**原地**修改,这意味着它们会**修改**底层的变量。

尽管最后一种方法运行良好,但最好在**创建**张量时就将其**分配**给一个**设备**。

# We can specify the device at the moment of creation - RECOMMENDED!
torch.manual_seed(42)
a = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)
b = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)
print(a, b)
tensor([0.1940], device='cuda:0', requires_grad=True)
tensor([0.1391], device='cuda:0', requires_grad=True)

简单多了,对吧?

现在我们知道了如何创建需要梯度的张量,让我们看看 PyTorch 是如何处理它们的——这就是……的作用。

Autograd

Autograd 是 PyTorch 的*自动微分包*。有了它,我们**无需担心**偏导数、链式法则或类似的问题。

那么,我们如何告诉 PyTorch 执行其操作并**计算所有梯度**呢?这就是 backward() 的作用。

你还记得**计算梯度**的**起点**吗?它是**损失函数**,因为我们计算了损失函数相对于我们参数的偏导数。因此,我们需要从相应的 Python 变量调用 backward() 方法,例如 loss.backward()

**梯度**的**实际值**是多少?我们可以通过查看张量的 grad **属性**来检查它们。

如果你查看该方法的文档,它清楚地说明**梯度是累积的**。因此,每当我们使用**梯度**来**更新**参数时,都需要**在之后将梯度清零**。这就是 zero_() 的作用。

**方法名称末尾的下划线 (_)** 是什么意思?你还记得吗?如果忘了,请翻到上一节查找。

所以,让我们**放弃手动计算梯度**,转而使用 backward()zero_() 方法。

就这样吗?嗯,差不多……但是,总是有一个**问题**,这次与**参数的更新**有关……

lr = 1e-1
n_epochs = 1000

torch.manual_seed(42)
a = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)
b = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)

for epoch in range(n_epochs):
    # Step 1
    yhat = a + b * x_train_tensor
    # Step 2
    error = y_train_tensor - yhat
    loss = (error ** 2).mean()     

    # No more manual computation of gradients! 
    # a_grad = -2 * error.mean()
    # b_grad = -2 * (x_tensor * error).mean()

    # Step 3
    # We just tell PyTorch to work its way BACKWARDS from the specified loss!
    loss.backward()
    # Let's check the computed gradients...
    # print(a.grad)
    # print(b.grad)
    
    # What about UPDATING the parameters? Not so fast...
    
    # FIRST ATTEMPT
    # AttributeError: 'NoneType' object has no attribute 'zero_'
    # a = a - lr * a.grad
    # b = b - lr * b.grad
    # print(a)

    # SECOND ATTEMPT
    # RuntimeError: a leaf Variable that requires grad has been used in an in-place operation.
    # a -= lr * a.grad
    # b -= lr * b.grad        
    
    # THIRD ATTEMPT
    # We need to use NO_GRAD to keep the update out of the gradient computation
    # Why is that? It boils down to the DYNAMIC GRAPH that PyTorch uses...
    # Step 4
    with torch.no_grad():
        a -= lr * a.grad
        b -= lr * b.grad
    
    # PyTorch is "clingy" to its computed gradients, we need to tell it to let it go...
    a.grad.zero_()
    b.grad.zero_()
    
print(a, b)

在第一次尝试中,如果使用与 Numpy 代码中相同的更新结构,会得到下面**奇怪的错误**……但我们可以通过查看张量本身来推测发生了什么——我们再次在将更新结果重新分配给参数时**“丢失”了梯度**。因此,grad 属性变为 None,并引发错误……

# FIRST ATTEMPT
tensor([0.7518], device='cuda:0', grad_fn=<SubBackward0>)
AttributeError: 'NoneType' object has no attribute 'zero_'

然后我们稍作修改,在第二次尝试中使用了熟悉的**原地 Python 赋值**。然而,PyTorch 再次对此发出抱怨并引发了**错误**。

# SECOND ATTEMPT
RuntimeError: a leaf Variable that requires grad has been used in an in-place operation.

为什么?!结果是“**好事过头**”的一个例子。罪魁祸首是 PyTorch 的能力,它能从涉及任何**梯度计算张量或其依赖项**的**每个 Python 操作**中构建一个**动态计算图**。
我们将在下一节深入探讨动态计算图的内部工作原理。

那么,我们如何告诉 PyTorch“**退后一步**”,让我们**更新参数**而不扰乱其*花哨的动态计算图*呢?这就是 torch.no_grad() 的作用。它允许我们**在张量上执行常规的 Python 操作,独立于 PyTorch 的计算图**。

最后,我们成功地运行了我们的模型并得到了**结果参数**。果然,它们**与**我们在纯 Numpy 实现中得到的结果**匹配**。

# THIRD ATTEMPT
tensor([1.0235], device='cuda:0', requires_grad=True)
tensor([1.9690], device='cuda:0', requires_grad=True)

动态计算图

“不幸的是,没有人能被告知动态计算图是什么。你必须亲眼所见。”

墨菲斯

《黑客帝国》多棒啊,对吧?但是,开玩笑归开玩笑,我希望**你**也能**亲眼看看这个图**!

PyTorchViz 包及其make_dot(变量)方法允许我们轻松地可视化与给定 Python 变量相关的图。

因此,我们只保留**最基本的部分**:用于参数的两个(计算梯度的)**张量**、预测、误差和损失。

torch.manual_seed(42)
a = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)
b = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)

yhat = a + b * x_train_tensor
error = y_train_tensor - yhat
loss = (error ** 2).mean()

下图分别显示了在 yhaterrorloss 变量上调用 make_dot() 函数所对应的图。

image/png

让我们仔细看看它的组成部分

  • **蓝色框**:这些对应于我们用作**参数**的张量,即我们要求 PyTorch**计算梯度**的张量;
  • **灰色框**:涉及**梯度计算张量或其依赖项**的**Python 操作**;
  • **绿色框**:与灰色框相同,但它是梯度**计算的起点**(假设从用于**可视化**图的**变量**调用了 backward() 方法)——它们在图中**自下而上**计算。

如果绘制 error(中)和 loss(右)变量的图,它们与第一个图的**唯一区别**在于**中间步骤(灰色框)**的数量。

现在,仔细看看**最左边图**的**绿色框**:有**两支箭头**指向它,因为它正在**将两个变量相加**,ab*x。看起来很明显,对吧?

然后,看看同一张图的**灰色框**:它正在执行**乘法**,即 b*x。但只有**一支箭头**指向它!这支箭头来自与我们的**参数 b** 对应的**蓝色框**。

为什么我们没有数据 x 的框呢?答案是:我们不为其计算梯度!所以,尽管计算图所执行的操作中涉及更多的张量,但它只显示计算梯度的张量及其依赖项。

如果我们将**参数 a** 的 requires_grad 设置为 False,计算图会发生什么变化?

a_nograd = torch.randn(1, requires_grad=False, dtype=torch.float, device=device)
b = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)
yhat2 = a_nograd + b * x_train_tensor
make_dot(yhat2)

image/png

不出所料,对应**参数 a** 的**蓝色框**不见了!很简单:**没有梯度,就没有图**。

动态计算图**最棒**的地方在于你可以让它**随心所欲地复杂**。你甚至可以使用*控制流语句*(例如,if 语句)来**控制梯度流**(显然!):-)

下图显示了此示例。是的,我知道这个计算本身是*完全没有意义的*……

a = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)
b = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)
yhat = a + b * x_train_tensor
error = y_train_tensor - yhat
loss = (error ** 2).mean()

if loss > 0:
    yhat2 = b * x_train_tensor
    error2 = y_train_tensor - yhat2
loss += error2.mean()
make_dot(loss)

image/png

优化器

到目前为止,我们一直**手动更新**参数,使用计算出的梯度。对于*两个参数*来说,这可能没问题……但如果我们有**大量参数**呢?!我们可以使用 PyTorch 的**优化器**之一,例如 SGD 或 Adam。

优化器接收我们要更新的**参数**、要使用的**学习率**(可能还有许多其他超参数!),并通过其 step() 方法**执行更新**。

此外,我们也不再需要一个接一个地清零梯度了。我们只需调用优化器的 zero_grad() 方法即可!

在下面的代码中,我们创建了一个随机梯度下降(SGD)优化器来更新我们的参数 ab

不要被优化器的**名称**所迷惑:如果我们一次性使用**所有训练数据**进行更新——就像我们在代码中实际做的那样——那么优化器正在执行**批量**梯度下降,尽管它的名称是 SGD。

torch.manual_seed(42)
a = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)
b = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)
print(a, b)

lr = 1e-1
n_epochs = 1000

# Defines a SGD optimizer to update the parameters
optimizer = optim.SGD([a, b], lr=lr)

for epoch in range(n_epochs):
    # Step 1
    yhat = a + b * x_train_tensor
    # Step 2
    error = y_train_tensor - yhat
    loss = (error ** 2).mean()
    # Step 3
    loss.backward()    
    
    # No more manual update!
    # with torch.no_grad():
    #     a -= lr * a.grad
    #     b -= lr * b.grad
    # Step 4
    optimizer.step()
    
    # No more telling PyTorch to let gradients go!
    # a.grad.zero_()
    # b.grad.zero_()
    optimizer.zero_grad()
    
print(a, b)

让我们检查一下我们的两个参数,在训练前后,只是为了确保一切仍然正常运行。

# BEFORE: a, b
tensor([0.1940], device='cuda:0', requires_grad=True) tensor([0.1391], device='cuda:0', requires_grad=True)
# AFTER: a, b
tensor([1.0235], device='cuda:0', requires_grad=True) tensor([1.9690], device='cuda:0', requires_grad=True)

太棒了!我们已经“*优化*”了**优化**过程:-)还剩下什么呢?

损失

我们现在来处理**损失计算**。正如所料,PyTorch 又为我们提供了支持。根据手头的任务,有许多损失函数可供选择。由于我们的是回归任务,我们使用的是均方误差(MSE)损失。

请注意n.MSELoss实际上是为我们**创建了一个损失函数**——**它本身并不是损失函数**。此外,你可以指定一个**归约方法**来应用,也就是说,你**希望如何聚合单个点的结果**——你可以对它们进行平均(reduction='mean')或者简单地将它们求和(reduction='sum').

然后我们在后面,在第 20 行,使用创建的损失函数来计算给定我们的预测和标签的损失。

我们的代码现在看起来是这样的

torch.manual_seed(42)
a = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)
b = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)
print(a, b)

lr = 1e-1
n_epochs = 1000

# Defines a MSE loss function
loss_fn = nn.MSELoss(reduction='mean')

optimizer = optim.SGD([a, b], lr=lr)

for epoch in range(n_epochs):
    # Step 1
    yhat = a + b * x_train_tensor
    # No more manual loss!
    # error = y_tensor - yhat
    # loss = (error ** 2).mean()
    # Step 2
    loss = loss_fn(y_train_tensor, yhat)   # Line 20
    # Step 3
    loss.backward()
    # Step 4
    optimizer.step()
    optimizer.zero_grad()
    
print(a, b)
tensor([0.1940], device='cuda:0', requires_grad=True) tensor([0.1391], device='cuda:0', requires_grad=True)
tensor([1.0235], device='cuda:0', requires_grad=True) tensor([1.9690], device='cuda:0', requires_grad=True)

目前,只剩下最后一段代码需要修改:**预测**。现在是时候介绍 PyTorch 实现……的方式了。

模型

在 PyTorch 中,模型由一个继承自 Module 类的常规**Python 类**表示。

它需要实现的最基本方法是:

  • __init__(self):**它定义了构成模型的各个部分**——在我们的例子中,是两个参数,ab

不过,你**不限于定义参数**……**模型也可以包含其他模型(或层)作为其属性**,因此你可以轻松地嵌套它们。我们很快也会看到一个例子。

  • forward(self, x):它执行**实际计算**,也就是说,它在给定输入 x 的情况下**输出一个预测**。

不过,你**不应该调用 forward(x)** 方法。你应该直接调用整个模型,就像这样:模型(x)来执行前向传播并输出预测。

让我们为我们的回归任务构建一个合适的(但简单的)模型。它应该看起来像这样:

class ManualLinearRegression(nn.Module):
    def __init__(self):
        super().__init__()
        # To make "a" and "b" real parameters of the model, we need to wrap them with nn.Parameter
        self.a = nn.Parameter(torch.randn(1, requires_grad=True, dtype=torch.float))
        self.b = nn.Parameter(torch.randn(1, requires_grad=True, dtype=torch.float))
        
    def forward(self, x):
        # Computes the outputs / predictions
        return self.a + self.b * x

__init__ 方法中,我们使用 Parameter() 类定义了**两个参数 *a* 和 *b***,以告诉 PyTorch 这些**张量应该被视为它们所属模型的参数**。

我们为什么要关心这个呢?通过这样做,我们可以使用模型的 parameters() 方法来检索**模型所有参数的迭代器**,甚至是**嵌套模型**的参数,我们可以用它来供给我们的优化器(而不是自己构建一个参数列表!)。

此外,我们可以使用模型的 state_dict() 方法获取**所有参数的当前值**。

**重要提示**:我们需要**将模型发送到与数据相同的设备**。如果我们的数据由 GPU 张量组成,那么我们的模型也必须“生活”在 GPU 内部。

我们可以使用所有这些便捷的方法来修改我们的代码,它应该看起来像这样:

torch.manual_seed(42)

# Now we can create a model and send it at once to the device
model = ManualLinearRegression().to(device)
# We can also inspect its parameters using its state_dict
print(model.state_dict())

lr = 1e-1
n_epochs = 1000

loss_fn = nn.MSELoss(reduction='mean')
optimizer = optim.SGD(model.parameters(), lr=lr)

for epoch in range(n_epochs):
    # What is this?!?
    model.train()

    # No more manual prediction!
    # yhat = a + b * x_tensor
    # Step 1
    yhat = model(x_train_tensor)
    # Step 2
    loss = loss_fn(y_train_tensor, yhat)
    # Step 3
    loss.backward()
    # Step 4
    optimizer.step()
    optimizer.zero_grad()
    
print(model.state_dict())

现在,打印出的语句将是这样的——参数 ab 的最终值仍然相同,所以一切正常:-)

OrderedDict([('a', tensor([0.3367], device='cuda:0')), ('b', tensor([0.1288], device='cuda:0'))])
OrderedDict([('a', tensor([1.0235], device='cuda:0')), ('b', tensor([1.9690], device='cuda:0'))])

我希望你注意到了代码中一个我标记为“这是什么?!”的特定语句——model.train()

在 PyTorch 中,模型有一个训练()方法,有点令人失望的是,它**并不执行训练步骤**。它的唯一目的是**将模型设置为训练模式**。为什么这很重要?有些模型可能会使用类似**Dropout**这样的机制,它们在**训练和评估**阶段有**不同的行为**。Dropout,举例来说,在**训练和评估**阶段具有**不同的行为**。

嵌套模型

在我们的模型中,我们手动创建了两个参数来执行线性回归。现在让我们将 PyTorch 的 Linear 模型作为我们自己的模型的属性,从而创建一个嵌套模型。

尽管这显然是一个牵强的例子,因为我们几乎只是在封装底层模型,而没有向其添加任何有用的(或根本没有!)东西,但它很好地说明了这个概念。

__init__ 方法中,我们创建了一个包含**嵌套 Linear 模型**的**属性**。

forward() 方法中,我们**直接调用嵌套模型**来执行前向传播(*请注意,我们**没有**调用 self.linear.forward(x)*!)。

class LayerLinearRegression(nn.Module):
    def __init__(self):
        super().__init__()
        # Instead of our custom parameters, we use a Linear layer with single input and single output
        self.linear = nn.Linear(1, 1)
                
    def forward(self, x):
        # Now it only takes a call to the layer to make predictions
        return self.linear(x)

现在,如果我们调用此模型的 parameters() 方法,**PyTorch 将以递归方式识别其属性的参数**。你可以自己尝试使用类似 [*LayerLinearRegression().parameters()] 的代码来获取所有参数的列表。你还可以添加新的 Linear 属性,即使你根本没有在前向传播中使用它们,它们仍然会列在 parameters() 下。

顺序模型

我们的模型足够简单……你可能会想:“*为什么要费心为它构建一个类呢?!*” 嗯,你说的有道理……

对于**直观模型**,使用**常规层**,其中一个层的输出依次作为下一个层的输入,我们可以使用一个,呃……**Sequential** 模型 :-)

在我们的例子中,我们将构建一个 Sequential 模型,它只有一个参数,那就是我们用于训练线性回归的 Linear 层。该模型将如下所示:

# Alternatively, you can use a Sequential model
torch.manual_seed(42)
model = nn.Sequential(nn.Linear(1, 1)).to(device)

够简单吧?

训练步骤

到目前为止,我们已经定义了一个**优化器**、一个**损失函数**和一个**模型**。向上滚动一点,快速查看*循环内部*的代码。如果我们使用**不同的优化器、损失函数,甚至是模型**,它会**改变**吗?如果不会,我们如何使其**更通用**?

嗯,我想我们可以说所有这些代码行都**执行一个训练步骤**,给定那**三个元素**(*优化器、损失和模型*)、**特征**和**标签**。

那么,我们能不能**编写一个函数,它接收这三个元素**并**返回另一个执行训练步骤的函数**,该函数以一组特征和标签作为参数并返回相应的损失?

然后我们可以使用这个通用函数来构建一个 train_step() 函数,以便在我们的训练循环中调用。现在我们的代码应该看起来像这样……看看训练循环现在多么**小巧**?

def make_train_step(model, loss_fn, optimizer):
    # Builds function that performs a step in the train loop
    def train_step(x, y):
        # Sets model to TRAIN mode
        model.train()
        # Step 1: Makes predictions
        yhat = model(x)
        # Step 2: Computes loss
        loss = loss_fn(y, yhat)
        # Step 3: Computes gradients
        loss.backward()
        # Step 4: Updates parameters and zeroes gradients
        optimizer.step()
        optimizer.zero_grad()
        # Returns the loss
        return loss.item()
    
    # Returns the function that will be called inside the train loop
    return train_step

# Starts from scratch
lr = 1e-1
n_epochs = 1000
model = nn.Sequential(nn.Linear(1, 1)).to(device)
loss_fn = nn.MSELoss(reduction='mean')
optimizer = optim.SGD(model.parameters(), lr=lr)

# Creates the train_step function for our model, loss function and optimizer
train_step = make_train_step(model, loss_fn, optimizer)
losses = []

# For each epoch...
for epoch in range(n_epochs):
    # Performs one train step and returns the corresponding loss
    loss = train_step(x_train_tensor, y_train_tensor)
    losses.append(loss)
    
# Checks model's parameters
print(model.state_dict())
OrderedDict([('0.weight', tensor([[1.9690]], device='cuda:0')), ('0.bias', tensor([1.0235], device='cuda:0'))])

让我们暂停一下训练循环,暂时把注意力放在**数据**上……到目前为止,我们只使用了由 Numpy 数组转换而来的**PyTorch 张量**。但我们可以做得更好,我们可以构建一个……

数据集

在 PyTorch 中,**数据集**由一个继承自**Dataset**类的常规**Python 类**表示。你可以将其视为一种 Python **元组列表**,每个元组对应**一个数据点(特征,标签)**。

它需要实现的最基本方法是:

  • __init__(self):它接收构建**元组列表**所需的**任何参数**——它可能是一个将被加载和处理的 CSV 文件名;它可能是*两个张量*,一个用于特征,另一个用于标签;或者任何其他,取决于手头的任务。

**不需要在构造方法中加载整个数据集**(__init__)。如果你的**数据集很大**(例如,数万个图像文件),一次性加载它会降低内存效率。建议**按需加载**(无论何时调用__get_item__)。

  • __get_item__(self, index):它允许数据集被**索引**,使其像列表一样工作(dataset[i])——它必须**返回一个元组(特征,标签)**,对应于请求的数据点。我们可以返回我们**预加载**的数据集或张量的**相应切片**,或者,如上所述,**按需加载**(例如这个例子)。

  • __len__(self):它应该简单地返回整个数据集的**大小**,这样无论何时采样,其索引都限制在实际大小。

让我们构建一个简单的自定义数据集,它以两个张量作为参数:一个用于特征,一个用于标签。对于任何给定的索引,我们的数据集类将返回这些张量中每个的相应切片。它应该看起来像这样:

from torch.utils.data import Dataset, TensorDataset

class CustomDataset(Dataset):
    def __init__(self, x_tensor, y_tensor):
        self.x = x_tensor
        self.y = y_tensor
        
    def __getitem__(self, index):
        return (self.x[index], self.y[index])

    def __len__(self):
        return len(self.x)

# Wait, is this a CPU tensor now? Why? Where is .to(device)?
x_train_tensor = torch.from_numpy(x_train).float()
y_train_tensor = torch.from_numpy(y_train).float()

train_data = CustomDataset(x_train_tensor, y_train_tensor)
print(train_data[0])

train_data = TensorDataset(x_train_tensor, y_train_tensor)
print(train_data[0])

你可能又在想:“*为什么要费这么大劲把两个张量封装在一个类里?*” 而且,你又说对了……如果一个数据集只是**两个张量**,我们可以使用 PyTorch 的 TensorDataset 类,它会做我们上面自定义数据集中所做的大部分事情。

你有没有注意到我们从 Numpy 数组构建了**训练张量**,但是我们**没有将它们发送到设备**?所以,它们现在是**CPU**张量!**为什么?**
我们**不希望将整个训练数据加载到 GPU 张量中**,就像我们到目前为止在示例中所做的那样,因为它会**占用**我们宝贵的**显存**。

好吧,那么,我们为什么要构建数据集呢?我们这样做是因为我们想使用一个……

DataLoader

到目前为止,我们在每个训练步骤中都使用了**整个训练数据**。这始终是**批量梯度下降**。对于我们*小得可笑的数据集*来说,这当然没问题,但如果我们想认真对待这件事,我们**必须使用小批量**梯度下降。因此,我们需要小批量。因此,我们需要相应地**切片**我们的数据集。你想手动完成吗?!我也不想!

所以我们使用 PyTorch 的 DataLoader 类来完成这项工作。我们告诉它要使用哪个**数据集**(我们刚刚在前一节中构建的那个)、所需的**小批量大小**以及是否要**打乱**它。就是这样!

我们的**加载器**将像一个**迭代器**一样,所以我们可以**循环遍历它**,每次**获取不同的小批量**。

from torch.utils.data import DataLoader

train_loader = DataLoader(dataset=train_data, batch_size=16, shuffle=True)

要检索一个样本小批量,只需运行以下命令——它将返回一个包含两个张量的列表,一个用于特征,另一个用于标签。

next(iter(train_loader))

这如何改变我们的训练循环?让我们来看看!

# Starts from scratch
lr = 1e-1
n_epochs = 1000
torch.manual_seed(42)
model = nn.Sequential(nn.Linear(1, 1)).to(device)
loss_fn = nn.MSELoss(reduction='mean')
optimizer = optim.SGD(model.parameters(), lr=lr)

losses = []
train_step = make_train_step(model, loss_fn, optimizer)

for epoch in range(n_epochs):
    for x_batch, y_batch in train_loader:
        # the dataset "lives" in the CPU, so do our mini-batches
        # therefore, we need to send those mini-batches to the
        # device where the model "lives"
        x_batch = x_batch.to(device)
        y_batch = y_batch.to(device)
        
        loss = train_step(x_batch, y_batch)
        losses.append(loss)
        
print(model.state_dict())
OrderedDict([('0.weight', tensor([[1.9696]], device='cuda:0')), ('0.bias', tensor([1.0243], device='cuda:0'))])

现在有两点不同:我们不仅有一个*内循环*来从 DataLoader 中加载每个*小批量*,更重要的是,我们现在**只将一个**小批量发送到设备。

对于更大的数据集,使用 Dataset 的方法**逐个样本加载数据**(到 CPU 张量),然后将属于同一个**小批量**的**所有样本一次性发送到 GPU**(设备),是**充分利用显存**的最佳方法。__get_item__以及然后将属于同一个**小批量**的**所有样本一次性发送到 GPU**(设备),这是**充分利用显存**的最佳方法。
此外,如果你有**多个 GPU** 来训练你的模型,最好让你的数据集“不可知”,并在训练期间将批次分配给不同的 GPU。

到目前为止,我们只关注了**训练数据**。我们为它构建了一个数据集和一个数据加载器。我们也可以为**验证**数据做同样的事情,使用我们在这篇文章开头执行的**分割**……或者我们可以使用 random_split

随机分割

PyTorch 的 random_split() 方法是一种简单且常见的方式来执行**训练-验证分割**。请记住,在我们的例子中,我们需要将其应用于**整个数据集**(*而不是我们两节前构建的训练数据集*)。

然后,对于每个数据子集,我们构建一个相应的 DataLoader,所以我们的代码看起来像这样:

from torch.utils.data.dataset import random_split

x_tensor = torch.from_numpy(x).float()
y_tensor = torch.from_numpy(y).float()

dataset = TensorDataset(x_tensor, y_tensor)

train_dataset, val_dataset = random_split(dataset, [80, 20])

train_loader = DataLoader(dataset=train_dataset, batch_size=16)
val_loader = DataLoader(dataset=val_dataset, batch_size=20)

现在我们有了**验证集的数据加载器**,所以,理所当然地,我们将它用于……

评估

这是我们旅程的**最后**一部分——我们需要修改训练循环以包含**模型评估**,即计算**验证损失**。第一步是包含另一个内循环来处理来自*验证加载器*的*小批量*,并将它们发送到与模型相同的设备。接下来,我们使用模型进行**预测**(第 28 行)并计算相应的**损失**(第 30 行)。

差不多就是这样了,但还有**两件小而重要**的事情需要考虑:

  • torch.no_grad():尽管在我们的简单模型中这不会产生任何区别,但**将验证**内循环用这个**上下文管理器包裹起来是一个好习惯,以禁用任何你可能无意中触发的梯度计算**——**梯度属于训练**,而不属于验证步骤;

从 1.9 版本开始,PyTorch 提供了一个**新的上下文管理器**:torch.inference_mode()。它也禁用梯度计算,但更进一步,同时**禁用 PyTorch 的内部视图跟踪**,从而提供更好的性能。然而,在我们这里使用的示例中,差异可以忽略不计。

  • eval():它唯一的作用是**将模型设置为评估模式**(就像它的 train() 对应方法一样),以便模型可以根据一些操作(如 Dropout)调整其行为。

现在,我们的训练循环应该看起来像这样

# Starts from scratch
lr = 1e-1
n_epochs = 1000
torch.manual_seed(42)
model = nn.Sequential(nn.Linear(1, 1)).to(device)
loss_fn = nn.MSELoss(reduction='mean')
optimizer = optim.SGD(model.parameters(), lr=lr)

losses = []
val_losses = []
train_step = make_train_step(model, loss_fn, optimizer)

for epoch in range(n_epochs):
    for x_batch, y_batch in train_loader:
        x_batch = x_batch.to(device)
        y_batch = y_batch.to(device)

        loss = train_step(x_batch, y_batch)
        losses.append(loss)
        
    with torch.no_grad():
        for x_val, y_val in val_loader:
            x_val = x_val.to(device)
            y_val = y_val.to(device)
            # Evaluation only performs Steps 1 and 2
            model.eval()
            # Step 1
            yhat = model(x_val)              # Line 28
            # Step 2
            val_loss = loss_fn(y_val, yhat)  # Line 30
            val_losses.append(val_loss.item())

print(model.state_dict())
OrderedDict([('0.weight', tensor([[1.9586]], device='cuda:0')), ('0.bias', tensor([1.0195], device='cuda:0'))])

还有**其他**我们可以改进或更改的地方吗?当然,模型总是可以添加更多东西——例如,使用**学习率调度器**。但是这篇文章已经*太长了*,所以我就到此为止了。

“*哪里有带所有花里胡哨功能的完整可用代码?*” 你问?你可以在 GitHub gist 上找到它:这里

最终思考

尽管这篇文章有点长,但我不会做任何改变——我相信它包含了**大多数必要的步骤**,一个人需要通过这些步骤才能**以结构化和渐进的方式学习**如何**使用 PyTorch 开发深度学习模型**。

希望在完成本文中所有代码的学习后,您能够更好地理解并更轻松地通过 PyTorch 官方教程

你喜欢这篇文章吗?你可以在我的系列丛书中了解更多关于 PyTorch 基础、计算机视觉和自然语言处理的知识:《深度学习与 PyTorch 逐步指南:初学者指南》。

订阅 关注 连接

社区

注册登录发表评论