PyTorch 101, Part1:计算图的理解、自动微分和Autograd模块

翻译原文:https://blog.paperspace.com/pytorch-101-understanding-graphs-and-automatic-differentiation/

说在前面:这篇文章是Ayoosh Kathuria关于PyTorch教程的系列文章,非常喜欢他的系列教程,讲的很详细很有启发。因此把原文的系列教程翻译了下来,并结合了自己的部分理解。因为本人能力有限,难免和原文表达的含义有所出入,仅仅作为交流使用。


PyTorch 101Part1:计算图的理解、自动微分和Autograd模块

PyTorch是最重要的深度学习库之一。它是深度学习研究不错的选择,并且随着时间的推移,越来越多的公司和研究实验室都在采用这个库。

        在这个系列教程中,我会向你们介绍PyTorch、如何充分利用这个库以及围绕着它所构建的工具生态系统。我们首先会涉及基本的构造模块,然后教你如何快速的构造定制的架构。最后我们会用两篇文章总结如何去扩展你的代码,并且如何去调试出错的代码。

        这是我们PyTorch 101系列文章的第一部分。

  1. 计算图理解、自动微分和Autograd模块
  2. 构造你的第一个神经网络
  3. 用PyTroch走的更深
  4. 内存管理以及使用多GPU
  5. 理解Hooks

      你可以在GitHub仓库这里得到这篇文章(以及其他文章)的所有代码。


目录

PyTorch 101,Part1:计算图的理解、自动微分和Autograd模块

1 前沿知识

2 自动微分

3 一个简单例子

4 计算图

5 计算梯度

6  PyTorch Autograd

   6.1 Tensor

   6.2 函数

7 PyTorch的图和TensorFlow的图有什么不同?

8 一些技巧

    8.1 requires_grad

    8.2 torch.no_grad()

9 结论


 

1 前沿知识

  1. 链式求导法则
  2. 基本了解深度学习
  3. PyTorch 1.0

2 自动微分

      很多PyTorch的系列文章都是以讨论什么是网络的基本架构开始的。但是,我喜欢先讨论一下自动微分。

      自动微分不仅是PyTorch,而且还是每一个深度学习库的一个构造块。在我看来,PyTorch的自动微分机制叫做Autograd,它是一个很棒的工具来理解自动微分是如何工作的。这不仅有助于你更好的理解PyTorch,而且更好的理解其他深度学习库。

      现在的神经网络架构有上百万个可以学习的参数。从计算的角度来说,训练一个神经网络由两个阶段组成:

  1. 前向传播去计算损失函数
  2. 反向传播去计算可学习参数的梯度

      前向传播非常的直接。一层的输出就是另一层的输入,依次类推。反向传播稍微有点复杂,因为它需要我们使用链式求导法则去计算权重相对于损失函数的梯度。

3 一个简单例子

      我们举一个非常简单的神经网络的例子,它有5个神经元。我们的神经网络就像下面这样。

下面的等式描述了我们的神经网络:

让我们来计算一下每一个可学习的参数w的梯度:

所有的这些梯度都已经使用链式法则求得了。注意,上面所有的等式右边的单独的梯度都可以直接得到,因为梯度的分子是分母的显函数。

4 计算图

      因为上面的例子非常简单,所以我们可以手算一下网络的梯度。设想一下,如果现在你有一个152层网络,或者,如果这个网络有多个分支,手算可能就会比较麻烦了。当我们设计软件去实现神经网络的时候,我们希望有一个方法,不管网络结构是什么类型的,我们都可以无缝衔接地去计算梯度。所以,当网络发生变化的时候,程序员不需要去手动计算梯度,只需要通过软件来实现。

        我们用计算图的数据结构来实现这个想法。计算图和我们在上面制作的示意图非常的相似。但是,计算图中的结点表示的是基本的运算。除了需要表示用户自定义的变量外,这些结点基本上是数学运算符(加减乘除)。

      注意为了表述清晰,在图中我们已经标记了叶子变量a,w1,w2,w3,w4。但是需要注意的是,他们不是计算图中的一部分。在我们的图中,他们代表一个用户自定义变量的特殊情况,我们仅仅作为一个例外(图中蓝色的部分是结点,紫色的是叶子结点,叶子结点并不是计算图的一部分)。

        变量b,c和d作为数学运算的结果,然而a,w1,w2,w3和w4由用户自己初始化的。因为他们不能由数学运算来创建,和他们相关的创建的结点由他们自己的名字表示,图中所有的叶子结点都是这样。

5 计算梯度

      现在,我准备讲一下,如何使用一个计算图去计算梯度。

      除了叶子结点,计算图中的每个结点都可以看做是一个函数,它接收一个输入然后计算一个输出。假设图的结点,它可以从w4c和w3b中计算得到变量d。因此,我们可以这样写:

上面的函数可以使用计算图来表示:

现在,我们可以轻松地计算出f相对于它的输入的梯度。𝜕𝑓/(𝜕𝑤3𝑏)和𝜕𝑓/(𝜕𝑤4𝑐)(它们都是1)现在,用它们相对的梯度给输入结点的各个边贴上标签,像下面图片一样:

我们给整张图贴上标签。这张图看起来是这样的: 

      接下来,我们描述计算图中所有结点相对于损失L的导数的算法。假设我们想要去计算导数𝜕𝑓/(𝜕𝑤4):

  1. 我们首先向后回溯从d到w4的所有可能的路径
  2. 在这里只有一个路径
  3. 我们沿着路径乘以经过所有的边

      你看,这个乘积就是和我们使用链式求导法则得到的一样。如果有不止一条路径从L到一个变量,然后,我们沿着每一个路径乘以边,接着把它们加在一起。举例来说,𝜕𝐿/𝜕𝑎就是:

6  PyTorch Autograd

      现在,我们已经理解什么是计算图了,我们回到PyTorch,理解在PyTorch中上面的链式求导法则是如何实现的:

     6.1 Tensor

        Tensor是一个数据结构,它是PyTorch的一个基本的构造块。Tensor和numpy arrays非常像,除了Tensors可以充分利用GPU的平行计算能力,这点和numpy不一样,Tensor其他的语法和numpy arrays非常类似。

In [1]: import torch

In [2]: tsr = torch.Tensor(3,5)

In [3]: tsr
Out[3]: 
tensor([[ 0.0000e+00,  0.0000e+00,  8.4452e-29, -1.0842e-19,  1.2413e-35],
        [ 1.4013e-45,  1.2416e-35,  1.4013e-45,  2.3331e-35,  1.4013e-45],
        [ 1.0108e-36,  1.4013e-45,  8.3641e-37,  1.4013e-45,  1.0040e-36]])

Tensor它自己就像一个numpy ndarray。这个数据结构可以让你更快速的进行线性运算。如果你想让PyTorch去创建和这些运算符相关的图,你要设置Tensor的requires_grad的属性为True,这样才会计算这个Tensor的梯度。

      在这里,这个API可能稍微有点让人匪夷所思。在PyTorch中,有很多方法去初始化一个Tensors。虽然你可以使用一些方法,在它的构造体中显示定义requires_grad,但是,其他方法要求你在创建Tensor之后,手动去设置:

>> t1 = torch.randn((3,3), requires_grad = True) 

>> t2 = torch.FloatTensor(3,3) # No way to specify requires_grad while initiating 
>> t2.requires_grad = True

     requires_grad是可传递的。这意味着当一个Tensor由其他Tensors运算得到的时候,假设这些进行运算的Tensors中至少有一个的requires_grad为True,那么结果Tensor的requires_grad就会置为True。

      每个Tensor都有一个叫做grad_fn的属性,它指的是变量的数学运算。如果requires_grad置为了False,那么grad_fn也会是None。

      在我们的例子中,d的梯度函数是一个加法运算,因为f将它的输入都加在一起了。注意,加法运算也是我们图中的结点,它的输出是d。如果我们的Tensor是一个叶子结点(由用户初始化),那么grad_fn也是None。

import torch 

a = torch.randn((3,3), requires_grad = True)

w1 = torch.randn((3,3), requires_grad = True)
w2 = torch.randn((3,3), requires_grad = True)
w3 = torch.randn((3,3), requires_grad = True)
w4 = torch.randn((3,3), requires_grad = True)

b = w1*a 
c = w2*a

d = w3*b + w4*c 

L = 10 - d

print("The grad fn for a is", a.grad_fn)
print("The grad fn for d is", d.grad_fn)

如果运行上面的代码,你就会得到下面的输出:

# 因为a和w1,w2都是有用户直接指定,他们是叶子(leaf node),因此不存在grad_fn
The grad fn for a is None
# d是在前向传播中是通过加法运算得到的,因此有grad_fn
The grad fn for d is <AddBackward0 object at 0x1033afe48>

       通过上面的例子,我们可以得到两个信息

  • 叶子结点的requires_grad为True,但是不存在grad_fn
  • 结点的requires_grad为True,并且存在grad_fn

我们可以使用成员函数is_leaf来判断一个变量是否是叶子Tensor,使用data.requires_grad来判断一个张量是否可以进行梯度计算。

   6.2 函数

      在PyTorch中,所有的数学运算都需要实现torch.nn.Autograd.Function类。这个类有两个我们需要关注的成员函数。

      第一个是forward函数,这个函数使用输入简单地计算输出。

      这个backward函数接收它前面部分网络的梯度。正如你看到的,从函数f反向传播来的梯度,基本上是从它前面的层到f的反向传播的梯度乘以以f为输出的相对于它的输入的局部梯度。也正是backward函数在做的事情。

      让我们再次思考我们的例子:

  1. d是我们的Tensor。它的grad_fn是<ThAddBackward>。这是一个基本的加法运算,因为构造d的函数,将输入都加在了一起。
  2. 它的grad_fn的forward函数接收了输入w3b和w4c,并且把它们加在一起,这个值基本地保存在d中。
  3. <ThAddBackward>的backward函数接收从前层传入的梯度作为它的输入。这个就是从L到d沿着边的主要的𝜕𝐿/𝜕𝑑。这个梯度也是L相对于d的梯度,而且它保存在d的grad属性中。可以使用d.grad来得到。(每个结点中保存着它相对于L的梯度,可以使用d.grad来获得当前的梯度值)
  4. 它与局部梯度𝜕𝑑 / (𝜕𝑤4𝑐)和𝜕𝑑 / (𝜕𝑤3𝑏)进行相乘。
  5. 然后,反向传播分别用局部计算的梯度乘以输入梯度,然后通过激活输入的grad_fn的反向传播方法,把梯度“送”到它的输入。
  6. 举个例子来说,和d相关的反向传播函数的<ThAddBackward>w4*c的grad_fn的反向传播函数(这里,w4*c是中间Tensor,它的grad_fn是<ThMulBackward>)。在调用backward函数的同时,梯度(𝜕𝐿 / 𝜕𝑑)∗(𝜕𝑑 / 𝜕𝑤4𝑐)作为输入传递。
  7. 现在,对于变量w4∗c,(𝜕𝐿 / 𝜕𝑑)∗(𝜕𝑑 / 𝜕𝑤4𝑐)变为了传入的梯度,就像步骤3中𝜕𝐿𝜕𝑑传给d那样,然后循环整个过程。

      在算法上,这里是反向传播在计算图中是怎样进行计算的。

def backward (incoming_gradients):
	self.Tensor.grad = incoming_gradients

	for inp in self.inputs:
		if inp.grad_fn is not None:
			new_incoming_gradients = //
			  incoming_gradient * local_grad(self.Tensor, inp)
			
			inp.grad_fn.backward(new_incoming_gradients)
		else:
			pass

        这里,self.Tensor是由Autograd.Function创建的基本的Tensor,在我们的例子中是d。

        输入梯度和局部梯度在上面都已经定义了。     


       为了在我们的神经网络中计算微分,我们一般会对表示我们损失的Tensor,调用backward函数。然后,我们从代表我们损失的grad_fn的结点开始,反向传播整个图。

     就像上面所描述的那样,但我们回溯的时候,这个backward函数就会穿过整个图递归地调用。一旦我们到达了叶子结点,因为grad_fn是None,但是,我们仅仅暂停这个路径上的回溯。

      在这里需要注意的一件事,如果我们在一个矢量的Tensor上调用backward()函数,PyTorch会报错。这意味着你只能够在标量Tensor上调用backward函数。在我们的例子中,如果我们假设a是一个矢量Tensor,然后我们在L上调用backward函数,它将会报错。

import torch 

a = torch.randn((3,3), requires_grad = True)

w1 = torch.randn((3,3), requires_grad = True)
w2 = torch.randn((3,3), requires_grad = True)
w3 = torch.randn((3,3), requires_grad = True)
w4 = torch.randn((3,3), requires_grad = True)

b = w1*a 
c = w2*a

d = w3*b + w4*c 

L = (10 - d)

L.backward()

       运行上面的代码将会导致下面的错误:

RuntimeError: grad can be implicitly created only for scalar outputs

      这是因为根据定义,梯度可以相对于标量值计算得到。你不能确切地求一个向量相对于另一个向量的微分。这种情况的数学名词称之为雅克比矩阵,但是这超出了这篇文章的范围,我们暂且不讨论。

       这里有两个方法来解决这个问题。

      (1)第一个方法:如果你只是想稍微改变一下上面的代码,只需要将设置L为所有误差的和,我们的问题就解决了。

import torch 

a = torch.randn((3,3), requires_grad = True)

w1 = torch.randn((3,3), requires_grad = True)
w2 = torch.randn((3,3), requires_grad = True)
w3 = torch.randn((3,3), requires_grad = True)
w4 = torch.randn((3,3), requires_grad = True)

b = w1*a 
c = w2*a

d = w3*b + w4*c 

# Replace L = (10 - d) by 
L = (10 -d).sum()

L.backward()

      一旦这样做了,你可以通过调用Tensor的属性grad来获取梯度。

       (2) 第二个方法:因为某些原因,你不得不去在一个矢量函数中调用backward,你可以传入向你正在调用反向传播的张量的形状大小torch.ones

# Replace L.backward() with 
L.backward(torch.ones(L.shape))

      需要注意,backward是如何使用输入梯度作为它的输出的。上面这些让backward以为输入梯度的尺寸和L的尺寸一样,而且能够进行反向传播。

      使用这种方式,我们可以让每个Tensor都有梯度,并且我们可以使用我们选择的优化算法去更新它们。

w1 = w1 - learning_rate * w1.grad

        以此类推

7 PyTorch的图和TensorFlow的图有什么不同?

        PyTorch创建一个称之为动态计算图的东西,它意味着图是动态生成的。

        在调用一个变量的forward函数之前,在图中是不存在Tensor结点的。

a = torch.randn((3,3), requires_grad = True)   #No graph yet, as a is a leaf

w1 = torch.randn((3,3), requires_grad = True)  #Same logic as above

b = w1*a   #Graph with node `mulBackward` is created.

这个图是很多激活Tensors调用foward函数的结果。只有这样,非叶子节点的缓存器才会为图和中间值(稍后用来计算梯度)分配空间。当你调用backward函数的时候,梯度也计算出来了,这些缓存器(对于非叶子结点变量)被释放,然后这个图被杀死了(从某种意义上来说,你不能用它来进行反向传播,因为保存数值去计算梯度的缓存器已经消失了),在 PyTorch 的计算图中,只有叶子结点的变量会保留梯度。而所有中间变量的梯度只被用于反向传播,一旦完成反向传播,中间变量的梯度就将自动释放,从而节约内存(参考自)

      下一次,你会用相同的一组tensors来调用forward函数,从前面运行得到的叶子结点缓存器就会共享,同时,非叶子节点缓存器将会再次创建。

        如果你在一个图上的非叶子节点调用多于一次的backward函数,你就会遇到下面的错误(因为图已经消失了):

RuntimeError: Trying to backward through the graph a second time, 
but the buffers have already been freed. 
Specify retain_graph=True when calling backward the first time.

        这是因为在第一次调用backward函数的时候,非叶子节点缓存器就被杀死了,因此当backward函数第二次调用的时候,没有路径去指引它到叶子结点那里。你可以向backward函数中添加retain_graph = True的声明,来撤销杀死非叶子缓存器。

loss.backward(retain_graph = True)

     如果你像上面那样做了,你就能够在同一个图上再次反向传播,并且也会计算梯度,也就是说下一次你反向传播的时候,这个梯度会加入到上次反向传播保存的梯度中了。


        和TensorFlow使用的静态计算图相比,它在运行程序之前就创建好了图。之后,这个图通过向已经定义好的图中传入数据来运行。

      这个动态图模型让我们能够在运行的时候修改我们网络架构,这是因为只有当这段代码运行的时候,这个图才能够创建。

        这意味着一个图在代码运行的期间,能够再次定义,因为你不能够提前定义。但是,这个在静态图中是不可能的,在代码运行之前要创建图,然后之后仅仅执行。动态图很方便调试,因为很容易定位到你错误的地方。

8 一些技巧

      8.1 requires_grad

      它是Tensor类的一个属性。默认是False。当你要去冻结一些层的时候会变得容易,并且在训练的时候,暂停他们去更新参数。你可以简单设置requires_grad为False,并且这些Tensor不会参与到计算图中。

       因此,没有梯度向它们传播,或者那些依赖于那些梯度流requires_grad的层。当设置为True的时候,requires_grad是可传染的,这意味着尽管一个操作符的操作数的requires_grad置为True,它还是这个结果。

    8.2 torch.no_grad()

      在我们计算梯度的时候,我们需要保存输入值和中间特征,因为后面它们可能需要去计算梯度。

        b = w1*a相对于输入w1和a的梯度分别是a和w1。在反向传播的过程中,我们需要为梯度计算保存这些值。这会影响这个网络的内存的占用。

       在我们运行推理的时候,我们并不计算梯度,因此我们不需要去保存这些值。事实上,在推理的过程中,不需要创建图,因为这会导致无用的内存消耗。

        为了这个要求,PyTorch提供了一个上下文管理器,叫做torch.no_grad

with torch.no_grad:
	inference code goes here 

      在这个上下文管理器下,执行运算不会定义图。

9 结论

在本文的最后部分,我简单总结一下这篇文章的主要内容:

  • 计算图:计算图本质上就是一个数据结构,有了计算图我们可以更直观的表示链式求导法则。
  • Autograd模块:它是PyTorch进行梯度计算的模块,当我们希望一个Tensor可以进行梯度计算的时候,我们要设置这个           Tensor的requires_grad为True。需要注意,计算图中的非叶子结点存在grad_fn,但是,叶子结点(用于创建的变量)           不存在的。
  • no_grad:在我们进行推理的时候,我们是不需要计算梯度的,因此可以在no_grad环境下进行推理。

理解Autograd和计算图是如何工作的,可以更简单地使用PyTorch。有了坚实的基础,下一篇文章中,我们会详细介绍如果创建定制的复杂的架构,如果去创建定制的数据管道和更多有趣的东西。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章