前沿重器[33] | 试了试简单的prompt

前沿重器

栏目主要给大家分享各种大厂、顶会的论文和分享,从中抽取关键精华的部分和大家分享,和大家一起把握前沿技术。具体介绍:仓颉专项:飞机大炮我都会,利器心法我还有。(算起来,专项启动已经是20年的事了!)

2022年的文章合集,累积起来有60w字,在这:CS的陋室60w字原创算法经验分享-2022版

往期回顾

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

5451bad96265785a2a0588b8c29bd68f.png

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值