当Bert遇上Keras:这可能是Bert最简单的打开姿势
Bert 是什么,估计也不用笔者来诸多介绍了。虽然笔者不是很喜欢Bert,但不得不说,Bert 确实在 NLP 界引起了一阵轩然大波。现在不管是中文还是英文,关于 Bert 的科普和解读已经满天飞了,隐隐已经超过了当年 Word2Vec 刚出来的势头了。有意思的是,Bert 是 Google 搞出来的,当年的 word2vec 也是 Google 搞出来的,不管你用哪个,都是在跟着 Google 大佬的屁股跑。
Bert 刚出来不久,就有读者建议我写个解读,但我终究还是没有写。一来,Bert 的解读已经不少了,二来其实 Bert 也就是基于 Attention 搞出来的大规模语料预训练的模型,本身在技术上不算什么创新,而关于 Google 的 Attention 我已经写过解读了,所以就提不起劲来写了。
总的来说,我个人对 Bert 一直也没啥兴趣,直到上个月末在做信息抽取比赛时,才首次尝试了 Bert。毕竟即使不感兴趣,终究也是得学会它,毕竟用不用是一回事,会不会又是另一回事。再加上在 Keras 中使用(fine tune)Bert,似乎还没有什么文章介绍,所以就分享一下自己的使用经验。
当Bert遇上Keras
很幸运的是,已经有大佬封装好了 Keras 版的 Bert,可以直接调用官方发布的预训练权重,对于已经有一定 Keras 基础的读者来说,这可能是最简单的调用 Bert 的方式了。所谓“站在巨人的肩膀上”,就是形容我们这些 Keras 爱好者此刻的心情了。
keras-bert
个人认为,目前在 Keras 下对 Bert 最好的封装是:
keras-bert:
https://github.com/CyberZHG/keras-bert
本文也是以此为基础的。 顺便一提的是,除了 keras-bert 之外,CyberZHG 大佬还封装了很多有价值的 keras 模块,比如 keras-gpt-2(你可以用像用 Bert 一样用 GPT2 模型了)、keras-lr-multiplier(分层设置学习率)、keras-ordered-neurons(就是前不久介绍的 ON-LSTM)等等。看来也是一位 Keras 铁杆粉丝,致敬大佬。
汇总可以看:
https://github.com/CyberZHG/summary
事实上,有了 keras-bert 之后,再加上一点点 Keras 基础知识,而且 keras-bert 所给的 demo 已经足够完善,调用、微调 Bert 都已经变成了意见没有什么技术含量的事情了。所以后面笔者只是给出几个中文的例子,来让读者上手 keras-bert 的基本用法。
Tokenizer
正式讲例子之前,还有必要先讲一下 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] 位置对应的输出向量是能代表整句的句向量(反正 Bert 是这样设计的),而 [SEP] 则是句间的分隔符,其余部分则是单字输出(对于中文来说)。
本来 Tokenizer 有自己的 _tokenize 方法,我这里重写了这个方法,是要保证 tokenize 之后的结果,跟原来的字符串长度等长(如果算上两个标记,那么就是等长再加 2)。 Tokenizer 自带的 _tokenize 会自动去掉空格,然后有些字符会粘在一块输出,导致 tokenize 之后的列表不等于原来字符串的长度了,这样如果做序列标注的任务会很麻烦。
而为了避免这种麻烦,还是自己重写一遍好了。主要就是用 [unused1] 来表示空格类字符,而其余的不在列表的字符用 [UNK] 表示,其中 [unused*] 这些标记是未经训练的(随即初始化),是 Bert 预留出来用来增量添加词汇的标记,所以我们可以用它们来指代任何新字符。
三个例子
这里包含 keras-bert 的三个例子,分别是文本分类、关系抽取和主体抽取,都是在官方发布的预训练权重基础上进行微调来做的。
Bert官方Github:https://github.com/google-research/bert
官方的中文预训练权重:https://storage.googleapis.com/bert_models/2018_11_03/chinese_L-12_H-768_A-12.zip
例子所在Github:https://github.com/bojone/bert_in_keras/
根据官方介绍,这份权重是用中文维基百科为语料进行训练的。
文本分类
作为第一个例子,我们做一个最基本的文本分类任务,熟悉做这个基本任务之后,剩下的各种任务都会变得相当简单了。这次我们以之前已经讨论过多次的文本感情分类任务 [1] 为例,所用的标注数据 [2] 也是以前所整理的。
让我们来看看模型部分全貌,完整代码见:
https://github.com/bojone/bert_in_keras/blob/master/sentiment.py
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 操作(再次感谢 CyberZHG 大佬)。所以,如果你已经入门了 Keras,那么调用 Bert 是无往不利啊。
如此简单的调用,能达到什么精度?经过5个epoch的fine tune后,验证集的最好准确率是95.5%+!之前我们在《文本情感分类(三):分词 OR 不分词》[1] 中死调烂调,也就只有 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 的预训练权重造成剧烈扰动,容易降低效果甚至造成模型不收敛。
关系抽取
假如读者已经有了一定的 Keras 基础,那么经过第一个例子的学习,其实我们应该已经完全掌握了 Bert 的 fine tune 了,因为实在是简单到没有什么好讲了。所以,后面两个例子主要是提供一些参考模式,让读者能体会到如何“用尽可能少的层来完成你的任务”。
在第二个例子中,我们介绍基于 Bert 实现的一个极简的关系抽取模型,其标注原理跟《基于 DGCNN 和概率图的轻量级信息抽取模型》[3] 介绍的一样,但是得益于 Bert 强大的编码能力,我们所写的部分可以大大简化。
在笔者所给出的一种参考实现中,模型部分如下,完整模型见:
https://github.com/bojone/bert_in_keras/blob/master/relation_extract.py
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 和概率图的轻量级信息抽取模型》一文 [3],了解到不用 Bert 时的模型架构,那么就会理解到上述实现是多么的简介明了。
可以看到,我们引入了 Bert 作为编码器,然后得到了编码序列 t,然后直接接两个 Dense(1),这就完成了 subject 的标注模型;接着,我们把传入的 s 的首尾对应的编码向量拿出来,直接加到编码向量序列 t 中去,然后再接两个 Dense(num_classes),就完成 object 的标注模型(同时标注出了关系)。
这样简单的设计,最终 F1 能到多少?答案是:线下 dev 能接近 82%,线上我提交过一次,结果是 85%+(都是单模型)!
相比之下,《基于 DGCNN 和概率图的轻量级信息抽取模型》[3] 中的模型,需要接 CNN,需要搞全局特征,需要将 s 传入到 LSTM 进行编码,还需要相对位置向量,各种拍脑袋的模块融合在一起,单模型也只比它好一点点(大约 82.5%)。
要知道,这个基于 Bert 的简单模型我只写了一个小时就写出来了,而各种技巧和模型融合在一起的 DGCNN 模型,我前前后后调试了差不多两个月!Bert 的强悍之处可见一斑。
注:这个模型的 fine tune 最好有 8G 以上的显存。另外,因为我在比赛即将结束的前几天才接触的 Bert,才把这个基于 Bert 的模型写出来,没有花心思好好调试,所以最终的提交结果并没有包含 Bert。
用 Bert 做关系抽取的这个例子,跟前面情感分析的简单例子,有一个明显的差别是学习率的变化。情感分析的例子中,只是用了恒定的学习率训练了几个 epoch,效果就还不错了。
在关系抽取这个例子中,第一个 epoch 的学习率慢慢从 0 增加到(这样称为 warmup),第二个 epoch 再从降到,总的来说就是先增后减,Bert 本身也是用类似的学习率曲线来训练的,这样的训练方式比较稳定,不容易崩溃,而且效果也比较好。
事件主体抽取
最后一个例子来自 CCKS 2019 面向金融领域的事件主体抽取 [4],这个比赛目前还在进行,不过我也已经没有什么动力和兴趣做下去了,所以放出我现在的模型(准确率为 89%+)供大家参考,祝继续参赛的选手取得更好的成绩。
简单介绍一下这个比赛的数据,大概是这样的:
输入:“公司 A 产品出现添加剂,其下属子公司 B 和公司 C 遭到了调查”, “产品出现问题”
输出:“公司 A”
也就是说,这是个双输入、单输出的模型,输入是一个 query 和一个事件类型,输出一个实体(有且只有一个,并且是 query 的一个片段)。其实这个任务可以看成是 SQUAD 1.0 [5] 的简化版,根据这个输出特性,输出应该用指针结构比较好(两个 softmax 分别预测首尾)。剩下的问题是:双输入怎么搞?
前面两个例子虽然复杂度不同,但它们都是单一输入的,双输入怎么办呢?当然,这里的实体类型只有有限个,直接 Embedding 也行,只不过我使用一种更能体现 Bert 的简单粗暴和强悍的方案:直接用连接符将两个输入连接成一个句子,然后就变成单输入了!
比如上述示例样本处理成:
输入:“___产品出现问题___公司 A 产品出现添加剂,其下属子公司 B 和公司 C 遭到了调查”
输出:“公司 A”
然后就变成了普通的单输入抽取问题了。说到这个,这个模型的代码也就没有什么好说的了,就简单几行,完整代码请看:
https://github.com/bojone/bert_in_keras/blob/master/subject_extract.py
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%+ 了。在看看目前排行榜,发现最好的结果也就是 90% 多一点点,所以估计大家都差不多是这样做的了。这个代码重复实验时波动比较大,大家可以多跑几次,取最优结果。
这个例子主要告诉我们,用 Bert 实现自己的任务时,最好能整理成单输入的模式,这样一来比较简单,二来也更加高效。
比如做句子相似度模型,输入两个句子,输出一个相似度,有两个可以想到的做法,第一种是两个句子分别过同一个 Bert,然后取出各自的 [CLS] 特征来做分类;第二种就是像上面一样,用个记号把两个句子连接在一起,变成一个句子,然后过一个 Bert,然后将输出特征做分类,后者显然会更快一些,而且能够做到特征之间更全面的交互。
文章小结
本文介绍了 Keras 下 Bert 的基本调用方法,其中主要是提供三个参考例子,供大家逐步熟悉 Bert 的 fine tune 步骤和原理。其中有不少是笔者自己闭门造车的经验之谈,如果有所偏颇,还望读者指正。
事实上有了 CyberZHG 大佬实现的 keras-bert,在 Keras 下使用 Bert 也就是小菜一碟,大家折腾个半天,也就上手了。最后祝大家用得痛快~
相关链接
[1] https://kexue.fm/archives/3863
[2] https://kexue.fm/archives/3414
[3] https://kexue.fm/archives/6671
[4] https://biendata.com/competition/ccks_2019_4/
[5] https://rajpurkar.github.io/SQuAD-explorer/explore/1.1/dev/