情感分析本质是一个文本分类任务。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