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

假设输入的句子维度为 B ∗ W ∗ C B * W * C BWC,这里的 B B B 表示batch_size W W W 表示num_words,即一句话中的单词数目,在一个batch中可能需要padding, C C C 表示max_characters_per_token,即每个单词的字符数目,这里论文里面用了固定值50,不根据每个batch的不同而动态设置, D D D 表示projection_dim,即单词输入biLMs的embedding_size,或者理解为最终生成的ELMo词向量维度的 1 / 2 1 / 2 1/2

从图里面看,输入的句子会经过:

  1. Char Encode Layer: 即首先经过一个字符编码层,因为ELMo实际上是基于char的,所以它会先对每个单词中的所有char进行编码,从而得到这个单词的表示。因此经过字符编码层出来之后的维度为 B ∗ W ∗ D B * W * D BWD,这就是我们熟知的对于一个句子在单词级别上的编码维度。
  2. biLMs:随后该句子表示会经过biLMs,即双向语言模型的建模,内部其实是分开训练了两个正向和反向的语言模型,而后将其表征进行拼接,最终得到的输出维度为 ( L + 1 ) ∗ B ∗ W ∗ 2 D (L+1) * B * W * 2D (L+1)BW2D,+1实际上是加上了最初的embedding层,有点儿像residual,后面在“biLMs”部分会详细提到。
  3. Scalar Mixer:紧接着,得到了biLMs各个层的表征之后,会经过一个混合层,它会将前面这些层的表示进行线性融合(后面在“生成ELMo词向量”部分会进行详细说明),得出最终的ELMo向量,维度为 B ∗ W ∗ 2 D B * W * 2D BW2D

这里只是对ELMo模型从全局上进行的一个统观,对每个模块里面的结构还是很懵逼?没关系,下面我们逐一来进行剖析:

2. 字符编码层

这一层即“Char Encode Layer”,它的输入维度是 B ∗ W ∗ C B * W * C BWC,输出维度是 B ∗ W ∗ D B * W * D BWD,经查看源码,它的结构图长这样:
Char Encode Layer结构

画的有点儿乱,大家将就着看~

首先,输入的句子会被reshape成 B W ∗ C BW * C BWC,因其是针对所有的char进行处理。然后会分别经过如下几个层:

  1. Char Embedding:这就是正常的embedding层,针对每个char进行编码,实际上所有char的词表大概是262,其中0-255是char的unicode编码,256-261这6个分别是<bow>(单词的开始)、<eow>(单词的结束)、 <bos>(句子的开始)、<eos>(句子的结束)、<pow>(单词补齐符)和<pos>(句子补齐符)。可见词表还是比较小的,而且没有OOV的情况出现。这里的Embedding参数维度为 262 ( n u m _ c h a r a c t e r s ) ∗ d ( c h a r _ e m b e d _ d i m ) 262(num\_characters) * d(char\_embed\_dim) 262(num_characters)d(char_embed_dim)。注意这里的 d d d 与上一节提到的 D D D 是两个概念, d d d 表示的是字符的embedding维度,而 D D D 表示的是单词的embedding维度,后面会看到它们之间的映射关系。这部分的输出维度为 B W ∗ C ∗ d BW * C * d BWCd
  2. Multi-Scale卷积层:这里用的是不同scale的卷积层,注意是在宽度上扩展,而不是深度上,即输入都是一样的,卷积之间的不同在于其kernel_sizechannel_size的大小不同,用于捕捉不同n-grams之间的信息,这点其实是仿照 TextCNN 的模型结构。假设有 m m m个这样的卷积层,其kernel_size k 1 , k 2 , . . . , k m k1, k2, ..., km k1,k2,...,km,比如1,2,3,4,5,6,7这种,其channel_size d 1 , d 2 , . . . , d m d1, d2, ..., dm d1,d2,...,dm,比如32,64,128,256,512,1024这种。注意:这里的卷积都是1维卷积,即只在序列长度上做卷积。与图像中的处理类似,在卷积之后,会经过MaxPooling进行池化,这里的目的主要在于经过前面卷积出的序列长度往往不一致,后期没办法进行合并,所以这里在序列维度上进行MaxPooling,其实就是取一个单词中最大的那个char的表示作为整个单词的表示。最后再经过激活层,这一步就算结束了。根据不同的channel_size的大小,这一步的输出维度分别为 B W ∗ d 1 , B W ∗ d 2 , . . . , B W ∗ d m BW * d1, BW * d2, ..., BW * dm BWd1,BWd2,...,BWdm
  3. Concat层:上一步得出的是m个不同维度的矩阵,为了方便后期处理,这里将其在最后一维上进行拼接,而后将其reshape回单词级别的维度 B ∗ W ∗ ( d 1 + d 2 + . . . + d m ) B * W * (d1+d2+...+dm) BW(d1+d2+...+dm)
  4. Highway层:Highway(参见:https://arxiv.org/abs/1505.00387 )是仿照图像中residual的做法,在NLP领域中常有应用,看代码里面的实现,这一层实现的公式见下面:其实就是一种全连接+残差的实现方式,只不过这里还需要一个element-wise的gate矩阵对 x x x f ( A ( x ) ) f(A(x)) f(A(x))进行变换。这里需要经过 H H H 层这样的Highway层,输出维度仍为 B ∗ W ∗ ( d 1 + d 2 + . . . + d m ) B * W * (d1+d2+...+dm) BW(d1+d2+...+dm)

y = g ∗ x + ( 1 − g ) ∗ f ( A ( x ) ) , g = S i g m o i d ( B ( x ) )

以下是使用 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 模型中,得到两个向量。然后将这两个向量拼接起来,作为分类器的输入。分类器输出的结果与真实标签计算二元交叉熵损失,并进行反向传播更新模型参数。最终,我们使用测试集评估模型的准确率。
评论 19
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值