Ernie 中文情感分类任务

情感分析本质是一个文本分类任务。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” 信息如下:

  1. 6-layer
  2. 384-hidden
  3. 12-heads
  4. 27M parameters
  5. 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, 然后保持学习率不变到训练结束。

训练代码主要包含三部分:

  1. 初始化训练对象(模型、优化方法、学习率调节器、损失函数等等)
  2. 训练过程
  3. 评估过程
  4. 模型保存

我们将训练过程中使用到的对象保存到磁盘中,便于模型加载以及继续训练:

  1. 模型数据(模型对象配置参数、权重参数)
  2. 分词器数据(分词器对象配置参数、特殊标记映射、词表)
  3. 优化器参数
  4. 学习率调节器参数
.
├── 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
未经允许不得转载:一亩三分地 » Ernie 中文情感分类任务
评论 (0)

8 + 8 =