import torch
torch.cuda.is_available()
True
2.1 张量
从本章开始,我们将开始介绍PyTorch基础知识,本章我们将介绍张量,以帮助大家建立起对数据的描述,随后我们再介绍张量的运算,最后再讲PyTorch中所有神经网络的核心包 autograd
,也就是自动微分,了解完这些内容我们就可以较好地理解PyTorch代码了。在深度学习中,我们通常将数据以张量的形式进行表示,比如我们用三维张量表示一个RGB图像,四维张量表示视频。
经过本节的学习,你将收获:
- 张量的简介
- PyTorch如何创建张量
- PyTorch中张量的操作
- PyTorch中张量的广播机制
2.1.1 简介
几何代数中定义的张量是基于向量和矩阵的推广,比如我们可以将标量视为零阶张量,矢量可以视为一阶张量,矩阵就是二阶张量。
张量维度 | 代表含义 |
---|---|
0维张量 | 代表的是标量(数字) |
1维张量 | 代表的是向量 |
2维张量 | 代表的是矩阵 |
3维张量 | 时间序列数据 股价 文本数据 单张彩色图片(RGB) |
张量是现代机器学习的基础。它的核心是一个数据容器,多数情况下,它包含数字,有时候它也包含字符串,但这种情况比较少。因此可以把它想象成一个数字的水桶。
这里有一些存储在各种类型张量的公用数据集类型:
- 3维 = 时间序列
- 4维 = 图像
- 5维 = 视频
例子:一个图像可以用三个字段表示:
(width, height, channel) = 3D
但是,在机器学习工作中,我们经常要处理不止一张图片或一篇文档——我们要处理一个集合。我们可能有10,000张郁金香的图片,这意味着,我们将用到4D张量:
(batch_size, width, height, channel) = 4D
在PyTorch中, torch.Tensor
是存储和变换数据的主要工具。如果你之前用过NumPy
,你会发现 Tensor
和NumPy的多维数组非常类似。然而,Tensor
提供GPU计算和自动求梯度等更多功能,这些使 Tensor
这一数据类型更加适合深度学习。
2.1.2 创建tensor
在接下来的内容中,我们将介绍几种常见的创建tensor
的方法。
- 随机初始化矩阵
我们可以通过torch.rand()
的方法,构造一个随机初始化的矩阵:
x = torch.rand(4, 3)
x
tensor([[0.7779, 0.4420, 0.0016],
[0.9195, 0.4000, 0.4866],
[0.9196, 0.0188, 0.7230],
[0.5205, 0.4675, 0.3574]])
- 全0矩阵的构建
我们可以通过torch.zeros()
构造一个矩阵全为 0,并且通过dtype
设置数据类型为 long。除此以外,我们还可以通过torch.zero_()和torch.zeros_like()将现有矩阵转换为全0矩阵.
x = torch.zeros(4, 3, dtype=torch.long)
x
tensor([[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
[0, 0, 0]])
- 张量的构建
我们可以通过torch.tensor()
直接使用数据,构造一个张量:
x = torch.tensor([5.5, 3])
x
tensor([5.5000, 3.0000])
- 基于已经存在的 tensor,创建一个 tensor :
x = x.new_ones(4, 3, dtype=torch.double)
# 创建一个新的全1矩阵tensor,返回的tensor默认具有相同的torch.dtype和torch.device
# 也可以像之前的写法 x = torch.ones(4, 3, dtype=torch.double)
x
tensor([[1., 1., 1.],
[1., 1., 1.],
[1., 1., 1.],
[1., 1., 1.]], dtype=torch.float64)
x = torch.randn_like(x, dtype=torch.float)
# 重置数据类型
x
tensor([[ 0.0293, -0.6992, 1.4155],
[-0.7935, 0.0886, -0.8630],
[ 0.6494, 0.7248, -1.1408],
[ 0.6564, 1.3558, 1.1522]])
# 结果会有一样的size
x.size()
torch.Size([4, 3])
# 获取它的维度信息
x.shape
torch.Size([4, 3])
返回的torch.Size其实是一个tuple,⽀持所有tuple的操作。我们可以使用索引操作取得张量的长、宽等数据维度。
- 常见的构造Tensor的方法:
函数 | 功能 |
---|---|
Tensor(sizes) | 基础构造函数 |
tensor(data) | 类似于np.array |
ones(sizes) | 全1 |
zeros(sizes) | 全0 |
eye(sizes) | 对角为1,其余为0 |
arange(s,e,step) | 从s到e,步长为step |
linspace(s,e,steps) | 从s到e,均匀分成step份 |
rand/randn(sizes) | rand是[0,1)均匀分布;randn是服从N(0,1)的正态分布 |
normal(mean,std) | 正态分布(均值为mean,标准差是std) |
randperm(m) | 随机排列 |
2.1.3 张量的操作
在接下来的内容中,我们将介绍几种常见的张量的操作方法:
- 加法操作:
y = torch.rand(4, 3)
# 方式1
x + y
tensor([[ 0.6728, -0.0943, 2.2533],
[-0.2971, 1.0802, -0.2280],
[ 1.0922, 1.3770, -0.3606],
[ 0.8574, 2.3418, 1.6688]])
# 方式2
torch.add(x, y)
tensor([[ 0.6728, -0.0943, 2.2533],
[-0.2971, 1.0802, -0.2280],
[ 1.0922, 1.3770, -0.3606],
[ 0.8574, 2.3418, 1.6688]])
# 方式3 in-place,原值修改
y.add_(x)
tensor([[ 0.6728, -0.0943, 2.2533],
[-0.2971, 1.0802, -0.2280],
[ 1.0922, 1.3770, -0.3606],
[ 0.8574, 2.3418, 1.6688]])
- 索引操作:(类似于numpy)
需要注意的是:索引出来的结果与原数据共享内存,修改一个,另一个会跟着修改。如果不想修改,可以考虑使用copy()等方法
x = torch.rand(4,3)
# 取第二列
print(x[:, 1])
tensor([0.8449, 0.3866, 0.1187, 0.5323])
y = x[0,:]
y
tensor([0.4596, 0.8449, 0.0443])
x[0, :] # 源tensor也被改了了
tensor([0.4596, 0.8449, 0.0443])
- 维度变换
张量的维度变换常见的方法有torch.view()
和torch.reshape()
,下面我们将介绍第一中方法torch.view()
:
x = torch.randn(4, 4)
y = x.view(16)
z = x.view(-1, 8) # -1是指这一维的维数由其他维度决定
x.size(), y.size(), z.size()
(torch.Size([4, 4]), torch.Size([16]), torch.Size([2, 8]))
注: torch.view()
返回的新tensor
与源tensor
共享内存(其实是同一个tensor
),更改其中的一个,另外一个也会跟着改变。(顾名思义,view()仅仅是改变了对这个张量的观察角度)
x += 1
x
tensor([[2.6970, 2.0630, 3.3716, 0.7889],
[2.6527, 2.8597, 0.9921, 1.8509],
[0.8133, 1.8197, 2.3281, 2.2485],
[3.2220, 1.5270, 2.1183, 3.7229]])
y # 也加了了1
tensor([2.6970, 2.0630, 3.3716, 0.7889, 2.6527, 2.8597, 0.9921, 1.8509, 0.8133,
1.8197, 2.3281, 2.2485, 3.2220, 1.5270, 2.1183, 3.7229])
上面我们说过torch.view()会改变原始张量,但是很多情况下,我们希望原始张量和变换后的张量互相不影响。为为了使创建的张量和原始张量不共享内存,我们需要使用第二种方法torch.reshape()
, 同样可以改变张量的形状,但是此函数并不能保证返回的是其拷贝值,所以官方不推荐使用。推荐的方法是我们先用 clone()
创造一个张量副本然后再使用 torch.view()
进行函数维度变换 。
注:使用 clone()
还有一个好处是会被记录在计算图中,即梯度回传到副本时也会传到源 Tensor 。
3. 取值操作
如果我们有一个元素 tensor
,我们可以使用 .item()
来获得这个 value
,而不获得其他性质:
x = torch.randn(1)
type(x)
torch.Tensor
type(x.item())
float
PyTorch中的 Tensor 支持超过一百种操作,包括转置、索引、切片、数学运算、线性代数、随机数等等,具体使用方法可参考官方文档。
2.1.4 广播机制
当对两个形状不同的 Tensor 按元素运算时,可能会触发广播(broadcasting)机制:先适当复制元素使这两个 Tensor 形状相同后再按元素运算。
x = torch.arange(1, 3).view(1, 2)
x
tensor([[1, 2]])
y = torch.arange(1, 4).view(3, 1)
y
tensor([[1],
[2],
[3]])
x + y
tensor([[2, 3],
[3, 4],
[4, 5]])
由于x和y分别是1行2列和3行1列的矩阵,如果要计算x+y,那么x中第一行的2个元素被广播 (复制)到了第二行和第三行,⽽y中第⼀列的3个元素被广播(复制)到了第二列。如此,就可以对2个3行2列的矩阵按元素相加。
2.2 自动求导
PyTorch 中,所有神经网络的核心是 autograd
包。autograd包为张量上的所有操作提供了自动求导机制。它是一个在运行时定义 ( define-by-run )的框架,这意味着反向传播是根据代码如何运行来决定的,并且每次迭代可以是不同的。
经过本节的学习,你将收获:
- autograd的求导机制
- 梯度的反向传播
Autograd简介
torch.Tensor
是这个包的核心类。如果设置它的属性.requires_grad
为 True
,那么它将会追踪对于该张量的所有操作。当完成计算后可以通过调用.backward()
,来自动计算所有的梯度。这个张量的所有梯度将会自动累加到.grad
属性。
注意:在 y.backward() 时,如果 y 是标量,则不需要为 backward() 传入任何参数;否则,需要传入一个与 y 同形的Tensor。
要阻止一个张量被跟踪历史,可以调用.detach()
方法将其与计算历史分离,并阻止它未来的计算记录被跟踪。为了防止跟踪历史记录(和使用内存),可以将代码块包装在 with torch.no_grad():
中。在评估模型时特别有用,因为模型可能具有 requires_grad = True
的可训练的参数,但是我们不需要在此过程中对他们进行梯度计算。
还有一个类对于autograd
的实现非常重要:Function
。Tensor
和Function
互相连接生成了一个无环图 (acyclic graph),它编码了完整的计算历史。每个张量都有一个.grad_fn
属性,该属性引用了创建 Tensor
自身的Function
(除非这个张量是用户手动创建的,即这个张量的grad_fn
是 None
)。下面给出的例子中,张量由用户手动创建,因此grad_fn返回结果是None。
from __future__ import print_function
x = torch.randn(3,3,requires_grad=True)
print(x.grad_fn)
None
如果需要计算导数,可以在 Tensor
上调用 .backward()
。如果Tensor
是一个标量(即它包含一个元素的数据),则不需要为 backward()
指定任何参数,但是如果它有更多的元素,则需要指定一个gradient
参数,该参数是形状匹配的张量。
创建一个张量并设置requires_grad=True
用来追踪其计算历史
x = torch.ones(2, 2, requires_grad=True)
x
tensor([[1., 1.],
[1., 1.]], requires_grad=True)
对这个张量做一次运算:
y = x**2
y
tensor([[1., 1.],
[1., 1.]], grad_fn=<PowBackward0>)
y
是计算的结果,所以它有grad_fn
属性。
y.grad_fn
<PowBackward0 at 0x19e4b9e3e80>
对 y 进行更多操作
z = y * y * 3
z
tensor([[3., 3.],
[3., 3.]], grad_fn=<MulBackward0>)
out = z.mean()
out
tensor(3., grad_fn=<MeanBackward0>)
.requires_grad_(...)
原地改变了现有张量的requires_grad
标志。如果没有指定的话,默认输入的这个标志是False
。
a = torch.randn(2, 2) # 缺失情况下默认 requires_grad = False
a = ((a * 3) / (a - 1))
a.requires_grad
False
a.requires_grad_(True)
a.requires_grad
True
b = (a * a).sum()
b.grad_fn
<SumBackward0 at 0x19e4b9e3bb0>
2.2.1 梯度
现在开始进行反向传播,因为out
是一个标量,因此out.backward()
和out.backward(torch.tensor(1.))
等价。
out.backward()
输出导数d(out)/dx
x.grad
tensor([[3., 3.],
[3., 3.]])
数学上,若有向量函数
y
⃗
=
f
(
x
⃗
)
\vec{y}=f(\vec{x})
y=f(x),那么
y
⃗
\vec{y}
y 关于
x
⃗
\vec{x}
x 的梯度就是一个雅可比矩阵:
$
J=\left(\begin{array}{ccc}\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{array}\right)
$
而 torch.autograd
这个包就是用来计算一些雅可比矩阵的乘积的。例如,如果
v
v
v 是一个标量函数
l
=
g
(
y
⃗
)
l = g(\vec{y})
l=g(y) 的梯度:
$
v=\left(\begin{array}{lll}\frac{\partial l}{\partial y_{1}} & \cdots & \frac{\partial l}{\partial y_{m}}\end{array}\right)
$
由链式法则,我们可以得到:
$
v J=\left(\begin{array}{lll}\frac{\partial l}{\partial y_{1}} & \cdots & \frac{\partial l}{\partial y_{m}}\end{array}\right)\left(\begin{array}{ccc}\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{array}\right)=\left(\begin{array}{lll}\frac{\partial l}{\partial x_{1}} & \cdots & \frac{\partial l}{\partial x_{n}}\end{array}\right)
$
注意:grad在反向传播过程中是累加的(accumulated),这意味着每一次运行反向传播,梯度都会累加之前的梯度,所以一般在反向传播之前需把梯度清零。
# 再来反向传播⼀一次,注意grad是累加的
out2 = x.sum()
out2.backward()
x.grad
tensor([[4., 4.],
[4., 4.]])
out3 = x.sum()
out3.backward()
x.grad
tensor([[5., 5.],
[5., 5.]])
x.grad.data.zero_()
x.grad
tensor([[0., 0.],
[0., 0.]])
现在我们来看一个雅可比向量积的例子:
x = torch.randn(3, requires_grad=True)
x
tensor([-0.1537, -0.5546, 0.8440], requires_grad=True)
y = x * 2
i = 0
while y.data.norm() < 1000:
y = y * 2
i = i + 1
y, i
(tensor([-157.3868, -567.9474, 864.2151], grad_fn=<MulBackward0>), 9)
在这种情况下,y
不再是标量。torch.autograd
不能直接计算完整的雅可比矩阵,但是如果我们只想要雅可比向量积,只需将这个向量作为参数传给 backward:
v = torch.tensor([0.1, 1.0, 0.0001], dtype=torch.float)
y.backward(v)
x.grad
tensor([1.0240e+02, 1.0240e+03, 1.0240e-01])
也可以通过将代码块包装在with torch.no_grad():
中,来阻止 autograd 跟踪设置了.requires_grad=True
的张量的历史记录。
x.requires_grad
True
(x ** 2).requires_grad
True
with torch.no_grad():
print((x ** 2).requires_grad)
False
如果我们想要修改 tensor 的数值,但是又不希望被 autograd 记录(即不会影响反向传播), 那么我们可以对 tensor.data 进行操作。
x = torch.ones(1,requires_grad=True)
x.data # 还是一个tensor
tensor([1.])
x.data.requires_grad # 但是已经是独立于计算图之外
False
y = 2 * x
x.data *= 100 # 只改变了值,不会记录在计算图,所以不会影响梯度传播
y.backward()
x
tensor([10000.], requires_grad=True)
x.grad
tensor([2.])
2.3 并行计算简介
在利用PyTorch做深度学习的过程中,可能会遇到数据量较大无法在单块GPU上完成,或者需要提升计算速度的场景,这时就需要用到并行计算。
经过本节的学习,你将收获:
- 并行计算的简介
- CUDA简介
- 并行计算的三种实现方式
2.3.1 为什么要做并行计算
深度学习的发展离不开算力的发展,GPU的出现让我们的模型可以训练的更快,更好。所以,如何充分利用GPU的性能来提高我们模型学习的效果,这一技能是我们必须要学习的。这一节,我们主要讲的就是PyTorch的并行计算。PyTorch可以在编写完模型之后,让多个GPU来参与训练,减少训练时间。
2.3.2 为什么需要CUDA
CUDA
是我们使用GPU的提供商——NVIDIA提供的GPU并行计算框架。对于GPU本身的编程,使用的是CUDA
语言来实现的。但是,在我们使用PyTorch编写深度学习代码时,使用的CUDA
又是另一个意思。在PyTorch使用 CUDA
表示要开始要求我们的模型或者数据开始使用GPU了。
在编写程序中,当我们使用了 .cuda()
时,其功能是让我们的模型或者数据从CPU迁移到GPU(0)当中,通过GPU开始计算。
注:
- 我们使用GPU时使用的是
.cuda()
而不是使用.gpu()
。这是因为当前GPU的编程接口采用CUDA,但是市面上的GPU并不是都支持CUDA,只有部分NVIDIA的GPU才支持,AMD的GPU编程接口采用的是OpenCL,在现阶段PyTorch并不支持。 - 数据在GPU和CPU之间进行传递时会比较耗时,我们应当尽量避免数据的切换。
- GPU运算很快,但是在使用简单的操作时,我们应该尽量使用CPU去完成。
- 当我们的服务器上有多个GPU,我们应该指明我们使用的GPU是哪一块,如果我们不设置的话,tensor.cuda()方法会默认将tensor保存到第一块GPU上,等价于tensor.cuda(0),这将会导致爆出
out of memory
的错误。我们可以通过以下两种方式继续设置。-
#设置在文件最开始部分 import os os.environ["CUDA_VISIBLE_DEVICE"] = "2" # 设置默认的显卡
-
CUDA_VISBLE_DEVICE=0,1 python train.py # 使用0,1两块GPU
-
2.3.3 常见的并行的方法:
Network partitioning
网络结构分布到不同的设备中(Network partitioning)
在刚开始做模型并行的时候,这个方案使用的比较多。其中主要的思路是,将一个模型的各个部分拆分,然后将不同的部分放入到GPU来做不同任务的计算。其架构如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CYMBYHn4-1660930442992)(./figures/model_parllel.png)]
这里遇到的问题就是,不同模型组件在不同的GPU上时,GPU之间的传输就很重要,对于GPU之间的通信是一个考验。但是GPU的通信在这种密集任务中很难办到,所以这个方式慢慢淡出了视野。
Layer-wise partitioning
同一层的任务分布到不同数据中(Layer-wise partitioning)
第二种方式就是,同一层的模型做一个拆分,让不同的GPU去训练同一层模型的部分任务。其架构如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oLKVNmNo-1660930442993)(./figures/split.png)]
这样可以保证在不同组件之间传输的问题,但是在我们需要大量的训练,同步任务加重的情况下,会出现和第一种方式一样的问题。
Data parallelism
不同的数据分布到不同的设备中,执行相同的任务(Data parallelism)
第三种方式有点不一样,它的逻辑是,我不再拆分模型,我训练的时候模型都是一整个模型。但是我将输入的数据拆分。所谓的拆分数据就是,同一个模型在不同GPU中训练一部分数据,然后再分别计算一部分数据之后,只需要将输出的数据做一个汇总,然后再反传。其架构如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TzWXt15g-1660930442993)(./figures/data_parllel.png)]
这种方式可以解决之前模式遇到的通讯问题。现在的主流方式是数据并行的方式(Data parallelism)