大模型中的提示学习——情感预测示例项目

发布时间 2023-09-27 16:38:21作者: bonelee

【提示学习】

提示学习(Prompting)是一种自然语言处理(NLP)中的训练技术,它利用预训练的语言模型(如BERT、GPT等)来解决各种下游任务,如文本分类、命名实体识别、问答等。这种方法的关键思想是通过设计合适的提示(Prompt),将下游任务转化为一个填空任务,然后利用预训练的语言模型来预测填空。

例如,对于情感分类任务,我们可以设计一个提示如“这段文本的情感是{mask}”,其中{mask}是需要预测的部分。然后,我们将这个提示和实际的文本一起输入到预训练的语言模型中,模型的任务就是预测{mask}的内容,即文本的情感。

提示学习的优点是可以充分利用预训练语言模型的强大表示能力,而且不需要对模型结构进行大的修改,只需要设计合适的提示即可。但是,如何设计有效的提示是一项挑战,需要大量的实验和经验。

 

ChatGPTBook/PromptProj

Name
Last commit message
Last commit date

parent directory

..
(Directory)
3 months ago
(Directory)
3 months ago
(Directory)
3 months ago
(Directory)
3 months ago
(File)
3 months ago
3 months ago
(File)
3 months ago
(File)
3 months ago
(File)
3 months ago
3 months ago
(File)
3 months ago

本项目为书籍《ChatGPT原理与实战:大型语言模型的算法、技术和私有化》中第5章《提示学习与大模型的涌现》实战部分代码-基于Prompt的文本情感分析实战。

项目简介

针对酒店评论数据集,利用BERT模型在小样本数据下进行模型训练及测试,更深入地了解Prompt任务进行下游任务的流程。

项目主要结构如下:

  • data 存放数据的文件夹
    • ChnSentiCorp_htl_all.csv 原始酒店评论情感数据 【数据量3MB不到】
    • sample.json 处理后的语料样例
  • prompt_model 已训练好的模型路径
    • config.json
    • pytorch_model.bin
    • vocab.txt
  • pretrain_model 预训练文件路径
    • config.json
    • pytorch_model.bin 【虽然提示学习是在预训练中结合下游任务一起训练,但这里演示的还是有增量训练的,不过模型参数没有更新,见后】
    • vocab.txt
  • data_helper.py 数据预处理文件
  • data_set.py 模型所需数据类文件
  • model.py 模型文件
  • train.py 模型训练文件
  • predict.py 模型推理文件

注意:由于GitHub不方便放模型文件,因此prompt_model文件夹和pretrain_model文件夹中的模型bin文件,请从百度云盘中下载。

文件名称下载地址提取码
pretrain_model 百度云 tdzo
prompt_model 百度云 fjd9

环境配置

模型训练或推理所需环境,请参考requirements.txt文件。

数据处理

数据预处理需要运行data_helper.py文件,会在data文件夹中生成训练集和测试集文件。

命令如下:

python3 data_helper.py
 

注意:如果需要修改数据生成路径或名称,请修改data_helper.py文件66-68行,自行定义。

模型训练

模型训练需要运行train.py文件,会自动生成output_dir文件夹,存放每个epoch保存的模型文件。

命令如下:

python3 train.py --device 0 \
                 --data_dir "data/" \
                 --train_file_path "data/train.json" \
                 --test_file_path "data/test.json" \
                 --pretrained_model_path "pretrain_model/" \
                 --max_len 256 \
                 --train_batch_size 4 \
                 --test_batch_size 16 \
                 --num_train_epochs 10 \
                 --token_handler "mean" 
 

注意:当服务器资源不同或读者更换数据等时,可以在模型训练时修改响应参数,详细参数说明见代码或阅读书5.4.4小节。

模型训练示例如下:

img.png

模型训练阶段损失值及验证集准确率变化如下:

img.png img.png

模型推理

模型训练需要运行predict.py文件,可以采用项目中以提供的模型,也可以采用自己训练后的模型。

命令如下:

python3 predict.py --device 0 --max_len 256
 

注意:如果修改模型路径,请修改--model_path参数。

模型推理示例如下: img.png

样例1:
输入的评论数据:这家酒店是我在携程定的酒店里面是最差的,房间设施太小气,环境也不好,特别是我住的那天先是第一晚停了一会儿电,第二天停水,没法洗漱,就连厕所也没法上,糟糕头顶。
情感极性:负向
样例2:
输入的评论数据:这个宾馆的接待人员没有丝毫的职业道德可言。我以前定过几次这个宾馆,通常情况下因为入住客人少,因此未发生与他们的冲突,此于他们说要接待一个团,为了腾房,就要强迫已入住的客人退房,而且态度恶劣,言语嚣张,还采用欺骗手段说有其他的房间。
情感极性:负向
样例3:
输入的评论数据:香港马可最吸引人的地方当然是她便利的条件啦;附近的美心酒楼早茶很不错(就在文化中心里头),挺有特色的;
情感极性:正向
样例4:
输入的评论数据:绝对是天津最好的五星级酒店,无愧于万豪的品牌!我住过两次,感觉都非常好。非常喜欢酒店配备的CD机,遥控窗帘,卫生间电动百页窗。早餐也非常好,品种多品质好。能在早餐吃到寿司的酒店不多,我喜欢这里的大堂,很有三亚万豪的风范!
情感极性:正向


我自己测试的效果:
开始对评论数据进行情感分析,输入CTRL + C,则退出
输入的评论数据为:这家酒店是我在携程定的酒店里面是有点问题的。
情感极性为:负向
输入的评论数据为:好吧,就这样吧。
情感极性为:负向
输入的评论数据为:好吧
情感极性为:负向
输入的评论数据为:好
情感极性为:负向
输入的评论数据为:豪酒店啊!
情感极性为:负向
输入的评论数据为:好酒店啊!
情感极性为:正向
输入的评论数据为:豪华酒店!
情感极性为:正向
输入的评论数据为:华酒店!
情感极性为:负向
输入的评论数据为:华丽酒店!
情感极性为:正向

 

我们看下model.py内容:

# -*- coding:utf-8 -*-
from torch.nn import CrossEntropyLoss
import torch.nn as nn
import torch
from transformers.models.bert.modeling_bert import BertModel, BertOnlyMLMHead, BertPreTrainedModel


class PromptModel(BertPreTrainedModel):
    """Prompt分类模型"""

    def __init__(self, config):
        super().__init__(config)
        """
        初始化函数
        Args:
            config: 配置参数
        """
        self.bert = BertModel(config, add_pooling_layer=False)
        self.cls = BertOnlyMLMHead(config)

    def forward(self, input_ids, attention_mask, mask_index, token_handler, words_ids, words_ids_mask,
                label=None):
        """
        前向函数,计算Prompt模型预测结果
        Args:
            input_ids:
            attention_mask:
            mask_index:
            token_handler:
            words_ids:
            words_ids_mask:
            label:

        Returns:

        """
        # 获取BERT模型的输出结果
        sequence_output = self.bert(input_ids=input_ids, attention_mask=attention_mask)[0]
        # 经过一个全连接层,获取隐层节点状态中的每一个位置的词表
        logits = self.cls(sequence_output)
        # 获取批次数据中每个样本内容对应mask位置标记
        logits_shapes = logits.shape
        mask = mask_index + torch.range(0, logits_shapes[0] - 1, dtype=torch.long, device=logits.device) * \
               logits_shapes[1]
        mask = mask.reshape([-1, 1]).repeat([1, logits_shapes[2]])
        # 获取每个mask标记对应的logits内容
        mask_logits = logits.reshape([-1, logits_shapes[2]]).gather(0, mask).reshape(-1, logits_shapes[2])
        # 获取答案空间映射的标签向量
        label_words_logits = self.process_logits(mask_logits, token_handler, words_ids, words_ids_mask)
        # 将其进行归一化以及获取对应标签
        score = torch.nn.functional.softmax(label_words_logits, dim=-1)
        pre_label = torch.argmax(label_words_logits, dim=1)
        outputs = (score, pre_label)
        # 当label不为空时,计算损失值
        if label is not None:
            loss_fct = CrossEntropyLoss()
            loss = loss_fct(label_words_logits, label)
            outputs = (loss,) + outputs
        return outputs

    def process_logits(self, mask_logits, token_handler, words_ids, words_ids_mask):
        """
        获取答案空间映射的标签向量,用于分类判断
        Args:
            mask_logits: mask位置信息
            token_handler: 多token操作策略,包含first、mask和mean
            words_ids: 标签词id矩阵
            words_ids_mask: 标签词id掩码矩阵

        Returns:

        """
        # 获取标签词id及掩码矩阵
        label_words_ids = nn.Parameter(words_ids, requires_grad=False)
        label_words_mask = nn.Parameter(torch.clamp(words_ids_mask.sum(dim=-1), max=1), requires_grad=False)
        # 获取mask位置上标签词向量
        label_words_logits = mask_logits[:, label_words_ids]
        # 根据多token操作策略进行标签词向量构建
        if token_handler == "first":
            label_words_logits = label_words_logits.select(dim=-1, index=0)
        elif token_handler == "max":
            label_words_logits = label_words_logits - 1000 * (1 - words_ids_mask.unsqueeze(0))
            label_words_logits = label_words_logits.max(dim=-1).values
        elif token_handler == "mean":
            label_words_logits = (label_words_logits * words_ids_mask.unsqueeze(0)).sum(dim=-1) / (
                    words_ids_mask.unsqueeze(0).sum(dim=-1) + 1e-15)
        # 将填充的位置进行掩码
        label_words_logits -= 10000 * (1 - label_words_mask)
        # 最终获取mask标记对应的答案空间映射向量
        label_words_logits = (label_words_logits * label_words_mask).sum(-1) / label_words_mask.sum(-1)
        return label_words_logits

  

提示学习的核心还是在里面:

 # 获取每个mask标记对应的logits内容
 mask_logits = logits.reshape([-1, logits_shapes[2]]).gather(0, mask).reshape(-1, logits_shapes[2])
 # 获取答案空间映射的标签向量
 label_words_logits = self.process_logits(mask_logits, token_handler, words_ids, words_ids_mask)
 # 将其进行归一化以及获取对应标签

 

上述模型是一个基于BERT的Prompt分类模型,主要由两部分组成:

1. self.bert:这是BERT模型的主体部分,用于提取输入文本的特征表示。

2. self.cls:这是一个全连接层,用于从BERT模型的输出中获取每个位置的词表。

模型的功能主要是通过BERT模型提取输入文本的特征,然后通过全连接层获取每个位置的词表,最后通过处理logits和计算损失值来进行分类预测。

在前向传播过程中,模型首先获取BERT模型的输出结果,然后经过全连接层获取每个位置的词表,然后获取每个mask标记对应的logits内容,然后获取答案空间映射的标签向量,最后计算损失值。

在处理logits的过程中,模型首先获取标签词id及掩码矩阵,然后获取mask位置上标签词向量,然后根据多token操作策略进行标签词向量构建,然后将填充的位置进行掩码,最后获取mask标记对应的答案空间映射向量。

总的来说,这个模型的主要功能是进行文本分类预测

 

 

【模型训练】

def train(model, device, train_data, test_data, args, tokenizer):
    """
    训练模型
    Args:
        model: 模型
        device: 设备信息
        train_data: 训练数据类
        test_data: 测试数据类
        args: 训练参数配置信息
        tokenizer: 分词器
    Returns:
    """
    tb_write = SummaryWriter()
    if args.gradient_accumulation_steps < 1:
        raise ValueError("gradient_accumulation_steps参数无效,必须大于等于1")
    # 计算真实的训练batch_size大小
    train_batch_size = int(args.train_batch_size / args.gradient_accumulation_steps)
    train_sampler = RandomSampler(train_data)
    # 构造训练所需的data_loader
    train_data_loader = DataLoader(train_data, sampler=train_sampler,
                                   batch_size=train_batch_size, collate_fn=collate_func)
    total_steps = int(len(train_data_loader) * args.num_train_epochs / args.gradient_accumulation_steps)
    logger.info("总训练步数为:{}".format(total_steps))
    model.to(device)
    # 获取模型所有参数
    param_optimizer = list(model.named_parameters())
    optimizer_grouped_parameters = [
        {'params': [p for n, p in param_optimizer if not any(
            nd in n for nd in args.requires_grad_params)], 'weight_decay': 0.01},
        {'params': [p for n, p in param_optimizer if any(
            nd in n for nd in args.requires_grad_params)], 'weight_decay': 0.0}
    ]

    # 冻结不训练的参数
    for name, param in model.named_parameters():
        if not any(r_name in name for r_name in args.requires_grad_params):
            param.requires_grad = False

    # 验证是否冻结成功
    requires_grad_params = []
    for name, param in model.named_parameters():
        if param.requires_grad:
            requires_grad_params.append(name)
            print("需要训练参数为{},大小为{}".format(name, param.size()))
    # 设置优化器
    optimizer = AdamW(optimizer_grouped_parameters,
                      lr=args.learning_rate, eps=args.adam_epsilon)
    scheduler = get_linear_schedule_with_warmup(optimizer, num_warmup_steps=int(args.warmup_proportion * total_steps),
                                                num_training_steps=total_steps)
    # 清空cuda缓存
    torch.cuda.empty_cache()
    # 将模型调至训练状态
    model.train()
    tr_loss, logging_loss, min_loss = 0.0, 0.0, 0.0
    global_step = 0
    words_ids = train_data.words_ids.to(device)
    words_ids_mask = train_data.words_ids_mask.to(device)
    # 开始训练模型
    for iepoch in trange(0, int(args.num_train_epochs), desc="Epoch", disable=False):
        iter_bar = tqdm(train_data_loader, desc="Iter (loss=X.XXX)", disable=False)
        for step, batch in enumerate(iter_bar):
            # 获取模型训练每个批次所需的输入内容,并放到对应设备上
            input_ids = batch["input_ids"].to(device)
            attention_mask = batch["attention_mask"].to(device)
            mask_index = batch["mask_index"].to(device)
            label = batch["label"].to(device)

            # 获取训练结果
            outputs = model.forward(input_ids=input_ids, attention_mask=attention_mask, mask_index=mask_index,
                                    token_handler=args.token_handler,
                                    words_ids=words_ids, words_ids_mask=words_ids_mask,
                                    label=label)
            loss = outputs[0]
            tr_loss += loss.item()
            # 将损失值放到Iter中,方便观察
            iter_bar.set_description("Iter (loss=%5.3f)" % loss.item())
            # 判断是否进行梯度累积,如果进行,则将损失值除以累积步数
            if args.gradient_accumulation_steps > 1:
                loss = loss / args.gradient_accumulation_steps
            # 损失进行回传
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), args.max_grad_norm)
            # 当训练步数整除累积步数时,进行参数优化
            if (step + 1) % args.gradient_accumulation_steps == 0:
                optimizer.step()
                scheduler.step()
                optimizer.zero_grad()
                global_step += 1
                # 如果步数整除logging_steps,则记录学习率和训练集损失值
                if args.logging_steps > 0 and global_step % args.logging_steps == 0:
                    tb_write.add_scalar("lr", scheduler.get_lr()[0], global_step)
                    tb_write.add_scalar("train_loss", (tr_loss - logging_loss) /
                                        (args.logging_steps * args.gradient_accumulation_steps), global_step)
                    logging_loss = tr_loss

        # 每个Epoch对模型进行一次测试,记录测试集的损失
        eval_loss, eval_acc = evaluate(model, device, test_data, args)
        tb_write.add_scalar("test_loss", eval_loss, global_step)
        tb_write.add_scalar("test_acc", eval_acc, global_step)
        print("test_loss: {}, test_acc:{}".format(eval_loss, eval_acc))
        model.train()
        # 每个epoch进行完,则保存模型
        output_dir = os.path.join(args.output_dir, "checkpoint-{}".format(global_step))
        model_to_save = model.module if hasattr(model, "module") else model
        model_to_save.save_pretrained(output_dir)
        tokenizer.save_pretrained(output_dir)
        # 清空cuda缓存
        torch.cuda.empty_cache()

 

关键代码:

for name, param in model.named_parameters():
        if not any(r_name in name for r_name in args.requires_grad_params):
            param.requires_grad = False
 
这段代码的作用是冻结模型中的某些参数,使它们在训练过程中不会被更新。

具体来说,model.named_parameters()是一个迭代器,它返回模型中所有参数的名称(name)和值(param)。然后,对于每一个参数,它检查参数的名称是否包含在args.requires_grad_params列表中。如果参数的名称不在这个列表中,那么就将这个参数的requires_grad属性设置为False,这意味着在训练过程中,这个参数的值不会被更新。

这种技术通常用于迁移学习,当我们想要固定预训练模型的某些层,只训练模型的其他部分时,就会用到这种技术