小样本分类训练模型 PET 和 P-tuning

MLM

MLM,全称 “Masked Language Model”,可以翻译为 “掩码语言模型”,实际上就是一个完形填空任务,随机 Mask 掉文本中的某些字词,然后要模型去预测被 Mask 的字词,示意图如下:

其中被 Mask 掉的部分,可以是直接随机选择的 Token,也可以是随机选择连续的能组成一整个词的 Token,后者称为 WWM(Whole Word Masking)。

开始,MLM 仅被视为 BERT 的一个预训练任务,训练完了就可以扔掉的那种,因此有一些开源的模型干脆没保留 MLM 部分的权重,然而,随着研究的深入,研究人员发现不止 BERT 的 Encoder 很有用,预训练用的 MLM 本身也很有用。

将任务转成完形填空

在本文里,我们再学习 MLM 的一个精彩应用:用于小样本学习或半监督学习,某些场景下甚至能做到零样本学习。

怎么将我们要做的任务跟 MLM 结合起来呢?很简单,给任务一个文本描述,然后转换为完形填空问题即可。举个例子,假如给定句子“这趟北京之旅我感觉很不错。”,那么我们补充个描述,构建如下的完形填空:

______满意。这趟北京之旅我感觉很不错。

进一步地,我们限制空位处只能填一个“很”或“不”,问题就很清晰了,就是要我们根据上下文一致性判断是否满意,如果“很”的概率大于“不”的概率,说明是正面情感倾向,否则就是负面的,这样我们就将情感分类问题转换为一个完形填空问题了,它可以用 MLM 模型给出预测结果,而 MLM 模型的训练可以不需要监督数据,因此理论上这能够实现零样本学习了。

多分类问题也可以做类似转换,比如新闻主题分类,输入句子为“八个月了,终于又能在赛场上看到女排姑娘们了。”,那么就可以构建:

下面播报一则______新闻。八个月了,终于又能在赛场上看到女排姑娘们了。

这样我们就将新闻主题分类也转换为完形填空问题了,一个好的 MLM 模型应当能预测出“体育”二字来。

还有一些简单的推理任务也可以做这样的转换,常见的是给定两个句子,判断这两个句子是否相容,比如“我去了北京”跟“我去了上海”就是矛盾的,“我去了北京”跟“我在天安门广场”是相容的,常见的做法就是将两个句子拼接起来输入到模型做,作为一个二分类任务。如果要转换为完形填空,那该怎么构造呢?一种比较自然的构建方式是:

我去了北京?______,我去了上海。

我去了北京?______,我在天安门广场。

其中空位之处的候选词为 {是的, 不是}

PET (Pattern-Exploiting Training) 

给输入的文本增加一个前缀或者后缀描述,并且 Mask 掉某些 Token,转换为完形填空问题,这样的转换在原论文中称为 Pattern,这个转换要尽可能与原来的句子组成一句自然的话,不能过于生硬,因为预训练的 MLM 模型就是在自然语言上进行的。

然后,我们需要构建预测 Token 的候选空间,并且建立 Token 到实际类别的映射,这在原论文中称为 Verbalizer,比如情感分类的例子,我们的候选空间是{ 很, 不},映射关系是 很->正面不->负面,候选空间与实际类别之间不一定是一一映射,比如我们还可以加入“挺”、“太”、“难”字,并且认为 {很,挺,太}->正面 以及 {不,难}->负面,等等。

不难理解,不少 NLP 任务都有可能进行这种转换,但显然这种转换一般只适用于候选空间有限的任务,说白了就是只用来做选择题,常见任务的就是文本分类

刚才说了,同一个任务可以有多种不同的 Pattern,原论文是这样处理的:

  1. 样本数量比较少,没有大量的标注数据怎么办?先用标注数据 training data,进行 pattern 数据生成,然后将 pattern 部分进行 mask, finetune 一个 MLM 模型;

  2. 因为有多种 pattern, 这样我们就有了多个基于 training data 的 MLM 模型,因为不知道哪种 pattern 是最好的,所以将多个模型进行集成,得到一个融合模型;

  3. 用这个融合模型,对 unlabeled data 进行预测,每个 data 都得到了一个伪标签 (soft label);

  4. 用拥有伪标签的这部分 unlabeled data 去 finetune 一个分类模型,用于最终的分类预测。

  5. 注:如果使用了 Auxilliary Language Modeling,那么在第一步的时候,除了用 training data,mask 掉 pattern 的部分作为训练数据,还要使用 unlabeled data,随机 mask 掉一部分 token,然后对其预测。目的是为了能让 model 更好的适应当前的 domain。

P-tuning  自动构建模版,释放语言模型潜能

讲解:P-tuning:自动构建模版,释放语言模型潜能 - 科学空间|Scientific Spaces (kexue.fm)

Code:P-tuning: A novel method to tune language models. Codes and datasets for paper ``GPT understands, too’’.

直观来看,模版就是由自然语言构成的前缀/后缀,通过这些模版我们使得下游任务跟预训练任务一致,这样才能更加充分地利用原始预训练模型,起到更好的零样本、小样本学习效果。

等等,我们真的在乎模版是不是“自然语言”构成的吗?

并不是。本质上来说,我们并不关心模版长什么样,我们只需要知道模版由哪些 token 组成,该插入到哪里,插入后能不能完成我们的下游任务,输出的候选空间是什么。模版是不是自然语言组成的,对我们根本没影响,“自然语言”的要求,只是为了更好地实现“一致性”,但不是必须的。于是,P-tuning考虑了如下形式的模版:

这里的[u1]~[u6],代表BERT词表里边的[unused1]~[unused6],也就是用几个从未见过的 token 来构成模板,这里的 token 数目是一个超参数,放在前面还是后面也可以调整。接着,为了让“模版”发挥作用,我们用标注数据来求出这个模板。

BigBird p-tuning code

Reference code: https://github.com/bojone/P-tuning/blob/main/bert.py

1
2
3
4
5
6
7
8
9
10
11
12
import pandas as pd

def load_data(filename):
D = []
data = pd.read_csv(filename)
for i in range(len(data)):
D.append((data['titletext'][i], int(data['label'][i])))
return D

test_data = load_data('./new_data/test_data.csv')
train_data = load_data('./new_data/train_data.csv')
valid_data = load_data('./new_data/valid_data.csv')
1
2
3
4
5
6
7
from transformers import BigBirdTokenizer, BigBirdForMaskedLM

tokenizer = BigBirdTokenizer.from_pretrained('google/bigbird-roberta-base')

PAD_INDEX = tokenizer.convert_tokens_to_ids(tokenizer.pad_token) # PAD_INDEX = 0
UNK_INDEX = tokenizer.convert_tokens_to_ids(tokenizer.unk_token) # UNK_INDEX = 100
MASK_INDEX = tokenizer.convert_tokens_to_ids(tokenizer.mask_token) # MASK_INDEX = 67
1
2
3
4
5
""" 因为 BigBird 的 tokenizer 里面没有 "opinion" 和 "unused1"... 的 token,所以我们要先自己新加上这几个 token. """
tokenizer.add_tokens('opinion')
desc = ['[unused%s]' % i for i in range(1, 9)]
for item in desc:
tokenizer.add_tokens(item)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import numpy as np

def random_masking(token_ids):
""" 对输入进行随机 mask """
rands = np.random.random(len(token_ids))
source, target = [], []
for r, t in zip(rands, token_ids):
if r < 0.15 * 0.8:
source.append(MASK_INDEX)
target.append(t)
elif r < 0.15 * 0.9:
source.append(t)
target.append(t)
elif r < 0.15:
source.append(np.random.choice(tokenizer.vocab_size - 1) + 1)
target.append(t)
else:
source.append(t)
target.append(0)
return source, target
1
2
3
4
5
6
7
8
"""  对应的任务描述 """ 
mask_idx = 5
desc = ['[unused%s]' % i for i in range(1, 9)]
desc.insert(mask_idx - 1, tokenizer.mask_token)
desc_ids = [tokenizer.convert_tokens_to_ids(t) for t in desc]

pos_id = tokenizer.convert_tokens_to_ids('opinion')
neg_id = tokenizer.convert_tokens_to_ids('report')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from torch.utils.data import Dataset

class MaskDataset(Dataset):
def __init__(self, tokenizer, data, max_len, random=True):

self.token_ids_list, self.output_ids_list = [], []
for item in data:
data_text = item[0]
data_label = item[1]

token_ids = tokenizer.encode(data_text)
token_ids = token_ids[:max_len]

if data_label != 2:
token_ids = token_ids[:1] + desc_ids + token_ids[1:]
if random:
source_ids, target_ids = random_masking(token_ids)
else:
source_ids, target_ids = token_ids[:], token_ids[:]

if data_label == 0:
source_ids[mask_idx] = MASK_INDEX
target_ids[mask_idx] = neg_id
elif data_label == 1:
source_ids[mask_idx] = MASK_INDEX
target_ids[mask_idx] = pos_id

source_ids.extend((max_len+9-len(source_ids))*[PAD_INDEX])
target_ids.extend((max_len+9-len(target_ids))*[PAD_INDEX])

self.token_ids_list.append(source_ids)
self.output_ids_list.append(target_ids)

if len(self.token_ids_list) != len(self.output_ids_list):
raise Exception("The length of X does not match the length of Y")

def __len__(self):
return len(self.token_ids_list)

def __getitem__(self, index):
# note that this isn't randomly selecting. It's a simple get a single item that represents an x and y
_x = self.token_ids_list[index]
_y = self.output_ids_list[index]

return _x, _y
1
2
3
4
5
6
7
""" 没有这个函数,DataLoader 返回的是一个 list, 用这个将结果转为 tensor """
def collate_fn(data):
unit_x, unit_y = [], []
for item in data:
unit_x.append(item[0])
unit_y.append(item[1])
return {torch.tensor(unit_x), torch.tensor(unit_y)}
1
2
3
4
5
6
7
8
9
10
11
12
from torch.utils.data import DataLoader

MAX_SEQ_LEN = 896
BATCH_SIZE = 2

train_set = MaskDataset(tokenizer, train_data, MAX_SEQ_LEN)
valid_set = MaskDataset(tokenizer, valid_data, MAX_SEQ_LEN)
test_set = MaskDataset(tokenizer, test_data, MAX_SEQ_LEN)

train_loader = DataLoader(train_set, batch_size=BATCH_SIZE, shuffle=True, collate_fn=collate_fn)
valid_loader = DataLoader(valid_set, batch_size=BATCH_SIZE, collate_fn=collate_fn)
test_loader = DataLoader(test_set, batch_size=BATCH_SIZE, collate_fn=collate_fn)
1
2
3
4
5
6
from transformers import BigBirdForMaskedLM

""" model.resize_token_embeddings(len(tokenizer)) 要加上这句话,因为新加了 token """
model = BigBirdForMaskedLM.from_pretrained('google/bigbird-roberta-base')
model.resize_token_embeddings(len(tokenizer))
model.to(device)

完整代码: