这篇文章用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左右。