BERT 浅析

快速上手使用 BERT

Posted by Xiaosheng on August 7, 2019

2018 年 10 月 11 日,Google AI Language 发布了论文《BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding》,其中提出的 BERT 模型在 11 个 NLP 任务上的表现刷新了记录,在自然语言处理学界以及工业界都引起了不小的热议。BERT 的出现,彻底改变了预训练产生词向量和下游具体 NLP 任务的关系,提出龙骨级的训练词向量概念。

本文将简单地横向比较 Word2Vec,ELMo 和 BERT 这三个模型,并给出 Keras 版的使用实例。

1. 词向量模型

简单来说,词向量模型是一个工具,可以把文字(词语/字符)转换成向量,然后我们使用这些向量来完成各种 NLP 任务。因而某种意义上,NLP 任务分成两部分:预训练产生词向量,对词向量进行操作(下游具体 NLP 任务)

从 Word2Vec 到 ELMo 到 BERT,它们的主要思想其实就把下游具体 NLP 任务的工作,逐渐移到预训练产生词向量的过程中。可以简单地概括为:

  • Word2vec 到 ELMo
    • 操作:将 encoding 操作转移到预训练产生词向量的过程实现。
    • 结果:将上下文无关的静态词向量变成上下文相关的动态词向量,比如“苹果”在不同语境下的词向量就会不同。
  • ELMo 到 BERT:
    • 操作:使用句子级负采样获得句子表示/句对关系,Transformer 模型代替 LSTM 提升表达和时间上的效率,使用 masked LM 解决“自己看到自己”的问题。
    • 结果:训练出的 word-level 向量变成 sentence-level 向量,下游具体 NLP 任务调用更方便,修正了 ELMo 模型的潜在问题。

1.1 Word2Vec

Word2Vec 模型训练出来的词向量之间具有类似线性的关系,这是一个很神奇的地方,例如:

从而也说明高维空间映射的词向量可以很好体现真实世界中 token 之间的关系。

Word2Ved 的另一个重要特性就是其采用的负采样技术。由于训练词向量模型的目标不是为了得到一个多么精准的语言模型,而是为了获得它的副产物——词向量。所以要做到的不是在几万几十万个 token 中艰难计算 softmax 获得最优的那个词(预测对于给定词的下一词),而只需能做到在几个词中找到对的那个词就行。这几个词包括一个正例(即直接给定下一词),和随机产生的噪声词(采样抽取的几个负例),就是说训练一个 sigmoid 二分类器,只要模型能够从中找出正确的词就认为完成任务。

这种负采样思想也应用到之后的 BERT 里,只不过从 word-level 变成 sentence-level,这样能获取句子之间的关联关系。

但是 Word2Vec 存在一个很大的缺点,就是它获得的词向量是上下文无关 (static) 的。因而在下游具体的 NLP 任务中,为了获得句子的整体含义 (context),大家都会基于词向量的序列做 encoding 操作。下面的表格概括了 Word2Vec 模型。

预训练encoding(上下文相关) 模型 预测目标 下游具体任务 负采样 级别
CBOW/
Skip-Gram
next word 需要编码 词语级

预测目标这里写的是”下一个词“ (next word),是所有传统语言模型都做的事——寻找下一个词填什么。

如果大家对于 Word2Vec 模型的细节还不是很清楚,可以阅读《不可思议的 Word2Vec》系列

1.2 ELMo

ELmo 模型是 AllenNLP 在 2018 年 8 月发布的一个上下文相关模型,甚至在 9 月 10 月 BERT 没出来时,也小火了一把。但据说使用时很慢效率很低,再加上 Google 马上就提出了强势 BERT,ELMo 很快就被人们遗忘了。不过 BERT 的提出也算是对 ELMo 的致敬,毕竟它也硬是凑了一个同为《芝麻街》里的角色名:)。

1

这里介绍 ELMo 的两方面,一个是它的 encoder 模型 Bi-LSTM,另一个是它和下游具体 NLP 任务的接口(迁移策略)。

ELMo 通过采用 Bi-LSTM 做 encoder 来实现上下文相关,也就是之前我们说的把下游具体 NLP 任务转移到预训练产生词向量的过程里,从而达到获得一个根据上下文不同而不断变化的动态词向量。具体实现方法是使用双向语言模型 Bi-LSTM 来实现,如下图所示。从前到后和后到前分别通过双向的两层 LSTM 进行 encoding,从而获得两个方向的 token 联系,进而获得句子的 context。

2

但这里有两个潜在问题,姑且称作不完全双向自己看见自己

  • 首先,不完全双向是指模型的前向和后向 LSTM 两个模型是分别训练的,从图中也可以看出,对于一个序列,前向遍历一遍获得左边的 LSTM,后向遍历一遍获得右边的 LSTM,最后得到的隐层向量直接通过拼接 (concat) 得到结果向量,并且在最后的 Loss function 中也是前向和后向的 Loss function 直接相加,并非完全同时的双向计算。

  • 另外,自己看见自己是指要预测的下一个词在给定的序列中已经出现的情况。传统语言模型的数学原理决定了它的单向性。从公式 $p(s) = p(w_0)\cdot p(w_1\mid w_0)\cdot p(w_2\mid w_1,w_0) \cdots p(w_n\mid context)$ 可以看出,传统语言模型的目标是获得在给定序列从头到尾条件概率相乘后概率最大的下一词,而加深网络的层数会导致预测的下一词已经在给定序列中出现了的问题,这就是“自己看见自己”。

    3

    如上图所示(从下往上看),最下行是训练数据 $\text{A B C D}$,经过两个 Bi-LSTM 操作,需要预测某个词位置的内容。比如第二行第二列 $\text{A|CD}$ 这个结果是第一层 Bi-LSTM 在 $\text{B}$ 位置输出内容,包括正向 $\text{A}$ 和反向 $\text{CD}$,直接拼接成 $\text{A|CD}$。比如第三行第二例 $\text{ABCD}$ 这个结果是前向 $\text{BCD}$ 和反向 $\text{AB|D}$ 拼接结果,而当前位置需要预测的是 $\text{B}$,已经在 $\text{ABCD}$ 中出现了,这就会有问题。因而对于 Bi-LSTM,只要层数增加,就是会存在“自己看见自己”的问题。

ELMo 模型将 context 的 encoding 操作从下游具体 NLP 任务转换到了预训练词向量这里,但在具体应用时要做出一些调整。当 Bi-LSTM 有多层时,由于每层会学到不同的特征,而这些特征在具体应用中侧重点不同,每层的关注度也不同。ELMo 给原始词向量层和每个 RNN 隐层都设置了一个可训练参数,通过 softmax 层归一化后乘到相应的层上并求和起到了加权作用。

比如,原本论文中设定了两个隐层,第一隐层可以学到对词性、句法等信息,对此有明显需求的任务可以对第一隐层参数学到比较大的值;第二隐层更适合对词义消歧有需求的任务,从而分配更高权重。

因此 ELMo 可以概括为:

预训练encoding(上下文相关) 模型 预测目标 下游具体任务 负采样 级别
Bi-LSTM next word 需要设置每层参数 词语级

1.3 BERT

4

BERT 模型进一步增加词向量模型泛化能力,充分描述字符级、词级、句子级甚至句间关系特征。

首先,BERT 实现了真正的双向 encoding,并且通过 Masked LM 实现类似完形填空的效果来解决“自己看见自己”的问题。尽管仍旧看到所有位置信息,但需要预测的词已被特殊符号代替,可以放心堆叠多层。

其次,BERT 使用 Transformer 而不是 Bi-LSTM 做 encoder,可以有更深的层数、具有更好并行性。并且线性的 Transformer 比 LSTM 更易免受 mask 标记影响,只需要通过 self-attention 减小 mask 标记权重即可,而 LSTM 类似黑盒模型,很难确定其内部对于 mask 标记的处理方式。

最后,BERT 将向量提升至句子级别,通过句子级负采样,可以学习句子/句对关系表示。首先给定的一个句子,下一句子或者是正例(正确词),或者是随机采样一句负例(随机采样词),在句子级上来做二分类(即判断句子是当前句子的下一句还是噪声),类似 Word2Vec 的词语级负采样。

BERT 可以概括为:

预训练encoding(上下文相关) 模型 预测目标 下游具体任务 负采样 级别
Transformer masked 简单 MLP 句子级

2. BERT 模型细节

这里我们主要介绍 BERT 的三个亮点 Masked LM、transformer 和 sentence-level。

2.1 Masked Language Model

BERT 每次随机 mask 语料中 15% 的 token,然后将 masked token 位置输出的最终隐层向量送入 softmax,来预测 masked token。这样输入一个句子,每次只预测句子中大概 15% 的词,所以 BERT 训练很慢。

Input Sequence : The man went to [MASK] store with [MASK] dog
Target Sequence :                 the               his

而对于盖住词的特殊标记,在下游 NLP 任务中不存在。因此,为了和后续任务保持一致,作者按一定的比例在需要预测的词位置上输入原词或者输入某个随机的词。

例如:my dog is hairy:有 80% 的概率用 [mask] 标记来替换:my dog is [MASK];有 10% 的概率用随机采样的一个单词来替换:my dog is apple;有 10% 的概率不做替换:my dog is hairy

2.2 Transformer (attention is all you need)

BERT 采用 Transformer 模型作为 encoder,Transformer 模型由 Google 在 2018 年 5 月提出,是可以替代传统 RNN 和 CNN 的一种新的架构,用来实现机器翻译,论文名称是《Attention is all you need》。无论是 RNN 还是 CNN,在处理 NLP 任务时都有缺陷。CNN 由于其先天的卷积操作不是很适合处理序列化的文本,RNN 由于其循环结构无法并行化,很容易超出内存限制(比如 50 tokens 长度的句子就会占据很大的内存)。

5

上图是 transformer 模型的结构,分成左边 Nx 框框的 encoder 和右边 Nx 框框的 decoder,相较于 RNN+Attention 常见的 encoder-decoder 之间的 attention(右边上边的一个橙色框),还多出 encoder 和 decoder 内部的 self-attention(下边的两个橙色框)。每个 attention 都有采用 multi-head 结构。最后,通过 position encoding 加入没考虑过的位置信息。

如果大家对于 Transformer 模型还不了解,可以阅读《浅谈 NLP 中的 Attention 机制》

2.3 Sentence-level Representation

在很多任务中,仅仅靠 encoding 是不足以完成任务的(这个只是学到了一堆 token 级的特征),还需要捕捉一些句子级的模式,来完成 SLI、QA、dialogue 等需要句子表示、句间交互与匹配的任务。对此,BERT 又引入了另一个极其重要却又极其轻量级的任务,来试图把这种模式也学习到。

这个任务就是句子级别的连续性预测任务,即预测输入 BERT 的两端文本是否为连续的文本。训练的时候,输入模型的第二个片段会以 50% 的概率从全部文本中随机选取,剩下 50% 的概率选取第一个片段的后续文本。

这可以看作是句子级负采样,即首先给定的一个句子(相当于 Word2Vec 中给定 context),它下一个句子或者为正例(相当于 Word2Vec 中的正确词),或者随机采样一个句子作为负例(相当于 Word2Vec 中随机采样的词),然后在该 sentence-level 上来做二分类(即判断句子确实是当前句子的下一句还是噪声)。

6

BERT 是一个句子级别的语言模型,不像 ELMo 模型在与下游具体 NLP 任务拼接时需要每层加上权重做全局池化,BERT 可以直接获得一整个句子的唯一向量表示。它在每个 input 前面加一个特殊的记号 [CLS],如上图所示,然后让 Transformer 对 [CLS] 进行深度 encoding,由于 Transformer 是可以无视空间和距离的把全局信息 encoding 进每个位置的,而 [CLS] 的最高隐层向量作为句子/句对的表示直接跟 softmax 的输出层连接,因此其作为梯度反向传播路径上的“关卡”,可以学到整个 input 的上层特征。

BERT 还采用了 segment embedding,对于句对来说,EA 和 EB 分别代表左句子和右句子;对于句子来说,只有 EA。这个 EA 和 EB 也是随模型训练出来的。如下图所示,最终输入结果会变成下面 3 个 embedding 拼接的表示。

7

3. 迁移策略

下面我们看一下 BERT 如何具体地使用到实际的 NLP 任务中,NLP 任务主要分为 4 大类:

  • 序列标注:分词、实体识别、语义标注……
  • 分类任务:文本分类、情感计算……
  • 句子关系判断:entailment、QA、自然语言推理
  • 生成式任务:机器翻译、文本摘要

BERT 将传统大量在下游具体 NLP 任务中做的操作转移到预训练词向量中,在获得 BERT 词向量后,最终只需在词向量上加简单的 MLP 或线性分类器即可。比如论文中所给的几类任务:

8

对于图 (a) 句子关系判断(句对匹配)和图 (b) 文本分类任务来说,只需要在得到的表示(即 encoder 在 [CLS] 词位的顶层输出)上加一层 MLP 就好。对于图 (c) 文本抽取式任务,我们只需要用两个线性分类器分别输出 span 的起点和终点。对于图 (d) 序列标注任务,就只需要加一个 softmax 输出层。

4. 当 Bert 遇上 Keras

BERT 模型的标准版本有 1 亿的参数量,而大号版本有 3 亿多参数量,这应该是目前自然语言处理中最大的预训练模型了。Google 用了 16 个 TPU 集群(一共 64 块 TPU)来训练大号版本的 BERT,都花费了 4 天时间。不过他们会将已经训练好的模型和代码开源,方便大家在训练好的模型上进行后续任务。

很幸运的是,已经有 CyberZHG 大佬封装好了 Keras 版的 Bert:keras-bert,可以直接调用官方发布的预训练权重,对于已经有一定 Keras 基础的读者来说,这可能是最简单的调用 Bert 的方式了。

事实上,有了 keras-bert 之后,再加上一点点 keras 基础知识,而且 keras-bert 所给的 demo 已经足够完善,调用、微调 Bert 都已经变成了一件没有什么技术含量的事情了。本文下面只是给出几个中文的例子,来让大家熟悉keras-bert 的基本用法。

正式讲例子之前,还有必要先讲一下 Tokenizer 相关内容。我们导入 Bert 的 Tokenizer 并重构一下它:

from keras_bert import load_trained_model_from_checkpoint, Tokenizer
import codecs

config_path = '../bert/chinese_L-12_H-768_A-12/bert_config.json'
checkpoint_path = '../bert/chinese_L-12_H-768_A-12/bert_model.ckpt'
dict_path = '../bert/chinese_L-12_H-768_A-12/vocab.txt'

token_dict = {}
with codecs.open(dict_path, 'r', 'utf8') as reader:
    for line in reader:
        token = line.strip()
        token_dict[token] = len(token_dict)

class OurTokenizer(Tokenizer):
    def _tokenize(self, text):
        R = []
        for c in text:
            if c in self._token_dict:
                R.append(c)
            elif self._is_space(c):
                R.append('[unused1]') # space类用未经训练的[unused1]表示
            else:
                R.append('[UNK]') # 剩余的字符是[UNK]
        return R

tokenizer = OurTokenizer(token_dict)
tokenizer.tokenize(u'今天天气不错')
# 输出是 ['[CLS]', u'今', u'天', u'天', u'气', u'不', u'错', '[SEP]']

这里简单解释一下 Tokenizer 的输出结果。首先,默认情况下,分词后句子首位会分别加上 [CLS][SEP] 标记,正如我们之前介绍的,[CLS] 位置对应的输出向量是能代表整句的句向量,而 [SEP] 则是句间的分隔符,其余部分则是单字输出(对于中文来说)。

本来 Tokenizer 有自己的 _tokenize 方法,这里我们重写了这个方法,是要保证 tokenize 之后的结果,跟原来的字符串长度等长(如果算上两个标记,那么就是等长再加 2)。Tokenizer 自带的 _tokenize 会自动去掉空格,然后有些字符会粘在一块输出,导致 tokenize 之后的列表不等于原来字符串的长度了,这样如果做序列标注的任务会很麻烦。因此这里我们用 [unused1] 来表示空格类字符,而其余的不在列表的字符用 [UNK] 表示,其中 [unused*] 这些标记是未经训练的(随即初始化),是 Bert 预留出来用来增量添加词汇的标记,所以我们可以用它们来指代任何新字符。

废话不多说,下面我们通过文本分类关系抽取主体抽取三个例子来简单介绍 keras-bert 的使用。这三个例子都是在官方发布的预训练权重基础上进行微调来做的。

Bert 官方 Github:https://github.com/google-research/bert
官方的中文预训练权重:chinese_L-12_H-768_A-12.zip
本文例子所在 Github:https://github.com/bojone/bert_in_keras/

根据官方介绍,这份权重是用中文维基百科为语料进行训练的。2019 年 6 月 20 日,哈工大讯飞联合实验室发布了一版新权重,也可以用 keras_bert 加载,详情请看这里

4.1 文本分类

作为第一个例子,我们做一个最基本的文本分类任务——文本感情分类任务,熟悉做这个基本任务之后,剩下的各种任务都会变得相当简单了。

让我们来看看模型部分全貌(完整代码见这里),语料下载:sentiment.zip

bert_model = load_trained_model_from_checkpoint(config_path, checkpoint_path)

for l in bert_model.layers:
    l.trainable = True

x1_in = Input(shape=(None,))
x2_in = Input(shape=(None,))

x = bert_model([x1_in, x2_in])
x = Lambda(lambda x: x[:, 0])(x) # 取出[CLS]对应的向量用来做分类
p = Dense(1, activation='sigmoid')(x)

model = Model([x1_in, x2_in], p)
model.compile(
    loss='binary_crossentropy',
    optimizer=Adam(1e-5), # 用足够小的学习率
    metrics=['accuracy']
)
model.summary()

在 Keras 中调用 Bert 来做情感分类任务就这样写完了~写完了~~

是不是感觉还没有尽兴,模型代码就结束了?Keras 调用 Bert 就这么简短。事实上,真正调用 Bert 的也就只有 load_trained_model_from_checkpoint 那一行代码,剩下的只是普通的 Keras 操作。所以,如果你已经入门了 Keras,那么调用 Bert 是无往不利啊。

如此简单的调用,能达到什么精度?经过 5 个 epoch 的 fine tune 后,验证集的最好准确率是95.5%+!之前我们死调烂调也就只有 90% 上下的准确率;而用了 Bert 之后,寥寥几行,就提升了 5 个百分点多的准确率!也难怪 Bert 能在 NLP 界掀起一阵热潮…

这里说说大家可能关心的两个问题。

第一个问题是“要多少显存才够?”。事实上,这没有一个标准答案,显存的使用取决于三个因素:句子长度、batch size、模型复杂度。像上面的情感分析例子,在 GTX1060 6G 显存上也能跑起来,只需要将 batch size 调到 24 即可。所以,如果你的显存不够大,将句子的 maxlen 和 batch size 都调小一点试试。当然,如果你的任务太复杂,再小的 maxlen 和 batch size 也可能 OOM,那就只有升级显卡了。

第二个问题是“有什么原则来指导 Bert 后面应该要接哪些层?”。答案是:用尽可能少的层来完成你的任务。比如上述情感分析只是一个二分类任务,你就取出第一个向量然后加个 Dense(1) 就好了,不要想着多加几层 Dense,更加不要想着接个 LSTM 再接 Dense;如果你要做序列标注(比如 NER),那你就接个 Dense+CRF 就好,也不要多加其他东西。总之,额外加的东西尽可能少。一是因为 Bert 本身就足够复杂,它有足够能力应对你要做的很多任务;二来你自己加的层都是随即初始化的,加太多会对 Bert 的预训练权重造成剧烈扰动,容易降低效果甚至造成模型不收敛。

4.2 关系抽取

假如读者已经有了一定的 Keras 基础,那么经过第一个例子的学习,其实我们应该已经完全掌握了 Bert 的 fine tune 了,因为实在是简单到没有什么好讲了。所以,后面两个例子主要是提供一些参考模式,让读者能体会到如何“用尽可能少的层来完成你的任务”。

在第二个例子中,我们介绍基于 Bert 实现的一个极简的关系抽取模型,其标注原理跟《基于DGCNN和概率图的轻量级信息抽取模型》介绍的一样,但是得益于 Bert 强大的编码能力,我们所写的部分可以大大简化。模型部分如下(完整模型见这里):

t = bert_model([t1, t2])
ps1 = Dense(1, activation='sigmoid')(t)
ps2 = Dense(1, activation='sigmoid')(t)

subject_model = Model([t1_in, t2_in], [ps1, ps2]) # 预测subject的模型

k1v = Lambda(seq_gather)([t, k1])
k2v = Lambda(seq_gather)([t, k2])
kv = Average()([k1v, k2v])
t = Add()([t, kv])
po1 = Dense(num_classes, activation='sigmoid')(t)
po2 = Dense(num_classes, activation='sigmoid')(t)

object_model = Model([t1_in, t2_in, k1_in, k2_in], [po1, po2]) # 输入text和subject,预测object及其关系

train_model = Model([t1_in, t2_in, s1_in, s2_in, k1_in, k2_in, o1_in, o2_in],
                    [ps1, ps2, po1, po2])

如果大家已经读过《基于DGCNN和概率图的轻量级信息抽取模型》一文,了解到不用Bert时的模型架构,那么就会理解到上述实现是多么的简介明了。

可以看到,我们引入了 Bert 作为编码器,然后得到了编码序列 $t$,然后直接接两个 Dense(1),这就完成了 subject 的标注模型;接着,我们把传入的 s 的首尾对应的编码向量拿出来,直接加到编码向量序列 $t$ 中去,然后再接两个 Dense(num_classes),就完成 object 的标注模型(同时标注出了关系)。

这样简单的设计,最终 F1 能到多少?答案是:线下 dev 能接近 82%,线上我提交过一次,结果是 85%+(都是单模型)!相比之下,《基于DGCNN和概率图的轻量级信息抽取模型》中的模型,需要接 CNN,需要搞全局特征,需要将 s 传入到 LSTM 进行编码,还需要相对位置向量,各种拍脑袋的模块融合在一起,单模型也只比它好一点点(大约 82.5%)。Bert 的强悍之处可见一斑。

用 Bert 做关系抽取的这个例子,跟前面情感分析的简单例子,有一个明显的差别是学习率的变化。

情感分析的例子中,只是用了恒定的学习率($10^{−5}$)训练了几个 epoch,效果就还不错了。在关系抽取这个例子中,第一个 epoch 的学习率慢慢从 $0$ 增加到 $5×10^{−5}$(这样称为 warmup),第二个 epoch 再从 $5×10^{−5}$ 降到 $10^{−5}$,总的来说就是先增后减,Bert 本身也是用类似的学习率曲线来训练的,这样的训练方式比较稳定,不容易崩溃,而且效果也比较好。

4.3 事件主体抽取

最后一个例子来自CCKS 2019 面向金融领域的事件主体抽取,下面实例的模型准确率为 89%+,供大家参考。简单介绍一下这个比赛的数据,大概是这样的

输入:“公司A产品出现添加剂,其下属子公司B和公司C遭到了调查”, “产品出现问题”

输出: “公司A”

也就是说,这是个双输入、单输出的模型,输入是一个 query 和一个事件类型,输出一个实体(有且只有一个,并且是 query 的一个片段)。其实这个任务可以看成是 SQUAD 1.0 的简化版,根据这个输出特性,输出应该用指针结构比较好(两个 softmax 分别预测首尾)。剩下的问题是:双输入怎么搞?

前面两个例子虽然复杂度不同,但它们都是单一输入的,双输入怎么办呢?当然,这里的实体类型只有有限个,直接 Embedding 也行,只不过我使用一种更能体现 Bert 的简单粗暴和强悍的方案:直接用连接符将两个输入连接成一个句子,然后就变成单输入了!比如上述示例样本处理成:

输入:“___产品出现问题___公司A产品出现添加剂,其下属子公司B和公司C遭到了调查”

输出: “公司A”

然后就变成了普通的单输入抽取问题了。说到这个,这个模型的代码也就没有什么好说的了,就简单几行(完整代码请看这里):

x = bert_model([x1, x2])
ps1 = Dense(1, use_bias=False)(x)
ps1 = Lambda(lambda x: x[0][..., 0] - (1 - x[1][..., 0]) * 1e10)([ps1, x_mask])
ps2 = Dense(1, use_bias=False)(x)
ps2 = Lambda(lambda x: x[0][..., 0] - (1 - x[1][..., 0]) * 1e10)([ps2, x_mask])

model = Model([x1_in, x2_in], [ps1, ps2])

另外加上一些解码的 trick,还有模型融合,提交上去,就可以做到 89%+ 了。(这个代码重复实验时波动比较大,大家可以多跑几次,取最优结果。)

这个例子主要告诉我们,用 Bert 实现自己的任务时,最好能整理成单输入的模式,这样一来比较简单,二来也更加高效。

比如做句子相似度模型,输入两个句子,输出一个相似度,有两个可以想到的做法,第一种是两个句子分别过同一个 Bert,然后取出各自的 [CLS] 特征来做分类;第二种就是像上面一样,用个记号把两个句子连接在一起,变成一个句子,然后过一个 Bert,然后将输出特征做分类,后者显然会更快一些,而且能够做到特征之间更全面的交互。

参考

《当Bert遇上Keras:这可能是Bert最简单的打开姿势》
《【NLP】彻底搞懂BERT》
《谷歌最强 NLP 模型 BERT 解读》