论文阅读笔记
Motivation
- 新兴的 GPT-3 及其在使用手工prompt模板在few-shot和zero-shot学习方面的成功表明,使用prompt learning的方案可以使大规模自回归预训练模型适用于自然语言理解;
- 过去的prompt learning方案过度依赖手工设计,针对一些较复杂任务不好设计,且人力成本高,让模型自己学习模板能够在效率和准确性两个维度上给这个任务带来提升。
Method
Architecture
P-tuning模型学习的目标是得到能最大化提示预训练模型根据给定输入
x
x
x预测标签
y
y
y的前缀模板
{
p
1
,
.
.
.
,
p
m
}
\{p_1,...,p_m\}
{p1,...,pm}。
为此,模型首先构造这个前缀模板,论文中将输入x插入前缀模板内部,得到
{
p
1
,
.
.
.
,
p
i
,
x
,
p
i
+
1
,
.
.
.
p
m
}
\{p_1,...,p_i,x,p_{i+1},...p_m\}
{p1,...,pi,x,pi+1,...pm}。我认为可以根据需要组合提示模板,输入和输出的位置(注:根据苏剑林大佬的试验前缀效果最优
[
2
]
^{[2]}
[2])。
而后通过embedding层和预训练模型映射得到隐向量:
e
(
[
P
0
:
i
]
)
,
e
(
x
)
,
e
(
[
P
i
+
1
:
m
]
)
,
e
(
y
)
{e([P0:i]),e(x),e([Pi+1:m]),e(y)}
e([P0:i]),e(x),e([Pi+1:m]),e(y)
h
0
,
.
.
.
,
h
i
,
e
(
x
)
,
h
i
+
1
,
.
.
.
,
h
m
,
e
(
y
)
{h0, ..., hi,e(x), hi+1, ..., hm,e(y)}
h0,...,hi,e(x),hi+1,...,hm,e(y)
其中 h i ( 0 ≤ i < m ) h_i(0 ≤ i < m) hi(0≤i<m) 是更好的连续prompt模板,这些均为可训练嵌入张量。
最后,利用下游损失函数 L,可以通过以下方式差分优化学习最优的连续提示模板
h
i
(
0
≤
i
<
m
)
hi(0 ≤ i < m)
hi(0≤i<m):
h
^
0
:
m
=
a
r
g
m
i
n
h
L
(
M
(
x
,
y
)
)
\hat{h}_{0:m}= arg min_hL(M(x,y))
h^0:m=argminhL(M(x,y))
Optimization
论文认为,在模型学习优化上存在两个挑战:
1)离散性:预训练模型的原始embedding在预训练后已经变得高度离散。 如果提示模板随机分布初始化,然后用随机梯度下降 (SGD) 进行优化,已经证明只改变小邻域的参数,优化器很容易陷入局部最小值;
2)关联性:直觉上认为提示嵌入
h
i
h_i
hi 的值应该相互依赖而不是独立。 需要一些机制来将提示模板的embedding相互关联。鉴于面临的挑战,在 P-tuning 中,使用提示编码器将
h
h
h 建模为一个序列,该编码器由一个非常精简的神经网络组成,可以解决离散性和关联问题。在实践中,选择双向长短期记忆网络 (LSTM),使用 ReLU 激活的两层多层感知器 (MLP) 来鼓励离散性。
Experiment
试验表明,在知识探测 (LAMA) 基准测试中,最好的 GPT 在测试期间无需提供任何额外文本即可恢复 64% (P@1) 的世界知识,这大大提高了之前的最佳水平 20+ 个百分点。在 SuperGlue 基准测试中,GPT 在监督学习中实现了与类似大小的 BERT 相当甚至更好的性能。并且证明了 P-tuning 还提高了 BERT 在少样本和监督设置中的性能,同时大大减少了对prompt工程的需求。
代码实现
代码实现上,我参考了苏神的想法。相比较于使用LSTM来学习这个提示模板,讲学习提示模板的任务回归MLM等预训练任务直觉上能有更强的学习能力,且能够让提示模板更接近自然语言。我使用pytorch来实现了这个试验:
数据构建中,如上图方式构建模板。针对训练集,按照预训练MLM任务的比例构造mask(15%mask,mask部分里80%输入为[MASK],10%保留原本输入,10%改为词表随机替换);验证和测试集不再构造新的mask,代码如下:
class data_generator():
def __init__(self, data, tokenizer, max_len):
self.data = data
self.tokenizer = tokenizer
self.max_len = max_len
def random_masking(self, token_id):
'''
对句子进行随机mask,依照mlm任务
'''
rand_num = np.random.random(len(token_id)) # 每一个位置怎么mask的依据
masked_token_id, masked_output_id = [], []
for r,t in zip(rand_num, token_id):
# 如果到了pad部分,文字部分加入pad的id,预测部分直接mask,不需要mlm
if t == 0:
masked_token_id.append(0)
masked_output_id.append(-100)
else:
# 0.15做mlm,80%mask,10%保留, 10%随机替换
if r < 0.15 * 0.8:
masked_token_id.append(103) # mask的id
masked_output_id.append(t) # 标签加入正确答案,需要预测
elif r < 0.15 * 0.9:
masked_token_id.append(t)
masked_output_id.append(t)
elif r < 0.15:
masked_token_id.append(np.random.choice(self.tokenizer.vocab_size - 1) + 1) # 除了pad以外选一个
masked_output_id.append(t)
else:
masked_token_id.append(t)
masked_output_id.append(-100)
return masked_token_id, masked_output_id
def build_generator(self, prex_id, random=False):
# 句子tokens的id们,segments的id们以及bert应该预测出来每一字实际的文字id们
token_ids, segment_ids, output_ids = [], [], []
for sample in self.data:
text, label = sample[0], sample[1]
# 将前缀加入句子并且用分词器处理成id
result = self.tokenizer.encode_plus(text, max_length=self.max_len,
padding='max_length',truncation=True)
token_id = result['input_ids'][:1] + prex_id + result['input_ids'][1:]
segment_id = result['token_type_ids'] + [0] * len(prex_id)
# print(token_id)
# break
# 随机模式下依据mlm进行数据mask
if random:
token_id, output_id = self.random_masking(token_id)
else:
token_id, output_id = token_id[:], token_id[:]
if label == 1: # 正例
output_id[label_idx + 1] = self.tokenizer._convert_token_to_id(pos_tag)
if label == 0: # 负例
output_id[label_idx + 1] = self.tokenizer._convert_token_to_id(neg_tag)
token_ids.append(token_id)
segment_ids.append(segment_id)
output_ids.append(output_id)
token_ids = np.array(token_ids)
segment_ids = np.array(segment_ids)
output_ids = np.array(output_ids)
assert len(token_ids) == len(segment_ids) == len(output_ids)
generator = {
"token_ids": token_ids,
"segment_ids": segment_ids,
"output_ids": output_ids
}
return generator
训练部分中,我冻结了预训练模型除了embedding层以外所有的梯度;而针对embedding层的反向传播,我设计了一个仅模板部分对应位置为1,其他部分为0的mask矩阵,通过register_hook使得梯度每次传播更新的时候仅保留模板对应位置的梯度,达到仅仅更新模板部分的embedding的效果:
# 设置除了embedding层以外都freeze
named_param = list(model.named_parameters())
for n,p in named_param:
if "word_embeddings" not in n:
p.requires_grad = False
else:
print(n)
def back_prex(grad,DEVICE,prex_len=9):
'''
仅仅计算前缀模板的梯度
'''
grad_mask = np.zeros((grad.shape[0], grad.shape[1]))
grad_mask[1:prex_len] += 1
grad_mask = torch.LongTensor(grad_mask).to(DEVICE)
grad = grad * grad_mask
return grad
for epoch in range(1000):
model.train()
# 实现仅仅更新模板部分的梯度
model.bert.embeddings.word_embeddings.weight.register_hook(lambda grad:back_prex(grad=grad,DEVICE=DEVICE))
with tqdm(total=len(train_dataloader)) as bar:
for idx, data in enumerate(train_dataloader):
token_id, segment_id, output_id = data["token_id"].to(DEVICE), data["segment_id"].to(DEVICE), data["output_id"].to(DEVICE)
model.zero_grad() # 梯度清零
outputs = model(input_ids=token_id,token_type_ids= segment_id, labels=output_id)
loss = outputs.loss
loss.backward()
# 梯度下降,更新参数
optimizer.step()
bar.set_postfix(loss=loss.item())
bar.update(1)
if epoch % 50 == 0 and epoch > 0:
acc = eval(model,val_dataloader,DEVICE)
model.train()
通过文本分类中文数据集的试验,发现p-tuning有良好的few-shot能力,并且在相同条件下优于fine-tuning进行文本分类的效果。
参考文献
[1] Liu X , Zheng Y , Du Z , et al. GPT Understands, Too[J]. 2021.
[2] 苏剑林. (Apr. 03, 2021). 《P-tuning:自动构建模版,释放语言模型潜能 》[Blog post]. Retrieved from https://kexue.fm/archives/8295