[PyTorch] 译+注:一个例子,让你明白PyTorch框架

写在前面的话:

虽然一直在使用PyTorch框架,但是对于PyTorch框架的思路还是有点不太清楚。所以就一直在等待一个契机,能好好梳理一下PyTorch框架的思路。本文便是出于这样的目的。

本文是在《Understanding PyTorch with an example: a step-by-step tutorial》的基础上进行的。在对其进行翻译的同时,删去了一些无关紧要的内容以使得内容更加连续,同时在 (·) 中加入了一些自己的见解,并以斜体 表示。

整体来说,本文确实是step-by-step,它从我们常用的numpy和简单的线性回归讲起,先讲解numpy实现,然后转向tensor实现,并逐步使用PyTorch框架替代自己写的代码,把PyTorch的核心模块讲的很明白。

在最后,本文给出使用PyTorch搭建深度学习平台的基本代码baseline。

文章有两张图来自瑾er的一篇文章:一文理解PyTorch:附代码实例。这篇文章其实也是对《Understanding PyTorch with an example: a step-by-step tutorial》的翻译,但是一些关键的地方并没有解释清楚,给我一种机翻的感觉,这也能算是我写这篇文章的另一个motivation吧。
最后,这篇文章的原文写的真的好,英文的原版放到最后了。


Introduction

PyTorch是增长最快的深度学习框架 (较早的论文中使用的深度学习框架是TensotFlow,近些年的论文中PyTorch用的越来越多) ,而且它对Python匹配得很好。


Motivation

既然已经有了很多PyTorch教程,其文档也非常完整和广泛 (查阅 PyTorch 相关资料的时候,还是推荐官网 ) , 那你为什么要继续阅读这个step-by-step的教程吗?

在本文中,将展示PyTorch如何使Python能够更轻松,更直观地构建深度学习模型——autograd,dynamic computation graph,model classes等,以及如何避免一些常见的陷阱和错误。


Table of Contents

  • A Simple Regression Problem
  • Gradient Descent
  • Linear Regression in Numpy
  • PyTorch
  • Autograd
  • Dynamic Computation Graph
  • Optimizer
  • Loss
  • Model
  • Dataset
  • DataLoader
  • Evaluation

A Simple Regression Problem (一个简单的线性回归)

许多教程用图像分类问题来展示如何使用PyTorch,这确实挺酷的,但我认为这使得你错过了主要目标:PyTorch是如何工作的?

因此,本教程通篇使用一个简单熟悉的例子:单特征x的线性回归 (即一元一次多项式)
y = a + b x + ϵ y = a + b x + \epsilon y=a+bx+ϵ
( ϵ \epsilon ϵ 为高斯噪声 )

Data Generation (生成数据)

生成一些数据:我们生成一个100点的向量,作为特征 x x x,设定上式中的 a = 1 a=1 a=1 , b = 2 b=2 b=2 来生成标签,并加入高斯噪声。

之后,将生成的数据分割训练集验证集,打乱数组的索引 (即,将训练集和验证集随机排序 ) ,并使用前80个点作为训练集。

# 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

在这里插入图片描述


Gradient Descent

关于梯度下降算法,本文限于篇幅无法完全介绍清楚梯度下降如何工作。

这里只简单介绍梯度下降的四个基本步骤

Step 1: Compute the Loss

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

值得一提的是,如果我们使用训练集(N)中的所有点来计算损失,我们是执行批量梯度下降 (batch gradient descent)。如果我们每次都用一个点,就是随机梯度下降 (stochastic gradient descent)。在1和N之间的n是小批量梯度下降 (mini­batch gradient descent)

M S E = 1 N ∑ i = 1 N ( y i − y ^ i ) 2 MSE = \frac1N \sum^N_{i=1} (y_i - \widehat{y}_i)^2 MSE=N1i=1N(yiy i)2

M S E = 1 N ∑ i = 1 N ( y i − a − b x i ) 2 MSE = \frac1N \sum^N_{i=1} (y_i - a - bx_i)^2 MSE=N1i=1N(yiabxi)2

(上面两个式子中的 M S E MSE MSE 其实是损失 l o s s loss loss )

Step 2: Compute the Gradients

梯度偏导数,为什么偏导数呢?因为它对于 (with respect to, 论文中简写为w.r.t) 单一参数来计算的。我们有两个参数,a和b,所以我们必须计算两个偏导。

当稍微改变一些其他值时,导数会反映给定值有多少变化 (即,自变量变化时,因变量变化的程度 ) 。在我们的例子中,偏导数反映了当我们改变a, b两个参数中的每一个时,均方误差会有多大的变化。

下面等式的最右边,是在简单线性回归下实现的梯度下降。中间部分通过链式法则 (即,求导的链式法则 ) 显示了有关的所有元素,以展示最终表达式是如何得到的。
在这里插入图片描述

Step 3: Update the Parameters

在最后一步,我们使用梯度更新参数。因为我们试图最小化损失,所以参数将向负梯度的方向更新。

我们还需要考虑另一个参数:学习率lr,用 η \eta η 表示,它是在使用梯度来更新参数时的乘子
a = a − η ∂ M S E ∂ a b = b − η ∂ M S E ∂ b a = a - \eta \frac{\partial{MSE}} {\partial a} \\ b = b - \eta \frac{\partial{MSE}} {\partial b} a=aηaMSEb=bηbMSE
即使用计算得到的梯度和学习率来更新系数a和b。

(学习率其实是一个认为设定的超参数,一般来说不应该太大,也不应该太小。而且在实际训练中,先大后小是比较好的 ) 。关于如何选择学习率,由于篇幅限制,本文不再赘述。

借用瑾er的一张图片来可视化通过梯度来优化参数的过程。

在这里插入图片描述

Step 4: Rinse and Repeat!

现在,我们使用更新的参数重新回到步骤1并重新启动流程。

当训练集中所有的点都用来计算损失后,一个epoch完成 (即,所有的训练数据用于优化模型参数,称为1个epoch) 。

对于批量梯度下降 ( batch gradient descent) ,这个概念是多余的,因为它每次都对所有的训练样本计算损失,即1个epoch就等价于一次参数更新。对于随机梯度下降 (stochastic gradient descent) ,一个epoch等价于N次参数更新。对于小批量梯度下降 (mini­batch gradient descent) ,一个epoch等价于N/n次参数更新

重复多个epoch,即训练一个模型。


Linear Regression in Numpy

接下来在Numpy上通过梯度下降来实现线性回归模型。

之所以先使用Numpy而不是PyTorch,有两个目的

  • 展示整个流程的结构,以便于理解
  • 展示主要的痛点,以充分理解使用PyTorch的方便之处

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

  • 参数/权重的随机初始化 (这里的a和b)
  • 超参数的初始化 (这里的学习率和epoch的数量)

确保始终初始化随机数种子,以确保结果的可复现。一般来说,随机的种子是42,是所有随机种子中最不随机的:-)。

对于每个epoch,有4个训练步骤

  • 计算模型的预测,即正向传递 (forward pass)
  • 计算损失。使用预测和标签,以及当前任务下合适的损失函数
  • 计算每个参数的梯度
  • 更新参数

注意,如果不使用 batch gradient descent (示例中为 batch gradient descent ),则必须有一个内部循环来为每个点 (stochastic gradient descent) 或n个点 (mini­batch gradient descent) 来执行四个训练步骤 (即,每次训练为1个epoch,要把所有的训练数据过一遍 )。稍后会有一个mini­batch的示例。

# 随机初始化a和b
np.random.seed(42)
a = np.random.randn(1)
b = np.random.randn(1)

print(a, b)

# 设定学习率
lr = 1e-1
# 设定epochs
n_epochs = 1000

for epoch in range(n_epochs):
    # 计算模型输出
    yhat = a + b * x_train
    
    # 计算error
    error = (y_train - yhat)
    # 计算均方损失 MSE
    loss = (error ** 2).mean()
    
    # 对a,b分别计算梯度
    a_grad = -2 * error.mean()
    b_grad = -2 * (x_train * error).mean()
    
    # 根据学习率和梯度更新参数a,b
    a = a - lr * a_grad
    b = b - lr * b_grad
    
print(a, b)

# 安全测试: 得到的梯度下降结果是否正确?
from sklearn.linear_model import LinearRegression
linr = LinearRegression()
linr.fit(x_train, y_train)
print(linr.intercept_, linr.coef_[0])

结果为:

# 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]

Numpy的做法如上,下面就是PyTorch的做法。


PyTorch

首先要介绍一些基本概念,否则下面会看不懂。

在深度学习中,用到的都是张量。包括谷歌的框架也被称为TensorFlow。所以,什么是张量tensor?

Tensor

在Numpy中,3维数组array,严格来讲,就已经是tensor了。

一个标量scalar (单个数字) 有0维,一个向量vector 1维,一个矩阵matrix2维,一个张量tensor3维或更多。 但是,为了简单起见,我们通常也称向量和矩阵张量。即所有数据被分为两类:标量 or 张量。

在这里插入图片描述

以及瑾er的一张图:

在这里插入图片描述

Loading Data, Devices and CUDA

如何将Numpy的array转换为PyTorch的tensor?使用PyTorch中的from_numpy()的函数即可。但要注意,该函数返回值为CPU张量to()函数可以将一个CPU张量放到GPU上,实际上它把数据放到你指定的设备device上,包括你的GPU (referred to as cuda or cuda:0)。

“如果如果没有可用的GPU,我想让我的代码回退到CPU?” 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'

# numpy's array ==> pytorch's tensor,并将其放到相应device
x_train_tensor = torch.from_numpy(x_train).float().to(device)
y_train_tensor = torch.from_numpy(y_train).float().to(device)

# 通过type()查看区别
# since it also tells us WHERE the tensor is (device)
print(type(x_train), type(x_train_tensor), x_train_tensor.type())

比较两个变量的类型,x_trainnumpy.ndarrayx_train_tensortorch.Tensor

使用PyTorch的type(),会显示变量的位置 (即,CPU or CUDA)。

反过来,使用numpy()将tensor转换回Numpy的array。但是注意,只能将CPU上的tensor转换为array,而GPU上的不行,要先使用cpu()将tensor放到CPU。具体的报错信息如下:

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

(而实际中,将用于计算的tensor转换为numpy的array用的是:tensor.cpu().detach().numpy(),因为tensor一般也在计算图中 )。

Creating Parameters

如何区分用作data的tensor、用作可训练的parameter/weight的tensor?

后一个张量需要计算它的梯度,以更新它们的值 (即,parameters’ values)。这就是requires_grad=True参数的作用。它告诉PyTorch我们想让它为我们计算梯度。

# FIRST
# 随机初始化a和b,由于想要计算关于两个参数的梯度, 我们需要 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)

# SECOND
# 如果想要在GPU上运行程序,我们需要将其发送到对应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...

# THIRD
# 我们可以创建常规张量,并将其发送到对应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)
# 然后对其设置 requiring gradients...
a.requires_grad_()
b.requires_grad_()
print(a, b)

第一段代码块为我们的parameters、gradients and all 创建了很好的张量。但它们是CPU张量。

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

第二段代码中,尝试将它们发送到GPU的简单方法。我们成功地将它们发送到对应设备上,但是不知怎么地“丢失”了梯度……

(注意,这里是对parameter来说的。用于计算的tensor (即data) 不用设定requires_grad=True,因为它们不在计算图中,对于这种data,直接to(device)就行了 )

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

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

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

在PyTorch中,每个以下划线_结尾的方法都是 in-place 的改变,这意味着它们将修改底层变量。

尽管最后一种方法也能用,但最好在张量创建时将device分配给它们

(即,提前计算出device,然后创建parameter时候就直接to(device))

# 推荐!!!在创建parameter的时候就将其放到相应的设备上
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.6226], device='cuda:0', requires_grad=True) 
# tensor([1.4505], device='cuda:0', requires_grad=True)

知道了如何创建需要梯度的张量 (即parameter) ,下面就是PyTorch如何处理它们。


Autograd

Autograd是PyTorch的自动微分包。多亏了它,我们不需要担心偏导,链条规则之类的东西,直接使用backward()即可计算梯度 (即,反向传播过程 )。

一开始我们计算梯度的原因,是因为要计算损失对于parameters的偏导。因此,我们对相应的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):
    yhat = a + b * x_train_tensor
    error = y_train_tensor - yhat
    loss = (error ** 2).mean()

    # 不再手工计算梯度
    # a_grad = -2 * error.mean()
    # b_grad = -2 * (x_tensor * error).mean()
    
    # 只需要告诉PyTorch对特定的loss进行backward
    loss.backward()
    # 来检验一下梯度...
    print(a.grad)
    print(b.grad)
    
    # 怎么更新参数呢? 还没那么快...
    
    # 第一次尝试
    # AttributeError: 'NoneType' object has no attribute 'zero_'
    # a = a - lr * a.grad
    # b = b - lr * b.grad
    # print(a)

    # 第二次尝试
    # RuntimeError: a leaf Variable that requires grad has been used in an in-place operation.
    # a -= lr * a.grad
    # b -= lr * b.grad        
    
    # 第三次尝试
    # 我们需要使用 NO_GRAD 来保持更新操作免于计算梯度
    # 为什么? 这归结为PyTorch使用的动态图...
    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代码相同的更新结构,我们会得到下面的奇怪的报错…但是我们可以通过查看tensor本身来了解发生了什么——在将更新结果重新分配给我们的参数的同时,我们再次“失去”了梯度。因此,grad属性为None,它会引发错误…

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

然后,我们稍微更改一下,在第二次尝试中使用熟悉的 in-place Python赋值。而且,PyTorch再一次报错。

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

为什么?!这是一个“ too much of a good thing ”的例子。罪魁祸首是PyTorch的能力,它能够从每一个涉及任何要计算的梯度张量其依赖项Python操作中构建一个动态计算图

在下一节中,我们将深入讨论动态计算图的内部工作方式。

那么,我们如何告诉PyTorch “ back off ” 并让我们更新参数,而不影响它的动态计算图呢?这就是torch.no_grad()的用处。no_grad()的好处,它允许我们对tensor执行常规的Python操作,而不影响PyTorch的计算图

最后,我们成功地运行了模型并获得了结果参数。当然,它们与我们在纯numpy实现中得到的那些差不多。

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

Dynamic Computation Graph

“Unfortunately, no one can be told what the dynamic computation graph is. You have to see it for yourself.” ——Morpheus

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

(详见另一篇博客:[Python] 绘制Python代码的函数调用关系:graphviz+pycallgraph)

关于静态图动态图,引用瑾er的一段话:

目前神经网络框架分为静态图框架和动态图框架,PyTorch 和 TensorFlow、Caffe 等框架最大的区别就是他们拥有不同的计算图表现形式。 TensorFlow 使用静态图,这意味着我们先定义计算图,然后不断使用它,而在 PyTorch 中,每次都会重新构建一个新的计算图

对于使用者来说,两种形式的计算图有着非常大的区别,同时静态图和动态图都有他们各自的优点,比如动态图比较方便debug,使用者能够用任何他们喜欢的方式进行debug,同时非常直观,而静态图是通过先定义后运行的方式,之后再次运行的时候就不再需要重新构建计算图,所以速度会比动态图更快。

从最简单的开始:两个要对parameters、predictions、errors和loss计算梯度的tensor

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()

调用make_dot(yhat),我们将得到下图中最左边的图形:

在这里插入图片描述

让我们仔细看看其组成部分:

  • 蓝方框:这些对应于我们用作parameters张量,即要求PyTorch计算梯度的张量;
  • 灰箱:包含要计算梯度的张量或其依赖量Python操作
  • 绿色方框:与灰色方框相同,只是它是计算梯度的起点——它们是从图形中的自底向上计算的。

如果我们为error (中间图) 和loss (右边图) 绘制图形,那么它们与第一个变量之间的惟一区别就是中间步骤的数量 (灰色框)。

为什么我们没有 data x 的方框呢?答案是:我们不对它计算梯度!因此,即使计算图的操作涉及更多的张量,也只显示了要计算梯度的张量及其依赖量

如果我们将 parameters arequires_grad设为False,计算图会发生什么变化?

在这里插入图片描述

不出意外,与参数a对应的蓝色框消失了!很简单的道理:no gradients, no graph


Optimizer

到目前为止,我们一直在根据计算出的梯度手动更新参数。两个参数可能还好,但是如果我们有很多参数呢?我们使用PyTorch的一个优化器,比如SGDAdam

优化器会包含我们要更新的参数、要使用的学习率 (可能还有其他超参数!) 并通过其step()方法执行参数的更新

此外,我们也不需要一个一个地将梯度归零了。我们只需调用优化器的zero_grad()方法就可以!

在下面的代码中,我们使用随机梯度下降 (SGD) 优化器来更新参数a和b。

不要被优化器的名字所欺骗:如果我们一次使用所有的训练数据进行更新——就像我们在代码中实际做的那样——优化器执行的是批量梯度下降,而不是它的名字。

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

# 定义SGD优化器,以更新参数
optimizer = optim.SGD([a, b], lr=lr)

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

    loss.backward()    
    
    # 不再手动更新
    # with torch.no_grad():
    #     a -= lr * a.grad
    #     b -= lr * b.grad
    optimizer.step()
    
    # 不再对每个参数梯度置零
    # a.grad.zero_()
    # b.grad.zero_()
    optimizer.zero_grad()
    
print(a, b)

前后对比一下两个参数,以确保一切正常:

# BEFORE: a, b
tensor([0.6226], device='cuda:0', requires_grad=True) tensor([1.4505], 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)

We’ve optimized the optimization process 😃 What’s left?


Loss

PyTorch有了很多损失函数。损失函数的选择取决于手上的任务。在这个回归问题中,我们使用的是MSE损失。

注意nn.MSELoss实际上为我们创建了一个损失函数——而不是损失函数本身。此外,你还可以指定一个要应用的 reduction method,即如何聚合单个点的结果—你可以对它们取平均 (reduction = ' mean '),或者简单地对它们求和 (reduction = ' sum ')。

然后使用创建的损失函数,根据预测标签计算损失。

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

# 定义损失函数为MSE
loss_fn = nn.MSELoss(reduction='mean')

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

for epoch in range(n_epochs):
    yhat = a + b * x_train_tensor
    
    # 不再手动计算loss
    # error = y_tensor - yhat
    # loss = (error ** 2).mean()
    loss = loss_fn(y_train_tensor, yhat)

    loss.backward()    
    optimizer.step()
    optimizer.zero_grad()
    
print(a, b)

到现在,只有一小段代码需要更改:预测


Model

在PyTorch中,model由一个常规的Python类表示,该类继承自Module类。

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

  • __ init__(self) :它定义了组成模型的各部分 —在我们这里,是两个参数,a和b。

    但我们不仅仅能定义参数models还可以包含其他models (或layers)作为它的属性,以轻松地嵌套他们。我们稍后会看一个例子。

  • forward(self, x):它实现实际的计算 (即,前向传播过程 ),即对于给定输入x,它输出一个预测

    但你不应该直接调用 forward(x) 方法。你应该调用整个model,因为 model(x) 会执行前向传播和输出预测.

为我们的回归任务构建一个适当的 (但简单的) 模型。它应该是这样的:

class ManualLinearRegression(nn.Module):
    def __init__(self):
        super().__init__()
        # 使 "a" 和 "b" 作为模型的参数, 所以我们用 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):
        # 计算 outputs / predictions
        return self.a + self.b * x

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

我们为什么要在乎呢? 这样,我们可以使用模型的parameters()方法来检索所有模型参数(包括嵌套模型的那些参数)上的迭代器,我们可以使用这些迭代器来填充优化器(而不是自己构建参数列表!)。

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

重要model所在的device应该和data相同。如果tensor为GPU,我们的莫model也应该在GPU上。

我们可以使用所有这些方便的方法来改变我们的代码,应该是这样的:

torch.manual_seed(42)

# 现在我们可以创建一个model,并立即将其放到相应的device上。
model = ManualLinearRegression().to(device)
# 通过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):
    # 将模型转为train形式
    model.train()

    # 不再手动计算预测
    # yhat = a + b * x_tensor
    yhat = model(x_train_tensor)
    
    loss = loss_fn(y_train_tensor, yhat)
    loss.backward()    
    optimizer.step()
    optimizer.zero_grad()
    
print(model.state_dict())

现在打印出来的语句将是这样的——参数a和参数b的最终值仍然相同,所以一切正常。

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'))])

在PyTorch中,模型有一个train()方法,但是它不执行训练步骤。其唯一作用是将模型设置为训练模式。为什么这很重要?有些模型可能使用Dropout机制,在training和evaluation阶段有不同的行为。

Nested Models

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

forward() 方法中,我们调用嵌套模型本身来实现前向传播 (注意,我们不是调用 self.linear.forward(x) !)。

class LayerLinearRegression(nn.Module):
    def __init__(self):
        super().__init__()
        # 相比于自定义参数,我们使用了单输入单输出的 Linear layer 
        self.linear = nn.Linear(1, 1)
                
    def forward(self, x):
        # 现在仅仅调用 layer 就可以计算预测
        return self.linear(x)

现在,如果我们调用这个模型的parameters()方法,PyTorch将以递归方式显示其属性的参数。你可以试试用类似于[*LayerLinearRegression().parameters()]的方法来获得所有参数的列表。你还可以添加新的 Linear 属性,即使在前向传递中不使用它们,它们会在parameters()下列出。

Sequential Models

我们的模型很简单……你可能会想:”为什么要这么麻烦地为它构建一个类呢?“好吧,你说的有道理…

对于简单的模型,这些模型使用逐层连接的结构,其中上一层的输出作为下一层的输入,即顺序模型:-)

在我们的例子中,我们使用单个声明构建一个序列模型,即训练线性回归的 Linear 层。模型应该是这样的:

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

非常简单,对吧?

Training Step

到目前为止,我们已经定义了optimizerloss functionmodel。如果我们使用不同的optimizerloss functionmodel,它会改变吗?如果不会,我们如何使它更通用

我们可以说所有代码是在给定这三个元素 (optimizerloss functionmodel) 、featurelabel 下,执行一个训练步骤。

那么,如何编写一个函数来获取这三个元素返回另一个函数来执行一个训练步骤,将一组 featurelabel 并返回相应的损失呢?

然后,我们可以使用这个 general-purpose 的函数来构建一个train_step()函数,用来在训练的循环中调用。现在我们的代码应该是这样的…看到训练循环有多简短了吗?

def make_train_step(model, loss_fn, optimizer):
    # 建立在训练循环中执行一步的函数
    def train_step(x, y):
        # 设定模型为训练模式
        model.train()
        # 计算预测(正向传播)
        yhat = model(x)
        # 计算损失
        loss = loss_fn(y, yhat)
        # 计算梯度(反向传播)
        loss.backward()
        # 更新参数 + 梯度置零
        optimizer.step()
        optimizer.zero_grad()
        # 返回损失
        return loss.item()
    
    # 返回 train_step 函数,以便在 train loop 中被调用
    return train_step

# 为我们的 model,loss function 和 optimizer 创建 train_step 函数,
train_step = make_train_step(model, loss_fn, optimizer)
losses = []

# For each epoch...
for epoch in range(n_epochs):
    # 执行一个训练步骤并返回相应损失
    loss = train_step(x_train_tensor, y_train_tensor)
    losses.append(loss)
    
# 检查模型参数
print(model.state_dict())

(但实话实说,在各种论文给出的开源实现中,没有这么写的……都是直接就是train_step()里面的内容,而不像上面这样以递归调用的形式实现。所以最后这点看不懂也没关系,虽然也不难看懂 )

现在把注意力放在我们的数据上…到目前为止,我们将Numpy数组转换成PyTorch张量。但我们可以做得更好,我们可以建立一个数据集。


Dataset

在PyTorch中,dataset由一个常规的Python类表示,该类继承自Dataset类。你可以把理解为一种Python元组的列表,每个元组对应于一个数据 (feature,label)

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

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

    无需在构造函数方法 (__init__) 中加载整个数据集。如果数据集很大 (例如数以万计的图像文件),则一次全部加载并不是内存高效的方法。推荐按照需要来加载它们 (即当 __get_item__被调用的时候 )。

  • get_item__(self, index):它允许数据集被索引,因此它可以像列表一样工作 (dataset[i]) ——它必须返回与请求的数据点对应的元组 (feature,label)。我们可以返回预加载的数据集或张量的相应切片,或者如上所述,按需加载它们 (如本例所示)。

  • __len__(self):它应该返回整个数据集的**大小。**无论什么时候采样它,它的索引都被限制在实际大小。

下面构建一个简单的自定义数据集,它接受两个张量作为参数:一个作为 features,一个作为 labels。对于任意给定的索引,我们的数据集会返回这两个张量的对应切片。它应该像这样:

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])

若在变量定义时没有指定device,则默认在CPU。

我们不希望所有的训练数据都变成GPU张量,因为会占用显卡的空间。

但是为什么要建立一个dataset呢?这是我们想使用…

DataLoader

直到现在,我们在每个训练步骤都使用了全部的训练数据,即batch梯度下降。

这对于我们的小得可笑的数据集没问题。但对于大数据集,我们必须使用 mini­batch 梯度下降,这就意味着我们需要对数据集进行分割。而分割的操作必然不能再手动完成,而要自动完成。

PyTorch’s DataLoader 类就是干这个的。我们需要告诉它使用的数据集、所需的 mini-batch 的 batch size,以及是否打乱顺序。

我们的 loader 工作原理和迭代器 iterator 类似。因此我们可以遍历它,并每次获取一个不同的 mini-­batch。

from torch.utils.data import DataLoader

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

这要如何整合到我们的 training loop 中呢?方法如下:

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

for epoch in range(n_epochs):
    for x_batch, y_batch in train_loader:
        # 数据集存在于GPU, 我们的mini-batch也应如此
        # 因此,我们需要将mini-batch发送到相应的device
        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())

有两件事不同了:我们不仅有一个内部循环来从DataLoader依次加载每个mini-batch;更重要的是,我们现在只发送一个mini-batch的数据到GPU上 (从而节省了GPU资源 )。

对于更大的数据集,使用Dataset的_get_item__将样本一个个地加载CPU上,然后将属于一个mini-batch的样本一次性发送到GPU,这样就可以充分利用你的显卡RAM。

更进一步,如果有很多GPU可用,则最好保持数据集为“不可知”,并在训练过程中将批次各batch分配给不同的GPU。

到目前为止,我们只关注训练数据。我们为它建立了一个dataset和一个data loader。我们可以对验证数据做同样的事情,使用本节开始时执行的分割…或者我们可以使用random_split

Random Split

PyTorch的random_split()方法是执行训练-验证分割的一种简单且熟悉的方法。

然后,对相应data的子集分别构建相应的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)

现在,我们的验证集有了一个data loader。验证集要起作用的话,需要……


Evaluation

本文的最后一部分——我们更改 training loop,以包括模型的评估,即计算验证集损失。第一步是包含另一个内部循环来处理来自 validation loader 的 mini-batch,并将它们发送到与我们的模型相同的设备上。接下来,就使用模型进行预测,并计算相应的损失

就是这样,但还有两个小但重要的问题要考虑:

  • torch.no_grad():最好使用此命令来包装验证集循环,以禁用可能的无意触发的任何梯度计算—— 梯度只存在于训练中,而不存在于验证步骤中!
  • eval():它唯一的作用就是将模型转换到验证模式 (就像train()一样),以使得模型可以针对某些操作 (例如Dropout) 调整其行为。

现在,我们的 training loop 是这样的:

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)
            
            model.eval()

            yhat = model(x_val)
            val_loss = loss_fn(y_val, yhat)
            val_losses.append(val_loss.item())

print(model.state_dict())

Is there anything else we can improve or change? Sure, there is always something else to add to your model — using a learning rate scheduler, for instance. But this post is already waaaay too long, so I will stop right here.


使用PyTorch搭建深度学习框架的baseline

def train(model, loss_fn, optimizer):
	# 建立在训练循环中执行一步的函数

	# 设定模型为训练模式
	model.train()
	# 计算预测(正向传播)
	yhat = model(x)
	# 计算损失
	loss = loss_fn(y, yhat)
	# 计算梯度(反向传播)
	loss.backward()
	# 更新参数 + 梯度置零
	optimizer.step()
	optimizer.zero_grad()
	# 返回损失
	return loss.item()

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(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)
            
            model.eval()

            yhat = model(x_val)
            val_loss = loss_fn(y_val, yhat)
            val_losses.append(val_loss.item())

# print(model.state_dict())

写在最后的话

建议有条件的,还是去读一读英文的原版。翻译以后的结果,哪怕翻译得再用心,也不能完全传达原文的意思。

网盘链接在这:

链接:https://pan.baidu.com/s/19KjHeocdqJhMMYHY9zw0_g 提取码:yz6a

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值