ELMo解读(论文 + PyTorch源码)
ELMo的概念也是很早就出了,應該是18年初的事情了。但我仍然是后知后覺,居然還是等BERT出來很久之后,才知道有這么個東西。這兩天才仔細看了下論文和源碼,在這里做一些記錄,如果有不詳實的地方,歡迎指出~
文章目錄
前言
一. ELMo原理
1. ELMo整體模型結構
2. 字符編碼層
3. biLMs原理
4. 生成ELMo詞向量
5. 結合下游NLP任務
二. PyTorch實現
1. 字符編碼層
2. biLMs層
3. 生成ELMo詞向量
三. 實驗
四. 一些分析
1. 使用哪些層的輸出?
2. 在哪里加入ELMo?
3. 每層輸出的側重點是什么?
4. 效率分析
五. 總結
傳送門
前言
前言
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的源碼,得出它大概是下面這么個東西(手殘黨畫的丑,勿怪):
?
?
?
?
?
?
?
5. 結合下游NLP任務
一般ELMo模型會在一個超大的語料庫上進行預訓練,因為是訓練語言模型,不需要任何的標簽,純文本就可以,因而這里可以用超大的語料庫,這一點的優勢是十分明顯的。訓練完ELMo模型之后,就可以輸入一個新句子,得到其中每個單詞在當前這個句子上下文下的ELMo詞向量了。
論文中提到,在訓練的時候,發現使用合適的dropout和L2在ELMo模型上時會提升效果。
此時這個詞向量就可以接入到下游的NLP任務中,比如問答、情感分析等。從接入的位置來看,可以與下游NLP任務本身輸入的embedding拼接在一起,也可以與其輸出拼接在一起。而從模型是否固定來看,又可以將ELMo詞向量預先全部提取出來,即固定ELMo模型不讓其訓練,也可以在訓練下游NLP任務時順帶fine-tune這個ELMo模型??傊?#xff0c;使用起來非常的方便,可以插入到任何想插入的地方進行增補。
二. 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 = inputsfor 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_partreturn 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 iin 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_maskmean = torch.sum(tensor_masked) / num_elements_not_maskedvariance = torch.sum(((tensor_masked - mean) * broadcast_mask)**2) / num_elements_not_maskedreturn (tensor - mean) / torch.sqrt(variance + 1E-12)normed_weights = torch.nn.functional.softmax(torch.cat([parameter for parameterin 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_dimpieces = []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最頂層的輸出,λ \lambdaλ 指的是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,還有繁體中文的)
總結
以上是生活随笔為你收集整理的ELMo解读(论文 + PyTorch源码)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: NLP.TM | GloVe模型及其Py
- 下一篇: 用gensim学习word2vec