微调 Bert 实现评论分类

数据集是中文的酒店评论,共有 50216 + 12555 条评论,前者是训练集,后者是验证集。clean_data 函数是对评论做的一些简单的处理。train_data 的数据对象为:

DatasetDict({
    train: Dataset({
        features: ['review', '__index_level_0__', 'labels', 'input_ids', 'token_type_ids', 'attention_mask'],
        num_rows: 50216
    })
    valid: Dataset({
        features: ['review', '__index_level_0__', 'labels', 'input_ids', 'token_type_ids', 'attention_mask'],
        num_rows: 12555
    })
})

我们最终会将数据序列化到 data/senti-dataset 文件中,数据文件结构如下:

senti-dataset/
├── dataset_dict.json
├── test
│   ├── dataset.arrow
│   ├── dataset_info.json
│   └── state.json
├── train
│   ├── dataset.arrow
│   ├── dataset_info.json
│   └── state.json
└── valid
    ├── dataset.arrow
    ├── dataset_info.json
    └── state.json

完整代码如下:

from datasets import load_dataset
from datasets import Dataset
import pandas as pd
import zhconv
import re
from transformers import BertTokenizer


def clean_data(inputs: str):

    # 繁体转简体
    inputs = zhconv.convert(inputs, 'zh-hans')

    # 大写转小写
    inputs = inputs.lower()

    # 删除 "免费注册 网站导航..."
    start = inputs.find('免费注册')
    if start != -1:
        inputs = inputs[:start]

    # 去除除了中文、数字、字母、逗号、句号、问号
    # inputs = inputs.replace(',', ',')
    # inputs = inputs.replace('!', '!')
    # inputs = inputs.replace('?', '?')
    # inputs = inputs.replace('.', '。')
    inputs = re.sub(r'[^\u4e00-\u9fa50-9a-z]', ' ', inputs)
    # 替换连续重复
    inputs = re.sub(r'(.)\1+', r'\1', inputs)
    # 去除多余空格
    inputs = ' '.join(inputs.split())

    return inputs


def preprocess():

    tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')

    train_data = pd.read_csv('data/online_shopping_10_cats.csv')
    train_data = train_data[['label', 'review']]
    train_data = train_data.dropna()
    # DataFrame 转换为 Dataset 对象
    train_data = Dataset.from_pandas(train_data)

    # 处理数据
    # 注意: Trainer 要求输入的数据带有 labels 标签
    train_data = train_data.map(lambda x: {'labels': x['label'], 'review': clean_data(x['review'])})
    # 过滤空数据
    train_data = train_data.filter(lambda x: len(x['review']) != 0)
    # map 函数会将返回的字典并到原来的字典中
    train_data = train_data.map(
        lambda x: tokenizer(x['review'], truncation=True, padding='max_length', max_length=256),
        batched=True)
    # 删除某些列
    train_data = train_data.remove_columns(['label'])

    # 分割数据集
    train_data = train_data.train_test_split(test_size=0.2)
    train_data['valid'] = train_data.pop('test')

    print(train_data)

    # 存储数据
    train_data.save_to_disk('data/senti-dataset')


if __name__ == '__main__':
    preprocess()

我们在 bert-base-chinese 中文预训练模型的基础上进行微调,以适应在新的数据集-中文酒店评论上进行文本分类。在这里我们使用 Train 类来完成中文评论分类模型的训练。我们训练时,只训练下游任务的参数部分。

from datasets import load_from_disk
from transformers import BertForSequenceClassification
from transformers import TrainingArguments
from transformers import Trainer
from sklearn.metrics import accuracy_score, f1_score, recall_score, precision_score
from sklearn.metrics import precision_recall_fscore_support

1. TrainingArguments

Trainer 要求我们传递一些训练相关的参数,即: 构建 TrainingArguments 对象,其具体参数如下:

  1. output_dir 参数为必须添加的参数,它主要指定了模型训练过程中需要输出的内容,例如:保存 checkpoint 的路径
  2. per_device_train_batch_size 参数设置 batch_size 大小,由于 Bert 模型很大,如果在 GPU 训练时出现 Cuda 内存分配失败,则应该相应调低该值,我的显卡为 RTX 2060,显存大小为 6G,在训练时就报错了,所以,我把这个参数设置为 2
  3. overwrite_output_dir 参数表示覆盖输出目录中的内容
  4. num_train_epochs 参数表示训练的 Epoch 数量
  5. learning_rate 这个不用说了,设置学习率
  6. eval_steps 参数表示每次训练多少步时,使用设置的验证集进行一次评估。我这里设置的是 10000,这是因为我的总的迭代次数是 19 万多,设置太小,评估过于频繁了,这个根据自己情况设置就好
  7. save_steps 参数表示训练迭代多少次,也就是训练多少个 batch 保存一次模型
  8. logging_dir 表示日志的输出目录
  9. metric_for_best_model 因为我们在训练过程中会输出多个 checkpoint,这个参数用来指定评估最优模型的指标是什么,我们这里使用的时 precision,如果不指定的话,默认是根据 loss 值来评定最优模型

2. Trainer

Trainer 是 Transformers 封装的用于模型训练的高级 API,可以帮助我们快速搭建训练过程。训练机器配置为:centos7.8 + torch1.8.2 + cuda11 + 10代i5 + 16G固态内存 + RTX 2060 显卡,训练时间大概在 3 小时 30 分钟左右。

我们设置的训练参数如下:

  1. 训练轮数:20
  2. 每 2000 次迭代存储一次模型
  3. 初始学习率设置为 2e-5
  4. 优化方法使用的是 AdamW
  5. 每次迭代送入模型的数据是 32 个
  6. 每次迭代更新一次梯度
  7. 模型存储目录为当前的 model 目录下

完整训练代码如下:

def train():

    # 1. 读取数据集
    data = load_from_disk('data/senti-dataset')
    # 将数据类型转换为张量
    data.set_format('pytorch', columns=['labels', 'input_ids', 'token_type_ids', 'attention_mask'])
    # 训练集
    train_data = data['train'].remove_columns(['review', '__index_level_0__'])
    # 验证集
    # valid_data = data['valid'].remove_columns(['review', '__index_level_0__'])

    # 2. 训练器
    train_model = BertForSequenceClassification.from_pretrained(
        'bert-base-chinese',
        num_labels=2)
    train_model.to('cuda')
    # 固定基础编码器的参数
    for param in train_model.base_model.parameters():
        param.requires_grad = False


    train_args = TrainingArguments(output_dir='model',
                                   overwrite_output_dir=True,
                                   save_steps=2000,
                                   per_device_train_batch_size=32,
                                   num_train_epochs=20,
                                   learning_rate=2e-5,
                                   evaluation_strategy='no',  # 训练期间不进行评估
                                   # eval_steps=1000,
                                   logging_strategy='epoch',
                                   logging_dir='logs',
                                   gradient_accumulation_steps=1,  # 每n个step更新一次参数
                                   metric_for_best_model='accuracy')

    trainer = Trainer(train_model,
                      train_args,
                      train_dataset=train_data)

    # 训练模型
    trainer.train()
    # 存储模型
    trainer.save_model('model/checkpoint-final')

训练过程输出如下:

******************* Running training *******************
  Num examples = 50216
  Num Epochs = 20
  Instantaneous batch size per device = 32
  Total train batch size (w. parallel, distributed & accumulation) = 32
  Gradient Accumulation steps = 1
  Total optimization steps = 31400
*******************************************************
{'loss': 0.5807, 'learning_rate': 1.9e-05, 'epoch': 1.0}
{'loss': 0.4634, 'learning_rate': 1.8e-05, 'epoch': 2.0}
{'loss': 0.4168, 'learning_rate': 1.7e-05, 'epoch': 3.0}
{'loss': 0.3925, 'learning_rate': 1.6e-05, 'epoch': 4.0}
{'loss': 0.3779, 'learning_rate': 1.5e-05, 'epoch': 5.0}
{'loss': 0.366,  'learning_rate': 1e-05,   'epoch': 6.0}
{'loss': 0.3604, 'learning_rate': 1e-05,   'epoch': 7.0}
{'loss': 0.355,  'learning_rate': 1e-05,   'epoch': 8.0}
{'loss': 0.3507, 'learning_rate': 1e-05,   'epoch': 9.0}
{'loss': 0.3486, 'learning_rate': 1e-05,   'epoch': 10.0}
{'loss': 0.344,  'learning_rate': 9e-06,   'epoch': 11.0}
{'loss': 0.3427, 'learning_rate': 8e-06,   'epoch': 12.0}
{'loss': 0.341,  'learning_rate': 7e-06,   'epoch': 13.0}
{'loss': 0.3393, 'learning_rate': 6e-06,   'epoch': 14.0}
{'loss': 0.3404, 'learning_rate': 5e-06,   'epoch': 15.0}
{'loss': 0.3395, 'learning_rate': 4e-06,   'epoch': 16.0}
{'loss': 0.3386, 'learning_rate': 3e-06,   'epoch': 17.0}
{'loss': 0.3367, 'learning_rate': 2e-06,   'epoch': 18.0}
{'loss': 0.3361, 'learning_rate': 1e-06,   'epoch': 19.0}
{'loss': 0.34,   'learning_rate': 0.0,     'epoch': 20.0}

3. 训练日志

另外,模型训练结束后会在 logging_dir 目录下输出 events.out.tfevents文件,该文件可以通过 tensorboard 可视化工具来查看输出日志,命令为:

tensorboard --logdir=./logs --port=8008

我们在前面的训练过程中,共产生多个 checkpoint, 分别如下:

checkpoint-10000  checkpoint-18000  checkpoint-24000  checkpoint-4000
checkpoint-12000  checkpoint-2000   checkpoint-26000  checkpoint-6000
checkpoint-14000  checkpoint-20000  checkpoint-28000  checkpoint-8000
checkpoint-16000  checkpoint-22000  checkpoint-30000  checkpoint-final

我们接下来,使用测试集分别在不同的 checkpoint 下去评估下模型的准确率、精度、召回率,还有 f1-score。

1. 模型评估

在我电脑上,使用所有的 12555 测试集评估一次大概需要 2 分钟多,我们共有 16 个模型,评估所有的模型需要 35 分钟左右。

完成模型评估代码如下:

def evaluate(model_path, valid_data):

    # 评估模型
    test_model = BertForSequenceClassification.from_pretrained(model_path, num_labels=2)
    test_model.to('cuda')

    with torch.no_grad():

        all_y_true = []
        all_y_pred = []

        def eval(inputs):

            outputs = test_model(**inputs)
            y_true = inputs['labels']
            y_pred = torch.argmax(outputs.logits, dim=-1)

            all_y_true.extend(y_true.cpu().numpy().tolist())
            all_y_pred.extend(y_pred.cpu().numpy().tolist())


        valid_data.map(eval, batched=True, batch_size=32)

        # 评估预测结果
        accuracy = accuracy_score(all_y_true, all_y_pred)
        precis_0 = precision_score(all_y_true, all_y_pred, pos_label=0)
        precis_1 = precision_score(all_y_true, all_y_pred, pos_label=1)
        recall_0 = recall_score(all_y_true, all_y_pred, pos_label=0)
        recall_1 = recall_score(all_y_true, all_y_pred, pos_label=1)
        fscore_0 = f1_score(all_y_true, all_y_pred, pos_label=0)
        fscore_1 = f1_score(all_y_true, all_y_pred, pos_label=1)

        print('------测试集报告------')
        print('accuracy: %.5f' % accuracy)
        print('--------------------')
        print('precis_0: %.5f' % precis_0)
        print('precis_1: %.5f' % precis_1)
        print('--------------------')
        print('recall_0: %.5f' % recall_0)
        print('recall_1: %.5f' % recall_1)
        print('--------------------')
        print('fscore_0: %.5f' % fscore_0)
        print('fscore_1: %.5f' % fscore_1)
        print('--------------------')


def evaluate_models():

    data = load_from_disk('data/senti-dataset')
    data.set_format('pytorch',
                    columns=['labels', 
                             'input_ids', 
                             'token_type_ids', 
                             'attention_mask'],
                    device='cuda')
    valid_data = data['valid'].remove_columns(['review', '__index_level_0__'])
    print('数据总量:', valid_data.num_rows)
    
    # 1. 获得模型路径
    model_path_list = glob.glob('model/*')
    for model_path in model_path_list:
        model_name = model_path.replace('model/', '')
        print('-' * 20 + model_name + '-' * 20)
        evaluate(model_path, valid_data)

评估函数输出结果如下:

accuracy: 0.86874
--------------------
precis_0: 0.86322
precis_1: 0.87424
--------------------
recall_0: 0.87268
recall_1: 0.86488
--------------------
fscore_0: 0.86793
fscore_1: 0.86954
--------------------

--------------------checkpoint-8000--------------------
accuracy: 0.87360
--------------------
precis_0: 0.86443
precis_1: 0.88294
--------------------
recall_0: 0.88268
recall_1: 0.86472
--------------------
fscore_0: 0.87346
fscore_1: 0.87374
--------------------

--------------------checkpoint-10000--------------------
accuracy: 0.87686
--------------------
precis_0: 0.86737
precis_1: 0.88655
--------------------
recall_0: 0.88638
recall_1: 0.86756
--------------------
fscore_0: 0.87677
fscore_1: 0.87695
--------------------

--------------------checkpoint-12000--------------------
accuracy: 0.87814
--------------------
precis_0: 0.87215
precis_1: 0.88412
--------------------
recall_0: 0.88284
recall_1: 0.87354
--------------------
fscore_0: 0.87746
fscore_1: 0.87880
--------------------

--------------------checkpoint-14000--------------------
accuracy: 0.88037
--------------------
precis_0: 0.87308
precis_1: 0.88772
--------------------
recall_0: 0.88687
recall_1: 0.87402
--------------------
fscore_0: 0.87992
fscore_1: 0.88081
--------------------

--------------------checkpoint-16000--------------------
accuracy: 0.88220
--------------------
precis_0: 0.87224
precis_1: 0.89238
--------------------
recall_0: 0.89234
recall_1: 0.87228
--------------------
fscore_0: 0.88218
fscore_1: 0.88222
--------------------

--------------------checkpoint-18000--------------------
accuracy: 0.88284
--------------------
precis_0: 0.87631
precis_1: 0.88939
--------------------
recall_0: 0.88832
recall_1: 0.87748
--------------------
fscore_0: 0.88227
fscore_1: 0.88339
--------------------

--------------------checkpoint-20000--------------------
accuracy: 0.88371
--------------------
precis_0: 0.87677
precis_1: 0.89070
--------------------
recall_0: 0.88977
recall_1: 0.87780
--------------------
fscore_0: 0.88322
fscore_1: 0.88420
--------------------

--------------------checkpoint-22000--------------------
accuracy: 0.88491
--------------------
precis_0: 0.87363
precis_1: 0.89652
--------------------
recall_0: 0.89686
recall_1: 0.87323
--------------------
fscore_0: 0.88509
fscore_1: 0.88472
--------------------

--------------------checkpoint-24000--------------------
accuracy: 0.88586
--------------------
precis_0: 0.87622
precis_1: 0.89570
--------------------
recall_0: 0.89557
recall_1: 0.87638
--------------------
fscore_0: 0.88579
fscore_1: 0.88593
--------------------

--------------------checkpoint-26000--------------------
accuracy: 0.88515
--------------------
precis_0: 0.87322
precis_1: 0.89747
--------------------
recall_0: 0.89799
recall_1: 0.87260
--------------------
fscore_0: 0.88543
fscore_1: 0.88486
--------------------

--------------------checkpoint-28000--------------------
accuracy: 0.88530
--------------------
precis_0: 0.87163
precis_1: 0.89958
--------------------
recall_0: 0.90056
recall_1: 0.87039
--------------------
fscore_0: 0.88586
fscore_1: 0.88474
--------------------

--------------------checkpoint-30000--------------------
accuracy: 0.88546
--------------------
precis_0: 0.87318
precis_1: 0.89818
--------------------
recall_0: 0.89879
recall_1: 0.87244
--------------------
fscore_0: 0.88580
fscore_1: 0.88513
--------------------

--------------------checkpoint-final--------------------
accuracy: 0.88530
--------------------
precis_0: 0.87349
precis_1: 0.89751
--------------------
recall_0: 0.89799
recall_1: 0.87291
--------------------
fscore_0: 0.88557
fscore_1: 0.88504
--------------------

2. 模型预测

在评估完整之后,我们选择在测试集上相对较好的 checkpoint-24000,随机输入一些评论,来查看预测的结果。

def predict(text):

    # 任务模型
    test_model = BertForSequenceClassification.from_pretrained('model/checkpoint-24000', num_labels=2)
    # test_model.to('cuda')
    # 分词器
    tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
    # 数据处理
    inputs = clean_data(text)
    # 数据标注
    inputs = tokenizer(inputs, truncation=True, max_length=256, padding='max_length', return_tensors='pt')
    # 模型计算
    outputs = test_model(**inputs)
    # 计算结果
    y_pred = torch.argmax(outputs.logits, dim=-1)
    print('输入:', text)
    print('预测:', '好评' if y_pred.item() == 1 else '差评')


if __name__ == '__main__':
    predict('酒店设施还可以,总体我觉得不错')
    predict('什么垃圾地方,连个矿泉水都没有,下次再也不来了')
    predict('一进酒店,有简单的装饰,中式的简单装修,进入标间,两张床,白色的床单,必备的台灯,还有塑料拖鞋。')
    predict('非常安静,非常赞地理位置:离地铁站很近很方便,前台小姐姐,服务态度很好,很贴心')
    predict('我以前去过其他的酒店,服务特别好,但是在这家酒店就差很多')

预测结果:

输入: 酒店设施还可以,总体我觉得不错
预测: 好评
输入: 什么垃圾地方,连个矿泉水都没有,下次再也不来了
预测: 差评
输入: 一进酒店,有简单的装饰,中式的简单装修,进入标间,两张床,白色的床单,必备的台灯,还有塑料拖鞋。
预测: 差评
输入: 非常安静,非常赞地理位置:离地铁站很近很方便,前台小姐姐,服务态度很好,很贴心
预测: 好评
输入: 我以前去过其他的酒店,服务特别好,但是在这家酒店就差很多
预测: 差评
未经允许不得转载:一亩三分地 » 微调 Bert 实现评论分类