一、项目背景

本测评任务旨在预测在线平台用户评论中挖掘用户情感信息,可以体现大众对于热门话题的感受与情感取向。本测评任务的输入数据包含两部分:热门话题(hashtag)与前五条用户评论,输出为该话题下用户的情感取向,由三个表情标签表示。数据集中共包含24个表情标签(如下表)。面向微博话题的群体情感识别训练数据集7200条,验证数据集2400条,测试数据集2400条。

Column 1Column 2Column 3Column 4Column 5Column 6
微笑嘻嘻笑cry允悲
憧憬doge并不简单思考费解吃惊
拜拜吃瓜伤心蜡烛
给力威武跪了中国赞给你小心心

CCAC2022 Task2 面向微博话题的群体情感识别 任务官网 链接本方案(HS100队)以测试集F-Score评价值0.4204获Top1方案。整理和分享不易,期望大家在Fork的同时点“喜欢”。谢谢。

二、项目方案

面向微博话题的群体情感识别本质是一项多标签文本分类任务,我们基于ERNIE3.0等预训练模型,采用数据增强、学习率与Warmup策略、损失函数修正、模型集成等训练策略,在验证集F1-Score评价指标值0.44459 (Pred>=0.5时)和0.41871 (Pred>=0.5 and Top3时,测试集0.4202)。

在这里插入图片描述

对于多标签文本分类,我们首先加载文本数据,然后对数据进行预处理,把数据转换成id的形式,然后用放入到dataloader中,用于训练过程中给模型提供批量的数据,这些处理做好以后,经过BERT模型,得到每个标签的类别,其中每个标签的类别是一个二分类,都会有一个概率,概率大于阈值(通常是0.5)的标签就会保存下来,作为该文本的标签,所以一个文本可能对应多个标签,所以叫做多标签分类。

三、数据处理

3.1 数据读取

# 下载最新的paddlenlp
!pip install --upgrade paddlenlp
from functools import partial
import argparse
import os
import random
import time
import re
import ast
import json
import codecs
import pandas as pd
import numpy as np
from tqdm import tqdm
from sklearn.metrics import accuracy_score
from sklearn.metrics import f1_score

import paddle
import paddle.nn.functional as F
import paddle.nn as nn
from paddle.metric import Metric

# 导入paddlenlp的库
import paddlenlp as ppnlp
from paddlenlp.data import Stack, Tuple, Pad
from paddlenlp.datasets import load_dataset
from paddlenlp.transformers import *

from sklearn.model_selection import train_test_split
def read_json(path):
    data = []
    with open(path, 'r',encoding='utf-8') as fp:
        for line in fp:
            data.append(json.loads(line))
    return data
train_df = pd.DataFrame(read_json('work/data156187/train.json'))
valid_df = pd.DataFrame(read_json('work/data156187/valid.json'))
test_df = pd.DataFrame(read_json('work/data156187/test.json'))
train_df.head(3)
label_dic = ['[微笑]', '[嘻嘻]', '[笑cry]', '[怒]', '[泪]', '[允悲]', '[憧憬]', '[doge]', '[并不简单]', '[思考]', '[费解]', '[吃惊]', '[拜拜]', '[吃瓜]', '[赞]', '[心]', '[伤心]', '[蜡烛]', '[给力]', '[威武]', '[跪了]', '[中国赞]', '[给你小心心]', '[酸]']
id2label = {k: v for k, v in enumerate(label_dic)}
label2id = {v: k for k, v in enumerate(label_dic)}
def convert_label(label):
    onehot_label = [0] * 24
    for i in label:
        onehot_label[label2id[i]] = 1
    return onehot_label
train_data = []
valid_data = []
test_data = []

for i in range(train_df.shape[0]):
    hashtag, label, comments = train_df.iloc[i, :]
    sentence ='[SEP]'.join(comments)
    label_id = convert_label(label)
    train_data.append([hashtag, sentence, label_id])

for i in range(valid_df.shape[0]):
    hashtag, label, comments = valid_df.iloc[i, :]
    sentence ='[SEP]'.join(comments)
    label_id = convert_label(label)
    valid_data.append([hashtag, sentence, label_id])

for i in range(test_df.shape[0]):
    hashtag, comments = test_df.iloc[i, :]
    sentence ='[SEP]'.join(comments)
    test_data.append([hashtag, sentence])

print("finish data processing!")

3.2 数据增强

参考 NLPCDA——中文数据增强工具 介绍和方法 链接针对样本量小的问题,参考相关文档资料,使用随机同义词替换、等价字替换、随机置换邻近的字、随机字删除 和 百度中英翻译互转等数据增强方法使得数据量增加约2倍,ERNIR 3.0 预训练模型验证集F1-Score评价指标值提升约1.4-2.1%至0.39885。

注:不提供数据增强数据集,请自行按链接介绍和方法探索。

因后续转换函数convert_example把文本转换成id的形式需参数tokenizer,故此处一并将模型训练相关参数、可选预训练模型设置或列出。

注:学习率learning_rate建议不大于5e-5,批次量batch_size建议设置64,其他可自行探索。此处使用ernie-3.0-xbase-zh,且需GPU显存不少于30G,若显存不足时,请适当调小。

weight_decay=0.01
data_path='data'
warmup_proportion=0.1
init_from_ckpt=None
learning_rate=2e-5

batch_size=64
max_seq_length=192

# 切换语言模型,加载预训练模型

# ernie-3.0-xbase-zh
# ernie-3.0-base-zh
# ernie-3.0-medium-zh
# ernie-3.0-mini-zh
# ernie-3.0-micro-zh
# ernie-3.0-nano-zh

# hfl/roberta-wwm-ext
# hfl/roberta-wwm-ext-large
# hfl/rbt3
# hfl/rbtl3

# bert-base-chinese
# bert-wwm-chinese
# bert-wwm-ext-chinese

epochs=15
model_name_or_path='ernie-3.0-xbase-zh'

tokenizer = AutoTokenizer.from_pretrained(model_name_or_path)
print(train_data[:5])

其中,如下图所示,大部分数据长度在200 字符以内,75%的样本长度在175字符以内,故我们使用文本截断和填充处理,即设置max_seq_length=192。

train_reviews = pd.DataFrame(train_data)
(train_reviews[0].str.len()+train_reviews[1].str.len()).describe()
# 可视化训练集类别标签分布情况
%matplotlib inline
(train_reviews[0].map(len)+train_reviews[1].map(len)).plot(kind='hist')
# 可视化验证集类别标签分布情况
valid_reviews = pd.DataFrame(valid_data)
(valid_reviews[0].map(len)+valid_reviews[1].map(len)).plot(kind='hist')

构造load dataset的处理函数,并把它解析成{“text_a”: text_a, “text_b”: text_b, “label”: label}的形式

def read_custom_data(data, is_test=False):
    """Reads data."""
    for line in data:
        if is_test:
            text_a, text_b = line[0], line[1]
            yield {"text_a": text_a, "text_b": text_b, "label": ""}
        else:
            text_a, text_b, label = line[0], line[1], line[2]
            yield {"text_a": text_a, "text_b": text_b, "label": label}
# 加载训练集
train_ds = load_dataset(read_custom_data, data=train_data, is_test=False, lazy=False)
# 加载验证集
dev_ds = load_dataset(read_custom_data, data=valid_data, is_test=False, lazy=False)
# 加载测试集
test_ds = load_dataset(read_custom_data, data=test_data, is_test=True, lazy=False)
print(test_ds[2])

设置模型的转换函数convert_example,用于把文本转换成id的形式。

# 定义数据加载和处理函数
def convert_example(example, tokenizer, max_seq_length=512,is_test=False,is_pair=True):
    if is_pair:
        text = example["text_a"]
        text_pair = example["text_b"]
    else:
        text = example["text"]
        text_pair = None
    encoded_inputs = tokenizer(text=text,
                               text_pair=text_pair,
                               max_seq_len=max_seq_length)
    input_ids = encoded_inputs["input_ids"]
    token_type_ids = encoded_inputs["token_type_ids"]

    if not is_test:
        label = np.array(example["label"], dtype="float32")
        return input_ids, token_type_ids, label
    return input_ids, token_type_ids

3.3 Dataloader

构造dataloader,dataloader会源源不断的给模型提供批量的数据。

def create_dataloader(dataset,
                      mode='train',
                      batch_size=1,
                      batchify_fn=None,
                      trans_fn=None):
    if trans_fn:
        dataset = dataset.map(trans_fn)

    shuffle = True if mode == 'train' else False
    if mode == 'train':
        batch_sampler = paddle.io.DistributedBatchSampler(
            dataset, batch_size=batch_size, shuffle=shuffle)
    else:
        batch_sampler = paddle.io.BatchSampler(
            dataset, batch_size=batch_size, shuffle=shuffle)

    return paddle.io.DataLoader(
        dataset=dataset,
        batch_sampler=batch_sampler,
        collate_fn=batchify_fn,
        return_list=True)
trans_dev_func = partial(
        convert_example,
        tokenizer=tokenizer,
        max_seq_length=max_seq_length,
        is_test=False)

test_func = partial(
        convert_example,
        tokenizer=tokenizer,
        max_seq_length=max_seq_length,
        is_test=True)

batchify_fn = lambda samples, fn=Tuple(
        Pad(axis=0, pad_val=tokenizer.pad_token_id),  # input
        Pad(axis=0, pad_val=tokenizer.pad_token_type_id),  # segment
        Stack(dtype='float32')  # label
    ): [data for data in fn(samples)]

batchify_fn_test = lambda samples, fn=Tuple(
        Pad(axis=0, pad_val=tokenizer.pad_token_id),  # input
        Pad(axis=0, pad_val=tokenizer.pad_token_type_id),  # segment
    ): [data for data in fn(samples)]


# 构造训练集的dataloader
train_data_loader = create_dataloader(
        train_ds,
        mode='train',
        batch_size=batch_size,
        batchify_fn=batchify_fn,
        trans_fn=trans_dev_func)
# 构造验证集的dataloader
dev_data_loader=create_dataloader(
        dev_ds,
        mode='dev',
        batch_size=batch_size,
        batchify_fn=batchify_fn,
        trans_fn=trans_dev_func)
# 构造验证集的dataloader
test_data_loader=create_dataloader(
        test_ds,
        mode='test',
        batch_size=batch_size,
        batchify_fn=batchify_fn_test,
        trans_fn=test_func)

四、模型搭建

构建多标签分类的模型MultiLabelClassifier,主要是使用bert/robert/ernie3.0模型然后加入全连接。

class MultiLabelClassifier(nn.Layer):
    def __init__(self, pretrained_model, num_labels=2, dropout=None):
        super(MultiLabelClassifier, self).__init__()
        self.ptm = pretrained_model
        self.num_labels = num_labels
        self.dropout = nn.Dropout(dropout if dropout is not None else
                                  self.ptm.config["hidden_dropout_prob"])
        self.classifier = nn.Linear(self.ptm.config["hidden_size"],
                                    num_labels)

    def forward(self,
                input_ids,
                token_type_ids=None,
                position_ids=None,
                attention_mask=None):
        _, pooled_output = self.ptm(
            input_ids,
            token_type_ids=token_type_ids,
            position_ids=position_ids,
            attention_mask=attention_mask)

        pooled_output = self.dropout(pooled_output)
        logits = self.classifier(pooled_output)
        return logits

五、训练配置

5.1 实例化ERNIE的模型

对比官方给出BERT基线模型0.36489,相同参数设置下(learning_rate=5e-5),ERNIR 3.0 预训练模型,在验证集F1-Score评价指标值提升约1.3%至0.37752。

pretrained_model = AutoModel.from_pretrained(model_name_or_path)  # 加载预训练模型

5.2 实例化多标签分类的模型

model = MultiLabelClassifier(pretrained_model, num_labels=24)

5.3 学习率与Warmup设置、损失函数设置

  1. 学习率WarmUp策略 影响模型的收敛速度和稳定性;
  2. WarmUp策略+损失函数参数 共同作用 对模型的影响不同;

3.针对样本不均衡问题,使用损失函数修正参数‘正类的权重pos_weight’,验证集F1-Score评价指标值在‘数据增强+ERNIE3.0’基础上,可提升约2.0-3.0%至0.43195。

注:

学习率WarmUp设置 可参考 学习率可视化实验 PaddleNLP库的学习率Warmup可视化(新)

BCEWithLogitsLoss 二分类的损失 官方 介绍

# 如果有预训练模型,则加载模型
if init_from_ckpt and os.path.isfile(init_from_ckpt):
        state_dict = paddle.load(init_from_ckpt)
        model.set_dict(state_dict)

# 设置训练的steps
num_training_steps = len(train_data_loader) * epochs

# 学习率调整
'''
__all__ = [
   1 'LinearDecayWithWarmup',
   2 'ConstScheduleWithWarmup',
   3 'CosineDecayWithWarmup',
   4 'PolyDecayWithWarmup',
]
'''

lr_scheduler = CosineDecayWithWarmup(learning_rate,
        num_training_steps, warmup_proportion)

# Generate parameter names needed to perform weight decay.
# All bias and LayerNorm parameters are excluded.
decay_params = [
        p.name for n, p in model.named_parameters()
        if not any(nd in n for nd in ["bias", "norm"])
    ]
# 使用AdamW的优化器
optimizer = paddle.optimizer.AdamW(
        learning_rate=lr_scheduler,
        parameters=model.parameters(),
        weight_decay=weight_decay,
        apply_decay_param_fun=lambda x: x in decay_params)

# 定义多标签分类的损失函数,BCEWithLogitsLoss 是二分类的损失
'''
reduction (str,可选) - 指定应用于输出结果的计算方式,可选值有: 'none', 'mean', 'sum' 。
默认为 'mean',计算 BCELoss 的均值;设置为 'sum' 时,计算 BCELoss 的总和;设置为 'none' 时,则返回原始loss。
pos_weight (Tensor,可选) - 手动指定正类的权重,必须是与类别数相等长度的向量。数据类型是float32, float64。默认值是:None。
'''
pos_weight = paddle.to_tensor([4.0], dtype="float32")
reduction = 'mean'
criterion = paddle.nn.loss.BCEWithLogitsLoss(reduction=reduction, pos_weight=pos_weight)

六、模型训练

下面是模型的训练过程,包括do_train的函数,训练的时候需要评估,所以需要实现evaluate的函数。

注:因官方文档要求“预测个数<=3个”的要求,故evaluate函数 注释部分可替换和实践。但Pred>=0.5时,在验证集F1-Score效果相对(Pred>=0.5 and Top3)更佳。

@paddle.no_grad()
def evaluate(model, data_loader):
    model.eval()
    y_pred = []
    y_true = []
    for batch in tqdm(data_loader):
        input_ids, token_type_ids, labels = batch
        logits = model(input_ids, token_type_ids)
        probs = F.sigmoid(logits)
        preds =paddle.where(probs>=0.5, 1, 0)
        
        # value_3, indices_3 = paddle.topk(probs, k=3)
        # value_24=paddle.repeat_interleave(value_3[:,2], 24, None)
        # preds =paddle.where(probs>=paddle.reshape(value_24,[-1,24]), 1, 0)

        # value_3, indices_3 = paddle.topk(probs, k=3)
        # value_24=paddle.repeat_interleave(value_3[:,2], 24, None)
        # preds =paddle.where(((probs>=paddle.reshape(value_24,[-1,24])) and (probs>=0.5)), 1, 0)

        y_pred.extend(preds.numpy().tolist())
        y_true.extend(labels.numpy().tolist())

    macro_f1 = f1_score(y_true, y_pred, average='macro') # 训练和评估采用F1Score指标  
    
    model.train()
    
    return  macro_f1
def do_train(model, train_data_loader, dev_data_loader, criterion, optimizer, lr_scheduler):
    model.train()
    max_f1_score=0
    save_dir = "./checkpoint/" + model_name_or_path
    for epoch in range(1, epochs + 1):
        with tqdm(total=len(train_data_loader)) as pbar:
            for step, batch in enumerate(train_data_loader, start=1):
                input_ids, token_type_ids, labels = batch
                logits = model(input_ids, token_type_ids)
                loss = criterion(logits, labels)
                loss.backward()
                optimizer.step()
                lr_scheduler.step()
                optimizer.clear_grad()
                pbar.set_postfix({'loss' : '%.5f' % (loss.numpy())})
                pbar.update(1)
        eval_f1_score = evaluate(model, dev_data_loader)
        print("Epoch: %d, eval_f1_score: %.5f" % (epoch, eval_f1_score))
        if not os.path.exists(save_dir):
            os.makedirs(save_dir)  ## 递归创建

        print("Epoch: %d, eval_f1_score: %.5f" % (epoch, eval_f1_score), file=open(save_dir +'/best_model_log.txt', 'a'))
            
        if eval_f1_score >= max_f1_score:
            max_f1_score = eval_f1_score 
            save_param_path = os.path.join(save_dir, 'best_model.pdparams')
            paddle.save(model.state_dict(), save_param_path)
            tokenizer.save_pretrained(save_dir)                
    save_param_path = os.path.join(save_dir, 'last_model.pdparams')
    paddle.save(model.state_dict(), save_param_path)           
do_train(model, train_data_loader, dev_data_loader, criterion, optimizer, lr_scheduler)

七、模型预测

模型的预测predict的过程,遍历data_loader,然后调用模型进行输出。

注:
提供在测试集和验证集预测函数,因不知测试集标签,故注释掉。

# @paddle.no_grad()
# def predict_test(model, test_data_loader):
    
#     y_pred=[]
#     y_prob=[]
#     model.eval()
#     for batch in tqdm(test_data_loader):
#         input_ids, token_type_ids = batch
#         logits = model(input_ids, token_type_ids)
#         probs = F.sigmoid(logits)
#         preds =paddle.where(probs>=0.5, 1, 0)
#         y_prob.extend(probs)
#         y_pred.extend(preds)

#     return y_prob,y_pred

# params_path="./checkpoint/" + model_name_or_path + "/best_model.pdparams"
# state_dict = paddle.load(params_path)
# model.set_dict(state_dict)
# print("Loaded parameters from %s" % params_path)
@paddle.no_grad()
def predict_dev(model, dev_data_loader):
    
    y_pred=[]
    y_prob=[]
    y_label=[]
    model.eval()
    for batch in tqdm(dev_data_loader):
        input_ids, token_type_ids, labels = batch
        logits = model(input_ids, token_type_ids)
        probs = F.sigmoid(logits)
        preds =paddle.where(probs>=0.5, 1, 0)
        y_prob.extend(probs)
        y_pred.extend(preds)
        y_label.extend(labels)
    return y_prob,y_pred,y_label
    
params_path="./checkpoint/" + model_name_or_path + "/best_model.pdparams"
state_dict = paddle.load(params_path)
model.set_dict(state_dict)
print("Loaded parameters from %s" % params_path)
probs,preds,labels=predict_dev(model, dev_data_loader)

打印预测出来的label和验证集的label:

print(dev_ds[2])
print(preds[2])
print(probs[2])

八、模型集成

参考:竞赛上分Trick-结果融合

注:可根据参考文档自行进行模型集成,不提供各单个模型预测结果。

在多结果等权投票(Pred>=0.5)时,验证集F1-Score评价指标值可提升约1.2%至0.44459,并提交测试集预测结果(第一次)。但不满足官方文档要求“预测个数<=3个”的要求,故按多结果平均融合 (Pred>=0.5 and Top3),F1-Score评价指标值约0.41871,并提交测试集预测结果(第二次,获0.4202)。

1.多结果投票融合

主要使用于当预测结果为具体的类别而非概率时,基于少数服从多数的原则选择集体同意的类别。分为等权融合和加权投票融合两种。

2.多结果平均融合

平均融合常用于输出结果为概率分数时,通过采用多个个体模型预测值的平均降低过拟合。分为普通平均、几何平均和排名平均三种。

九、补充

本面向微博话题的群体情感识别测评任务中,也对涉及参数在如下“弯路”和总结(均指在验证集):

  1. 标签概率阈值Pred非0.5(如0.4、0.45、0.475、0.525、0.55、0.6等)效果不佳;取Top3效果不佳;取Top3且Pred>=0.5效果不佳;
  2. 预训练模型roberta-wwm-ext-large效果不及ernie-3.0-xbase-zh;
  3. 损失函数修正参数pos_weight非3至5(如2,6,7,8等) 效果不佳;
  4. 在pos_weight=4/learning_rate=2e-5时,批量大小batchsize=32/48/96效果不及64。

下一步可能的改进:

  1. 清洗数据(全角转半角、繁转简、英文大写转小写、去除url、去除email、去除@以及保留emoji等操作);
  2. 迁移学习(无监督、类似任务);
  3. 模型集成(选择性能较好且相关系数较低)

参考文献

  1. 百度飞桨 PaddlePaddle 深度学习框架 https://www.paddlepaddle.org.cn
  2. Sun Y, Wang S, Feng S, et al. Ernie 3.0: Large-scale knowledge enhanced pre-training for language understanding and generation[J]. arXiv preprint arXiv:2107.02137, 2021.
  3. NLP Chinese Data Augmentation 一键中文数据增强工具 https://github.com/425776024/nlpcda
  4. https://github.com/MLWave/Kaggle-Ensemble-Guide

此文章为搬运
原项目链接

Logo

学大模型,用大模型上飞桨星河社区!每天8点V100G算力免费领!免费领取ERNIE 4.0 100w Token >>>

更多推荐