跳转至

前言

相信各位做算法的同学都很熟悉框架的使用,但未必很清楚了解我们跑模型的时候,框架内部在做什么,比如怎么自动求导,反向传播。这一系列细节虽然用户不需要关注,但如果能深入理解,那会对整个框架底层更加熟悉

从一道算法题开始

有算法基础的同学,应该都知道迪杰斯特拉的双栈算术表达式求和这个经典算法。他的原理是利用两个栈分别存放运算数,操作。根据不同的情况弹出栈里的元素,并进行运算,我们可以具体看下图

在这里插入图片描述

这里讨论的是最简单的情况,我们根据操作符的优先级,以及括号的种类(左括号和右括号),分别进行运算,然后得到最终结果。

神经网络里怎么做?

在神经网络里,我们把数据和权重都以矩阵运算的形式来计算得到最终的结果。举个常见的例子,在全连接层中,我们都是使用矩阵乘法matmul来进行运算,形式如下

在这里插入图片描述

如图,一个(2x3)的矩阵W和一个(3x2)的矩阵X运算出来的结果Y1是(2x2) 那么Y可以被表示为

Y_1 = W_1*X_1

那后续还有一系列相关操作,比如我们可以假设

Y_2 = W_2*Y_1 + B_2 \\ Y_3 = W_3*Y_2 + B_3

这一系列运算,都是我们拿输入X一层,一层的前向计算,因此这一个过程被称为前向传播

神经网络为了学习调节参数,那就需要优化,我们通过一个损失函数来衡量模型性能,然后使用梯度下降法对模型进行优化 原理如下(完整的可以参考我写的一篇深度学习里的优化在这里插入图片描述 可以看到最后我们能让loss值变小,这也能代表模型性能得到了优化。 那既然涉及到了梯度,就需要对里面的元素进行求导了。那么应该对谁求呢, 也就是神经网络里的权重W1, W2, W3

Y_3 = W_3*Y_2 \\ Y_3 对W_3求偏导,那就是Y_2, 以此类推

可以观察到,要想求各个权重,就需要从最后一层往前逐层推进。求导得到各个权重对应的梯度,这叫后向传播。 那既然算术表达式可以用双栈来轻松的表达

对于神经网络里的运算,需要前向传播和后向传播,有没有什么好的数据结构对其进行抽象呢?有的,那就是我们需要说的计算图

计算图

我们借用的结构就能很好的表示整个前向和后向的过程。形式如下

在这里插入图片描述

我们再来看一个更具体的例子

在这里插入图片描述

(这幅图摘自Paddle教程。

比如最后一项计算是

650 *1.1 = 715

则在反向传播中 650这一项对应的梯度为1.1 1.1这一项对应的梯度为650 以此类推。

常见的反向传播

卷积层的反向传播

这里参考的是知乎一篇 Conv卷积层反向求导 我们写一个简单的1通道,3x3大小的卷积

import torch
import torch.nn as nn

conv = nn.Conv2d(in_channels=1, out_channels=1, kernel_size=3, padding=0, bias=False, stride=1)
inputv = torch.range(1, 16).view(1, 1, 4, 4)

print(inputv)
out = conv(inputv)
print(out)
out = out.mean()
out.backward()
print(conv.weight.grad)

最后得到conv的梯度为

tensor([[[[ 3.5000,  4.5000,  5.5000],
          [ 7.5000,  8.5000,  9.5000],
          [11.5000, 12.5000, 13.5000]]]])

我们3x3 的卷积核形式如下

在这里插入图片描述

我们的数据为4x4矩阵

在这里插入图片描述

这里我们只关注卷积核左上角元素W1的求导过程 在stride=1,pad=0情况下,他的移动过程是这样的

在这里插入图片描述

白色是卷积核每次移动覆盖的区域,而蓝色区块,则是与权重W1经过计算的位置

可以看到W1分别和1, 2, 5, 6这四个数字进行计算 我们最后标准化一下

1/4 *(1 + 2 + 5 + 6) = 3.5

这就是权重W1对应的梯度,以此类推,我们可以得到9个梯度,分别对应着3x3卷积核每个权重的梯度

卷积层求导的延申

其实卷积操作是可以被优化成一个矩阵运算的形式,该方法名为img2col 这里简单介绍下

在这里插入图片描述

蓝色部分是我们的卷积核,我们可以摊平成1维向量,这里我们有两个卷积核,就将2个1维向量进行组合,得到一个核矩阵 同理,我们把输入特征也摊平,得到输入特征矩阵

这样我们就可以将卷积操作,转变成两个矩阵相乘,最终得到输出矩阵。 而不需要用for循环嵌套,极大提升了运算效率。

池化层的反向传播

池化层本身并不存在参数,但是不存在参数并不意味着不参加反向传播过程。如果池化层不参加反向传播过程,那么前面层的传播也就中断了。因此池化层需要将梯度传递到前面一层,而自身是不需要计算梯度优化参数。

import torch
import numpy as np

inputv = np.array(
    [
        [1, 2, 3, 4],
        [5, 6, 7, 8],
        [9, 10, 11, 12],
        [13, 14, 15, 16],
    ]
)
inputv = inputv.astype(np.float)
inputv = torch.tensor(inputv,requires_grad=True).float()
inputv = inputv.unsqueeze(0)

inputv.retain_grad()
print(inputv)
pool = torch.nn.functional.max_pool2d(inputv, kernel_size=(3, 3), stride=1)
print(pool)
pool = torch.mean(pool)
print(pool)
pool.backward()
print(inputv.grad)

注意这里我们打印的是input的梯度,因为池化层自身不具备梯度

tensor([[[0.0000, 0.0000, 0.0000, 0.0000],
         [0.0000, 0.0000, 0.0000, 0.0000],
         [0.0000, 0.0000, 0.2500, 0.2500],
         [0.0000, 0.0000, 0.2500, 0.2500]]])

其中最大池化层是这样做的

在这里插入图片描述

可以看到我们有4个元素进行了最大池化,但为了保证传播过程中,梯度总和不变,所以我们要归一化

也就是

1 ➗ 4 = 0.25

因此最大元素那四个位置对应的梯度是0.25 在平均池化过程中,操作有些许不一样,具体可以参考 Pool反向传播求导细节

静态图与动态图的区别

静态图

在tf1时代,其运行机制是静态图,也就是符号式编程,tensorflow也是按照上面计算图的思想,把整个运算逻辑抽象成一张数据流图

在这里插入图片描述

tensorflow提出了一个概念,叫PlaceHolder,即数据占位符。PlaceHolder只是有shape,dtype等基础信息,没有实际的数据。在网络定义好后,需要对其进行编译。于是网络就根据每一步骤的placeholder信息进行编译构图,构图过程中检查是否有维度不匹配等错误。待构图好后,再喂入数据给流图。 静态图只构图一次,运行效率也会相对较高点。当然现在的各大框架也在努力优化动态图,缩小两者之间效率差距。

动态图

动态图也称为命令式编程,就像我们写代码一样,写到哪儿就执行到哪儿。Pytorch便属于这种,它与用户更加友好,可以随时在中间打印张量信息,方便我们进行debug。

每一次读取数据进行计算,它都会重新进行一次构图,并按照流程执行下去。其特性更加适合研究者以及入门小白

两者区别

  1. 静态图只构图一次
  2. 动态图每次运行都重新构图
  3. 静态图能在编译中做更好的优化,但动态图的优化也在不断提升中

在这里插入图片描述

比如按动态图我们先乘后加,形式如左图。 在静态图里我们可以优化到同一层级,乘法和加法同时做到

总结

这篇文章讲解了计算图的提出,框架内部常见算子的反向传播方法,以及动静态图的主要区别。限于篇幅,没有讲的特别深入,但读完也基本可以对框架原理有了基本的了解~