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 B∗W∗C,这里的 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。
从图里面看,输入的句子会经过:
- Char Encode Layer: 即首先经过一个字符编码层,因为ELMo实际上是基于char的,所以它会先对每个单词中的所有char进行编码,从而得到这个单词的表示。因此经过字符编码层出来之后的维度为 B ∗ W ∗ D B * W * D B∗W∗D,这就是我们熟知的对于一个句子在单词级别上的编码维度。
- biLMs:随后该句子表示会经过biLMs,即双向语言模型的建模,内部其实是分开训练了两个正向和反向的语言模型,而后将其表征进行拼接,最终得到的输出维度为 ( L + 1 ) ∗ B ∗ W ∗ 2 D (L+1) * B * W * 2D (L+1)∗B∗W∗2D,+1实际上是加上了最初的embedding层,有点儿像residual,后面在“biLMs”部分会详细提到。
- Scalar Mixer:紧接着,得到了biLMs各个层的表征之后,会经过一个混合层,它会将前面这些层的表示进行线性融合(后面在“生成ELMo词向量”部分会进行详细说明),得出最终的ELMo向量,维度为 B ∗ W ∗ 2 D B * W * 2D B∗W∗2D。
这里只是对ELMo模型从全局上进行的一个统观,对每个模块里面的结构还是很懵逼?没关系,下面我们逐一来进行剖析:
2. 字符编码层
这一层即“Char Encode Layer”,它的输入维度是 B ∗ W ∗ C B * W * C B∗W∗C,输出维度是 B ∗ W ∗ D B * W * D B∗W∗D,经查看源码,它的结构图长这样:
画的有点儿乱,大家将就着看~
首先,输入的句子会被reshape成 B W ∗ C BW * C BW∗C,因其是针对所有的char进行处理。然后会分别经过如下几个层:
- 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 BW∗C∗d。 - Multi-Scale卷积层:这里用的是不同scale的卷积层,注意是在宽度上扩展,而不是深度上,即输入都是一样的,卷积之间的不同在于其
kernel_size
和channel_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 BW∗d1,BW∗d2,...,BW∗dm。 - Concat层:上一步得出的是m个不同维度的矩阵,为了方便后期处理,这里将其在最后一维上进行拼接,而后将其reshape回单词级别的维度 B ∗ W ∗ ( d 1 + d 2 + . . . + d m ) B * W * (d1+d2+...+dm) B∗W∗(d1+d2+...+dm)。
- 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) B∗W∗(d1+d2+...+dm)。
y = g ∗ x + ( 1 − g ) ∗ f ( A ( x ) ) , g = S i g m o i d ( B ( x ) )