情感分析本质是一个文本分类任务。PaddleNLP 内置了 ERNIE、BERT、RoBERTa、Electra 等丰富的预训练模型,并且内置了各种预训练模型对于不同下游任务的Fine-tune网络。用户可以使用 PaddleNLP 提供的模型,完成问答、序列分类、token分类等任务。这里以 ERNIE 模型为例,介绍如何将预训练模型 Fine-tune 完成文本分类任务。
整个案例分为:数据处理、训练评估、模型预测三部分,接下分别实现下这三部分,公共部分代码:
# Ernie 模型分词器
from paddlenlp.transformers import ErnieTokenizer
# Ernie 文本分类模型
from paddlenlp.transformers import ErnieForSequenceClassification
# Paddle 加载数据集
from paddlenlp.datasets import load_dataset
# Paddle 数据加载器
from paddle.io import DataLoader
import paddle
# 进度条
from tqdm import tqdm
# 动态学习率
from paddlenlp.transformers import ConstScheduleWithWarmup
# 优化方法
from paddle.optimizer import AdamW
# 损失函数
from paddle.nn import CrossEntropyLoss
# 评估函数
from paddle.metric import Accuracy
# 功能函数
import paddle.nn.functional as F
# 读写目录
import glob
import math
# 预训练模型参数
checkpoint = 'ernie-3.0-medium-zh'
# 设置默认计算设备, 对于 GPU 版本的 Paddle 默认设备就是 GPU
paddle.device.set_device('gpu:0')
1. 数据处理
在《中文情感分类任务》案例中,我们使用 ChnSentiCorp 语料,该语料不需要我们准备,可以直接通过 Paddle 的 load_dataset 接口下载获得。在 Paddle 中还提供了阅读理解类、文本分类、文本匹配、序列标注、机器翻译、对话系统、文本生成等等数据集的访问,这些数据集都可以通过 load_dataset 下载获得。
详细介绍:https://paddlenlp.readthedocs.io/zh/latest/data_prepare/dataset_list.html
ChnSentiCorp 语料中包含训练集有 9600 条数据,验证集和测试集分别有 1200 条标签 0 表示负面评论,标签 1 表示正向评论。每条数据的格式如下:
{'text': '来江门出差一直都住这个酒店,觉得性价比还可以', 'label': 1, 'qid': ''}
Ernie 是以字为单位处理中文。Paddle 对于各种预训练模型内置了相应的 Tokenizer。我们这里使用 ErnieTokenizer 来将文本处理成模型需要的输入格式。
示例代码:
# 初始化 Ernie 分词器
tokenizer = ErnieTokenizer.from_pretrained(checkpoint)
def load_data(batch_size=2):
# 加载训练集、验证集、测试集
train, valid = load_dataset('chnsenticorp', splits=['train', 'dev'])
def collate_fn(batch_data):
batch_inputs = []
batch_labels = []
# data 格式:
for data in batch_data:
batch_inputs.append(data['text'])
batch_labels.append(data['label'])
# 文本数据分词、编码
batch_inputs = tokenizer(batch_inputs,
padding=True,
truncation=True,
max_length=510,
add_special_tokens=True,
return_tensors='pd')
# 标签转换为 Paddle 张量
batch_labels = paddle.to_tensor(batch_labels)
return batch_inputs, batch_labels
# 数据加载器
params = {'batch_size': batch_size, 'collate_fn': collate_fn}
train_dataloader = DataLoader(train, **params, shuffle=True)
valid_dataloader = DataLoader(valid, **params, shuffle=False)
return train_dataloader, valid_dataloader
def test01():
train_dataloader, _ = load_data()
for batch_inputs, batch_labels in train_dataloader:
print('标签数量:', len(batch_labels))
print('输入编码:', batch_inputs['input_ids'])
break
程序运行结果:
标签数量: 2
输入编码: Tensor(shape=[2, 166], dtype=int64, place=Place(gpu:0), stop_gradient=True,
[[1 , 321 , 170 , 5 , 661 , 737 , 30 , 879 , 321 , 19 ,
12046, 573 , 21 , 143 , 5 , 94 , 159 , 321 , 170 , 42 ,
321 , 9 , 419 , 133 , 92 , 12046, 345 , 1183 , 109 , 520 ,
240 , 125 , 312 , 144 , 654 , 231 , 112 , 141 , 51 , 75 ,
5 , 836 , 523 , 1183 , 1183 , 12046, 12046, 2 , 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 , 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 , 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 , 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 ],
[1 , 31 , 38 , 144 , 521 , 79 , 11 , 10 , 170 , 12043,
75 , 520 , 5 , 10 , 269 , 8 , 19 , 879 , 458 , 4 ,
71 , 1207 , 7 , 335 , 1186 , 187 , 1057 , 810 , 458 , 7 ,
314 , 4 , 16 , 93 , 7 , 27 , 96 , 1207 , 9 , 1689 ,
85 , 14 , 128 , 367 , 4 , 770 , 7 , 27 , 96 , 1207 ,
297 , 52 , 364 , 86 , 7 , 501 , 879 , 14 , 128 , 367 ,
4 , 79 , 11 , 96 , 116 , 48 , 2547 , 12043, 196 , 13 ,
35 , 25 , 110 , 22 , 340 , 9 , 11 , 661 , 737 , 943 ,
748 , 1134 , 4 , 255 , 10 , 51 , 76 , 5 , 3485 , 1082 ,
1082 , 1303 , 792 , 1869 , 5 , 524 , 408 , 8 , 749 , 528 ,
365 , 786 , 4 , 1239 , 7 , 5 , 924 , 2842 , 113 , 10 ,
297 , 9 , 12 , 1598 , 88 , 232 , 4 , 1010 , 28 , 16 ,
372 , 26 , 12058, 12058, 75 , 313 , 11 , 661 , 737 , 5 ,
278 , 112 , 12 , 111 , 1042 , 706 , 98 , 1039 , 4 , 1186 ,
187 , 171 , 612 , 264 , 16 , 52 , 1697 , 777 , 4 , 297 ,
52 , 87 , 192 , 215 , 2226 , 4 , 196 , 198 , 340 , 1042 ,
33 , 4 , 16 , 2175 , 12044, 2 ]])
2. 训练评估
Paddle 目前提供了40 多个主流预训练模型,500多个模型权重。详细信息通过下面的链接查看:
https://paddlenlp.readthedocs.io/zh/latest/model_zoo/index.html
我们使用的预训练模型 “ernie-3.0-medium-zh” 信息如下:
- 6-layer
- 384-hidden
- 12-heads
- 27M parameters
- Trained on Chinese text
我们这里使用 ConstScheduleWithWarmup 的学习率策略:
from paddlenlp.transformers import ConstScheduleWithWarmup
import matplotlib.pyplot as plt
if __name__ == '__main__':
scheduler = ConstScheduleWithWarmup(learning_rate=1,
total_steps=1000,
warmup=0.1)
learning_rates = []
for _ in range(1000):
learning_rates.append(scheduler.get_lr())
scheduler.step()
plt.plot(range(1000), learning_rates)
plt.show()
输出图像:

学习率先由前 10% 的 step 从 0 上升到指定的 learning rate, 然后保持学习率不变到训练结束。
训练代码主要包含三部分:
- 初始化训练对象(模型、优化方法、学习率调节器、损失函数等等)
- 训练过程
- 评估过程
- 模型保存
我们将训练过程中使用到的对象保存到磁盘中,便于模型加载以及继续训练:
- 模型数据(模型对象配置参数、权重参数)
- 分词器数据(分词器对象配置参数、特殊标记映射、词表)
- 优化器参数
- 学习率调节器参数
. ├── model_config.json # 模型对象配置文件 ├── model_state.pdparams # 模型权重参数文件 ├── optimizer.optparams # 优化器相关参数文件,自定义文件名 ├── scheduler.optparams # 学习率调节器参数文件,自定义文件名 ├── special_tokens_map.json # 分词器特殊标记映射文件 ├── tokenizer_config.json # 分词器对象配置文件 └── vocab.txt # 分词器词表文件
此次训练产生的模型文件列表如下:
├── senti-10-0.93917 ├── senti-1-0.90250 ├── senti-2-0.93083 ├── senti-3-0.90333 ├── senti-4-0.94417 ├── senti-5-0.94083 ├── senti-6-0.93917 ├── senti-7-0.93750 ├── senti-8-0.92167 └── senti-9-0.93583
模型文件网盘链接:https://pan.baidu.com/s/1lMha9QULoUEi2FarVf4bpw 提取码: d3at
训练过程采用了累加梯度的训练方式。完整训练函数代码:
def train_model():
# **************************初始化训练对象***************************
# 加载数据集
train_set, valid_set = load_data()
# 初始化模型
estimator = ErnieForSequenceClassification.from_pretrained(checkpoint,
num_classes=2)
# 训练轮数
num_epoch = 10
# 动态学习率
scheduler = ConstScheduleWithWarmup(learning_rate=1e-5,
total_steps=len(train_set)*num_epoch,
warmup=1e-1)
# 优化方法
optimizer = AdamW(parameters=estimator.parameters(),
learning_rate=scheduler)
# 损失函数
criterion = CrossEntropyLoss(use_softmax=True)
# 评估方法
metric = Accuracy()
# 累加梯度
accumulation_steps = 16
# 训练逻辑
for epoch_idx in range(num_epoch):
# **************************训练集训练*************************
optimizer.clear_grad()
progress = tqdm(range(len(train_set)),
desc='epoch %2d' % (epoch_idx + 1))
total_loss = 0.0 # 轮次损失
stage_loss = 0.0 # 阶段损失
for iter_idx, (batch_inputs, batch_labels) in enumerate(train_set):
# 模型计算
outputs = estimator(**batch_inputs)
# 损失计算
loss = criterion(outputs, batch_labels)
# 训练信息
total_loss += loss.item() * len(batch_labels)
stage_loss += loss.item() * len(batch_labels)
# 梯度计算
(loss / accumulation_steps).backward()
# 参数更新
if iter_idx % accumulation_steps == 0:
optimizer.step()
optimizer.clear_grad()
progress.set_description('epoch % 2d stage loss %.5f'
% (epoch_idx + 1, stage_loss))
stage_loss = 0.0
# 学习率更新
scheduler.step()
# 进度条更新
progress.update()
# 设置轮次损失
progress.set_description('epoch % 2d total loss %.5f'
% (epoch_idx + 1, total_loss))
# 关闭进度条
progress.close()
# **************************验证集评估*************************
with paddle.no_grad():
progress = tqdm(range(len(valid_set)), desc='evaluate')
metric.reset()
for iter_idx, (batch_inputs, batch_labels) in enumerate(valid_set):
# 模型计算
outputs = estimator(**batch_inputs)
# 计算概率
probas = F.softmax(outputs)
# 标签预测
correct = metric.compute(probas, batch_labels)
metric.update(correct)
# 进度条更新
progress.update()
# 设置轮次损失
acc = metric.accumulate()
progress.set_description('evaluate acc %.3f' % acc)
# 关闭进度条
progress.close()
# **************************模型保存***************************
model_save_dir = 'model/senti-%d-%.5f' % (epoch_idx + 1, acc)
estimator.save_pretrained(model_save_dir)
tokenizer.save_pretrained(model_save_dir)
paddle.save(optimizer.state_dict(), '%s/optimizer.optparams' % model_save_dir)
paddle.save(scheduler.state_dict(), '%s/scheduler.optparams' % model_save_dir)
模型训练过程输出结果:
epoch 1 total loss 3610.87820: 100%|███████| 4800/4800 [02:17<00:00, 34.96it/s] evaluate acc 0.902: 100%|█████████████████████| 600/600 [00:07<00:00, 85.53it/s] epoch 2 total loss 1862.36450: 100%|███████| 4800/4800 [02:16<00:00, 35.18it/s] evaluate acc 0.931: 100%|█████████████████████| 600/600 [00:06<00:00, 86.07it/s] epoch 3 total loss 1252.31939: 100%|███████| 4800/4800 [02:16<00:00, 35.19it/s] evaluate acc 0.903: 100%|█████████████████████| 600/600 [00:07<00:00, 85.53it/s] epoch 4 total loss 807.33625: 100%|████████| 4800/4800 [02:17<00:00, 34.96it/s] evaluate acc 0.944: 100%|█████████████████████| 600/600 [00:07<00:00, 85.12it/s] epoch 5 total loss 595.85895: 100%|████████| 4800/4800 [02:16<00:00, 35.08it/s] evaluate acc 0.941: 100%|█████████████████████| 600/600 [00:06<00:00, 86.93it/s] epoch 6 total loss 377.61753: 100%|████████| 4800/4800 [02:16<00:00, 35.26it/s] evaluate acc 0.939: 100%|█████████████████████| 600/600 [00:07<00:00, 85.46it/s] epoch 7 total loss 326.36152: 100%|████████| 4800/4800 [02:16<00:00, 35.05it/s] evaluate acc 0.938: 100%|█████████████████████| 600/600 [00:06<00:00, 86.24it/s] epoch 8 total loss 269.62589: 100%|████████| 4800/4800 [02:16<00:00, 35.10it/s] evaluate acc 0.922: 100%|█████████████████████| 600/600 [00:06<00:00, 86.11it/s] epoch 9 total loss 237.38869: 100%|████████| 4800/4800 [02:16<00:00, 35.21it/s] evaluate acc 0.936: 100%|█████████████████████| 600/600 [00:06<00:00, 86.82it/s] epoch 10 total loss 224.89601: 100%|███████| 4800/4800 [02:16<00:00, 35.23it/s] evaluate acc 0.939: 100%|█████████████████████| 600/600 [00:06<00:00, 86.99it/s]
3. 模型预测
测试集数据是没有标签的,我们可以逐个去打出模型对每个样本的预测标签,修改 epoch_index 来选择使用不同的模型进行预测。完整示例代码如下:
def predict():
# 获得模型名字
epoch_index = 3
model_name = glob.glob('model/*')[epoch_index]
# 读取模型
tokenizer = ErnieTokenizer.from_pretrained(model_name)
estimator = ErnieForSequenceClassification.from_pretrained(model_name)
def load_test(batch_size=2):
test = load_dataset('chnsenticorp', splits='test')
def collate_fn(model_inputs):
return tokenizer(model_inputs['text'],
truncation=True,
max_length=510,
add_special_tokens=True,
return_tensors='pd'), model_inputs['text']
params = {'batch_size': None, 'collate_fn': collate_fn, 'shuffle': True}
return DataLoader(test, **params)
# 获得验证集
test_set = load_test()
with paddle.no_grad():
for model_inputs, input_text in test_set:
# 模型计算
outputs = estimator(**model_inputs)
# 计算概率
proba0, proba1 = F.softmax(outputs).squeeze().numpy().tolist()
# 输出结果
print('【内容】:', input_text)
print('【预测】:', '负面评价: {:.2f}, 正面评价: {:.2f}'.format(proba0, proba1))
print('-' * 70)
answer = input('是否继续任意键继续(n/N退出):')
if answer in ['n', 'N']:
break
程序执行结果:
【内容】: 这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般 【预测】: 负面评价: 0.48, 正面评价: 0.52 ---------------------------------------------------------------------- 是否继续任意键继续(n/N退出): 【内容】: 怀着十分激动的心情放映,可是看着看着发现,在放映完毕后,出现一集米老鼠的动画片!开始还怀疑是不是赠送的个别现象,可是后来发现每张DVD后面都有!真不知道生产商怎么想的,我想看的是猫和老鼠,不是米老鼠!如果厂家是想赠送的话,那就全套米老鼠和唐老鸭都赠送,只在每张DVD后面添加一集算什么??简直是画蛇添足!! 【预测】: 负面评价: 0.97, 正面评价: 0.03 ---------------------------------------------------------------------- 是否继续任意键继续(n/N退出): 【内容】: 还稍微重了点,可能是硬盘大的原故,还要再轻半斤就好了。其他要进一步验证。贴的几种膜气泡较多,用不了多久就要更换了,屏幕膜稍好点,但比没有要强多了。建议配赠几张膜让用用户自己贴。 【预测】: 负面评价: 0.99, 正面评价: 0.01 ---------------------------------------------------------------------- 是否继续任意键继续(n/N退出): 【内容】: 交通方便;环境很好;服务态度很好 房间较小 【预测】: 负面评价: 0.01, 正面评价: 0.99 ---------------------------------------------------------------------- 是否继续任意键继续(n/N退出): 【内容】: 不错,作者的观点很颠覆目前中国父母的教育方式,其实古人们对于教育已经有了很系统的体系了,可是现在的父母以及祖父母们更多的娇惯纵容孩子,放眼看去自私的孩子是大多数,父母觉得自己的孩子在外面只要不吃亏就是好事,完全把古人几千年总结的教育古训抛在的九霄云外。所以推荐准妈妈们可以在等待宝宝降临的时候,好好学习一下,怎么把孩子教育成一个有爱心、有责任心、宽容、大度的人。 【预测】: 负面评价: 0.02, 正面评价: 0.98 ---------------------------------------------------------------------- 是否继续任意键继续(n/N退出):n

冀公网安备13050302001966号