☰
ERNIE中文情感分析实战:句子级与属性级ABSA微调指南
2026/10/9 4:16:14 网站建设 项目流程

简介:这份资源面向NLP初学者与进阶开发者,提供基于百度ERNIE预训练大模型的情感分析完整项目,覆盖句子级与属性级两类任务,可用于产品评论挖掘、舆情监控等场景。压缩包共24个文件,约25.54MB,以json数据配置、py源码脚本、pdf说明文档为主,另含少量pyc缓存与license等辅助文件,结构上区分了属性级与IMDB情感分析两条实验线。已有220人学习下载。读者可获取数据预处理、ERNIE模型加载、特征提取、分类器构建、训练验证与预测的完整代码链路,并借助配套数据集完成训练集、验证集与测试集的划分实验;属性与观点抽取脚本进一步展示了细粒度情感极性的实现思路,适合作为预训练模型落地NLP任务的参考模板。

1. 句子级与属性级情感分析:ERNIE 能帮你省掉多少标注成本

电商评论里「屏幕很惊艳但续航拉胯」这句话,句子级情感分析只会给出一个「中性」或「矛盾」的标签,可运营真正想知道的是:屏幕是正向、续航是负向。这就是属性级情感分析(Aspect-Based Sentiment Analysis,ABSA)要解决的问题。而 ERNIE 作为百度开源的中文预训练大模型,在中文语义建模上比同尺寸 BERT 更贴合汉语习惯,用它在小样本上微调,往往几百到几千条标注就能跑出可用的效果。这篇笔记面向两类人:一是想用 Python 快速搭一套中文情感分析流水线的工程师,二是手里有评论数据、想搞清楚句子级和属性级到底怎么落地、参数怎么调、坑在哪的从业者。下面从数据准备、模型加载、两套任务的训练脚本,一路讲到排查和进阶技巧,代码可以直接抄。

2. 先搞清楚 ERNIE 做情感分析的两个任务边界

2.1 句子级和属性级到底差在哪

句子级情感分析(Sentence-level Sentiment Classification)把一整句话当成一个输入,输出一个情感极性,通常是正向、负向、中性三分类。它的输入是[CLS] 句子 [SEP],取[CLS]位置的向量接一个全连接层做分类。实现简单、标注便宜,但遇到「除了贵没毛病」这种混合情感就抓瞎。

属性级情感分析要复杂一层。它需要先知道句子里的属性词(Aspect Term),比如「续航」「屏幕」「物流」,再判断针对每个属性的情感极性。常见做法有两种:一种是抽取式,先做属性词抽取再做极性分类;另一种是端到端,把「属性词 + 句子」拼成[CLS] 属性词 [SEP] 句子 [SEP]送进模型,直接输出该属性对应的极性。后者工程上更好落地,本文走的就是这条路线。

两者的数据格式差别很大。句子级只需要text, label两列;属性级需要text, aspect, label三列,而且同一句话会因为不同属性出现多条样本。这一点在准备数据集时如果没想清楚,后面训练脚本会反复报维度错误。

2.2 为什么选 ERNIE 而不是从头训一个模型

中文情感分析如果从零训练,标注量至少是万级起步,而且很难覆盖领域外的表达。ERNIE 在预训练阶段引入了知识增强和短语级掩码,对中文里的实体和短语边界更敏感,这在属性级任务里尤其重要——属性词往往就是短语。用ernie-3.0-base-zh这类中文 base 模型,在 2 万条以内的标注数据上微调,通常 3 到 5 个 epoch 就能收敛到可用水平。

选型上还有几个现实理由:一是 PaddleNLP 和 HuggingFace 两套生态都能加载 ERNIE,团队里用哪套都不至于卡死;二是 base 版本显存占用在 8G 以内,单卡 1080Ti 都能跑;三是中文社区里 ERNIE 的微调示例多,遇到问题搜得到。如果你的数据量在千条以下,建议先用句子级跑通,再逐步加属性标注,不要一上来就做端到端 ABSA。

2.3 环境准备与依赖安装

先把环境搭起来。Python 建议 3.8 及以上,PaddlePaddle 和 PaddleNLP 版本要对应,否则加载模型时会报算子不匹配。下面这套命令在 Linux 和 Windows 的 conda 环境里都验证过。

# 创建独立环境,避免和系统里的 python 冲突 conda create -n ernie_sentiment python=3.8 -y conda activate ernie_sentiment # 安装 PaddlePaddle GPU 版本,CPU 版本把 paddlepaddle-gpu 换成 paddlepaddle # 具体 CUDA 版本按自己显卡驱动选,这里以 cu118 为例 pip install paddlepaddle-gpu==2.6.1 -i https://mirror.baidu.com/pypi/simple # 安装 PaddleNLP,负责加载 ERNIE 和 tokenizer pip install paddlenlp==2.7.0 -i https://mirror.baidu.com/pypi/simple # 数据处理和评估常用库 pip install pandas scikit-learn tqdm

安装完成后用一行代码验证是否能加载 tokenizer,能打印出词表大小就说明环境通了。如果卡在下载模型权重,可以提前把模型缓存目录配好,避免每次换机器都重新下。

from paddlenlp.transformers import ErnieTokenizer # 加载中文 base 模型的 tokenizer tokenizer = ErnieTokenizer.from_pretrained("ernie-3.0-base-zh") print(len(tokenizer)) # 输出词表大小,正常在 4 万左右

参数说明:ernie-3.0-base-zh是模型名,PaddleNLP 会自动从官方源拉取;如果内网环境,需要提前把模型文件放到本地目录,再把路径传进去。len(tokenizer)返回的是词表规模,用来确认加载的是中文模型而不是英文版。

3. 句子级情感分析:从数据到可复现的训练脚本

3.1 数据格式与标签映射

句子级任务的数据建议统一成三列:text、label、label_id。label存原始标签名,label_id存映射后的整数,训练时只读label_id。常见的中文情感数据集标签是正向、负向、中性,映射成 0、1、2。如果只有正向和负向,就映射成 0、1,不要留空类别,否则分类头维度对不上。

数据清洗上有几个血泪经验:一是去掉纯表情和纯符号的样本,它们会让模型学到噪声;二是长度超过 512 的句子要截断,ERNIE base 的最大位置编码就是 512;三是标签分布严重不均时,先做重采样或加权,不然模型会偏向多数类。下面这段脚本负责读取 CSV 并做标签映射。

import pandas as pd from sklearn.model_selection import train_test_split # 读取原始数据,假设列名为 text 和 label df = pd.read_csv("sentiment_sentence.csv") # 标签映射,按自己数据的实际标签改 label_map = {"正向": 0, "负向": 1, "中性": 2} df["label_id"] = df["label"].map(label_map) # 去掉映射失败的脏数据 df = df.dropna(subset=["label_id"]) df["label_id"] = df["label_id"].astype(int) # 划分训练集和验证集,固定随机种子保证可复现 train_df, dev_df = train_test_split( df, test_size=0.2, random_state=42, stratify=df["label_id"] ) print(train_df.shape, dev_df.shape)

逻辑说明:stratify保证训练集和验证集的类别比例一致,避免验证集里某个类别一条都没有。random_state固定后,每次跑出来的划分都一样,方便对比不同超参的效果。如果数据量小于 500 条,建议把test_size调到 0.3,留出足够的验证样本。

3.2 用 PaddleNLP 加载 ERNIE 并构建分类头

PaddleNLP 里加载 ERNIE 做序列分类,最省事的方式是用ErnieForSequenceClassification,它内部已经接好了池化层和分类头。需要指定的参数只有模型名和类别数。下面这段代码构建模型和 tokenizer,并写一个把文本转成模型输入的函数。

import paddle from paddlenlp.transformers import ErnieForSequenceClassification, ErnieTokenizer MODEL_NAME = "ernie-3.0-base-zh" NUM_CLASSES = 3 # 正向、负向、中性 tokenizer = ErnieTokenizer.from_pretrained(MODEL_NAME) model = ErnieForSequenceClassification.from_pretrained( MODEL_NAME, num_classes=NUM_CLASSES ) def encode(text, max_len=128): # 返回 input_ids 和 token_type_ids,padding 到固定长度 encoded = tokenizer( text, max_seq_len=max_len, padding="max_length", truncation=True, return_attention_mask=True, ) return encoded

参数说明:max_seq_len控制截断长度,句子级任务 128 通常够用,长评论可以调到 256,但显存占用会翻倍。padding="max_length"保证一个 batch 内长度一致,省去动态 padding 的麻烦。return_attention_mask=True在 PaddleNLP 里默认会返回,显式写出来是为了提醒自己后面要传给模型。

3.3 训练循环与关键超参设置

训练循环用 Paddle 原生的写法,不依赖 Trainer,方便自己控制每一步。核心超参有三个:学习率、batch size、epoch 数。ERNIE 微调的学习率一般设在 2e-5 到 5e-5 之间,太大容易把预训练权重冲垮,太小收敛慢。batch size 在 8G 显存下建议 16 或 32。epoch 数 3 到 5 足够,再多会过拟合。

from paddle.io import DataLoader, Dataset class SentimentDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len=128): self.texts = texts self.labels = labels self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): encoded = self.tokenizer( self.texts[idx], max_seq_len=self.max_len, padding="max_length", truncation=True, ) return ( paddle.to_tensor(encoded["input_ids"]), paddle.to_tensor(encoded["token_type_ids"]), paddle.to_tensor(self.labels[idx], dtype="int64"), ) train_ds = SentimentDataset(train_df["text"].tolist(), train_df["label_id"].tolist(), tokenizer) train_loader = DataLoader(train_ds, batch_size=16, shuffle=True) optimizer = paddle.optimizer.AdamW( learning_rate=3e-5, parameters=model.parameters() ) loss_fn = paddle.nn.CrossEntropyLoss() model.train() for epoch in range(4): total_loss = 0 for input_ids, token_type_ids, labels in train_loader: logits = model(input_ids, token_type_ids=token_type_ids) loss = loss_fn(logits, labels) loss.backward() optimizer.step() optimizer.clear_grad() total_loss += loss.item() print(f"epoch {epoch}, loss {total_loss / len(train_loader):.4f}")

逻辑说明:每个 batch 前向算出 logits,和标签算交叉熵,反向传播更新参数。optimizer.clear_grad()不能漏,否则梯度会累加。学习率 3e-5 是中文 base 模型微调的常用起点,如果 loss 震荡明显就降到 2e-5,如果 4 个 epoch 后 loss 还在缓慢下降可以加到 5 个 epoch。验证集评估建议每个 epoch 跑一次,看准确率和 F1,不要只看 loss。

4. 属性级情感分析:端到端 ABSA 的输入构造与训练

4.1 属性级数据的组织方式

属性级数据每条样本包含三部分:原句、属性词、该属性对应的极性。同一句话如果有两个属性,就拆成两条样本。比如「屏幕很惊艳但续航拉胯」拆成「屏幕-正向」和「续航-负向」两条。这种拆法会让样本数膨胀,但训练目标清晰,模型学的是「给定句子和属性词,判断极性」。

数据表建议保留text、aspect、label、label_id四列。属性词在句子里的位置信息可以不用,因为端到端模型靠[SEP]分隔就能区分。如果属性词在句子里出现多次,建议在数据里标注清楚是哪一个,否则模型会混淆。下面这段脚本把原始 ABSA 数据展开成训练格式。

import pandas as pd # 原始数据:text 里可能包含多个属性,aspects 是列表,labels 是对应极性 raw = [ {"text": "屏幕很惊艳但续航拉胯", "aspects": ["屏幕", "续航"], "labels": ["正向", "负向"]}, {"text": "物流很快,包装也完好", "aspects": ["物流", "包装"], "labels": ["正向", "正向"]}, ] rows = [] for item in raw: for asp, lab in zip(item["aspects"], item["labels"]): rows.append({"text": item["text"], "aspect": asp, "label": lab}) df = pd.DataFrame(rows) label_map = {"正向": 0, "负向": 1, "中性": 2} df["label_id"] = df["label"].map(label_map) print(df)

逻辑说明:展开后每条样本只对应一个属性,训练时不会互相干扰。zip保证属性和标签一一对应,如果原始数据里两者长度不一致,这里会直接暴露出来。实际项目中建议加一步校验,长度不等就报错,避免脏数据混进训练集。

4.2 构造 [CLS] 属性 [SEP] 句子 [SEP] 输入

端到端 ABSA 的关键在输入构造。把属性词放在句子前面,用[SEP]隔开,模型在编码时会让属性词和句子做注意力交互,从而判断该属性的极性。PaddleNLP 的 tokenizer 支持text_pair参数,可以直接传两段文本。下面这个函数负责把aspect和text拼成模型输入。

def encode_absa(aspect, text, tokenizer, max_len=160): # text_pair 会自动插入 [SEP],并生成对应的 token_type_ids encoded = tokenizer( aspect, text_pair=text, max_seq_len=max_len, padding="max_length", truncation=True, ) return encoded # 测试一条 sample = encode_absa("续航", "屏幕很惊艳但续航拉胯", tokenizer) print(len(sample["input_ids"])) # 输出 max_len

参数说明:max_seq_len建议比句子级大一些,因为要容纳属性词和句子两段,160 到 256 比较稳妥。text_pair的顺序是属性在前、句子在后,这个顺序要和训练时保持一致,推理时如果反过来,效果会明显下降。token_type_ids会区分两段文本,模型靠它知道哪部分是属性、哪部分是句子。

4.3 ABSA 训练脚本与损失函数选择

ABSA 本质还是分类任务,损失函数用交叉熵即可。和句子级不同的是,输入多了一段属性词,所以 Dataset 的__getitem__要同时取aspect和text。训练超参和句子级接近,但学习率可以稍微低一点,因为输入更长、梯度更容易爆炸。

class ABSADataset(Dataset): def __init__(self, df, tokenizer, max_len=160): self.df = df.reset_index(drop=True) self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] encoded = self.tokenizer( row["aspect"], text_pair=row["text"], max_seq_len=self.max_len, padding="max_length", truncation=True, ) return ( paddle.to_tensor(encoded["input_ids"]), paddle.to_tensor(encoded["token_type_ids"]), paddle.to_tensor(row["label_id"], dtype="int64"), ) absa_ds = ABSADataset(df, tokenizer) absa_loader = DataLoader(absa_ds, batch_size=16, shuffle=True) # 复用同一个模型结构,类别数按实际标签数改 absa_model = ErnieForSequenceClassification.from_pretrained( MODEL_NAME, num_classes=3 ) optimizer = paddle.optimizer.AdamW( learning_rate=2e-5, parameters=absa_model.parameters() ) loss_fn = paddle.nn.CrossEntropyLoss() absa_model.train() for epoch in range(4): total_loss = 0 for input_ids, token_type_ids, labels in absa_loader: logits = absa_model(input_ids, token_type_ids=token_type_ids) loss = loss_fn(logits, labels) loss.backward() optimizer.step() optimizer.clear_grad() total_loss += loss.item() print(f"absa epoch {epoch}, loss {total_loss / len(absa_loader):.4f}")

逻辑说明:ABSA 的学习率设成 2e-5,比句子级略低,因为输入更长,梯度累积更明显。如果训练集小于 2000 条,建议把 epoch 降到 3,并在验证集上早停。损失函数用交叉熵,如果类别极不均衡,可以换成带权重的交叉熵,权重按类别频率的倒数设置。

5. 避坑与排查:情感分析落地时最容易翻车的 5 个点

5.1 现象:验证集准确率很高,上线后效果崩了

原因通常是训练集和线上数据的分布不一致。比如训练集是电商评论,线上是社交媒体短文本,用词和句式差别很大。解决方法是先做一轮线上数据的抽样标注,混进训练集做增量微调,或者至少用线上数据做一次评估,看真实差距。不要只看验证集数字就上线。

5.2 现象:属性级模型把属性词判断反了

原因多半是输入构造时属性词和句子的顺序反了,或者token_type_ids没传对。检查方法是打印一条样本的input_ids和token_type_ids,确认属性词在前、句子在后,且两段的 type id 不同。如果顺序反了,模型会把句子当属性、属性当句子,极性判断自然错乱。

5.3 现象:训练 loss 不下降,一直卡在 1.1 左右

三分类的随机猜测 loss 是 ln(3)≈1.0986,如果 loss 一直在这个值附近,说明模型没学到东西。常见原因是学习率太大把预训练权重冲垮了,或者数据标签全是同一类。先把学习率降到 1e-5 试一个 epoch,如果还不动,就检查标签分布,打印df["label_id"].value_counts()看一眼。

5.4 现象:显存溢出,batch size 降到 1 还报错

原因通常是max_seq_len设得太大,或者模型加载了两份。ABSA 任务里 256 的长度在 8G 显存上 batch size 只能开到 8 左右。解决方法是把长度降到 128 或 160,或者用梯度累积模拟大 batch。另外确认没有同时加载句子级和属性级两个模型,两个 base 模型一起跑很容易爆显存。

5.5 现象:推理速度慢,单条要几百毫秒

原因是没有做 batch 推理,或者每次推理都重新加载模型。正确做法是模型加载一次常驻内存,推理时把多条文本攒成一个 batch 一起送进去。另外可以开model.eval()和paddle.no_grad(),省掉梯度计算的开销。如果还慢,考虑用 Paddle Inference 做图优化,或者把模型转成 ONNX 再用 ONNXRuntime 跑。

6. 进阶技巧:用置信度过滤和属性词扩展把效果再提一档

模型跑通之后,真正拉开差距的是后处理。第一个技巧是置信度过滤:推理时输出 softmax 概率,如果最大概率低于 0.6,就把这条样本标成「待人工复核」,而不是硬给一个标签。这样能把错误率压下来,代价是增加少量人工成本。实际项目里我一般把阈值设在 0.55 到 0.65 之间,按业务对准确率的容忍度调。

import paddle.nn.functional as F def predict_with_confidence(model, tokenizer, aspect, text, threshold=0.6): model.eval() encoded = tokenizer( aspect, text_pair=text, max_seq_len=160, padding="max_length", truncation=True, ) input_ids = paddle.to_tensor([encoded["input_ids"]]) token_type_ids = paddle.to_tensor([encoded["token_type_ids"]]) with paddle.no_grad(): logits = model(input_ids, token_type_ids=token_type_ids) probs = F.softmax(logits, axis=-1) conf = float(paddle.max(probs, axis=-1)) pred = int(paddle.argmax(probs, axis=-1)) if conf < threshold: return "待复核", conf return pred, conf

第二个技巧是属性词扩展。很多业务场景里属性词是开放集合,模型没见过的新属性词效果会差。做法是先维护一个属性词表,推理时如果属性词不在表里,就用同义词或上位词替换后再送模型。比如「待机」不在表里,可以映射到「续航」。这个映射表不用很大,覆盖高频属性即可,维护成本低但收益明显。

第三个技巧是两阶段训练:先用句子级数据预微调,再用属性级数据精调。句子级数据通常比属性级多,先让模型适应领域表达,再做 ABSA 时收敛更快。我试过在 5 万条句子级数据上跑 2 个 epoch,再在 5000 条属性级数据上跑 3 个 epoch,F1 比直接做 ABSA 高 3 到 5 个点。这个顺序不要反,反过来容易过拟合到属性级的小样本上。

最后说一个验证习惯:每次改完超参或数据,固定用同一批验证样本跑评估,记录准确率和 F1,不要凭感觉判断「好像变好了」。我吃过这个亏,换了学习率之后觉得 loss 降得快就上线,结果 F1 掉了 4 个点。后来养成习惯,每次实验都存一份评估结果,对比着看才靠谱。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询