BERT增量微调实战:冻结BERT,用ChnSentiCorp训练中文情感分类器

这篇文章用BERT做一个中文情感分类的小项目,给模型一条评价,让它判断是好评还是差评。数据用的是开源的ChnSentiCorp数据集,用到的方法是微调。先安装需要的依赖。

bash 复制代码
pip install transformers datasets

文章目录

    • [01 微调的概念与增量微调](#01 微调的概念与增量微调)
    • [02 AI项目开发流程](#02 AI项目开发流程)
    • [03 数据准备](#03 数据准备)
    • [04 文本编码](#04 文本编码)
    • [05 三种拟合状态与判断方法](#05 三种拟合状态与判断方法)
    • [06 模型设计](#06 模型设计)
    • [07 训练](#07 训练)
    • 小结

01 微调的概念与增量微调

微调,就是在预训练模型的基础上继续训练,让它适应特定的下游任务,具备原本不具备的能力。预训练模型已经学会了语言的通用模式,所以不用从头开始训练,毕竟预训练需要大量的数据和算力。

微调分为全量微调、局部微调和增量微调三类。全量微调让整个预训练模型都参与训练,局部微调只训练其中的一部分,增量微调则是预训练模型完全不参与训练,在它的基础上新增下游层,只训练新增的这部分。

本文用的是增量微调。BERT负责把文本编码成向量,新增的全连接层负责根据向量判断情感,训练时只更新这一层的参数。

02 AI项目开发流程

一个AI项目从接到需求到最终上线,大体分为五个步骤,依次是需求分析、数据准备、模型训练、模型测试和部署运行。

需求分析阶段要确定做什么。这个项目的需求很明确,对输入的一段文本做情感分析,判断它是正面评价还是负面评价。

数据准备阶段要对数据做清洗和整理,再转换成模型可以使用的数据集。这一步有个前提,数据必须拿得到,如果甲方提供不了数据,自己也无法获取,这个项目就无法实现。

模型训练阶段负责训练模型,并判断训练的状态。模型测试阶段根据测试集计算各项指标,出一份测试报告,最后一步是部署运行。本文只涉及前三步,测试和部署不展开。

03 数据准备

本文用的数据集是ChnSentiCorp,每条数据包含一段评价文本和一个情感标签。

数据先在线加载到本地,格式是arrow,再用save_to_disk转存,之后每次只需要加载本地缓存。数据被分为训练集9600条,验证集和测试集各1200条。

python 复制代码
from datasets import load_dataset,load_from_disk

#1. 在线加载数据到本地,格式是arrow加密格式;
# dataset = load_dataset(path="lansinuote/ChnSentiCorp",cache_dir="data/")
# print(dataset)

#2. 转成 save_to_disk 格式
# dataset.save_to_disk("data/ChnSentiCorp")

#3. 缓存到本地之后只需加载缓存数据,打印如下,数据被分为训练集9600条,验证集和测试集各1200条
# DatasetDict({
#     train: Dataset({
#         features: ['text', 'label'],
#         num_rows: 9600
#     })
#     validation: Dataset({
#         features: ['text', 'label'],
#         num_rows: 1200
#     })
#     test: Dataset({
#         features: ['text', 'label'],
#         num_rows: 1200
#     })
# })
datasets = load_from_disk(r"D:\bert_demo\data\ChnSentiCorp")
print(datasets)

# 4. 打印测试集数据,示例:{'text': '配置算可以的,虽然显卡是瓶颈(vista下评分是4.0),不过性价比来说,值得了 看电影很爽,音质也可以,这个是最满意的一部分了 系统跑起来速度可以,考虑升级成旗舰版再装个XP', 'label': 1}
train_data = datasets["test"]
for data in train_data:
    print(data)

分类任务的数据集中,每个类别的数据量应该均衡,最好接近一比一。如果不均衡,就要自己先处理均衡,否则训练不会生效。原因在于分类任务本质上是在求概率,数据集中每个类别的样本数量,就是这个事件概率的样本空间。

加载数据集之后,还需要对它做处理,来适应模型的输入格式,这里通过继承Dataset来定义自己的数据集类。

python 复制代码
from torch.utils.data import Dataset
from datasets import load_from_disk

# 定义数据集类,加载数据集并转为模型的输入格式
# 这个处理数据代码是自己写的,可以自己适配数据的格式
# 而后面使用训练框架时,它的代码是写好的,所以需要数据去匹配人家的代码,把数据做成框架要求的格式。
class MyDataset(Dataset):
    #初始化数据集
    def __init__(self,split):
        #从磁盘加载数据
        self.dataset = load_from_disk(r"D:\bert_demo\data\ChnSentiCorp")
        if split == "train":
            self.dataset = self.dataset["train"]
        elif split == "test":
            self.dataset = self.dataset["test"]
        elif split == "validation":
            self.dataset = self.dataset["validation"]
        else:
            print("数据名错误!")

    #返回数据集长度
    def __len__(self):
        return len(self.dataset)

    #对每条数据单独做处理
    def __getitem__(self, item):
        text = self.dataset[item]["text"]
        label = self.dataset[item]["label"]

        return text,label

if __name__ == '__main__':
    dataset = MyDataset("train")
    for data in dataset:
        print(data)

04 文本编码

模型处理的是数字,不能直接读文字,所以文本要先转成数字再送进模型。这里加载bert-base-chinese自带的字典和分词器,对两个句子做批量编码。

python 复制代码
from transformers import BertTokenizer

#加载字典和分词器
token = BertTokenizer.from_pretrained(r"D:\bert_demo\model\bert-base-chinese")
# print(token)

#准备要编码的文本数据
sents = ["白日依山尽,",
         "价格在这个地段属于适中, 附近有早餐店,小饭店, 比较方便,无早也无所"]

#批量编码句子
out = token(
    text=sents,
    add_special_tokens=True, #是否添加特殊token
    truncation=True, #当句子长度大于最大上下文长度时,截断
    max_length=15,#提示词的总长度
    padding="max_length",#一律补0到max_length
    return_tensors=None,#可取值为tf,pt,np,默认为list
    return_attention_mask=True,
    return_token_type_ids=True,
    return_special_tokens_mask=True,
    return_length=True#返回序列长度
)

# 打印编码后的结果,输出如下:
# input_ids : [[101, 4635, 3189, 898, 2255, 2226, 8024, 102, 0, 0, 0, 0, 0, 0, 0], [101, 817, 3419, 1762, 6821, 702, 1765, 3667, 2247, 754, 6844, 704, 117, 7353, 102]]
# token_type_ids : [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]
# attention_mask : [[1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]]
# special_tokens_mask : [[1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1], [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]]
# length : [15, 15]
#input_ids:就是编码后的词,根据vocab.txt进行编码,101对应[CLS]句子开始,102是[SEP]句子结束,4635对应白等
#token_type_ids:第一个句子和特殊符号的位置是0,第二个句子的位置1,只有同时传入两段文本(句子对)进行编码时才会出现1,我们这里批量编码的都是单个句子,所以都是0
#attention_mask:告诉模型"这个位置是真实内容(1),还是为了凑长度补上去的填充(0)",值为 0 的位置会被注意力忽略
#special_tokens_mask:特殊符号的位置是1,其他位置是0
#length:编码之后的序列长度
for k,v in out.items():
    print(k,":",v)

#解码文本数据:[CLS] 白 日 依 山 尽 , [SEP] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [PAD] [CLS] 价 格 在 这 个 地 段 属 于 适 中, 附 [SEP]
print(token.decode(out["input_ids"][0]),token.decode(out["input_ids"][1]))

05 三种拟合状态与判断方法

训练一个模型,要判断它训练到了什么状态,状态分为欠拟合、拟合和过拟合三种,本文只针对分类问题。

判断的依据是损失,也就是loss。数据集分为训练集、验证集和测试集三部分,训练集用来训练,验证集用来判断模型是否拟合,测试集留到最后,根据它的各项指标出一份测试报告。训练时把训练集和验证集的loss放在一起看。

欠拟合时,两条loss都很高,模型还没有学会。拟合时,两条loss都降到了较低的位置,并且贴得很近。过拟合时,训练集的loss一直在下降,验证集的loss却降到某个位置后开始上升,说明模型把训练集记住了,在没见过的数据上反而表现变差。所以只看训练集的loss是不够的,验证集的loss才能说明模型真正学到了东西。

06 模型设计

模型由两部分组成,一部分是预训练好的BERT,另一部分是新增的全连接层。BERT输出的是每个token编码成的768维上下文向量,为下游任务提供特征。

下面的代码先加载BERT并打印它的结构,再定义增量模型。全连接层把768维向量转成2维,输出是每个标签的得分,推理时取得分最大的下标,得到0或1。BERT给每个token都输出了一个768维向量,代码里的last_hidden_state[:,0]取的是每条文本第0个位置的向量,也就是句首CLS对应的向量,只把这一个向量交给全连接层。

python 复制代码
import torch
from transformers import BertModel

#定义设备信息
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(DEVICE)

#加载预训练模型,输出模型:每个 token 编码成一个 768 维的上下文向量,提供特征,给下游任务使用
# BertModel(
#   (embeddings): BertEmbeddings(
#     (word_embeddings): Embedding(21128, 768, padding_idx=0)
#     (position_embeddings): Embedding(512, 768)
#     (token_type_embeddings): Embedding(2, 768)
#     (LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True, bias=True)
#     (dropout): Dropout(p=0.1, inplace=False)
#   )
#   (encoder): BertEncoder(
#     (layer): ModuleList(
#       (0-11): 12 x BertLayer(
#         (attention): BertAttention(
#           (self): BertSelfAttention(
#             (query): Linear(in_features=768, out_features=768, bias=True)
#             (key): Linear(in_features=768, out_features=768, bias=True)
#             (value): Linear(in_features=768, out_features=768, bias=True)
#             (dropout): Dropout(p=0.1, inplace=False)
#           )
#           (output): BertSelfOutput(
#             (dense): Linear(in_features=768, out_features=768, bias=True)
#             (LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True, bias=True)
#             (dropout): Dropout(p=0.1, inplace=False)
#           )
#         )
#         (intermediate): BertIntermediate(
#           (dense): Linear(in_features=768, out_features=3072, bias=True)
#           (intermediate_act_fn): GELUActivation()
#         )
#         (output): BertOutput(
#           (dense): Linear(in_features=3072, out_features=768, bias=True)
#           (LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True, bias=True)
#           (dropout): Dropout(p=0.1, inplace=False)
#         )
#       )
#     )
#   )
#   (pooler): BertPooler(
#     (dense): Linear(in_features=768, out_features=768, bias=True)
#     (activation): Tanh()
#   )
# )
pretrained = BertModel.from_pretrained(r"D:\bert_demo\model\bert-base-chinese").to(DEVICE)
print(pretrained)

#定义下游任务(增量模型)
class Model(torch.nn.Module):
    def __init__(self):
        super().__init__()
        #设计全连接网络,实现二分类任务,将768维向量转为2维,输出是每个标签的得分。
        # 比如 [-1.3, 2.1],意思是"负面 -1.3 分,正面 2.1 分";推理时取最大得分的下标pred = logits.argmax(dim=1) 得到 0 或 1
        self.fc = torch.nn.Linear(768,2)

    #使用模型处理数据(执行前向计算)
    def forward(self,input_ids,attention_mask,token_type_ids):
        #冻结Bert模型的参数,让其不参与训练
        with torch.no_grad():
            out = pretrained(input_ids=input_ids,attention_mask=attention_mask,token_type_ids=token_type_ids)
        #增量模型参与训练
        out = self.fc(out.last_hidden_state[:,0])
        return out

07 训练

训练时先要把文本编码成模型能接收的数字,再按批次送进模型,每轮训练结束后用验证集检查一次,并保存参数。导入的MyDataset和Model就是前面定义的数据集类和模型,分别保存在MyData.py和net.py中。

python 复制代码
#模型训练
import torch
from MyData import MyDataset
from torch.utils.data import DataLoader
from net import Model
from transformers import BertTokenizer
from torch.optim import AdamW

#定义设备信息
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
#定义训练的轮次(将整个数据集训练完一次为一轮)
EPOCH = 30000

#加载字典和分词器
token = BertTokenizer.from_pretrained(r"D:\bert_demo\model\bert-base-chinese")

#将传入的字符串进行编码
def collate_fn(data):
    sents = [i[0]for i in data]
    label = [i[1] for i in data]
    #编码
    data = token(
        text=sents,
        # 当句子长度大于max_length时,截断
        truncation=True,
        max_length=512,
        # 一律补0到max_length
        padding="max_length",
        # 可取值为tf,pt,np,默认为list
        return_tensors="pt",
        # 返回序列长度
        return_length=True
    )
    input_ids = data["input_ids"]
    attention_mask = data["attention_mask"]
    token_type_ids = data["token_type_ids"]
    label = torch.LongTensor(label)
    return input_ids,attention_mask,token_type_ids,label

#创建数据集
train_dataset = MyDataset("train")
train_loader = DataLoader(
    dataset=train_dataset,
    #训练批次,需要根据GPU显存调整
    batch_size=100,
    #打乱数据集
    shuffle=True,
    #舍弃最后一个批次的数据,防止形状出错:比如前面批次都是50条数据,最后一批是1条,则舍弃
    drop_last=True,
    #对加载的数据进行编码
    collate_fn=collate_fn
)

#创建验证数据集
val_dataset = MyDataset("validation")
val_loader = DataLoader(
    dataset=val_dataset,
    #训练批次
    batch_size=50,
    #打乱数据集
    shuffle=True,
    #舍弃最后一个批次的数据,防止形状出错
    drop_last=True,
    #对加载的数据进行编码
    collate_fn=collate_fn
)

if __name__ == '__main__':
    #开始训练
    print(DEVICE)
    model = Model().to(DEVICE)
    #定义优化器
    optimizer = AdamW(model.parameters())
    #定义损失函数
    loss_func = torch.nn.CrossEntropyLoss()

    #初始化验证最佳准确率
    best_val_acc = 0.0

    for epoch in range(EPOCH):
        for i,(input_ids,attention_mask,token_type_ids,label) in enumerate(train_loader):
            #将数据放到DEVICE上面
            input_ids, attention_mask, token_type_ids, label = input_ids.to(DEVICE),attention_mask.to(DEVICE),token_type_ids.to(DEVICE),label.to(DEVICE)
            #前向计算(将数据输入模型得到输出)
            out = model(input_ids,attention_mask,token_type_ids)
            #根据输出计算损失
            loss = loss_func(out,label)
            #根据误差优化参数
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

            #每隔5个批次输出训练信息
            if i%5 ==0:
                out = out.argmax(dim=1) #得到 0 或 1
                #计算训练精度
                acc = (out==label).sum().item()/len(label)
                print(f"epoch:{epoch},i:{i},loss:{loss.item()},acc:{acc}")

        #每轮训练结束验证模型(判断模型是否过拟合)
        #设置为评估模式
        model.eval()
        #不需要模型参与训练
        with torch.no_grad():
            val_acc = 0.0
            val_loss = 0.0
            for i, (input_ids, attention_mask, token_type_ids, label) in enumerate(val_loader):
                # 将数据放到DEVICE上面
                input_ids, attention_mask, token_type_ids, label = input_ids.to(DEVICE), attention_mask.to(
                    DEVICE), token_type_ids.to(DEVICE), label.to(DEVICE)
                # 前向计算(将数据输入模型得到输出)
                out = model(input_ids, attention_mask, token_type_ids)
                # 根据输出计算损失
                val_loss += loss_func(out, label).item()
                #根据数据,计算验证精度
                out = out.argmax(dim=1)
                val_acc+=(out==label).sum().item()
            #loss是每个批次的平均值,除以批次数;准确率累计的是正确的样本数,除以样本总数
            val_loss/=len(val_loader)
            val_acc/=len(val_dataset)
            print(f"验证集:loss:{val_loss},acc:{val_acc}")

            #根据验证准确率保存最优参数
            if val_acc > best_val_acc:
                best_val_acc = val_acc
                torch.save(model.state_dict(),"params/best_bert.pth")
                print(f"EPOCH:{epoch}:保存最优参数:acc{best_val_acc}")

        #保存最后一轮参数
        torch.save(model.state_dict(), "params/last_bert.pth")
        print(f"EPOCH:{epoch}:最后一轮参数保存成功!")

训练时的GPU使用情况如下,显卡是RTX 3050 Laptop,专用GPU内存4GB,训练时占用了3.2GB,batch_size需要根据显存大小来调整。

训练日志节选如下,每轮只保留第一个和最后一个批次的训练信息,以及这一轮结束后的验证结果。

复制代码
epoch:0,i:0,loss:0.7643778324127197,acc:0.59
...
epoch:0,i:95,loss:0.25920459628105164,acc:0.91
验证集:loss:0.3238421678543091,acc:0.8741666666666666
EPOCH:0:保存最优参数:acc0.8741666666666666
EPOCH:0:最后一轮参数保存成功!
epoch:1,i:0,loss:0.31255024671554565,acc:0.88
...
epoch:1,i:95,loss:0.3278043270111084,acc:0.88
验证集:loss:0.3263868987560272,acc:0.8716666666666667
EPOCH:1:最后一轮参数保存成功!
epoch:2,i:0,loss:0.2892961903642808,acc:0.89
...
epoch:2,i:95,loss:0.2784047817908864,acc:0.9
验证集:loss:0.3105103906176173,acc:0.8808333333333334
EPOCH:2:保存最优参数:acc0.8808333333333334
EPOCH:2:最后一轮参数保存成功!
epoch:3,i:0,loss:0.2641339368467219,acc:0.91
...
epoch:3,i:95,loss:0.25169725200560794,acc:0.91
验证集:loss:0.3068234381498016,acc:0.8833333333333333
EPOCH:3:保存最优参数:acc0.8833333333333333
EPOCH:3:最后一轮参数保存成功!
epoch:4,i:0,loss:0.27880062730184496,acc:0.9
...
epoch:4,i:95,loss:0.239506990786308,acc:0.92
验证集:loss:0.30886752987649835,acc:0.8825
EPOCH:4:最后一轮参数保存成功!
epoch:5,i:0,loss:0.2425747728275084,acc:0.92
...
epoch:5,i:95,loss:0.23620095269112334,acc:0.92
验证集:loss:0.31118427210100996,acc:0.8816666666666667
EPOCH:5:最后一轮参数保存成功!

训练集的准确率随着训练逐渐上升,验证集的准确率则在0.88左右趋于稳定。第一轮、第三轮和第四轮的验证准确率刷新了最高值,这几轮除了保存最后一轮的参数,还额外保存了最优参数。

小结

这篇文章用增量微调的方式,让BERT完成了中文情感二分类。BERT冻结不动,只负责把文本编码成768维向量,新增的一层全连接层把向量转成2个得分,取得分最大的下标就是预测结果,训练时只更新这一层的参数。

整个项目从数据准备走到了训练。数据要先保证各类别均衡,文本经过分词和编码变成数字再送进模型。训练时每一轮结束都用验证集检查一次,通过训练集和验证集的loss判断模型处于欠拟合、拟合还是过拟合,同时根据验证准确率保存最优参数,这个项目的验证准确率在0.87左右。

相关推荐
老徐聊GEO1 小时前
GEO 复测的差分口径:2026 年同批题、同参数、同平台怎么落地
人工智能·geo优化
愤怒的代码1 小时前
什么是 Agent Loop
人工智能·agent
正飞信息技术1 小时前
llms.txt部署指南:给AI爬虫一张清晰的网站地图
人工智能·爬虫
Zhao-Jichao1 小时前
AI Prompting for Everyone 课程笔记 - P08 上下文 Context
人工智能·笔记
Token_w1 小时前
手机远控跑Claude Code:三个AI任务同时运行,ToDesk/UU远程/向日葵终端功能对比
人工智能·智能手机·todesk
鬼手点金1 小时前
Skills:领域能力的可组合单元
java·人工智能·笔记·自动化·1024程序员节
jianjinwssy1 小时前
从零设计一个 Agent 安全沙箱:Docker 隔离、三级风控与 Kafka 审计
人工智能·docker·kafka
深蓝AI4 小时前
不调模型不改壳:CMU 开源 Hermit,给 Agent 外挂一个能训练的适配器
人工智能·agent
汤姆宝库6 小时前
世界模型深拆:视频生成、潜在模拟与物理直觉
人工智能