• 问答
  • 技术
  • 实践
  • 资源
【深度学习理论】一文搞透 pytorch 中的 tensor、autograd、反向传播和计算图
技术讨论

作者丨Lyon@知乎(已授权)
来源丨https://zhuanlan.zhihu.com/p/145353262
编辑丨极市平台

前言

本文的主要目标

一遍搞懂反向传播的底层原理,以及其在深度学习框架pytorch中的实现机制。当然一遍搞不定两遍三遍也差不多了~ 这样以后,面试时被问到反向传播、tensor、计算图和autograd的底层原理,你不会慌~当然,我们的主要目的,在于能从底层大致理解整个深度学习框架的设计原理,实现方式,这样在模型训练和优化时,能做到心中有谱!

本文主要内容:

  • 1.前向传播、反向传播和计算图
  • 2.pytorch中的Tensor
  • 3.链式求导法则和Jacobian雅克比矩阵
  • 4.pytorch中的Autograd的机制和底层原理

1.前向传播、反向传播和计算图

1.1前向传播和反向传播

随着各种深度神经网络的兴起,各种深度学习框架也是层出不穷,caffe,pytorch,tensorflow,mxnet,那么这些深度学习框架,帮我们解决了哪些问题呢 ? 以简单的深度神经网络为例,为了完成loss的优化,我们不断以mini-batch的数据送入模型网络中进行以下迭代过程,最终优化网络达到收敛:

  • 1.mini-batch送入网络进行前馈/前向传播后输出的预测值,同真实值/label对比后用loss函数计算出此次迭代的loss
  • 2.loss进行反向传播,送入神经网络模型中之前的每一层,以更新weight矩阵和bias

可见,模型训练的重点过程就两点:前向传播和反向传播,而其中前向传播就是一系列的矩阵+激活函数等的组合运算,比较简单直观;反向传播就稍显复杂了,如果我们不用深度学习框架,单纯的使用numpy也是可以完成大多数的模型训练,因为模型的训练本质上也就是一系列的矩阵运算,不过我们需要自己写方法以进行复杂的梯度计算和更新。(仅仅使用numpy训练模型,速度和效率肯定不如框架,且numpy不可以使用GPU加深计算)

深度学习框架,帮助我们解决的核心问题就是反向传播时的梯度计算和更新。 当然,它们的功能远不止这些,像各种方便的loss函数:交叉熵,MSE均方损失...;各种优化器:sgd,adam...;GPU并行计算加速等;模型的保存恢复可视化等等。

1.2计算图

在深度学习框架中,反向传播的计算依赖于autograd自动微分机制(这里说的自动微分,即指求导/梯度)。而autograd实现的基础,有以下两个部分:

  • 1.数学基础——链式求导法则和雅克比矩阵
  • 2.底层结构基础——由Tensor张量为基础构成的计算图模型(DAG有向无环图)。

在pytorch和tensorflow中,底层结构都是由tensor组成的计算图,虽然框架代码在实际autograd自动求梯度的过程中,并没有显示地构造和展示出计算图,不过其计算路径确实是沿着计算图的路径来进行的。 所以,为了方便理解,我们需要了解计算图的概念。

计算图,即用图的方式来表示计算过程。 这里先看一个简单的计算图:设x,y,z都是shape(3,4)的矩阵,a,b,c分别表示了一系列的矩阵运算过程。a = x ×y;b = a + z;c = np.sum(b)整个运算过程,我们可以用numpy表示如下:

numpy表示

# numpy
import numpy as np
np.random.seed(0)

N, D = 3, 4

x = np.random.randn(N, D)
y = np.random.randn(N, D)
z = np.random.randn(N, D)

a = x * y
b = a + z
c = np.sum(b)

上述的计算过程,我们可以用计算图表示:

如上,蓝绿色的一个个节点构成了一个计算图,节点里的内容是变量或者计算符,这就是一个简单的计算图。同样的计算过程,也可以用pytorch中的tensor来表示:

pytorch表示

import torch
x = torch.randn(N, D, requires_grad=True)
y = torch.randn(N, D)
z = torch.randn(N, D)

a = x * y
b = a + z
c = torch.sum(b)

如果用numpy表示,我们为了求出所有元素的梯度,需要以下几步:

grad_c = 1.0
grad_b = grad_c * np.ones((N, D))
grad_a = grad_b.copy()
grad_z = grad_b.copy()
grad_x = grad_a * y
grad_y = grad_a * x

而这只是一个很简单的情形,当我们面对复杂的神经网络时,仅依靠简单的numpy手动显示地计算梯度,是复杂度爆炸的且无法完成的事情!这时,框架的好处就体现出来了。基于计算图的数据结构使得pytorch可以应对复杂的神经网络,能方便地利用autograd机制来自动求导,这里只需一个.backward\(\)即可自动求出标量对所有变量的梯度,并将梯度值存在各个变量tensor节点中,只需.grad即可读取:

c.backward()
print(x.grad)

# 输出
tensor([[-1.1826e+00, -1.9904e-01,  1.6238e+00,  8.1178e-04],
       [-7.6080e-01,  7.8881e-02,  2.1591e+00,  3.8564e-01],
       [ 4.7460e-01, -3.8614e-01,  1.5341e-01, -6.5654e-01]])

当然计算图的表示方式也是多种多样的,譬如w = x+y+z_就可以用下面两种计算图来表示,表达的意思相同:

下面,我们来看一个pytorch中的简单计算图:

该计算图表示了pytorch中的计算过程:x = 1.0 y = 2.0 z = x × y。其中,紫色节点为计算符;绿色节点为Tensor张量,框中存储的data,grad,is_leaf等为Tensor的部分属性;

  • data 存放的是该张量的数据;
  • requires\_grad: 用于判断该tensor是否需要被跟踪,用以计算梯度,默认为False;
  • grad: 初始为None;requires_grad = False时为None,否则当某out节点调用out.backward()时,生成新tensor节点,存放计算后的 ∂out/∂x 梯度值,梯度值不会自动清空(下次调用out.backward()时可累积)
  • grad\_fn: 反向传播时,用来计算梯度的函数;
  • is\_leaf: 表明该tensor是否为叶子节点

2.Pytorch中的Tensor

上面例子中的计算图,简单介绍了Tensor,实际上Tensor张量就是pytorch中构建计算图的基础,而计算图又构成了前向/反向传播的结构基础。

在Pytorch中,Tensor类在python代码中有定义,其中的很多方法实现是调用底层的c++完成的,不过我们还是可以看一下其定义,大致了解下这个类中有哪些类变量和方法。

class Tensor:
    requires_grad: _bool = ...
    grad: Optional[Tensor] = ...
    data: Tensor = ...
    names: List[str] = ...
    @property
    def dtype(self) -> _dtype: ...
    @property
    def shape(self) -> Size: ...
    @property
    def device(self) -> _device: ...
    @property
    def T(self) -> Tensor: ...
    @property
    def grad_fn(self) -> Optional[Any]: ...
    @property
    def ndim(self) -> _int: ...
    @property
    def layout(self) -> _layout: ...

    def __abs__(self) -> Tensor: ...
    def __add__(self, other: Any) -> Tensor: ...
    @overload
    def __and__(self, other: Number) -> Tensor: ...
    @overload
    def __and__(self, other: Tensor) -> Tensor: ...
    @overload
    def __and__(self, other: Any) -> Tensor: ...
    def __bool__(self) -> builtins.bool: ...
    def __div__(self, other: Any) -> Tensor: ...
        ...
        ...

Tensor类很长,有很多类变量和方法,譬如:requires_grad、grad、data、is_leaf...

类中还有很多\@property注解表示的方法,譬如表示数据类型的dtype表示张量形状的shape、表示张量使用设备类型的device和表示张量梯度计算函数的grad\_fn....这些类方法可以当做属性被.调用,如:tensor.device;而无注解的正常方法是通过方法调用,如tensor.tolist()

tensor = torch.tensor(3.0, requires_grad=True)
print(tensor.requires_grad)
print(tensor.data)
# True
# tensor(3.)
print(tensor.shape)
print(tensor.device)
print(tensor.grad_fn)
# torch.Size([])
# cpu
# None
print(tensor.tolist())
# 3.0

根据官网文档的描述,我们看几个比较重要的变量/方法的含义:

  • dtype 该张量存储的值类型,可选类型见:torch.``dtype
  • device 该张量存放的设备类型,cpu/gpu
  • data 该张量节点存储的值;
  • requires_grad 表示autograd时是否需要计算此tensor的梯度,默认False;
  • grad 存储梯度的值,初始为None
  • grad_fn 反向传播时,用来计算梯度的函数;
  • is_leaf 该张量节点在计算图中是否为叶子节点;

requires_grad

Tensor类变量,布尔值,表示autograd时是否需要计算此tensor的梯度,默认False;用官方文档上的话描述:requires_grad允许从梯度计算中细粒度地排除子图,并可以提高计算效率。

这里需要注意一点,某个操作/tensor构成的模型中,只要有单个输入需要记录梯度(requires_grad=True),则该操作/模型的输出也必然需要记录梯度(否则梯度是无法传递到该tensor上的)。当且仅当某个操作/模型上所有的输入都无需记录梯度时,输出才可以不记录梯度,设置为requires\_grad=False
例子:

>>> y = torch.randn(5, 5)  # requires_grad=False by default
>>> z = torch.randn((5, 5), requires_grad=True)
>>> a = x + y
>>> a.requires_grad
False
>>> b = a + z
>>> b.requires_grad
True

实际应用中,requires_grad有什么用呢?以目标检测为例,通常我们在迁移学习的时候往往采取以下方式:

用预训练的(如ImageNet上)过的基础网络(如VGG/ResNet)做backbone,用作特征提取;基础网络后面接多层卷积网络/全连接层用于特征分类和box回归;训练时冻结backbone骨干网络的权重,不对其进行反向传播,于是我们需要将backbone网络的所有层和参数设置requires_grad = False。

def frozen_basenet(base_net):
    for param in base_net.parameters():
            param.requires_grad = False

grad

Tensor类变量,该变量表示梯度,初始为None;当self第一次调用backward()计算梯度时,生成新tensor节点,存储该属性存放梯度值,且当下次调用backward()时,梯度值可累积。(也可以设置清空)

grad_fn

Tensor类属性方法,反向传播时,用来计算梯度的函数

is_leaf

Tensor类变量,布尔值,标记该tensor是否为叶子节点

  • 按照惯例,所有requires_grad=False的Tensors都为叶子节点;

  • 所有用户显示初始化的Tensors也为叶子节点;

且当用户显示初始化时,如x = torch.tensor(1.0)或x = torch.randn(1,1)方式,表明该tensor不是由各种操作(operation)的结果隐式生成的,故其为叶子节点。
例如:

# 用户显示初始化
>>> a = torch.rand(10, requires_grad=True)
>>> a.is_leaf
True

# 使用.cuda()后,机器隐式地将cpu类型的tensor转换为了cuda类型的tensor
# 且其requires_grad=True故非叶子节点
>>> b = torch.rand(10, requires_grad=True).cuda()
>>> b.is_leaf
False

# c是有操作符 + 隐式生成的tensor,且requires_grad=True,故非叶子节点
>>> c = torch.rand(10, requires_grad=True) + 2
>>> c.is_leaf
False

3.链式求导法则和雅克比矩阵

3.1链式求导法则

链式法则是微积分中的求导法则,用于求一个复合函数的导数,是在微积分的求导运算中一种常用的方法。复合函数的导数将是构成复合这有限个函数在相应点的 导数的乘积,就像锁链一样一环套一环,故称链式法则。

3.1.1复合函数的链式法则

这里给出wiki上的一个例子:求函数 $\displaystyle f(x)=(x^{2}+1)^{3}$ 的导数。

解答:

我们可以直接暴力破解硬求导,也可以构造复合函数,利用链式求导法则来求导,设:
$\displaystyle g(x)=x^{2}+1$
$\displaystyle h(g)=g^{3}\to h(g(x))=g(x)^{3}.$

则原函数可表示为 $\displaystyle f(x)=h(g(x))$ ,应用链式法则后有:

$$
f'(x) = h'(g(x)) ={\partial h \over \partial x} = {\partial h \over \partial g} * {\partial g \over \partial x} =3(g(x))^{2}(2x)=3(x^{2}+1)^{2}(2x)=6x(x^{2}+1)^{2}.
$$

3.1.2神经网络中的链式法则

以上是简单的数学公式中链式求导法则的应用,下面我们看一个简单的神经网络模型中的例子:

一个神经网络中有5个神经元a,b,c,d,L;其中w1\~w4为权重矩阵,L为输出。满足以下计算关系:
b=w1∗a
c=w2∗a
d=w3∗b+w4∗c
L=10−d

组成的前向计算图如下:

求L对w1的偏导?L对a的偏导?

解答:

L对w1的偏导 ${\partial L \over \partial w_1 } = {\partial L \over \partial d } {\partial d \over \partial b } {\partial b \over \partial w_1 } $

L对a的偏导 ${\partial L \over \partial a } = {\partial L \over \partial d } {\partial d \over \partial b } {\partial b \over \partial a } + {\partial L \over \partial d } {\partial d \over \partial c } {\partial c \over \partial a } $

实际上,在pytorch的神经网络模型中,通过反向传播来更新weight和bias的梯度时,计算过程就类似如下的计算图:

我们通过雅克比矩阵,即可表示所有L对所有权重的偏导: $J = [{\partial L \over \partial w_1}, {\partial L \over \partial w_2}, {\partial L \over \partial w_3}, {\partial L \over \partial w_4} ]$

实际上,pytorch计算L对w1的偏导时,正是沿着反向传播计算图的路径执行的:
${\partial L \over \partial w_1 } = {\partial L \over \partial d } {\partial d \over \partial b } {\partial b \over \partial w_1 } $
先求L对d的偏导数,再求d对b的偏导,然后求b对w1的偏导,最后乘积即为所求。

3.2 Jacobian雅克比矩阵

百度百科:

在向量微积分中,Jacobian雅可比矩阵是一阶偏导数以一定方式排列成的矩阵,其行列式称为雅可比行列式。雅可比矩阵的重要性在于它体现了一个可微方程与给出点的最优线性逼近。因此,雅可比矩阵类似于多元函数的导数。
wiki

在矢量运算中,雅克比矩阵是基于函数对所有变量一阶偏导数的数值矩阵,当输入个数 = 输出个数时又称为雅克比行列式。
假设f: ℝ_n_ → ℝ_m 是一个函数,其每个一阶偏导数都存在且属于ℝ_n。函数以x ∈ ℝ_n_ 为输入,以向量 f(x) ∈ ℝ_m_为输出。则f的Jacobian矩阵定义为m×n矩阵,表达如下:

4.动态计算图和Autograd原理

Autograd

在熟悉了计算图、链式求导法则、雅克比矩阵的概念后,我们现在来看下反向传播在pytorch中的核心底层原理——Autograd和动态计算图。Autograd简而言之就是反向的自动微分(求偏导)系统。其实现的基础依赖于两点,前面也说过:

  • 1.数学基础——链式求导法则和雅克比矩阵
  • 2.底层结构基础——由Tensor张量为基础构成的计算图模型(DAG有向无环图)。

以下内容概况自官方文档:how-autograd-encodes-the-history

在用户用Tensor节点定义网络模型时,对Tensor的所有操作(包括tensor之间的关系,tensor的值的改变等)将被记录跟踪,形成一个概念上的前向传播的有向无环图DAG,在图中,输入tensor作为叶子节点,输出tensor作为根节点。反向传播autograd计算梯度时,从根节点开始遍历这些tensors来构造一个反向传播梯度的计算图模型,将计算得到的梯度值更新到上一层的节点,并重复此过程直至所有required=True的tensor变量都得到更新。此过程是从输出到输入节点一层层更新梯度,故称为反向传播。这一层层地求导过程,即隐式地利用了链式法则,最终各个变量的梯度值得以更新,故此过程形象地称为autograd。

有的学习资料里也称为autometic dierentiation(自动分化)或auto di,不过实在不如autograd简洁。

反向传播计算图

从输出节点(根)遍历tensors,使用了栈结构,每个tensor梯度计算的具体方法存放于tensor节点的grad_fn属性中,依据此构建出包含梯度计算方法的反向传播计算图。

静态计算图vs动态计算图

静态计算图
理论上神经网络模型定义好以后就无需更改,当计算图构建好以后,在一轮轮的前向传播/反向传播迭代中可以重复使用此计算图,只不过将计算的梯度值不断更新到每个变量节点处即可,这种方式称为静态计算图,而早期的tensorflow采用的就是静态计算图的方式。

动态计算图
什么是动态计算图 ?和静态图相反,pytorch在设计中采取了动态计算图的方式,即反向传播的计算图是动态更新的。每一轮反向传播开始时(前向传播结束后)都会动态的重新构建一个计算图,当本次反向传播完成后,计算图再次销毁。这种动态更新的方式允许用户在迭代过程中更改网络的形状和大小。

个人简单理解
理论上静态图,更节省内存,速度更快;动态图更灵活自由,不过牺牲了部分速度和内存空间。但实际并不一定,因为深度学习框架不仅仅是计算图,还涉及到其他代码和底层优化,所以究竟是哪种更好,需要自行判断~

总结说明

1.在pytorch的代码实现层面,并没有显示地构造出反向传播的计算图模型,而是从遍历根节点开始就开始了节点遍历+梯度计算/更新的同步过程(并不是等构建完成了计算图才开始节点的梯度计算和更新,而是一边遍历节点一边更新和计算梯度)。当所有节点都更新完成后,从路径上看,节点遍历和梯度更新的顺序,恰恰是沿着概念上的反向传播计算图来进行的。

2.在代码实现层面,并没有显示地利用链式求导法则的公式,进行梯度计算。而是同样,通过一层层节点的遍历和梯度计算,隐式的利用了链式求导的法则(见3.1.2);同样,并没有显示地计算雅克比矩阵,而是矩阵运算时,无意形成的Jacobian。

3.深度学习框架的核心,即tensor和计算图,基于此结构,我们可以方便地利用链式法则对各个变量更新梯度。上面说过,如果不利用诸如pytorch、tensorflow之类的框架,纯python+numpy开发一个可以autograd的框架是很复杂的,不过复杂归复杂,也是可以实现的——github:https://github.com/hips/autogradgithub.com


参考资料

知乎:
计算图反向传播的原理及实现
PyTorch 的 Autograd
官方文档:
pytorch-doc-tensors
pytorch-doc-autograd
博客:
pytorch-101-understanding-graphs-and-automatic-differentiation/
pytorch-autograd-understanding-the-heart-of-pytorchs-magic
课程:
[http://cs231n.stanford.edu/slides/2019/cs231n\_2019\_lecture06.pdf](

  • 0
  • 0
  • 225
收藏
暂无评论
chengzi

阿里巴巴

  • 56

    关注
  • 151

    获赞
  • 10

    精选文章
近期动态
  • 目标跟踪
文章专栏
  • 目标跟踪分享