最難讀的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優化,這部分與之前章節大致相同。
——————————————————————————————————————————————
至此,這份源碼算是解析完了。