BERT 图解学习笔记:NLP 是怎么破解迁移学习的
本文内容参考 Jay Alammar 的 The Illustrated BERT, ELMo, and co.结合原文展开写作。写在前面2018 年对于 NLP 来说是一个分水岭。这一年我们对如何表示词语和句子的理解发生了根本性的变化。这一年计算机视觉社区多年前经历的那种转折——模型可以在大数据上预训练、然后迁移到下游任务——终于在 NLP 里真正发生了。这个时刻有一个专属名字NLP 的 ImageNet 时刻。推动这个时刻的最重要力量之一就是 BERT。Jay Alammar 的《图解 BERT》是我读过的把 BERT 讲得最清晰的一篇文章它从词嵌入的历史讲起途经 ELMo、ULMFiT、OpenAI GPT最终落到 BERT 本身把整条脉络串联得非常完整。这篇笔记就沿着这条脉络走把每一站都讲透围绕原文的关键图示展开说明。一、一个最基础的问题词语怎么变成数字在深入 BERT 之前我们需要先回到起点——词嵌入word embedding。机器学习模型只能处理数字不能直接处理文字。所以 NLP 的第一个核心问题永远是怎么把词语编码成数字Word2Vec 和 GloVe 给出了一个的答案把每个词用一个高维向量表示使得语义上相近的词在向量空间里也相近。国王和王后的向量很接近北京和中国的关系和东京和日本的关系在向量空间里也是类似的几何关系。这个思路非常强大而且它有一个重要的工程价值你可以在大规模语料上预训练好词向量然后把这个词向量直接拿过来用在其他模型里不需要再从头学起。这是 NLP 领域最早的一批预训练 迁移实践。文章里展示了 stick 这个词的 GloVe 向量视觉上就是一排颜色深浅不一的小方块。这张图很能说明问题这些数字承载了这个词的所有语义信息可以被任何下游模型直接使用。二、Word2Vec/GloVe 的根本局限词没有上下文但这个方案有一个严重的问题同一个词无论出现在什么句子里它的向量永远是同一个。stick 这个词有很多含义树枝、棍子、粘贴、坚持……在He picked up a stick里stick 是树枝在Stick to the plan里stick 是坚持在The glue wont stick里stick 是粘合。静态词向量给所有这些用法分配同一个向量这显然是不够精确的。这个问题在 2017-2018 年被一批研究者认真对待其中最著名的解决方案就是 ELMo。三、ELMo让词向量随上下文而变化ELMoEmbeddings from Language Models的核心思想是一个词的表示应该由它的上下文决定而不是一个固定的查表结果。ELMo 的工作方式是给定一个句子看完整个句子然后为每个词生成一个向量。这个向量不只取决于这个词本身而是取决于这个词在这个具体句子里的位置和上下文。ELMo 是怎么学到这种上下文感知能力的通过一个叫做语言建模Language Modeling的任务——给定一段文字预测下一个词。文章里有一张非常直观的图输入Lets stick to模型的任务是预测下一个最可能出现的词是什么。经过在大规模语料上的训练模型逐渐学会了语言的内部规律。更关键的是ELMo 不只是单向地从左看到右它训练的是一个双向 LSTM——既有从左到右的正向语言模型又有从右到左的反向语言模型。原文里有一张展示双向 ELMo 的图可以看到两个方向的 LSTM 分别运行每个 token 都能从两个方向的隐藏状态里获取信息。最终每个词的上下文嵌入是把各层的隐藏状态和初始嵌入按权重拼接加和得到的。ELMo 带来的改进是显著的。它在当时多个主要的 NLP benchmark 上刷新了记录。更重要的是它证明了用无标注数据预训练、然后把学到的表示迁移给下游任务这条路在 NLP 里是可行的。但 ELMo 有自己的局限它用的是 LSTM而不是 Transformer。这在后来被证明是一个关键的瓶颈。四、ULMFiT把微调这件事做对了与 ELMo 几乎同期Jeremy Howard 和 Sebastian Ruder 提出了 ULMFiTUniversal Language Model Fine-Tuning。ULMFiT 的贡献不在于架构创新而在于方法论它系统研究了怎么把预训练语言模型迁移到下游任务。它引入了差分学习率不同层用不同学习率微调、逐层解冻从顶层开始逐层解冻参与微调、斜三角学习率调度等技巧让预训练模型在微调时不会灾难性遗忘原来学到的知识。简单说ELMo 主要是改变了词向量的表示方式从静态到动态而 ULMFiT 是把先预训练再微调这个范式真正固定下来证明它在 NLP 里能像计算机视觉里的 ImageNet fine-tuning 一样有效。五、OpenAI GPT换掉 LSTM用 Transformer Decoder2018 年另一个重要里程碑是 OpenAI 发布的 GPTGenerative Pre-trained Transformer。GPT 的关键选择是用 Transformer 替换 LSTM 来做语言建模的预训练。为什么 Transformer 比 LSTM 更适合这件事Transformer 的自注意力机制能直接建模序列里任意两个位置之间的依赖关系而 LSTM 需要把信息沿着序列一步步传递对长距离依赖的处理天然地弱于 Transformer。具体地GPT 使用的是Transformer 的解码器Decoder部分堆叠了 12 层。图清晰展示了这个结构一摞解码器层没有编码器也没有编码器-解码器交叉注意力——只有带因果 mask 的自注意力层和前馈网络。这个 mask 确保每个 token 只能 attend 到它左边的 token这是语言建模预测下一个词的天然要求。GPT 在 7000 本书BookCorpus上预训练学习预测下一个词。完成预训练后它通过特定的输入转换格式迁移到各种下游任务句子分类、文本蕴含、相似度匹配、多项选择等。原文展示了 OpenAI 论文里那张经典的输入变换图——针对不同任务把文本序列拼成不同格式塞给同一个预训练模型只需要训练最后一层分类头。GPT 的效果很好在多个 benchmark 上超越了之前的 SOTA。但它有一个根本性的问题它是单向的。解码器的因果 mask 决定了每个 token 只能看到左边的上下文看不到右边。对于语言生成任务这是合理的但对于语言理解任务——理解一个词的含义需要同时考虑它左边和右边的语境——这是一个严重的限制。六、从解码器到编码器BERT 的创新到这里一个自然的问题浮现了能不能用 Transformer 的编码器Encoder而不是解码器来做语言模型的预训练从而获得真正的双向上下文感知直觉上这个想法很诱人——编码器的自注意力是无方向性的每个 token 可以直接 attend 到序列里的所有其他 token左边右边都没有限制。但有一个技术上的根本障碍如果双向 Transformer 编码器用预测下一个词作为训练目标每个词在预测时可以直接看到自己——在多层注意力的堆叠里当前 token 的最终表示会直接或间接地融入来自自身位置的信息这让预测自己变成了一个平凡任务模型什么都学不到。Jay Alammar 的文章里用了一段有趣的对话来描述这个时刻我们用 Transformer 编码器BERT 说。这是疯话Ernie 回答每个人都知道双向条件化会让每个词在多层上下文里间接看到自己。我们用掩码masks。BERT 镇定地说。这就是 BERT 的核心创新Masked Language ModelMLM。七、Masked LMBERT 的解法MLM 的做法极其简单随机遮住输入序列里15% 的 token用一个特殊的[MASK]标记替换然后让模型预测被遮住的是什么词。原文有一张图直观展示了这个过程输入是一段带有[MASK]标记的句子模型的任务是在对应位置输出被遮住词的预测。这个设计的精妙在于被遮住的词无法直接看到自己因为它已经被换成了[MASK]所以模型必须依赖两侧的上下文来做预测从而真正学到双向的语义理解。但文章里也提到了 BERT 论文对这个方案的一个精细处理——[MASK]带来的预训练和微调之间的分布不匹配问题。在预训练时输入里有[MASK]标记但在微调下游任务时真实的输入里完全没有[MASK]。这个不一致会让模型在微调时有些迷惑。解决方案是对于被选中的 15% 位置并不总是替换成[MASK]而是用三种方式处理80% 的时候替换成[MASK]10% 的时候替换成词表里随机的另一个词10% 的时候保持原词不变这样做的效果是模型不能依赖这个位置有[MASK]标记所以我需要预测这样的捷径而是必须对每个 token 都保持这个位置的词可能是对的也可能是错的我要结合上下文判断的警觉性这让预训练出来的表示更加鲁棒。八、下一句预测NSP让模型理解句子关系MLM 让 BERT 学会了词级别的双向上下文理解但很多重要的下游任务——问答QA、自然语言推断NLI——需要理解的是两个句子之间的关系而不只是单个句子里的词。为此BERT 引入了第二个预训练任务Next Sentence PredictionNSP。原文有一张图展示了 NSP 的输入格式两个句子 A 和 B 被拼接在一起用[SEP]分隔最前面是一个[CLS]token。模型的任务是判断 B 是不是 A 在原文里真实的下一句。训练时50% 的情况下 B 是 A 的真实下一句标记为 IsNext50% 的情况下 B 是从语料库里随机取的一句不相关的话标记为 NotNext。这种数据可以从任何大规模语料里自动生成完全不需要人工标注。NSP 最终的训练精度能达到 97%-98%说明这个任务对模型来说确实不难但它给模型提供了一个直接学习句间关系的机会对下游的问答和推断任务有实质性帮助。九、BERT 的模型架构BERT 的架构本质上就是一摞 Transformer 编码器层。论文提出了两个规模版本原文配了一张对比图BERT-Base12 层 Transformer 编码器块每层隐层维度 76812 个注意力头总参数量 110M。这个规模刻意和 OpenAI GPT 对齐方便做公平比较。BERT-Large24 层隐层维度 102416 个注意力头总参数量 340M。这是在论文里刷新各项 SOTA 记录的版本。相比 Transformer 原始论文里的参考实现6 层、512 维、8 头BERT 在所有维度上都更大。从结构上看每一层都是标准的 Transformer 编码器块多头自注意力 → Add Norm → 前馈网络 → Add Norm。关键是自注意力没有方向限制每个 token 可以直接 attend 到序列里的所有其他 token。这和 GPT 的因果 mask 是根本区别。每个 token 作为输入经过 12 或 24 层的编码器处理之后在对应位置输出一个 768 维或 1024 维的向量。整个模型同时处理整个序列不像 RNN 那样按顺序处理。十、输入的三种 EmbeddingToken Segment PositionBERT 的输入不是简单的词向量而是三种 embedding 的逐元素相加Token Embedding词嵌入使用 WordPiece 分词词表 30,000 个子词单元。WordPiece 会把playing拆成play和##ing确保所有可能的词都能被覆盖解决 OOVout-of-vocabulary问题。Segment Embedding片段嵌入区分句子 A 和句子 B 的可学习嵌入。对于单句子输入所有 token 都使用同一个 Segment A embedding对于句子对输入A 的 token 用 Segment AB 的 token 用 Segment B。这是 NSP 任务在输入层面的体现。Position Embedding位置嵌入BERT 使用可学习的绝对位置嵌入最大支持 512 个位置。这和原始 Transformer 用正弦函数计算位置编码不同——BERT 让模型自己学习每个位置的表示。这三路 embedding 在同一维度空间里直接相加而不是拼接最终得到每个 token 的输入向量。十一、[CLS] token一个承担分类重任的特殊标记原文特别强调了[CLS]token 的设计。BERT 规定每个输入序列的第一个位置必须是[CLS]Classification 的缩写。这个设计的目的是让序列级别的信息有一个自然的汇聚点。经过多层 Transformer 的处理之后[CLS]位置的输出向量会通过自注意力机制汇集了整个序列的信息可以被用来做序列级别的分类任务。原文里有一张图非常清晰12 或 24 层编码器处理完整个序列[CLS]位置输出一个 768/1024 维的向量 C。这个向量 C 直接被送进一个简单的线性分类层加 softmax就能做情感分析、垃圾邮件检测等分类任务。原文把这个类比成 VGGNet 的结构卷积层负责提取特征最后的全连接层负责分类。BERT 的编码器层就像卷积层[CLS]的输出向量就是被传递给分类头的特征表示。需要注意的是原论文里有一句容易被忽视的脚注[CLS]的输出向量如果没有针对具体任务做微调本身不是一个有意义的句子表示。预训练阶段它是在 NSP 任务上训练的含义依赖于微调阶段的下游任务。直接把预训练的[CLS]向量拿来算句子相似度效果往往很差——这是实践中的一个常见坑。十二、BERT 的微调不同任务同一套骨架BERT 最强大的工程特性之一是它对不同类型下游任务的统一处理方式不需要改架构只需要在输出层加一个轻量的任务特定头。原文展示了 BERT 论文里那张经典的四种任务微调图。单句分类如情感分析、垃圾邮件检测输入格式是[CLS] 句子 [SEP]。取[CLS]的输出向量 C加一个 K 类线性分类层K 是类别数做 softmax计算交叉熵 loss。微调时整个 BERT 和这个分类层都参与梯度更新。句子对分类如文本蕴含 NLI、句子相似度输入格式是[CLS] 句子A [SEP] 句子B [SEP]。同样取[CLS]的输出做分类。BERT 的双向自注意力会在编码时让两个句子的 token 互相 attend相当于内置了跨句子的交互不需要额外的跨注意力模块。抽取式问答如 SQuAD输入是[CLS] 问题 [SEP] 文段 [SEP]。需要预测答案 span 的起始和结束位置。做法是引入两个新的可学习向量 Sstart和 Eend对文段里每个 token i 的输出向量 T_i分别与 S 和 E 做点积并 softmax得到它是答案起始/结束位置的概率。训练 loss 是正确起始和结束位置的对数似然之和。序列标注如命名实体识别 NER输入是[CLS] 句子 [SEP]对每个 token 位置的输出向量 T_i 分别加一个标签数量的线性层做分类。每个 token 独立预测它的 NER 标签B-PER、I-ORG 等。这四种范式覆盖了 NLP 绝大多数任务类型。微调的计算成本非常低——论文里提到SQuAD 的微调在单张 Cloud TPU 上只需要约 30 分钟。十三、特征抽取模式冻住 BERT只用它的表示除了微调之外BERT 还可以用另一种方式使用——把它当作特征提取器不做任何参数更新只是把它的中间层输出作为词的上下文嵌入然后把这些嵌入输入给其他模型。原文里专门提到了这种用法在 CoNLL NER 任务上的实验结果展示了使用不同层输出作为嵌入时的 F1 分数对比这个结果非常有趣。第一它说明 BERT 即使以纯粹的特征提取器的方式使用效果也非常接近完整微调。第二最后四层的拼接比只用最后一层好很多——这提示我们 BERT 不同层编码的是不同层次的信息底层可能更多是句法结构高层更多是语义信息拼接多层能保留更全面的表示。第三但也不是越多越好——用全部 12 层反而不如只用最后 4 层这暗示了底层的表示和最终的 NER 任务相关性较低加进来反而引入了噪声。在实际工程中特征提取模式的优势是可以预计算并缓存 BERT 的输出每个样本只需要过一遍 BERT然后轻量的下游模型可以非常快地训练。对于样本量大、计算资源有限的场景这是一个重要的工程选项。十四、BERT 的预训练数据BERT 的预训练数据是BooksCorpus8 亿词 英文维基百科25 亿词共约 33 亿词。论文里有一个值得注意的细节必须用文档级别的语料而不是句子级别的打乱语料。原因是 NSP 任务需要真实的段落内连续句子对作为正样本而且在文档内部训练让模型能学到更长的跨句依赖关系——这是在推特文本或短文上训练得不到的东西。长连续文本里一个话题可能绵延数段模型需要理解跨越大量文本的信息关联这种能力只能从长文档里学到。原参考文章https://jalammar.github.io/illustrated-bert/BERT 论文https://arxiv.org/abs/1810.04805BERT 官方代码https://github.com/google-research/bert