本文主要对PyTorch的使用做一简单介绍,本节主要来自于PyTorch官网Deep Learning with PyTorch: A 60 Minute Blitz这一栏目的文字内容,并忽略了一些代码
PyTorch的核心是张量,张量类似于Numpy的ndarray
,但是可以在GPU上加速计算。使用如下方法可以创造一个大小为 5 × 3 5 \times 3 5×3的二维张量
import torch
# 空张量,不初始化
x = torch.empty(5,3)
# 随机初始化
x = torch.rand(5, 3)
# 初始化为0且类型为long
x = torch.zeros(5, 3, dytpe=torch.long)
# 直接通过数据构建
x = torch.tensor([5.5, 3])
# 获取张量的“尺寸”
print(x.size())
张量有很多种操作,以加法为例举例如下
y = torch.rand(5, 3)
# 写法一
print(x + y)
# 写法二
print(torch.add(x, y))
# 就地操作,修改y
y.add_(x) # 所有就地操作都会以下划线_结尾
# resize可以使用torch.view
# 一维张量可以通过.item()获取值作为python自带的数字形式
x = torch.randn(1)
print(x.item())
PyTorch张量和numpy数组可以便捷地互相转化。如果张量在CPU上,那么它和对应的numpy数组共享内存,修改了一个也会修改另一个。使用.numpy()
将张量转化成numpy数组,使用torch.from_numpy(x)
将numpy数组转化成张量
张量可以通过成员函数.to()
转移到某个设备
if torch.cud.is_available():
device = torch.device('cuda') # 一个CUDA设备对象
y = torch.ones_like(x, device=device) # 直接在GPU上创建张量
x = x.to(devices) # x.to('cuda')也可
z = x + y
print(z)
print(z.to('cpu', torch.double)) # ".to()"也可以修改数据类型
PyTorch中各神经网络的核心是torch.autograd
这个包,其为张量的所有操作提供自动微分计算
如果将张量的属性.requires_grad
设置为True
,框架会追踪对其所作的所有操作。计算结束时,可以调用.backward()
来自动计算梯度,所有梯度信息被写进.grad
属性。这是因为张量背后的Tensor
类会和操作背后的Function
类交互,构建一个有向无环图,这张图可以编码计算的全部历史。每个向量都有一个属性.grad_fn
,该属性指向一个Function
类对象,这个对象所代表的的函数创建了该张量(用户手动创建的张量除外,这种张量的grad_fn
为None
)
要想停止追踪一个张量的梯度更新信息,可以调用.detach()
,也可以将代码块放进with torch.no_grad()
这一上下文环境中。这种办法在评估模型时尤其有用,因为此时不需要计算模型中参数的梯度
这里给出一个计算梯度的示例程序
import torch
x = torch.ones(2, 2, requires_grad=True)
y = x + 2
z = y * y * 3
out = z.mean()
out.backward()
print(x.grad)
# 输出结果
# tensor([[4.5000, 4.5000],
# [4.5000, 4.5000]])
假设输出张量out
记为 o o o,则有
o = 1 4 ∑ i z i , z i = 3 ( x i + 2 ) 2 , z i ∣ x i = 1 = 27 ∴ ∂ o ∂ x i = 3 2 ( x i + 2 ) , ∂ o ∂ x i ∣ x i = 1 = 4.5 \begin{aligned} o &= \frac{1}{4}\sum_i z_i,\ z_i = 3(x_i + 2)^2,\ z_i\bigg\rvert_{x_i = 1} = 27 \\ \therefore \frac{\partial o}{\partial x_i} &= \frac{3}{2}(x_i + 2),\ \frac{\partial o}{\partial x_i}\bigg\rvert_{x_i = 1} = 4.5 \end{aligned} o∴∂xi∂o=41i∑zi, zi=3(xi+2)2, zi∣∣∣∣xi=1=27=23(xi+2), ∂xi∂o∣∣∣∣xi=1=4.5
假设有一个函数 f : R n → R m f:\mathbb{R}^n \rightarrow \mathbb{R}^m f:Rn→Rm,则输出 y \boldsymbol{y} y对输入 x \boldsymbol{x} x的梯度为一个雅可比矩阵
J = [ ∂ y 1 ∂ x 1 ⋯ ∂ y 1 ∂ x n ⋮ ⋱ ⋮ ∂ y m ∂ x 1 ⋯ ∂ y m ∂ x n ] \boldsymbol{J} = \left[\begin{matrix} \frac{\partial y_1}{\partial x_1} & \cdots & \frac{\partial y_1}{\partial x_n} \\ \vdots & \ddots & \vdots \\ \frac{\partial y_m}{\partial x_1} & \cdots & \frac{\partial y_m}{\partial x_n} \end{matrix}\right] J=⎣⎢⎡∂x1∂y1⋮∂x1∂ym⋯⋱⋯∂xn∂y1⋮∂xn∂ym⎦⎥⎤
torch.autograd
就是一个计算向量-雅可比矩阵乘积的引擎。也就是,给定任何向量 v = [ v 1 v 2 ⋯ v m ] T \boldsymbol{v} = \left[\begin{matrix}v_1 & v_2 & \cdots & v_m\end{matrix}\right]^\mathsf{T} v=[v1v2⋯vm]T,计算 v T ⋅ J \boldsymbol{v}^\mathsf{T} \cdot \boldsymbol{J} vT⋅J。如果 v \boldsymbol{v} v是标量函数 l = g ( y ) l = g(\boldsymbol{y}) l=g(y)的梯度,也就是 v = [ ∂ l ∂ y 1 ∂ l ∂ y 2 ⋯ ∂ l ∂ y m ] T \boldsymbol{v} = \left[\begin{matrix}\frac{\partial l}{\partial y_1} & \frac{\partial l}{\partial y_2} & \cdots & \frac{\partial l}{\partial y_m}\end{matrix}\right]^\mathsf{T} v=[∂y1∂l∂y2∂l⋯∂ym∂l]T,根据链式法则,向量-雅可比矩阵的乘积就是 l l l对 x \boldsymbol{x} x的梯度,即
J T ⋅ v = [ ∂ y 1 ∂ x 1 ⋯ ∂ y 1 ∂ x n ⋮ ⋱ ⋮ ∂ y m ∂ x 1 ⋯ ∂ y m ∂ x n ] [ ∂ l ∂ y 1 ⋮ ∂ l ∂ y m ] = [ ∂ l ∂ x 1 ⋮ ∂ l ∂ x n ] \boldsymbol{J}^\mathsf{T}\cdot \boldsymbol{v} = \left[\begin{matrix} \frac{\partial y_1}{\partial x_1} & \cdots & \frac{\partial y_1}{\partial x_n} \\ \vdots & \ddots & \vdots \\ \frac{\partial y_m}{\partial x_1} & \cdots & \frac{\partial y_m}{\partial x_n} \end{matrix}\right]\left[\begin{matrix}\frac{\partial l}{\partial y_1} \\ \vdots \\ \frac{\partial l}{\partial y_m} \end{matrix}\right] = \left[\begin{matrix}\frac{\partial l}{\partial x_1} \\ \vdots \\ \frac{\partial l}{\partial x_n} \end{matrix}\right] JT⋅v=⎣⎢⎡∂x1∂y1⋮∂x1∂ym⋯⋱⋯∂xn∂y1⋮∂xn∂ym⎦⎥⎤⎣⎢⎡∂y1∂l⋮∂ym∂l⎦⎥⎤=⎣⎢⎡∂x1∂l⋮∂xn∂l⎦⎥⎤
如果某个操作有某个输入需要梯度,那么输出也会需要梯度;反之,如果某个操作的所有输入都不需要梯度,那么输出也不需要,反向传播计算不在这个子图上进行
x = torch.randn(5, 5) # 默认不需梯度
y = torch.randn(5, 5)
a = x + y # 输入x和y都不需梯度,所以a不需
z = torch.randn((5, 5), requires_grad=True)
b = a + z # z需要梯度,尽管a不需要,也不妨碍b需要梯度
使用该特点,可以固定预训练模型权重,微调分类器,如下所示
model = torchvision.models.resnet18(pretrained=True)
for param in model.parameters():
param.requires_grad = False
# 替换最后一个全连接层
# 新创建的模块默认requires_grad=True
model.fc = nn.Linear(512, 100)
# 只优化分类器
optimizer = optim.SGD(model.fc.parameters(), lr=1e-2, momentum=0.9)
本节来自于PyTorch官网的Deep Learning with PyTorch: A 60 Minute Blitz中Neural Network部分,但是具体网络定义来自于使用字符级RNN判别名字所属国家/地区这一示例的代码
通常使用torch.nn
来定义一个神经网络。用户定义“层”(layer)和层的前向计算逻辑,PyTorch通过前面提到的autograd
来自动计算反向传播逻辑。定义并训练一个网络时,一般包括如下几步
用户定义的网络一般需要继承自torch.nn.Module
类,具体后面解释。定义时,通常包括两项内容:在构造函数中定义参数,在forward
函数中定义计算逻辑。一旦forward
被定义好,backward
参数(也就是梯度计算和传播的逻辑)会通过autograd
自动定义。具体如下所示
class RNN(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super(RNN, self).__init__()
self.hidden_size = hidden_size
self.i2h = nn.Linear(input_size + hidden_size, hidden_size)
self.i2o = nn.Linear(input_size + hidden_size, output_size)
self.softmax = nn.LogSoftmax(dim=1)
def forward(self, input, hidden):
combined = torch.cat((input, hidden), 1)
hidden = self.i2h(combined)
output = self.i2o(combined)
output = self.softmax(output)
return output, hidden
def initHidden(self):
return torch.zeros(1, self.hidden_size)
n_hidden = 128
rnn = RNN(n_letters, n_hidden, n_categories)
模型的可学习参数通过rnn.parameters()
返回
这里我们涉及了如下四个关键类
torch.Tensor
:实际上是一个多维数组,但是支持诸如backward
这样的自动微分操作,并且保存关于这个张量的梯度nn.Module
:神经网络模块,提供一种便捷的手段来封装参数nn.Parameter
:Tensor的一种,当它被赋给Module
子类的一个属性时,自动注册为参数。具体后文再解释autograd.Function
:实现一个可被自动微分操作的前向计算逻辑和反向计算逻辑。每个Tensor
操作创建至少一个Function
节点,将其与创建Tensor
的操作连接,并记录历史损失函数一般接收两个参数,输出和目标值,并计算模型输出的预测值离目标值有多远。这一问题我们使用NLLLoss
(negative log likelihood loss)。求得损失以后,调用backward
,就可以得到损失值对各参数的梯度。通常使用torch.optim
中的优化器来根据梯度执行参数更新策略
示例代码如下
import torch.optim as optim
hidden = rnn.initHidden()
optimizer = optim.SGD(rnn.parameters(), lr=0.005)
# 必须手动调用zero_grad将所有参数缓存的梯度清零
# 否则新的梯度会累加到已有的梯度上
rnn.zero_grad()
# Blitz中的代码示例用的是optimizer.zero_grad()
# 当优化器参数为model.parameters()时,两者等价
for i in range(line_tensor.size()[0]):
output, hidden = rnn(line_tensor[i], hidden)
criterion = nn.NLLLoss()
loss = criterion(output, category_tensor)
# 调用时,计算并更新梯度
loss.backward()
# 更新参数
optimizer.step()
PyTorch官网的What is torch.nn
really?一文(以下简称原文)以一个简单的MNIST数据分类任务为例,介绍了PyTorch常用的几个包。这里不想将全部代码贴出,仅准备对其提到的这些包做一简单分析
torch.utils.data.Dataset
原文使用的是torch.utils.data.TensorDataset
,不过该类继承自抽象基类torch.utils.data.Dataset
。从本质上讲,Dataset
类,如名字所示,提供了数据访问的功能,即给定一个索引,该类的实现需要能返回索引对应的数据,因此所有子类肯定是要实现抽象方法__getitem__(self, index)
。同时,文档还要求子类实现__len__
方法,来返回数据集的大小
原文使用的TensorDataset
类实现非常简洁,具体代码如下所示(可以作为自己定义Dataset
子类的一个简单示例)
class TensorDataset(Dataset):
def __init__(self, *tensors):
assert all(tensors[0].size(0) == tensor.size(0) for tensor in tensors)
self.tensors = tensors
def __getitem__(self, index):
return tuple(tensor[index] for tensor in self.tensors)
def __len__(self):
return self.tensors[0].size(0)
新建对象时可以将样本和标签各自的向量一同传给构造函数,打包成一个大的数据集
((x_train, y_train), (x_valid, y_valid), _) = pickle.load(f, encoding='latin-1')
x_train, y_train, x_valid, y_valid = map(torch.tensor, (x_train, y_train, x_valid, y_valid))
train_ds = TensorDataset(x_train, y_train)
valid_ds = TensorDataset(x_valid, y_valid)
常见的Dataset
对象通常都可以看做是映射风格(map style)的,因为它们实现了根类的__getitem__
方法,所以可以方便地随机访问数据集中的对象。但是Dataset
有一个子类IterableDataset
,按照设计其不应该重写__getitem__
方法,而是应该重写__iter__
方法来顺序访问数据集中的数据。该子类通常是为了在随机读操作比较重的情况下使用,例如访问数据库、流数据等等
torch.utils.data.DataLoader
数据集(即Dataset
对象)通常作为参数传递给torch.utils.data.DataLoader
类的构造函数,以创建一个DataLoader
类对象
train_dl = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True)
valid_dl = DataLoader(valid_ds, batch_size=BATCH_SIZE)
该对象负责访问数据集,将其随机划分,按照batch大小返回每个batch的数据和对应的标签。DataLoader
默认的构造函数有多个参数,这里暂时只选择上述代码片段中涉及的参数和一些比较重要的参数介绍,分别有
dataset
,即所访问的数据集batch_size
,每个batch所装载的样本数量shuffle
,即每个epoch(将数据集完整访问过一遍)后是否重新打乱数据集sampler
,是一个torch.utils.data.Sampler
类对象,其通过重写__iter__
方法来控制以什么顺序访问数据源的元素,即其本质是决定DataLoader
的读取方法。如果用户没有自定义一个sampler,则采取如下逻辑设置默认sampler:如果所访问的数据集是IterableDataset
,则使用_InfiniteConstantSampler
;如果所访问的数据集可随机读,则在shuffle
为True
时使用RandomSampler
,否则使用SequentialSampler
batch_sampler
,和sampler
一致,只不过返回一个batch大小的索引集合drop_last
,设为True
时如果最后一个batch的样本数量少于batch_size
,则丢弃这些数据num_workers
,同时需要多少个子进程读数据DataLoader
在被迭代时,首先调用其重写的__iter__
方法,返回一个_BaseDataLoaderIter
可迭代对象实例,当num_workers
为0时,为单线程读取,返回_SingleProcessDataLoaderIter
对象;否则多线程读取返回_MultiProcessingDataLoaderIter
对象。两者都会调用自己的__next__
方法,先获得索引列表,然后调用成员变量_dataset_fetcher
的fetch
方法从这些索引获取数据并返回
真正使用时,使用for
循环迭代即可
for xb, yb in train_dl:
loss = loss_func(model(xb), yb)
loss.backward()
opt.step()
opt.zero_grad()
torch.optim
本节参考了torch.optim
的官方文档
优化器所在的包,里面封装了各种优化器的实现,例如SGD、Adam等。所有优化器都继承自torch.optim.Optimizer
这个基类,各自实现不同的step
方法以更新模型参数
要构造一个优化器实例,需要传递给它一个可迭代的参数列表,通常是调用给定模型(一般是torch.nn.Module
子类对象)的parameters()
方法
optimizer = optim.SGD(model.parameters(), lr=0.5)
优化器内部维护的实际上是参数组。默认情况下(如上例所示)参数组只有一组,但是可以通过传入字典列表来实现对参数的分组,从而对不同的参数施加不同学习率或其他设置,例如
optim.SGD([
{'params': model.base.parameters()},
{'params': model.classifier.parameters(), 'lr': 1e-3}
], lr=1e-2, momentum=0.9)
即model.base
的参数使用默认配置lr=1e-2, momentum=0.9
,但是对model.classifier
设置学习率为1e-3
torch.nn
最后来说说torch.nn
这个包。个人感觉这是PyTorch最核心的组件之一,其中torch.nn.functional
相对独立,提供了各种函数实现,例如激活函数、损失函数等(感觉像是个utils这样的工具集合)
这个包中最核心的自然是torch.nn.Module
类(以下简称Module
类)。如前所述,这个类是所有神经网络层的基类。前面给出了如何继承该类,自定义一个模型结构,因此这里不再在应用层面赘述,只对该类的几个核心逻辑做简单分析
Module
在构造函数中被创建的成员变量并不多,这些属性可以根据用途分为三类
self.training
,标志该层所处的状态。一些层,例如Dropout和BatchNorm等,其训练时的行为和推断时的行为有不同,需要根据该属性判断。默认为Trueself._parameters
、self._modules
和self._buffers
,都是OrderedDict
类对象,按照名字存储。其中_parameters
存储层所需要的的参数(例如线性层,就是权重W和偏置b);_modules
存储子层,例如前面自己实现的RNN
类包含一个线性层Linear
对象;_buffers
缓存一些不是参数(不参与反向传播,不需要保存梯度),但是需要保存状态的成员变量,例如BatchNorm中的running_mean
。_buffers
中的成员可以被持久化,持久化的buffer
会被保存在层的状态字典state_dict
中Module
类重写了__setattr__
方法,这样子类在构造函数中创建成员变量时会有些特殊操作,比较重要的两个有
nn.Parameter
类对象,那么会自动调用register_parameter
方法,将其加入_parameters
中nn.Module
类对象,那么会自动调用add_module
方法,将其加入_modules
中如前所述,nn.Parameter
类是Tensor
类的一个子类,其特点除了会被层对象自动注册进_parameters
列表以外,还有就是会自动设置requires_grad
属性为True
。因此,该类对象的梯度会被保存,同时在层对象通过调用parameters()
方法时会被给出,可以被优化器获得然后更新参数
parameters()
Module
类通过parameters
方法给出自身所有参数,传递给优化器做更新。其核心实现逻辑是先递归获得自身的所有module(包括自身),然后对每个module获取其parameters。可将parameters
方法的recurse
设为False
来避免递归搜索子module
forward()
和__call__
所有Module
的子类都需要实现forward
方法,即前向计算逻辑。此外,Module
基类重写了__call__
方法,因此可以用类似调用函数的方式“调用”Module
子类实例,进行前向计算。事实上,也应该使用这种方法做前向计算,而非调用forward
,因为重写的__call__
除了调用forward
以外,还调用了一些钩子函数。如果开发者想加入一些额外的行为,重写这些钩子函数即可
__call__
方法的计算流程大致为
def __call__(self, *input):
# 对输入张量,逐个调用_forward_pre_hooks中的函数
# 使用register_forward_pre_hook注册该类钩子函数
for hook in self._forward_pre_hooks.values():
input = hook(self, input)
result = self.forward(input)
# 对前向计算的结果,逐个调用_forward_hooks中的函数
# 使用register_forward_hook注册该类钩子函数
for hook in self._forward_hooks.values():
result = hook(self, result)
# 在对应的梯度函数中,逐个注册_backward_hooks中的函数。这些函数在计算模块输入的梯度时被调用
# 使用register_backward_hook注册该类钩子函数
for hook in self._backward_hooks.values():
result.grad_fn.register_hook(hook)
return result
总地说来,使用PyTorch构建并训练模型,大致可以总结为如下模式
import torch
# 定义自己的module
class MyModule(torch.nn.Module):
def __init__():
super().__init__()
# 定义自己的成员变量
# 对module需要的参数,使用torch.nn.Parameter。
# 该类是Tensor的子类,自动设置requires_grad为True
# 且会被注册进参数列表,可以被parameters()方法包含
# 常见地,也会新建其它module类对象,作为子模块
def forward(input):
# 定义前向计算逻辑
# 个人感觉,在定义前向计算逻辑的过程中,实际上也在建立计算图
# 用户定义的参数作为图的叶子节点,各种操作实际都是torch.autograd.Function类的子类对象
# 所有运算符其实也被重写了背后的逻辑,这样,内部通过运算得到的张量对象和函数对象建立了对应关系
# 每个函数对象都定义了前向计算的方法和反向计算的方法。在反向传播求梯度时,调用反向计算方法即可
# 关于建图,有一篇老文很透彻 https://www.cnblogs.com/catnip/p/8760780.html
# 上文可能有些过时,等待熟悉PyTorch深入原理后再结合该文看代码
# 建立torch.utils.data.Dataset对象
# 建立DataLoader,将其与dataset相关联
train_dl = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True)
valid_dl = DataLoader(valid_ds, batch_size=BATCH_SIZE)
model = MyModule()
# 将Module中的所有参数传递给optimizer实例
# parameters方法不仅返回module中的Parameter实例
# 还递归返回子module中所有Parameter实例
optimizer = optim.SGD(model.parameters(), lr=0.5)
for epoch in range(epochs):
# 该函数简单,实际就是设置为train模式
# 对dropout、batchnorm等有用
model.train()
for xb, yb in train_dl:
# loss_func通常是torch.nn.functional中定义的损失函数
loss = loss_func(model(xb), yb)
# 开始反向传播,计算梯度,保留在各个张量的tensor.grad里
# 可以通过tensor.grad.data获得具体梯度值
loss.backward()
# 根据计算得到的梯度及优化器具体逻辑
# 对参数值进行一次更新
opt.step()
# 梯度清零,避免累加
opt.zero_grad()