BERT 出来之后,预训练模型成了 NLP 的主流做法。但它有个别扭的地方,预训练时把一部分词换成 [MASK] 让模型猜,可真正微调、上线的时候,输入里根本没有 [MASK],训练和实际使用对不上。XLNet 是 BERT 之后最有代表性的回应之一,它想证明不靠 [MASK] 也能让模型双向看上下文。这个思路后来影响了不少模型,哪怕今天很多任务已经用上更新的模型,它留下的两套思想仍然值得弄懂。
接下来,我们先讲 BERT 的 [MASK] 缺陷到底出在哪,再拆开 XLNet 的两套关键机制,排列语言模型和双流自注意力,最后说怎么加载现成的预训练模型来用。读这篇之前,你需要知道 BERT 的大致原理,明白自注意力是怎么看上下文的。如果这两个概念还不太熟,建议先补一下 BERT 和自注意力的知识再回来。
1. 问题由来
我们先看 BERT 埋下的问题。BERT 预训练时,会随机把一部分词替换成 [MASK] 占位符,让模型根据上下文猜被遮住的词。可真正微调、上线的时候,输入里根本没有 [MASK],模型在预训练里学的技能到了实际场景派不上用场。训练和实际使用对不上,这个缺陷叫预训练与微调不一致,会影响下游任务的效果。另外 BERT 还受输入长度限制,长文本只能截断。
XLNet 是 Google Brain 与 CMU 联合提出的自回归语言模型,目标很明确,去掉 [MASK],又保留双向上下文理解,还能处理更长文本。它的解法靠两套机制加一个借用:排列语言模型负责实现双向,双流自注意力负责堵住偷看,长文本能力则借自 Transformer-XL 的段循环。下面我们一节一节拆开讲。
2. 排列语言模型
双向到底从哪来?我们先把两种语言模型的分工讲清楚,你才知道 XLNet 为什么非要两头都要。看一个预测的例子,要预测今天天气很好这句话里的很字。自回归模型(AR)的习惯是从左往右顺着读,它只能看到今天天气,后面的很好还没生成,它够不着,这就是单向。而 BERT 这种自编码模型(AE)是做完形填空,左右两边都能看,前后的字都能当线索。这两种做法各有各的好处。自回归的形态下,训练时怎么预测、用的时候就怎么预测,完全一致,没有 [MASK] 那种预训练和实际使用对不上的别扭,天生适合写句子、做翻译这类生成任务。自编码能双向看上下文,理解类任务效果更好。XLNet 想两头都要,保持自回归顺着预测的形态,同时通过打乱预测顺序拿到双向的视野。这样既绕开了 [MASK],生成和理解两类任务又都能接住,这就是它值得做整套复杂设计的原因。
2.1 打乱预测顺序
自回归模型天生只能看上文,怎么让它也能看到下文呢?XLNet 的办法是打乱预测顺序。排列语言模型(PLM)并不真的重排文字,只是随机决定谁先被预测、谁后被预测。对输入序列 [x1, x2, x3, x4],可以有很多种预测顺序,比如排成 x2、x4、x3、x1,轮到预测 x3 时,它前面既有 x2 这个上文,又有 x4 这个下文,自回归模型就这样用上了两边的信息。
我们拿中文句子举例。把我爱北京天安门拆开看,BERT 的做法是改成我爱[MASK]京天[MASK]门,让模型猜被遮住的字。XLNet 换个思路,重排成我爱京天门北安,预测北字时已经能看到我爱京天门,效果类似,却完全不需要 [MASK]。
2.2 文字掩码
这里有个关键点,XLNet 并不真的重排输入文字,而是通过注意力掩码来实现排列。选定预测顺序后,每种排列对应一个掩码矩阵,矩阵决定每个 token 能看哪些位置。我们以 3、2、4、1 的预测顺序为例,可见关系如下表:
| 预测顺序 | 被预测词 | 能看到的词 |
|---|---|---|
| 第 1 位 | x3 | 只有 x3 |
| 第 2 位 | x2 | x2、x3 |
| 第 3 位 | x4 | x2、x3、x4 |
| 第 4 位 | x1 | x1、x2、x3、x4 |
你看,先预测的 x3 只能看自己,最后预测的 x1 能看到全部。训练时每次随机采样一种排列,模型见过各种顺序,学出来的表示就带上了双向信息。
3. 双流自注意力
排列顺序解决了双向的问题,却带出一个新麻烦。输入文字是原样摆着的,每个词的内容都明明白白在那里。轮到预测 x3 的时候,模型如果直接读了 x3 自己,等于考试抄答案,训练就失去了意义。怎么既用上其他词的信息,又不看答案呢?XLNet 的办法是给每个词准备两个表示,一个负责记内容,一个负责猜答案,合起来叫双流自注意力。
3.1 内容查询流
模型给每个词维护两个表示。一个叫内容流,记作 h,负责记这个词本身,走标准的自注意力,能看到包括自己在内的所有位置,相当于一本参考书,词的内容都在里面。一个叫查询流,记作 g,负责猜,它做预测的时候把自己位置的内容遮掉,只能看其他词,相当于考生,答题时不能翻到自己答案那一页。最终预测用的是查询流的输出,所以模型从头到尾看不到当前位置的答案。轮到预测 x3 时,两个流各自能看到的范围是这样的:
| 轮到预测 x3 | 能看到 | 看不到 |
|---|---|---|
| 内容流 h | x1、x2、x3、x4 的全部内容 | 无 |
| 查询流 g | x1、x2、x4 的内容,以及 x3 的位置 | x3 的内容 |
3.2 双流分工
这两个流具体怎么配合呢?你可以把它想成一次普通的注意力查询。发起查询的是查询流 g,它提供查询向量,相当于在提问,结合能看到的上下文,x3 最可能是哪个词。回答它的是内容流 h,它提供键和值,也就是其他词的表示,x1、x2、x4 的内容都可以用,唯独不含 x3 自己。注意力算出来的结果,就是 x3 在给定上下文下的预测依据。文字本身并没有被打乱,所谓的排列全靠内部的注意力掩码实现,查询流的掩码和内容流的掩码叠加,就还原出任意一种预测顺序。预训练时两个流一起用,微调时只留内容流,因为下游任务要的是理解句子,不是猜词。
4. 训练与使用
机制清楚了,我们说点落地的。先走一遍预训练的完整流程,看模型到底在练什么,再说微调怎么把预训练的成果搬到具体任务上。
4.1 预训练流程
我们拿训练样本今天天气很好来走一遍,四个词记为 x1 今天、x2 天气、x3 很、x4 好。模型先随机抽一个预测顺序,比如这次抽到 2、4、3、1,意思是按天气、好、很、今天的顺序逐个预测。再随机选一个切分点,比如选在 2,表示排在前面的天气、好不预测,直接当已知信息,真正要猜的是后面的很和今天。
轮到预测很的时候,查询流看一眼排列里排在它前面的词,天气和好的内容可以用,今天排在后面还没轮到,内容不能用,很自己的内容当然更不能看。轮到预测今天的时候,天气、好、很的内容都能用了,只有今天的答案要避开。这两次预测的可见范围是这样的:
| 第几次预测 | 要猜的词 | 查询流能看的内容 | 不能看的内容 |
|---|---|---|---|
| 第 1 次 | 很 | 天气、好 | 很(答案)、今天(还没轮到) |
| 第 2 次 | 今天 | 天气、好、很 | 今天(答案) |
每次预测,模型在词表上给出一个概率分布,和真实词对比算出交叉熵损失,再反向传播更新全部参数,一次迭代就完成了。换个句子,或者同一个句子换个排列,再来一遍。
这样练下来,模型到底学到了什么?关键就在排列是随机抽的。同一个句子,这次先猜很,下次可能先猜今天,模型不能指望固定顺序,它必须学会在看不到某个词的情况下,利用任何一组上下文,包括后面的词,把它猜出来。练的次数多了,每个词的表示都融合了两边的信息,双向视野就出来了。整个过程没有 [MASK],猜词的形态和生成任务一致,这正是 XLNet 想要的。
还有个效率上的细节。一次迭代并不预测序列里所有词,为省计算,通常只预测排列靠后的 1/k 个 token,前面的直接当已知输入。这个设计叫 Partial Prediction,是正常做法,不是偷工减料。
4.2 微调方法
预训练让模型学会了通用的语言表示,但它本质是个语言模型,不是分类器。微调就是拿带标签的任务数据,在预训练权重上继续训练,把模型改造成干具体活的样子。我们拿情感分类举例,任务是判断一句评论是正面还是负面。
做法不复杂,模型换成带分类头的版本,其他和普通训练没两样。句子进 tokenizer 转成输入,模型的输出接一个二分类头,用交叉熵算损失,梯度更新参数。代码骨架是这样:
from transformers import XLNetTokenizer, XLNetForSequenceClassification
tokenizer = XLNetTokenizer.from_pretrained('xlnet-base-cased')
model = XLNetForSequenceClassification.from_pretrained('xlnet-base-cased', num_labels=2)
text = '这家餐厅的菜真好吃'
inputs = tokenizer(text, return_tensors='pt')
out = model(**inputs) # out.logits 是正负两个类别的得分
label = out.logits.argmax().item() # 0 负面, 1 正面
# 训练循环:拿标注好的评论和标签,跑几个 epoch
# for epoch in range(3):
# for batch in dataloader:
# inputs = tokenizer(batch['text'], padding=True, truncation=True, return_tensors='pt')
# loss = model(**inputs, labels=batch['label']).loss
# loss.backward()
# optimizer.step()
# optimizer.zero_grad()
这里有三个注意点。第一,前面说过微调时只留内容流,实际操作里体现为模型走普通的前向计算,双流掩码不再参与,取最后一层输出直接接任务头。第二,学习率要调小,预训练权重已经学得很好,步子太大容易把学到的知识冲掉,微调常用的学习率在 2e-5 到 5e-5 这个量级。第三,数据量不用很大,几百到几千条标注数据就够把模型调到任务上,这正体现了预训练加微调这套流程的价值。
5. 边界与误区
下面四个地方容易理解偏,我们逐个澄清。
第一个误区,以为 XLNet 真把句子词序打乱了。其实文字顺序原样不动,排列完全靠注意力掩码实现,别去手动重排输入,那样反而破坏了位置信息。
第二个误区,以为 XLNet 就是 BERT 的改名版。BERT 是带 [MASK] 的自编码思路,XLNet 是不带 [MASK] 的自回归加排列思路,两者训练目标本质不同,XLNet 恰好绕开了预训练与微调不一致的问题。
第三个误区,忽略长文本能力的来源。XLNet 能处理更长文本,靠的是 Transformer-XL 的段循环和相对位置编码,不是排列语言模型,这两件事别混在一起。
第四,适用边界要清楚。XLNet 偏理解类任务,比如分类、问答、情感分析。如今很多场景已被更新的预训练模型取代,学习它主要是为了理解它的思想,怎么在自回归框架里塞进双向视野。
我们回到开头的问题。XLNet 用排列语言模型,在不出现 [MASK] 的前提下,让自回归模型通过注意力掩码获得双向上下文,再用双流自注意力把当前位置的内容挡在预测之外,解决了偷看答案的问题,长文本则借助 Transformer-XL 的段循环。这套组合绕开了 BERT 预训练与微调不一致的缺陷。如今它未必是任务首选,但排列建模和双流设计的思路,值得读完记在心里。原论文见 https://arxiv.org/pdf/1906.08237.pdf




冀公网安备13050302001966号