BERT网络模型改进优化分析
BERT网络模型改进优化分析
BERT模型的优化改进方法!
BERT基础
BERT是由Google AI于2018年10月提出的一种基于深度学习的语言表示模型。BERT 发布时,在11种不同的NLP测试任务中取得最佳效果,NLP领域近期重要的研究成果。
BERT基础
BERT主要的模型结构是Transformer编码器。Transformer是由 Ashish 等于2017年提出的,用于Google机器翻译,包含编码器(Encoder)和解码器(Decoder)两部分。
BERT预训练方法
BERT 模型使用两个预训练目标来完成文本内容特征的学习。
掩藏语言模型(Masked Language Model,MLM)通过将单词掩盖,从而学习其上下文内容特征来预测被掩盖的单词相邻句预测(Next Sentence Predication,NSP)通过学习句子间关系特征,预测两个句子的位置是否相邻
分支1:改进预训练
自然语言的特点在于丰富多变,很多研究者针对更丰富多变的文本表达形式,在这两个训练目标的基础上进一步完善和改进,提升了模型的文本特征学习能力。
改进掩藏语言模型
在BERT模型中,对文本的预处理都按照最小单位进行了切分。例如对于英文文本的预处理采用了Google的wordpiece方法以解决其未登录词的问题。
在MLM中掩盖的对象多数情况下为词根(subword),并不是完整的词;对于中文则直接按字切分,直接对单个字进行掩盖。这种掩盖策略导致了模型对于词语信息学习的不完整。针对这一不足,大部分研究者改进了MLM的掩盖策略。在 Google 随后发布的BERT-WWM模型中,提出了全词覆盖的方式。
BERT-Chinese-wwm
利用中文分词,将组成一个完整词语的所有单字同时掩盖。
ERNIE
扩展了中文全词掩盖策略,扩展到对于中文分词、短语及命名实体的全词掩盖。
SpanBERT
采用了几何分布来随机采样被掩盖的短语片段,通过Span边界词向量来预测掩盖词
引入降噪自编码器
MLM 将原文中的词用[MASK]标记随机替换,这本身是对文本进行了破坏,相当于在文本中添加了噪声,然后通过训练语言模型来还原文本,消除噪声。
DAE 是一种具有降噪功能的自编码器,旨在将含有噪声的输入数据还原为干净的原始数据。对于语言模型来说,就是在原始语言中加入噪声数据,再通过模型学习进行噪声的去除以恢复原始文本。
BART
引入了降噪自编码器,丰富了文本的破坏方式。例如随机掩盖(同 MLM 一致)某些词、随机删掉某些词或片段、打乱文档顺序等,将文本输入到编码器中后,利用一个解码器生成破坏之前的原始文档。
引入替代词检测
MLM 对文本中的[MASK]标记的词进行预测,以试图恢复原始文本。其预测结果可能完全正确,也可能预测出一个不属于原文本中的词。
ELECTRA
引入了替代词检测,来预测一个由语言模型生成的句子中哪些词是原本句子中的词,哪些词是语言模型生成的且不属于原句子中的词。
ELECTRA 使用一个小型的 MLM 模型作为生成器(Generator),来对包含[MASK]的句子进行预测。另外训练一个基于二分类的判别器(Discriminator)来对生成器生成的句子进行判断。
改进相邻句预测
在大多数应用场景下,模型仅需要针对单个句子完成建模,舍弃NSP训练目标来优化模型对于单个句子的特征学习能力。
删除NSP:NSP仅仅考虑了两个句子是否相邻,而没有兼顾到句子在整个段落、篇章中的位置信息。改进NSP:通过预测句子之间的顺序关系,从而学习其位置信息。
分支2:融合融合外部知识
当下知识图谱的相关研究已经取得了极大的进展,大量的外部知识库都可以应用到 NLP 的相关研究中。
嵌入实体关系知识
实体关系三元组是知识图谱的最基本的结构,也是外部知识最直接和结构化的表达。K-BERT
从BERT模型输入层入手,将实体关系的三元组显式地嵌入到输入层中。
特征向量拼接知识
BERT可以将任意文本表示为特征向量的形式,因此可以考虑采用向量拼接的方式在 BERT 模型中融合外部知识。
SemBERT
利用语义角色标注工具,获取文本中的语义角色向量表示,与原始BERT文本表示融合。
训练目标融合知识
在知识图谱技术中,大量丰富的外部知识被用来直接进行模型训练,形成了多种训练任务。ERNIE
以DAE的方式在BERT中引入了实体对齐训练目标,WKLM通过随机替换维基百科文本中的实体,让模型预测正误,从而在预训练过程中嵌入知识。
分支3:改进Transformer
由于Transformer结构自身的限制,BERT等一系列采用 Transformer 的模型所能处理的最大文本长度为 512个token。
改进 Encoder MASK矩阵
BERT 作为一种双向编码的语言模型,其“双向”主要体现在 Transformer结构的 MASK 矩阵中。Transformer 基于自注意力机制(Self-Attention),利用MASK 矩阵提供一种“注意”机制,即 MASK 矩阵决定了文本中哪些词可以互相“看见”。
UniLM
通过对输入数据中的两个句子设计不同的 MASK 矩阵来完成生成模型的学习。对于第一个句子,采用跟 BERT 中的 Transformer-Encoder 一致的结构,每个词都能够“注意”到其“上文”和“下文”信息。
对于第二个句子,其中的每个词只能“注意”到第一句话中的所有词和当前句子的“上文”信息。利用这种巧妙的设计,模型输入的第一句话和第二句话形成了经典的“Seq2Seq”的模式,从而将 BERT 成功用于语言生成任务。
Encoder + Decoder语言生成
BART模型同样采用Encoder+Decoder 的结构,借助DAE语言模型的训练方式,能够很好地预测和生成被“噪声”破坏的文本,从而也得到具有文本生成能力的预训练语言模型。
分支4:量化与压缩
模型蒸馏
对 BERT 蒸馏的研究主要存在于以下几个方面:
在预训练阶段还是微调阶段使用蒸馏学生模型的选择
蒸馏的位置
DistilBERT
在预训练阶段蒸馏,其学生模型具有与BERT结构,但层数减半。
TinyBERT
为BERT的嵌入层、输出层、Transformer中的隐藏层、注意力矩阵都设计了损失函数,来学习 BERT 中大量的语言知识。
模型剪枝
剪枝(Pruning)是指去掉模型中不太重要的权重或组件,以提升推理速度。用于 BERT 的剪枝方法主要有权重修剪和结构修剪。
BERT加速的N种方法
从BERT面世的第二天,笔者就实现了BERT用于序列标注的工作,几乎是全网最早的用BERT做序列标注的工作,到今天离线场景下,BERT做序列标注已经成为一种普惠技术。从huggingface开源transformers的几乎最早的时间开始跟进,复现组内早期基于Tensorflow做中文纠错的工作,之后模型侧的工作基本一直基于该框架完成。从BERT早期的一系列比较fancy的工作一直在跟进,到组内推广transformers的使用,到如今Pytorch地位飙升,transformers社区受众极广,BERT几乎是笔者过去很长一段时间经常讨论的话题。
但是,围绕BERT,最为诟病的一个问题:模型太重,inference时间太长,效果好,但是在线场景基本不能使用?
围绕该问题,学术界和工业界有太多的工作在做。这篇文章简单梳理一些具体的研究方向,同时围绕笔者个人比较感兴趣的一个方向,做一些评测和对比。
那么,具有有哪些研究方向呢?整体上,有两种观察视角。一种是train和inference,另一种是算法侧和工程侧,这里不做具体的区分。
- 模型大,是慢的一个重要原因,那就换小模型
- 模型大,通过模型设计,有些部分是可以快的
- 模型蒸馏
- 模型压缩剪枝
- 模型量化:混合精度
- 服务优化:CPU或者GPU推断,请求管理(批式或者流式),缓存
- 其他
每个方向都有大量的工作出现,这篇文章主要讨论偏向于工程侧的优化方式。
基于huggingface的transformers的实现,支持不同的模型加载方式native,onnx,jit,libtorch(c++),native c++(fastertransformer和其他c++版实现),tensorRT,tensorflow serving共七种方式。
(1)统一的请求接口设计
为了测试不同的inference速度,并不限于模型类型,这里固定模型条件,统一为MaskedLM(bert-base-uncased)。假设脱离本文的主题设定,模型类型显然是影响inference速度的关键因素,这里分为两种条件,第一是不同的模型类型,比如TextCNN和BERT;第二是BERT的不同实现,比如Layer数量的不同,特殊Trick的使用等。
核心接口代码如下:
#预处理:载入分词器from transformers import AutoTokenizertokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")#测试文本text = "[CLS] In deep [MASK], everything is amazing![SEP]"#分词tokenized_text = tokenizer.tokenize(text)#token2idindexed_tokens = tokenizer.convert_tokens_to_ids(tokenized_text)#服务请求url=""post(url)
得益于transformers的优雅的接口设计,可以利用两行代码加载分词器,类似的,可以用两行代码加载模型:
from transformers import AutoModelForMaskedLMmodel = AutoModelForMaskedLM.from_pretrained("bert-base-uncased")
(2)不同的inference方式
(2.1)native
朴素的方式是直接加载pytorch_model.bin,config.json, vocab.txt,作为server端的模型。核心服务代码如下:
import torchfrom transformers import AutoModelForMaskedLMmodel = AutoModelForMaskedLM.from_pretrained("bert-base-uncased")with torch.no_grad(): output = model(tokens.to(device))[0].detach().cpu()output = torch.softmax(output[0][idx_to_predict], 0)
这种方式是平时pytorch用户使用最多的方式。
(2.2)onnx
笔者第一次接触onnx是2018年做CV的时候,那个时候需要将一个pytorch的模型转化为onnx,做android移动端的部署,大概在那个时候,不同框架之间的模型转化已经成为一个业界的实际需求。为了通过onnx加载模型,首先需要将native的模型转化为onnx的模型。模型转换代码如下:
import torch.onnxdummy_tensor = torch.randint(0, 30522, (1, 512))batch_size = 1torch_out = model(dummy_tensor)torch.onnx.export(model, # model being run dummy_tensor, # model input (or a tuple for multiple inputs) model_path, # where to save the model (can be a file or file-like object) export_params=True, # store the trained parameter weights inside the model file opset_version=10, # the ONNX version to export the model to do_constant_folding=True, # whether to execute constant folding for optimization input_names=['input'], # the model's input names output_names=['output'], # the model's output names dynamic_axes={'input': {0: 'batch_size'}, # variable length axes 'output': {0: 'batch_size'}})
这里转换的逻辑中,有一个细节。导入模型之后,需要通过构造一个dummy tensor才能够获取网络的结构,同时模型转换中提供了一些优化的方式。核心服务代码如下:
import onnxruntimeonnx_session = onnxruntime.InferenceSession(model_path)ort_inputs = {onnx_session.get_inputs()[0].name: tokens}ort_outs = onnx_session.run(None, ort_inputs)output = np.array(ort_outs)[0][0]output = softmax(output[idx_to_predict])
(2.3)jit
使用jit的方式,同样需要做模型转换,转换代码如下:
with torch.no_grad(): traced_model = torch.jit.trace(model, dummy_tensor) torch.jit.save(traced_model, model_path)
核心服务代码如下:
model = torch.jit.load(model_path)with torch.no_grad(): output = model(tokens.to(device))[0].detach().cpu() output = torch.softmax(output[0][idx_to_predict], 0)
(2.4)libtorch(c++)
采用libtorch(c++)加载的模型同jit,服务端的核心加载代码如下:
#include
"torch/script.h"torch::jit::script::Module module = torch::jit::load(model_path)module.eval()module.forward(tokens)
这里值得一提的是,不同于python的server端,可以选择fastAPI,flask,gunicorn等,c++也有对应的server端,典型的比如crow。使用该种方式的一个问题是:要解决c++编译的各种依赖问题。
(2.5)其他三种方式暂未测试
(3)评测结果
加载方式 |
onnx |
native |
jit |
libtorch(c++) |
备注 |
时间(相同请求次数) |
6.20s |
7.07s |
6.83s |
libtorch(c++),限于各种依赖,笔者未测 |
笔者的结果 |
时间(相同请求次数) |
12.43s |
19.43s |
18.24s |
12.10s |
他人的结果 |
笔者个人的环境和他人的环境不相同,因此具体时间上不同,但是趋势是基本一致的。onnx和libtorch(c++)的方式都较快,NLP算法同学中,python用户居多,因此选择onnx是一种比较理想的方式。native是最慢的,也就是说最常用的方式恰恰是inference效率最低的方式。jit介于两者之间。
对于没有实测过的结果,这里给出一张他人的评测结果,如下:
对比可知:说啥都没有用C++重写一遍来的快!笔者在之前做过一个表格数据处理的加速,向量指令,cache等多种技术都有尝试,最后发现,C++重写一遍核心逻辑,速度立刻显著提升。关于BERT的C++实现,可以参考字节的开源工作。
从整体上看BERT的加速可以从多个方面开展。C++的加速方式效果最理想,但是成本也较高。onnx的方法目前来看,成本较低可执行。实际上,最近的天池的小布助手比赛中,Top选手也多采用了这种方案,但是采用tensorRT的方式也有,这篇文章没有做实测,可以作为一种备选的方案。此外,配合低精度,服务优化等方式。不论怎样,从一开始,结合对数据的理解,选择一个小的模型,使用最native的方式也许就可以满足inference的要求了。蒸馏和剪枝在技术上比较fancy,需要反复的迭代和优化。
参考文献链接
https://mp.weixin.qq.com/s/MHm7AxmcuEgFR_oNbNqFkQ
https://mp.weixin.qq.com/s/xrQHdIzPZwM3CheQAIaqPA