Theano:LSTM源码解析

最难读的Theano代码

这份LSTM代码的作者,感觉和前面Tutorial代码作者不是同一个人。对于Theano、Python的手法使用得非常娴熟。

尤其是在两重并行设计上:

①LSTM各个门之间并行

②Mini-batch让多个句子并行

同时,在训练、预处理上使用了诸多技巧,相比之前的Tutorial,更接近一个完整的框架,所以导致代码阅读十分困难。

本文旨在梳理这份LSTM代码的脉络。

数据集:IMDB Large Movie Review Dataset

来源

该数据集是来自Stanford的一个爬虫数据集。

对IMDB每部电影的评论页面的每条评论进行爬虫,分为正面/负面两类情感标签。

相比于朴素贝叶斯用于垃圾邮件分类,显然,分析一段文字的情感难度比较大。

因为语义在各个词之间连锁着,有些喜欢玩梗的负面讽刺语义需要一个强力的Represention Extractor。

该数据集同时也在CS224D:Deep Learning for NLP    [Leture4]中演示,用于体现Pre-Training过后的词向量威力。

数据读取

原始数据集被Bengio组封装过,链接 http://www.iro.umontreal.ca/~lisa/deep/data/imdb.pkl

cPickle封装的格式如下:

train_set[0]  ---->  一个包含所有句子的二重列表,列表的每个元素也为一个列表,内容为:

[词索引1,词索引2,.....,词索引n],构成一个句子。熟悉文本数据的应该很清楚。

词索引之前建立了一个词库,实际使用的时候如果要对照索引,获取真实的词,则需要词库:~Link~

train_set[0][n]指的是第n个句子。

————————————————————————————————————————————

train_set[1]  ---->  一个一重列表,每个元素为每个句子的情感标签,0/1。

test_set格式相同。

————————————————————————————————————————————

本Tutorial使用的一些简单处理包括:maxLen句子词数剪枝、词库越界词剪枝、句子词数排序(不知道啥作用)

数据变形与预处理

这份代码的经典之处在于,让多个句子并行训练,构成一个mini-batch。

在RNN章节中,每次训练只是一个句子,所以输入就是一个向量,但mini-batch之后就是一个矩阵。

这个矩阵最大不同在于,xy轴是倒置的,文字方向在竖式伸展。

这么做的原因是由于theano.tensor.scan函数的工作机制。

scan函数的一旦sequence不为空,就进入序列循环模式,设sequence=[x],则

①Step1:取x[0]作为循环函数第一参数

②Step2:取x[1]作为循环函数第一参数

........

③StepN: 取x[N]作为循环函数第一参数

对应语言模型的序列学习算法,每个Step就相当于取一个句子的一个词。

竖式伸展,每次scan取的一排词,称为examples,数量等于batch_size。

横向batch并行,纵向序列时序伸展,这是mini-batch和序列学习的共同作用结果。

——————————————————————————————————————————

每个句子长度是不同的,为了便于并行矩阵计算,必须选定最长句子,最大化矩阵。句子词较少的,用0填充。

而在实际LSTM计算中,这个0填充则成了麻烦,因为你不能让标记为0的Padding参与递归网络计算,需要剔除。

这时候就需要Mask矩阵。来看LSTM.py中的实际代码:

$c = m\_[:, None] * c + (1. - m\_)[:, None] * c\_$

由于mask矩阵也在sequence中,所以m_被降成了1D,通过Numpy的第二维None扩充,可以和c([batch_size,dim_proj])

进行点乘(不是矩阵乘法)。

Mask矩阵的作用就是对于Padding,通过递推,直接滚到到上一个非0的状态,而不引入Padding。

源码解析

def get_minibatches_idx(n, minibatch_size, shuffle=False)

这部分设计对数据(句子)Shuffle随机排列。首先获取数据集所有句子数n,

对所有句子,按照batch_size,划分出 (n//batch_size)+1个列表。

拼在一起,构成一个二重列表。

返回一个zip(batch索引,batch内容),用于运行。

每份batch是一个列表,包含句子的索引。后续会根据句子的索引,拼出一个小量的x,

进过prepare_data处理过之后,方能使用。

def dropout_layer(state_before, use_noise, trng)

50%的Dropout,开了之后千万不要加Weght_Decay,两种一叠加,惩罚太重了。

Dropout的实现,利用了Tensor.switch(Bool,Ture Operation,False Operation)来动态实现。

传入的use_noise,在训练时置为1,这时候Dropout为动态概率屏蔽。

反之在测试时,置为0,这时候Dropout为平均网络。

具体原理见Hinton关于Dropout的论文:

Dropout: A Simple Way to Prevent Neural Networks from Overfitting

def init_params(options)

这部分主要是初始化全部参数。分为两个部分:

①初始化Embedding参数、Softmax输出层参数

②初始化LSTM参数,在下面的 def param_init_lstm()函数中。

①中参数莫名其妙使用了[0,0.01]的随机值初始化,不知道为什么不用负值。

像Word2Vec的初始化,可能更好些:

$ Init \, \sim \, Rand(\frac{-0.5}{Emb\_Dim},\frac{0.5}{Emb\_Dim}) $

Emb大小为$[VocabSize,Emb\_Dim]$,Softmax参数大小为$[Emb\_Dim,2]$

def param_init_lstm(options, params, prefix='lstm')

这部分用于初始化LSTM参数,W阵、U阵。

LSTM的初始化值很特殊,先用[0,1]随机数生成矩阵,然后对随机矩阵进行SVD奇异值分解。

取正交基矩阵来初始化,即 ortho_weight,原理不明,没有找到相关文献。

————————————————————————————————————————

值得注意的就是numpy.concatenate函数的使用,它锁定了AXIS=1轴(横向,列,第二维)

将Input、Forget、Output三态门与Cell的RNN核的相同操作$Wx+Uh^{'}$合并,并行计算。

如果$x$是$[1000,100]$,即$dim\_proj=100$, 那么$W$大小是$[100,100*4]$。

一次计算,有$Wx$的大小是$[1000,400]$,四个部分在矩阵中被并行计算了。

矩阵计算和FOR循环在串行算法下速度是差不多的,但是在并行算法下,矩阵同时计算比先后串行计算快不少。

def init_tparams(params)

这个函数意义就是一键将Numpy标准的params全部转为Theano.shared标准。

替代大量的Theano.shared(......)

启用tparams作为Model的正式params,而原来的params废弃。

def lstm_layer(tparams, state_below, options, prefix='lstm', mask=None)

LSTM的计算核心。

首先得注意参数state_below,这是个3D矩阵,$[n\_Step,BatchSize,Emb\_Dim]$

在scan函数的Sequence里,每步循环,都会降解第一维n_Step,得到一个Emb矩阵,作为输入$X\_$

计算过程:

①用 $[state\_below] \cdot [lstm\_W]$。

这步很奇妙,这是一个3D矩阵与2D矩阵的乘法,由于每个Step,都需要做$Wx$

所以无须每一个Step做一次$Wx$,而是把所有Step的$Wx$预计算好了,并行量很大。

②进入scan函数过程,每一个Step:

  I、将前一时序$h'$与4倍化$U$阵并行计算,并加上4倍化$Wx$的预计算

  II、分离计算,按照LSTM结构定义,分别计算$Input Gate$、$Forget Gate$、$Outout Gate$

      $\tilde{Cell}$、$Cell$、$h$

③返回rval[0],即h矩阵。注意,scan函数的输出结果会增加1D。

每个Step里,h结果是一个2D矩阵,$[BatchSize,Emb\_Dim]$

而rval[0]是一个3D矩阵,$[n\_Step,BatchSize,Emb\_Dim]$。

后续会对第一维$n\_Step$进行Mean Pooling,之后才能降解成用于Softmax的2D输入。

def sgd|adadelta|rmsprop(lr, tparams, grads, x, mask, y, cost)

这是三个可选梯度更新算法。由于作者想要保持格式一致,所以AdaDelta和RMSProp写的有点啰嗦。

AdaDelta详见我的 自适应学习率调整:AdaDelta

至于Hinton在2014 Winter的公开课提出的RMSProp,没有找到具体的数学推导,不好解释。

应该是由AdaDelta改良过来的,代码很费解。由可视化结果来看,RMSProp在某些特殊情况下,比AdaDelta要稳定。

官方代码里默认用的是AdaDelta,毕竟有Matthew D. Zeiler的论文详细推导与说明。

AdaDelta和RMSProp都是模拟二阶梯度更新,所以可以和Learning Rate这个恶心的超参说Bye~Bye了。值得把玩。

def build_model(tparams, options)

该部分是联合LSTM和Softmax,构成完整Theano.function的重要部分。

①首先是定义几个Tensor量,$x$、$y$、$mask$。

②接着,从tparams['Wemb']中取出Words*Sentences数量的词向量,并且变形为3D矩阵。

x.flatten()的使用非常巧妙,它将词矩阵拆成1D列表,然后按顺序取出词向量,然后再按顺序变形成3D形态。

体现了Python和Numpy的强大之处。

③得到3D的输入state_below之后,配合mask,经过LSTM,得到一个3D的h矩阵proj。

④对3D的h矩阵,各个时序进行Mean Pooling,得到2D矩阵,有点像Dropout的平均网络。

⑤Dropout处理

⑥Softmax、构建prob、pred、cost,都是老面孔了。

特别的是,这里有一个offset,防止prob爆0,造成log溢出。碰到这种情况可能不大。

def pred_probs|pred_error()

用于Cross-Validation计算Print信息。也就是Debug Mode...

def train_lstm()

训练过程。Theano代码块中最冗长、最臃肿的部分。

①options=locals().copy()是python中的一个小trick。

它能将函数中所有参数按爬虫下来,保存为一个词典,方便访问。

②load_data。定义在imdb.py中,获取train、vaild、test三个数据集

让人费解的是,既然这里要对test做shuffle,又何必在load_data里把test排序呢?

③初始化params,并且build_model,返回theano.function的pred、prob、cost。

④WeightDecay。有Dropout之后,必要性不大。

⑤利用cost,得到grad。利用tparams、grad,得到theano.function的update。

这里代码很啰嗦,如cost完全没有必要通过theano.function转化出来,保持Tensor状态是可以带入T.grad

而前面就没有这么写。代码风格和前面的章节截然不同。

至此,准备工作完毕,进入mini-batch执行阶段。

——————————————————————————————————————————————

①首先获取vaild和test的zip化minibatch。

每轮zip返回一个二元组(batch_idx,batch_content_list),idx实际并不用。

list是指当前batch中所有句子的idx。然后,对这些离散的idx,拼出实际的1D$x$。

经过imdb.py下的prepare_data,得到2D的$x$,作为Word Embedding的预备输入。

②进入max_epochs循环阶段:

包括early stopping优化,这部分与之前章节大致相同。

——————————————————————————————————————————————

至此,这份源码算是解析完了。

posted @ 2015-09-14 02:00  Physcal  阅读(23314)  评论(65编辑  收藏  举报