下面介绍下为什么要引入自动 自动微分(automatic differentiation -> AD )。
上面所述的在训练数据上结合ERM原则对模型参数进行拟合的过程其实就是一个机器学习的过程。机器学习的目标就是最小化我们的损失函数,当各个参数学到一定程度后,我们的损失函数也收敛,即达到最小值,这个学习的过程需要以下步骤:
上面求解方法存在的问题:
几种微分求解的方法
根据这篇paper:Automatic differentiation in machine learning: a survey中的介绍,目前微分求解有如下的方法:
下面说下各自的优缺点:
1. 手动求导效率低下、易于出错。
2. 数值微分:根据导数定义有:
数值微分易于实现,但参数多时计算量较大,且引起的round off error(舍入错误)和truncation error(截断错误), 使用center difference(中心差分)可以减少truncation error但是,存在rouding error, 往往我们利用Numerical Differentiation来检验其它微分算法的正确性,如在BP计算的时候,gradient check就是利用数值微分法进行check point。
center difference:
3.符号微分是为了解决手动微分以及数值微分的缺点而提出来的,其利用代数软件,实现微分的一些公式,它试图将问题转化为一个纯数学符号问题。符号微分的缺点是它经常导致复杂、晦涩难懂的表达式,最终出现“表达式膨胀”(expression swell)问题,它使得问题符号微分求解的表达式急速“膨胀”,导致最终求解速度变慢。
4.自动微分利用链式法则达到自动求导的效果。
自动微分法是一种介于符号微分和数值微分的方法:数值微分强调一开始直接代入数值近似求解;符号微分强调直接对代数进行求解,最后才代入问题数值;自动微分将符号微分法应用于最基本的算子,比如常数,幂函数,指数函数,对数函数,三角函数等,然后代入数值,保留中间结果,最后再应用于整个函数。因此它应用相当灵活,可以做到完全向用户隐藏微分求解过程,由于它只对基本函数或常数运用符号微分法则,所以它可以灵活结合编程语言的循环结构,条件结构等,使用自动微分和不使用自动微分对代码总体改动非常小,并且由于它的计算实际是一种图计算,可以对其做很多优化,这也是为什么该方法在现代深度学习系统中得以广泛应用。
在数学和计算代数领域,automatic differentiation (AD)又称为 algorithmic differentiation 或者 computational differentiation。AD是一个可以对程序代码表示的数学函数进行自动微分的技术。AD利用链式法则来达到自动求解的目录,AD有两种主要的方法:
AD 这两种实现方式:运算符重载与代码生成,两种方式的原理都一样,链式法则。AD相关工具,请到这个http://www.autodiff.org/页面。自动微分(AD)是计算导数的最优方法,比符号计算、有限微分更快更精确,AD已经广泛应用在优化领域,包括人工神经网络的训练算法 back-propagation(BP)等。
不难想象,任何计算都可以由第1步到第k步的序列形式,其中第 i 步计算的输入,在之前的 i-1 步中已经计算(例如编译器生成的汇编指令序列)。因此,任何计算都可以看作形式如子计算序列的复合函数,结合微积分中的链式法则AD有forward-mode 和reverse mode两种计算方式。下面结合实际的例子进行说明:
假设有算术计算公式:
y= x0*x1 + x1 (1)
(1)式用计算图表示为:
把(1)式展开计算子序列为:
c=x0*x1
d=c+x1
y=d
对应的计算图为:
这里假设x0,x1的初值分别为:1, 2。 把(1)式展开计算子序列为:
x0=1
x1=2
c=x0*x1
d=c+x1
y=d
对应计算图为:
为了计算计算图上的偏导数,我们需知道导数的加法和乘法原则:
利用上面两个原则结合计算图,其中图中的边即为偏导数则有:
有了上面的计算图,为了求偏导dy/dx0, 我们还需知道链式法则, 简单概括起来即为:“由两个函数凑起来的复合函数,其导数等于里边函数代入外边函数的导数(注意,里面的函数看成一个自变量了),乘以里边函数的导数”,数学表达式即为:
因此结合导数的加法和乘法原则以及链式法则,可知对于特点节点的偏导数及法则是:
一个节点对另一个节点求偏导,对从这个节点到另个所有路径进行累加,其中每条路径上的值是这条路径所有边进行累积。举个例子比如y对x1求偏导:
利用上述公式结合x0=1,x1=2的初始值可知,dy/dx1= 1*1*1+1*1 = 2 ,符合实际结果。
微分流形上的矢量有两种:切空间 (Tangent space)的矢量和对偶空间 (Dual space)的矢量。
其中切空间的基 {∂∂xi} 是关于坐标系的偏导,对偶空间的基是关于坐标系的微分 {dxi} 。从切矢量出发可以得到自动微分的正序模式 (forward mode, tangent-linear),从对偶矢量出发可以得到自动微分的逆序模式 (reverse mode,adjoint mode,backward mode)。
1. forward-mode
任意切矢量 dds=dxids∂∂xi 的定义是其对应的方向导数算符将它依次应用在计算序列的左侧便可获得下图左侧的张量公式:
对张量(Tensor)变换即可得上图右侧的矩阵公式。其中 , J 便是一阶导 Jacobian 矩阵,这种计算导数的方式与展开的计算子序列顺序相同,所以正序模式每一个方向导数的计算复杂度与计算序列相同 空间复杂度也相同。
对于算式(1):
y=x0∗x1+x1 (1)
如求 y 对 x1 的偏导其对应的forward-mode计算序列为:
∂x0dx1=0
∂cdx1=x0
∂ddc=1
∂ddx1=1
∂ddx1=∂ddc∂cdx1+∂x1∂x1 (sum rule)
在计算图上自底向上求关于 x1 的偏导,如下图:
从图中我们可以看出来,利用forward-mode对 x1 求偏导,可以得到所有节点关于 x1 的偏导。即forward-mode可以求出所有输出关于某一个输入的偏导数,注意这里一次forward计算只得到所有输出关于某一个自变量的偏导数,如果输入(自变量)是n维的,想得到输出关于其它输入(自变量)的偏导数,则需要计算多次。
注意 若计算序列的自变量有 n 维,则获得 Jacobian 矩阵需要计算 n 个方向上的方向导数。
2. reverse-mode
任意对偶矢量 dt=tidzi 的定义是其对应方向的微分
对该矢量做关于坐标基 {dxi} 的坐标变换 便可获得下图左侧的张量公式:
对张量缩并可得上图右侧的矩阵公式, 其中 D+=TP 表示叠加 D=D+TP , Dx 便是关于x的一阶导。这种计算导数的方式与计算序列逆序,所以称为逆序模式(reverse-model)。问题来了,如何得到这种逆序方式呢?
关于如何求这种逆序模式,前人已经有所研究,Recipes for adjoint code construction, 结合论文的思路举个例子说明吧,对于计算式:
y=x0∗x1+x1
上面展开对应的子序列及其微分如下图:
对于上面图左侧,逆序模式是对图中右侧计算子序列的微分形式的逆序求解伴随模式序列,然后按照伴随模式的顺序计算即可得到相关变量的偏导数,关于伴随模式的求解方法,其实比较简单,知道矩阵的转置是怎么回事就能求出adjoint code,关于上图式(2)微分表达式及其矩阵形式如下图:
对上图等式右侧的矩阵进行转置即可得到式(2)对应的伴随模式所对应的计算序列,如下图所示:
图中转置矩阵展开后的三个表达式即为公式(2)对应的伴随模式代码(也即逆序模式对应的代码),把计算子序列的微分形式按照这种方式逆序展开即为逆序模式的计算序列,把前序模式对应的微分表达式逆序展开为伴随表达式,即为下图所示:
这里注意d其实就是y的别名因此表达的意思所以一样的,下面我们根据adjoint 计算序列来写出reverse-mode求解偏导的代码:
#include
#include
using namespace std;
double reverse_AD(const double x[2], double y_ad[1], double x_ad[2]) {
double c = x[0] * x[1];
double d = c + x[1];
double y = d;
/* Adjoint part: */
// init
double d_ad = 0, c_ad = 0;
d_ad = y_ad[0]; // 1
c_ad = c_ad + d_ad; // 2
x_ad[1] = x_ad[1] + d_ad;
d_ad = 0;
x_ad[0] = x_ad[0] + x[1] * c_ad; // 3
x_ad[1] = x_ad[1] + x[0]*c_ad;
c_ad = c_ad;
return y;
}
int main()
{
double x[] = { 1.0, 2.0 };
double y_ad[1] = { 1.0 };// init to 1
double x_ad[2] = { 0, 0 };
double y = reverse_AD(x, y_ad, x_ad);
cout << "y=" << y << "\n";
cout << "dy/dy=" << y_ad[0] << "\n";
cout << "dy/dx0=" << x_ad[0] << "\n";
cout << "dy/dx1=" << x_ad[1] << "\n";
return 0;
}
输出为:
y=4
dy/dy=1
dy/dx0=2
dy/dx1=2
利用reverse-mode在计算图上从图顶部y向下求解偏导,我们可以得到输出关于所有节点的偏导数,如下图:
从图中以及上面的程序也能看出来,reverse-mode从上至下计算,我们能够获得输出关于所有节点的偏导(当然包括所有自变量,即这里的x0,x1)。 对比上面介绍的forward-mode,利用 forward-mode一次计算,我们只能得到关于某一个自变量的偏导数。因此,试想我们在机器学习中有这样一个优化函数,它有上百万个参数,如果利用 forward-mode求解这些参数的偏导,那么需要计算百万次,这是一个很耗费时间的事情,但是如果利用reverse-mode仅仅需要计算一次,我们就能得到关于所有参数的偏导数。所以基于reverse-mode的这一特性,现在流行的深度学习框架中,神级网络模型训练的BP算法基本也是利用reverse-mode进行求解的。
虽然reverse-mode有诸多优点,但是forward-mode也有它自身的优点(比如方法简单易懂,不需要保存很多中间变量,reverse-mode就需要保存挺多中间变量的…)。因此 何时用正序模式 何时用逆序模式?
取决于自变量维度 n 与因变量维度 m,若 n > m 则逆序更快 若 n < m 则正序更快。
如何实现reverse-mode?有兴趣的读一下这篇论文:
“Fast Reverse-Mode Automatic Differentiation using Expression”