基于ERNIE3.0模型对小红书评论进行句子级情感分析

1.1 引言

本文将基于ERNIE 3.0中文预训练模型对小红书热评进行句子级别情感分析,实现对评论内容输入的文本和输出的每个句子进行对比的情感判断,判断他们具有积极、消极两种情感中的一种。对小红书热门笔记评论进行爬取评论,将得到的评论内容进行预处理、标注(2分类标注和3分类标注)、预处理、分割数据集、导入数据集、加载模型、分词、训练等工作,最后对其模型效果进行测试。

1.2 情感分析任务

人类的自然语言蕴含着丰富的情感色彩,语言可以表达情绪(如悲伤、快乐)、心情(如倦怠、忧郁)、喜好(如喜欢、讨厌)、个性特征和立场等等。在互联网大数据时代,人类比以往任何时候都更公开地表达自己的想法和感受,如何快速地监控和理解所有类型数据中情绪变得尤为重要。情感分析是一种自然语言处理 (NLP) 技术,用于确定数据情感是正面的、负面的还是中性的。情感分析通常在文本数据上进行,在商品喜好、消费决策、舆情分析等场景中均有应用。利用机器自动分析这些情感倾向,不但有助于帮助企业监控客户反馈中的品牌和产品情感,并了解客户需求,还有助于企业分析商业伙伴们的态度,以便更好地进行商业决策。

生活中常见的将一句话或一段文字进行情感标记,如标记为正向、负向、中性的三分类问题,这属于句子级别情感分析任务。此外常见的情感分析任务还包括词级别情感分析和目标级别情感分析。

二、ERNIE 3.0模型

2.1 模型介绍

ERNIE 3.0[1]首次在百亿级预训练模型中引入大规模知识图谱,提出了海量无监督文本与大规模知识图谱的平行预训练方法(Universal Knowledge-Text Prediction),通过将知识图谱挖掘算法得到五千万知识图谱三元组与4TB大规模语料同时输入到预训练模型中进行联合掩码训练,促进了结构化知识和无结构文本之间的信息共享,大幅提升了模型对于知识的记忆和推理能力。

2.2 模型概述

ERNIE 3.0框架分为两层。第一层是通用语义表示网络,该网络学习数据中的基础和通用的知识。第二层是任务语义表示网络,该网络基于通用语义表示,学习任务相关的知识。在学习过程中,任务语义表示网络只学习对应类别的预训练任务,而通用语义表示网络会学习所有的预训练任务。

图1 ERNIE 3.0模型框架示意图

ERNIE 3.0的框架如图1所示,它可以广泛用于预训练、微调和zero/few-shot学习。与普遍的统一预训练策略不同,ERNIE 3.0设计了一个新的连续多范式统一预训练框架,即对不同的精心设计的cloze任务采用共享的Transformer网络,并利用特定的self-attention mask来控制预测条件的内容。我们认为,自然语言处理的不同任务范式对相同的底层抽象特征的依赖是一致的,如词汇信息和句法信息,但对顶层具体特征的要求是不一致的,其中自然语言理解任务有学习语义连贯性的要求,而自然语言生成任务则期望进一步的语境信息。因此,受多任务学习的经典模型架构的启发,即低层是所有任务共享的,而顶层是特定任务的,我们提出了ERNIE 3.0,使不同的任务范式能够共享在一个共享网络中学习的底层抽象特征,并分别利用在他们自己的特定任务网络中学习的特定任务顶层具体特征。此外,为了帮助模型有效地学习词汇、句法和语义表示,ERNIE 3.0利用了ERNIE 2.0中引入的持续的多任务学习框架。至于不同种类的下游任务的应用,我们将首先用预训练好的共享网络和相应的特定

任务网络的参数组合来初始化ERNIE 3.0,用于不同的任务范式,然后利用特定任务的数据执行相应的后续程序。

在ERNIE 3.0中,我们将骨干共享网络和特定任务网络称为通用表示模块和特定任务表示模块。具体来说,通用表示网络扮演着通用语义特征提取器的actor(例如,它可以是一个多层transformer),其中的参数在各种任务范式中都是共享的,包括自然语言理解、自然语言生成等等。而特定任务的表示网络承担着提取特定任务语义特征的特征,其中的参数是由特定任务的目标学习的。ERNIE 3.0不仅使模型能够区分不同任务范式的特定语义信息,而且缓解了大规模预训练模型在有限的时间和硬件资源下难以实现的困境,其中ERNIE 3.0允许模型只在微调阶段更新特定任务表示网络的参数。具体来说,ERNIE 3.0采用了一个通用表示模块和两个特定任务表示模块的协作架构,即自然语言理解(NLU)特定表示模块和自然语言生成(NLG)特定表示模块。

三、小红书评论爬虫

3.1 小红书评论分析[2]

任意打开一个小红书笔记的评论,打开浏览器的开发者模式(F12),选择网络,Fetch/XHR,找到目标链接的预览数据,经过我的实际测试,请求头包含User-Agent和Cookie这两项,即可实现爬取。其中,Cookie很关键,需要定期更换。那么Cookie从哪里获得呢?方法如下:

图2 小红书网页分析

从上图我们即可得到该网页的URL和Cookie,那么接下来即可对其评论进行分析,查找如何可以爬取一级后的评论。下面,开发翻页逻辑。

由于我并不知道一共有多少页,往下翻多少次,所以采用while循环,直到触发终止条件,循环才结束。那么怎么定义终止条件呢?我注意到,在返回数据里有一个叫做"has_more"的参数,大胆猜测它的含义,是否有更多数据,正常情况它的值是true。如果它的值是false,代表没有更多数据了,即到达最后一页了,也就该终止循环了。

另外,还有一个关键问题,如何进行翻页操作,实现对所有评论的爬取。

图3 cursor分析

这里的游标,就是向下翻页的依据,因为每次请求的返回数据中,也有一个cursor,大胆猜测,返回数据中的cursor,就是给下一页请求用的cursor,如下图4。

图4 下一页请求cursor分析

经过分析,返回数据中有个节点sub_comment_count代表子评论数量,如果大于0代表该评论有子评论,进而可以从sub_comments节点中爬取二级评论。其中,二级展开评论,请求参数中的root_comment_id代表父评论的id,其他逻辑同理,不再赘述。

3.2 爬取流程

导入项目所需依赖包。

# coding: utf-8
import random
import csv
import asyncio
import aiohttp
import os
import time
from datetime import datetime
import sys
import io

3.2.1 Cookie和id的提取

根据浏览器前端内容,定义所需的变量,包括User-Agent和Content-Type、Cookie、Referer

Red_Booklet_id 以及host 。

headers = {
    # "User-Agent": random.choice(user_agents),
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
    "Content-Type": "application/json; charset=utf-8",  # application/json,text/plain,*/*
    "Cookie": "a1=1875eb0dc71x6y1lc8ss6ossq4lfhzkefl204g58u50000163255; webId=9d85021b028e56c27f2f2046144062a3; gid=yYW2dD8d0Di4yYW2dD8fS1k2WyCKvy1SYMMKJY7lMMA41828ixuTd4888yKqJ228ydqiJJ24; gid.sign=ZGRT3S8+BZnIEZZpxicI+11lRRw=; customerClientId=471982307117784; x-user-id-creator.xiaohongshu.com=5d29ec7400000000160208da; access-token-creator.xiaohongshu.com=customer.ares.AT-4af310ac22bf43bf9c545f2998adc128-7e4f01aad4484a3ca531b0db164434f1; xsecappid=xhs-pc-web; abRequestId=9d85021b028e56c27f2f2046144062a3; web_session=0400698c5daf3c08588cd5757a374b7a2741e5; webBuild=3.19.5; websectiga=6169c1e84f393779a5f7de7303038f3b47a78e47be716e7bec57ccce17d45f99; sec_poison_id=5009feed-4ff4-4281-8156-1a048eb941a1; acw_tc=2330a2860539a906b0bf579dce77d906a01c4647cbd0bddd75d883beefe8c58b",
    "Referer": "https://www.xiaohongshu.com/",
}
# 小红书ID
Red_Booklet_id = ['6561e3260000000032037d73']

host = 'https://edith.xiaohongshu.com'

3.2.2 文件命名及保存

定义函数将给定的毫秒时间戳转换为指定格式的时间字符串,使用time模块的localtime和strftime函数来实现,localtime函数将毫秒时间戳转换为本地时间,然后使用strftime函数将时间按照指定格式转换为字符串。默认格式为"%Y-%m-%d %H:%M:%S",可以使用其他格式,最后将转换后的时间字符串返回,用于我们对创建csv文件进行时间命名。

def millis_to_formatted_time(timestamp, ts_format="%Y-%m-%d %H:%M:%S"):
    date = time.strftime(ts_format, time.localtime(timestamp / 1000))
    return date

定义函数将给定的数据列表写入到一个CSV文件中,使用Python内置的csv模块来创建一个writer对象,并将数据逐行写入文件,函数使用“a”模式打开文件,表示追加模式,如果文件不存在则创建,函数通过迭代给定的数据列表,并将每个子列表作为一行写入文件。

3.2.3 获取二级评论

创建一个用于获取指定笔记的二级评论的函数,函数的参数包括笔记ID、页码、根评论ID和游标,函数通过调用HTTP GET请求获取二级评论的数据,并解析返回的JSON响应,然后,它会将每个二级评论的相关信息提取出来,并存储在一个列表中。最后,它将列表保存到CSV文件中。如果数据中没有更多二级评论了,则函数返回,否则,函数会更新游标,并递归调用自身以获取下一页的二级评论,如果在获取二级评论的过程中出现异常,函数会打印异常信息。

def millis_to_formatted_time(timestamp, ts_format="%Y-%m-%d %H:%M:%S"):
    date = time.strftime(ts_format, time.localtime(timestamp / 1000))
    return date
def save_to_csv(data):
    with open(output_file, 'a', newline='') as file:
        writer = csv.writer(file)
        for sublist in data:
            writer.writerow(sublist)
async def get_second_comments(note_id, page, root_comment_id=None, next_cursor=None):
    note_url = f'{host}/api/sns/web/v2/comment/sub/page'  # 注意二级评论的接口地址和一级评论地址不一样
    # note_url.encode('utf-8')
    params = {
        "note_id": note_id,
        "image_scenes": "FD_WM_WEBP,CRD_WM_WEBP"
    }
    # await asyncio.sleep(random.uniform(0.1, 0.5))
    if next_cursor:
        params['cursor'] = next_cursor
    if root_comment_id:
        params['root_comment_id'] = root_comment_id
        params['num'] = 10
    async with aiohttp.ClientSession() as session:
        async with session.get(note_url, headers=headers, ssl=False, params=params) as resp:
            if resp.status == 200:
                try:
                    data = await resp.json()
                    tmp_all_list = list()
                    for c in data['data']['comments']:
                        level_comment = '二级展开评论'
                        # extend_comment_flag
                        if c['target_comment']['id'] == root_comment_id:  # 如果目标ID是root_comment_id,说明是二级评论
                            level_comment = '二级评论'
                        tmp_list = [f'https://www.xiaohongshu.com/explore/{note_id}', page,
                                    c['user_info']['nickname'], c['user_info']['user_id'],
                                    f"https://www.xiaohongshu.com/user/profile/{c['user_info']['user_id']}",
                                    millis_to_formatted_time(c['create_time']),
                                    c.get('ip_location'), c['like_count'], level_comment, c['content']
                                    ]
                        # 不是所有的评论都携带IP归属地
                        tmp_all_list.append(tmp_list)
                    save_to_csv(tmp_all_list)
                    if not data['data']['has_more']:  # 如果has_more 为false说明没有数据了
                        return  # 结束递归
                    else:
                        next_cursor = data['data']['cursor']
                        await get_second_comments(note_id, page, root_comment_id, next_cursor)  # root_comment_id
                except Exception as e:
                    print('二级评论', e)

3.2.4 获取评论

下面函数,用于获取指定note_id的评论数据,函数通过调用API请求,获取一页一页的评论数据,并将每页的评论数据存储到CSV文件中。

async def get_note_comments(note_id, page=1, cursor=None):
    note_url = f'{host}/api/sns/web/v2/comment/page'
    # note_url.encode('utf-8')
    params = {
        "note_id": note_id,
        "image_scenes": "FD_WM_WEBP,CRD_WM_WEBP"
    }
    if cursor:
        params['cursor'] = cursor
    # print(f'{"*" * 10}请求第{page}页{"*" * 10}')
    async with aiohttp.ClientSession() as session:
        async with session.get(note_url, headers=headers, params=params, ssl=False) as resp:
            data = await resp.json()
            try:
                next_cursor = data.get('data').get('cursor')  # 翻页游标,一级评论

                for c in data['data']['comments']:
                    try:
                        first_comments = [f'https://www.xiaohongshu.com/explore/{note_id}', page,
                                          c['user_info']['nickname'], c['user_info']['user_id'],
                                          f"https://www.xiaohongshu.com/user/profile/{c['user_info']['user_id']}",
                                          millis_to_formatted_time(c['create_time']),
                                          c.get('ip_location'), c['like_count'], '根评论', c['content']
                                          ]
                        # 不是所有的评论都携带IP归属地
                        save_to_csv([first_comments])
                    except Exception as e:
                        print(e, c, note_id)
                    if 0 < int(c['sub_comment_count']) <= 3:  # 需注意,如果3条以内的评论还是从原来的接口获取
                        tmp_comments = list()
                        for i in c['sub_comments']:
                            tmp_comments.append([f'https://www.xiaohongshu.com/explore/{note_id}', page,
                                                 i['user_info']['nickname'], i['user_info']['user_id'],
                                                 f"https://www.xiaohongshu.com/user/profile/{i['user_info']['user_id']}",
                                                 millis_to_formatted_time(c['create_time']),
                                                 i.get('ip_location'), i['like_count'], '二级评论', i['content']
                                                 ])
                        save_to_csv(tmp_comments)
                    elif 0 < int(c['sub_comment_count']):
                        root_comment_id = c['id']
                        next_cursor_id = c['sub_comment_cursor']
                        await get_second_comments(note_id, page, root_comment_id, next_cursor_id)
            except Exception as e:
                print(e, data)
    time.sleep(random.uniform(0.1, 0.3))
    has_more = data['data']['has_more']
    # print(':::', next_cursor, has_more)
    if has_more is True:
        # print(f'当前页{page},下一页{page + 1}')
        page += 1  # 直接更新当前页
        await get_note_comments(note_id, page, next_cursor)  # 在递归调用之前更新 current_page

3.2.5 保存评论

我们利用上述函数对小红书网页笔记进行爬取评论,然后讲爬取的数据保存到以当前时间命名的csv文件中,具体如下:

async def main():
    # 小红书id
    note_id_list = Red_Booklet_id
    # note_id_list = ['653b08b5000000001e03c8ee', '6526dbb4000000001d03bdb6', '652d61d7000000001d0399f4',
    #                 '652545b4000000001e03fa85']
    tasks = [asyncio.create_task(get_note_comments(item)) for item in note_id_list]
    await asyncio.wait(tasks)


if __name__ == "__main__":
    start_ts = time.time()
    # num = 20
    # semaphore = asyncio.Semaphore(num)  # 限制同时进行的请求数量为5
    timeout = aiohttp.ClientTimeout(total=30)  # 设置总超时时间为10秒
    # 获取当前时间
    current_time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
    # 指定目标文件路径为当前同级目录下:/data/Not_data
    output_dir = f"./Data/Not_data"
    # 创建目录(如果不存在)
    if not os.path.exists(output_dir):
        os.makedirs(output_dir)
    # 文件名为当前时间.csv
    output_file = f"{output_dir}/{current_time}.csv"
    csv_head = ["笔记链接", "页码", "评论者昵称", "评论者ID", "评论者主页链接", "评论时间", '评论IP属地', '评论点赞数',
                '评论级别', '评论内容']
    # 写入CSV文件表头
    if not os.path.isfile(output_file):
        with open(output_file, "w", newline="") as file:
            writer = csv.DictWriter(file, fieldnames=csv_head)
            writer.writeheader()
    all_list = list()
    asyncio.run(main())
    print(f'耗时:{time.time() - start_ts}')

经过上述操作,我们即可对特定小红书笔记中的评论内容进行爬取。

四、数据预处理

经过上述爬取数据,我们得到了一万八千条评论数据,因数据质量不高,需要对其进行数据清洗操作。

4.1 查看数据

首先初步观察一下数据集的内容,防止出现错误,导致后续的处理,特别是数据格式。

import pandas as pd
# 修改文件名
filename = '2023-12-14_12-29-18'

# 读取csv文件
data = pd.read_csv(f'./Data/Not_data/{filename}.csv',encoding='gbk')
# 查看数据
data.head()

首先初步观察一下数据集的内容,防止出现错误,导致后续的处理,特别是数据格式。

4.2 数据清洗

从数据情况我们可以得到,数据中的有很多符号内容和一些转发、@以及表情、过多的符号对模型训练有很大的影响,因此我们需要将文本中的这些内容进行剔除。对于空缺的数据也要进行删除。

#去除[评论内容]中的@及后面的内容
data['评论内容'] = data['评论内容'].str.replace(r'@\S+', '')
#去除[评论内容]中的/转发中的用户名
data['评论内容'] = data['评论内容'].str.replace('/','')
#去除表情符号
data['评论内容'] = data['评论内容'].str.replace('\[.*?\]','')
#去除[评论内容]中[哭惹R]和[石化R]和[偷笑R]和[生气R]和[doge]和[失望R]和[微笑R]等
data['评论内容'] = data['评论内容'].str.replace('哭惹R','')
data['评论内容'] = data['评论内容'].str.replace('石化R','')
data['评论内容'] = data['评论内容'].str.replace('偷笑R','')
data['评论内容'] = data['评论内容'].str.replace('生气R','')
data['评论内容'] = data['评论内容'].str.replace('doge','')
data['评论内容'] = data['评论内容'].str.replace('失望R','')
data['评论内容'] = data['评论内容'].str.replace('[A-Za-z]','')
data['评论内容'] = data['评论内容'].str.replace('6666','')
# 去除[评论内容]中所有的空格
data['评论内容'] = data['评论内容'].str.replace(' ','')
#去除^_^
data['评论内容'] = data['评论内容'].str.replace('^_^','')
#去除[评论内容]中的[]的内容
data['评论内容'] = data['评论内容'].str.replace('[','')
data['评论内容'] = data['评论内容'].str.replace(']','')
#删除[评论内容]中字数少于3个的行
data = data[data['评论内容'].str.len() > 5]
#删除[评论内容]中字数大于100的行
data = data[data['评论内容'].str.len() < 200]
#合并评论中过多的空格
#data['评论内容'] = data['评论内容'].str.replace(' +',' ')
# 删除评论中含有哈哈哈的行
data = data[data['评论内容'].str.contains('哈哈哈哈哈') == False]
# 删除含有”求“子的行
data = data[data['评论内容'].str.contains('求') == False]
# 删除含有”私“的行
data = data[data['评论内容'].str.contains('私') == False]
# 删除含有”啊啊啊啊啊啊啊啊啊“的行
data = data[data['评论内容'].str.contains('啊啊啊啊啊') == False]

4.3 保存数据

接下来我们需要将清洗的数据进行保存,为了便于我们后续出现bug,可以逐级分析。但是模型对于tsv文件读取数据集时,最为合适,但为了方便后续的标注工作,我们先把其保存为cvs文件,后面再对标注完成的数据格式进行转化。

#保存数据编码为utf-8
data.to_csv(f'./Data/Clean_data/{filename}_clean.csv',encoding='gbk')
print("保存成功")

4.4 数据打乱

因数据集来自不同的话题,并且数据较为集中,如果数据集中的样本按照某种顺序排列,模型可能会学到这种顺序性,而这种顺序性在实际应用中可能是无关紧要的。因此为了防止出现模型过拟合的情况,我们需要将数据内容进行打乱,让其离散程度更高。通过打乱数据,可以防止模型过度依赖样本的顺序,从而更好地泛化到未见过的数据;增加模型在不同数据分布下的泛化能力,因为它不再依赖于训练数据的特定排列方式;减少过拟合的风险,使模型更加健壮。

总而言之,将文本数据进行打乱是一种有助于提高模型性能、泛化能力和鲁棒性的常见做法。这通常在每个训练轮次开始前进行,以确保模型在整个数据集上充分学习,并且不受数据顺序的影响。

import csv
import random
from sklearn.utils import shuffle
# 打乱参考 https://blog.csdn.net/m0_47702386/article/details/123233426
# 读取原始CSV文件
input_file_path = './Data/Label_data/RedBookletReview_09.csv'
output_file_path = './Data/Label_data/RedBookletReview_10.csv'

with open(input_file_path, 'r', newline='', encoding='gbk') as input_file:
    # 读取CSV文件内容,过滤空行
    reader = csv.reader(input_file)
    data = [row for row in reader if row]

    # 使用随机索引创建打乱后的数据
    shuffled_data = shuffle(data)  # 打乱
    # 写入打乱后的数据到新的CSV文件
    with open(output_file_path, 'w', newline='', encoding='gbk') as output_file:
        writer = csv.writer(output_file)
        writer.writerows(shuffled_data)

print(f'文件已成功打乱并保存为 {output_file_path}')

五、文本数据的标注

5.1 数据标注

对于上述得到的文本数据,我们采用的是人工标注,将得到的一万三千多条数据,对其进行二分类和三分类的标注,分类为“0”和“1”分别表示为消极、积极,如下图5。

图4 数据标注示意图

5.2 格式转换

因tsv文件可以更好的将文本和标注内容进行读取,因此我们需要将csv文件格式转换为tsv文件,代码如下。

import pandas as pd
#  参考 https://blog.csdn.net/weixin_45750972/article/details/121358100?ops_request_misc=&request_id=&biz_id=102&utm_term=csv%E8%BD%ACtsv&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduweb~default-0-121358100.nonecase&spm=1018.2226.3001.4187
import pandas as pd
def preprocess_text(text, max_length=256):
    # 进行填充或截断,确保文本长度不超过max_length
    padded_text = text[:max_length] + ' ' * (max_length - len(text))
    return padded_text

if __name__ == '__main__':
    # filename = 'dev','test','train'
    filenames = ['dev', 'test', 'train']
    max_length = 64
    # filenames = ['test-标注']
    for filename in filenames:
        # 读取中文数据,如果是英文数据,编码可能是'gbk'
        pd_all = pd.read_csv(f"./Data/Label_data/{filename}.csv", sep=',', encoding='gbk')

        # 对文本列进行填充或截断
        # pd_all['text_a'] = pd_all['text_a'].apply(lambda x: preprocess_text(x, max_length))

        # 打乱数据
        # pd_all = pd_all.sample(frac=1).reset_index(drop=True)

        # 保存为TSV文件,指定float_format为None,保持整数不变成浮点数
        pd_all.to_csv(f"./Data/RedBookletReview/{filename}.tsv", index=False, sep='\t', encoding='utf-8',
                      float_format=None)

六、模型训练与测试

本项目在Paddle官方服务器上运行的,本节项目流程包括将数据导入、文本数据编码、加载预训练模型、对模型进行训练、调参、测试等工作。

6.1 准备环境

6.1.1 服务器依赖包的安装

安装项目所需的包和库,特别是paddlenlp 一定要安装与paddle适配的版本,不然将会引起环境冲突的问题。

!pip install --upgrade pip
#!pip install paddlepaddle-gpu==2.4.2.post112 -f https://www.paddlepaddle.org.cn/whl/linux/mkl/avx/stable.html
!pip install paddlepaddle-gpu==2.5.2.post120 -f https://www.paddlepaddle.org.cn/whl/linux/mkl/avx/stable.html
!pip install --upgrade paddlenlp==2.5.2 -i https://pypi.tuna.tsinghua.edu.cn/simple/
!pip install --upgrade jupyter ipywidgets -i https://pypi.tuna.tsinghua.edu.cn/simple

6.1.2 指定GPU

为了调用GPU对模型进行训练时,我们需要对GPU进行指定,防止使用cpu进行训练。

# 指定 GPU 设备
paddle.set_device("gpu:0")  # 使用 "gpu:0 "表示第一个 GPU

6.2 加载数据集

6.2.1 加载ChnSentiCorp数据集

ChnSentiCorp是千言开源的中文句子级情感分类数据集,包含酒店、笔记本电脑和书籍的网购评论,数据集示例:

label

review

1

距离川沙公路较近,但是公交指示不对,如果是"蔡陆线"的话,会非常麻烦.建议用别的路线.房间较为简单.

1

商务大床房,房间很大,床有2M宽,整体感觉经济实惠不错!

表1 ChnSentiCorp数据集

其中1表示正向情感,0表示负向情感,PaddleNLP已经内置该数据集,一键即可加载。

#加载中文评论情感分析语料数据集ChnSentiCorp
from paddlenlp.datasets import load_dataset

train_ds, dev_ds, test_ds = load_dataset("chnsenticorp", splits=["train", "dev", "test"])

# 数据集返回为MapDataset类型
print("数据类型:", type(train_ds))
# label代表标签,qid代表数据编号,测试集中不包含标签信息
print("训练集样例:", train_ds[0])
print("验证集样例:", dev_ds[0])
print("测试集样例:", test_ds[0])

6.2.2 加载自定义RedBookletReview数据集

RedBookletReview数据集是本人整理了上万条小红书评论数据,并且对其进行了打标操作。

from paddlenlp.datasets import load_dataset

def read_local_dataset(path, label_list=None, is_test=False,is_dev=False):
    # 读取数据集
    with open(path, 'r', encoding='utf-8') as f:
        next(f)
        for line in f:
            if is_test:
                items = line.strip().split('\t')
                #将分割后的第一项赋值给qid
                qid = items[0]
                #将分割后的最后一项合并为一个字符串
                test = ''.join(items[1:])
                yield {'text': test,'label': '','qid': qid}
            elif is_dev:
                items = line.strip().split('\t')
                #将分割后的第一项赋值给qid
                qid = items[0]
                #将分割后的第二项赋值给label
                label = int(items[1])
                #将分割后的最后一项合并为一个字符串
                test = ''.join(items[2:])
                yield {'text': test,'label':  label_list[label],'qid': qid}
            else:
                items = line.strip().split('\t')
                #将分割后的第一项赋值给qid
                label = int(items[0])
                #将分割后的最后一项合并为一个字符串
                test = ''.join(items[1:])
                yield {'text': test, 'label': label_list[label],'qid':''}

data_path_train = '/home/aistudio/data/data252380/train.tsv'
data_path_dev = '/home/aistudio/data/data252380/dev.tsv'
data_path_test = '/home/aistudio/data/data252380/test.tsv'
label_list = [0, 1]

# 加载数据集,返回为MapDataset类型
train_ds = load_dataset(read_local_dataset, path=data_path_train, label_list=label_list,lazy=False)
dev_ds = load_dataset(read_local_dataset, path=data_path_dev, label_list=label_list, is_dev=True,lazy=False)
test_ds = load_dataset(read_local_dataset, path=data_path_test, is_test=True, lazy=False)

print("数据类型:",type(label_list[1]))
# 将MapDataset类型的数据集分成训练集train_ds、验证集dev_ds和测试集dev_ds
print("数据类型:", type(train_ds))
# label代表标签,qid代表数据编号,测试集中不包含标签信息
print("训练集样例:", train_ds[0])
print("验证集样例:", dev_ds[0])
print("测试集样例:", test_ds[0])

6.3 加载中文ERNIE 3.0预训练模型和分词器

PaddleNLP中Auto模块(包括AutoModel, AutoTokenizer及各种下游任务类)提供了方便易用的接口,无需指定模型类别,即可调用不同网络结构的预训练模型。PaddleNLP的预训练模型可以很容易地通过from_pretrained()方法加载,[Transformer预训练模型汇总](https://paddlenlp.readthedocs.io/zh/latest/model_zoo/index.html#transformer)包含了40多个主流预训练模型,500多个模型权重。

AutoModelForSequenceClassification可用于句子级情感分析和目标级情感分析任务,通过预训练模型获取输入文本的表示,之后将文本表示进行分类。PaddleNLP已经实现了ERNIE 3.0预训练模型,可以通过一行代码实现ERNIE 3.0预训练模型和分词器的加载。

from paddlenlp.transformers import AutoTokenizer, AutoModelForSequenceClassification

model_name = "ernie-3.0-medium-zh"
label_list = [0,1]

model = AutoModelForSequenceClassification.from_pretrained(model_name, num_classes=len(label_list))
tokenizer = AutoTokenizer.from_pretrained(model_name)

6.4 基于预训练模型的数据处理

Dataset中通常为原始数据,需要经过一定的数据处理并进行采样组batch,通过Dataset的map函数,使用分词器将数据集从原始文本处理成模型的输入。定义paddle.io.BatchSampler和collate_fn构建 paddle.io.DataLoader。

实际训练中,根据显存大小调整批大小batch_size和文本最大长度max_seq_length。

import functools
import numpy as np

from paddle.io import DataLoader, BatchSampler
from paddlenlp.data import DataCollatorWithPadding

# 数据预处理函数,利用分词器将文本转化为整数序列
def preprocess_function(examples, tokenizer, max_seq_length, is_test=False):

    result = tokenizer(text=examples["text"], max_seq_len=max_seq_length)
    if not is_test:
        result["labels"] = examples["label"]
    return result

trans_func = functools.partial(preprocess_function, tokenizer=tokenizer, max_seq_length=256)
train_ds = train_ds.map(trans_func)
dev_ds = dev_ds.map(trans_func)

# collate_fn函数构造,将不同长度序列充到批中数据的最大长度,再将数据堆叠
collate_fn = DataCollatorWithPadding(tokenizer)

# 定义BatchSampler,选择批大小和是否随机乱序,进行DataLoader
train_batch_sampler = BatchSampler(train_ds, batch_size=32, shuffle=True)
dev_batch_sampler = BatchSampler(dev_ds, batch_size=64, shuffle=False)
train_data_loader = DataLoader(dataset=train_ds, batch_sampler=train_batch_sampler, collate_fn=collate_fn)
dev_data_loader = DataLoader(dataset=dev_ds, batch_sampler=dev_batch_sampler, collate_fn=collate_fn)

6.5 设计模型

6.5.1 设计模型优化器

定义训练所需的优化器、损失函数、评价指标等,就可以开始进行预模型微调任务。

import paddle
# Adam优化器、交叉熵损失函数、accuracy评价指标
optimizer = paddle.optimizer.AdamW(learning_rate=0.000002, parameters=model.parameters(),weight_decay=0.01)
from paddle.optimizer.lr import StepDecay
#lr_scheduler = StepDecay(learning_rate=0.000005, step_size=10, gamma=0.1)
#optimizer = paddle.optimizer.AdamW(learning_rate=lr_scheduler, parameters=model.parameters(), weight_decay=0.01)
dropout = paddle.nn.Dropout(p=0.3)
criterion = paddle.nn.loss.CrossEntropyLoss()
metric = paddle.metric.Accuracy()

6.5.2 模型评价和绘图函数

import paddle
import numpy as np

accuracy = paddle.metric.Accuracy()

def evaluate(model, criterion, metric, data_loader):
    model.eval()
    accuracy.reset()

    losses = []  # record loss
    for batch in data_loader:
        input_ids = batch['input_ids']
        token_type_ids = batch['token_type_ids']
        labels = batch['labels']

        logits = model(input_ids, token_type_ids)
        loss = criterion(logits, labels)
        losses.append(loss.numpy())

        # compute accuracy
        correct = accuracy.compute(logits, labels)
        accuracy.update(correct)

    # accumulate and print accuracy
    accu = accuracy.accumulate()
    print("eval loss: %.5f, accuracy: %.5f" % (np.mean(losses), accu))

    model.train()
    accuracy.reset()
    return accu, losses

import matplotlib.pyplot as plt

def plot_curves(train_acc_list, val_acc_list, train_loss_list, val_loss_list):
    # Plot accuracy curves
    plt.figure(figsize=(12, 6))
    plt.subplot(1, 2, 1)
    plt.plot(train_acc_list, label='Training Accuracy')
    plt.plot(val_acc_list, label='Validation Accuracy')
    plt.title('Training and Validation Accuracy')
    plt.xlabel('Iterations')
    plt.ylabel('Accuracy')
    plt.legend()

    # Plot loss curves
    plt.subplot(1, 2, 2)
    plt.plot(train_loss_list, label='Training Loss')
    plt.plot(val_loss_list, label='Validation Loss')
    plt.title('Training and Validation Loss')
    plt.xlabel('Iterations')
    plt.ylabel('Loss')
    plt.legend()

    # Show the plots
    plt.tight_layout()
    plt.show()

6.6 模型训练

接下来,我们将正式对模型进行训练的工作。


# 开始训练
import time
import paddle.nn.functional as F
import os

epochs = 4 # 训练轮次
ckpt_dir = "ernie_ckpt" #训练过程中保存模型参数的文件夹
best_acc = 0
best_step = 0
global_step = 0 #迭代次数
tic_train = time.time()

train_acc_list = []
val_acc_list = []
train_loss_list = []
val_loss_list = []

for epoch in range(1, epochs + 1):
    for step, batch in enumerate(train_data_loader, start=1):
        input_ids, token_type_ids, labels = batch['input_ids'], batch['token_type_ids'], batch['labels']

        # 计算模型输出、损失函数值、分类概率值、准确率
        logits = model(input_ids, token_type_ids)
        loss = criterion(logits, labels)
        probs = F.softmax(logits, axis=1)
        correct = metric.compute(probs, labels)
        metric.update(correct)
        acc = metric.accumulate()

        # 每迭代10次,打印损失函数值、准确率、计算速度
        global_step += 1
        if global_step % 10 == 0:
            print(
                "global step %d, epoch: %d, batch: %d, loss: %.5f, accu: %.5f, speed: %.2f step/s"
                % (global_step, epoch, step, loss, acc,
                    10 / (time.time() - tic_train)))
            tic_train = time.time()

        # 反向梯度回传,更新参数
        loss.backward()
        optimizer.step()
        optimizer.clear_grad()

        # 每迭代100次,评估当前训练的模型、保存当前模型参数和分词器的词表等
        if global_step % 100 == 0:
            save_dir = ckpt_dir
            if not os.path.exists(save_dir):
                os.makedirs(save_dir)
            print(global_step, end=' ')
            #acc_eval = evaluate(model, criterion, metric, dev_data_loader)
            acc_eval, losses = evaluate(model, criterion, metric, dev_data_loader)

            # Append accuracy and loss values
            train_acc_list.append(acc)
            val_acc_list.append(acc_eval)
            train_loss_list.append(loss.numpy())
            val_loss_list.append(np.mean(losses))

            if acc_eval > best_acc:
                best_acc = acc_eval
                best_step = global_step

                model.save_pretrained(save_dir)
                tokenizer.save_pretrained(save_dir)
# Call the plot function after training
plot_curves(train_acc_list, val_acc_list, train_loss_list, val_loss_list)

6.7 模型评价

经过上述对模型的训练工作以后,为了判别模型的好坏,需要对模型进行评价,我们通过分别绘制训练集和验证集的准确率和损失率图像来对其进行更加直观的观察。

6.7.1 加载验证集最佳模型

# 加载ERNIR 3.0最佳模型参数
params_path = 'ernie_ckpt/model_state.pdparams'
state_dict = paddle.load(params_path)
model.set_dict(state_dict)

# 也可以选择加载预先训练好的模型参数结果查看模型训练结果
# model.set_dict(paddle.load('ernie_ckpt_trained/model_state.pdparams'))

print('ERNIE 3.0-Medium 在ChnSentiCorp的dev集表现', end=' ')
eval_acc = evaluate(model, criterion, metric, dev_data_loader)

6.6.2 结果预测与保存

加载微调好的模型参数进行情感分析预测,并保存预测结果。测试集数据预处理,利用分词器将文本转化为整数序列。

# 测试集数据预处理,利用分词器将文本转化为整数序列
trans_func_test = functools.partial(preprocess_function, tokenizer=tokenizer, max_seq_length=128, is_test=True)
test_ds_trans = test_ds.map(trans_func_test)

# 进行采样组batch
collate_fn_test = DataCollatorWithPadding(tokenizer)
test_batch_sampler = BatchSampler(test_ds_trans, batch_size=32, shuffle=False)
test_data_loader = DataLoader(dataset=test_ds_trans, batch_sampler=test_batch_sampler, collate_fn=collate_fn_test)

模型预测分类结果

# 模型预测分类结果
import paddle.nn.functional as F

label_map = {0: '负面', 1: '正面'}
results = []
model.eval()
for batch in test_data_loader:
    input_ids, token_type_ids = batch['input_ids'], batch['token_type_ids']
    logits = model(batch['input_ids'], batch['token_type_ids'])
    probs = F.softmax(logits, axis=-1)
    idx = paddle.argmax(probs, axis=1).numpy()
    idx = idx.tolist()
    preds = [label_map[i] for i in idx]
    results.extend(preds)
# 存储ChnSentiCorp预测结果
test_ds = load_dataset(read_local_dataset, path=data_path_test, is_test=True, lazy=False)

res_dir = "./results"
if not os.path.exists(res_dir):
    os.makedirs(res_dir)
with open(os.path.join(res_dir, "RedBookletReview.tsv"), 'w', encoding="utf8") as f:
    f.write("qid\ttext\tprediction\n")
    for i, pred in enumerate(results):
        f.write(test_ds[i]['qid']+"\t"+test_ds[i]['text']+"\t"+pred+"\n")

test_ds = load_dataset(read_local_dataset, path=data_path_test, is_test=True, lazy=False)

res_dir = "./results"

if not os.path.exists(res_dir):

    os.makedirs(res_dir)

with open(os.path.join(res_dir, "RedBookletReview.tsv"), 'w', encoding="utf8") as f:

    f.write("qid\ttext\tprediction\n")

    for i, pred in enumerate(results):

        f.write(test_ds[i]['qid']+"\t"+test_ds[i]['text']+"\t"+pred+"\n")


七、模型调优与结果分析

7.1 模型调优

经过多次调优,我们得到如下表最优参数。

参数

hidden_dropout_prob

0.2

max_seq_length

128

batch_size

train_ds

32

dev_ds

16

learning_rate

0.000002

weight_decay

0.1

P

0.2

Epochs

10

表2 最优参数

7.2 问题及结果分析

在实验过程中,我们遇到下列问题:首先是数据质量的问题,网络评论数据可能存在噪声、错别字、语法错误、缺乏标注等,影响模型的学习效果;网络评论数据可能存在正负情感类别的不平衡,导致模型的偏向性和泛化能力下降;ERNIE3模型是一个大规模的预训练模型,需要大量的计算资源和时间进行微调和推理,可能不适合低成本的应用场景。其次,网络评论数据的情感分析结果可能存在主观性和多样性,不同的人对同一条评论的情感判断可能不一致,导致模型的评估指标难以确定和比较。

为了解决上述问题,通过查找确定出现问题的原因,然后我们经过对模型进行多次调试后,最终得到的效果有所改进,但模型准确率依然没有很好的提高,具体解决方案如下。

7.2.1 过拟合

训练集准确率上升、验证集准确率下降,出现过拟合,训练集损失震荡,验证集损失上升,不能很好的优化。出现这种情况我们需要进行多次对比实验,尝试对超参数进行改进,然后对比分析确定其这种现象的原因。

(1)降低学习率,利用小学习率小批量训练---未解决

(2)继续对数据进行预处理,增强数据的质量---未解决

(3)增加dropout = paddle.nn.Dropout(p=0.2)层,有效果,但效果不高,过拟合问题依然存在

(4)重新对数据集进行划分---未解决

(5)对文本数据利用数据增强---未解决

(6)再次利用开源数据进行训练,将数据进行对比分析

经过上述测试,我们发现上述问题很有可能是我们在数据处理的时候,对数据进行随机打乱不够充分,造成模型集中在部分数据上过拟合,从而不能很好对整体数据进行训练,因此我们需要将数据进行多次随机打乱后,再对数据集进行划分,经过测试,最终发现将数据进行重新打乱后,这种情况得到了很好的解决。因此我们找到了此模型过拟合的原因。

7.2.2 数据集重复

训练集损失震荡;验证集准确率99%而训练集的准确率只有92%,验证集准确率远远高于训练集的准确率等问题。这种情况经过我们分析和对训练集和验证集的对比可以得出,是因为数据在打乱的时候出现了重复的情况,这种情况我们需要对数据重新打乱或者对数据去重,然后再对模型进行训练。

7.2.3 数据分布不均匀

当我们对模型经过多次调试后,损失依然不能很好的收敛,验证集准去率不能很好的提高,出现这种情况不仅仅是模型过拟合的问题,而是数据长度和类型分布不均匀的原因。但数据标注工作,过于复杂、繁琐、数量较多,因此我们无法再重新整理数据集,只能在此数据集上进行修改。

7.2.4 准确率、损失率曲线

经过上述处理,我们得到了最为好的准去率和损失率曲线效果图如下:

图5 最优参数准确率、损失率曲线图

输出最终的验证集准确率和损失如下

我们可以看到,验证集损失降到0.37536以后,开始上升,因此在第25到30左右便可以停止训练,得到的准确率为0.84373。

八、总结

ERNIE(Enhanced Representation through Knowledge Distillation)是一种基于神经网络的预训练语言模型,具有强大的语言理解和生成能力。通过使用ERNIE3模型,可以对小红书评论进行句子级别的情感分析,从而识别出评论中的积极或消极情感。ERNIE 3.0在多个中文自然语言处理任务上都取得了领先的效果,包括情感分析。但本次整理的数据集质量和数量均需要进一步改进,才能够更好的提高模型的准确率和鲁棒性。

参考文献:

[1]ERNIE 3.0: Large-scale Knowledge Enhanced Pre-training for Language Understanding and Generation.

[2]【爬虫实战】用Python采集任意小红书笔记下的评论,爬了10000多条,含二级评论!_python爬虫批量爬取小红书数据-CSDN博客

  • 25
    点赞
  • 39
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

伪_装

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值