摒棄encoder-decoder結構,Pervasive Attention模型與Keras實現
1.引言
現有的主流機器翻譯模型,基本都是基於encoder-decoder的結構,其思想就是對於輸入句子序列,通過RNN先進行編碼(encoder),轉化為一個上下文向量context vector,然後利用另一個RNN對上下文向量context vector進行解碼(decoder)。其結構如下:
之後,又有學者在該結構的基礎上,做了各種改進,其中主要有兩方面的改進,一種是添加了注意力機制,其思想就是在decoder的每一步輸出時,不再僅僅關注encoder最後的輸出狀態,而是綜合encoder每一個時間步的輸出狀態,並對其進行加權求和,從而使得在decoder的每一個時間步輸出時,可以對輸入句子序列中的個別詞彙有所側重,進而提高模型的準確率,另一種改進是替換encoder和decoder的RNN模型,比如Facebook提出的Fairseq模型,該模型在encoder和decoder都採用卷積神經網路模型,以及Googlet提出的Transformer模型,該模型在encoder和decoder都採用attention,這兩種模型的出發點都是為了解決RNN沒法平行計算的缺點,因此,在訓練速度上得到了很大的提升。但是,這些改進其實都沒有脫離encoder-decoder的結構。
因此,《Pervasive Attention: 2D Convolutional Neural Networks for Sequence-to-Sequence Prediction》一文作者提出了一種新的結構,不再使用encoder-decoder的結構,而是採用一種基於2D卷積神經網路(2D CNN)的結構,其思想就是將輸入序列和目標序列embedding之後的矩陣進行拼接,轉化為一個3D的矩陣,然後直接使用卷積網路進行卷積操作,其卷積網路的結構採用的是DenseNet的結構,並且在進行卷積時對卷積核進行遮掩,以防止在卷積時後續資訊的流入,拼接後的feature map如下圖所示:
2.相關符號定義
論文中涉及到的相關符號及其定義分別如下:
- :輸入序列和目標序列句子對
- :輸入序列長度
- :目標序列長度
- :輸入序列embedding的維度
- :目標序列embedding的維度
- :已經經過embedding的輸入序列矩陣
- :已經經過embedding的目標序列矩陣
- :growth rate,與DenseNet中的growth rate含義相同,是每個dense layer的輸出維度
3. pervasive attention模型介紹
pervasive attention模型的結構主要還是借鑑DenseNet的結構,在結構方面其實並沒有多新奇,其主要特別的地方是將輸入序列和目標序列的資料進行融合,轉化為一個3D的矩陣,從而可以避開encoder-decoder的結構,下面對該模型具體展開介紹。
3.1 模型的輸入(Input source-target tensor)
首先是模型的輸入,記和分別表示輸入序列和目標序列經過embedding後的二維矩陣,其中表示輸入序列的長度,表示目標序列的長度,表示輸入序列embedding後的維度,表示目標序列embedding後的維度。接著,將這兩個矩陣進行拼接,得到一個三維的矩陣,記為,其中,。這裡有一個地方需要注意的是,作者在論文中是將資料轉化為的形式,這時,在後面的卷積操作時,卷積核的mask就應該是對行方向進行mask,而不是上圖顯示的列方向。
另外,筆者在檢視作者原始碼時,發現其實在將資料進行拼接之前,作者其實還做了一個conv embedding的操作,即對embedding後的輸入序列和輸出序列矩陣進行1維的卷積操作,這樣使得後面每個單詞其實都可以融合前一個單詞的資訊。
3.2 卷積層(Convolutional layers)
該論文中卷積層的結構主要參考的DenseNet的dense block結構,在每一個卷積block中,都包含以下7層:
- Batch_normalizes:第一層標準化層,對輸入資料進行batch標準化
- ReLU:第一層啟用層
- Conv(1):第一層卷積層,採用(1,1)的卷積核,輸入的通道數是,其中,表示當前的層數,,為dense layer的層數,稱作growth rate。因為採用的是DenseNet的結構,因此,需要將當前層前面的層的輸出作為附加的通道數與Input一起拼接。第一層卷積操作後的輸出通道數設定為
- Batch_normalizes:第二層標準化層
- ReLU:第二層啟用層
- Conv(k):第二層卷積層,採用的卷積核,輸出通道數是
- dropout:dropout層
具體的模型結構如下圖所示:
不過需要注意的是,筆者在檢視作者原始碼時,發現其實在最開始的Input與dense layer之間,其實還有一層DenseNet的transition操作,即對輸入資料進行卷積,使得通道數減半,這樣在後續的卷積操作時,資料量不會太大。
3.3 輸出層(Target sequence prediction)
卷積層結束後,記模型的輸出為,其中為輸出的通道數,由於輸出的是一個3維的結構,因此,需要對第2維進行摺疊,使其轉化為的形式,這裡作者介紹了兩種主要的操作方法,分別是pooling和注意力機制:
- pooling:可以選擇max_pooling或average_pooling,其計算方式分別如下: 這裡主要需要注意的是做average_pooling時,作者不是直接計算平均,而是除以句子長度的開根號,作者通過實驗發現這種做法效果更好,並且作者在實驗時發現用max_pooling效果要比average_pooling好。
- 注意力機制:與傳統的注意力機制一樣操作,這裡不具體展開細講了
得到摺疊後的結果後,再將結果傳入一個全連線層,使得輸出的size轉化為 ,這裡是目標序列的詞彙總數,並將結果傳入一個softmax層即可得到最終的概率分佈,計算如下:
4.pervasive attention的keras實現
筆者用keras框架對pervasive attention進行了復現,下面對主要的程式碼模組按照上面介紹的模型結構進行講解。首先匯入相關的依賴庫和函式。程式碼如下:
from keras.layers import Input, Embedding, \
Lambda, Concatenate, BatchNormalization, \
Conv2D, Dropout, Dense, MaxPool2D, ZeroPadding2D, \
AveragePooling2D, ZeroPadding1D
from keras.layers import Activation, TimeDistributed, Conv1D
from keras.models import Model
import keras.backend as K
from keras import optimizers
接著是模型的Input中的embedding部分,其中max_enc_len表示輸入序列的最大長度,max_dec_len表示目標序列的最大長度,src_word_num表示輸入序列的詞彙數,tgt_word_num表示目標序列的詞彙數,這裡+2是為了新增<UNK>、<PAD>兩個特殊字元。另外,這裡加了一個conv embedding,作者在論文中沒有提及,但是原始碼裡面其實是含有這一層,筆者發現加了conv embedding後,每個單詞可以融合前一個單詞的資訊,有助於提升模型的效果,這裡conv embedding的思想其實類似Fairseq的思想。
# Inputs
src_input = Input(shape=(max_enc_len,), name='src_input')
tgt_input = Input(shape=(max_dec_len,), name='tgt_input')
# embedding
src_embedding = Embedding(src_word_num + 2,
embedding_dim,
name='src_embedding')(src_input)
tgt_embedding = Embedding(tgt_word_num + 2,
embedding_dim,
name='tgt_embedding')(tgt_input)
# implement a convEmbedding
for i in range(conv_emb_layers):
src_embedding = Conv1D(embedding_dim, 3, padding='same',
data_format='channels_last', activation='relu')(src_embedding)
tgt_embedding = ZeroPadding1D(padding=(2, 0))(tgt_embedding)
tgt_embedding = Conv1D(embedding_dim, 3, padding='valid',
data_format='channels_last', activation='relu')(tgt_embedding)
然後對embedding之後的資料進行拼接,使其轉化為一個3D的結構,這裡筆者的程式碼與作者有點不一樣的地方是將資料轉化為的形式,這樣方便後面的卷積mask操作,本質上是一樣的。
def src_reshape_func(src_embedding, repeat):
"""
對embedding之後的source sentence的tensor轉換成pervasive-attention model需要的shape
arxiv.org/pdf/1808.03867.pdf
:param src_embedding: source sentence embedding之後的結果[tensor]
:param repeat: 需要重複的次數, target sentence t的長度[int]
:return: 2D tensor (?, s, t, embedding_dim)
"""
input_shape = src_embedding.shape
src_embedding = K.reshape(src_embedding, [-1, 1, input_shape[-1]])
src_embedding = K.tile(src_embedding, [1, repeat, 1])
src_embedding = K.reshape(src_embedding, [-1, input_shape[1], repeat, input_shape[-1]])
return src_embedding
def tgt_reshape_func(tgt_embedding, repeat):
"""
對embedding之後的target sentence的tensor轉換成pervasive-attention model需要的shape
arxiv.org/pdf/1808.03867.pdf
:param tgt_embedding: target sentence embedding之後的結果[tensor]
:param repeat: 需要重複的次數, source sentence s的長度[int]
:return: 2D tensor (?, s, t, embedding_dim)
"""
input_shape = tgt_embedding.shape
tgt_embedding = K.reshape(tgt_embedding, [-1, 1, input_shape[-1]])
tgt_embedding = K.tile(tgt_embedding, [1, repeat, 1])
tgt_embedding = K.reshape(tgt_embedding, [-1, input_shape[1], repeat, input_shape[-1]])
tgt_embedding = K.permute_dimensions(tgt_embedding, [0, 2, 1, 3])
return tgt_embedding
def src_embedding_layer(src_embedding, repeat):
"""
轉換成Lambda層
:param src_embedding: source sentence embedding之後的結果[tensor]
:param repeat: 需要重複的次數, target sentence t的長度[int]
:return: 2D tensor (?, s, t, embedding_dim)
"""
return Lambda(src_reshape_func,
arguments={'repeat': repeat})(src_embedding)
def tgt_embedding_layer(tgt_embedding, repeat):
"""
轉換層Lambda層
:param tgt_embedding: target sentence embedding之後的結果[tensor]
:param repeat: 需要重複的次數, target sentence t的長度[int]
:return: 2D tensor (?, s, t, embedding_dim)
"""
return Lambda(tgt_reshape_func,
arguments={'repeat': repeat})(tgt_embedding)
# concatenate
src_embedding = src_embedding_layer(src_embedding, repeat=max_dec_len)
tgt_embedding = tgt_embedding_layer(tgt_embedding, repeat=max_enc_len)
src_tgt_embedding = Concatenate(axis=3)([src_embedding, tgt_embedding])
拼接操作後, 為了避免後續卷積時資料太大,並且預測過多地依賴模型的初始資訊,先將資料進行一次卷積操作,使得資料的通道數減半,這裡conv2_filters即為卷積後的通道數,筆者設為原資料embedding維度大小。
# densenet conv1 1x1
x = Conv2D(conv1_filters, 1, strides=1)(src_tgt_embedding)
x = BatchNormalization(axis=3, epsilon=1.001e-5)(x)
x = Activation('relu')(x)
x = MaxPool2D((2, 1), strides=(2, 1))(x)
接下來是模型的卷積層部分,採用的是DenseNet的結構,由於句子比較長,因此,筆者在transition函式裡做了一點修改,即每次transition操作對輸入序列的維度進行降維,採用的是pooling操作,使得每次輸入序列的維度可以不斷下降,而更多的空間給通道數的增加,這裡transition操作是一個可選操作,作者在論文中沒講,但是DenseNet原始的結構是有這一個操作的。另外,在卷積操作時,原作者是對卷積核的權重進行mask,比如卷積核為時,直接對最後一列變為0,從而保證非法資訊不會被傳入,但是這裡筆者直接採用的卷積核,並對資料進行左padding兩列,這樣就不用重寫卷積層了。
# transition layer
def transition_block(x,
reduction):
"""A transition block.
該transition block與densenet的標準操作不一樣,此處不包括pooling層
pervasive-attention model中的transition layer需要保持輸入tensor
的shape不變 arxiv.org/pdf/1808.03867.pdf
# Arguments
x: input tensor.
reduction: float, the rate of feature maps need to retain.
# Returns
output tensor for the block.
"""
x = BatchNormalization(axis=3, epsilon=1.001e-5)(x)
x = Activation('relu')(x)
x = Conv2D(int(K.int_shape(x)[3] * reduction), 1, use_bias=False)(x)
x = MaxPool2D((2, 1), strides=(2, 1))(x)
return x
# building block
def conv_block(x,
growth_rate,
dropout):
"""A building block for a dense block.
該conv block與densenet的標準操作不一樣,此處通過
增加Zeropadding2D層實現論文中的mask操作,並將
Conv2D的kernel size設定為(3, 2)
# Arguments
x: input tensor.
growth_rate: float, growth rate at dense layers.
dropout: float, dropout rate at dense layers.
# Returns
Output tensor for the block.
"""
x1 = BatchNormalization(axis=3,
epsilon=1.001e-5)(x)
x1 = Activation('relu')(x1)
x1 = Conv2D(4 * growth_rate, 1, use_bias=False)(x1)
x1 = BatchNormalization(axis=3, epsilon=1.001e-5)(x1)
x1 = Activation('relu')(x1)
x1 = ZeroPadding2D(padding=((1, 1), (1, 0)))(x1) # mask sake
x1 = Conv2D(growth_rate, (3, 2), padding='valid')(x1)
x1 = Dropout(rate=dropout)(x1)
x = Concatenate(axis=3)([x, x1])
return x
# dense block
def dense_block(x,
blocks,
growth_rate,
dropout):
"""A dense block.
# Arguments
x: input tensor.
blocks: integer, the number of building blocks.
growth_rate:float, growth rate at dense layers.
dropout: float, dropout rate at dense layers.
# Returns
output tensor for the block.
"""
for i in range(blocks):
x = conv_block(x, growth_rate=growth_rate, dropout=dropout)
return x
# densenet 4 dense block
if len(blocks) == 1:
x = dense_block(x, blocks=blocks[-1], growth_rate=growth_rate, dropout=dropout)
else:
for i in range(len(blocks) - 1):
x = dense_block(x, blocks=blocks[i], growth_rate=growth_rate, dropout=dropout)
x = transition_block(x, reduction)
x = dense_block(x, blocks=blocks[-1], growth_rate=growth_rate, dropout=dropout)
卷積操作結束後,是模型的pooling操作,對s維度進行摺疊,這裡筆者只寫了pooling操作。
# avg pooling
def h_avg_pooling_layer(h):
"""
實現論文中提到的最大池化 arxiv.org/pdf/1808.03867.pdf
:param h: 由densenet結構輸出的shape為(?, s, t, fl)的tensor[tensor]
:return: (?, t, fl)
"""
h = Lambda(lambda x: K.permute_dimensions(x, [0, 2, 1, 3]))(h)
h = AveragePooling2D(data_format='channels_first',
pool_size=(h.shape[2], 1))(h)
h = Lambda(lambda x: K.squeeze(x, axis=2))(h)
return h
# max pooling
def h_max_pooling_layer(h):
"""
實現論文中提到的最大池化 arxiv.org/pdf/1808.03867.pdf
:param h: 由densenet結構輸出的shape為(?, s, t, fl)的tensor[tensor]
:return: (?, t, fl)
"""
h = Lambda(lambda x: K.permute_dimensions(x, [0, 2, 1, 3]))(h)
h = MaxPool2D(data_format='channels_first',
pool_size=(h.shape[2], 1))(h)
h = Lambda(lambda x: K.squeeze(x, axis=2))(h)
return h
# Max pooling
h = h_max_pooling_layer(x)
最後是模型的輸出,是一個全連線層+softmax層,這裡沒什麼好講的,程式碼如下:
# Max pooling
h = h_max_pooling_layer(x)
# Target sequence prediction
output = Dense(tgt_word_num + 2, activation='softmax')(h)
以上對整個模型各個模組程式碼分別進行了講解,最後,將上面的程式碼串聯起來,彙總如下:
# pervasive-attention model
def pervasive_attention(blocks,
conv1_filters=64,
growth_rate=12,
reduction=0.5,
dropout=0.2,
max_enc_len=200,
max_dec_len=200,
embedding_dim=128,
src_word_num=4000,
tgt_word_num=4000,
samples=12000,
batch_size=8,
conv_emb_layers=6
):
"""
build a pervasive-attention model with a densenet-like cnn structure.
:param blocks: a list with length 4, indicates different number of
building blocks in 4 dense blocks, e.g which [6, 12, 48, 32]
for DenseNet201 and [6, 12, 32, 32] for DenseNet169. [list]
:param conv1_filters: the filters used in first 1x1 conv to
reduce the channel size of embedding input. [int]
:param growth_rate: float, growth rate at dense layers. [int]
:param reduction: float, the rate of feature maps which
need to retain after transition layer. [float]
:param dropout: dropout rate used in each conv block, default 0.2. [float]
:param max_enc_len: the max len of source sentences. [int]
:param max_dec_len: the max len of target sentences. [int]
:param embedding_dim: the hidden units of first two embedding layers. [int]
:param src_word_num: the vocabulary size of source sentences. [int]
:param tgt_word_num: the vocabulary size of target sentences. [int]
:param samples: the size of the training data. [int]
:param batch_size: batch size. [int]
:param conv_emb_layers: the layers of the convolution embedding. [int]
:return:
"""
# Inputs
src_input = Input(shape=(max_enc_len,), name='src_input')
tgt_input = Input(shape=(max_dec_len,), name='tgt_input')
# embedding
src_embedding = Embedding(src_word_num + 2,
embedding_dim,
name='src_embedding')(src_input)
tgt_embedding = Embedding(tgt_word_num + 2,
embedding_dim,
name='tgt_embedding')(tgt_input)
# implement a convEmbedding
for i in range(conv_emb_layers):
src_embedding = Conv1D(embedding_dim, 3, padding='same',
data_format='channels_last', activation='relu')(src_embedding)
tgt_embedding = ZeroPadding1D(padding=(2, 0))(tgt_embedding)
tgt_embedding = Conv1D(embedding_dim, 3, padding='valid',
data_format='channels_last', activation='relu')(tgt_embedding)
# concatenate
src_embedding = src_embedding_layer(src_embedding, repeat=max_dec_len)
tgt_embedding = tgt_embedding_layer(tgt_embedding, repeat=max_enc_len)
src_tgt_embedding = Concatenate(axis=3)([src_embedding, tgt_embedding])
# densenet conv1 1x1
x = Conv2D(conv1_filters, 1, strides=1)(src_tgt_embedding)
x = BatchNormalization(axis=3, epsilon=1.001e-5)(x)
x = Activation('relu')(x)
x = MaxPool2D((2, 1), strides=(2, 1))(x)
# densenet 4 dense block
if len(blocks) == 1:
x = dense_block(x, blocks=blocks[-1], growth_rate=growth_rate, dropout=dropout)
else:
for i in range(len(blocks) - 1):
x = dense_block(x, blocks=blocks[i], growth_rate=growth_rate, dropout=dropout)
x = transition_block(x, reduction)
x = dense_block(x, blocks=blocks[-1], growth_rate=growth_rate, dropout=dropout)
# Max pooling
h = h_max_pooling_layer(x)
# Target sequence prediction
output = Dense(tgt_word_num + 2, activation='softmax')(h)
# compile
model = Model([src_input, tgt_input], [output])
adam = optimizers.Adam(lr=0.0001,
beta_1=0.9,
beta_2=0.999,
epsilon=1e-08,
decay=0.05 * batch_size / samples)
model.compile(optimizer=adam, loss='categorical_crossentropy')
return model
5.小結
以上就是pervasive attention模型的整體結構及其復現,其實整個模型的思路都不算太難,下面談一談筆者自己對這個模型的一個感受吧:
- 優點:①拋棄了以往的encoder和decoder結構,可以直接採用卷積操作進行計算,從而實現並行化;②引數量整體比seq2seq要少很多;③可以在每一層的結果實現attention,這也是模型為什麼叫pervasive attention的原因。
- 缺點:該模型由於對輸入序列和目標序列的資料進行拼接,當序列的長度比較長時,對GPU的記憶體要求就很高,特別是當層數和growth rate比較大時,對GPU的效能要求就特別大。
最後,附上原論文的地址和作者原始碼的地址:
- 論文地址:arxiv.org/pdf/1808.03867.pdf
- Pytorch實現:github.com/elbayadm/attn2d