论文链接:https://arxiv.org/abs/1802.05365
代码链接:https://github.com/allenai/allennlp
论文来源:NAACL 2018
参考来源:https://mp.weixin.qq.com/s/myYKfOvN9LvMmSRUJudmpA
参考来源:https://zhuanlan.zhihu.com/p/37915351
今天学习的是 AllenNLP 和华盛顿大学 2018 年的论文《Deep contextualized word representations》,是 NAACL 2018 best paper。
这篇论文提出的 ELMo 模型是 2013 年以来 Embedding 领域非常精彩的转折点,并在 2018 年及以后的很长一段时间里掀起了迁移学习在 NLP 领域的风潮。
ELMo 是一种基于语境的深度词表示模型(Word Representation Model),它可以捕获单词的复杂特征(词性句法),也可以解决同一个单词在不同语境下的不同表示(语义)。
以 Word2Vec 和 GloVe 为代表的词表示模型通过训练为每个单词训练出固定大小的词向量,这在以往的 NLP 任务中都取得了不错的效果,但是他们都存在两个问题:
为了解决这个问题,作者提出了一个新的深层语境词表示模型——ELMo。
之前的做法的缺点是对于每一个单词都有唯一的一个embedding表示, 而对于多义词显然这种做法不符合直觉, 而单词的意思又和上下文相关, ELMo的做法是我们只预训练language model, 而word embedding是通过输入的句子实时输出的, 这样单词的意思就是上下文相关的了, 这样就很大程度上缓解了歧义的发生.
区别于传统模型生成的固定单词映射表的形式(为每个单词生成一个固定的词向量),ELMo使用了预训练的语言模型(Language Model),模型会扫描句子结构,并更新内部状态,从而为句子中的每个单词都生成一个基于当前的句子的词向量(Embedding)。这也是就是 ELMo 取名的由来:Embeddings from Language Models。
此外,ELMo 采用字符级的多层 BI-LM 模型作为语言模型,高层的网络能够捕获基于语境的词特征(例如主题情感),而底层的 LSTM 可以学到语法层次的信息(例如词性句法),前者可以处理一词多义,后者可以被用作词性标注,作者通过线性组合多层 LSTM 的内部状态来丰富单词的表示。
ELMo 是一种称为 Bi-LM 的特殊类型的语言模型,它是两个方向上的 LM 的组合,如下图所示:
ELMo 利用正向和反向扫描句子计算单词的词向量,并通过级联的方式产生一个中间向量(下面会给出具体的级联方式)。通过这种方式得到的词向量它可以了解到当前句子的结构和该单词的使用方式。
值得注意是,ELMo 使用的 Bi-LM 与 Bi-LSTM 不同,虽然长得相似,但是 Bi-LM 是两个 LM 模型的串联,一个向前,一个向后;而 Bi-LSTM 不仅仅是两个 LSTM 串联,Bi-LSTM 模型中来自两个方向的内部状态在被送到下层时进行级联(注意下图的 out 部分,在 out 中进行级联),而在 Bi-LM 中,两个方向的内部状态仅从两个独立训练的 LM 中进行级联。
设一个序列有 N 个 token ( t 1 , t 2 , ⋯ , t N ) (t_1,t_2,\cdots,t_N) (t1,t2,⋯,tN)(这里说 token 是为了兼容字符和单词,EMLo 使用的是字符级别的 Embedding)
对于一个前向语言模型来说,是基于先前的序列来预测当前 token: p ( t 1 , t 2 , ⋯ , t N ) = ∏ k = 1 N p ( t k ∣ t 1 , t 2 , ⋯ , t k − 1 ) p(t_1,t_2,\cdots,t_N)=\prod_{k=1}^{N}p(t_k|t_1,t_2,\cdots,t_{k-1}) p(t1,t2,⋯,tN)=k=1∏Np(tk∣t1,t2,⋯,tk−1)而对于一个后向语言模型来说,是基于后面的序列来预测当前 token: p ( t 1 , t 2 , ⋯ , t N ) = ∏ k = 1 N p ( t k ∣ t k + 1 , t k + 2 , ⋯ , t N ) p(t_1,t_2,\cdots,t_N)=\prod_{k=1}^{N}p(t_k|t_{k+1},t_{k+2},\cdots,t_N) p(t1,t2,⋯,tN)=k=1∏Np(tk∣tk+1,tk+2,⋯,tN)可以用 h k , j → \overrightarrow{h_{k,j}} hk,j和 h k , j ← \overleftarrow{h_{k,j}} hk,j分别表示前向和后向语言模型。
ELMo 用的是多层双向的 LSTM,所以我们联合前向模型和后向模型给出对数似然估计: ∑ k = 1 N ( log p ( t k ∣ t 1 , ⋯ , t k − 1 ; Θ x , Θ L S T M → , Θ s ) + log p ( t k ∣ t k + 1 , ⋯ , t N ; Θ x , Θ L S T M ← , Θ s ) ) \sum_{k=1}^{N}(\log p(t_k|t_1,\cdots,t_{k-1};\Theta_x,\overrightarrow{\Theta_{LSTM}},\Theta_s)+\log p(t_k|t_{k+1},\cdots,t_N;\Theta_x,\overleftarrow{\Theta_{LSTM}},\Theta_s)) k=1∑N(logp(tk∣t1,⋯,tk−1;Θx,ΘLSTM,Θs)+logp(tk∣tk+1,⋯,tN;Θx,ΘLSTM,Θs))其中, Θ x \Theta_x Θx 表示 token 的向量, Θ s \Theta_s Θs 表示 Softmax 层对的参数, Θ L S T M → \overrightarrow{\Theta_{LSTM}} ΘLSTM 和 Θ L S T M ← \overleftarrow{\Theta_{LSTM}} ΘLSTM 表示前向和后向的 LSTM 的参数。
我们刚说 ELMo 通过级联的方式给出中间向量(这边要注意两个地方:一个是级联,一个是中间向量),现在给出符号定义:
对每一个 token t k t_k tk 来说,一个 L 层的 ELMo 的 2L + 1 个表征: R k = { x k L M , h k , j → , h k , j ← ∣ j = 1 , ⋯ , L } = { h k , j ∣ j = 0 , ⋯ , L } R_k=\{x_k^{LM},\overrightarrow{h_{k,j}},\overleftarrow{h_{k,j}}|j=1,\cdots,L\}=\{h_{k,j}|j=0,\cdots,L\} Rk={xkLM,hk,j,hk,j∣j=1,⋯,L}={hk,j∣j=0,⋯,L}其中, h k , 0 h_{k,0} hk,0 表示输入层, h k , j = [ h k , j → , h k , j ← ] h_{k,j}=[\overrightarrow{h_{k,j}},\overleftarrow{h_{k,j}}] hk,j=[hk,j,hk,j] 。(之所以是 2L + 1 是因为把输入层加了进来)
对于下游任务来说,ELMo 会将所有的表征加权合并为一个中间向量: E L M o k = E ( R k ; Θ ) = γ ∑ j = 0 L s j h k , j L M ELMo_k=E(R_k;\Theta)=\gamma\sum_{j=0}^Ls_jh_{k,j}^{LM} ELMok=E(Rk;Θ)=γj=0∑Lsjhk,jLM其中, s s s 是 Softmax 的结果,用作权重; γ \gamma γ 是常量参数,允许模型缩放整个 ELMo 向量,考虑到各个 Bi-LSTM 层分布不同,某些情况下对网络的 Layer Normalization 会有帮助。
我们来看下 ELMo 在有监督学习中应用,这里假设 ELMo 模型已经完成预训练。
对于给定的一个监督学习 NLP 任务,我们只需为每个句子中的单词记录下 ELMo 各层的词表征,然后用端到端的任务来学习这些表示的线性组合(学习向量的权重)。
对于大多数 NLP 任务而言,靠近输入端的结构所含信息基本一致(例如词法句法,想象下 CV 浅层网络的可视化都是一些线条),所以这就允许我们直接把训练好的 ELMo 加到现有的 NLP 的监督任务模型中(因为底层结构相似,所以直接用 ELMo 提取上下文的浅层信息也可以)。通常来说,会使用预训练的 Word Embedding(或者是字符级别的 Embedding)来为每个位置生成一个上下文无关的词表示 ,然后拼接 EMLo 后会生成一个上下文相关的词表示(即,通过 ELMo 提取单词及周围的上下文信息,然后拼接原本的单词向量)。
为了将 ELMo 加到监督学习的模型中,我们有两种拼接方式:
预训练的架构采用类似下图 c 的架构,下图来自《Exploring the Limits of Language Modeling》。
简单解释下这张图,a 是普通的基于 LSTM 的语言模型,b 是用字符级别的 CNN 来代替原本的输入和 Softmax 层,c 是用 LSTM 层代替 CNN Softmax 层并预测下一个单词。(这里的 CNN Softmax 层区别于 Word2Vec 中的 Softmax,并不是直接预测词汇表,而是计算 z w = h T e w z_w=h^Te_w zw=hTew 的 Logistic 值,其中 h 为单词上下文向量, e w = C N N ( c h a r s w ) e_w=CNN(char\space s_w) ew=CNN(char sw))
作者在论文中指出:ELMo 使用 CNN-BIG-LSTM 的架构进行预训练(这里的 BIG 只是想说多很多 LSTM),并且为了平衡 LM 的复杂度、模型大小和下游任务的计算需求,同时保持纯粹基于字符的输入表示,ELMo 只使用了两层的 LSTM 层,每层 4096 个隐藏单元和 512 维的词向量大小,以及跨一层的残差连接。
而在提取静态字符时,使用两层具有 2048 个卷积过滤器的 highway layer 和一个含有 512 个隐藏单元的 linear projection layer。
这里的 highway layer 由《Training Very Deep Networks》论文中给出,可以简单理解为该网络层只处理一部分输入,而另外一部分直接通过。所以相比于传统的网络,HighwayNet 可以有更深的网络,而试验结果也表明其更容易训练。
相比其他模型只提供一层 Representation 而言,作者提供了三层 Representations:单词原始的 Embedding,第一层双向 LSTM 中对应单词位置的 Embedding (包含句法信息)和第二层双向 LSTM 中对应单词位置的 Embedding(包含语义信息)。
下面这张图看的可能更清楚一点。
在训练了 10 个 epochs 后,前向和后向的平均困惑度(perplexities)分别是 39.7,而 CNN-BIG-LSTM 的困惑度为 30.0。总体看前向和后向困惑度相当,后向稍微低一些。
困惑度(perplexities):如果每个时间步都根据语言模型计算的概率分布随机挑词,那么平均情况下,挑多少个词才能挑到正确的那个。显然,困惑度越小越好。
完成预训练后可以得到训练好的 Bi-LM 模型和单词的 Embedding 向量。对于下游任务来说可以对 Bi-LM 进行微调,也可以直接使用。
简单看下实验,下图显示了不同任务下,ELMo 相对 baseline 的提升程度:
正则化系数的影响:
模型训练效率:
与训练的ELMo已经放出, pytorch用户可以通过AlenNLP使用, 预训的Tensorflow版本也在 TF版 中放出, 这里介绍一下通过AllenNLP包用ELMo的方法.
通过pip install allennlp 或其他方法安装AllenNLP包之后, 一般我们直接调用allennlp的allennlp.commands.elmo.ElmoEmbedder 中的batch_to_embeddings对一个batch的token序列进行编码, 下面是样例做法, 第一次运行加载模型时运行时间会很长, 需要等待. 这里展示的用法不会对language model的参数进行更新, 如果需要请自己设置, 因为没有给通用接口, 所以需要修改allennlp的源码, 但是因为设置梯度回传之后效率大减, 且所需内存暴增, 我也只测试了一下, 并没有在我自己的模型中使用.
from allennlp.commands.elmo import ElmoEmbedder
elmo = ElmoEmbedder(options_file='../data/elmo_options.json', weight_file='../data/elmo_weights.hdf5', cuda_device=0)
context_tokens = [['I', 'love', 'you', '.'], ['Sorry', ',', 'I', 'don', "'t", 'love', 'you', '.']]
elmo_embedding, elmo_mask = elmo.batch_to_embeddings(context_tokens)
print(elmo_embedding)
print(elmo_mask)
1. 导入ElmoEmbedder类
2. 实例化ElmoEmbedder. 3个参数分别为参数配置文件, 预训练的权值文件, 想要用的gpu编号, 这里两个文件我是直接下载好的, 如果指定系统默认自动下载会花费一定的时间, 下载地址
DEFAULT_OPTIONS_FILE = "https://s3-us-west-2.amazonaws.com/allennlp/models/elmo/2x4096_512_2048cnn_2xhighway/elmo_2x4096_512_2048cnn_2xhighway_options.json"
DEFAULT_WEIGHT_FILE = "https://s3-us-west-2.amazonaws.com/allennlp/models/elmo/2x4096_512_2048cnn_2xhighway/elmo_2x4096_512_2048cnn_2xhighway_weights.hdf5"
3. 输入是一个list的token序列, 其中外层list的size即内层list的个数就是我们平时说的batch_size, 内层每个list包含一个你想要处理的序列(这里是一句话, 你可以一篇文章或输入任意的序列, 因为这里预训练的模型是在英文wikipidia上训的, 所以输入非英文的序列肯定得到的结果没什么意义).
4. 通过batch_to_embeddings对输入进行计算的到tokens的embedding结果以及我们输入的batch的mask信息(自动求mask)
Variable containing:
( 0 , 0 ,.,.) =
0.6923 -0.3261 0.2283 ... 0.1757 0.2660 -0.1013
-0.7348 -0.0965 -0.1411 ... -0.3411 0.3681 0.5445
0.3645 -0.1415 -0.0662 ... 0.1163 0.1783 -0.7290
... ⋱ ...
0.0000 0.0000 0.0000 ... 0.0000 0.0000 0.0000
0.0000 0.0000 0.0000 ... 0.0000 0.0000 0.0000
0.0000 0.0000 0.0000 ... 0.0000 0.0000 0.0000
⋮
( 1 , 2 ,.,.) =
-0.0830 -1.5891 -0.2576 ... -1.2944 0.1082 0.6745
-0.0724 -0.7200 0.1463 ... 0.6919 0.9144 -0.1260
-2.3460 -1.1714 -0.7065 ... -1.2885 0.4679 0.3800
... ⋱ ...
0.1246 -0.6929 0.6330 ... 0.6294 1.6869 -0.6655
-0.5757 -1.0845 0.5794 ... 0.0825 0.5020 0.2765
-1.2392 -0.6155 -0.9032 ... 0.0524 -0.0852 0.0805
[torch.cuda.FloatTensor of size 2x3x8x1024 (GPU 0)]
Variable containing:
1 1 1 1 0 0 0 0
1 1 1 1 1 1 1 1
[torch.cuda.LongTensor of size 2x8 (GPU 0)]
输出两个Variable, 第一个是2*3*8*1024的embedding信息, 第二个是mask, 其中2是batch_size, 3是两层biLM的输出加一层CNN对character编码的输出, 8是最长list的长度(对齐), 1024是每层输出的维度; mask的输出2是batch_size, 8实在最长list的长度, 第一个list有4个tokens, 第二个list有8个tokens, 所以对应位置输出1.
AllenNLP还提供的第二种功能更丰富的接口,这种方法自动将多层LM的输出mix,即自动计算了权值 s j t a s k s_j^{task} sjtask和 γ \gamma γ,且可以通过参数决定要不要梯度回传要不要做normalization,下面是例子
from allennlp.modules.elmo import batch_to_ids
from allennlp.modules.elmo import Elmo
options_file = "../data/elmo_options.json"
weight_file = "../data/elmo_weights.hdf5"
ids = batch_to_ids([['I', 'love', 'you', '.'], ['Sorry', ',', 'I', 'don', "'t", 'love', 'you', '.']])
# print(ids)
elmo = Elmo(options_file, weight_file, num_output_representations=1, dropout=0, requires_grad=False, do_layer_norm=False)
# num_output_representations设置为1, 因为这种做法已经把多层的embedding自动mix了
# 如果设置为其他大于1的值k,则会输出相同的k个embedding(没什么意义)
# 如果想要设置LM的层数,需要在elmo_options.json中设置
out = elmo(ids)
print(out)
{'elmo_representations':
[tensor([[[-1.2254, -0.5600, -0.1760, ..., -0.5546, 0.4775, -0.1869],
[-0.3379, -0.4475, -0.2574, ..., 0.2418, 0.8130, -0.2851],
[-0.4254, -0.5710, -0.1849, ..., 0.1513, 0.2559, -0.0584],
...,
[ 0.0000, 0.0000, 0.0000, ..., 0.0000, 0.0000, 0.0000],
[ 0.0000, 0.0000, 0.0000, ..., 0.0000, 0.0000, 0.0000],
[ 0.0000, 0.0000, 0.0000, ..., 0.0000, 0.0000, 0.0000]],
[[ 0.1811, -0.9851, -0.0880, ..., -0.6426, -0.4531, -0.1817],
[ 0.1269, -0.3436, 0.1588, ..., 0.3357, 0.2904, -0.0641],
[-0.8224, -0.7822, -0.2682, ..., -0.5600, 0.3326, 0.1598],
...,
[-0.0547, -0.4123, 0.1112, ..., 0.2418, 0.8130, -0.2851],
[-0.1133, -0.4033, 0.1816, ..., 0.1513, 0.2559, -0.0584],
[-1.1212, -0.3844, -0.6179, ..., -0.0803, 0.0361, 0.1128]]])],
'mask': tensor([[ 1, 1, 1, 1, 0, 0, 0, 0],
[ 1, 1, 1, 1, 1, 1, 1, 1]])}
输出是一个dict,这里用的0.4版本的pytorch,tensor和variable已经合并了,所以看起来和第一种输出有些不同
ELMo 采用预训练的方式得到原始 Embedding 向量和双层 Bi-LSTM 模型,同时 ELMo 会为每个单词提供三个 Embedding 向量并学习具体任务下线性组合后的中间向量,与原始向量拼接后即可为下游任务提供基于语境的 Word Embedding。
第一次看 ELMo 时的想法是:为什么要用 LSTM 而不用类似 Transformer 的结构?毕竟 Transformer 在发表于 2017 年,早于 ELMo;
其次,ELMo 采用的并不是真正的双向 LSTM,而是两个独立的 LSTM 分别训练,并且只是在 Loss Function 中通过简单相加进行约束,只能一定程度上学习到单词两边句子的特征。
当然,虽然这会在 BERT 一文中给出解答。