前沿重器
栏目主要给大家分享各种大厂、顶会的论文和分享,从中抽取关键精华的部分和大家分享,和大家一起把握前沿技术。具体介绍:仓颉专项:飞机大炮我都会,利器心法我还有。(算起来,专项启动已经是20年的事了!)
2022年的文章合集,累积起来有60w字,在这:CS的陋室60w字原创算法经验分享-2022版。
往期回顾
前沿重器[28] | 前沿的向量召回都是怎么做的
前沿重器[29] | ERNIE-Search:向交互式学习的表征式语义匹配代表作
前沿重器[30] | 聊综述-预训练模型在信息检索中的应用
前沿重器[31] | 理性聊聊ChatGPT
前沿重器[32] | 域外意图检测——解决“没见过”的问题
prompt这个东西在现阶段应该不算新东西了,大家的关注点也到了后续的别的研究上了,前段时间拓展工具库的想法,于是开始想尝试一下prompt的效果。
懒人目录:
先说下原理
代码
代码细节
有关实验后的一些有意义的结论
小结
参考文章
先说下原理
所谓的prompt,简单而又笼统地说,其实就是把传统的NLP问题转化为一个类似我们以前做的“完形填空”一样,然后用MLM任务来预测对应的结果。以文本分类为例,例如分好评和差评的二分类,常规的方式是把句子输入到模型中,让模型预测正负,而在prompt中,我们对句子补充些内容,然后预测挖的空来判断正负。
例如一个句子“我觉得这个商品是我买的最对的商品了”,此时给句子进行一些补充,例如改成“句子:我觉得这个商品是我买的最对的商品了。这是一个[MASK]评”,此时我们只需要对比这里填“好”的概率和“差”的概率其实就能分析出最终的结果了。
是不是觉得原理超级简单,那后面就上代码了。
代码
首先是一些比较常规的提前配置,包括一些超参数和预训练模型的加载。
# 超参数
hidden_dropout_prob = 0.3
num_labels = 2
learning_rate = 1e-5
weight_decay = 1e-2
epochs = 15
batch_size = 16
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
prefix = "这也太[MASK]了吧," # prompt的配置在合理,为了简单化我直接用的是前缀的形式,其实大家要有时间改一下也可以成为后缀。
maskpos = 4
# 预训练模型路径
ptm_path = "./data/ptms/bert-base-chinese/"
vocab_file = ptm_path +"vocab.txt" # 词汇表
tokenizer = BertTokenizer(vocab_file)
config = BertConfig.from_pretrained(ptm_path + "config.json")
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
model = Bert_Model(bert_path=ptm_path + "pytorch_model.bin", config_file=config).to(device)
# 提前提取正负类代表的词汇的id,方便后面提取概率。
pos_id = tokenizer.convert_tokens_to_ids('棒')
neg_id = tokenizer.convert_tokens_to_ids('差')
# 只是做实验,所以这里的训练配置都是用的非常默认而且简单的。
loss_func = nn.CrossEntropyLoss(ignore_index=-1)
optimizer = AdamW(model.parameters(),lr=2e-5,weight_decay=1e-4) #使用Adam优化器
此处的预训练模型,用的是这个结构,这里使用的是BertForMaskedLM
,就是一个MLM模型的结构,相信大家都是比较熟悉的了,transformers库里面集成了很多任务类型的基础结构,用起来都很方便。
from transformers import BertForMaskedLM
class Bert_Model(nn.Module):
def __init__(self, bert_path, config_file):
super(Bert_Model, self).__init__()
print(bert_path)
self.bert = BertForMaskedLM.from_pretrained(bert_path,config=config_file) # 加载预训练模型权重
def forward(self, input_ids, attention_mask, token_type_ids):
outputs = self.bert(input_ids, attention_mask, token_type_ids)
logit = outputs.logits # 池化后的输出 [bs, config.hidden_size]
return logit
数据集其实是一个挺部分,我把拼接放在了这一步,具体函数就在这个prompt_dataset
里面。
# 训练集整理
Inputid, Labelid, sid, atid = prompt_dataset(x_train, y_train, prefix, tokenizer, maskpos)
Inputid = np.array(Inputid)
Labelid = np.array(Labelid)
sid = np.array(sid)
atid = np.array(atid)
# 偷个懒,验证集和训练集一样
input_ids_train, input_ids_valid = Inputid, Inputid
input_masks_train, input_masks_valid = atid, atid
input_types_train, input_types_valid = sid, sid
label_train, y_valid = Labelid, Labelid
来看看prompt_dataset
的具体怎么写,这里比较特别的其实就是text_ = prefix + x[i]
,就是把前缀和句子拼接在一起,然后后面就是比较常规的转化了。
def prompt_dataset(x, y, prefix, tokenizer, maskpos):
# prompt x: 原始数据输入, y: 输出,prefix: 前缀,tokenizer: 转换器
Inputid = []
Labelid = []
sid = []
atid = []
for i in range(len(x)):
text_ = prefix + x[i]
encode_dict = tokenizer.encode_plus(text_, max_length=200, padding='max_length', truncation=True, add_special_tokens=True)
id = encode_dict["input_ids"]
segmentid = encode_dict["token_type_ids"]
attid = encode_dict["attention_mask"]
labelid, inputid = id[:], id[:]
if y[i] == 0:
labelid[maskpos] = neg_id
labelid[: maskpos ] = [-1]*len(labelid[: maskpos ])
labelid[maskpos + 1 : ] = [-1]*len(labelid[maskpos + 1 : ])
inputid[maskpos] = tokenizer.mask_token_id
else:
labelid[maskpos] = pos_id
labelid[: maskpos] = [-1] * len(labelid[: maskpos])
labelid[maskpos + 1:] = [-1] * len(labelid[maskpos + 1:])
inputid[maskpos] = tokenizer.mask_token_id
Labelid.append(labelid)
Inputid.append(inputid)
sid.append(segmentid)
atid.append(attid)
return Inputid, Labelid, sid, atid
为了方便,我这里还写了一个预测单个case的函数,在平时自测啥的,都会挺方便,这里用的概率
def pred_single(model, data_info, maskpos, pos_id, neg_id):
ids, att, tpe= list2cuda(data_info["Inputid"]), list2cuda(data_info["atid"]), list2cuda(data_info["sid"])
out = model(ids, att, tpe)
tout_train_mask = out[:, maskpos, :] # 预测值,这里是这个位置所有token的概率。
pos_score = tout_train_mask[:,pos_id].cpu().detach().numpy().tolist() # 正类关键词的概率
neg_score = tout_train_mask[:,neg_id].cpu().detach().numpy().tolist() # 负类关键词的概率
# print(pos_score, neg_score)
pred = cal_pred(pos_score, neg_score)
return pred
def list2cuda(data):
return torch.from_numpy(np.array(data)).long().to(device)
def cal_pred(pos_score, neg_score):
# 计算正负类概率,取高
# print(pos_score, neg_score)
pred = []
for idx in range(len(pos_score)):
if pos_score[idx] >= neg_score[idx]:
pred.append(1)
else:
pred.append(0)
return pred
然后是还比较关键的训练代码,开始之前需要专门说的是,这个训练不着急做,可以在无监督的情况下先直接试试效果,其实只要设计到比较好的prompt,我的实验是能达到80%这个水平,这个水平不算高,但是在无监督没什么数据的情况下,已经是一个很高的baseline了,这点就非常值得我们吸收学习了。
下面就是重头戏了,训练,其实训练的部分也比较简单,损失函数就是交叉熵(前文已经定义了),我们是希望对应类目的关键词,在这个句子中的概率尽可能高,通过这种方式训练的,剩下就看代码理解吧:
def train(model, epoch, optimizer, dataset, device, loss_func):
starttime_train = datetime.now()
start = time.time()
correct = 0
train_loss_sum = 0.0
model.train()
schedule = get_cosine_schedule_with_warmup(optimizer,num_warmup_steps=len(dataset),num_training_steps=epoch*len(dataset))
logger.info("***** Running training epoch {} *****".format(epoch + 1))
for idx, (ids, att, tpe, y) in enumerate(tqdm(dataset)):
ids, att, tpe, y = ids.to(device), att.to(device), tpe.to(device), y.to(device)
out_train = model(ids, att, tpe)
# print(out_train.view(-1, 21128).shape, y.view(-1).shape)
loss = loss_func(out_train.view(-1, 21128), y.view(-1))
optimizer.zero_grad()
loss.backward()
optimizer.step()
schedule.step()
train_loss_sum += loss.item()
if (idx + 1) % 100 == 0:
logger.info("Epoch {:04d} | Step {:06d}/{:06d} | Loss {:.4f} | Time {:.0f}".format(
epoch + 1, idx + 1, len(dataset), train_loss_sum / (idx + 1), time.time() - start))
truelabel = y[:, maskpos]
out_train_mask = out_train[:, maskpos, :]
predicted = torch.max(out_train_mask.data, 1)[1]
correct += (predicted == truelabel).sum()
correct = np.float(correct)
acc = float(correct / len(label_train))
其实看代码会发现非常常规,就是一般的MLM任务的训练流程了,让模型预测的token尽可能接近label。
代码细节
在写这个代码过程中,其实还挺波折,这里面还是有关注到挺多细节的。
Transformers所封装的几种常见的模型结构,分类、句子对等,都是需要熟悉了解的,包括这次用到的
BertForMaskedLM
。大家可以通过文档等方式直接学习。各种数据类型、设备的转化,熟练度似乎不太够,就是
tout_train_mask[:,pos_id].cpu().detach().numpy().tolist()
。另外注意,我这种只是为了跑通做玩具的脚本,这个代码风格不值得学习,参考技术方案就好了。
训练并非必须,可以尝试直接不训练地直接预测,好的prompt会有一个还不错的baseline。
有关实验后的一些有意义的结论
实验结果我就不摆在这里了,但又一些有意义的发现直接列举给大家,供大家做实验的参考:
不训练的情况下,多换几种prompt,能得到一个不差的结果,这让我们在比较困难的环境下,也可以得到一个不错的baseline。(我的实验上限F1在80%左右)
不训练的情况下,不同的prompt,对结果的差距非常大,下限能到55%,所以如果不训练,需要花点时间在prompt的设计上。
训练情况,可能是我的数据都偏简单,所以prompt和其他模型,例如bert-cls,差距不是很大,没有显著变好,和数据有关。
训练情况,不同的prompt对最终的效果也会有影响,但不会那么大,收官时间调一调还可以,早期不要花太多时间。
令人惊喜的是,强行压缩训练集,我这里压缩到100条,此时prompt方案仍然能够达到接近全量数据的水平(当然数据量少了epoch就要增加不少,不过收敛的其实挺快的),大概能达到较差的prompt的水平。
这个压缩数据集的fewshot的场景下能有这个效果,是bert-cls、textcnn等经典方法都办不到的,所以小数据集下,可以试试这个方案的。
上述效果是建立在大型预训练模型的基础上的,CNN等的一些比较小的结果似乎没有这个效果,大家需要注意。
小结
这次尝试算是刷新了我的工具库了,是一个比较新的,有新的适配场景的方案了,在few-shot场景下,这个方案有非常令人惊喜的结果,这个可以说是比较大的卖点了,推荐大家也用来试试,除了分类任务外,ner等任务其实也可以尝试下的。
参考文章
苏剑林:曾被嫌弃的预训练任务NSP,做出了优秀的Zero Shot效果,https://spaces.ac.cn/archives/8671
谢立阳:Prompt 初探,https://zhuanlan.zhihu.com/p/464562384
谢立阳:Prompt 中文分类任务工程尝试,https://zhuanlan.zhihu.com/p/464684532