十三、PyTorch基础:Tensor和Autograd
1、Tensor
Tensor,又名张量,是Theano、TensorFlow、 Torch和MxNet中重要的数据结构。从工程角度来讲,可简单地认为它就是一个数组,且支持高效的科学计算。它可以是一个数(标量)、一维数组(向量)、二维数组(矩阵)和更高维的数组(高阶数据)。Tensor和Numpy的ndarrays类似,但PyTorch的tensor支持GPU加速。
1.1、基础操作
从接口的角度来讲,对tensor的操作可分为两类:
torch.function
,如torch.save
等。- 另一类是
tensor.function
,如tensor.view
等。
为方便使用,对tensor的大部分操作同时支持这两类接口,接下来不做具体区分,如torch.sum (torch.sum(a, b))
与tensor.sum (a.sum(b))
功能等价。
从存储的角度来讲,对tensor的操作又可分为两类:
- 不会修改自身的数据,如
a.add(b)
, 加法的结果会返回一个新的tensor。 - 会修改自身的数据,如
a.add_(b)
, 加法的结果仍存储在a中,a被修改了。
函数名以_
结尾的都是inplace方式, 即会修改调用者自己的数据,在实际应用中需加以区分。
(1)创建Tensor
在PyTorch中新建tensor的方法有很多,具体如表所示。
这些创建方法都可以在创建的时候指定数据类型dtype和存放device(cpu/gpu).其中使用Tensor
函数新建tensor是最复杂多变的方式,它既可以接收一个list,并根据list的数据新建tensor,也能根据指定的形状新建tensor,还能传入其他的tensor,下面举几个例子。
# 指定tensor的形状
a = t.Tensor(2, 3)
a # 数值取决于内存空间的状态,print时候可能overflow
# tensor([[-8.5769e-19, 4.5622e-41, 0.0000e+00],
# [ 0.0000e+00, 0.0000e+00, 0.0000e+00]])
# 用list的数据创建tensor
b = t.Tensor([[1,2,3],[4,5,6]])
b
# tensor([[1., 2., 3.],
# [4., 5., 6.]])
b.tolist() # 把tensor转为list
# [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]
# tensor.shape等价于tensor.size()
b_size = b.size()
b_size # torch.Size([2, 3])
# 需要注意的是,t.Tensor(*sizes)创建tensor时,系统不会马上分配空间,只是会计算剩余的内存是否足够使用,
# 使用到tensor时才会分配,而其它操作都是在创建完tensor之后马上进行空间分配。
(2)常用Tensor操作
通过tensor.view
方法可以调整tensor的形状,但必须保证调整前后元素总数一致。view
不会修改自身的数据,返回的新tensor与源tensor共享内存,也即更改其中的一个,另外一个也会跟着改变。在实际应用中可能经常需要添加或减少某一维度,这时候squeeze
(减少)和unsqueeze
(增加)两个函数就派上用场了。
a = t.arange(0, 6)
a.view(2, 3)
# tensor([[0, 1, 2],
# [3, 4, 5]])
b = a.view(-1, 3) # 当某一维为-1的时候,自动推断这个维度的大小
b.shape
# torch.Size([2, 3])
b.unsqueeze(1) # 注意形状,在第1维(下标从0开始)上增加“1” ,-2表示倒数第二个维度增加一个维度
# 等价于 b[:,None]
b[:, None].shape
# torch.Size([2, 1, 3])
c = b.view(1, 1, 1, 2, 3)
c.squeeze(0) # 只去除第0维(第一维)
# tensor([[[[0, 1, 2],
# [3, 4, 5]]]])
c.squeeze() # 只去除该维度上的大小为1的维度
# tensor([[0, 1, 2],
# [3, 4, 5]])
a[1] = 100
b # a修改,b作为view之后的,也会跟着修改
# tensor([[ 0, 100, 2],
# [ 3, 4, 5]])
# resize是另一种可用来调整size的方法,但与view不同,它可以修改tensor的大小。
# 如果新大小超过了原大小,会自动分配新的内存空间,而如果新大小小于原大小,则之前的数据依旧会被保存,看一个例子。
b.resize_(1, 3)
# tensor([[ 0, 100, 2]])
b.resize_(3, 3) # 旧的数据依旧保存着,多出的大小会分配新空间
# tensor([[ 0, 100, 2],
# [ 3, 4, 5],
# [ 0, 0, 112]])
(3)索引操作
Tensor支持与numpy.ndarray类似的索引操作,语法上也类似,如无特殊说明,索引出来的结果与原tensor共享内存,也即修改一个,另一个会跟着修改。
其他常用的选择函数如表所示:
(4)Tensor类型
Tensor有不同的数据类型,如下表所示,每种类型分别对应有CPU和GPU版本(HalfTensor除外)。默认的tensor是FloatTensor,可通过t.set_default_tensor_type
来修改默认tensor类型(如果默认类型为GPU tensor,则所有操作都将在GPU上进行)。
Tensor的类型对分析内存占用很有帮助。HalfTensor是专门为GPU版本设计的,同样的元素个数,显存占用只有FloatTensor的一半,所以可以极大缓解GPU显存不足的问题,但由于HalfTensor所能表示的数值大小和精度有限^2,所以可能出现溢出等问题。
各数据类型之间可以互相转换,type(new_type)
是通用的做法,同时还有float
、long
、half
等快捷方法。CPU tensor与GPU tensor之间的互相转换通过tensor.cuda
和tensor.cpu
方法实现,此外还可以使用tensor.to(device)
。Tensor还有一个new
方法,用法与t.Tensor
一样,会调用该tensor对应类型的构造函数,生成与当前tensor类型一致的tensor。
torch.*_like(tensora)
可以生成和tensora
拥有同样属性(类型,形状,cpu/gpu)的新tensor。 tensor.new_*(new_shape)
新建一个不同形状的tensor。
(5)逐元素操作
这部分操作会对tensor的每一个元素(point-wise,又名element-wise)进行操作,此类操作的输入与输出形状一致。常用的操作如表所示。
对于很多操作,例如div、mul、pow、fmod等,PyTorch都实现了运算符重载,所以可以直接使用运算符。如a ** 2
等价于torch.pow(a,2)
, a * 2
等价于torch.mul(a,2)
。
(6)归并操作
此类操作会使输出形状小于输入形状,并可以沿着某一维度进行指定操作。如加法sum
,既可以计算整个tensor的和,也可以计算tensor中每一行或每一列的和。常用的归并操作如表所示。
以上大多数函数都有一个参数**dim
**(指定操作的维度),用来指定这些操作是在哪个维度上执行的。关于dim(对应于Numpy中的axis)的解释众说纷纭,这里提供一个简单的记忆方式:
(7)比较
比较函数中有一些是逐元素比较,操作类似于逐元素操作,还有一些则类似于归并操作。常用比较函数如表所示。
表中第一行的比较操作已经实现了运算符重载,因此可以使用a>=b
、a>b
、a!=b
、a==b
,其返回结果是一个ByteTensor
,可用来选取元素。max/min这两个操作比较特殊,以max来说,它有以下三种使用情况:
至于比较一个tensor和一个数,可以使用clamp函数。下面举例说明:
1.2、Tensor和Numpy
Tensor和Numpy数组之间具有很高的相似性,彼此之间的互操作也非常简单高效。需要注意的是,Numpy和Tensor共享内存。所以当遇到Tensor不支持的操作时,可先转成Numpy数组,处理后再转回tensor,其转换开销很小。
广播法则(broadcast)是科学运算中经常使用的一个技巧,它在快速执行向量化的同时不会占用额外的内存/显存。 Numpy的广播法则定义如下:
- 让所有输入数组都向其中shape最长的数组看齐,shape中不足的部分通过在前面加1补齐
- 两个数组要么在某一个维度的长度一致,要么其中一个为1,否则不能计算
- 当输入数组的某个维度的长度为1时,计算时沿此维度复制扩充成一样的形状
PyTorch当前已经支持了自动广播法则,但还是建议通过以下两个函数的组合手动实现广播法则,这样更直观,更不易出错:
unsqueeze
或者view
,或者tensor[None],:为数据某一维的形状补1,实现法则1expand
或者expand_as
,重复数组,实现法则3;该操作不会复制数组,所以不会占用额外的空间。
注意:repeat实现与expand相类似的功能,但是repeat会把相同数据复制多份,因此会占用额外的空间。
1.3、其他内容
(1)GPU/CPU
tensor可以很随意的在gpu/cpu上传输。使用tensor.cuda(device_id)
或者tensor.cpu()
。另外一个更通用的方法是tensor.to(device)
。
注意:
-
- 尽量使用
tensor.to(device)
, 将device
设为一个可配置的参数,这样可以很轻松的使程序同时兼容GPU和CPU - 数据在GPU之中传输的速度要远快于内存(CPU)到显存(GPU), 所以尽量避免频繁的在内存和显存中传输数据。
- 尽量使用
(2)持久化
Tensor的保存和加载十分的简单,使用t.save和t.load即可完成相应的功能。在save/load时可指定使用的pickle
模块,在load时还可将GPU tensor映射到CPU或其它GPU上。
(3)向量化
向量化可极大提高科学运算的效率,Python本身是一门高级语言,使用很方便,但这也意味着很多操作很低效,尤其是for
循环。在科学计算程序中应当极力避免使用Python原生的for循环
。
此外还有以下几点需要注意:
-
- 大多数
t.function
都有一个参数out
,这时候产生的结果将保存在out指定tensor之中。 t.set_num_threads
可以设置PyTorch进行CPU多线程并行计算时候所占用的线程数,这个可以用来限制PyTorch所占用的CPU数目。t.set_printoptions
可以用来设置打印tensor时的数值精度和格式。
- 大多数
1.4、示例:线性回归
线性回归是机器学习入门知识,应用十分广泛。线性回归利用数理统计中回归分析,来确定两种或两种以上变量间相互依赖的定量关系的,其表达形式为y=wx+b+e,e为误差服从均值为0的正态分布。首先让我们来确认线性回归的损失函数:
然后利用随机梯度下降法更新参数w和b来最小化损失函数,最终学得w和b的数值。
import torch as t
%matplotlib inline
from matplotlib import pyplot as plt
from IPython import display
device = t.device('cpu') #如果你想用gpu,改成t.device('cuda:0')
# 设置随机数种子,保证在不同电脑上运行时下面的输出一致
t.manual_seed(1000)
def get_fake_data(batch_size=8):
''' 产生随机数据:y=x*2+3,加上了一些噪声'''
x = t.rand(batch_size, 1, device=device) * 5
y = x * 2 + 3 + t.randn(batch_size, 1, device=device)
return x, y
# 来看看产生的x-y分布
x, y = get_fake_data(batch_size=16)
plt.scatter(x.squeeze().cpu().numpy(), y.squeeze().cpu().numpy())
# 随机初始化参数
w = t.rand(1, 1).to(device)
b = t.zeros(1, 1).to(device)
lr =0.02 # 学习率
for ii in range(500):
x, y = get_fake_data(batch_size=4)
# forward:计算loss
y_pred = x.mm(w) + b.expand_as(y) # x@W等价于x.mm(w);for python3 only
loss = 0.5 * (y_pred - y) ** 2 # 均方误差
loss = loss.mean()
# backward:手动计算梯度
dloss = 1
dy_pred = dloss * (y_pred - y)
dw = x.t().mm(dy_pred)
db = dy_pred.sum()
# 更新参数
w.sub_(lr * dw)
b.sub_(lr * db)
if ii%50 ==0:
# 画图
display.clear_output(wait=True)
x = t.arange(0, 6).view(-1, 1)
y = x.float().mm(w) + b.expand_as(x)
plt.plot(x.cpu().numpy(), y.cpu().numpy()) # predicted
x2, y2 = get_fake_data(batch_size=32)
plt.scatter(x2.numpy(), y2.numpy()) # true data
plt.xlim(0, 5)
plt.ylim(0, 13)
plt.show()
plt.pause(0.5)
print('w: ', w.item(), 'b: ', b.item())
可见程序已经基本学出w=2、b=3,并且图中直线和数据已经实现较好的拟合。
2、AUTOGRAD
autograd
是 PyTorch 中自动求导机制的核心部分,它能够根据计算图自动计算张量的梯度。其主要目的是为深度学习模型的反向传播(backpropagation)计算提供自动微分功能。
以下是相关的关键概念:
-
- 计算图(Computational Graph): 当你对张量进行操作时,
autograd
会记录操作并构建一个有向无环图(DAG),称为计算图。节点表示张量,边表示操作。这个图用来追踪每个张量的计算历史,从而自动进行微分。 -
反向传播(Backpropagation): 在模型的前向传播结束后,通过调用
backward()
方法,可以从损失开始,沿着计算图反向计算各个张量的梯度。这是深度学习模型更新参数的关键步骤。 -
requires_grad
属性:- 每个张量都有一个
requires_grad
属性,如果设置为True
,表示 PyTorch 将为这个张量记录计算操作并允许求导。 - 在训练模型时,通常只需要对模型的参数(如权重)设置
requires_grad=True
,以便在反向传播时更新这些参数。
- 每个张量都有一个
-
.grad
属性:- 当反向传播结束后,张量的
.grad
属性会存储该张量对应的梯度。使用这些梯度,可以进行参数更新(如通过梯度下降法)。
- 当反向传播结束后,张量的
-
torch.no_grad()
:- 在模型评估或者预测阶段,你可以使用
torch.no_grad()
来关闭自动求导机制。因为在这些阶段不需要计算梯度,这样可以节省内存和加快计算。
- 在模型评估或者预测阶段,你可以使用
- 计算图(Computational Graph): 当你对张量进行操作时,
autograd
的工作流程:
-
- 前向传播:当进行前向传播时,
autograd
记录操作,构建计算图。 - 反向传播:调用
.backward()
时,autograd
从损失开始,遍历计算图,计算每个需要梯度的张量的导数。 - 梯度更新:利用这些计算得到的梯度,使用优化器(如
SGD
或Adam
)更新模型参数。
- 前向传播:当进行前向传播时,
2.1 requires_grad
PyTorch在autograd模块中实现了计算图的相关功能,autograd中的核心数据结构是Variable。我们可以认为需要求导(requires_grad)的tensor即Variable. autograd记录对tensor的操作记录用来构建计算图。
Variable提供了大部分tensor支持的函数,但其不支持部分inplace
函数,因这些函数会修改tensor自身,而在反向传播中,variable需要缓存原来的tensor来计算反向传播梯度。如果想要计算各个Variable的梯度,只需调用根节点variable的backward
方法,autograd会自动沿着计算图反向传播,计算每一个叶子节点的梯度。
from __future__ import print_function
import torch as t
#在创建tensor的时候指定requires_grad
a = t.randn(3,4, requires_grad=True)
# 或者
a = t.randn(3,4).requires_grad_()
# 或者
a = t.randn(3,4)
a.requires_grad=True
b = t.zeros(3,4).requires_grad_()
# 也可写成c = a + b
c = a.add(b)
# 此处虽然没有指定c需要求导,但c依赖于a,而a需要求导,
# 因此c的requires_grad属性会自动设为True
a.requires_grad, b.requires_grad, c.requires_grad
# (True, True, True)
# 由用户创建的variable属于叶子节点(用户直接创建的),对应的grad_fn是None
# is_leaf属性告诉我们一个张量是否是计算图的起点
a.is_leaf, b.is_leaf, c.is_leaf
# (True, True, False)
# c.grad是None, 因c不是叶子节点,它的梯度是用来计算a的梯度
# 所以虽然c.requires_grad = True,但其梯度计算完之后即被释放
c.grad is None # True
2.2 计算图
PyTorch中autograd
的底层采用了计算图,计算图是一种特殊的有向无环图(DAG),用于记录算子与变量之间的关系。一般用矩形表示算子,椭圆形表示变量。如表达式z = wx + b可分解为y = wx和z = y + b,其计算图如下图所示,图中MUL
,ADD
都是算子,w,x,b即变量。
如上有向无环图中,X和b是叶子节点(leaf node),这些节点通常由用户自己创建,不依赖于其他变量。z称为根节点,是计算图的最终目标。利用链式法则很容易求得各个叶子节点的梯度。
而有了计算图,上述链式求导即可利用计算图的反向传播自动完成,其过程如下图所示。
在PyTorch实现中,autograd会随着用户的操作,记录生成当前variable的所有操作,并由此建立一个有向无环图。用户每进行一个操作,相应的计算图就会发生改变。更底层的实现中,图中记录了操作Function
,每一个变量在图中的位置可通过其grad_fn
属性在图中的位置推测得到。在反向传播过程中,autograd沿着这个图从当前变量(根节点z)溯源,可以利用链式求导法则计算所有叶子节点的梯度。每一个前向传播操作的函数都有与之对应的反向传播函数用来计算输入的各个variable的梯度,这些函数的函数名通常以Backward
结尾。
变量的requires_grad
属性默认为False,如果某一个节点requires_grad被设置为True,那么所有依赖它的节点requires_grad
都是True。这其实很好理解,对于x→y→z,x.requires_grad = True,当需要计算∂z∂x时,根据链式法则自然也需要求∂z∂y,所以y.requires_grad会被自动标为True.
有些时候我们可能不希望autograd对tensor求导。认为求导需要缓存许多中间结构,增加额外的内存/显存开销,那么我们可以关闭自动求导。对于不需要反向传播的情景(如inference,即测试推理时),关闭自动求导可实现一定程度的速度提升,并节省约一半显存,因其不需要分配空间计算梯度。
当我们希望对tensor,但是又不希望被记录, 可以使用tensor.data 或者tensor.detach(),另外值得注意的是,只有对tensor的操作才能使用autograd,如果对tensor的data直接进行操作,将无法使用反向传播。除了对参数初始化,一般我们不会修改tensor.data的值。
在反向传播过程中非叶子节点的导数计算完之后即被清空。若想查看这些变量的梯度,有两种方法:
- 使用autograd.grad函数
- 使用hook
autograd.grad
和hook
方法都是很强大的工具,更详细的用法参考官方api文档,这里举例说明基础的使用。推荐使用hook
方法,但是在实际使用中应尽量避免修改grad的值。
在PyTorch中计算图的特点可总结如下:
- autograd根据用户对tensor的操作构建其计算图。对变量的操作抽象为
Function
。 - 对于那些不是任何函数(Function)的输出,由用户创建的节点称为叶子节点,叶子节点的
grad_fn
为None。叶子节点中需要求导的tensor,具有AccumulateGrad
标识,因其梯度是累加的。 - tensor默认是不需要求导的,即
requires_grad
属性默认为False,如果某一个节点requires_grad被设置为True,那么所有依赖它的节点requires_grad
都为True。 - 多次反向传播时,梯度是累加的。反向传播的中间缓存会被清空,为进行多次反向传播需指定
retain_graph
=True来保存这些缓存。 - 非叶子节点的梯度计算完之后即被清空,可以使用
autograd.grad
或hook
技术获取非叶子节点的值。 - tensor的grad与data形状一致,应避免直接修改tensor.data,因为对data的直接操作无法利用autograd进行反向传播
- 反向传播函数
backward
的参数grad_variables
可以看成链式求导的中间结果,如果是标量,可以省略,默认为1 - PyTorch采用动态图设计,可以很方便地查看中间层的输出,动态的设计计算图结构。
2.3 扩展autograd
目前绝大多数函数都可以使用autograd
实现反向求导,但如果需要自己写一个复杂的函数,不支持自动反向求导怎么办? 写一个Function
,实现它的前向传播和反向传播代码,Function
对应于计算图中的矩形, 它接收参数,计算并返回结果。下面给出一个例子。
class Mul(Function):
@staticmethod
def forward(ctx, w, x, b, x_requires_grad = True):
ctx.x_requires_grad = x_requires_grad
ctx.save_for_backward(w,x)
output = w * x + b
return output
@staticmethod
def backward(ctx, grad_output):
w,x = ctx.saved_tensors
grad_w = grad_output * x
if ctx.x_requires_grad:
grad_x = grad_output * w
else:
grad_x = None
grad_b = grad_output * 1
return grad_w, grad_x, grad_b, None
分析如下:
- 自定义的Function需要继承autograd.Function,没有构造函数
__init__
,forward和backward函数都是静态方法 - backward函数的输出和forward函数的输入一一对应,backward函数的输入和forward函数的输出一一对应
- backward函数的grad_output参数代表了来自后续层的梯度即t.autograd.backward中的
grad_variables
- 如果某一个输入不需要求导,直接返回None,如forward中的输入参数x_requires_grad显然无法对它求导,直接返回None即可
- 反向传播可能需要利用前向传播的某些中间结果,需要进行保存,否则前向传播结束后这些对象即被释放
Function的使用利用Function.apply(variable),如下所示:
import torch
class MyFunction(torch.autograd.Function):
@staticmethod
def forward(ctx, x):
ctx.save_for_backward(x) # 保存输入
return x ** 2 # 返回 x 的平方
@staticmethod
def backward(ctx, grad_output):
x, = ctx.saved_tensors
grad_input = 2 * x * grad_output # 计算一阶梯度
return grad_input
# 使用自定义的操作
x = torch.tensor(2.0, requires_grad=True)
y = MyFunction.apply(x)
y.backward() # 计算一阶导数
print(x.grad) # 输出: 4.0 (即 2 * 2)
# 如果我们需要计算二阶导数
x.grad.zero_() # 清空梯度
y.backward() # 再次计算一阶导数
x.grad.backward() # 计算二阶导数
print(x.grad) # 输出: 2.0