ELMo的概念也是很早就出了,應該是18年初的事情了。但我仍然是後知後覺,居然還是等BERT出來很久之後,才知道有這麼個東西。這兩天才仔細看了下論文和源碼,在這裏做一些記錄,如果有不詳實的地方,歡迎指出~
文章目錄
前言
ELMo出自Allen研究所在NAACL2018會議上發表的一篇論文《Deep contextualized word representations》,從論文名稱看,應該是提出了一個新的詞表徵的方法。據他們自己的介紹:ELMo是一個深度帶上下文的詞表徵模型,能同時建模(1)單詞使用的複雜特徵(例如,語法和語義);(2)這些特徵在上下文中會有何變化(如歧義等)。這些詞向量從深度雙向語言模型(biLM)的隱層狀態中衍生出來,biLM是在大規模的語料上面Pretrain的。它們可以靈活輕鬆地加入到現有的模型中,並且能在很多NLP任務中顯著提升現有的表現,比如問答、文本蘊含和情感分析等。聽起來非常的exciting,它的原理也十分reasonable!下面就將針對論文及其PyTorch源碼進行剖析,具體的資料參見文末的傳送門。
這裏先聲明一點:筆者認爲“ELMo”這個名稱既可以代表得到詞向量的模型,也可以是得出的詞向量本身,就像Word2Vec、GloVe這些名稱一樣,都是可以代表兩個含義的。下面提到ELMo時,一般帶有“模型”相關字眼的就是指的訓練出詞向量的模型,而帶有“詞向量”相關字眼的就是指的得出的詞向量。
一. ELMo原理
之前我們一般比較常用的詞嵌入的方法是諸如Word2Vec和GloVe這種,但這些詞嵌入的訓練方式一般都是上下文無關的,並且對於同一個詞,不管它處於什麼樣的語境,它的詞向量都是一樣的,這樣對於那些有歧義的詞非常不友好。因此,論文就考慮到了要根據輸入的句子作爲上下文,來具體計算每個詞的表徵,提出了ELMo(Embeddings from Language Model)。它的基本思想,用大白話來說就是,還是用訓練語言模型的套路,然後把語言模型中間隱含層的輸出提取出來,作爲這個詞在當前上下文情境下的表徵,簡單但很有用!
1. ELMo整體模型結構
對於ELMo的模型結構,其實論文中並沒有給出具體的圖(這點對於筆者這種想象力極差的人來說很痛苦),筆者通過整合論文裏面的蛛絲馬跡以及PyTorch的源碼,得出它大概是下面這麼個東西(手殘黨畫的醜,勿怪):
假設輸入的句子維度爲,這裏的 表示batch_size
, 表示num_words
,即一句話中的單詞數目,在一個batch中可能需要padding, 表示max_characters_per_token
,即每個單詞的字符數目,這裏論文裏面用了固定值50,不根據每個batch的不同而動態設置, 表示projection_dim
,即單詞輸入biLMs的embedding_size
,或者理解爲最終生成的ELMo詞向量維度的。
從圖裏面看,輸入的句子會經過:
- Char Encode Layer: 即首先經過一個字符編碼層,因爲ELMo實際上是基於char的,所以它會先對每個單詞中的所有char進行編碼,從而得到這個單詞的表示。因此經過字符編碼層出來之後的維度爲,這就是我們熟知的對於一個句子在單詞級別上的編碼維度。
- biLMs:隨後該句子表示會經過biLMs,即雙向語言模型的建模,內部其實是分開訓練了兩個正向和反向的語言模型,而後將其表徵進行拼接,最終得到的輸出維度爲,+1實際上是加上了最初的embedding層,有點兒像residual,後面在“biLMs”部分會詳細提到。
- Scalar Mixer:緊接着,得到了biLMs各個層的表徵之後,會經過一個混合層,它會將前面這些層的表示進行線性融合(後面在“生成ELMo詞向量”部分會進行詳細說明),得出最終的ELMo向量,維度爲。
這裏只是對ELMo模型從全局上進行的一個統觀,對每個模塊裏面的結構還是很懵逼?沒關係,下面我們逐一來進行剖析:
2. 字符編碼層
這一層即“Char Encode Layer”,它的輸入維度是,輸出維度是,經查看源碼,它的結構圖長這樣:
畫的有點兒亂,大家將就着看~
首先,輸入的句子會被reshape成,因其是針對所有的char進行處理。然後會分別經過如下幾個層:
- Char Embedding:這就是正常的embedding層,針對每個char進行編碼,實際上所有char的詞表大概是262,其中0-255是char的unicode編碼,256-261這6個分別是
<bow>
(單詞的開始)、<eow>
(單詞的結束)、<bos>
(句子的開始)、<eos>
(句子的結束)、<pow>
(單詞補齊符)和<pos>
(句子補齊符)。可見詞表還是比較小的,而且沒有OOV的情況出現。這裏的Embedding參數維度爲。注意這裏的 與上一節提到的 是兩個概念, 表示的是字符的embedding維度,而 表示的是單詞的embedding維度,後面會看到它們之間的映射關係。這部分的輸出維度爲。 - Multi-Scale卷積層:這裏用的是不同scale的卷積層,注意是在寬度上擴展,而不是深度上,即輸入都是一樣的,卷積之間的不同在於其
kernel_size
和channel_size
的大小不同,用於捕捉不同n-grams之間的信息,這點其實是仿照 TextCNN 的模型結構。假設有個這樣的卷積層,其kernel_size
從 ,比如1,2,3,4,5,6,7
這種,其channel_size
從 ,比如32,64,128,256,512,1024
這種。注意:這裏的卷積都是1維卷積,即只在序列長度上做卷積。與圖像中的處理類似,在卷積之後,會經過MaxPooling進行池化,這裏的目的主要在於經過前面卷積出的序列長度往往不一致,後期沒辦法進行合併,所以這裏在序列維度上進行MaxPooling,其實就是取一個單詞中最大的那個char的表示作爲整個單詞的表示。最後再經過激活層,這一步就算結束了。根據不同的channel_size
的大小,這一步的輸出維度分別爲。 - Concat層:上一步得出的是m個不同維度的矩陣,爲了方便後期處理,這裏將其在最後一維上進行拼接,而後將其reshape回單詞級別的維度。
- Highway層:Highway(參見:https://arxiv.org/abs/1505.00387 )是仿照圖像中residual的做法,在NLP領域中常有應用,看代碼裏面的實現,這一層實現的公式見下面:其實就是一種全連接+殘差的實現方式,只不過這裏還需要一個element-wise的gate矩陣對和進行變換。這裏需要經過 層這樣的Highway層,輸出維度仍爲。
- Linear映射層:經過前面的計算,得到的向量維度往往比較長,這裏額外加了一層的Linear進行映射,將維度映射到,作爲詞的embedding送入後續的層中,這裏輸出的維度爲。
3. biLMs原理
ELMo主要是建立在biLMs(雙向語言模型)上的,下面先從數學上介紹一下什麼是biLMs。
具體來說,給定一個有個token的序列,前向的語言模型(一般是多層的LSTM之類的)用於計算給定前面tokens的情況下當前token的概率,即:
在每一個位置,模型都會在每一層輸出一個上下文相關的表徵,這裏的表示第幾層。頂層的輸出用於預測下一個token:。
同樣地,反向的語言模型訓練與正向的一樣,只不過輸入是反過來的,即計算給定後面tokens的情況下當前token的概率:
同樣,反向的LM在每個位置,也會在每一層生成一個上下文相關的表徵。
ELMo用的biLMs就是同時結合正向和反向的語言模型,其目標是最大化如下的似然值:
裏面的、和及分別是詞嵌入,輸出層(Softmax之前的)以及正反向LSTM的參數。
可以看出,其實就是相當於分別訓練了正向和反向的兩個LM。 好像也只能分開進行訓練,因爲LM不能訓練雙向的。
示意圖的話,就是下面這種多層BiLSTM的樣子:
這裏的 表示LSTM單元的hidden_size
,可能會比較大,比如這樣。所以在每一層結束後還需要一個Linear
層將維度從 映射爲 ,而後再輸入到下一層中。最後的輸出是將每一層的所有輸出以及embedding的輸出,進行stack,每一層的輸出裏面又是對每個timestep的正向和反向的輸出進行concat,因而最後的輸出維度爲,這裏的 中的 就代表着那一層embedding輸出,其會複製成兩份,以與biLMs每層的輸出維度保持一致。
4. 生成ELMo詞向量
在經過了biLMs層之後,得到的表徵維度爲,接下來就需要生成最終的ELMo向量了!
對於每一個token , 層的biLMs,生成出來的表徵有 個,如下公式:
這裏的是詞的embedding輸出,表示每一層的正向和反向輸出拼接後的結果。
對於這些表徵,論文用如下公式對它們做了一個scalar mixer:
這裏的是一個softmax後的概率值,標量參數是用於對整個ELMo向量進行scale上的縮放。這兩部分都是作爲參數來學習的,針對不同任務會有不同的值。
同時論文裏面還提到,每一層輸出的分佈之間可能會有較大差別,所以有時也會在線性融合之前,爲每層的輸出做一個Layer Normalization,這與Transformer裏面一致。
經過Scalar Mixer之後的向量維度爲,即爲生成的ELMo詞向量,可以用於後續的任務。
5. 結合下游NLP任務
一般ELMo模型會在一個超大的語料庫上進行預訓練,因爲是訓練語言模型,不需要任何的標籤,純文本就可以,因而這裏可以用超大的語料庫,這一點的優勢是十分明顯的。訓練完ELMo模型之後,就可以輸入一個新句子,得到其中每個單詞在當前這個句子上下文下的ELMo詞向量了。
論文中提到,在訓練的時候,發現使用合適的dropout和L2在ELMo模型上時會提升效果。
此時這個詞向量就可以接入到下游的NLP任務中,比如問答、情感分析等。從接入的位置來看,可以與下游NLP任務本身輸入的embedding拼接在一起,也可以與其輸出拼接在一起。而從模型是否固定來看,又可以將ELMo詞向量預先全部提取出來,即固定ELMo模型不讓其訓練,也可以在訓練下游NLP任務時順帶fine-tune這個ELMo模型。總之,使用起來非常的方便,可以插入到任何想插入的地方進行增補。
二. PyTorch實現
這裏參考的主要是allennlp裏面與ELMo本身有關的部分,涉及到biLMs的模型實現,以及ELMo推理部分,會只列出核心的部分,細枝末節的代碼就不列舉了。至於如何與下游的NLP任務結合以及fine-tune,還需要讀者自己去探索和實踐,這裏不做說明!
1. 字符編碼層
這裏實現的就是前面提到的Char Encode Layer。
首先是multi-scale CNN的實現:
# multi-scale CNN
# 網絡定義
for i, (width, num) in enumerate(filters):
conv = torch.nn.Conv1d(
in_channels=char_embed_dim,
out_channels=num,
kernel_size=width,
bias=True
)
self.add_module('char_conv_{}'.format(i), conv)
# forward函數
def forward(sef, character_embedding)
convs = []
for i in range(len(self._convolutions)):
conv = getattr(self, 'char_conv_{}'.format(i))
convolved = conv(character_embedding)
# (batch_size * sequence_length, n_filters for this width)
convolved, _ = torch.max(convolved, dim=-1)
convolved = activation(convolved)
convs.append(convolved)
# (batch_size * sequence_length, n_filters)
token_embedding = torch.cat(convs, dim=-1)
return token_embedding
然後是highway的實現:
# HighWay
# 網絡定義
self._layers = torch.nn.ModuleList([torch.nn.Linear(input_dim, input_dim * 2)
for _ in range(num_layers)])
# forward函數
def forward(self, inputs):
current_input = inputs
for layer in self._layers:
projected_input = layer(current_input)
linear_part = current_input
# NOTE: if you modify this, think about whether you should modify the initialization
# above, too.
nonlinear_part, gate = projected_input.chunk(2, dim=-1)
nonlinear_part = self._activation(nonlinear_part)
gate = torch.sigmoid(gate)
current_input = gate * linear_part + (1 - gate) * nonlinear_part
return current_input
2. biLMs層
這部分實際上是兩個不同方向的BiLSTM訓練,然後輸出經過映射後直接進行拼接即可,代碼如下:(以單向單層的爲例)
# 網絡定義
# input_size:輸入embedding的維度
# hidden_size:輸入和輸出hidden state的維度
# cell_size:LSTMCell的內部維度。
# 一般input_size = hidden_size = D, hidden_size即爲h。
self.input_linearity = torch.nn.Linear(input_size, 4 * cell_size, bias=False)
self.state_linearity = torch.nn.Linear(hidden_size, 4 * cell_size, bias=True)
self.state_projection = torch.nn.Linear(cell_size, hidden_size, bias=False)
# forward函數
def forward(self, inputs, batch_lengths, initial_state):
for timestep in range(total_timesteps):
# Do the projections for all the gates all at once.
# Both have shape (batch_size, 4 * cell_size)
projected_input = self.input_linearity(timestep_input)
projected_state = self.state_linearity(previous_state)
# Main LSTM equations using relevant chunks of the big linear
# projections of the hidden state and inputs.
input_gate = torch.sigmoid(projected_input[:, (0 * self.cell_size):(1 * self.cell_size)] +
projected_state[:, (0 * self.cell_size):(1 * self.cell_size)])
forget_gate = torch.sigmoid(projected_input[:, (1 * self.cell_size):(2 * self.cell_size)] +
projected_state[:, (1 * self.cell_size):(2 * self.cell_size)])
memory_init = torch.tanh(projected_input[:, (2 * self.cell_size):(3 * self.cell_size)] +
projected_state[:, (2 * self.cell_size):(3 * self.cell_size)])
output_gate = torch.sigmoid(projected_input[:, (3 * self.cell_size):(4 * self.cell_size)] +
projected_state[:, (3 * self.cell_size):(4 * self.cell_size)])
memory = input_gate * memory_init + forget_gate * previous_memory
# shape (current_length_index, cell_size)
pre_projection_timestep_output = output_gate * torch.tanh(memory)
# shape (current_length_index, hidden_size)
timestep_output = self.state_projection(pre_projection_timestep_output)
output_accumulator[0:current_length_index + 1, index] = timestep_output
# Mimic the pytorch API by returning state in the following shape:
# (num_layers * num_directions, batch_size, ...). As this
# LSTM cell cannot be stacked, the first dimension here is just 1.
final_state = (full_batch_previous_state.unsqueeze(0),
full_batch_previous_memory.unsqueeze(0))
return output_accumulator, final_state
3. 生成ELMo詞向量
這部分即爲Scalar Mixer,其代碼如下:
# 參數定義
self.scalar_parameters = ParameterList(
[Parameter(torch.FloatTensor([initial_scalar_parameters[i]]),
requires_grad=trainable) for i
in range(mixture_size)])
self.gamma = Parameter(torch.FloatTensor([1.0]), requires_grad=trainable)
# forward函數
def forward(tensors, mask):
def _do_layer_norm(tensor, broadcast_mask, num_elements_not_masked):
tensor_masked = tensor * broadcast_mask
mean = torch.sum(tensor_masked) / num_elements_not_masked
variance = torch.sum(((tensor_masked - mean) * broadcast_mask)**2) / num_elements_not_masked
return (tensor - mean) / torch.sqrt(variance + 1E-12)
normed_weights = torch.nn.functional.softmax(torch.cat([parameter for parameter
in self.scalar_parameters]), dim=0)
normed_weights = torch.split(normed_weights, split_size_or_sections=1)
if not self.do_layer_norm:
pieces = []
for weight, tensor in zip(normed_weights, tensors):
pieces.append(weight * tensor)
return self.gamma * sum(pieces)
else:
mask_float = mask.float()
broadcast_mask = mask_float.unsqueeze(-1)
input_dim = tensors[0].size(-1)
num_elements_not_masked = torch.sum(mask_float) * input_dim
pieces = []
for weight, tensor in zip(normed_weights, tensors):
pieces.append(weight * _do_layer_norm(tensor,
broadcast_mask, num_elements_not_masked))
return self.gamma * sum(pieces)
三. 實驗
這裏主要列舉一些在實際下游任務上結合ELMo的表現,分別是SQuAD(問答任務)、SNLI(文本蘊含)、SRL(語義角色標註)、Coref(共指消解)、NER(命名實體識別)以及SST-5(情感分析任務),其結果如下:
可見,基本都是在一個較低的baseline的情況下,用了ELMo後,達到了超越之前SoTA的效果!
四. 一些分析
論文中,作者也做了一些有趣的分析,從各個角度窺探ELMo的優勢和特性。比如:
1. 使用哪些層的輸出?
作者探索了使用不同biLMs層帶來的效果,以及使用不同的L2範數的權重,如下表所示:
這裏面的Last Only指的是隻是用biLM最頂層的輸出, 指的是L2範數的權重,可見使用所有層的效果普遍比較好,並且較低的L2範數效果也較好,因其讓每一層的表示都趨於不同,當L2範數的權重較大時,會讓模型所有層的參數值趨於一致,導致模型每層的輸出也會趨於一致。
2. 在哪裏加入ELMo?
前面提到過,可以在輸入和輸出的時候加入ELMo向量,作者比較了這兩者的不同:
在問答和文本蘊含任務上,是同時在輸入和輸出加入ELMo的效果較好,而在語義角色標註任務上,則是隻在輸入加入比較好。論文猜測這個原因可能是因爲,在前兩個任務上,都需要用到attention,而在輸出的時候加入ELMo,能讓attention直接看到ELMo的輸出,會對整個任務有利。而在語義角色標註上,與任務相關的上下文表徵要比biLMs的通用輸出更重要一些。
3. 每層輸出的側重點是什麼?
論文通過實驗得出,在biLMs的低層,表徵更側重於諸如詞性等這種語法特徵,而在高層的表徵則更側重於語義特徵。比如下面的實驗結果:
左邊的任務是語義消歧,右邊的任務是詞性標註,可見在語義消歧任務上面,使用第二層的效果比第一層的要好;而在詞性標註任務上面,使用第一層的效果反而比使用第二層的效果要好。
總體來看,還是使用所有層輸出的效果會更好,具體的weight讓模型自己去學就好了。
4. 效率分析
一般而言,用了預訓練模型的網絡往往收斂的會更快,同時也可以使用更少的數據集。論文通過實驗驗證了這一點:
比如在SRL任務中,使用了ELMo的模型僅使用1%的數據集就能達到不使用ELMo模型在使用10%數據集的效果!
五. 總結
ELMo具有如下的優良特性:
- 上下文相關:每個單詞的表示取決於使用它的整個上下文。
- 深度:單詞表示組合了深度預訓練神經網絡的所有層。
- 基於字符:ELMo表示純粹基於字符,然後經過CharCNN之後再作爲詞的表示,解決了OOV問題,而且輸入的詞表也很小。
- 資源豐富:有完整的源碼、預訓練模型、參數以及詳盡的調用方式和例子,又是一個造福伸手黨的好項目!而且:還有人專門實現了多語的,好像是哈工大搞的,戳這裏看項目。
傳送門
論文:https://arxiv.org/pdf/1802.05365.pdf
項目首頁:https://allennlp.org/elmo
源碼:https://github.com/allenai/allennlp (PyTorch,關於ELMo的部分戳這裏)
https://github.com/allenai/bilm-tf (TensorFlow)
多語言:https://github.com/HIT-SCIR/ELMoForManyLangs (哈工大CoNLL評測的多國語言ELMo,還有繁體中文的)