基于BERT实现机器阅读理解
更新时间:2026-01-14 11:57:27
-
-
易播最新版 v1.0.17
- 类型:学习办公
- 大小:162.8m
- 语言:简体中文
- 评分:
- 查看详情
基于BERT实现机器阅读理解
本文介绍基于BERT的阅读理解实验,旨在掌握BERT相关知识和飞桨构建方法。实验以DuReaderRobust数据集为对象,通过数据预处理、特征转换、模型构建等六步实现。经过训练与评估后,最终保存模型,并使用ROUGE-L指标评估,F达到
1. 实验介绍
1.1 实验目的
了解和掌握BERT的基础知识,包括Transformer结构、LayerNorm技术;学习BERT的创建过程和构建方法;熟练使用飞桨框架进行BERT的开发。
1.2 实验内容
1.2.1 阅读理解任务
阅读理解在自然语言处理中是一项重要任务,最常见的数据集是单篇目、抽取式阅读理解数据集。对于给定的问题 q 和一篇篇章 p,根据其内容给出答案 a。该数据集中的每个样本由三个元素组成。例如:问题 q: “这个城市的哪个区域最繁忙?” 篇章 p: “我在城市中心发现了一座巨大的购物中心。”回答 a: 市中心。这就是抽取式阅读理解任务的一个示例。
问题 q: 燃气热水器哪个牌子好?
篇章 p : 选择燃气热水器时,一定要关注这几个问题:
出水稳定性良好且不会出现忽热忽冷的现象 快速达到设定的需求水温 操作简便安全 采用了先进的智能设计和便捷操作功能 确保了热水器的高效性与安全性 在市场上众多燃气热水器品牌中,选购时建议多方面对比和仔细鉴别 方太今年推出了主打磁化恒温系列的热水器产品,这款热水器在使用体验方面进行了全面升级:速热,进入洗浴模式迅速响应,水温和出水量都达到了稳定且持久的状态 水流经过水量伺服技术精确控制,确保了出水温度误差在±范围内,满足了敏感肌肤使用者的特殊需求 方太热水器还配备了CO和CH气体报警装置,这是市场上少见的安全设备,大大提升了使用安全性能 而其智能WIFI互联功能更是贴心设计,只需下载手机APP,就可以通过远程控制调节水温,适用于家中不同成员的洗浴需求 方太这款磁化恒温系列热水器除了提供快速热水外,还专门针对水质问题进行优化,增加了磁化功能,能有效吸附水中的铁锈、铁屑等杂质,并且能够防止细菌滋生,改善水质,保持浴室环境更洁净。长期使用还可促进身体健康。
参考答案 a : 方太
当前的阅读理解任务已在多个产业领域广泛应用,例如:智能客服中,通过使用机器来解读用户手册和其他内容材料,可以自动或协助客服解答用户问题;教育方面,利用这项技术可以从大规模试题库中辅助出题;在金融行业,该技术能够从海量新闻文本中提取相关的金融信息。
注:用BERT微调来解决机器阅读理解问题已经成为NLP的主流思路,本文的实验都是基于bert进行阅读理解任务。
1.2.2 BERT介绍
,Google AI研究院提出了BERT(Bidirectional Encoder Representation from Transformers),一个预训练模型。在机器阅读理解领域,SQuAD试中,BERT的表现尤为突出:全面超越人类表现,并且创出多项NLP基准测试的SOTA成绩。具体而言,在GLUE基准测试中,BERT将准确度推高至(绝对改进),在MultiNLI测试中的准确度达到(绝对改进)。BERT成为NLP领域的里程碑模型,标志着该领域的新时代。
BERT的网络架构采用了Attention is all you need提出的多层Transformer模型,如图示。该架构最大的特点就是抛弃了传统的RNN和CNN,通过Attention机制将任意位置的两个单词的距离转换为有效解决了NLP中的长期依赖问题。此外,该结构已经广泛应用于自然语言处理(NLP)和计算机视觉(CV)等领域。
1.2.3 BERT的预训练任务
- BERT是个多任务模型,其预训练由两部分组成,分别是基于自监督的任务,包括MLM和NSP。

首先,MLM代表的是在训练过程中,随机从输入序列中截取单词进行mask化处理。这种设计思路与中学时期完成形填空的题目类似。 传统语言模型算法和RNN的匹配特性,使得MLM的任务性质与其结构相吻合。BERT实验显示,在的情况下,的WordPiece Token会被随机mask掉。例如,在句子“我爱我家”中,“我”被mask化后变为“我 [MASK]”。 在训练过程中,BERT模型将一个句子多次输入到自己的参数学习中,并且Google的做法是,在确定了要mask掉的单词之后,的情况下直接替换为[Mask]符号。这样做的目的是为了提高模型对未知单词(即被mask化但不完全被预测)的理解和适应能力。 此外,BERT还采用了预训练的方式,通过大量的无监督学习任务来捕捉语言模式,并且利用了上下文信息来进行mask化的替代。这些设计使得BERT在语义理解、对话生成等领域取得了显著的成果。
10%的时候将其替换为其它任意单词,将单词 "family" 替换成另一个随机词,例如 "cat"。将句子 "I love my family" 转换为句子 "I love my cat"。
- 的时候会保留原始Token,例如保持句子为 "I love my family" 不变。
下一句话预测(NSP)的任务是判断句子B是否为句子A的下文。若符合,则输出’IsNext‘;否则,输出’NotNext‘。数据生成方式是从平行语料随机抽取连续两句话,其中的第二句话与第一句形成IsNext关系,而另外的第二句话则是从预料中随机提取,其关系为NotNext。此特性通过图的[CLS]符号进行标记。
输入 = [CLS] 我 喜欢 [Mask] 学习 [SEP] 我 最 擅长 的 [Mask] 是 NLP [SEP] 类别 = IsNext
输入 = [CLS] 我 喜欢 [Mask] 学习 [SEP] 今天 我 跟 别人 [Mask] 了 [SEP] 类别 = NotNext
1.2.4 BERT的微调
在经过大量语料训练后,BERT便能够应用于NLP领域的多种任务进行微调。微调(Fine-Tuning)涵盖了一系列任务:包括基于句子对的分类、单个句子的分类、问答任务以及命名实体识别等。下面详细阐述这些微调任务的具体操作方法与步骤。
- 基于句子对的分类任务
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(无命名实体)。
1.3 实验环境
建议您使用AI Studio进行操作。
1.4 实验设计
本实验开发了一款基于BERT的阅读理解系统,方法图示,其核心为BERT。
训练阶段:BERT模型的输入是Question(问题)和Paragraph(文章),输出则是答案的位置。
推理阶段:采用已训练的抽取式阅读理解模型,输入问题与文章,模型定位答案所在位置,并从文本中提取对应段落内容。

在图中可见,进行QA任务时,输入包括两个句子:使用[SEP]符号分隔。其中,首个句子是问题(Question),第二个句子则是包含答案的段落(Paragraph)。输出则为答案部分的起始和结束可能性(Start/End Span)。
2. 实验详细实现
机器阅读理解实验流程如 图6 所示,包含如下6个步骤:
数据处理:根据网络接收的数据格式,完成相应的预处理操作,确保模型能够正常读取。模型构建:采用BERT网络结构。训练配置:实例化模型,并加载参数,指定优化算法(如Adam)作为寻解器。模型训练:执行多轮训练,不断调整参数以达到最佳效果。模型保存:将训练好的模型参数存储在指定位置,方便后续推理或继续训练使用。模型评估:对训练后的模型进行准确率和损失值的评估测试。
2.1 数据处理
2.1.1 数据集介绍
PaddleNLP已集成多种中英文阅读理解数据集,包括SQuAD和CMRC,只需轻触一下即可轻松加载。本例使用了DuReaderRobust中文阅读理解数据集,并且该数据集采用了SQuAD格式。
DuReaderRobust数据集的格式如下
非洲气候带非洲是一个热带大陆,其地理位置和地理特点赋予了它独特的气候特征。非洲的气候呈现明显的带状分布,南北对称,主要由热带雨林、热带草原、热带沙漠以及地中海式气候构成。赤道穿过非洲大陆中部,划分出了南北两个半球。这使得整个非洲大陆被赤道大致均分成了两个部分。因此,在纬度地带性上,非洲的气候非常明显。以热带雨林为中心的气候带向南北依次分布着热带草原、热带沙漠和地中海式气候。 热带雨林:面积较小,主要位于刚果河流域,分布在赤道附近。 热带草原:有明显的干湿季特征,夏季炎热而干旱,冬季温暖而湿润。位于非洲大陆的北部边缘。 热带沙漠:主要分布在撒哈拉大沙漠和西南角狭长地带,全年炎热干燥,日照时间长、昼夜温差大。 地中海式气候:位于非洲大陆的南北边缘,面积较小。整个非洲处于赤道附近,气温高,而且没有温带或寒带。由于赤道穿过大陆中部,使非洲的气候具有明显的纬度地带性特点。虽然炎热干燥是大部分地区的特征,但湿润地区仍然存在。 韩国全称韩国位于朝鲜半岛南部,与朝鲜民主主义人民共和国(朝中社)相邻。面积为平方千米,南北长度约里,东西宽度约里。东濒日本海,西临黄海,东南与日本隔海相望。韩国地形以山地为主,平原较少,海岸线长而曲折。四季分明,气候温和、湿润。目前,韩国的主要政党包括执政的新千年民主党和在野的大国家党等。大国家党是韩国国会的第一大党。韩国的首都为汉城(首尔),全国设有特别市(汉城市)、广域市(釜山市、大邱市、仁川市、光州市、大田市、蔚山市)和道(京畿道、江源道、忠清北道、忠清南道、全罗北道、全罗南道、庆尚北道、庆尚南道、济州道)。海岸线全长里,主要港口有釜山、仁川、浦项、蔚山和光阳等。总之,非洲的气候带由热带雨林为中心的热带大陆向南北两侧依次分布着热带草原、热带沙漠和地中海式气候。非洲的地理位置和赤道位置使得其气温高且干燥地区广,而湿润地区的面积相对较小。
说明:
随着技术的进步,数据存储模式也在不断演进。如今,我们经常接触到一种名为JSON的数据结构,它以json格式存储,其中最顶层是data。随后是标题(title)和段落(paragraphs),而每一部分内部还包含多个问答对(qas)。每个问答对由问题(question)、唯一标识符(id)以及与之对应的上下文信息组成,这些信息之间的关系类似于人和他的回忆。例如,如果想找到某个特定问题的答案,只需在context中查找相应的片段即可。具体来说,答案的起始位置用answer_start表示,而答案本身则由text字段提供。
2.1.2 数据预处理
首先,确保你的PaddleNLP已更新至最新版本,必要时还需其辅助。接着,创建并使用专门处理BERT输入文本的BertTokenizer,以便通过此工具对所加载的文本进行有效处理。
aistudio默认的paddlenlp过旧,所以这里手动升级paddlenlp版本,保持paddlenlp为最新版本 In []
!pip install paddlenlp --upgrade登录后复制
加载paddle的库和第三方的库 In []
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进行初始化。此实验中使用了基本分词器、字串件化器,并详解其功能。
基本tokenizer的主要作用是进行unicode转换、标点符号分割、小写转换、中文字符分割以及去除重音符号等操作,并最终返回词(或汉字)组成的数组。WordpieceTokenizer则是一种特殊的分词技术,通过将合成词分解为类似词根的词片形式,旨在减少因词汇过时而无法在字典中找到导致的词库缺失问题。例如,“unwanted”被分解为[un, want, ed],这样的处理方式可以有效地应对英语中大量合成词的情况。预训练tokenizer则用于从本地文件或目录加载和保存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
2.1.3 批量数据读取
训练集合处理
将使用load_datasetAPI加载的数据集转换为MapDataset对象,MapDataset是Paddle.io.Dataset功能的扩展版。适用于处理批量数据集,并通过map方法调用函数对数据进行预处理。
由于文本长度可能超过max_seq_length限制,答案出现在文章末尾,直接截断无法处理。因此,我们将过长的文章分成多个短段落,并将每个段落在tokenizer中转换为模型可以接受的格式。滑动窗口的距离由doc_stride参数决定,这直接影响了样本生成的过程。如图示:

from paddlenlp.datasets import load_dataset doc_stride=128 # 滑动窗口的大小max_seq_length=384 # 分词后的最大长度task_name='dureader_robust'登录后复制 In []
在准备训练特征时,首先需要获取输入的上下文和问题。然后,对每个样本进行处理,将不可能的答案用CLS字符来索引,并使用offset mapping计算开始位置和结束位置。接着,根据这些信息构建序列ID、token_type_ids等特征。接下来,在每个样本中抓取对应的位置信息(即样本的索引)。对于每个样本,找到答案在文本中的起始和结束字符的索引。如果答案超过了当前span,则将答案的位置用CLS的位置替代。然后,从答案的起始位置开始到结束进行token搜索,并调整token_start_index和token_end_index。最终,返回这些特征。
读取dureaderrobust的训练集合,把json数据处理成list的形式,list里面每一项以键值对的形式存放。 In []
train_ds = load_dataset(task_name, splits='train')print(train_ds[:2])登录后复制
输出了模型的2条数据,用list格式保存,list的每一项都是一个字典,键值对形式。 In []
train_ds.map(prepare_train_features, batched=True)登录后复制 In []
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()登录后复制
结果表明,示例已转换为模型可接受的形式,包含输入IDs、类型ID、答案起始位置及其它相关信息。
输入的序列ID表示了用户输入的内容,而token_type_ids则帮助我们识别哪些是问题部分,哪些是答案部分。Transformer类预训练模型支持处理单个句子或短句对输入,这需要通过溢出到样本(overflow_to_sample)来指定。对于每个token的位置,offset_mapping提供了原输入文本中对应的起始和结束字符位置信息。而start_positions和end_positions分别表示答案在原始输入中的确切开始和结束位置。
测试集合处理
测试集合的生成跟训练集合类似 In []
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里面每一项以键值对的形式存放。 In []
dev_ds = load_dataset(task_name, splits='dev')print(dev_ds[:2])登录后复制 In []
dev_ds.map(prepare_validation_features, batched=True)登录后复制
2.2 模型构建
- 阅读理解实质上是答案提取的任务,PaddleNLP已将各类预训练模型整合至下游任务中,如答案抽取。
答案抽取的任务是基于问题与文档的对比,预测答案的具体位置范围。

机器阅读理解模型包括了BertEmbeddings、TransformerEncoderLayer、BertPooler和FC层等重要组成部分,其中TransformerEncoderLayer是已有的实现,因此重点介绍其余部分。
BertEmbeddings代表的是BERT模型中的嵌入层,它包含了三个部分:词嵌入(word embedding)、位置嵌入(position embedding)和类型嵌入(token_type embedding)。TransformerEncoderLayer则是用来进行编码的组件,这部分已经被Paddle实现了。这个结构主要由两个子层组成:多头自注意力机制(multi-head self-attention mechanism)和前馈神经网络(feedforward neural network)。BERT的核心就是基于这些编码器层构建的Transformer模型。BertPooler层接收的是Transformer的最后一层输出,并从每一句中提取第一个单词,经过全连接和激活处理后作为输入,这在某些下游任务上是常见的做法。
2.2.1 BertEmbeddings实现
词嵌入张量(Token Embedding):通过训练学习得到的序列中的每个标记(token)的表示形式,例如“[CLS] dog”等。语句分块张量(Sentence Type Embedding / Segment Embedding):用于区分每一个单词属于句子A还是B。如果只输入一个句子,则仅使用EA,通过训练学习得到。BERT 中的位置编码张量(Position Embedding):编码单词出现的位置,并与Transformer 使用固定的公式计算不同,在BERT中假设最长的句子长度为最终的embedding向量是将上述向量直接加和的结果。

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 embeddings登录后复制
2.2.2 BertPooler实现
BertPooler: 只取每个序列的第一个token,使输入大小变为[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_output登录后复制
2.2.3 BertModel的实现
PretrainedModel:负责存储模型配置,处理加载、下载和保存方法,并提供通用模型方法:(i)调整输入嵌入大小,(ii)修剪自我注意力头。BERT-Base-Chinese预训练模型各参数的意义。
bert-base-chinese: { vocab_size: #词典中词数 hidden_size: #隐藏单元数 num_hidden_layers: #隐藏层数 num_attention_heads: #每个隐藏层中的attention head数 intermediate_size: #升维维度 hidden_act: gelu, #激活函数 hidden_dropout_prob: #隐藏层dropout概率 attention_probs_dropout_prob: #乘法attention时,softmax后dropout概率 max_position_embeddings: # 一个大于seq_length的参数,用于生成position_embedding type_vocab_size: #segment_ids类别 [ initializer_range: #初始化范围 pad_token_id: #对齐的值 }
from paddlenlp.transformers import PretrainedModel登录后复制
BERT预训练模型:BERTPretrainedModel是一个泛化类,提供Bert相关的配置选项,如model_config_file、resource_files_names等,以加载和下载预训练模型。
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-12登录后复制 In []
from paddlenlp.transformers import register_base_model登录后复制
`register_base_model`: 基础模型类的修饰者为包含“base_model_class”属性,同一架构下派生类表示基础模型类。在Bert预训练模型中应用此功能。具体实现由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_output登录后复制
2.2.4 BertForQuestionAnswering的实现
在使用 BertForQuestionAnswering 时,需先继承 BertPretrainedModel,并实例化BERT模型。接着,在该模型后添加一隐藏层,输入数量为其中第一项用于表示答案起点,第二项则表示终点。此结构旨在准确捕捉回答位置并将其转化为预测值。
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登录后复制
2.2.5 模型的损失函数设计
由于BertForQuestionAnswering模型将BertModel的sequence_output拆分为start_logits和end_logits输出,因此阅读理解任务的loss由start_loss和end_loss组成,需自定义损失函数。答案位置与结束位置预测应视为两个分类任务,设计的损失函数如下:In [ ]:

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 loss登录后复制
2.2.6 LinearDecayWithWarmup的实现
线性衰减和热启动:创造一个学习速率分配程序,以线性方式提升速率,并在一定时期后从基本学习速率缓慢降至零。
# 判断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)登录后复制
2.3 训练配置
训练配置包括实例化BertForQuestionAnswering,损失函数,优化器参数设置,优化器,数据加载等。 实例化BertForQuestionAnswering模型 In []
# 设置GPU模式paddle.set_device("gpu")# 实例化BertForQuestionAnswering模型model = BertForQuestionAnswering.from_pretrained(model_name_or_path)登录后复制 实例化损失函数 In []
criterion = CrossEntropyLossForSQuAD()登录后复制 模型超参数设置
- 需要设置batch_size的大小,训练的轮次num_train_epochs,设置优化器的参数learning_rate,weight_decayadam_epsilon这些。
warmup_proportion 表示慢热学习的比例。例如,warmup_proportion=在前的步长中,LR从性增加到 init_learning_rate,这个阶段被称为 warmup,然后 LR又从 init_learning_rate 线性衰减至完成所有步长)。这种方法可以避免较早对 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登录后复制
加载数据 In []
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)登录后复制
定义优化器 AdamW广泛应用于Hugging Face版本的Transformer模型,如BERT、XLNet、ELECTRA等,此优化器详情可参照链接AdamW
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)登录后复制
2.4 模型训练
模型训练的过程通常有以下步骤:
从前向模型到后向优化: DataLoader加载一批数据; Model进行前向传播计算; 损失函数评估误差; 通过梯度反向传播调整参数; 重复此过程直至收敛。
In []

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()登录后复制
2.5 模型保存
训练完成后,可以将模型参数保存到磁盘,用于模型推理或继续训练。 In []
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)登录后复制
2.6 模型评估

每训练一个epoch时,程序通过evaluate调用paddlenlp.metrics.squad中的squad_evaluate, compute_prediction评估当前模型训练的效果。compute_prediction用于生成模型的预测结果;而squad_evaluate则负责返回评价指标。模型的评估采用ROUGE-L进行,其中的L是指最长公共子序列(longest common subsequence, LCS),计算公式如下:\[ ROUGE-L(C,S) = \frac{{|C|} \sum_{i=^{|C|} LCS(C_i, S_i) / LCS(C, S) \]其中,\( C \) 是机器答案的集合,而 \( S \) 是参考答案的集合。这个公式用于计算两个文本之间的相似度,从而评估模型性能。

举个例子:
C : 热带.
S :热带气候.
In []

from paddlenlp.metrics.squad import squad_evaluate, compute_prediction登录后复制 In []
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)登录后复制 In []
@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()登录后复制 In []
evaluate(model, dev_data_loader)登录后复制
总共评估的样本数为对应上述的total和HasAns_total),每样本评估的时间为,这些结果表明FHasAns_f值分别为由于这些计算方法完全相同,因此exact和HasAns_exact的结果也是一样的,这说明它们的评估方式是基于相同的逻辑。
3. 实验总结
在这篇文章中,我们选择了BERT问答模型,在DuReader Robust数据集上实现了阅读理解能力的提升。通过这次实验,同学们不仅复习了人工智能导论课程中的相关知识点,还学习了如何使用飞桨深度学习框架来实现机器阅读理解的任务。在此基础上,您可以尝试开发自己感兴趣的研究任务。
以上就是基于BERT实现机器阅读理解的详细内容,更多请关注其它相关文章!
