2.5 Tensor与Autograd
在神经网络中,一个重要内容就是进行参数学习,而参数学习离不开求导,那么PyTorch是如何进行求导的呢?
现在大部分深度学习架构都有自动求导的功能,PyTorch也不例外,torch.autograd包就是用来自动求导的。Autograd包为张量上所有的操作提供了自动求导功能,而torch.Tensor和torch.Function为Autograd的两个核心类,它们相互连接并生成一个有向非循环图。接下来我们先简单介绍Tensor如何实现自动求导,然后介绍计算图,最后用代码来实现这些功能。
2.5.1 自动求导要点
为实现对Tensor自动求导,需考虑如下事项:
1)创建叶子节点(Leaf Node)的Tensor,使用requires_grad参数指定是否记录对其的操作,以便之后利用backward()方法进行梯度求解。requires_grad参数的缺省值为False,如果要对其求导需设置为True,然后与之有依赖关系的节点会自动变为True。
2)可利用requires_grad_()方法修改Tensor的requires_grad属性。可以调用.detach()或with torch.no_grad():,将不再计算张量的梯度,跟踪张量的历史记录。这点在评估模型、测试模型阶段中常常用到。
3)通过运算创建的Tensor(即非叶子节点),会自动被赋予grad_fn属性。该属性表示梯度函数。叶子节点的grad_fn为None。
4)最后得到的Tensor执行backward()函数,此时自动计算各变量的梯度,并将累加结果保存到grad属性中。计算完成后,非叶子节点的梯度自动释放。
5)backward()函数接收参数,该参数应和调用backward()函数的Tensor的维度相同,或者是可broadcast的维度。如果求导的Tensor为标量(即一个数字),则backward中的参数可省略。
6)反向传播的中间缓存会被清空,如果需要进行多次反向传播,需要指定backward中的参数retain_graph=True。多次反向传播时,梯度是累加的。
7)非叶子节点的梯度backward调用后即被清空。
8)可以通过用torch.no_grad()包裹代码块的形式来阻止autograd去跟踪那些标记为.requesgrad=True的张量的历史记录。这步在测试阶段经常使用。
在整个过程中,PyTorch采用计算图的形式进行组织,该计算图为动态图,且在每次前向传播时,将重新构建。其他深度学习架构,如TensorFlow、Keras一般为静态图。接下来我们介绍计算图,用图的形式来描述就更直观了,该计算图为有向无环图(DAG)。
2.5.2 计算图
计算图是一种有向无环图像,用图形方式来表示算子与变量之间的关系,直观高效。如图2-9所示,圆形表示变量,矩阵表示算子。如表达式:z=wx+b,可写成两个表示式:y=wx,则z=y+b,其中x、w、b为变量,是用户创建的变量,不依赖于其他变量,故又称为叶子节点。为计算各叶子节点的梯度,需要把对应的张量参数requires_grad属性设置为True,这样就可自动跟踪其历史记录。y、z是计算得到的变量,非叶子节点,z为根节点。mul和add是算子(或操作或函数)。由这些变量及算子,就构成一个完整的计算过程(或前向传播过程)。
图2-9 正向传播计算图
我们的目标是更新各叶子节点的梯度,根据复合函数导数的链式法则,不难算出各叶子节点的梯度。
PyTorch调用backward()方法,将自动计算各节点的梯度,这是一个反向传播过程,这个过程可用图2-9表示。且在反向传播过程中,autograd沿着图2-10,从当前根节点z反向溯源,利用导数链式法则,计算所有叶子节点的梯度,其梯度值将累加到grad属性中。对非叶子节点的计算操作(或Function)记录在grad_fn属性中,叶子节点的grad_fn值为None。
图2-10 梯度反向传播计算图
下面通过代码来实现这个计算图。
2.5.3 标量反向传播
假设x、w、b都是标量,z=wx+b,对标量z调用backward()方法,我们无须对backward()传入参数。以下是实现自动求导的主要步骤:
1)定义叶子节点及算子节点:
import torch #定义输入张量x x=torch.Tensor([2]) #初始化权重参数W,偏移量b、并设置require_grad属性为True,为自动求导 w=torch.randn(1,requires_grad=True) b=torch.randn(1,requires_grad=True) #实现前向传播 y=torch.mul(w,x) #等价于w*x z=torch.add(y,b) #等价于y+b #查看x,w,b页子节点的requite_grad属性 print("x,w,b的require_grad属性分别为:{},{},{}".format(x.requires_grad,w.requires_grad,b.requires_grad))
运行结果:
x,w,b的require_grad属性分别为:False,True,True
2)查看叶子节点、非叶子节点的其他属性。
#查看非叶子节点的requres_grad属性, print("y,z的requires_grad属性分别为:{},{}".format(y.requires_grad,z.requires_grad)) #因与w,b有依赖关系,故y,z的requires_grad属性也是:True,True #查看各节点是否为叶子节点 print("x,w,b,y,z的是否为叶子节点:{},{},{},{},{}".format(x.is_leaf,w.is_leaf,b.is_leaf,y.is_leaf,z.is_leaf)) #x,w,b,y,z的是否为叶子节点:True,True,True,False,False #查看叶子节点的grad_fn属性 print("x,w,b的grad_fn属性:{},{},{}".format(x.grad_fn,w.grad_fn,b.grad_fn)) #因x,w,b为用户创建的,为通过其他张量计算得到,故x,w,b的grad_fn属性:None,None,None #查看非叶子节点的grad_fn属性 print("y,z的是否为叶子节点:{},{}".format(y.grad_fn,z.grad_fn)) #y,z的是否为叶子节点:<MulBackward0 object at 0x7f923e85dda0>,<AddBackward0 object at 0x7f923e85d9b0>
3)自动求导,实现梯度方向传播,即梯度的反向传播。
#基于z张量进行梯度反向传播,执行backward之后计算图会自动清空, z.backward() #如果需要多次使用backward,需要修改参数retain_graph为True,此时梯度是累加的 #z.backward(retain_graph=True) #查看叶子节点的梯度,x是叶子节点但它无须求导,故其梯度为None print("参数w,b的梯度分别为:{},{},{}".format(w.grad,b.grad,x.grad)) #参数w,b的梯度分别为:tensor([2.]),tensor([1.]),None #非叶子节点的梯度,执行backward之后,会自动清空 print("非叶子节点y,z的梯度分别为:{},{}".format(y.grad,z.grad)) #非叶子节点y,z的梯度分别为:None,None
2.5.4 非标量反向传播
在2.5.3节中介绍了当目标张量为标量时,可以调用backward()方法且无须传入参数。目标张量一般都是标量,如我们经常使用的损失值Loss,一般都是一个标量。但也有非标量的情况,后面将介绍的Deep Dream的目标值就是一个含多个元素的张量。那如何对非标量进行反向传播呢?PyTorch有个简单的规定,不让张量(Tensor)对张量求导,只允许标量对张量求导,因此,如果目标张量对一个非标量调用backward(),则需要传入一个gradient参数,该参数也是张量,而且需要与调用backward()的张量形状相同。那么为什么要传入一个张量gradient呢?
传入这个参数就是为了把张量对张量的求导转换为标量对张量的求导。这有点拗口,我们举一个例子来说,假设目标值为loss=(y1,y2,…,ym),传入的参数为v=(v1,v2,…,vm),那么就可把对loss的求导,转换为对loss*vT标量的求导。即把原来得到的雅可比矩阵(Jacobian)乘以张量vT,便可得到我们需要的梯度矩阵。
backward函数的格式为:
backward(gradient=None, retain_graph=None, create_graph=False)
上面说的可能有点抽象,下面来通过一个实例进行说明。
1)定义叶子节点及计算节点。
import torch #定义叶子节点张量x,形状为1x2 x= torch.tensor([[2, 3]], dtype=torch.float, requires_grad=True) #初始化Jacobian矩阵 J= torch.zeros(2 ,2) #初始化目标张量,形状为1x2 y = torch.zeros(1, 2) #定义y与x之间的映射关系: #y1=x1**2+3*x2,y2=x2**2+2*x1 y[0, 0] = x[0, 0] ** 2 + 3 * x[0 ,1] y[0, 1] = x[0, 1] ** 2 + 2 * x[0, 0]
2)手工计算y对x的梯度。
我们先手工计算一下y对x的梯度,验证PyTorch的backward的结果是否正确。
y对x的梯度是一个雅可比矩阵,我们可通过以下方法进行计算各项的值。
假设x=(x1=2,x2=3),,不难得到:
当x1=2,x2=3时,
所以:
3)调用backward来获取y对x的梯度。
y.backward(torch.Tensor([[1, 1]])) print(x.grad) #结果为tensor([[6., 9.]])
这个结果与我们手工运算的不符,显然这个结果是错误的,那错在哪里呢?这个结果的计算过程是:
由此可见,错在v的取值,通过这种方式得到的并不是y对x的梯度。这里我们可以分成两步计算。首先让v=(1,0)得到y1对x的梯度,然后使v=(0,1),得到y2对x的梯度。这里因需要重复使用backward(),需要使参数retain_graph=True,具体代码如下:
#生成y1对x的梯度 y.backward(torch.Tensor([[1, 0]]),retain_graph=True) J[0]=x.grad #梯度是累加的,故需要对x的梯度清零 x.grad = torch.zeros_like(x.grad) #生成y2对x的梯度 y.backward(torch.Tensor([[0, 1]])) J[1]=x.grad #显示jacobian矩阵的值 print(J)
运行结果
tensor([[4., 3.],[2., 6.]])
这个结果与手工运行的式(2-5)结果一致。