ELMo解讀(論文 + PyTorch源碼)

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的源碼,得出它大概是下面這麼個東西(手殘黨畫的醜,勿怪):

假設輸入的句子維度爲BWCB * W * C,這裏的 BB 表示batch_sizeWW 表示num_words,即一句話中的單詞數目,在一個batch中可能需要padding,CC 表示max_characters_per_token,即每個單詞的字符數目,這裏論文裏面用了固定值50,不根據每個batch的不同而動態設置,DD 表示projection_dim,即單詞輸入biLMs的embedding_size,或者理解爲最終生成的ELMo詞向量維度的1/21 / 2

從圖裏面看,輸入的句子會經過:

  1. Char Encode Layer: 即首先經過一個字符編碼層,因爲ELMo實際上是基於char的,所以它會先對每個單詞中的所有char進行編碼,從而得到這個單詞的表示。因此經過字符編碼層出來之後的維度爲BWDB * W * D,這就是我們熟知的對於一個句子在單詞級別上的編碼維度。
  2. biLMs:隨後該句子表示會經過biLMs,即雙向語言模型的建模,內部其實是分開訓練了兩個正向和反向的語言模型,而後將其表徵進行拼接,最終得到的輸出維度爲(L+1)BW2D(L+1) * B * W * 2D,+1實際上是加上了最初的embedding層,有點兒像residual,後面在“biLMs”部分會詳細提到。
  3. Scalar Mixer:緊接着,得到了biLMs各個層的表徵之後,會經過一個混合層,它會將前面這些層的表示進行線性融合(後面在“生成ELMo詞向量”部分會進行詳細說明),得出最終的ELMo向量,維度爲BW2DB * W * 2D

這裏只是對ELMo模型從全局上進行的一個統觀,對每個模塊裏面的結構還是很懵逼?沒關係,下面我們逐一來進行剖析:

2. 字符編碼層

這一層即“Char Encode Layer”,它的輸入維度是BWCB * W * C,輸出維度是BWDB * W * D,經查看源碼,它的結構圖長這樣:
Char Encode Layer結構

畫的有點兒亂,大家將就着看~

首先,輸入的句子會被reshape成BWCBW * C,因其是針對所有的char進行處理。然後會分別經過如下幾個層:

  1. Char Embedding:這就是正常的embedding層,針對每個char進行編碼,實際上所有char的詞表大概是262,其中0-255是char的unicode編碼,256-261這6個分別是<bow>(單詞的開始)、<eow>(單詞的結束)、 <bos>(句子的開始)、<eos>(句子的結束)、<pow>(單詞補齊符)和<pos>(句子補齊符)。可見詞表還是比較小的,而且沒有OOV的情況出現。這裏的Embedding參數維度爲262(num_characters)d(char_embed_dim)262(num\_characters) * d(char\_embed\_dim)。注意這裏的 dd 與上一節提到的 DD 是兩個概念,dd 表示的是字符的embedding維度,而 DD 表示的是單詞的embedding維度,後面會看到它們之間的映射關係。這部分的輸出維度爲BWCdBW * C * d
  2. Multi-Scale卷積層:這裏用的是不同scale的卷積層,注意是在寬度上擴展,而不是深度上,即輸入都是一樣的,卷積之間的不同在於其kernel_sizechannel_size的大小不同,用於捕捉不同n-grams之間的信息,這點其實是仿照 TextCNN 的模型結構。假設有mm個這樣的卷積層,其kernel_sizek1,k2,...,kmk1, k2, ..., km,比如1,2,3,4,5,6,7這種,其channel_sized1,d2,...,dmd1, d2, ..., dm,比如32,64,128,256,512,1024這種。注意:這裏的卷積都是1維卷積,即只在序列長度上做卷積。與圖像中的處理類似,在卷積之後,會經過MaxPooling進行池化,這裏的目的主要在於經過前面卷積出的序列長度往往不一致,後期沒辦法進行合併,所以這裏在序列維度上進行MaxPooling,其實就是取一個單詞中最大的那個char的表示作爲整個單詞的表示。最後再經過激活層,這一步就算結束了。根據不同的channel_size的大小,這一步的輸出維度分別爲BWd1,BWd2,...,BWdmBW * d1, BW * d2, ..., BW * dm
  3. Concat層:上一步得出的是m個不同維度的矩陣,爲了方便後期處理,這裏將其在最後一維上進行拼接,而後將其reshape回單詞級別的維度BW(d1+d2+...+dm)B * W * (d1+d2+...+dm)
  4. Highway層:Highway(參見:https://arxiv.org/abs/1505.00387 )是仿照圖像中residual的做法,在NLP領域中常有應用,看代碼裏面的實現,這一層實現的公式見下面:其實就是一種全連接+殘差的實現方式,只不過這裏還需要一個element-wise的gate矩陣對xxf(A(x))f(A(x))進行變換。這裏需要經過HH 層這樣的Highway層,輸出維度仍爲BW(d1+d2+...+dm)B * W * (d1+d2+...+dm)

y=gx+(1g)f(A(x)),g=Sigmoid(B(x))y = g * x + (1 - g) * f(A(x)), g = Sigmoid(B(x))

  1. Linear映射層:經過前面的計算,得到的向量維度d1+d2+...+dmd1+d2+...+dm往往比較長,這裏額外加了一層的Linear進行映射,將維度映射到DD,作爲詞的embedding送入後續的層中,這裏輸出的維度爲BWDB * W * D

3. biLMs原理

ELMo主要是建立在biLMs(雙向語言模型)上的,下面先從數學上介紹一下什麼是biLMs。

具體來說,給定一個有NN個token的序列(t1,t2,...,tN)(t_1, t_2, ..., t_N),前向的語言模型(一般是多層的LSTM之類的)用於計算給定前面tokens的情況下當前token的概率,即:

p(t1,t2,...,tN)=k=1Np(tkt1,t2,...,tk1)p(t_1, t_2, ..., t_N) = \prod_{k=1}^{N} p(t_k | t_1, t_2, ..., t_{k-1})

在每一個位置kk,模型都會在每一層輸出一個上下文相關的表徵hk,jLM\overrightarrow{h}_{k, j}^{LM},這裏的j=1,...,Lj = 1, ..., L表示第幾層。頂層的輸出hk,LLM\overrightarrow{h}_{k, L}^{LM}用於預測下一個token:tk+1t_{k+1}

同樣地,反向的語言模型訓練與正向的一樣,只不過輸入是反過來的,即計算給定後面tokens的情況下當前token的概率:

p(t1,t2,...,tN)=k=1Np(tktk+1,tk+2,...,tN)p(t_1, t_2, ..., t_N) = \prod_{k=1}^{N} p(t_k | t_{k+1}, t_{k+2}, ..., t_N)

同樣,反向的LM在每個位置kk,也會在每一層生成一個上下文相關的表徵hk,jLM\overleftarrow{h}_{k, j}^{LM}

ELMo用的biLMs就是同時結合正向和反向的語言模型,其目標是最大化如下的似然值:

k=1N(logp(tkt1,...,tk1;Θx,ΘLSTM,Θs)+(logp(tktk+1,...,tN;Θx,ΘLSTM,Θs))\sum_{k=1}^N(\log p(t_k | t_1, ..., t_{k-1}; \Theta_x, \overrightarrow \Theta _{LSTM}, \Theta_s) + (\log p(t_k | t_{k+1}, ..., t_N; \Theta_x, \overleftarrow \Theta _{LSTM}, \Theta_s))

裏面的Θx\Theta_xΘs\Theta_sΘLSTM\overrightarrow \Theta _{LSTM}ΘLSTM\overleftarrow \Theta _{LSTM}分別是詞嵌入,輸出層(Softmax之前的)以及正反向LSTM的參數。

可以看出,其實就是相當於分別訓練了正向和反向的兩個LM。 好像也只能分開進行訓練,因爲LM不能訓練雙向的。

示意圖的話,就是下面這種多層BiLSTM的樣子:
biLMs架構
這裏的 hh 表示LSTM單元的hidden_size,可能會比較大,比如D=512,h=4096D = 512, h = 4096這樣。所以在每一層結束後還需要一個Linear層將維度從 hh 映射爲 DD,而後再輸入到下一層中。最後的輸出是將每一層的所有輸出以及embedding的輸出,進行stack,每一層的輸出裏面又是對每個timestep的正向和反向的輸出進行concat,因而最後的輸出維度爲(L+1)BW2D(L+1) * B * W * 2D,這裏的 L+1L + 1 中的 +1+1 就代表着那一層embedding輸出,其會複製成兩份,以與biLMs每層的輸出維度保持一致。

4. 生成ELMo詞向量

在經過了biLMs層之後,得到的表徵維度爲(L+1)BW2D(L+1) * B * W * 2D,接下來就需要生成最終的ELMo向量了!

對於每一個token tkt_kLL 層的biLMs,生成出來的表徵有 2L+12L + 1 個,如下公式:

Rk={xkLM,hk,jLM,hk,jLMj=1,...,L}={hk,jLMj=0,..,L}R_k = \{x_k^{LM}, \overrightarrow{h}_{k,j}^{LM}, \overleftarrow{h}_{k,j}^{LM} | j = 1, ..., L\} = \{h_{k,j}^{LM} | j = 0, .., L\}

這裏的hk,0LMh_{k,0}^{LM}是詞的embedding輸出,hk,jLM=[hk,jLM;hk,jLM]h_{k,j}^{LM} = [\overrightarrow{h}_{k,j}^{LM}; \overleftarrow{h}_{k,j}^{LM}]表示每一層的正向和反向輸出拼接後的結果。

對於這些表徵,論文用如下公式對它們做了一個scalar mixer:

ELMoktask=E(Rk;Θtask)=γtaskj=0Lsjtaskhk,jLMELMo_{k}^{task} = E(R_k; \Theta^{task}) = \gamma^{task} \sum_{j=0}^L s_j^{task} h_{k,j}^{LM}

這裏的sjtasks_j^{task}是一個softmax後的概率值,標量參數γtask\gamma^{task}是用於對整個ELMo向量進行scale上的縮放。這兩部分都是作爲參數來學習的,針對不同任務會有不同的值。

同時論文裏面還提到,每一層輸出的分佈之間可能會有較大差別,所以有時也會在線性融合之前,爲每層的輸出做一個Layer Normalization,這與Transformer裏面一致。

經過Scalar Mixer之後的向量維度爲BW2DB * W * 2D,即爲生成的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(情感分析任務),其結果如下:
EMLo結合下游NLP任務的表現
可見,基本都是在一個較低的baseline的情況下,用了ELMo後,達到了超越之前SoTA的效果!

四. 一些分析

論文中,作者也做了一些有趣的分析,從各個角度窺探ELMo的優勢和特性。比如:

1. 使用哪些層的輸出?

作者探索了使用不同biLMs層帶來的效果,以及使用不同的L2範數的權重,如下表所示:

這裏面的Last Only指的是隻是用biLM最頂層的輸出,λ\lambda 指的是L2範數的權重,可見使用所有層的效果普遍比較好,並且較低的L2範數效果也較好,因其讓每一層的表示都趨於不同,當L2範數的權重較大時,會讓模型所有層的參數值趨於一致,導致模型每層的輸出也會趨於一致。

2. 在哪裏加入ELMo?

前面提到過,可以在輸入和輸出的時候加入ELMo向量,作者比較了這兩者的不同:

在問答和文本蘊含任務上,是同時在輸入和輸出加入ELMo的效果較好,而在語義角色標註任務上,則是隻在輸入加入比較好。論文猜測這個原因可能是因爲,在前兩個任務上,都需要用到attention,而在輸出的時候加入ELMo,能讓attention直接看到ELMo的輸出,會對整個任務有利。而在語義角色標註上,與任務相關的上下文表徵要比biLMs的通用輸出更重要一些。

3. 每層輸出的側重點是什麼?

論文通過實驗得出,在biLMs的低層,表徵更側重於諸如詞性等這種語法特徵,而在高層的表徵則更側重於語義特徵。比如下面的實驗結果:
在這裏插入圖片描述

左邊的任務是語義消歧,右邊的任務是詞性標註,可見在語義消歧任務上面,使用第二層的效果比第一層的要好;而在詞性標註任務上面,使用第一層的效果反而比使用第二層的效果要好。

總體來看,還是使用所有層輸出的效果會更好,具體的weight讓模型自己去學就好了。

4. 效率分析

一般而言,用了預訓練模型的網絡往往收斂的會更快,同時也可以使用更少的數據集。論文通過實驗驗證了這一點:

比如在SRL任務中,使用了ELMo的模型僅使用1%的數據集就能達到不使用ELMo模型在使用10%數據集的效果!

五. 總結

ELMo具有如下的優良特性:

  1. 上下文相關:每個單詞的表示取決於使用它的整個上下文。
  2. 深度:單詞表示組合了深度預訓練神經網絡的所有層。
  3. 基於字符:ELMo表示純粹基於字符,然後經過CharCNN之後再作爲詞的表示,解決了OOV問題,而且輸入的詞表也很小。
  4. 資源豐富:有完整的源碼、預訓練模型、參數以及詳盡的調用方式和例子,又是一個造福伸手黨的好項目!而且:還有人專門實現了多語的,好像是哈工大搞的,戳這裏看項目。

傳送門

論文: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,還有繁體中文的)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章