解決NLP任務的三大法寶:注意力機制、卷積神經網絡以及循環神經網絡。可見注意力機制對於NLP來說非常重要,所以這裏着重說一說注意力機制,以及靈活的使用注意力機制到實際工程中。
衆所周知,注意力機制通常是運用於seq2seq模型中,我們常用的注意力機制是基於信息的注意力機制,也就是說我們只選擇一些關鍵的的輸入信息進行處理,但有時我們也需要關注其他信息,比如說位置信息。在進行公式時別、語音時別等一些場合中,字符間位置信息也非常重要,所以這一篇中我們將介紹如何靈活的改裝seq2seq的注意力機制,運用於我們實際的任務需求中。。。
目錄
一、BahdanauAttention注意力機制的實現
這裏不多介紹,具體可以參考我之前的文章,地址:【深度學習系列(六)】:RNN系列(4):帶注意力機制的seq2seq模型及其實戰(2):爲圖片添加內容描述。
二、多頭注意力機制和內部注意力機制的實現
2.1、注意力機制的基本思想
從第一節中我們已經能夠非常熟悉的掌握注意力機制了,這裏簡單談一下。其實注意力機制的思想也很簡單:具體來說就是使用query進行查詢任務,然後根據key值來查詢value中的我們關注的部分。這裏我們簡單將query、key、value三個對象分別簡寫爲q、k、v。具體實現如下:
具體計算過程如下:
- 將q與k進行內積計算,同時需要除以來去除維度的影響(起到調節數值的作用,使內積不至於過大);
- 使用第一步的結果計算softmax分數,也就是注意力分數;
- 使用上一步的分數與v相乘,得到q與v的相似度;
- 最後一步就是對上述結果進行加權求和,得到對應的輸出d。
該模型可以用於翻譯模型,如輸入的m個單詞,詞向量維度爲,翻譯後有n個詞,詞向量維度爲,將上述attention過程可以簡化其運算過程爲:,最終得到。當然,該模型也可以用於其他任務,如閱讀理解任務,把文章作爲q,閱讀理解的問題作爲k,答案作爲v。
2.2、多頭注意力機制和內部注意力機制
多頭注意力機制是Google在2017年《Attention is All You Need》發出的論文,這個是Google提出的新概念,是Attention機制的完善,其使用多頭的技術來改進原始注意力機制。在深度學習做NLP的方法中,我們通常會將句子轉化爲embedding然後進行處理,處理的方法主要有:
- RNN:比如lstm、GRU或其他改進的RNN單元來完成任務。RNN結構本身比較簡單,也很適合序列建模,但RNN的明顯缺點之一就是無法並行,因此速度較慢,這是遞歸的天然缺陷。另外我個人覺得RNN無法很好地學習到全局的結構信息,因爲它本質是一個馬爾科夫決策過程。
- CNN:比如我們之前提到過的TextCNN。CNN方便並行,而且容易捕捉到一些全局的結構信息,筆者本身是比較偏愛CNN的,在目前的工作或競賽模型中,我都已經儘量用CNN來代替已有的RNN模型了,並形成了自己的一套使用經驗。
- 注意力機制:對,沒錯,這貨也能作完成NLP任務。RNN要逐步遞歸才能獲得全局信息,因此一般要雙向RNN才比較好;CNN事實上只能獲取局部信息,是通過層疊來增大感受野;Attention的思路最爲粗暴,它一步到位獲取了全局信息!
(注:上述藍色部分都是別人文章中的,我覺得講的非常好,這裏就借用下)
多頭注意力機制從形式上看,它其實就再簡單不過了,就是把Q,K,V通過參數矩陣映射一下,然後再做Attention,把這個過程重複做h次,結果拼接起來就行了,可謂“大道至簡”了。具體實現如下:
具體計算過程如下:
- 把Q、K、V通過參數矩陣進行全連接映射轉換;
- 將第一步中的三個結果作點積運算(Attention運算);
- 將第一、二步中的運算重複h次,在第一步中每次都需要使用新的權重矩陣;
- 使用concat函數將h次計算後的結果進行拼接。
計算過程如下圖:
每次Attention運算都會使數據中的某方面特徵發生注意力轉化(得到局部特徵),當發生多次Attention運算後會得到多個方向的局部注意力特徵,將所有的局部注意力特徵拼接起來之後在通過神經網絡轉化爲整體的特徵,從而達到擬合或分類的結果。
內部注意力機制(Self Attention)主要是用於發現序列數據內部的特徵,其結構與多頭注意力機制類似,但又有一些不同。具體做法是將Q、K、V都轉化爲X,即Attention(X,X,X)。Google論文的主要貢獻之一是它表明了內部注意力在機器翻譯(甚至是一般的Seq2Seq任務)的序列編碼上是相當重要的,而之前關於Seq2Seq的研究基本都只是把注意力機制用在解碼端。類似的事情是,目前SQUAD閱讀理解的榜首模型R-Net也加入了自注意力機制,這也使得它的模型有所提升。內部注意力機制的一般表達式如下:
參考鏈接:《Attention is All You Need》淺讀(簡介+代碼)
2.3、多頭注意力機制的實戰——分析評論者是否滿意
2.3.1、數據讀取
本例使用的數據集爲康奈爾大學發佈的電影評價數據集,具體鏈接如下:
鏈接: https://pan.baidu.com/s/1QBYjRjcO8MP3XFCwUPkz1g 密碼: 5d40
該數據集包含兩個文件rt-polarity.neg和rt-polarity.pos,其分別包含5331個正面的評論和5331個負面的評論,具體文件如下圖所示。
這裏直接使用tf.keras.preprocessing.text.Tokenizer()模塊進行數據的讀取和預處理,詳細細節這裏不多介紹,代碼如下:
import tensorflow as tf
def load_data(positive_data_file,negative_data_file):
'''加載數據'''
# 讀取數據
file_list=[positive_data_file,negative_data_file]
train_data=[]
train_labels=[]
for index,file in enumerate(file_list):
with open(file,'r',encoding='utf-8') as fp:
for line in fp.readlines():
train_data.append(line.strip())
train_labels.append(index)
#文本標籤預處理:(1)文本過濾;(2)建立字典;(3)向量化文本以及文本對齊
# 文本過濾,去除無效字符
tokenizer=tf.keras.preprocessing.text.Tokenizer(oov_token="<unk>",
filters='!"#$%&()*+.,-/:;=?@[\]^_`{|}~ ')
tokenizer.fit_on_texts(train_data)
# 建立字典,構造正反向字典
tokenizer.word_index={key:value for key,value in tokenizer.word_index.items()}
# 向字典中加入<unk>字符
tokenizer.word_index[tokenizer.oov_token] = len(tokenizer.word_index) + 1
# 向字典中加入<pad>字符
tokenizer.word_index['<pad>'] = 0
index_word = {value: key for key, value in tokenizer.word_index.items()}
# 向量化文本和對齊操作,將文本按照字典的數字進行項向量化處理,
# 並按照指定長度進行對齊操作(多餘的截調,不足的進行補零)
train_seq=tokenizer.texts_to_sequences(train_data)
len_seq=[len(l) for l in train_seq]
cap_vector=tf.keras.preprocessing.sequence.pad_sequences(train_seq,padding='post')
max_length = len(cap_vector[0]) # 標籤最大長度
return cap_vector, train_labels,max_length, len_seq,tokenizer.word_index, index_word
def dataset(positive_data_file,negative_data_file,batch_size=64):
cap_vector, train_labels,max_length, len_seq, word_index, index_word = load_data(positive_data_file, negative_data_file)
dataset=tf.data.Dataset.from_tensor_slices(((cap_vector,len_seq),train_labels))
dataset=dataset.shuffle(len(cap_vector))
dataset=dataset.batch(batch_size,drop_remainder=True)
return dataset,max_length, word_index, index_word
2.3.2、模型搭建
- 帶有位置信息的詞嵌入層的實現
雖然MultiAttention本質是key-value的查找機制,但是這樣的模型並不能捕捉序列的順序!換句話說,如果將K,V按行打亂順序(相當於句子中的詞序打亂),那麼Attention的結果還是一樣的。對於時間序列來說,尤其是對於NLP中的任務來說,順序是很重要的信息,它代表着局部甚至是全局的結構,學習不到順序信息,那麼效果將會大打折扣(比如機器翻譯中,有可能只把每個詞都翻譯出來了,但是不能組織成合理的句子)。
於是Google再祭出了一招——Position Embedding,也就是“位置向量”,將每個位置編號,然後每個編號對應一個向量,通過結合位置向量和詞向量,就給每個詞都引入了一定的位置信息,這樣Attention就可以分辨出不同位置的詞了。
在以往的Position Embedding中,基本都是根據任務訓練出來的向量。而Google直接給出了一個構造Position Embedding的公式:
這裏的意思是將id爲p的位置映射爲一個dpos維的位置向量,這個向量的第ii個元素的數值就是PEi(p)。Google在論文中說到他們比較過直接訓練出來的位置向量和上述公式計算出來的位置向量,效果是接近的。因此顯然我們更樂意使用公式構造的Position Embedding了。
Position Embedding本身是一個絕對位置的信息,但在語言中,相對位置也很重要,Google選擇前述的位置向量公式的一個重要原因是:由於我們有sin(α+β)=sin(α)cos(β)+cos(α)sin(β)以及cos(α+β)=cos(α)cosβ−sin(α)sin(β),這表明位置p+k的向量可以表示成位置p的向量的線性變換,這提供了表達相對位置信息的可能性。
Position Embedding的主要實現步驟如下:
1、使用sin和cos算法對嵌入中的每一個元素進行計算;
2、將第一步的結果使用concat函數連接起來作爲最終的位置信息;
3、將得到的位置信息與embedding進行拼接或直接加入到embedding中。
這裏我們自定義Position Embedding層,在tf.keras中自定義網絡主要有以下幾步:
1、繼承tf.keras.layers.Layer;
2、在類中實現__init__方法,用於對該層的初始化;
3、實現build方法用於定義該層的權重;
4、實現call方法,用於調用事件。對輸入數據進行自定義處理,同時還需要支持masking(根據實際長度進行計算);
5、在類中實現compute_output_shape方法,指定該層的輸出shape。
具體實現如下:
class Position_Embedding(tf.keras.layers.Layer):
'''帶位置信息的詞嵌入'''
def __init__(self,size=None,mode='sum',**kwargs):
super(Position_Embedding,self).__init__(**kwargs)
self.size=size#必須爲偶數
self.mode=mode
def call(self, inputs, **kwargs):
if self.size==None or self.mode=='sum':
self.size=int(inputs.shape[-1])
position_j=1./K.pow(10000.,2*K.arange(self.size/2,dtype='float32')/self.size)
position_j=K.expand_dims(position_j,0)
# 按照x的1維度累計求和,與arange一樣,生成序列。只不過按照x的實際長度來
position_i=tf.cumsum(K.ones_like(inputs[:,:,0]),1)-1
position_i=K.expand_dims(position_i,2)
position_ij=K.dot(position_i,position_j)
position_ij=K.concatenate([K.cos(position_ij),K.sin(position_ij)],2)
if self.mode=='sum':
return position_ij+inputs
elif self.mode=='concat':
return K.concatenate([position_ij,inputs],2)
def compute_output_shape(self, input_shape):
if self.mode == 'sum':
return input_shape
elif self.mode == 'concat':
return (input_shape[0], input_shape[1], input_shape[2]+self.size)
- 多頭注意力層的實現
Multi-Head的意思雖然很簡單——重複做幾次然後拼接,但事實上不能按照這個思路來寫程序,這樣會非常慢。因此我們必須把Multi-Head的操作合併到一個張量來運算,因爲單個張量的乘法內部則會自動並行。該方法直接將多頭注意力機制最後的全連接網絡中的權重提取出來,併入到原有的輸入Q、K、V中並按指定的次數展開,使他們直接以矩陣的方式進行計算。具體實現步驟如下:
1、對注意力機制中的三個角色Q、K、V作線性變換;
2、調用batch_dot對變換後的Q和K作基於矩陣的相乘計算;
3、對第二步的結果與V作作基於矩陣的相乘計算。
具體實現如下:
class Attention(tf.keras.layers.Layer):
'''基於內部注意力機制的多頭注意力機制'''
def __init__(self,nb_head,size_per_head,**kwargs):
super(Attention,self).__init__(**kwargs)
self.nb_head=nb_head #設置注意力的計算次數
self.size_per_head=size_per_head #設置每次線性變化爲size_per_head的維度
self.output_dim=nb_head*size_per_head #設置輸出的總維度
def build(self,input_shape):
'''定義q、k、v的權重'''
super(Attention,self).build(input_shape)
self.WQ=self.add_weight(name='WQ',
shape=(int(input_shape[0][-1]),self.output_dim),
initializer='glorot_uniform',trainable=True)
self.WK = self.add_weight(name='WK',
shape=(int(input_shape[1][-1]), self.output_dim),
initializer='glorot_uniform', trainable=True)
self.WV = self.add_weight(name='WV',
shape=(int(input_shape[2][-1]), self.output_dim),
initializer='glorot_uniform', trainable=True)
def Mask(self,inputs,seq_len,mode='mul'):
'''定義Mask方法方法,按照seq_len實際長度對inputs進行計算'''
if seq_len==None:
return inputs
else:
mask=K.one_hot(seq_len[:,0],K.shape(inputs)[1])
mask=1-K.cumsum(mask,1)
for _ in range(len(inputs.shape)-2):
mask=K.expand_dims(mask,2)
if mode=='mul':
return inputs*mask
if mode=='add':
return inputs-(1-mask)*1e12
def call(self, inputs, **kwargs):
#解析傳入的Q_seq,K_seq,V_seq
if len(inputs)==3:
Q_seq,K_seq,V_seq=inputs
Q_len,V_len=None,None
elif len(inputs)==5:
Q_seq,K_seq,V_seq,Q_len,V_len=inputs
#對Q_seq,K_seq,V_seq作nb_head次線性變換,並轉化爲size_per_head
Q_seq=K.dot(Q_seq,self.WQ)
Q_seq=K.reshape(Q_seq,(-1,K.shape(Q_seq)[1],self.nb_head,self.size_per_head))
Q_seq=K.permute_dimensions(Q_seq,(0,2,1,3))
K_seq = K.dot(K_seq, self.WK)
K_seq = K.reshape(K_seq, (-1, K.shape(K_seq)[1], self.nb_head, self.size_per_head))
K_seq = K.permute_dimensions(K_seq, (0, 2, 1, 3))
V_seq = K.dot(V_seq, self.WV)
V_seq = K.reshape(V_seq, (-1, K.shape(V_seq)[1], self.nb_head, self.size_per_head))
V_seq = K.permute_dimensions(V_seq, (0, 2, 1, 3))
# 計算內積,然後mask,然後softmax
# A=tf.compat.v1.keras.backend.batch_dot(Q_seq, K_seq, axes=[3,3])/ self.size_per_head**0.5
A = K.batch_dot(Q_seq, K_seq, axes=[3,3]) / self.size_per_head**0.5
A=K.permute_dimensions(A,(0,3,2,1))
A=self.Mask(A,V_len,'add')
A=K.permute_dimensions(A,(0,3,2,1))
A=K.softmax(A)
# 輸出並mask
O_seq=K.batch_dot(A,V_seq,axes=[3,2])
O_seq=K.permute_dimensions(O_seq,(0,2,1,3))
O_seq=K.reshape(O_seq,(-1,K.shape(O_seq)[1],self.output_dim))
O_seq=self.Mask(O_seq,Q_len,'mul')
return O_seq
def compute_output_shape(self, input_shape):
return (input_shape[0][0], input_shape[0][1], self.output_dim)
-
模型搭建
這裏沒什麼好說的,直接看代碼:
def RNN_Attention(embedding_size,vocab_size,max_len):
input=tf.keras.layers.Input([max_len])
# 生成帶位置信息的詞向量
embeddings=tf.keras.layers.Embedding(vocab_size,embedding_size)(input)
embeddings=Position_Embedding()(embeddings) #默認使用同等維度的位置向量
#注意力機制
x=Attention(8,16)([embeddings,embeddings,embeddings])
#全局池化
x=tf.keras.layers.GlobalAveragePooling1D()(x)
#dropout
x=tf.keras.layers.Dropout(rate=0.5)(x)
# x=TargetedDropout(drop_rate=0.5, target_rate=0.5)(x)
x=tf.keras.layers.Dense(1,activation='sigmoid')(x)
model=tf.keras.Model(inputs=input,outputs=x)
return model
2.3.3、模型的訓練
positive_data_file="./rt-polaritydata/rt-polarity.pos"
negative_data_file="./rt-polaritydata/rt-polarity.neg"
dataset,max_length, word_index, index_word=dataset(positive_data_file,negative_data_file)
embedding_size=128
vocab_size=len(word_index)
max_len=max_length
model=RNN_Attention(embedding_size,vocab_size,max_len)
model.summary()
#添加反向傳播節點
model.compile(loss='binary_crossentropy',optimizer='adam', metrics=['accuracy'])
#開始訓練
print('Train...')
model.fit(dataset,epochs=5)
最終訓練結果如下:
三、混合注意力機制的實現
混合注意力機制在之前的文章中已經詳細的進行解析,具體可以參考我之前的文章:【深度學習系列(六)】:RNN系列(4):帶注意力機制的seq2seq模型及其實戰(1)。這裏主要講一下該注意力機制與一般的注意力機制的區別。一般來說混合注意力機制的結構如下:
也就是說混合注意力機制與上一時刻的輸出s和位置信息a,以及當前時刻的內容h有關。而不帶位置信息的一般注意力機制的結構如下:
與混合注意力機制的區別就是多了個位置信息a。
3.1、混合注意力機制的具體實現
具體實現代碼如下:
import tensorflow as tf
from tensorflow.contrib.seq2seq.python.ops.attention_wrapper import BahdanauAttention
#from tensorflow.python.layers import core as layers_core
#from tensorflow.python.ops import array_ops, math_ops, nn_ops, variable_scope
from tensorflow.python.ops import array_ops, variable_scope
def _location_sensitive_score(processed_query, processed_location, keys):
#獲取注意力的深度(全連接神經元的個數)
dtype = processed_query.dtype
num_units = keys.shape[-1].value or array_ops.shape(keys)[-1]
#定義了最後一個全連接v
v_a = tf.get_variable('attention_variable', shape=[num_units], dtype=dtype,
initializer=tf.contrib.layers.xavier_initializer())
#定義了偏執b
b_a = tf.get_variable('attention_bias', shape=[num_units], dtype=dtype,
initializer=tf.zeros_initializer())
#計算注意力分數
return tf.reduce_sum(v_a * tf.tanh(keys + processed_query + processed_location + b_a), [2])
def _smoothing_normalization(e):#平滑歸一化函數,返回[batch_size, max_time],代替softmax
return tf.nn.sigmoid(e) / tf.reduce_sum(tf.nn.sigmoid(e), axis=-1, keepdims=True)
class LocationSensitiveAttention(BahdanauAttention):#位置敏感注意力
def __init__(self, #初始化
num_units, #實現過程中全連接的神經元個數
memory, #編碼器encoder的結果
smoothing=False, #是否使用平滑歸一化函數代替softmax
cumulate_weights=True, #是否對注意力結果進行累加
name='LocationSensitiveAttention'):
#smoothing爲true則使用_smoothing_normalization,否則使用softmax
normalization_function = _smoothing_normalization if (smoothing == True) else None
super(LocationSensitiveAttention, self).__init__(
num_units=num_units,
memory=memory,
memory_sequence_length=None,
probability_fn=normalization_function,#當爲None時,基類會調用softmax
name=name)
self.location_convolution = tf.layers.Conv1D(filters=32,
kernel_size=(31, ), padding='same', use_bias=True,
bias_initializer=tf.zeros_initializer(), name='location_features_convolution')
self.location_layer = tf.layers.Dense(units=num_units, use_bias=False,
dtype=tf.float32, name='location_features_layer')
self._cumulate = cumulate_weights
def __call__(self, query, #query爲解碼器decoder的中間態結果[batch_size, query_depth]
state):#state爲上一次的注意力[batch_size, alignments_size]
with variable_scope.variable_scope(None, "Location_Sensitive_Attention", [query]):
#全連接處理query特徵[batch_size, query_depth] -> [batch_size, attention_dim]
processed_query = self.query_layer(query) if self.query_layer else query
#維度擴展 -> [batch_size, 1, attention_dim]
processed_query = tf.expand_dims(processed_query, 1)
#維度擴展 [batch_size, max_time] -> [batch_size, max_time, 1]
expanded_alignments = tf.expand_dims(state, axis=2)
#通過卷積獲取位置特徵[batch_size, max_time, filters]
f = self.location_convolution(expanded_alignments)
#經過全連接變化[batch_size, max_time, attention_dim]
processed_location_features = self.location_layer(f)
#計算注意力分數 [batch_size, max_time]
energy = _location_sensitive_score(processed_query, processed_location_features, self.keys)
#計算最終注意力結果[batch_size, max_time]
alignments = self._probability_fn(energy, state)
#是否累加
if self._cumulate:
next_state = alignments + state
else:
next_state = alignments#[batch_size, alignments_size],alignments_size爲memory的最大序列次數max_time
return alignments, next_state