深度学习中的微分

我们在研究与应用深度学习时,会碰到一个无法绕过的内容,就是微分求导,再具体点其实就是反向传播优。如果我们只是简单地应用深度学习、搭搭模型,那么可以不用深究。但是如果想深入的从工程上了解深度学习及对应框架的实现,那么了解程序是如何进行反向传播,自动微分就十分重要了。本文将一步步的从简单的手动求导一直谈到自动微分。

我们在研究与应用深度学习时,会碰到一个无法绕过的内容,就是微分求导,再具体点其实就是反向传播。如果我们只是简单地应用深度学习、搭搭模型,那么可以不用深究。但是如果想深入的从工程上了解深度学习及对应框架的实现,那么了解程序是如何进行反向传播,自动微分就十分重要了。本文将一步步的从简单的手动求导一直谈到自动微分。
我们目前可以将微分划分为四大类,Manual Differentiation、Symbolic Differentiation、Numeric Differentiation、Automatic Differentiation

Manual Differentiation

Manual Differentiation正如字面意思所写,就是手动对函数求导。
比如下式就是个简单的函数。
$f ( x , y ) = x ^ { 2 } y + y + 2$
对x求导可得:
$\frac { \partial f } { \partial x } = \frac { \partial x ^ { 2 } y } { \partial x } + \frac { \partial y } { \partial x } + \frac { \partial 2 } { \partial x } = 2 x y + 0 + 0 = 2 x y$
对y求导可得:
$\frac { \partial f } { \partial y } = \frac { \partial x ^ { 2 } y } { \partial y } + \frac { \partial y } { \partial y } + \frac { \partial 2 } { \partial y } = x ^ { 2 } + 1 + 0 = x ^ { 2 } + 1$
这个例子很见到,只要了解基本的求导公式,都可以很迅速的解决。但是当我们面临的是一个极其复杂的公式时,整个过程将会非常繁琐,而且容易出错。

Symbolic Differentiation

为了避免人力的介入,首先提出了 Symbolic Differentiation 即符号微分。
下面也是个简单的例子:
$g ( x , y ) = 5 + x y$
符号微分方式如下图:
 symbolic differentiation graph

这个例子很简单,但是对于复杂函数会生成非常大的图,很难简化,有一定的性能问题。
更重要的是,符号微分无法对任意代码定义的函数进行求导,如下:

1
2
3
4
5
def my_func(a, b):
z = 0
for i in range(1000):
z = a * np.cos(z + i) + z * n
return z

Numeric Differentiation

那怎样才能对任意函数进行求导呢,于是引出了Numeric Differentiation,顾名思义就是用数值去近似计算,求微分。
我们知道
$\begin{aligned} h ^ { \prime } ( x ) = \lim _ { x \rightarrow x _ { 0 } } \frac { h ( x ) - h \left( x _ { 0 } \right) } { x - x _ { 0 } } = \lim _ { \epsilon \rightarrow 0 } \frac { h \left( x _ { 0 } + \epsilon \right) - h \left( x _ { 0 } \right) } { \epsilon } \end{aligned}$

1
2
3
4
5
6
7
8
def f(x, y):
return x**2*y + y + 2

def derivative(f, x, y, x_eps, y_eps):
return (f(x + x_eps, y + y_eps) - f(x, y)) / (x_eps + y_eps)

df_dx = derivative(f, 3, 4, 0.00001, 0) # 24.000039999805264
df_dy = derivative(f, 3, 4, 0, 0.00001) # 10.000000000331966

数值微分里边,如上边的代码,我们至少需要执行三次f(x,y),当我们的参数有1000个时就至少要执行1001次,在大规模的神经网络模型中,这样的做法是很低效的。不过数值微分比较简单,可以用来对手动求导的值进行验证。

Automatic Differentiation

Autodiff算是在之前提到的这些方案基础之上得到的一个兼具性能和数值稳定的方案。

Forward-Mode Autodiff

  • Forward-Mode是数值微分和符号微分的结合
  • 引入二元数ε, ϵ**2 = 0 ( ϵ ≠ 0)
  • $h ( a + b \epsilon ) = h ( a ) + b \times h ^ { \prime } ( a ) \epsilon$
  • 所以f(x,y) 在(3,4)点对x的导数为ϵ前面的系数

 Forward Autodiff graph
forward-mode autodiff 比数值微分更准确,但是也有同样的问题,就是有n个参数要走n次图

Reverse-Mode Autodiff

为了解决Forward-Mode中需要根据参数进行图遍历的问题,提出了Reverse-Mode Autodiff方案。这也就是我们常说的链式法则、反向传播算法(machine learning中我们常常将其等价)。

  • 第一遍,先从输入到输出,进行前向计算
  • 第二遍,从输出到输入,进行反向计算求偏导

 Reverse Autodiff graph

  • Reverse-Mode Autodiff在有大量输入,少量输出时是非常有力的方案
  • 一次正向,一次反向可以计算所有输入的偏导
  • 一个简易教学版的Autograd的实现:https://github.com/mattjj/autodidact

相关工程实现

Autograd将numpy包了一层,提供与numpy相同的基础ops,但自己内部创建了计算图
 autograd

1
2
3
4
5
6
7
8
9
10
11
import autograd.numpy as np
from autograd import grad


def logistic(z):
# 等价形式
# np.reciprocal(np.add(1, np.exp(np.negative(z))))
return 1. / (1. + np.exp(-z))

print(logistic(1.5)) # 0.8175744761936437
print(grad(logistic)(1.5)) # 0.14914645207033284

 autograd wrapper graph

Vector-Jacobian Products

雅克比矩阵

$\mathbf { J } = \frac { \partial \mathbf { y } } { \partial \mathbf { x } } = \left( \begin{array} { c c c } { \frac { \partial y _ { 1 } } { \partial x _ { 1 } } } & { \cdots } & { \frac { \partial y _ { 1 } } { \partial x _ { n } } } \ { \vdots } & { \ddots } & { \vdots } \ { \frac { \partial y _ { m } } { \partial x _ { 1 } } } & { \cdots } & { \frac { \partial y _ { m } } { \partial x _ { n } } } \end{array} \right)$

vector-Jacobian product (VJP)

$\overline { x _ { j } } = \sum _ { i } \overline { y _ { i } } \frac { \partial y _ { i } } { \partial x _ { j } } \quad \overline { \mathbf { x } } = \overline { \mathbf { y } } ^ { \top } \mathbf { J }$

对于每一个基本操作都需要定义一个VJP

1
2
3
4
5
6
7
8
9
10
11
12
13
14

primitive_vjps = defaultdict(dict)
def defvjp(fun, *vjps, **kwargs):
argnums = kwargs.get('argnums', count())
for argnum, vjp in zip(argnums, vjps):
primitive_vjps[fun][argnum] = vjp


defvjp(anp.negative, lambda g, ans, x: -g)
defvjp(anp.exp, lambda g, ans, x: ans * g)
defvjp(anp.log, lambda g, ans, x: g / x)
defvjp(anp.tanh, lambda g, ans, x: g / anp.cosh(x) **2)
defvjp(anp.sinh, lambda g, ans, x: g * anp.cosh(x))
defvjp(anp.cosh, lambda g, ans, x: g * anp.sinh(x))

上边的代码是截取了autodidact的部分代码,像negative基本操作,求出倒数就是原来基础上取负。整个过程可以划分为三块。

  • tracing the forward pass to build the computation graph

  • vector-Jacobian products for primitive ops

  • backwards pass

再具体的细节就不粘贴了,有兴趣的同学可以访问我之前贴出的链接,几百行代码,麻雀虽小五脏俱全,非常适合学习。

Reference

Géron, Aurélien. “Hands-On Machine Learning with Scikit-Learn and TensorFlow”
https://www.cs.toronto.edu/~rgrosse/courses/csc321_2018/slides/lec10.pdf
https://github.com/mattjj/autodidact