BERT网络模型改进优化分析

发布时间 2023-07-12 05:10:33作者: 吴建明wujianming

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