news 2026/10/7 6:12:18

ERNIE微调情感分析实战:句子级与属性级任务全流程解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ERNIE微调情感分析实战:句子级与属性级任务全流程解析

简介:一套面向自然语言处理开发者的情感分析完整实现,基于百度预训练大模型ERNIE,覆盖句子级与属性级两种粒度,提供从数据预处理、模型加载、特征提取到分类器训练评估的可运行源码,可直接用于实际项目或作为预训练模型应用模板。资源共24个文件,含6个Python脚本、9个JSON文件、4个PDF说明文档及数据集,压缩包25.54MB,结构上分为基于ERNIE完成属性级情感分析和IMDB情感分析两个子项目,便于按需学习。已有220人浏览学习。随包数据集包含标注的句子和属性,PDF文档辅助理解模型原理与代码流程,脚本覆盖情感分类、属性与观点抽取等关键环节,适合入门与进阶者快速掌握预训练模型在情感分析中的落地方法,也可迁移至其他自然语言处理任务,并复用其中的关键流程。

1. 情感分析从“规则”到“预训练”:ERNIE解决的是什么样的任务

做电商评论分析或者舆情监控的人,多多少少都积累过一本翻车账本:正则表达式把“没想象中差”当成差评,词频统计把“格调有了,就是太贵”算成好评。规则方法不是不能做,而是每换一个领域,特征就得重新设计,补第100个规则时也得补第101个洞。

预训练大模型时代,这个任务的解法变成了一条标准流水线:选一个中文预训练模型,在标注数据上做微调。本方案要讲的是Python生态里用百度ERNIE完成情感分析,跑通句子级和属性级两个任务。ERNIE在预训练阶段加入了中文实体与短语级别的语义理解,做情感判断时比直接套通用BERT更容易收敛。接下来的章节会从环境、数据、代码到参数逐一展开,每一步都给可复现的写法。

2. 环境与数据准备:装对依赖、看懂两份数据集的标注差异

先把环境建好是半天的任务。因为PaddlePaddle的安装包分CPU和GPU两个版本,且PaddleNLP的API会随着主版本变化,最稳的做法是用conda环境把Python版本锁死,依赖装在虚拟环境里而不是base环境。源码和数据集也尽量放在同一个独立目录里,避免路径和缓存互相污染。

2.1 版本组合与安装命令

我自己在本地复现这套任务时,用的组合是Python 3.8 + PaddlePaddle 2.5.x + PaddleNLP 2.6.x。为什么把Python锁在3.8:Paddle的轮子在3.9和3.10上的兼容性虽然接近,但个别依赖(尤其是pybind11相关的)在3.8上历史最干净,遇到问题时的网上解决方案也最多。Python 3.11及以上暂时不要碰,部分旧代码在算子注册上会直接把进程搞崩溃。

conda create -n ernie_sentiment python=3.8 -y conda activate ernie_sentiment # CPU版本,适合先跑通流程 pip install paddlepaddle==2.5.2 # GPU版本用下面这行,需提前装好CUDA 11.8 # pip install paddlepaddle-gpu==2.5.2 pip install paddlenlp==2.6.2 pip install pandas numpy scikit-learn

逻辑说明:这里分了CPU和GPU两条路径。首次跑通代码的时候不需要上GPU,拿CPU小数据验证接口再换GPU训练,能少踩很多版本坑。paddlenlp里面自带ERNIE模型的加载和微调接口,我们不需要手动下载权重文件,from_pretrained会从模型库拉取并缓存到本地目录。

参数说明:Python环境名ernie_sentiment是自定义的;paddlepaddle==2.5.2是CPU版本;paddlenlp==2.6.2对应PaddlePaddle 2.5的API。如果你拿到一份旧项目代码且出现了“parameter not found”“Op(scale) error”这类报错,第一件事就应该检查这两个包的版本,而不是去改代码。

依赖版本用途备注
Python3.8运行环境3.9也可用,但易出兼容性小毛病
paddlepaddle2.5.x深度学习框架CPU版先跑通流程
paddlenlp2.6.x预训练模型接口内置ERNIE权重加载
pandas任一稳定版读取数据集无版本硬性要求
scikit-learn任一稳定版切分数据与评估用于train_test_split

2.2 句子级数据集的格式与读取代码

句子级情感分析的数据集格式很简单,最常见是tsv文本,每行两列:文本和标签。标签可以设计成0/1两分类(负面/正面),也可以设计成0/1/2三分类(负面/中性/正面),还可以对应五星评分映射到0-4五个类别。下文以三分类为例,因为三分类更贴近真实评论:一条“价格还行,但质量一般般”往往不像规则系统那样被硬塞进正面或负面。

import pandas as pd df = pd.read_csv( "data/sentence_level/train.tsv", sep="\t", header=0, names=["text", "label"], encoding="utf-8" ) print(df.head()) print(df["label"].value_counts(normalize=True)) df["label"] = df["label"].astype(int) classes_num = df["label"].nunique()

逻辑说明:用pandas读tsv,必须显式指定sep="\t"和encoding="utf-8",否则Windows平台上读取中文很容易报UnicodeDecodeError。文件首行有列名时用header=0,没有列名时用names=["text", "label"]直接指定。读取后先看一眼label分布,再用astype(int)转数值,模型接受的是数值标签。

参数说明:value_counts(normalize=True)输出各类别占比,用于判断类别是否均衡。如果发现负面只占5%,后面训练时就得考虑加权损失,否则模型会无脑把所有样本预测成多数类。

2.3 属性级数据集怎么组织才不亏

属性级情感分析要拆开来说。以手机评论“屏幕显示效果很好,但电池不太耐用”为例,句子级模型只能看到整句的混合情感,很可能被预测为中立甚至负面;属性级模型需要输出两个三元组:(屏幕,正面)和(电池,负面)。数据集就不能一行一个标签了,而是一个句子对应多个属性-情感对。

最小可用的属性级数据集有两种组织方式。第一种是“句对”方式:第一列是句子,第二列是属性词,第三列是情感极性。第二种是把一个句子拆成多条样本,每个样本是(句子,属性),也就是说一条评论会出现在多行里。我建议按第二种组织,因为后续迁移到句对输入的自然语言处理模型时最顺手。

import pandas as pd df_absa = pd.read_csv( "data/aspect_level/aspect_train.tsv", sep="\t", header=None, names=["text", "aspect", "polarity"], encoding="utf-8" ) print(df_absa.groupby("text").size().value_counts()) print(df_absa["polarity"].value_counts()) polarity_map = {"negative": 0, "neutral": 1, "positive": 2} df_absa["polarity_id"] = df_absa["polarity"].map(polarity_map)

逻辑说明:这个数据组织把一条评论拆成了多行,每行只保留一个属性和对应情感。groupby("text").size().value_counts()能帮我们看平均每个句子有几条标注样本,如果多数句子都只有一条属性标注,那就要检查标注是否漏标了中性属性。情感极性映射成0/1/2后,就能直接送进ERNIE做三分类。

参数说明:polarity字段最好用英文枚举值,避免中文映射时出现前后不一致。如果你拿到的数据里用的是正/负/中,映射表改成{"正":2, "负":0, "中":1}即可。

字段示例1示例2含义
text屏幕显示效果很好,但电池不太耐用屏幕显示效果很好,但电池不太耐用原始评论文本
aspect屏幕电池需要判断情感的属性
polaritypositivenegative该属性对应正/负/中性

2.4 验证集划分这一件事必须做

一开始我常常贪图省事把全部数据拿去训练,然后对着训练集loss沾沾自喜,等拿到一批新数据才发现效果一塌糊涂。后来老老实实切出一折做验证集,很多过拟合问题当场暴露。预训练模型的微调速度快,验证集是最后的“后悔药”。

from sklearn.model_selection import train_test_split train_texts, val_texts, train_labels, val_labels = train_test_split( df["text"].tolist(), df["label"].tolist(), test_size=0.1, random_state=42, stratify=df["label"] ) val_ds = SentimentDataset(val_texts, val_labels, tokenizer, max_length=128) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False)

逻辑说明:stratify=df["label"]让划分前后各类别比例保持一致,否则类别不平衡数据随机切分后验证集可能近乎全正例。验证集的shuffle必须设为False,否则每次评估的样本顺序变化会导致指标轻微抖动,难以判断当前改动是真是假。

参数说明:test_size=0.1在小数据集(几千条)上可能让验证集样本太少,建议比例放到0.2;如果总数超过5万条,再缩回0.1节省训练时间。random_state=42固定随机种子,保证重新运行时划分结果一致。

这一章结束后,至少有一个可自检的点:两条命令行能解决环境,两份数据集的结构能无脑复现。如果训练后出现loss不降,先回头检查这里,不用先去怀疑模型或代码。环境错一层,后面的所有操作都会叠加出错,这属于血泪经验。

3. 句子级情感分析跑通:用ERNIE微调一个评论分类器的完整代码

句子级任务可以当作一个经典文本分类问题看待。ERNIE的最后一层隐藏状态代表整个句子的语义,接一个全连接分类头就能输出每个类别概率。它的收敛速度和准确率都明显优于用word2vec、TF-IDF特征搭出来的传统模型,尤其是在句子长度为10到100字的中文评论上。

3.1 任务本质与分类头选择:为什么后面只接一个Linear层

ERNIE这类预训练模型在[CLS]位置输出的向量,已经从预训练阶段学会了汇总全句信息。我们只需要把它接到一个num_classes的全连接层上。PaddleNLP的ErnieForSequenceClassification已经把这段封装好了,不需要手动拼模型。这样做还有一个额外的好处:预训练阶段的语义知识被完整保留,微调只是“校准”,而不是“从零学习”。

from paddlenlp.transformers import ErnieForSequenceClassification, ErnieTokenizer model_name = "ernie-3.0-base-zh" num_classes = 3 model = ErnieForSequenceClassification.from_pretrained( model_name, num_classes=num_classes ) tokenizer = ErnieTokenizer.from_pretrained(model_name)

逻辑说明:from_pretrained会优先从本地缓存加载权重,本地没有才会从模型库下载,初次下载体积不小,耗时看网络状况。num_classes=3会自动替换模型顶部的分类头,无须自己写Linear层。tokenizer负责把中文句子切成token序列,并生成input_ids和token_type_ids两类输入。

参数说明:model_name可以换成ernie-3.0-medium-zh或ernie-3.0-mini-zh,更小更快但效果略降。如果只是验证代码流程,用mini版本能省一半显存;最终压测再换回base。换模型名后tokenizer也要同步换,不少人只换了模型名没换tokenizer,跑起来报字表维度不匹配。

3.2 tokenizer与Dataset:把文本变成Paddle能吃的张量

这里碰上的是第一个真正会反复调整的环节。最大长度max_length直接影响训练速度、显存和效果。常见做法是设128,对短评论足够;长文本场景要谨慎,后文避坑章节会专门讲截断问题。把这条评论处理成一个固定长度的张量,是后续训练稳定性的前提。

import numpy as np from paddle.io import Dataset, DataLoader class SentimentDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_length=128): self.texts = texts self.labels = labels self.tokenizer = tokenizer self.max_length = max_length def __getitem__(self, idx): text = self.texts[idx] labels = self.labels[idx] encoded = self.tokenizer( text, max_length=self.max_length, truncation=True, padding="max_length", return_tensors="pd" ) return encoded["input_ids"], encoded["token_type_ids"], np.array([labels], dtype="int64") def __len__(self): return len(self.texts) train_ds = SentimentDataset(df["text"].tolist(), df["label"].tolist(), tokenizer) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True)

逻辑说明:tokenizer里的return_tensors="pd"直接返回Paddle张量,DataLoader拿到手就能送进模型,避免在训练循环里手动转格式。padding="max_length"让同批次内所有样本等长,方便矩阵运算,代价是短样本有填充浪费。labels转成np.array后在外层放一个括号,是为了之后batch维度统一成(batch_size, 1)。

参数说明:batch_size=32在base模型上大概占5到7GB显存,显存少就降到16,CPU上跑可以调到8。shuffle=True每个epoch打乱样本顺序,避免模型学到数据排列里的顺序偏差。max_length设得过大只会浪费算力,128对大多数电商评论已经覆盖90%以上的句子。

3.3 训练循环:优化器、学习率与warmup设置

预训练模型微调有一个通行的规矩:学习率不能像训练普通神经网络那样动辄0.01,一般落在2e-5到5e-5之间,而且前面要加warmup。原因是预训练权重已经收敛到较好状态,学习率太大容易把学到的语义结构冲散,也就是业内调侃的“灾难性遗忘”。

import paddle import paddle.nn.functional as F from paddlenlp.transformers import LinearDecayWithWarmup learning_rate = 3e-5 epochs = 3 steps_per_epoch = len(train_loader) total_steps = steps_per_epoch * epochs warmup_steps = int(total_steps * 0.1) scheduler = LinearDecayWithWarmup( learning_rate=learning_rate, total_steps=total_steps, warmup=warmup_steps ) optimizer = paddle.optimizer.AdamW( learning_rate=scheduler, parameters=model.parameters(), weight_decay=0.01 ) for epoch in range(epochs): model.train() total_loss = 0.0 for step, batch in enumerate(train_loader): input_ids, token_type_ids, labels = batch labels = paddle.squeeze(labels, axis=-1) logits = model(input_ids, token_type_ids) loss = F.cross_entropy(logits, labels) loss.backward() optimizer.step() scheduler.step() optimizer.clear_grad() total_loss += loss.item() if step % 100 == 0: print(f"epoch:{epoch} step:{step}/{len(train_loader)} loss:{loss.item():.4f}") avg_loss = total_loss / len(train_loader) print(f"epoch {epoch} done, avg_loss: {avg_loss:.4f}")

逻辑说明:LinearDecayWithWarmup是PaddleNLP自带的学习率调度器,前10%的step学习率从0线性升到设定值,后90%线性降到接近0,相当于给模型一个“先热身再冲刺”的节奏。loss.backward()之后必须加optimizer.clear_grad(),Paddle是手动梯度清理,漏了这一行梯度会在每个step上累加,loss会越训越离谱。

参数说明:epochs=3在这个任务上通常是收敛边界,更多轮次未必提升F1,反而可能过拟合训练集;weight_decay=0.01是微调标准值。打印loss时关注在epoch结束前是否下降到0.1附近——如果只降到0.7左右下不去,多半是学习率偏大或者数据标签有噪声。

3.4 评估与保存:用准确率和F1两个视角看模型

训练完成后,看分类效果不能只盯着accuracy。情感分析数据集十有八九存在类别不平衡,正面评论往往占大多数,一个全投正面的模型也能拿到0.7以上的accuracy,但业务上没有任何价值。正确做法是计算每个类别的precision、recall、F1,再取macro平均。

from sklearn.metrics import precision_recall_fscore_support def evaluate(model, loader): model.eval() all_preds = [] all_labels = [] with paddle.no_grad(): for batch in loader: input_ids, token_type_ids, labels = batch labels = paddle.squeeze(labels, axis=-1) logits = model(input_ids, token_type_ids) preds = paddle.argmax(logits, axis=-1) all_preds.extend(preds.numpy().tolist()) all_labels.extend(labels.numpy().tolist()) precision, recall, f1, _ = precision_recall_fscore_support( all_labels, all_preds, average="macro", zero_division=0 ) acc = sum(1 for p, l in zip(all_preds, all_labels) if p == l) / len(all_labels) print(f"accuracy: {acc:.4f}, macro-precision: {precision:.4f}, macro-recall: {recall:.4f}, macro-F1: {f1:.4f}") return all_preds, all_labels

逻辑说明:启用model.eval()并包裹paddle.no_grad(),在评估阶段不计算梯度,避免显存和耗时翻倍。将preds和labels转成numpy list交给sklearn计算指标。average="macro"把三类指标平均汇总,类别不平衡时macro比accuracy诚实得多。zero_division=0防止某个类别一条都没预测对时报除零警告。

参数说明:如果希望输出每个类别的单独指标,可以把average换成None,得到形状为(num_classes,)的三个数组,特别适合排查“某个负面类别完全没召回”的情况。评估后保存模型用model.save_pretrained,同时保存tokenizer,否则推理阶段加载模型后无法还原词表。

model.save_pretrained("./checkpoints/ernie_sentiment_sentence") tokenizer.save_pretrained("./checkpoints/ernie_sentiment_sentence")

逻辑说明:PaddleNLP的save_pretrained把权重和配置文件写入指定目录。推理时从同一目录用from_pretrained读取,就能和训练时保持完全一致的词表和预处理逻辑。保存目录建议按项目名分二级目录,避免多版本覆盖。

4. 属性级情感分析:把“好看但贵”拆成两个独立情感判定的实现

属性级情感分析和句子级最大的不同在于训练样本的构造方式。“屏幕显示效果很好,但电池不太耐用”这句话里,模型需要对“屏幕”和“电池”分别给出情感极性。如果不把属性信息告诉模型,模型天然分不开两段情感,这也是属性级任务最常见的翻车根源。

4.1 属性级任务拆解:两个子任务缺一不可

动手之前先搞清楚要做哪一步。属性级情感分析在学术上分两个子任务:

  1. 属性词抽取(Aspect Term Extraction):自动找出句子里的属性词或属性短语,比如“屏幕”“电池”。
  2. 属性级情感分类(Aspect Sentiment Classification):给定句子和属性词,输出该属性的情感极性。

放在工程落地里,多数时候两个子任务都要做。可以先把属性词当作序列标注任务用ERNIE抽出来,再把抽到的名词逐个跑情感分类。很多现成的开源项目只给第二段的标注数据,所以项目里“数据集”这一项的质量,往往决定了属性级最终能跑多远。

4.2 用句对输入实现属性级情感分类:把属性和上下文绑在一起

最稳妥的做法是把句子和属性词拼成一个BERT式“句对”输入:[CLS] 整句 [SEP] 属性词 [SEP]。在tokenizer里直接用text_pair参数构造。这里的关键是token_type_ids:模型需要区分哪些token属于评论正文、哪些属于属性词,这样Attention机制才能在不同片段之间做有方向的对齐。

class AspectSentimentDataset(Dataset): def __init__(self, texts, aspects, polarities, tokenizer, max_length=160): self.texts = texts self.aspects = aspects self.polarities = polarities self.tokenizer = tokenizer self.max_length = max_length def __getitem__(self, idx): text = self.texts[idx] aspect = self.aspects[idx] polarity = self.polarities[idx] encoded = self.tokenizer( text, text_pair=aspect, max_length=self.max_length, truncation=True, padding="max_length", return_tensors="pd" ) return encoded["input_ids"], encoded["token_type_ids"], np.array([polarity], dtype="int64") def __len__(self): return len(self.texts)

逻辑说明:text_pair=aspect让tokenizer生成两个句子段的token_type_ids。相比把整句和属性词直接拼接成一段文本喂进去,句对结构给模型提供的归纳偏置更合理——模型能明确知道哪些位置是上下文、哪些位置是被评价的对象。微调阶段模型会逐渐学会把属性词附近的语义抠出来做极性判断。

参数说明:max_length建议放宽到160,因为句对输入比单句多了一段属性词,截断带来的影响也更明显。如果数据集里句子普遍不超过30个字,120也够用;不要无脑上256,训练速度会成倍下降。

4.3 训练复用句级代码,改这几个参数就行

属性级微调的代码和句子级几乎一样,唯一的区别是num_classes改为3,labels换成每个样本的属性极性。很多新手给属性级模型单独设计一套训练逻辑,其实换汤不换药。先跑通再调参是这里最好的策略。

model = ErnieForSequenceClassification.from_pretrained( "ernie-3.0-base-zh", num_classes=3, dropout=0.1 ) aspect_ds = AspectSentimentDataset( df_absa["text"].tolist(), df_absa["aspect"].tolist(), df_absa["polarity_id"].tolist(), tokenizer, max_length=160 ) aspect_loader = DataLoader(aspect_ds, batch_size=16, shuffle=True)

逻辑说明:dropout=0.1是PaddleNLP模型默认值,一般不用调。如果数据集很小(少于1万条),可以把dropout调到0.3,抑制过拟合的效果立竿见影。batch_size=16是因为句对样本每条约比单句长20到30个token,显存压力大于句级任务。

训练循环本身和3.3节完全一致,优化器、warmup、损失函数都不需要动。唯一要提防的是训练数据量不足:属性级任务对每个属性-情感对都要有足够样本,如果某个属性只出现几十次,模型很难学稳。这时候优先考虑在数据层面做增强,而不是调模型结构。

4.4 属性词兜底方案:用词性筛选作为保底管道

在真实项目中,标注好的属性词未必齐全。这时候有一个低成本兜底:用词性分析筛选名词短语作为属性候选,再交给上面训练好的属性级分类器过滤。不需要额外训练一个序列标注模型也能跑出可用的结果。

import jieba.posseg as pseg def extract_aspect_candidates(text, max_candidates=5): words = pseg.cut(text) candidates = [] for word, flag in words: if flag.startswith("n") and len(word) >= 2: candidates.append(word) return candidates[:max_candidates] sentence = "屏幕显示效果很好,但电池不太耐用" print(extract_aspect_candidates(sentence)) # 屏幕, 效果, 电池, 耐用(后两个需要过滤)

逻辑说明:词性筛选只能作为候选生成器,不能直接当最终属性,因为序列里会混入“效果”这类宽泛名词和“耐用”这种被动词修饰的形容词。把候选喂给句对模型后,模型对“效果”很可能输出中性,对“电池”输出负面,等于自动做了第二轮过滤。这个兜底方案的效果上限不如专门训练的属性抽取模型,但胜在零标注成本。

5. ERNIE微调避坑档案:五个常见的翻车点与对应解法

这章是给急着上线的人看的。以下五个坑我都踩过,每个都按“现象、原因、解决”来写。如果某一条跟你的现场对上了,直接照做通常能省半天时间。

5.1 Windows下读取中文数据集报编码错

现象:在Windows下直接读训练tsv,抛UnicodeDecodeError: 'gbk' codec can't decode byte...。

原因:pandas在Windows默认使用系统编码GBK,而数据文件是UTF-8。这是Python读取文本文件最经典的坑,也最容易被人忽略——因为同一份代码在Linux上跑没有任何问题。

解决:读文件时显式传入encoding="utf-8"。如果你手里已经有了一批GBK编码的旧文件,可以先用脚本统一转码,不要靠pandas自动推断,损失数据不可逆。

df = pd.read_csv("train.tsv", sep="\t", encoding="utf-8", engine="python")

5.2 模型权重加载时报“参数名不匹配”

现象:从模型库下载好权重后,实例化模型时抛parameter not found in checkpoint或['weight'] shape mismatch。

原因:PaddlePaddle 2.4升到2.5之后,PaddleNLP也换了主版本,from_pretrained读取的检查点结构从旧版本格式变成了新格式;模型缓存目录里残留旧版本文件时,from_pretrained会优先读旧文件,于是报参数名不匹配。

解决:把本地paddlenlp缓存目录下对应的ERNIE缓存文件夹删掉,确保paddlepaddle和paddlenlp版本配对,再重新下载。这条可以直接套用到任何“昨天还能跑,今天突然不行”的诡异场景。

5.3 类别不平衡导致训练时准确率虚高

现象:训练完成,accuracy显示89%,但报表里“负面”类别的precision为0。

原因:负面样本只占总数6%,模型学到把所有样本预测为多数类,准确率被正面样本拉高了。不看分类别指标,这种假象很难被发现。

解决:先改用macro-F1评估,再对损失函数做类别加权。加权公式是按类别样本数的倒数归一化权重:

counts = df["label"].value_counts().sort_index() class_weight = (counts.sum() / (counts * len(counts))).to_numpy() loss = F.cross_entropy( logits, labels, weight=paddle.to_tensor(class_weight, dtype="float32") )

逻辑说明:class_weight让少数类在loss里占据更高比例,模型不再偏向多数类。注意这个权重只在类别比例严重失衡(超过10:1)时使用,正常分布下加了反而让公共类别变差。

5.4 长文本截断把关键情感信息截掉了

现象:一篇500字的评论总体正面,但最后一句是“总之不会再买了”,模型预测成positive。

原因:默认truncation=True从尾部截断,保留开头,关键的尾部转折被截掉了。预训练模型只看前128个token,根本看不到“不会再来”这个决定性表述。

解决:改用truncation_side="left",从开头截去超出部分,保留句尾。或者先把长文本按逗号句号分句,对每句单独预测,再用句级权重汇总。后者更稳,但代码量稍多。

tokenizer = ErnieTokenizer.from_pretrained(model_name, truncation_side="left")

5.5 属性级模型对属性词“视而不见”

现象:句对模型训练完,“屏幕显示效果很好,但电池不太耐用”分别以“屏幕”和“电池”作为属性词预测时,结果几乎都是正面。

原因:属性词在整个句对里占比太小,模型在训练中把大部分注意力放在了评论正文上,属性词的存在对最终分类头几乎没有贡献。如果训练数据里每个句子有多段情感,而属性词又被截断,模型就会学会“抄”整句的主情绪。

解决:两个方向,可以组合使用。第一是把输入结构改成“属性词在前”的拼接方式,让模型在处理正文前先看到被评价对象;第二是训练时复制属性词,替换成“attribute [MASK]”之类的增强信号,逼迫模型依赖属性信息。

# 属性词提前到句首的构造方式 encoded = tokenizer( aspect, text_pair=text, max_length=self.max_length, truncation=True, padding="max_length", return_tensors="pd" )

我实测下来,属性词前置于句首对小样本任务提升明显,大约能挽回5到8个点的macro-F1。

6. 用“访谈测试”验证模型真实水平,再做一轮针对性微调

训练完成之后,先别急着上线。我的习惯是跑一轮“访谈测试”:人工挑出二三十条在人类看来明显带情感倾向、但模型容易混淆的句子来核对。这一步能暴露评测集上完全看不出来的短板。

6.1 构造硬负例集,把模型的偏见逼出来

硬负例集要覆盖几类模式:带否定词、带转折、带委婉表达、带反讽。每类准备五条左右,跑一遍模型预测,统计判错的位置。

hard_cases = [ "没想象中那么差,但也没多好", # 转折 "这个价位就别指望有什么惊喜了", # 委婉否定 "性价比高是真的,服务差也是真的", # 双方面 "也就那样吧,不吹不黑", # 中性偏负 ] for text in hard_cases: inputs = tokenizer(text, max_length=128, truncation=True, return_tensors="pd") logits = model(inputs["input_ids"], inputs["token_type_ids"]) pred = paddle.argmax(logits, axis=-1).item() print(text, "->", ["negative", "neutral", "positive"][pred])

逐条记录与人类预期不一致的地方,你会发现失败往往集中在某一类模式上:比如所有转折句都被判成正面,所有委婉否定都被判成中性。找到共性后,针对这个共性去扩充训练数据,用1e-5的学习率额外微调两轮,不要从头训。这样能保住原有能力,只修正偏差点。

走完这轮访谈测试,模型才真正算“能用”,而不是“在评测集上分数好看”。我接过不少情感分析项目,发现最贵的部分从来不是训练本身,而是减少模型和业务真实分布之间的间隙。每换一个领域,我都会先用二三十条硬负例摸一遍底,再决定要不要追加训练数据,这个习惯帮我避开了很多翻车现场。希望帮到你。

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

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

Claude 跨会话记忆方案解析:claude-mem 工具实操指南

你有没有遇到过这种场景:上午刚跟 Claude 在终端里把项目架构聊得明明白白,下午新开一个会话问它“上次说那个模块还要不要重构”,结果它一脸茫然,让你把需求再贴一遍。这不是它笨,是真的“不记得”。Claude 每一次对话…

作者头像 李华
网站建设 2026/10/7 6:10:29

运营商工单系统智能Agent改造:分类路由与闭环处置落地实践

做运营商行业数智化改造这几年,工单系统一直是个“看起来简单、做起来难受”的领域。尤其到了省级或集团级别,“海量工单”四个字背后是每天几万到几十万张单子从接入到装维、从传输到核心网、从故障到投诉的滚滚洪流。最近我们团队把一套基于智能Agent的…

作者头像 李华
网站建设 2026/10/7 6:09:57

三极管推挽放大电路详解:原理、交越失真与实战设计

三极管推挽放大电路是模拟电路里绕不开的一个经典结构。音频功放、电机驱动、电源输出级,甚至IGBT的驱动板里都能看到它的身影。很多朋友一开始接触“推挽”两个字总觉得很高深,其实拆开了看,就是一只NPN管负责推,一只PNP管负责拉…

作者头像 李华
网站建设 2026/10/7 6:09:47

LangGraph.js前端AI Agent工程实践:简历优化系统搭建

1. 这不是“又一个AI简历生成器”,而是一套可部署、可监控、可迭代的AI Agent工作流我去年帮三位朋友做过简历优化,每次都要花两小时:先通读原始经历,再对照目标岗位JD逐条拆解能力关键词,接着重写项目描述、调整技术栈…

作者头像 李华