news 2026/10/8 16:30:04

ERNIE属性级情感分析实战:从句子级到属性级全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ERNIE属性级情感分析实战:从句子级到属性级全流程

简介:这份资源面向NLP初学者与进阶开发者,提供基于百度预训练大模型ERNIE完成情感分析的完整Python方案,覆盖句子级与属性级两类任务。包内共24个文件,以json数据、py脚本、pdf文档为主,另有少量pyc、txt与license文件,压缩包约25.54MB,目录按数据集、模型配置、预处理、训练、评估与预测等模块组织,结构清晰便于按流程学习。已有220人学习下载。读者可借助源码掌握ERNIE模型加载、文本预处理、特征提取与分类器构建的完整链路,理解属性与观点抽取、属性级情感分类的实现思路,并参考IMDB情感分析示例完成训练、验证与调优,快速将预训练模型迁移到实际情感分析场景中。

1. 从一句吐槽到属性级拆解:ERNIE 情感分析到底在做什么

电商评论里有一句典型的话:「屏幕很惊艳,但是续航太拉胯了」。句子级情感分析模型大概率会给你一个「中性」或者干脆判成「正面」,因为「惊艳」这个词的权重压过了「拉胯」。但业务方要的不是一个笼统的极性标签,而是想知道用户到底对「屏幕」满意、对「续航」不满——这就是属性级情感分析(Aspect-Based Sentiment Analysis,ABSA)要解决的问题。

这套基于预训练大模型 ERNIE 的情感分析方案,核心就是把两件事串起来:一是句子级别的情感分类,判断整句话是正面、负面还是中性;二是属性级别的情感分析,先抽出评论里被评价的对象(属性词),再判断针对这个属性的情感极性。ERNIE 在这里扮演的是语义编码器的角色,它比 Word2Vec、TextCNN 那套老方案更能吃透中文的语序和上下文,尤其是「不便宜但也不算贵」这种带转折和否定的句子。

适合谁看:手上有中文评论数据、想快速搭一套能跑通的情感分析流水线的 Python 工程师;已经用过 BERT 但想换成 ERNIE 试试中文效果的同学;以及需要把「句子级 + 属性级」两层结果都交付给业务方的从业者。下面从数据准备一路讲到训练、推理和踩坑,代码可以直接抄。

2. 数据准备与 ERNIE 环境搭建:把原始评论变成模型能吃的格式

2.1 句子级和属性级的数据长什么样

句子级情感分析的数据格式最简单,一条样本就是「文本 + 标签」,标签通常是 0/1/2 对应负面/中性/正面。属性级要复杂一层,一条样本需要「文本 + 属性词 + 属性对应的极性」,如果一句话里有多个属性,就拆成多条样本。常见做法是用 ChnSentiCorp 这类公开中文情感数据集做句子级,属性级则用 SemEval 的中文迁移版本或者自己标注。

我一般会把两类数据统一成 JSON Lines 格式,每行一个 JSON 对象,方便流式读取,也方便后续用 datasets 库直接加载。句子级字段用text和label,属性级字段用text、aspect、label。这样两套任务可以共用一套读取逻辑,只是训练时走不同的 Dataset 类。

提示:属性级数据标注成本很高,如果预算有限,可以先用句子级数据把 ERNIE 微调到一个不错的基线,再用少量属性级数据做二次微调,效果比从零训练属性级模型稳得多。

2.2 用 PaddleNLP 加载 ERNIE 预训练模型

ERNIE 是百度系模型,用 PaddlePaddle 生态的 PaddleNLP 加载最顺,一行from_pretrained就能把预训练权重拉下来。如果你习惯 PyTorch,也可以用 HuggingFace 上转换好的 ERNIE 权重,但要注意 tokenizer 的 vocab 对齐问题,否则会出现大量[UNK]。

# 用 PaddleNLP 加载 ERNIE 预训练模型和分词器 from paddlenlp.transformers import ErnieTokenizer, ErnieModel MODEL_NAME = "ernie-3.0-base-zh" # 中文 base 版本,显存友好 tokenizer = ErnieTokenizer.from_pretrained(MODEL_NAME) ernie = ErnieModel.from_pretrained(MODEL_NAME) # 试一条样本,看分词结果 text = "屏幕很惊艳,但是续航太拉胯了" encoded = tokenizer(text, max_seq_len=128) print(encoded["input_ids"][:20])

这段代码做了两件事:加载 ERNIE 的 tokenizer 和主干模型。max_seq_len=128是常见起点,中文评论一般不会太长,128 个 token 能覆盖绝大多数句子;如果评论普遍超过 200 字,可以调到 256,但显存占用会明显上升。ernie-3.0-base-zh是 base 规模,参数量适中,单张 8G 显存的卡就能微调,如果追求精度可以换 large 版本,但训练时间会翻倍。

2.3 把数据转成 PaddleNLP 的 Dataset

PaddleNLP 提供了MapDataset,可以把原始 list 转成模型能直接迭代的格式。关键是把文本和标签分别编码,标签转成 tensor。属性级任务里,属性词要和文本拼在一起送进模型,常见拼法是text + "[SEP]" + aspect,让模型在编码阶段就能感知到属性和上下文的对应关系。

import paddle from paddlenlp.datasets import MapDataset def convert_sentence_example(example, tokenizer, max_seq_len=128): # 句子级:文本编码 + 标签 encoded = tokenizer( example["text"], max_seq_len=max_seq_len, truncation=True, padding="max_length" ) encoded["labels"] = example["label"] return encoded def convert_aspect_example(example, tokenizer, max_seq_len=128): # 属性级:文本和属性词拼接,用 SEP 分隔 pair_text = example["text"] + tokenizer.sep_token + example["aspect"] encoded = tokenizer( pair_text, max_seq_len=max_seq_len, truncation=True, padding="max_length" ) encoded["labels"] = example["label"] return encoded raw_data = [{"text": "屏幕很惊艳,但是续航太拉胯了", "label": 1}] dataset = MapDataset(raw_data) dataset = dataset.map(lambda x: convert_sentence_example(x, tokenizer))

truncation=True保证超长文本被截断而不是报错,padding="max_length"让一个 batch 内所有样本长度一致,省去动态 padding 的麻烦。属性级拼接时用tokenizer.sep_token而不是硬编码[SEP],因为不同模型的特殊符号可能不一样,硬编码容易翻车。转换后的 dataset 可以直接丢给DataLoader,配合batch_size=16或32开始训练。

3. 句子级情感分析:ERNIE 微调的最小可跑通流程

3.1 在 ERNIE 上加一个分类头

ERNIE 主干输出的是每个 token 的隐状态,做句子分类时通常取[CLS]位置的向量,接一个 dropout 再接一个全连接层,输出类别数。PaddleNLP 里可以继承ErniePretrainedModel自己拼一个分类模型,也可以直接用ErnieForSequenceClassification,后者已经把分类头封装好了,改一下num_classes就能用。

import paddle.nn as nn from paddlenlp.transformers import ErniePretrainedModel class ErnieSentenceClassifier(ErniePretrainedModel): def __init__(self, ernie, num_classes=3, dropout=0.1): super().__init__() self.ernie = ernie self.dropout = nn.Dropout(dropout) self.classifier = nn.Linear(self.ernie.config["hidden_size"], num_classes) def forward(self, input_ids, token_type_ids=None, attention_mask=None): # 取 [CLS] 位置的输出作为句子表示 sequence_output, _ = self.ernie( input_ids, token_type_ids=token_type_ids, attention_mask=attention_mask ) cls_output = sequence_output[:, 0, :] logits = self.classifier(self.dropout(cls_output)) return logits model = ErnieSentenceClassifier(ernie, num_classes=3)

hidden_size从 ernie 的 config 里取,base 版本一般是 768,不要写死。dropout=0.1是常规正则化,数据量小的时候可以调到 0.2 或 0.3 防止过拟合。取sequence_output[:, 0, :]就是[CLS]向量,这是 BERT 系模型的惯例,ERNIE 同样适用。分类头只有一个线性层,参数量很小,训练时主要更新的是 ERNIE 主干的权重。

3.2 训练循环与关键超参

训练用paddle.optimizer.AdamW,学习率设成 2e-5 到 5e-5 之间,这是预训练模型微调的经典区间。学习率太大容易把预训练学到的语义打散,太小则收敛慢。batch size 在显存允许的前提下尽量大,16 或 32 都行。epoch 一般 3 到 5 轮就够,中文情感任务数据量不大,轮数多了必然过拟合。

from paddle.io import DataLoader from paddlenlp.data import Pad, Stack, Tuple def collate_fn(batch): # 把 input_ids、token_type_ids、labels 分别堆叠成 batch input_ids = paddle.to_tensor([x["input_ids"] for x in batch]) token_type_ids = paddle.to_tensor([x["token_type_ids"] for x in batch]) labels = paddle.to_tensor([x["labels"] for x in batch]) return input_ids, token_type_ids, labels train_loader = DataLoader(dataset, batch_size=16, shuffle=True, collate_fn=collate_fn) optimizer = paddle.optimizer.AdamW(learning_rate=3e-5, parameters=model.parameters()) criterion = nn.CrossEntropyLoss() model.train() for epoch in range(3): for step, (input_ids, token_type_ids, labels) in enumerate(train_loader): logits = model(input_ids, token_type_ids=token_type_ids) loss = criterion(logits, labels) loss.backward() optimizer.step() optimizer.clear_grad() if step % 50 == 0: print(f"epoch={epoch} step={step} loss={loss.numpy()[0]:.4f}")

collate_fn负责把单条样本拼成 batch,这里手动堆叠是因为字段简单,如果字段多可以用 PaddleNLP 的Tuple和Pad组合。clear_grad()每步必须调用,否则梯度会累积,这是 Paddle 和 PyTorch 写法上的一个差异点,从 PyTorch 转过来的同学容易漏。打印 loss 时用.numpy()[0]取值,直接打印 tensor 会带一堆元信息,看着乱。

3.3 验证集评估与早停

训练过程中一定要留验证集,每个 epoch 结束后算准确率和 F1,F1 比准确率更能反映类别不均衡时的真实效果。如果验证集 loss 连续两轮不降,就停掉,别硬撑。我一般会把验证集 F1 最高的那轮权重存下来,而不是用最后一轮的,因为最后一轮往往已经过拟合了。

from sklearn.metrics import f1_score, accuracy_score def evaluate(model, data_loader): model.eval() preds, trues = [], [] for input_ids, token_type_ids, labels in data_loader: logits = model(input_ids, token_type_ids=token_type_ids) pred = paddle.argmax(logits, axis=1) preds.extend(pred.numpy().tolist()) trues.extend(labels.numpy().tolist()) return accuracy_score(trues, preds), f1_score(trues, preds, average="macro") acc, f1 = evaluate(model, valid_loader) print(f"valid acc={acc:.4f} f1={f1:.4f}")

average="macro"表示对每个类别算 F1 再取平均,适合类别不均衡的场景;如果类别均衡,用"micro"也行。评估时记得model.eval(),否则 dropout 还在起作用,结果会偏低。这套流程跑下来,句子级三分类在中文评论上通常能到 0.85 以上的 F1,具体取决于数据质量。

4. 属性级情感分析:把属性词和极性一起抽出来

4.1 属性级任务的两种建模思路

属性级情感分析主流有两种做法:一种是流水线式,先做属性词抽取(序列标注),再对每个属性词做情感分类;另一种是端到端式,直接用一个模型同时输出属性和极性,比如把任务建模成「文本 + 属性」的分类,或者用 span 抽取的方式。流水线式实现简单、每步可解释,但误差会累积;端到端式精度高,但训练和调试复杂。

我一般先用流水线式跑通,因为属性词抽取用 ERNIE + CRF 就能做,情感分类复用第 3 章的句子级模型稍作改造即可。等业务稳定了,再考虑换成端到端。下面重点讲流水线式的实现,因为它对新手最友好,也最容易定位问题。

4.2 用 ERNIE + CRF 做属性词抽取

属性词抽取本质是序列标注,给每个 token 打 B-Aspect、I-Aspect、O 标签。ERNIE 输出每个 token 的隐状态后,接一个线性层映射到标签数,再接 CRF 层约束标签转移的合法性(比如 I 不能直接跟在 O 后面)。PaddleNLP 里有现成的ErnieForTokenClassification,但带 CRF 的需要自己拼。

from paddlenlp.layers import LinearChainCrf, LinearChainCrfLoss, ViterbiDecoder class ErnieAspectExtractor(ErniePretrainedModel): def __init__(self, ernie, num_labels=3, dropout=0.1): super().__init__() self.ernie = ernie self.dropout = nn.Dropout(dropout) self.classifier = nn.Linear(self.ernie.config["hidden_size"], num_labels) self.crf = LinearChainCrf(num_labels) self.crf_loss = LinearChainCrfLoss(self.crf) self.viterbi = ViterbiDecoder(self.crf.transitions) def forward(self, input_ids, token_type_ids=None, attention_mask=None, labels=None): sequence_output, _ = self.ernie( input_ids, token_type_ids=token_type_ids, attention_mask=attention_mask ) logits = self.classifier(self.dropout(sequence_output)) if labels is not None: # 训练时算 CRF loss,attention_mask 用来屏蔽 padding 位置 loss = self.crf_loss(logits, labels, attention_mask) return loss # 推理时用 Viterbi 解码出最优标签序列 preds = self.viterbi(logits, attention_mask) return preds

num_labels=3对应 B/I/O 三标签,如果属性词可能嵌套,标签体系要更复杂。attention_mask在 CRF loss 里很关键,不加的话 padding 位置会参与计算,导致 loss 异常。Viterbi 解码出来的标签序列再还原成属性词,用 BIO 规则合并即可。这一步的 F1 通常能到 0.8 左右,属性词边界模糊的样本是主要误差来源。

4.3 属性级情感分类:复用句子级模型改输入

属性词抽出来之后,情感分类就简单了:把「文本 + 属性词」拼起来送进第 3 章的ErnieSentenceClassifier,输出该属性对应的极性。区别在于训练数据要换成属性级样本,每条样本的标签是「针对这个属性的极性」,而不是整句话的极性。

# 属性级样本示例:同一句话拆成两条 aspect_samples = [ {"text": "屏幕很惊艳,但是续航太拉胯了", "aspect": "屏幕", "label": 2}, {"text": "屏幕很惊艳,但是续航太拉胯了", "aspect": "续航", "label": 0}, ] # 复用 convert_aspect_example 做编码,再走同样的训练循环 aspect_dataset = MapDataset(aspect_samples) aspect_dataset = aspect_dataset.map(lambda x: convert_aspect_example(x, tokenizer))

同一句话拆成两条样本后,模型学到的就是「针对特定属性的极性」,而不是整句的笼统极性。训练超参和句子级一致,学习率 3e-5、batch size 16、3 轮左右。这里有个细节:属性词在拼接文本里的位置会影响效果,常见做法是在属性词前后加特殊标记,或者用[SEP]分隔,我试过两种,差别不大,选一种保持一致性就行。

4.4 两阶段串联的推理脚本

推理时先跑属性抽取,拿到属性词列表,再对每个属性词跑情感分类,最后组装成结构化结果。这个串联脚本是整个方案对外交付的入口,要处理好空属性、超长文本、特殊字符这些边界情况。

def predict(text): # 第一步:属性词抽取 encoded = tokenizer(text, max_seq_len=128, return_tensors="pd") pred_tags = aspect_model(encoded["input_ids"], encoded["token_type_ids"]) aspects = decode_bio_tags(text, pred_tags, tokenizer) # 自定义 BIO 还原函数 if not aspects: return {"text": text, "aspects": [], "sentence_sentiment": predict_sentence(text)} # 第二步:对每个属性词做情感分类 results = [] for aspect in aspects: pair = text + tokenizer.sep_token + aspect pair_encoded = tokenizer(pair, max_seq_len=128, return_tensors="pd") logits = sent_model(pair_encoded["input_ids"], pair_encoded["token_type_ids"]) label = paddle.argmax(logits, axis=1).numpy()[0] results.append({"aspect": aspect, "sentiment": id2label[label]}) return {"text": text, "aspects": results, "sentence_sentiment": predict_sentence(text)}

decode_bio_tags负责把 B/I/O 序列还原成属性词字符串,注意处理中文 tokenizer 可能把一个词切成多个 subword 的情况,还原时要按字符拼接而不是按 token 拼接。空属性时回退到句子级结果,保证接口永远有输出。这个脚本可以直接包成 Flask 或 FastAPI 服务,对外提供 HTTP 接口。

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

5.1 现象:训练 loss 不降,准确率卡在 0.33 附近

原因通常是标签没对齐或者学习率过大。标签没对齐的典型表现是 loss 一直在ln(3)≈1.098附近震荡,说明模型在瞎猜。学习率过大则表现为 loss 先降后猛涨,甚至出现 NaN。

解决:先检查labels的取值是不是从 0 开始的连续整数,Paddle 的CrossEntropyLoss不接受负数或越界标签。再把学习率降到 1e-5 试一轮,如果 loss 开始稳定下降,说明之前学习率确实大了。另外确认optimizer.clear_grad()有没有漏调,漏了会导致梯度累积,等效于放大了学习率。

5.2 现象:验证集 F1 很高,上线后效果断崖式下跌

原因是训练数据和线上数据的分布不一致,最常见的是线上文本带大量表情符号、HTML 标签或者特殊空格,而训练数据是清洗过的。模型没见过这些噪声,tokenizer 会把它们切成[UNK],语义信息丢失。

解决:在预处理阶段加一层清洗,把 HTML 标签、连续空格、不可见字符去掉,表情符号可以保留但要做归一化。更稳妥的做法是从线上采样一批真实数据,人工标一小部分,混进训练集做微调。我一般会留 5% 的线上数据做验证,专门盯这个 gap。

5.3 现象:属性词抽取把「的」「了」这类虚词也标成属性

原因是 BIO 标签体系里 B/I 的边界定义不清晰,标注数据里虚词被误标,模型就学歪了。中文里属性词往往是名词或名词短语,虚词不该出现在 B-Aspect 位置。

解决:在标注规范里明确属性词必须是名词性成分,标注时做一轮交叉校验。训练时可以在 CRF 的转移矩阵里加约束,禁止从 O 直接跳到 I-Aspect,强制 B 开头。推理后用词性过滤,把明显是虚词的抽取结果丢掉,虽然粗暴但有效。

5.4 现象:显存溢出,batch size 调到 1 还是 OOM

原因是max_seq_len设得太大,或者模型用了 large 版本。ERNIE large 的参数量是 base 的三倍左右,8G 显存跑 batch size 8 都吃力。另外 Paddle 默认会缓存一部分显存,实际可用比标称少。

解决:先把max_seq_len从 256 降到 128,中文评论 128 基本够用。再确认用的是 base 而不是 large。如果还不够,开启梯度累积,用batch_size=4累积 4 步等效于 16,显存占用按 4 算。Paddle 里可以用paddle.amp开混合精度,显存能再省三成左右。

5.5 现象:同一句话多次推理结果不一致

原因是模型还在 train 模式,dropout 没关。推理前必须调model.eval(),否则每次前向传播 dropout 随机丢弃神经元,输出自然不稳定。这个坑很隐蔽,因为 loss 和训练都正常,只有推理时才发现。

解决:在推理脚本入口统一调model.eval(),并且用paddle.no_grad()包住前向传播,既关掉梯度计算省显存,也避免意外更新权重。如果用了 BatchNorm,eval()还会切换成用滑动统计量,进一步保证结果可复现。

6. 进阶技巧:用对抗训练和置信度过滤把属性级 F1 再抬一截

流水线跑通之后,如果想把属性级 F1 从 0.8 往 0.85 推,我一般会加两个技巧:对抗训练和置信度过滤。对抗训练是在 embedding 上加扰动,让模型对输入的小变化更鲁棒,中文评论里同义词替换、语序微调很常见,对抗训练能明显提升泛化。Paddle 里可以用 FGM(Fast Gradient Method)实现,在反向传播时对 embedding 梯度做归一化再加回去。

class FGM: def __init__(self, model, epsilon=1.0): self.model = model self.epsilon = epsilon self.backup = {} def attack(self): # 在 embedding 权重上加扰动 for name, param in self.model.named_parameters(): if "word_embeddings" in name: self.backup[name] = param.numpy().copy() norm = paddle.norm(param.grad) if norm > 0: r_at = self.epsilon * param.grad / norm param.set_value(param.numpy() + r_at.numpy()) def restore(self): # 恢复原始 embedding 权重 for name, param in self.model.named_parameters(): if name in self.backup: param.set_value(self.backup[name]) self.backup = {}

epsilon=1.0是扰动幅度,太大模型学不动,太小没效果,0.5 到 1.0 之间试。用法是在正常反向传播后调attack(),再前向传播一次算对抗 loss,累加后更新,最后restore()。这套下来训练时间增加约 50%,但 F1 通常能涨 1 到 2 个点。

置信度过滤是另一个便宜好用的技巧:推理时如果情感分类的 softmax 最大概率低于阈值(比如 0.6),就把这条结果标成「不确定」,交给人工复核,而不是硬给一个极性。这样能显著降低误判对业务的伤害,尤其是属性级任务里属性词边界模糊的样本。阈值怎么定?在验证集上画一条 precision-recall 曲线,选 F1 最高的那个点对应的概率值,我一般落在 0.55 到 0.65 之间。

最后说个习惯:每次改完超参或数据,我都会固定一个随机种子跑三遍,取 F1 的均值和方差。方差大于 0.02 说明结果不稳定,得先排查数据划分或者初始化的问题,别急着调模型。这套 ERNIE 情感分析方案从数据到推理大概两三天能跑通,属性级想调好需要多花一周在标注和阈值上。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 16:29:58

Claude Opus 5.5 编码提速与降价:开发者工作流接入实操与避坑指南

1. 从一条早报标题说起:Claude Opus 5.5 到底改了什么 早上刷到这条消息的时候,我正蹲在终端里调一个批量重构脚本,第一反应不是“又发新模型了”,而是“编码更快、定价更低”这八个字——对天天跟代码打交道的人来说,…

作者头像 李华
网站建设 2026/10/8 16:27:31

从AI Agent到自主容错控制:AI工程化落地的关键与实操

先说结论:看了今天这份AI日报的热搜词,我的感受是——AI行业正在从“拼模型”切换到“拼工程”。热搜词里密集出现的“AI Agent”“多AI协作”“自主容错控制”“AI工程实践”“AI模型部署”,说明大家关心的已经不是某个模型跑分多高&#xf…

作者头像 李华
网站建设 2026/10/8 16:26:45

拆解 Hermes Agent:从零搭建稳定高效的 Agent Loop 执行链路

手搓一个能自动干活的 Agent,最难的不是接大模型 API,而是把“思考 — 行动 — 观察 — 再思考”这条循环跑顺。市面上讲 AIAgent 的文章不少,但大多停在概念层,真正把执行流程拆到代码级、甚至能直接照着搭一遍的,少之…

作者头像 李华
网站建设 2026/10/8 16:25:22

AI Native开发团队落地实战:从工具链到流程重构

和不少团队负责人聊"AI Native"开发时,我发现大多数人的第一反应是:把Copilot类的工具买回来装好,让组员各自用起来,任务就完成了。真实落地根本不是这么回事。AI Native并不是"用AI辅助写代码",而…

作者头像 李华
网站建设 2026/10/8 16:25:21

Unity3D卫星车间数字孪生:高精度三维可视化与实时数据融合实战

简介:本资源是一套基于Unity3D引擎构建的卫星制造车间数字孪生系统,面向数字孪生开发人员、工业仿真学习者及虚拟现实项目实践者,用于搭建高精度三维可视化仿真平台。系统集成实时数据采集、物理引擎模拟、虚拟现实交互、多传感器融合与动态环…

作者头像 李华
网站建设 2026/10/8 16:25:01

企业智能体平台落地难?五种成熟路径与工程实践指南

这两年做企业智能体平台,我最大的感受是:Demo人人会做,落地十家有九家卡壳。客户要的不是一个会聊天的机器人,而是一套能接进审批流、能查对数据、能管住权限的“生产系统”。从工作流、RAG 到权限治理,每一条路都有典…

作者头像 李华