原来你是这样的BERT,i了i了! —— 超详细BERT介绍(二)BERT预训练

BERTBidirectional Encoder Representations from Transformers)是谷歌在2018年10月推出的深度语言表示模型。

一经推出便席卷整个NLP领域,带来了革命性的进步。
从此,无数英雄好汉竞相投身于这场追剧(芝麻街)运动。
只听得这边G家110亿,那边M家又1750亿,真是好不热闹!

然而大家真的了解BERT的具体构造,以及使用细节吗?
本文就带大家来细品一下。


前言

本系列文章分成三篇介绍BERT,上一篇介绍了BERT主模型的结构及其组件相关,本篇则主要介绍BERT预训练相关知识,其后还会有一篇介绍如何将BERT应用到不同的下游任务

文章中的一些缩写:NLP(natural language processing)自然语言处理;CV(computer vision)计算机视觉;DL(deep learning)深度学习;NLP&DL 自然语言处理和深度学习的交叉领域;CV&DL 计算机视觉和深度学习的交叉领域。

文章公式中的向量均为行向量,矩阵或张量的形状均按照PyTorch的方式描述。
向量、矩阵或张量后的括号表示其形状。

本系列文章的代码均是基于transformers库(v2.11.0)的代码(基于Python语言、PyTorch框架)。
为便于理解,简化了原代码中不必要的部分,并保持主要功能等价。

阅读本系列文章需要一些背景知识,包括Word2VecLSTMTransformer-BaseELMoGPT等,由于本文不想过于冗长(其实是懒),以及相信来看本文的读者们也都是冲着BERT来的,所以这部分内容还请读者们自行学习。
本文假设读者们均已有相关背景知识。


目录


2、预训练

BERT的预训练是一大特色,BERT经过预训练后,只需要在下游任务的数据集上进行少数几轮(epoch)的监督学习(supervised learning),就可以大幅度提升下游任务的精度。
另外,BERT的预训练是通过无监督学习(unsupervised learning)实现的。
预训练所使用的无监督数据集往往非常大,而下游任务的监督数据集则可以很小。
由于网络上文本数据非常多,所以获取大规模无监督的文本数据集是相对容易的。

BERT在预训练时学习两种任务:遮盖的语言模型(masked language model, MLM)、下一句预测(next sentence prediction,NSP)。

  • 遮盖的语言模型:在输入的序列中随机把原标记替换成[MASK]标记,然后用主模型输出的标记表示来预测所有原标记,即学习标记的概率分布。
  • 下一句预测:训练数据随机取同一篇文章中连续两句话,或分别来自不同文章的两句话,用序列表示来预测是否是连续的两句话(二分类)。

下面先来讲解一下BERT用到的损失函数,然后再讲解以上两个学习任务。


2.1、损失函数

BERT中主要使用到了回归(regression)和分类(classification)损失函数。
回归任务常用均方误差(mean square error,MSE)作为损失函数,而分类任务一般用交叉熵(cross entropy,CE)作为损失函数。


2.1.1、均方误差损失函数

均方误差可以度量连续的预测值和真实值之间的差异。
假设\(\hat{y_i}\)是预测值,\(y_i\)是真实值,\(i = 0, 1, ..., (N-1)\)是样本的编号,总共有\(N\)个样本,那么损失\(loss\)为:

\[loss = \frac{1}{N} \sum_{i=0}^{N-1} (\hat{y_i} - y_i)^2 \]

有时为了方便求导,会对这个\(loss\)除以\(2\)
由于学习任务是让\(loss\)最小化,给\(loss\)乘以一个常量对学习任务是没有影响的。


2.1.2、交叉熵损失函数

交叉熵理解起来略有些复杂。


2.1.2.1、熵

首先来看看什么是(entropy)。
熵是用来衡量随机变量的不确定性的,熵越大,不确定性越大,就需要越多的信息来消除不确定性。
比如小明考试打小抄,对于答案简短的题目,只需要简单做个标记,而像背古诗这种的,就需要更多字来记录了。

假设离散随机变量\(X \sim P\),则

\[-log(P(x)) \]

称为\(x\)\(X\)的某个取值)的信息量,单位是奈特(nat)或比特(bit),取决于对数是以\(e\)还是以\(2\)为底的。

\[H(X) = -\sum_x P(x) log(P(x)) \]

\(H(X)\)\(X\)\(X\)的所有取值)的信息量的期望,即熵,单位同上。

另外,由于熵和概率分布有关,所以很多时候写作某个概率分布的熵,而不是某个随机变量的熵。


2.1.2.2、KL散度

KL散度(Kullback-Leibler divergence),也叫相对熵(relative entropy)。

假设有概率分布\(P\)\(Q\),一般分别表示真实分布和预测分布,KL散度可以用来衡量两个分布的差异。
KL散度定义为:

\[D_{KL}(P||Q) = \sum_x P(x) log(\frac{P(x)}{Q(x)}) \]

将公式稍加修改:

\[D_{KL}(P||Q) = \sum_x P(x) ((-log(Q(x))) - (-log(P(x)))) \]

可以看出,KL散度实际上是用概率分布\(P\)来计算\(Q\)\(P\)的信息量差的期望。
如果\(P = Q\),那么\(D_{KL}(P||Q) = 0\)
另外可以证明,\(D_{KL}(P||Q) \ge 0\)总是成立,本文证明略。

如果把上式拆开,就是

\[D_{KL}(P||Q) = H(P, Q) - H(P) \]

其中,\(H(P, Q)\)称为\(Q\)\(P\)交叉熵\(H(P)\)\(P\)的熵。


2.1.2.3、交叉熵

由于KL散度描述了两个分布信息量差的期望,所以可以通过最小化KL散度来使得两个分布接近。
而在一些学习任务中,比如分类任务,真实分布是固定的,即\(H(P)\)是固定的,所以最小化KL散度等价于最小化交叉熵。

交叉熵公式为:

\[H(P, Q) = -\sum_x P(x) log(Q(x)) \]


单标签分类任务中,假设\(\hat{y}_{ik} \in [0, 1]\)\(y_{ik} \in \{0, 1\}\)分别是第\(i = 0, 1, ..., (N-1)\)个样本第\(k = 0, 1, ..., (K-1)\)类的预测值和真实值,其中\(\sum_{k=0}^{K-1} \hat{y}_{ik} = \sum_{k=0}^{K-1} y_{ik} = 1\)\(N\)是样本数量,\(K\)是类别数量,则损失\(loss\)为:

\[loss = -\frac{1}{N} \sum_{i=0}^{N-1} \sum_{k=0}^{K-1} y_{ik} log(\hat{y}_{ik}) \]


损失函数代码如下:

代码
# 损失函数之回归
class LossRgrs(nn.Module):
	def __init__(self, *args, **kwargs):
		super().__init__()
		# 均方误差损失函数
		self.loss_fct = nn.MSELoss(*args, **kwargs)
	def forward(self, logits, labels):
		return self.loss_fct(logits.view(-1), labels.view(-1))
# 损失函数之分类
class LossCls(nn.Module):
	def __init__(self, num_classes, *args, **kwargs):
		super().__init__()
		# 标签的类别数量
		self.num_classes = num_classes
		# 交叉熵损失函数
		self.loss_fct = nn.CrossEntropyLoss(*args, **kwargs)
	def forward(self, logits, labels):
		return self.loss_fct(logits.view(-1, self.num_classes), labels.view(-1))
# 损失函数之回归或分类
class LossRgrsCls(nn.Module):
	def __init__(self, num_classes, *args, **kwargs):
		super().__init__()
		self.loss_fct = (LossRgrs(*args, **kwargs) if not num_classes<=1
			else LossCls(num_classes, *args, **kwargs))
	def forward(self, logits, labels):
		return self.loss_fct(logits, labels)

其中,
num_classes是标签的类别数量,BERT应用到序列分类任务时,如果类别数量=1则为回归任务,否则为分类任务。

注意:无论是回归还是分类任务,模型输出表示后,都需要将表示转化成预测值,一般是在模型最后通过一个线性回归或分类器(其实也是一个线性变换)来实现,回归任务得到的就是预测值,然后直接输入MSE损失函数计算损失就可以了;而分类任务得到的是对数几率(logit),还要用softmax函数转化成概率,再通过CE损失函数计算损失,而PyTorch中softmax和CE封装在一起了,所以直接输入对数几率就可以了。


2.2、遮盖的语言模型

语言模型(language model,LM)是对语言(字符串)进行数学建模(表示)。
传统的语言模型包括离散的和连续的(分布式的),离散的最经典的是词袋(bag of words,BOW)模型和N元文法(N-gram)模型,连续的包括Word2Vec等,这些本文就不细说了。

然而到了DL时代,语言模型就是想个办法让神经网络学习序列的概率分布。
MLM采用了降噪自编码器(denoising autoencoder,DAE)的思想,简单来说,就是在输入数据中加噪声,输入神经网络后再让神经网络恢复出原本无噪声的数据,从而让模型学习到了联想能力,即输入数据的概率分布。

具体来说,MLM将序列中的标记随机替换成[MASK]标记,例如

I ' m repair ##ing immortal ##s .

这句话,修改成

I ' m repair [MASK] immortal ##s .

如果模型可以成功预测出[MASK]对应的原标记是##ing,那么就可以认为模型学到了现在进行时要加ing的知识。

另外在计算损失的时候,是所有标记都要参与计算的。


2.3、下一句预测

NSP是为了让模型学会表示句子连贯性等较为深层次的语言特征而设计的。

具体来说,首先看如下例子(来自transformers库的示例):

This text is included to make sure ...
Text should be one-sentence-per-line ...
This sample text is public domain ...

The rain had only ceased with the gray streaks ...
Indeed, it was recorded in Blazing Star that ...
Possibly this may have been the reason ...
"Cass" Beard had risen early that morning ...
A leak in his cabin roof ...

The fountain of classic wisdom ...
As the ancient sage ...
From my youth I felt in me a soul ...
She revealed to me the glorious fact ...
A fallen star, I am, sir ...

其中,每一行都是一个句子(原句子太长,所以省略了一部分),不同的文章用空行来隔开。
如果选择来自同一篇文章连续的两个句子:

This text is included to make sure ... ||| Text should be one-sentence-per-line ...

则NSP的标签为1;如果选择来自不同文章的两个句子:

This text is included to make sure ... ||| The fountain of classic wisdom ...

则NSP的标签为0。


另外无论是MLM还是NSP,BERT预训练的数据是在训练之前静态生成好的。

预训练代码如下:

代码
# BERT之预训练
class BertForPreTrain(BertPreTrainedModel):
	# noinspection PyUnresolvedReferences
	def __init__(self, config):
		super().__init__(config)
		self.config = config

		# 主模型
		self.bert = BertModel(config)
		self.linear = nn.Linear(config.hidden_size, config.hidden_size)
		self.act_fct = F.gelu
		self.layer_norm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)
		# 标记线性分类器
		self.cls = nn.Linear(config.hidden_size, config.vocab_size)
		# 句子关系线性分类器
		self.nsp_cls = nn.Linear(config.hidden_size, 2)
		# 标记分类损失函数
		self.loss_fct = LossCls(config.vocab_size)
		# 句子关系分类损失函数
		self.nsp_loss_fct = LossCls(2)

		self.init_weights()

	def get_output_embeddings(self):
		return self.cls

	def forward(self,
			tok_ids,  # 标记编码(batch_size * seq_length)
			pos_ids=None,  # 位置编码(batch_size * seq_length)
			sent_pos_ids=None,  # 句子位置编码(batch_size * seq_length)
			att_masks=None,  # 注意力掩码(batch_size * seq_length)
			mlm_labels=None,  # MLM标记标签(batch_size * seq_length)
			nsp_labels=None,  # NSP句子关系标签(batch_size)
	):
		outputs, pooled_outputs = self.bert(
			tok_ids,
			pos_ids=pos_ids,
			sent_pos_ids=sent_pos_ids,
			att_masks=att_masks,
		)

		outputs = self.linear(outputs)
		outputs = self.act_fct(outputs)
		outputs = self.layer_norm(outputs)
		logits = self.cls(outputs)
		nsp_logits = self.nsp_cls(pooled_outputs)

		if mlm_labels is None and nsp_labels is None:
			return (
				logits,  # 标记对数几率(batch_size * seq_length * vocab_size)
				nsp_logits,  # 句子关系对数几率(batch_size * 2)
			)

		loss = 0
		if mlm_labels is not None:
			loss = loss + self.loss_fct(logits, mlm_labels)
		if nsp_labels is not None:
			loss = loss + self.nsp_loss_fct(nsp_logits, nsp_labels)
		return loss

后记

本文详细地介绍了BERT预训练,BERT预训练是BERT有出色性能的关键,其中所使用的学习任务也是BERT的一大亮点。
后续一篇文章会介绍BERT下游任务相关。

从BERT预训练的实现中可以发现,BERT巧妙地充分利用了主模型输出的标记表示和序列表示,并分别学习标记分布概率和句子连贯性,并且运用了DAE的思想,以及两种学习任务都可以通过无监督的方式实现。

然而后续的一些研究也对BERT提出了批评,例如采用MLM学习,预训练时训练数据中有[MASK]标记,而微调时没有这个标记,这就导致预训练和微调的数据分布不一致;NSP并不能使模型学习到句子连贯性特征,因为来自不同文章的句子可能主题(topic)不一样,NSP最终可能只学习了主题特征,而主题特征是文本中的浅层次特征,应该改为来自同一篇文章连续的或不连续的两句话作为改良版的NSP训练数据;MLM是一种生成式任务,生成式任务也可以看成分类任务,只不过一个类是词汇表里的一个标记,词汇表往往比较大,所以类别往往很多,计算损失之前要将表示转化成长度为词汇表长度的对数几率向量,这个计算量是比较大的,如果模型又很大,那么整个学习任务对算力要求就会很高;BERT在计算每个标记的标签时,是独立计算的,即认为标记之间的标签是相互独立的,这往往不符合实际,所以其实BERT对标记分类(序列标注)任务的效果不是非常好。


 posted on 2020-06-21 13:22  wangzb96  阅读(6069)  评论(1编辑  收藏  举报