60分钟闪击Pytorch学习笔记
针对官方入门教程Deep Learning with PyTorch: A 60 Minute Blitz简单做个学习记录,本文大量参考该篇博客 Respect。
1. Tensor
什么是Tensor
torch中的Tensor是一种数据结构,其实在使用上与Python的list、numpy的array、ndarray等数据结构比较类似,可以当成一个多维数组来用。
在数学上对张量这一专业名词有特定的定义,但是反正大概理解成一个多维数组就够用了。
如何生成Tensor?
torch包中提供了一系列直接生成Tensor的函数,如 zeros()
、ones()
、rand()
等。
此外,可以用 tensor(data)
函数直接将某一表示数组的数据(接受list、numpy.ndarray等格式)转换为Tensor。
也可以通过 from_numpy(data)
函数将numpy.ndarray格式的数据转换为Tensor。
还可以生成一个与其他Tensor具有相同dtype和device等属性的Tensor,使用torch的 ones_like(data)
或 rand_like(data)
等函数,或Tensor的 new_ones()
等函数。
Tensor的属性
- shape(返回torch.Size格式)(也可以用size()函数)
- dtype
- device
Tensor可以进行的操作
类似numpy的API
改变原数据的原地操作在函数后面加_就可以(一般不建议这么操作)
- 索引
- 切片
- join:
cat(tensors)
或stack(tensors)
- 加法:
add()
或+
- 乘法:对元素层面的乘法
mul()
或*
,矩阵乘法matmul()
或@
- resize
reshape()
或view()
(建议使用reshape()
,因为仅使用view()
可能会造成Tensor不contiguous的问题squeeze()
去掉长度为1的维度unsqueeze()
增加一个维度(长度为1)transpose()
转置2个维度
Tensor.numpy()
可以将Tensor转换为numpy数据。反向的操作见上面。- 注意这两方向的转换的数据对象都是占用同一储存空间,修改后变化也会体现在另一对象上。
item()
函数返回仅有一个元素的Tensor的该元素值。
对PyTorch中的contiguous的理解可以参考这篇知乎文章
在PyTorch的Tensor上指其底层一维数组元素的存储顺序与Tensor按行优先一维展开的元素顺序是否一致。某些Tensor操作会导致Tensor不再按照行优先顺序来存储,如果需要让它重新变得连续,需要使用contiguous()
函数。
pytorch 里面的
contiguous()
是以C
为顺序保存在内存里面,如果不是,则返回一个以C
为顺序保存的tensor.tensor_name.is_contiguous()
可以用来判断是否以 C 为顺序保存的。
一些可能导致不是以 C 为顺序保存的可能为:
- narrow
- transpose
1
2
3
4
5 import torch
x = torch.ones(10, 10)
x.is_contiguous() # True
x.transpose(0, 1).is_contiguous() # False
x.transpose(0, 1).contiguous().is_contiguous() # True
view()
等方法就需要Tensor连续。如果不关心底层数据是否使用了新的内存,则使用reshape()
方法更方便(因为不再需要考虑contiguous问题)。
关于PyTorch为什么不在view()
方法中内置contiguous()
方法,在github上的issue中有讨论:view() after transpose() raises non contiguous error #764
其他可能有用的参考:
What is the difference between contiguous and non-contiguous arrays?
What is a “cache-friendly” code?
How to understand numpy strides for layman?
Munging PyTorch’s tensor shape from (C, B, H) to (B, C*H)
从Numpy中的ascontiguousarray说起
What does .contiguous() do in PyTorch?
2. Autograd
torch.autograd
是PyTorch提供的自动求导包,非常好用,可以不用自己算神经网络偏导了。
神经网络构成、常识部分这里就不再详细介绍了,总之大概就是:
- 神经网络由权重、偏置等参数决定的函数构成,这些参数在PyTorch中都储存在Tensor里
- 神经网络的训练包括前向传播和反向传播两部分,前向传播就是用函数计算预测值,反向传播就是通过这一预测值产生的error/loss来更新参数(通过梯度下降的方式)对反向传播算法的介绍,3b1b的视频可作为参考
神经网络的一轮训练:
- 前向传播:
prediction = model(data)
- 反向传播
- 计算loss
loss.backward()
(autograd会在这一步计算参数的梯度,存在相应参数Tensor的grad属性中)- 更新参数
- 加载optimizer(通过torch.optim)
optimizer.step()
对参数使用梯度下降的方法进行更新(梯度来源自参数的grad属性)
(以下涉及原理)
autograd实现细节:一个示例
- 将Tensor的requires_grad属性设置为True,可以追踪autograd在其上每一步的操作
- 示例中,提供了两个requires_grad为True的Tensor(含两个元素的向量)a和b,设其损失函数$Q = 3a^3 - b^2$
- 注意:对Q计算梯度时,需要在
backward()
函数中添加gradient参数,这个gradient是和当前Tensor形状相同的Tensor,包含当前Tensor的梯度,比如示例中使用的是:$\frac{dQ}{dQ} = 1 $(因为Q是向量而非标量,参考backward()
的文档。为了避免这个问题也可以直接将Q转化为标量然后使用backward()
方法,如Q.sum().backward()
) - 计算梯度:
external_grad = torch.tensor([1., 1.])
Q.backward(gradient=external_grad)
- 现在Q相对于a和b的梯度向量就分别储存在了a.grad和b.grad中,可以直接查看
教程中提供了aotugrad矢量分析方面的解释,我没看懂,以后学了矢量分析看懂了再说。
autograd的计算图:
- autograd维护一个由Function对象组成的DAG中的所有数据和操作。这个DAG是以输入向量为叶,输出向量为根。autograd从根溯叶计算梯度
- 在前向传播时,autograd同时干两件事:计算输出向量,维护DAG中操作的gradient function
- 反向传播以根节点调用
backward()
方法作为开始,autograd做以下三件事:用数据的grad_fn属性计算梯度,将梯度分别加总累积到各Tensor的grad属性中,根据链式法则传播到叶节点 - 如图,前序号4部分示例$Q = 3a^3 - b^2$的DAG(箭头是前向传播的方向,节点是前向传播过程中每个操作的backward functions,蓝色的叶节点是a和b)
- 注意:PyTorch中的DAG是动态的,每次调用
backward()
方法都重新填出一个DAG
将Tensor的requires_grad属性设置为False,可以将其排除在DAG之外,autograd就不会计算它的梯度。
在神经网络中,这种不需要计算梯度的参数叫frozen parameters。可以冻结不需要知道梯度的参数(节省计算资源),也可以在微调预训练模型时使用(此时往往冻结绝大多数参数,仅调整classifier layer参数,以在新标签上做预测)。
类似功能也可以用上下文管理器torch.no_grad()
实现。
3. Neural Network
神经网络可以通过torch.nn包搭建(torch.nn包里预定义的层调用了torch.nn.functional包的函数)
nn.Module包含了网络层
forward(input)
方法返回输出结果
典型的神经网络训练流程:
- 定义具有可训练参数(或权重)的神经网络
- 用数据集进行多次迭代
- 前向传播
- 计算loss
- 计算梯度
- 使用梯度下降法更新参数
定义网络
只需要定义forward()
方法,backward()
方法会自动定义(因为用了autograd)。在forward()
方法中可以进行任何Tensor操作。
本部分代码定义了一个卷积→池化→卷积→池化→仿射变换→仿射变换→仿射变换的叠叠乐网络。
(这个网络我有一点没搞懂,就是仿射变换前一步,既然已知数据维度是1666,为什么还要用num_flat_features()
这个方法算一遍啊……?)
1 | import torch |
1 | Output: >> |
模型的可学习参数存储在net.parameters()
中。这个方法的返回值是一个迭代器,包含了模型及其所有子模型的参数
前向传播
out = net(input)
反向传播
先将参数梯度缓冲池清零(否则梯度会累加),再反向传播(此处使用一个随机矩阵)
net.zero_grad()
out.backward(torch.randn(1, 10))
如果有计算出损失函数,上一行代码应为:loss.backward()
注意:torch.nn只支持mini-batch,所以如果只有一个输入数据的话,可以用input.unsqueeze(0)方法创造一个伪batch维度
损失函数
torch.nn包中定义的损失函数文档:https://pytorch.org/docs/nn.html#loss-functions
以MSELoss为例:
criterion = nn.MSELoss()
loss = criterion(output, target)
对如此得到的loss,其grad_fn组成的DAG为:
input -> conv2d -> relu -> maxpool2d -> conv2d -> relu -> maxpool2d -> view -> linear -> relu -> linear -> relu -> linear -> MSELoss -> loss
所以,调用loss.backward()
后,所有张量的梯度都会得到更新
1 | print(loss.grad_fn) # MSELoss |
1 | Output: >> |
更新网络中的权重:step()
使用torch.optim中的优化器(lr
入参是学习率,这个学习率也可以通过torch.optim.lr_scheduler包实现learning rate scheduling操作)
1 | import torch.optim as optim |
这里与前文的zero_grad()
用的地方有点不一样。前面是用在了net(一个网络(nn.Module子类)实例)上,后文的zero_grad()方法则是用在了optimizer(优化器)上。
前者可见文档,是将其所有参数梯度清零。
后者文档,是将优化器上所有参数梯度清零。
注意到我们往优化器中传的就是这个网络的所有参数:optimizer = optim.SGD(net.parameters(), lr=0.01)
,所以我觉得这两种写法应该是一样的(因为model.parameters()
返回一个Tensor的迭代器,Tensor作为一个可变object应该是直接传入引用,所以应该一样)。但是我还没有试验过,如果有闲情逸致的话可以试试。
另,如果有frozen parameters,或者优化器只优化一部分模型中的参数,可能不等价。这一部分我也还没有试验过。
为什么要使用zero_grad()
?
有两种方式直接把模型的参数梯度设成0:
1 | model.zero_grad() |
如果 optimizer=optim.Optimizer(net.parameters())
, optimizer.zero_grad()
和net.zero_grad()
是等价
如果想要把某一Variable的梯度置为0,只需用以下语句:
1 | Variable.grad.data.zero_() |
另外Pytorch 为什么每一轮batch需要设置optimizer.zero_grad:?
根据pytorch中的backward()
函数的计算,当网络参量进行反馈时,梯度是被积累的而不是被替换掉;
但是在每一个batch时毫无疑问并不需要将两个batch的梯度混合起来累积,因此这里就需要每个batch设置一遍zero_grad 了。
另外,如果不是处理每个batch清除一次梯度,而是两次或多次再清除一次,相当于提高了batch_size,对硬件要求更高,更适用于需要更高batch_size的情况。
其他有时间可以看一下的参考:
- 用代码演示演示了一下PyTorch的梯度积累和清零的过程。torch.nn.Module.zero_grad()的使用
4. CIFAR10 (Example: Image Classification)
各种形式的数据都可以通过Python标准库转换为numpy数组格式,然后再转换为Tensor格式
- 图像:Pillow, OpenCV
- 音频:scipy and librosa
- 文本:raw Python or Cython based loading, or NLTK and SpaCy
对计算机视觉任务,PyTorch有专门的包torchvision,可以直接通过torchvision.datasets
和torch.utils.data.DataLoader
下载Imagenet, CIFAR10, MNIST等常用数据集并对其进行数据转换
在本教程中使用的是CIFAR10。图片是3通道,大小为32*32。标签为图像类别(共10类)
Step1 下载并规范化数据集
通过torch.utils.data.DataLoader
加载torchvision.datasets
中的数据集,返回迭代器
使用torchvision.transforms
包进行规范化
Step2 定义一个卷积神经网络
这个神经网络和第3部分神经网络里的模型相似,只是将数据维度做了修改。
这里的数据特征尺寸在网络层之间的变化是$33232\xrightarrow{(conv1)}62828\xrightarrow{(pool)}61414\xrightarrow{(conv2)}161010\xrightarrow{(pool)}1655\xrightarrow{(fc1)}120\xrightarrow{(fc2)}84\xrightarrow{(fc3)}10$
1 | import torch.nn as nn |
Step3 定义损失函数和优化器
1 | import torch.optim as optim |
Step4 训练神经网络
1 | for epoch in range(2): # loop over the dataset multiple times |
将模型保存到本地
1 | PATH = './cifar_net.pth' |
更多模型存取细节:SERIALIZATION SEMANTICS
Step5 测试神经网络
加载模型文件:
1 | net = Net() |
用测试集输出向量中最大的元素代表的类作为输出
1 | correct = 0 |
在GPU上训练
1 | device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") |
注意,直接调用 my_tensor.to(device)
将返回一个在GPU上的 my_tensor
的副本而不是直接重写 my_tensor
,因此在后续训练的过程中需要将其赋予一个新Tensor,然后用新Tensor来训练。
5. 多GPU数据并行训练
这个教程主要讲如何使用 DataParallel
这个类(简称DP)。文档DataParallel
PyTorch常用的另一个多卡训练的类是 DistributedDataParallel
(简称DDP。文档:DistributedDataParallel)。那个类怎么用我还没搞懂,我就先把这个 DataParallel
搞懂了来写一写……
核心代码:model = nn.DataParallel(model)
在单卡上写好的model直接调用这个类,然后别的都跟单卡形式下的一样就可以了。程序会自动把数据拆分放到所有已知的GPU上来运行。
设置已知的GPU,可以在运行代码的 python
加上 CUDA_VISIBLE_DEVICES
参数,举例:
1 | CUDA_VISIBLE_DEVICES=0,1,2,3 python example.py |
注意如果要使用nohup的话,这个参数要加在nohup的还前面,举例:
1 | CUDA_VISIBLE_DEVICES=0,1,2,3 nohup python -u example.py >> nohup_output.log 2>&1 & |
如果不设置则默认为所有GPU
对GPU数量的计数可以使用 torch.cuda.device_count()
代码。
原理我还没怎么搞懂,但是据说直接用 DataParallel
不太好,有各卡空间不均衡之类的问题,建议使用 DistributedDataParallel
其他多卡运行PyTorch模型的资料可参考:
6. 其他值得学习的资料
60分钟闪击Pytorch学习笔记