本文介绍基于BERT的阅读理解实验,旨在掌握BERT相关知识及飞桨构建方法。实验以DuReaderRobust数据集为对象,通过数据处理、模型构建等六步实现。数据预处理含特征转换,模型含多层结构,经训练、评估,最终保存模型,评估用ROUGE-L指标,F1值达84.3176。
☞☞☞AI 智能聊天, 问答助手, AI 智能搜索, 免费无限量使用 DeepSeek R1 模型☜☜☜

理解并掌握BERT预训练语言模型基础知识点,包括:Transformer、LayerNorm等; 掌握BERT预训练语言模型的设计原理以及构建流程; 熟悉使用飞桨开源框架构建BERT的方法。
阅读理解是自然语言处理中的一个重要的任务,最常见的数据集是单篇章、抽取式阅读理解数据集。即:对于一个给定的问题 q 和一个篇章 p ,根据篇章内容,给出该问题的答案 a 。数据集中的每个样本都是一个三元组<q, p, a>。举例说明:
问题 q: 燃气热水器哪个牌子好?
篇章 p : 选择燃气热水器时,一定要关注这几个问题:
出水稳定性要好,不能出现忽热忽冷的现象 快速到达设定的需求水温 操作要智能、方便 安全性要好,要装有安全报警装置 市场上燃气热水器品牌众多,购买时还需多加对比和仔细鉴别。 方太今年主打的磁化恒温热水器在使用体验方面做了全面升级:9秒速热,可快速进入洗浴模式;水温持久稳定,不会出现忽热忽冷的现象,并通过水量伺服技术将出水温度精确控制在±0.5℃,可满足家里宝贝敏感肌肤洗护需求;配备CO和CH4双气体报警装置更安全(市场上一般多为CO单气体报警)。另外,这款热水器还有智能WIFI互联功能,只需下载个手机APP即可用手机远程操作热水器,实现精准调节水温,满足家人多样化的洗浴需求。当然方太的磁化恒温系列主要的是增加磁化功能,可以有效吸附水中的铁锈、铁屑等微小杂质,防止细菌滋生,使沐浴水质更洁净,长期使用磁化水沐浴更利于身体健康。
参考答案 a : 方太
目前阅读理解任务已经在各产业领域广泛应用,如:在智能客服应用中,可以使用机器阅读用户手册等材料,自动或辅助客服回答用户问题;在教育领域,可利用该技术从海量题库中辅助出题;在金融领域,该技术实现可从大量新闻文本中抽取相关金融信息等。
注:用BERT微调来解决机器阅读理解问题已经成为NLP的主流思路,本文的实验都是基于bert进行阅读理解任务。
BERT(Bidirectional Encoder Representation from Transformers,BERT)是2018年10月由Google AI研究院提出的预训练模型,BERT在机器阅读理解顶级水平测试SQuAD1.1中表现出惊人的成绩: 全部两个衡量指标上全面超越人类,并且在11种不同NLP测试中创出SOTA表现,包括将GLUE基准推高至80.4% (绝对改进7.6%),MultiNLI准确度达到86.7% (绝对改进5.6%),成为NLP发展史上的里程碑式的模型成就。
BERT的网络架构使用的是《Attention is all you need》中提出的多层Transformer结构,如图2所示。其最大的特点是抛弃了传统的RNN和CNN,通过Attention机制将任意位置的两个单词的距离转换成1,有效的解决了NLP中棘手的长期依赖问题。Transformer的结构在NLP和CV领域中已经得到了广泛应用。
BERT是一个多任务模型,它的预训练(Pre-training)任务是由两个自监督任务组成,即MLM和NSP,如图3所示。
1.MLM是指在训练的时候随即从输入预料上mask掉一些单词,然后通过的上下文预测该单词,该任务非常像我们在中学时期经常做的完形填空。正如传统的语言模型算法和RNN匹配那样,MLM的这个性质和Transformer的结构是非常匹配的。在BERT的实验中,15%的WordPiece Token会被随机Mask掉。在训练模型时,一个句子会被多次喂到模型中用于参数学习,但是Google并没有在每次都mask掉这些单词,而是在确定要Mask掉的单词之后,做如下处理: 80%的时候会直接替换为[Mask],将句子 "I love my family" 转换为句子 "I love my [Mask]"。
10%的时候将其替换为其它任意单词,将单词 "family" 替换成另一个随机词,例如 "cat"。将句子 "I love my family" 转换为句子 "I love my cat"。
10%的时候会保留原始Token,例如保持句子为 "I love my family" 不变。
2.Next Sentence Prediction(NSP)的任务是判断句子B是否是句子A的下文。如果是的话输出’IsNext‘,否则输出’NotNext‘。训练数据的生成方式是从平行语料中随机抽取的连续两句话,其中50%保留抽取的两句话,它们符合IsNext关系,另外50%的第二句话是随机从预料中提取的,它们的关系是NotNext的。这个关系保存在图4中的[CLS]符号中。
输入 = [CLS] 我 喜欢 [Mask] 学习 [SEP] 我 最 擅长 的 [Mask] 是 NLP [SEP] 类别 = IsNext
输入 = [CLS] 我 喜欢 [Mask] 学习 [SEP] 今天 我 跟 别人 [Mask] 了 [SEP] 类别 = NotNext
在海量的语料上训练完BERT之后,便可以将其应用到NLP的各个任务进行微调了。微调(Fine-Tuning)的任务包括:基于句子对的分类任务,基于单个句子的分类任务,问答任务,命名实体识别等。下面分别介绍BERT的微调任务:
1. 基于句子对的分类任务
MNLI:给定一个前提 (Premise) ,根据这个前提去推断假设 (Hypothesis) 与前提的关系。该任务的关系分为三种,蕴含关系 (Entailment)、矛盾关系 (Contradiction) 以及中立关系 (Neutral)。所以这个问题本质上是一个分类问题,我们需要做的是去发掘前提和假设这两个句子对之间的交互信息。 QQP:基于Quora,判断 Quora 上的两个问题句是否表示的是一样的意思。 QNLI:用于判断文本是否包含问题的答案,类似于我们做阅读理解定位问题所在的段落。 STS-B:预测两个句子的相似性,包括5个级别。 MRPC:也是判断两个句子是否是等价的。 RTE:类似于MNLI,但是只是对蕴含关系的二分类判断,而且数据集更小。 SWAG:从四个句子中选择为可能为前句下文的那个。 2. 基于单个句子的分类任务
SST-2:电影评价的情感分析。 CoLA:句子语义判断,是否是可接受的(Acceptable)。 3. 问答任务
SQuAD v1.1:给定一个句子(通常是一个问题)和一段描述文本,输出这个问题的答案,类似于做阅读理解的简答题。 4. 命名实体识别
CoNLL-2003 NER:判断一个句子中的单词是不是Person,Organization,Location,Miscellaneous或者other(无命名实体)。
建议您使用AI Studio进行操作。
本实验构建了一个基于BERT的阅读理解模型,实现方案如 图5 所示,阅读理解的主体部分是由BERT组成。
训练阶段:BERT模型的输入是Question(问题)和Paragraph(文章),输出则是答案的位置。
推理阶段:使用训练好的抽取式阅读理解模型,输入问题和文章,模型输出答案的位置,然后在文章中抽取相应位置的文字即可。

从图中可以看到,QA 任务的输入是两个句子,用 [SEP] 分隔,第一个句子是问题(Question),第二个句子是含有答案的上下文(Paragraph);输出是作为答案开始和结束的可能性(Start/End Span)
机器阅读理解实验流程如 图6 所示,包含如下6个步骤:
数据处理:根据网络接收的数据格式,完成相应的预处理操作,保证模型正常读取; 模型构建:设计BERT网络结构; 训练配置:实例化模型,加载模型参数,指定模型采用的寻解算法(优化器); 模型训练:执行多轮训练不断调整参数,以达到较好的效果; 模型保存:将模型参数保存到指定位置,便于后续推理或继续训练使用。 模型评估:对训练好的模型进行评估测试,观察准确率和Loss;
PaddleNLP已经内置SQuAD,CMRC等中英文阅读理解数据集,一键即可加载。本实例加载的是DuReaderRobust中文阅读理解数据集。由于DuReaderRobust数据集采用SQuAD数据格式。
DuReaderRobust数据集的格式如下
{ "data": [ { "title": "", "paragraphs": [ { "qas": [ { "question": "非洲气候带", "id": "bd664cb57a602ae784ae24364a602674", "answers": [ { "text": "热带气候", "answer_start": 45 } ] } ], "context": "1、全年气温高,有热带大陆之称。主要原因在与赤道穿过大陆中部,位于南北纬30度之间,主要是热带气候,没有温带和寒带。2、气候带呈明显带状分布,且南北对称。原因在于赤道穿过大陆中部,整个大陆基本被赤道均分为两部分。因此,纬度地带性明显。气候带以热带雨林为中心,向南北依次分布着热带草原、热带沙漠和地中海式气候。3、气候炎热干燥。第一:热带雨林气候面积较小,主要位于刚果河流域,面积较小。第二,地中海式气候,位于大陆的南北边缘,面积较小。夏季炎热而干旱,冬季温暖而湿润。第三,面积较大热带草原气候,有明显的干湿季。第四,热带沙漠气候主要位于撒哈拉大沙漠和西南角狭长地带。而撒哈拉沙漠占非洲总面积的1/4,全年炎热干燥,日照时间长,昼夜温差大。总之,全非洲纬度低,气温高;干燥地区广,常年湿润地区面积小。" }, { "qas": [ { "question": "韩国全称", "id": "a7eec8cf0c55077e667e0d85b45a6b34", "answers": [ { "text": "大韩民国", "answer_start": 5 } ] } ], "context": "韩国全称“大韩民国”,位于朝鲜半岛南部,隔“三八线”\x0d与朝鲜民主主义人民共和国相邻,面积9.93万平方公理,\x0d南北长约500公里,东西宽约250公里,东濒日本海,西临黄海 ,东南与日本隔海相望。 韩国的地形特点是山地多,平原少,海岸线长而曲折。韩国四 季分明,气候温和、湿润。\x0d目前韩国主要政党包括执政的新千年民主党和在野的大国家党、\x0d自由民主联盟等,大\x0d国家党为韩国会内的第一大党。\x0d韩国首都为汉城,全国设有1个特别市(汉城市)、6个广域市(\x0d釜山市、大邱市、仁\x0d川市、光州市、大田市、蔚山市)、9个道(京畿道、江源道、\x0d忠清北道、忠清南道、全\x0d罗北道、全罗南道、庆尚北道、庆尚南道、济州道)。\x0d海岸线全长5,259公里,主要港口\x0d有釜山、仁川、浦项、蔚山、光阳等。" }, } ] }
说明:
数据是以json的形式存储的,json最顶层是data,接下来是title和paragraphs,paragraphs包含多个qas和context,其中context和qas是一一对应的,每个qas包含question,id,answers,context则是qas对应的上下文。机器阅读理解就是从context中找到给定questions对应的answers片段,answers里面的answer_start表示的是答案的起始位置,text表示的是答案。
数据预处理部分首先要把paddlenlp升级到最新版本,部分功能的实现还需要paddlenlp的参与,然后实现专门处理bert输入文本的BertTokenizer,利用BertTokenizer处理加载的文本就可以了。
aistudio默认的paddlenlp过旧,所以这里手动升级paddlenlp版本,保持paddlenlp为最新版本
!pip install paddlenlp --upgrade
加载paddle的库和第三方的库
import siximport functoolsimport inspectimport paddleimport osimport jsonimport paddleimport paddle.nn as nnimport paddle.tensor as tensorimport paddle.nn.functional as Ffrom paddle.nn import Layerfrom paddle.nn import TransformerEncoder, Linear, Layer, Embedding, LayerNorm, Tanhfrom paddlenlp.transformers.tokenizer_utils import PretrainedTokenizerfrom paddlenlp.transformers.bert.tokenizer import BasicTokenizer,WordpieceTokenizerfrom paddlenlp.data import Pad, Stack, Tuple, Dictfrom paddle.io import DataLoaderfrom paddle.optimizer.lr import LambdaDecayimport sysimport math
BertTokenizer的实现需要继承PretrainedTokenizer,然后下载相应的配置文件,本实验下载的是bert-base-chinese的配置,初始化BasicTokenizer,WordpieceTokenizer,以下分别介绍这几个tokenizer各自的功能:
BasicTokenizer的主要是进行unicode转换、标点符号分割、小写转换、中文字符分割、去除重音符号等操作,最后返回的是关于词的数组(中文是字的数组) WordpieceTokenizer的目的是将合成词分解成类似词根一样的词片。例如将"unwanted"分解成["un", "##want", "##ed"]这么做的目的是防止因为词的过于生僻没有被收录进词典最后只能以[UNK]代替的局面,因为英语当中这样的合成词非常多,词典不可能全部收录。 PretrainedTokenizer:实现从本地文件或目录加载/保存tokenizer常用方法,或从库提供的预训练的tokenizer加载/保存 [CLS]表示该特征用于分类模型,对非分类模型,该符号可以省去。[SEP]表示分句符号,用于断开输入语料中的两个句子。[MASK]用于预训练MLM任务中,用于替换被mask的位置的占位符。[PAD]表示的是对齐符号,把模型的输入序列对齐到固定长度。
class BertTokenizer(PretrainedTokenizer):
resource_files_names = {"vocab_file": "vocab.txt"} # for save_pretrained
pretrained_resource_files_map = { "vocab_file": { "bert-base-chinese": "https://paddle-hapi.bj.bcebos.com/models/bert/bert-base-chinese-vocab.txt",
}
}
pretrained_init_configuration = { "bert-base-chinese": { "do_lower_case": False
},
}
padding_side = 'right'
def __init__(self,vocab_file,do_lower_case=True,unk_token="[UNK]",sep_token="[SEP]",
pad_token="[PAD]",
cls_token="[CLS]",
mask_token="[MASK]"):
if not os.path.isfile(vocab_file): raise ValueError( "Can't find a vocabulary file at path '{}'. To load the "
"vocabulary from a pretrained model please use "
"`tokenizer = BertTokenizer.from_pretrained(PRETRAINED_MODEL_NAME)`"
.format(vocab_file)) # 加载词汇文件vocab.txt,返回一个有序字典
self.vocab = self.load_vocabulary(vocab_file, unk_token=unk_token) # 定义 BasicTokenizer
self.basic_tokenizer = BasicTokenizer(do_lower_case=do_lower_case) # 定义 WordpieceTokenizer
self.wordpiece_tokenizer = WordpieceTokenizer(
vocab=self.vocab, unk_token=unk_token) @property
def vocab_size(self):
# 返回词汇表的大小
return len(self.vocab) def _tokenize(self, text):
split_tokens = [] # 进行unicode转换、标点符号分割、小写转换、中文字符分割、去除重音符号等操作,最后返回的是关于词的数组(中文是字的数组)
for token in self.basic_tokenizer.tokenize(text): # 将合成词分解成类似词根一样的词片,例如将"unwanted"分解成["un", "##want", "##ed"]
for sub_token in self.wordpiece_tokenizer.tokenize(token):
split_tokens.append(sub_token) return split_tokens def tokenize(self, text):
# 对文本进行切分和wordPiece化
return self._tokenize(text) def convert_tokens_to_string(self, tokens):
# 对tokens的list进行拼接,并去除里面的##符号
out_string = " ".join(tokens).replace(" ##", "").strip() return out_string def num_special_tokens_to_add(self, pair=False):
token_ids_0 = []
token_ids_1 = [] return len(
self.build_inputs_with_special_tokens(token_ids_0, token_ids_1 if pair else None)) def build_inputs_with_special_tokens(self, token_ids_0, token_ids_1=None):
# 给输入文本加上cls开始符号,和sep分隔符号
if token_ids_1 is None: return [self.cls_token_id] + token_ids_0 + [self.sep_token_id]
_cls = [self.cls_token_id]
_sep = [self.sep_token_id] return _cls + token_ids_0 + _sep + token_ids_1 + _sep def build_offset_mapping_with_special_tokens(self,offset_mapping_0,offset_mapping_1=None):
# 用来记录每个词起始字符和结束字符的索引
if offset_mapping_1 is None: return [(0, 0)] + offset_mapping_0 + [(0, 0)] return [(0, 0)] + offset_mapping_0 + [(0, 0)
] + offset_mapping_1 + [(0, 0)] def create_token_type_ids_from_sequences(self,
token_ids_0,
token_ids_1=None):
# 从传递的两个序列创建一个掩码,用于序列对分类任务。
_sep = [self.sep_token_id]
_cls = [self.cls_token_id] if token_ids_1 is None: return len(_cls + token_ids_0 + _sep) * [0] return len(_cls + token_ids_0 + _sep) * [0] + len(token_ids_1 +
_sep) * [1] def get_special_tokens_mask(self,
token_ids_0,
token_ids_1=None,
already_has_special_tokens=False):
# 从没有添加特殊标记的标记列表中检索序列ID。添加时调用此方法使用标记器“encode”方法的特殊标记。
if already_has_special_tokens: if token_ids_1 is not None: raise ValueError( "You should not supply a second sequence if the provided sequence of "
"ids is already formatted with special tokens for the model."
) return list( map(lambda x: 1 if x in [self.sep_token_id, self.cls_token_id] else 0,
token_ids_0)) if token_ids_1 is not None: return [1] + ([0] * len(token_ids_0)) + [1] + (
[0] * len(token_ids_1)) + [1] return [1] + ([0] * len(token_ids_0)) + [1]BERT提供了简单和复杂两个模型,分别是bert-base和bert-large,本实验采用bert-base模型,因为dureader是中文数据集,所以选择中文的配置和模型,最终我们选择bert-base-chinese来做本次实验,以下代码加载了bert-base-chinese的词汇和配置,用于后面的中文处理。
model_name_or_path='bert-base-chinese'tokenizer = BertTokenizer.from_pretrained(model_name_or_path)
由于本地没有bert-base-chinese-vocab.txt,所以自动下载了bert-base-chinese-vocab.txt,然后实例化了tokenizer
训练集合处理
使用load_dataset()API默认读取到的数据集是MapDataset对象,MapDataset是paddle.io.Dataset的功能增强版本。其内置的map()方法适合用来进行批量数据集处理。map()方法传入的是一个用于数据处理的function。
由于文章加问题的文本长度可能大于max_seq_length,答案出现的位置有可能出现在文章最后,所以不能简单的对文章进行截断。那么对于过长的文章,则采用滑动窗口将文章分成多段,分别与问题组合。再用对应的tokenizer转化为模型可接受的feature。doc_stride参数就是每次滑动的距离。滑动窗口生成样本的过程如 图7 所示:
from paddlenlp.datasets import load_dataset doc_stride=128 # 滑动窗口的大小max_seq_length=384 # 分词后的最大长度task_name='dureader_robust'
def prepare_train_features(examples):
contexts = [examples[i]['context'] for i in range(len(examples))]
questions = [examples[i]['question'] for i in range(len(examples))]
tokenized_examples = tokenizer(
questions,
contexts,
stride=doc_stride,
max_seq_len=max_seq_length) for i, tokenized_example in enumerate(tokenized_examples): # 把不可能的答案用CLS字符来索引
input_ids = tokenized_example["input_ids"]
cls_index = input_ids.index(tokenizer.cls_token_id) # offset mapping会建立一个从token到字符在原文中的位置的映射,这帮助我们计算开始位置和结束位置
offsets = tokenized_example['offset_mapping'] # 抓取样本对应的序列(知道上下文是什么,问题是什么)
sequence_ids = tokenized_example['token_type_ids'] # 一个样本可以有多个span,这是包含此文本span的示例的索引。
sample_index = tokenized_example['overflow_to_sample']
answers = examples[sample_index]['answers']
answer_starts = examples[sample_index]['answer_starts'] # 文本中答案所在的开始字符和结束字符的索引
start_char = answer_starts[0]
end_char = start_char + len(answers[0]) # 文本中当前span的开始token的索引
token_start_index = 0
while sequence_ids[token_start_index] != 1:
token_start_index += 1
# 文本中当前span的结束token的索引
token_end_index = len(input_ids) - 1
while sequence_ids[token_end_index] != 1:
token_end_index -= 1
token_end_index -= 1
# 检查答案是否超过了span(特征用cls的索引标注),如果超过了,答案的位置用cls的位置代替
if not (offsets[token_start_index][0] <= start_char and
offsets[token_end_index][1] >= end_char):
tokenized_examples[i]["start_positions"] = cls_index
tokenized_examples[i]["end_positions"] = cls_index else: # 把token_start_index和token_end_index移动到答案的两端
while token_start_index < len(offsets) and offsets[
token_start_index][0] <= start_char:
token_start_index += 1
tokenized_examples[i]["start_positions"] = token_start_index - 1
while offsets[token_end_index][1] >= end_char:
token_end_index -= 1
tokenized_examples[i]["end_positions"] = token_end_index + 1
return tokenized_examples读取dureaderrobust的训练集合,把json数据处理成list的形式,list里面每一项以键值对的形式存放。
train_ds = load_dataset(task_name, splits='train')print(train_ds[:2])
输出了模型的2条数据,用list格式保存,list的每一项都是一个字典,键值对形式。
train_ds.map(prepare_train_features, batched=True)
for idx in range(2): print('input_ids:{}'.format(train_ds[idx]['input_ids'])) print('token_type_ids:{}'.format(train_ds[idx]['token_type_ids'])) print('overflow_to_sample:{}'.format(train_ds[idx]['overflow_to_sample'])) print('offset_mapping:{}'.format(train_ds[idx]['offset_mapping'])) print('start_positions:{}'.format(train_ds[idx]['start_positions'])) print('end_positions:{}'.format(train_ds[idx]['end_positions'])) print()从以上结果可以看出,数据集中的example已经被转换成了模型可以接收的feature,包括input_ids、token_type_ids、答案的起始位置等信息。 其中:
input_ids: 表示输入文本的token ID。 token_type_ids: 表示对应的token属于输入的问题还是答案。(Transformer类预训练模型支持单句以及句对输入)。 overflow_to_sample: feature对应的example的编号。 offset_mapping: 每个token的起始字符和结束字符在原文中对应的index(用于生成答案文本)。 start_positions: 答案在这个feature中的开始位置。 end_positions: 答案在这个feature中的结束位置。
测试集合处理
测试集合的生成跟训练集合类似
def prepare_validation_features(examples):
contexts = [examples[i]['context'] for i in range(len(examples))]
questions = [examples[i]['question'] for i in range(len(examples))]
tokenized_examples = tokenizer(
questions,
contexts,
stride=doc_stride,
max_seq_len=max_seq_length) # 对于验证,不需要计算开始和结束位置
for i, tokenized_example in enumerate(tokenized_examples): # 抓取样本对应的序列(知道上下文是什么,问题是什么)
sequence_ids = tokenized_example['token_type_ids'] # 一个样本可以有多个span,这是包含此文本span的示例的索引。
sample_index = tokenized_example['overflow_to_sample']
tokenized_examples[i]["example_id"] = examples[sample_index]['id'] # 将不属于上下文的偏移量映射设置为None,这样就可以很容易地确定token位置是否属于上下文
tokenized_examples[i]["offset_mapping"] = [
(o if sequence_ids[k] == 1 else None) for k, o in enumerate(tokenized_example["offset_mapping"])
] return tokenized_examples读取dureaderrobust的dev集合,把json数据处理成list的形式,list里面每一项以键值对的形式存放。
dev_ds = load_dataset(task_name, splits='dev')print(dev_ds[:2])
dev_ds.map(prepare_validation_features, batched=True)
阅读理解本质是一个答案抽取任务,PaddleNLP对于各种预训练模型已经内置了对于下游任务-答案抽取的Fine-tune网络。
答案抽取任务的本质就是根据输入的问题和文章,预测答案在文章中的起始位置和结束位置。
机器阅读理解模型的构成需要实现BertEmbeddings,TransformerEncoderLayer,BertPooler,FC层这四部分。由于TransformerEncoderLayer已经由paddle实现了,所以下面介绍一下其他部分的实现:
BertEmbeddings表示的是bert的嵌入层,包括word embedding,position embedding和token_type embedding三部分。 TransformerEncoderLayer表示的是Transformer编码层,这部分paddle已经实现,Transformer编码器层由两个子层组成:多头自注意力机制和前馈神经网络。BERT主体是transformer的编码层结构,transformer的具体介绍请参考transformer BertPooler层的输入是transformer最后一层的输出,取出每一句的第一个单词,做全连接和激活。 FC层:是bert做下有任务的一般做法,把bert得到的句向量或者单词向量加入全连接,用于分类等下游任务。
词嵌入张量: Token Embedding,例如 [CLS] dog 等,通过训练学习得到。 语句分块张量: token_type_embeddings(或者Segment Embedding),用于区分每一个单词属于句子 A 还是句子 B,如果只输入一个句子就只使用 EA,通过训练学习得到。 位置编码张量: position_embeddings,编码单词出现的位置,与 Transformer 使用固定的公式计算不同,BERT 的 Position Embedding 也是通过学习得到的,在 BERT 中,假设句子最长为 512。 最终的embedding向量是将上述的3个向量直接做加和的结果。
class BertEmbeddings(Layer):
def __init__(self,
vocab_size,
hidden_size=768,
hidden_dropout_prob=0.1,
max_position_embeddings=512,
type_vocab_size=16):
super(BertEmbeddings, self).__init__() # Token Embedding
self.word_embeddings = nn.Embedding(vocab_size, hidden_size) # position embedding
self.position_embeddings = nn.Embedding(max_position_embeddings,
hidden_size) # token_type embedding
self.token_type_embeddings = nn.Embedding(type_vocab_size, hidden_size) # 层归一化
self.layer_norm = nn.LayerNorm(hidden_size) # dropout层
self.dropout = nn.Dropout(hidden_dropout_prob) def forward(self, input_ids, token_type_ids=None, position_ids=None):
if position_ids is None:
ones = paddle.ones_like(input_ids, dtype="int64")
seq_length = paddle.cumsum(ones, axis=-1)
position_ids = seq_length - ones
position_ids.stop_gradient = True
if token_type_ids is None:
token_type_ids = paddle.zeros_like(input_ids, dtype="int64") # token embedding
input_embedings = self.word_embeddings(input_ids) # position embedding
position_embeddings = self.position_embeddings(position_ids) # token_type embedding
token_type_embeddings = self.token_type_embeddings(token_type_ids) # token embedding, position embedding和token type embedding进行拼接
embeddings = input_embedings + position_embeddings + token_type_embeddings # 层归一化
embeddings = self.layer_norm(embeddings) # dropout操作
embeddings = self.dropout(embeddings) return embeddingsBertPooler: 只是取每个 sequence 的第一个token,即原本的输入大小为 [batch_size, seq_length, hidden_size],变换后大小为 [batch_size, hidden_size],去掉了 seq_length 维度,相当于是每个 sequence 都只用第一个 token 来表示。然后接上一层 hidden_size 大小的线性映射即可,激励函数为 nn.tanh。
class BertPooler(Layer):
def __init__(self, hidden_size):
super(BertPooler, self).__init__() # 全连接层
self.dense = nn.Linear(hidden_size, hidden_size) # tanh激活函数
self.activation = nn.Tanh() def forward(self, hidden_states):
# 把隐藏状态的第0个token的向量取出来
first_token_tensor = hidden_states[:, 0] # 全连接
pooled_output = self.dense(first_token_tensor) # tanh激活函数
pooled_output = self.activation(pooled_output) return pooled_outputPretrainedModel: 负责存储模型的配置,并处理加载/下载/保存模型的方法以及一些通用于所有模型的方法:(i)调整输入embedding的大小,(ii)修剪自我注意头中的头。 bert-base-chinese预训练模型各参数的含义
"bert-base-chinese": { "vocab_size": 21128, #词典中词数 "hidden_size": 768, #隐藏单元数 "num_hidden_layers": 12, #隐藏层数 "num_attention_heads": 12, #每个隐藏层中的attention head数 "intermediate_size": 3072, #升维维度 "hidden_act": "gelu", #激活函数 "hidden_dropout_prob": 0.1, #隐藏层dropout概率 "attention_probs_dropout_prob": 0.1, #乘法attention时,softmax后dropout概率 "max_position_embeddings": 512, # 一个大于seq_length的参数,用于生成position_embedding "type_vocab_size": 2, #segment_ids类别 [0,1] "initializer_range": 0.02, #初始化范围 "pad_token_id": 0, #对齐的值 }
from paddlenlp.transformers import PretrainedModel
**注:**BertPretrainedModel是一个预处理bert的抽象类,它提供了bert相关的模型配置,model_config_file, resource_files_names, pretrained_resource_files_map,pretrained_init_configuration, base_model_prefix。用于加载和下载预训练模型。
class BertPretrainedModel(PretrainedModel):
model_config_file = "model_config.json"
pretrained_init_configuration = { "bert-base-chinese": { "vocab_size": 21128, "hidden_size": 768, "num_hidden_layers": 12, "num_attention_heads": 12, "intermediate_size": 3072, "hidden_act": "gelu", "hidden_dropout_prob": 0.1, "attention_probs_dropout_prob": 0.1, "max_position_embeddings": 512, "type_vocab_size": 2, "initializer_range": 0.02, "pad_token_id": 0,
},
}
resource_files_names = {"model_state": "model_state.pdparams"}
pretrained_resource_files_map = { "model_state": { "bert-base-chinese": "http://paddlenlp.bj.bcebos.com/models/transformers/bert/bert-base-chinese.pdparams",
}
}
base_model_prefix = "bert"
def init_weights(self, layer):
""" Initialization hook """
if isinstance(layer, (nn.Linear, nn.Embedding)): if isinstance(layer.weight, paddle.Tensor):
layer.weight.set_value(
paddle.tensor.normal(
mean=0.0,
std=self.initializer_range if hasattr(self, "initializer_range") else
self.bert.config["initializer_range"],
shape=layer.weight.shape)) elif isinstance(layer, nn.LayerNorm):
layer._epsilon = 1e-12from paddlenlp.transformers import register_base_model
register_base_model: 为修饰类的基类添加“base_model_class”属性,在相同体系结构的派生类中表示基模型类。register_base_model在BertPretrainedModel的子类上使用。 BertModel: BERT模型的具体实现。
@register_base_modelclass BertModel(BertPretrainedModel):
def __init__(self,
vocab_size,
hidden_size=768,
num_hidden_layers=12,
num_attention_heads=12,
intermediate_size=3072,
hidden_act="gelu",
hidden_dropout_prob=0.1,
attention_probs_dropout_prob=0.1,
max_position_embeddings=512,
type_vocab_size=16,
initializer_range=0.02,
pad_token_id=0):
super(BertModel, self).__init__()
self.pad_token_id = pad_token_id
self.initializer_range = initializer_range # BertEmbeddings层
self.embeddings = BertEmbeddings(
vocab_size, hidden_size, hidden_dropout_prob,
max_position_embeddings, type_vocab_size) # TransformerEncoderLayer层 12个multi-head
encoder_layer = nn.TransformerEncoderLayer(
hidden_size,
num_attention_heads,
intermediate_size,
dropout=hidden_dropout_prob,
activation=hidden_act,
attn_dropout=attention_probs_dropout_prob,
act_dropout=0) # TransformerEncoder层 12层encoder_layer
self.encoder = nn.TransformerEncoder(encoder_layer, num_hidden_layers) # BertPooler层
self.pooler = BertPooler(hidden_size) # 模型参数初始化
self.apply(self.init_weights) def forward(self,input_ids,token_type_ids=None,position_ids=None,attention_mask=None):
if attention_mask is None:
attention_mask = paddle.unsqueeze(
(input_ids == self.pad_token_id
).astype(self.pooler.dense.weight.dtype) * -1e9,
axis=[1, 2]) # input_id,position_id,token_type_id embedding
embedding_output = self.embeddings(
input_ids=input_ids,
position_ids=position_ids,
token_type_ids=token_type_ids) # 调用TransformerEncoder层 12层encoder_layer
encoder_outputs = self.encoder(embedding_output, attention_mask)
sequence_output = encoder_outputs # 调用BertPooler
pooled_output = self.pooler(sequence_output) return sequence_output, pooled_outputBertForQuestionAnswering需要继承BertPretrainedModel; 实例化BERT; 在BERT后面加入一层全连接,连接的神经元的数目2,表示的是答案开始位置和答案结束位置输出。
class BertForQuestionAnswering(BertPretrainedModel):
def __init__(self, bert, dropout=None):
super(BertForQuestionAnswering, self).__init__() # 加载bert配置
self.bert = bert
# 全连接层
self.classifier = nn.Linear(self.bert.config["hidden_size"], 2)
self.apply(self.init_weights) def forward(self, input_ids, token_type_ids=None):
# Bert接收输入
sequence_output, _ = self.bert(
input_ids,
token_type_ids=token_type_ids,
position_ids=None,
attention_mask=None) # 分类
logits = self.classifier(sequence_output)
logits = paddle.transpose(logits, perm=[2, 0, 1])
start_logits, end_logits = paddle.unstack(x=logits, axis=0) return start_logits, end_logits由于BertForQuestionAnswering模型对将BertModel的sequence_output拆开成start_logits和end_logits进行输出,所以阅读理解任务的loss也由start_loss和end_loss组成,我们需要自己定义损失函数。对于答案其实位置和结束位置的预测可以分别成两个分类任务。所以设计的损失函数如下:
class CrossEntropyLossForSQuAD(paddle.nn.Layer):
def __init__(self):
super(CrossEntropyLossForSQuAD, self).__init__() def forward(self, y, label):
# 预测值的开始位置和结束位置
start_logits, end_logits = y # ground truth的开始位置和结束位置
start_position, end_position = label
start_position = paddle.unsqueeze(start_position, axis=-1)
end_position = paddle.unsqueeze(end_position, axis=-1) # 计算start loss
start_loss = paddle.nn.functional.softmax_with_cross_entropy(
logits=start_logits, label=start_position, soft_label=False)
start_loss = paddle.mean(start_loss) # 计算end loss
end_loss = paddle.nn.functional.softmax_with_cross_entropy(
logits=end_logits, label=end_position, soft_label=False)
end_loss = paddle.mean(end_loss) # 求最终的损失
loss = (start_loss + end_loss) / 2
return lossLinearDecayWithWarmup:创建一个学习率调度程序,线性地提高学习率,从0到给定的“学习率”,在这个热身期后学习率,从基本学习率线性下降到0。
# 判断number是否是整型def is_integer(number):
if sys.version > '3': return isinstance(number, int) return isinstance(number, (int, long))class LinearDecayWithWarmup(LambdaDecay):
def __init__(self,
learning_rate,
total_steps,
warmup,
last_epoch=-1,
verbose=False):
warmup_steps = warmup if is_integer(warmup) else int(
math.floor(warmup * total_steps)) def lr_lambda(current_step):
# current_step小于warmup_steps的时候,学习率逐渐增加
if current_step < warmup_steps: return float(current_step) / float(max(1, warmup_steps)) # 学习率随着current_step的增加而降低
return max(0.0, float(total_steps - current_step) / float(max(1, total_steps - warmup_steps))) super(LinearDecayWithWarmup, self).__init__(learning_rate, lr_lambda,
last_epoch, verbose)训练配置包括实例化BertForQuestionAnswering,损失函数,优化器参数设置,优化器,数据加载等。
# 设置GPU模式paddle.set_device("gpu")# 实例化BertForQuestionAnswering模型model = BertForQuestionAnswering.from_pretrained(model_name_or_path)criterion = CrossEntropyLossForSQuAD()
1.需要设置batch_size的大小,训练的轮次num_train_epochs,设置优化器的参数learning_rate,weight_decayadam_epsilon这些。
2.warmup_proportion表示,慢热学习的比例。比如warmup_proportion=0.1,在前10%的steps中,lr从0线性增加到 init_learning_rate,这个阶段又叫 warmup,然后,lr又从 init_learning_rate 线性衰减到0(完成所有steps)。可以避免较早的对mini-batch过拟合,即较早的进入不好的局部最优而无法跳出;保持模型深层的稳定性。
# batch size的大小batch_size=16 # epoch的大小num_train_epochs=1# warmup的比例warmup_proportion=0.1# 优化器的配置learning_rate=3e-5 weight_decay=0.01adam_epsilon=1e-8
加载数据
train_batch_sampler = paddle.io.DistributedBatchSampler(train_ds, batch_size=batch_size, shuffle=True)
train_batchify_fn = lambda samples, fn=Dict({ "input_ids": Pad(axis=0, pad_val=tokenizer.pad_token_id), "token_type_ids": Pad(axis=0, pad_val=tokenizer.pad_token_type_id), "start_positions": Stack(dtype="int64"), "end_positions": Stack(dtype="int64")
}): fn(samples)
train_data_loader = DataLoader(
dataset=train_ds,
batch_sampler=train_batch_sampler,
collate_fn=train_batchify_fn,
return_list=True)定义优化器
num_training_steps = len(train_data_loader) * num_train_epochs
lr_scheduler = LinearDecayWithWarmup(
learning_rate, num_training_steps, warmup_proportion)
decay_params = [
p.name for n, p in model.named_parameters() if not any(nd in n for nd in ["bias", "norm"])
]# 优化器optimizer = paddle.optimizer.AdamW(
learning_rate=lr_scheduler,
epsilon=adam_epsilon,
parameters=model.parameters(),
weight_decay=weight_decay,
apply_decay_param_fun=lambda x: x in decay_params)模型训练的过程通常有以下步骤:
从dataloader中取出一个batch data; 将batch data喂给model,做前向计算; 将前向计算结果传给损失函数,计算loss; loss反向回传,更新梯度。重复以上步骤。
global_step = 0logging_steps=100save_steps=1000import time
tic_train = time.time()for epoch in range(num_train_epochs): # 遍历每个batch的数据
for step, batch in enumerate(train_data_loader):
global_step += 1
input_ids, token_type_ids, start_positions, end_positions = batch # 调用模型
logits = model(input_ids=input_ids, token_type_ids=token_type_ids) # 求损失
loss = criterion(logits, (start_positions, end_positions)) # 打印日志
if global_step % logging_steps == 0: print("global step %d, epoch: %d, batch: %d, loss: %f, speed: %.2f step/s"
% (global_step, epoch, step, loss,
logging_steps / (time.time() - tic_train)))
tic_train = time.time() # 反向传播,更新权重,清除梯度
loss.backward()
optimizer.step()
lr_scheduler.step()
optimizer.clear_grad()训练完成后,可以将模型参数保存到磁盘,用于模型推理或继续训练。
output_path='./chekpoint/dureader_robust/'# 如果文件夹不存在就创建文件夹if not os.path.exists(output_path):
os.makedirs(output_path)# 保存模型model.save_pretrained(output_path)# 保存tokenizertokenizer.save_pretrained(output_path)print('Saving checkpoint to:', output_path)
每训练一个epoch时,程序通过evaluate()调用paddlenlp.metrics.squad中的squad_evaluate(), compute_prediction()评估当前模型训练的效果,其中:
举个例子:
C : 热带.
S :热带气候.
from paddlenlp.metrics.squad import squad_evaluate, compute_prediction
dev_batch_sampler = paddle.io.BatchSampler(
dev_ds, batch_size=batch_size, shuffle=False)
dev_batchify_fn = lambda samples, fn=Dict({ "input_ids": Pad(axis=0, pad_val=tokenizer.pad_token_id), "token_type_ids": Pad(axis=0, pad_val=tokenizer.pad_token_type_id)
}): fn(samples)
dev_data_loader = DataLoader(
dataset=dev_ds,
batch_sampler=dev_batch_sampler,
collate_fn=dev_batchify_fn,
return_list=True)@paddle.no_grad()def evaluate(model, data_loader):
# 切换为预测模式
model.eval()
n_best_size=20
max_answer_length=30
all_start_logits = []
all_end_logits = []
tic_eval = time.time() # 遍历每个batch数据
for batch in data_loader:
input_ids, token_type_ids = batch # 调用模型,得到模型的输出
start_logits_tensor, end_logits_tensor = model(input_ids,
token_type_ids) # 解析模型的输出
for idx in range(start_logits_tensor.shape[0]): if len(all_start_logits) % 1000 == 0 and len(all_start_logits): print("Processing example: %d" % len(all_start_logits)) print('time per 1000:', time.time() - tic_eval)
tic_eval = time.time()
all_start_logits.append(start_logits_tensor.numpy()[idx])
all_end_logits.append(end_logits_tensor.numpy()[idx]) # 预测
all_predictions, _, _ = compute_prediction(
data_loader.dataset.data, data_loader.dataset.new_data,
(all_start_logits, all_end_logits), False, n_best_size,
max_answer_length) # 预测的结果写入到文件
with open('prediction.json', "w", encoding='utf-8') as writer:
writer.write(
json.dumps(
all_predictions, ensure_ascii=False, indent=4) + "\n") # 评估结果
squad_evaluate(
examples=data_loader.dataset.data,
preds=all_predictions,
is_whitespace_splited=False) # 切换为训练模式
model.train()evaluate(model, dev_data_loader)
总共评估的样本数为1417(对应上述的total和HasAns_total),每1000个样本评估的时间8.143768072128296秒,上述的结果F1和HasAns_f1的值为84.31761822783372,exact和HasAns_exact计算结果也一样,这是因为他们的计算方式是完全一样的,F1对应的是Rouge-L的FLCSF_{LCS}F-LCS。
在这篇文章中,我们选择了BERT问答模型,在DuReader Robust数据集上实现了阅读理解。通过本实验,同学们不但回顾了《人工智能导论:模型与算法》- 6.4 循环神经网络这一节中介绍的相关原理,加深了对bert模型的理解,并且学习了通过飞桨深度学习开源框架实现本实验模型的机器阅读理解任务。大家可以在此实验的基础上,尝试开发自己感兴趣的机器阅读理解任务。
以上就是基于BERT实现机器阅读理解的详细内容,更多请关注php中文网其它相关文章!
每个人都需要一台速度更快、更稳定的 PC。随着时间的推移,垃圾文件、旧注册表数据和不必要的后台进程会占用资源并降低性能。幸运的是,许多工具可以让 Windows 保持平稳运行。
Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号