从编程语言的角度看深度学习

想必大家都听说过Artificial Neural Network(如果没有,快去看),但是具体来说,什么是反向传播呢?反传背后的Intuition又是啥?我会试着从编程语言的角度,说明深度学习的各种东西,并且指出从编程语言看待深度学习的潜在好处。同时,我也会介绍一下我从这角度写的深度学习框架DeepDarkFantasy,以展示编程语言里面的各种东西可以如何应用上神经网络框架。

什么是神经网络?

一言蔽之,一个神经网络就是一个带有一定未知参数的程序!这些未知参数,是一个神经网络的权重。正向传播,就是运行这个程序的过程。深度学习,就是寻找这些参数。

比如说,假设我们有如下的神经网络(并假设用的激活函数为Relu(为了方便描述,这是个很小的网络)):


输入0 -----输出0

输入1 -------/\

这个神经网络,如果转为scala代码,就是

def nn(in0 : Double)(in1 : Double)(w0 : Double)(w1 : Double) = max(in0 * w0 + in1 * w1, 0)

如何训练神经网络?

怎么找出最优的未知参数呢?

首先,什么叫‘最优’的参数呢?我们可以引入一个函数:指标。指标接受权重,用这些权重运行神经网络,最后得出一个分数。最优的未知参数最小化/最大化这个指标。这个指标叫做Loss Function。

type Weight = Double
//假设只有一个Weight,为Double
type LossFunction = Weight => Double
//LossFunction是一切接受权重返回Double的函数

很常见的Loss Function,有Mean Square Error:

对于一个多维度输出跟预期输出的每一维度,平方(该维度输出-该维度预期输出),并把结果累加,最后除以维数。如果只有一个维度,这就等于平方(输出-预期输出)。很明显,这是一个scala函数。

def MSE(l: Seq[Double])(r: Seq[Double]) = (l.zip(r).map(p => p._1 * p._2).sum: Double) / l.length

这并不是上面定义的LossFunction形式。但是,如果我们有一个确定的数据集(Seq[(Input, Output)]),就可以写一个wrapper:输入权重,对于给定的数据集,对每一个数据点(一个输入,跟一个输出),用给定的权重,跟该数据点的输入,运行神经网络,把结果跟该数据点的输出相减取绝对值,最后累加起来。

可以对LossFunction做转换,比如可以Scale之:

def Scale(d: Double)(lf: LossFunction): LossFunction = w => d * lf(w)

梯度下降/梯度上升之间的转换,就是Scale -1:

更多的转换:也可以把两个LossFunction加起来:

def Plus(l: LossFunction)(r: LossFunction): LossFunction = w => l(w) + r(w)

也有其他的LossFunction,比如L1 L2 Regularization,主要用于防止过大权重:

def L1: LossFunction = w => if (w > 0) w else -w
def L2: LossFunction = w => w * w

如果想对一个已有的LossFunction加上L2,很简单:我们先把L2 scale一个超参数,以调节Regularization的程度,然后跟原LossFunction相加。

回到正文,要最少化某个LossFunction,最常见的办法是,

0:找一些随机值作为初始参数

1:算出在这些参数上,Loss的导数(在多个权重下,是个gradient)

2:如果导数为正,就降低权重,如果导数为负,就增加权重(在多个权重下,为往gradient的反方向update)

3:循环往复1,2,直到你喜欢为止。

这叫梯度下降(按着某个梯度的方向降低权重),是深度学习里面很多优化算法的简化版。

反向传播,就是找出导数,然后更新的流程。

导数从那里来?

按照自动化程度排序:

  • 手动写出来(这是PHD存在的意义(划掉))
  • 手动写出神经网络层的求导,然后一层层的组合起来。Caffe 就是这样的:在里面,模块就是一层神经网络,比如说有Convolution层(用于CNN),有FullyConnected(叫InnerProduct)层,这些层都要框架程序员手写,但是其他人可以调用他们组合出自己的神经网络
  • 提供一个DSL,并用这个DSL实现神经网络层,或者其他样式的神经网络架构。如果DSL中一切都可以求导,实现出来的神经网络层就是可以求导的。这时候,深度学习框架就变成了一个带求导功能的编程语言(因为我们不跟Caffe/手写比较,我们以后就称一切深度学习框架为DSL,并且称神经网络为程序)。Tensorflow就是这样的

细心的同学可以发现,这三者都可以视为同一件东西:我们定义一个DSL,手动写出DSL的primitive的求导算法,然后用这个DSL定义自己需要的东西。

在Caffe-like的情况下,这个DSL的基础操作就是层,并且可以把层组合起来(不算很灵活,不过聊胜于无),更极端的情况(全手动)下,这个DSL的基础操作只有一个(整个算法),并且没有任何组合方法。这样看,其实一切都是基础操作粒度大小之争(粗粒度细粒度之争)。越细的粒度,就越灵活,(不考虑性能)实现框架也就越简单。

当然,并不是越细粒度越好-现阶段,由于优化不够好,手动写得越多,效率越高-有多少人工,就有多少智能。

同时,有三种导数的表示,根据灵活性排序(again,不是越灵活越好,因为效率问题):

0:对于一个DSL中的程序,返回一个求导函数(不在DSL)中。Caffe

1:对于一个DSL中的程序,返回一个DSL中的程序(这样可以后接优化,或者再次求导)。Theano。DeepDarkFantasy。

2:DSL中有一个函数:求导。StalinGrad。


DeepDarkFantasy有什么特点?

尽管深度学习框架是程序语言,但是他们支持的操作并不多:有基础的四则运算,有一定的条件控制/循环,有的有scan/变量(见tensorflow白皮书),然后就没啥了。DDF中则加入了各种编程语言的操作,在上面的基础下加入了递归,跳转(Continuation),异常,IO,等等。

跟其他AD语言比起来,DDF的特点是,是一个Typed Extensible Embedded DSL - 这DSL造出的AST是scala中强类型的term,并且这个DSL可以很简单的在scala中扩展。同时,DDF是Typed的-我们可以给出任意type的导数类型,也可以对任意term求导(并且有正确的type)。我们也同时(未完成)给出了一个算法是正确的证明。

DDF的原理是?

对于任意一个term,如果要找出他的导数形式,我们只需要把所有Double换成对应的二元数。但是,转换完以后,跟一般的,得出一个函数的二元数实现不同,得出的依然是一个AST-换句话说,AD其实可以是Symbolic的。这是DDF AD的原理。

换句话说,DDF抛弃了‘导数’这个概念。在DDF中,对一个东西求导以后,不会得出他的导数,只会得出该term跟该term的导数的一个混合物。打个比方:

Either[Double, (Double, Double)] => Either[Double, Double]并没有一个所谓的‘导数’。

但是可以把导数插进上面的类型,得出

Either[(Double, Double), ((Double, Double), (Double, Double))] => Either[(Double, Double), (Double, Double)]

这是上面类型的term,但是所有Double跟Double operation都转换成二元数的term,的类型。

注:的确可以给函数,Sum Type,找出单独的类型,早期DDF也是这样做的,但是我不喜欢,放弃了。

至于如何做Typed Extensible EDSL,可以看Finally Tagless


至于如何表示Lambda Abstraction,可以看Compiling Combinator

形式化定义:

可能这些东西都太玄乎,大家都没理解,于是我就给出一个缩小版的DDF,DDF-min,并更严谨地定义DDF-min,希望能帮助学过点Type Theory的人理解:

DDF-min基于Call By Value Simply Typed Lambda Calculus,带有Real,Sum Type, Product Type, Recursion(using Y schema)

有with_grad_t函数,可以traverse type structure,然后把所有遇到的Real转换成Real * Real。

还有with_grad函数,可以traverse AST,然后把类型转换成with_grad_t

然后有个logical relation,对于函数外的东西,都是trivial的定义,或者简单的recurse进去。

对于A -> B,除了普通的‘对所有符合logical relation的A,application满足logical relation’外,还有:如果A -> B = Real -> Real,这个函数的with_grad加点wrapper就是这个函数的Denotational Semantic的导数函数。

另:这根MarisaKirisame/DDFADC中描述的有一定出入。

Forward Mode AD会不会有性能问题?

如果对AD很熟的朋友,肯定会指出一个问题:如果有N个Double输入,Forward Mode AD就要运行N次。对于有着很多参数的神经网络来说,这无法忍受!

解决办法是,我们对Dual Number做一次Generalization:Dual Number并不一定是(Double, Double),也可以是(Double, (Double, Double))。用后者,可以运行一次,算出两个导数。

比如说,给定x, y, z,并且想知道(x+y)*z对于x, z的导,


可以写出

((x, (1, 0)) + (y, (0, 0))) * (z, (0, 1)) =

(x + y,(1, 0)) * (z, (0, 1)) =

((x + y) * z, (z, x + y))

在这里面,pair的第0项就是表达式的值,pair的第1项就是另一个pair,其中第0,1,项分别是表达式对于x,y的导。

或者,可以用(Double, Double => Double[1000])(注:Double[1000]不是真正的scala代码)代替(Double, Double[1000])-这样,当整个term要乘以一个literal的时候,并不需要进入整个Array去算,只需要update该Double则可-这就是反向传播。

在实现中,这通过引入一个Typeclass,Gradient(满足的有Unit, (Double, Double),(Double => Double[1000])等),(并限制Gradient一定要满足field的一个variation(其实本质上还是一个field,只不过为了提速)),并用之于Dual Number之上(第二个参数不再是Double,而是该Gradient)。然后,AD的四则运算就可以利用Field的操作写出。


这有什么用?

我们希望能做到Neural Networks, Types, and Functional Programming里面给的例子

DDF可以很简单的给出有递归/循环的函数的高阶导。这点tensorflow就不支持(Gradients of non-scalars (higher rank Jacobians) · Issue #675 · tensorflow/tensorflow)。

除了写神经网络以外,我们也希望可以写任意普通的算法,程序(但是带有未知变量),然后用DDF自动求导,以找出最优的这些变量。

能不能给个例子?这是一个用梯度下降解x*x+2x+3=27的例子。


致谢:感谢@凌云飞龙@Oling Cat帮我阅稿。

编辑于 2017-02-03

文章被以下专栏收录