ELMo解读(论文 + PyTorch源码)

ELMo是一种深度上下文词表示模型,能捕捉单词在特定上下文中的复杂特征,从大规模语料预训练的双向语言模型中衍生词向量,显著提升NLP任务表现。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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的源码,得出它大概是下面这么个东西(手残党画的丑,勿怪):
 

 

Char Encode Layer结构 

 

 

 biLMs架构

 

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具有如下的优良特性:

上下文相关:每个单词的表示取决于使用它的整个上下文。
深度:单词表示组合了深度预训练神经网络的所有层。
基于字符: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,还有繁体中文的)

以下是使用 PyTorch 实现的 ELMo 词向量和 Glove 词向量在情感分类任务上的简单代码: ```python import torch import torch.nn as nn import torch.optim as optim import torch.nn.functional as F from torchtext.datasets import IMDB from torchtext.data import Field, LabelField, BucketIterator class ELMo(nn.Module): def __init__(self, embedding_dim, hidden_dim, num_layers): super(ELMo, self).__init__() self.embedding_dim = embedding_dim self.hidden_dim = hidden_dim self.num_layers = num_layers self.embedding = nn.Embedding(vocab_size, embedding_dim) self.lstm = nn.LSTM(embedding_dim, hidden_dim, num_layers, bidirectional=True) self.linear = nn.Linear(hidden_dim * 2, 1) def forward(self, x): # x: (seq_len, batch_size) embedded = self.embedding(x) # embedded: (seq_len, batch_size, embedding_dim) outputs, _ = self.lstm(embedded) # outputs: (seq_len, batch_size, hidden_dim * 2) weights = F.softmax(self.linear(outputs), dim=0) # weights: (seq_len, batch_size, 1) embeddings = torch.sum(weights * outputs, dim=0) # embeddings: (batch_size, hidden_dim * 2) return embeddings class Glove(nn.Module): def __init__(self, embedding_dim): super(Glove, self).__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim) def forward(self, x): # x: (seq_len, batch_size) embedded = self.embedding(x) # embedded: (seq_len, batch_size, embedding_dim) embeddings = torch.mean(embedded, dim=0) # embeddings: (batch_size, embedding_dim) return embeddings class Classifier(nn.Module): def __init__(self, input_dim, hidden_dim, output_dim): super(Classifier, self).__init__() self.fc1 = nn.Linear(input_dim, hidden_dim) self.fc2 = nn.Linear(hidden_dim, output_dim) def forward(self, x): # x: (batch_size, input_dim) x = F.relu(self.fc1(x)) # x: (batch_size, hidden_dim) x = self.fc2(x) # x: (batch_size, output_dim) return x # define Fields TEXT = Field(tokenize='spacy') LABEL = LabelField(dtype=torch.float) # load data train_data, test_data = IMDB.splits(TEXT, LABEL) # build vocabulary TEXT.build_vocab(train_data, max_size=10000, vectors=['glove.6B.100d']) LABEL.build_vocab(train_data) # define device device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # define hyperparameters batch_size = 64 embedding_dim = 100 hidden_dim = 256 num_layers = 2 input_dim = hidden_dim * 4 output_dim = 1 lr = 1e-3 num_epochs = 10 # define models elmo = ELMo(embedding_dim, hidden_dim, num_layers).to(device) glove = Glove(embedding_dim).to(device) classifier = Classifier(input_dim, hidden_dim, output_dim).to(device) # define loss function and optimizer criterion = nn.BCEWithLogitsLoss() optimizer = optim.Adam(classifier.parameters(), lr=lr) # define iterators train_iterator, test_iterator = BucketIterator.splits( (train_data, test_data), batch_size=batch_size, device=device) # train models for epoch in range(num_epochs): for batch in train_iterator: elmo_embeddings = elmo(batch.text) glove_embeddings = glove(batch.text) embeddings = torch.cat((elmo_embeddings, glove_embeddings), dim=1) labels = batch.label optimizer.zero_grad() outputs = classifier(embeddings) loss = criterion(outputs, labels) loss.backward() optimizer.step() print('Epoch [{}/{}], Loss: {:.4f}'.format(epoch+1, num_epochs, loss.item())) # evaluate models correct = 0 total = 0 with torch.no_grad(): for batch in test_iterator: elmo_embeddings = elmo(batch.text) glove_embeddings = glove(batch.text) embeddings = torch.cat((elmo_embeddings, glove_embeddings), dim=1) labels = batch.label outputs = classifier(embeddings) predicted = torch.round(torch.sigmoid(outputs)) total += labels.size(0) correct += (predicted == labels).sum().item() print('Accuracy: {:.2f}%'.format(100 * correct / total)) ``` 在这个代码中,我们首先定义了三个模型:ELMo、Glove 和分类器。ELMo 和 Glove 模型分别用于提取 ELMo 词向量和 Glove 词向量,并将两者拼接起来作为分类器的输入。分类器是一个简单的全连接神经网络,用于将拼接后的向量映射到一个二元分类输出(正面或负面情感)。我们使用的数据集是 IMDB 电影评论数据集,其中每个样本都是一个电影评论文本和其对应的情感标签。 在训练过程中,我们首先将每个样本的文本输入到 ELMo 和 Glove 模型中,得到两个向量。然后将这两个向量拼接起来,作为分类器的输入。分类器输出的结果与真实标签计算二元交叉熵损失,并进行反向传播更新模型参数。最终,我们使用测试集评估模型的准确率。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值