简介:面向自然语言处理学习者和开发者,以百度预训练模型ERNIE为核心的完整情感分析工程包,覆盖句子级与属性级两个分析维度,并搭配已标注数据集,适合入门到进阶阶段的NLP实践。资源共24个文件,以py训练与推理脚本、json数据格式、pdf说明文档、pyc缓存等为主,整体约25.54MB,目录按情感分析任务拆分,便于定位代码与数据。已有220人学习下载。包内包含IMDB句子级情感分析与属性级情感分析两套可运行项目,从数据预处理、模型加载、特征提取到分类器训练与评估均有对应脚本;属性级分析还配有属性与观点抽取模块,结合pdf文档可快速掌握基于ERNIE完成情感分类的完整落地流程,也可作为其他预训练模型NLP任务的代码模板。
1. 情感分析为什么绕不开 ERNIE:句子级与属性级到底差在哪
Python基于预训练大模型ERNIE完成的情感分析,听起来规格很高,实际落地时你只需要一条文本、一个标注好的数据集、一张GPU(没有也行),就能把中文情感分类做到九成以上的准确率。这个方向包含两条任务线:句子级别判断整条评论是正面还是负面;属性级别为“服务”“位置”“价格”这些具体方面分别打分——同一句“酒店服务不错但隔音糟糕”,句子级只能给一个结果,属性级要同时输出“服务=正面、隔音=负面”。它适合想快速搭建中文情感分类服务的后端工程师、准备做情感分析实验的研究生,以及要在自己业务数据上微调模型的产品同学。接下来我把环境、数据、代码、踩过的坑一条条拆开。
2. 环境与数据集准备:用PaddleNLP加载ERNIE,以及两种数据格式的约定
中文情感分类的模型选型基本没有悬念:BERT能吃中文,但对中文语义的编码不够透彻。ERNIE 是百度开源的预训练大模型,它的掩码策略覆盖了实体和短语层面,比 BERT 的随机掩码更能保留“新瓶装旧酒”这类中文特有语义。更关键的是,Python 生态里 PaddleNLP 把 ERNIE 系列全部打包成一行from_pretrained,省去手工下载权重和转换格式的麻烦。这一章先把环境装明白,再把数据切成统一格式,避免后面训练脚本被各种小问题打断。
2.1 python安装与依赖匹配:先装 paddlepaddle,再装 paddlenlp
常见做法是在干净的虚拟环境里操作,避免和已有项目打架。python 安装好之后,用 venv 创建独立环境,然后装两个核心包:paddlepaddle(CPU 或 GPU 版)和 paddlenlp。安装顺序建议先 paddlepaddle 再 paddlenlp,让 pip 按依赖关系自动匹配;如果反过来,pip 可能会为了满足依赖把已装好的包又换掉版本。
python -m venv venv_ernie source venv_ernie/bin/activate # Windows 用 venv_ernie\Scripts\activate pip install --upgrade pip pip install paddlepaddle # GPU 机器装 paddlepaddle-gpu pip install paddlenlp装完别急着跑训练,先做一次导入自检。paddlepaddle 和 paddlenlp 的版本匹配是翻车重灾区,常见的现象是import paddlenlp时报底层算子找不到,或者模型加载时提示某个 API 不存在。自检脚本如下:
import paddle import paddlenlp from paddlenlp.transformers import AutoTokenizer print(paddle.__version__) print(paddlenlp.__version__) tok = AutoTokenizer.from_pretrained("ernie-3.0-base-zh", use_faster=False) print(len(tok.vocab))这里用use_faster=False值得单独说。新版 PaddleNLP 默认走 FasterTokenizer,速度快但对操作系统和 Python 版本有额外依赖,加载报错时退回传统 tokenizer 是最快的解决办法。vocab 长度打印出来,说明 ERNIE 词表已加载,环境这关就算过了。GPU 机器建议先跑一下nvidia-smi看显存,8GB 显存跑 base 模型、max_length 128、batch 32 是可以接受的;如果只有 CPU,调参阶段先用 mini 版本模型跑通链路。
2.2 数据集格式:句子级两列、属性级三列,先统一成 TSV
数据集是这个项目最容易“看起来能用、一跑就炸”的部分。我习惯把所有数据统一成 UTF-8 编码的 TSV,不用 CSV——中文评论里逗号太多,CSV 的引号转义会让清洗脚本多写一屏。
句子级(Sentence-level)表格:
| text | label |
|---|---|
| 酒店的服务还算不错 | 1 |
| 房间太小,不值这个价 | 0 |
属性级(Aspect-based)表格:
| text | aspect | label |
|---|---|---|
| 酒店的服务还算不错 | 服务 | 1 |
| 酒店的服务还算不错 | 位置 | 0 |
| 房间太小,不值这个价 | 价格 | 0 |
标签统一用 0 表示负面、1 表示正面。判断数据能不能直接用,先统计文件行数和合法标签:
wc -l data/*.tsv awk -F '\t' '{print $NF}' data/senti_aspect.tsv | sort | uniq -c第二个命令把属性级数据的标签列单独抽出来统计,如果出现 0、1 之外的取值,说明标注不一致或文件里混入了空行,训练前必须处理。公开数据集方面,句子级常用酒店评论和微博情感两个经典中文语料,属性级可以找 SemEval-2014 的餐厅/笔记本英文数据做原型验证,或者直接用业务场景里人工标注的中文方面数据。规模上,句子级一两万条足够微调出可用模型;属性级每个 aspect 类别最好不少于 500 条,否则容易过拟合。
这里有一个非常隐蔽的切分坑:属性级数据里,同一条句子会对应多行(不同 aspect 各一行)。切分训练集和验证集时,一定要按句子维度切,不能按行数直接切。按行切会把同一句话的“服务”分到训练集、“位置”分到验证集,造成数据泄漏,验证指标虚高。我一般先把句子去重拿到列表,按句子列表切分,再把 aspect 行映射回去。
2.3 用 tokenizer 验证一条文本:看清 input_ids 和 token_type_ids
拿到数据后,不要急着写训练循环,先用 tokenizer 打一条样本,看看 ERNIE 到底把文本变成了什么。这一步能看到很多后期排查依赖的细节。
from paddlenlp.transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("ernie-3.0-base-zh", use_faster=False) text = "酒店的服务不错,但隔音很糟糕" tokens = tokenizer( text, text_pair="服务", max_length=32, truncation=True, padding="max_length", ) print(tokenizer.convert_ids_to_tokens(tokens["input_ids"])) print(tokens["token_type_ids"][:20])第一次跑的人,一定要盯着token_type_ids看。前面的[CLS]和“酒店的服务不错”部分对应 0,从中间[SEP]开始、属性词“服务”及后续对应 1,padding 部分又回到 0。这个“句子=0、属性=1、padding=0”的分布是后面属性级别情感分析的关键,很多模型训练完不生效,就是这一步没做对。input_ids则决定了文本截断位置——把 tokens 打印出来,数一数最长样本在 max_length 下丢掉了哪些词。如果情感词经常被截掉,第 5 章会讲怎么统计和解决。
数据准备到这里告一段落。需要强调,下载什么数据不重要,重要的是每一行都能对应到上面的 TSV 结构;数据格式统一,训练脚本才能做到“换数据不换代码”。这个源代码项目之所以把数据集单独拎出来,是因为情感分析里数据质量对结果的影响,经常比模型结构还大。
3. 句子级别情感分析:完整训练脚本与三个关键参数
句子级别的目标很直接:给定一条文本,输出 0 或 1。由于 ERNIE 本身已经把中文语义编码得很充分,我们不需要在它上面堆复杂结构,输出端接一个线性分类头就够了。这一章给出一个完整可跑的源代码流程,从模型定义到验证,并交代清楚三个最影响结果的关键参数。
3.1 模型定义:用 AutoModelForSequenceClassification 而不是裸 ErnieModel
很多第一次写的人会试图加载ErnieModel后自己写 pooling 和全连接层,这是不必要的。PaddleNLP 提供的AutoModelForSequenceClassification已经在[CLS]输出上挂好了分类头,只需要传num_classes。它在内部会根据分类数自动初始化对应维度的全连接层,不会出现输出维度对不上标签的情况。
from paddlenlp.transformers import AutoModelForSequenceClassification model = AutoModelForSequenceClassification.from_pretrained( "ernie-3.0-base-zh", num_classes=2, ) print(model)如果机器显存有限,把模型名换成ernie-3.0-medium-zh或ernie-3.0-mini-zh,其余代码不用改。选型上我一般这样定:数据量少于 2 万条时用 base 性价比最高;超过 5 万条或追求极致精度再考虑更大模型;CPU 机器调参阶段先用 mini 跑通全流程,最后再用 base 训一版。打印模型结构只是为了确认分类头挂上了,不需要把完整输出看完。
3.2 Dataset 与 DataLoader:把 TSV 变成模型能消费的 batch
训练脚本的核心是把上一章准备好的 TSV 读进来,通过 tokenizer 转成input_ids、token_type_ids、attention_mask,再按 batch 喂给模型。这里有一个容易忽视的细节:Paddle 的 DataLoader 默认 collate 对普通列表能处理,但对 Paddle Tensor 的行为在不同版本里有差异。为避免版本差异带来的随机报错,我建议在 Dataset 里统一返回 tensor,并显式写一个collate_fn。
import paddle from paddle.io import Dataset, DataLoader from paddlenlp.transformers import AutoTokenizer class SentiDataset(Dataset): def __init__(self, file_path, tokenizer, max_len=128): self.tokenizer = tokenizer self.max_len = max_len self.texts, self.labels = [], [] with open(file_path, "r", encoding="utf-8-sig") as f: next(f) # 跳过表头 for line in f: parts = line.rstrip("\n").split("\t") if len(parts) < 2: continue self.texts.append(parts[0]) self.labels.append(int(parts[1])) def __len__(self): return len(self.texts) def __getitem__(self, idx): tokens = self.tokenizer( self.texts[idx], max_length=self.max_len, truncation=True, padding="max_length", ) return ( paddle.to_tensor(tokens["input_ids"], dtype="int64"), paddle.to_tensor(tokens["token_type_ids"], dtype="int64"), paddle.to_tensor(tokens["attention_mask"], dtype="int64"), paddle.to_tensor([self.labels[idx]], dtype="int64"), ) def collate_fn(batch): input_ids = paddle.stack([x[0] for x in batch]) token_type_ids = paddle.stack([x[1] for x in batch]) attention_mask = paddle.stack([x[2] for x in batch]) labels = paddle.concat([x[3] for x in batch], axis=0) return input_ids, token_type_ids, attention_mask, labels几个细节值得说明。文件打开用encoding="utf-8-sig"而非"utf-8",是因为 Windows 下生成的 TSV 常带 BOM 头,不处理的话第一行文本会多出\ufeff字符。paddle.concat处理 labels,是为了把每个 batch 里的[1]形状拼成[batch_size],而不是[batch_size, 1],否则计算损失时会报维度不匹配。padding="max_length"会带来一定计算浪费,但让collate_fn保持简单;想省显存可以改padding="longest",代价是每个 batch 序列长度不同,加载时略微多花时间。
3.3 训练循环:学习率、warmup、epoch 怎么定
训练循环本身不复杂,复杂的是三个参数的取舍:learning_rate、warmup 比例、epoch 数。预训练模型微调的标准学习率区间是 2e-5 到 5e-5,大于 5e-5 容易出现灾难性遗忘,小于 1e-5 则收敛太慢。warmup 比例 0.1 表示前 10% 的训练步数里学习率线性上升,之后按线性衰减。epoch 数在情感分类任务上 3 个就够,超过 5 个通常只会看到验证集指标停止增长。
| 参数 | 建议取值 | 说明 |
|---|---|---|
| learning_rate | 2e-5 ~ 5e-5 | 超过 5e-5 容易遗忘预训练语义 |
| warmup_ratio | 0.1 | 前 10% 步数线性升温 |
| epochs | 3 | 再多通常过拟合 |
| weight_decay | 0.01 | AdamW 默认正则强度 |
| batch_size | 16 ~ 64 | 取决于显存和文本长度 |
def evaluate(model, val_loader): model.eval() correct = 0 total = 0 with paddle.no_grad(): for input_ids, token_type_ids, attention_mask, labels in val_loader: outputs = model(input_ids, token_type_ids=token_type_ids, attention_mask=attention_mask) logits = outputs.logits if hasattr(outputs, "logits") else outputs preds = paddle.argmax(logits, axis=-1) correct += (preds == labels).sum().item() total += labels.shape[0] return correct / total def train(model, train_loader, val_loader, epochs=3): criterion = paddle.nn.CrossEntropyLoss() lr_scheduler = paddle.optimizer.lr.LinearDecay( learning_rate=2e-5, total_steps=epochs * len(train_loader), warmup_ratio=0.1, end_learning_rate=0.0, ) optimizer = paddle.optimizer.AdamW( learning_rate=lr_scheduler, parameters=model.parameters(), weight_decay=0.01, ) for epoch in range(epochs): model.train() total_loss = 0.0 for step, (input_ids, token_type_ids, attention_mask, labels) in enumerate(train_loader): outputs = model(input_ids, token_type_ids=token_type_ids, attention_mask=attention_mask) logits = outputs.logits if hasattr(outputs, "logits") else outputs loss = criterion(logits, labels) loss.backward() optimizer.step() optimizer.clear_grad() total_loss += loss.item() if step % 200 == 0: print(f"epoch {epoch} step {step} loss {loss.item():.4f}") acc = evaluate(model, val_loader) print(f"epoch {epoch} val_acc {acc:.4f}")这里hasattr(outputs, "logits")分支是我反复踩过的兼容性写法:PaddleNLP 2.x 早期版本直接返回 logits tensor,之后的版本返回带logits属性的对象。不写这个分支,换个版本代码就崩。attention_mask理论上可以不传,ERNIE 内部会自动补全,但显式传入能让 tokenizer 的 padding 与模型计算保持一致,尤其是属性级任务里text_pair带来的 mask 变化,这一步不能省。
主流程把数据集切好、模型加载、开训:
if __name__ == "__main__": tokenizer = AutoTokenizer.from_pretrained("ernie-3.0-base-zh", use_faster=False) train_ds = SentiDataset("data/senti_sentence_train.tsv", tokenizer) val_ds = SentiDataset("data/senti_sentence_dev.tsv", tokenizer) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, collate_fn=collate_fn) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False, collate_fn=collate_fn) model = AutoModelForSequenceClassification.from_pretrained("ernie-3.0-base-zh", num_classes=2) train(model, train_loader, val_loader) model.save_pretrained("models/ernie_sentence") tokenizer.save_pretrained("models/ernie_sentence")batch size 的设法和文本长度强相关。ERNIE base 处理 128 长度时,batch 32 在 8GB 显存上能跑,超出就 OOM;文本普遍在 50 字以内,可以用 32 或 64。训练结束后save_pretrained会把模型权重和配置写到目录,推理时直接from_pretrained加载,不需要重新初始化 tokenizer。
4. 属性级别情感分析:把 aspect 拼进输入,复用同一套分类管线
属性级别情感分析(Aspect-based Sentiment Analysis)不是句子级任务的简单重复。它的输入多了一个维度:属性词。任务要求对每个出现的方面分别给出情感极性,模型必须看到“句子中的哪个部分对应哪个属性”。最常见方案是把句子和属性词用[SEP]拼到一起,让 ERNIE 在编码时把属性词和句子中相关片段做语义对齐。这一章讲清楚这个方案为什么有效、代码怎么改、推理时如何覆盖未知属性。
4.1 为什么句子级模型不能直接解决属性级需求
回到开头那个例子:“酒店的服务不错,但隔音很糟糕。”句子级模型读完整个句子,输出只能是“正面”或“负面”其中之一,因为分类目标就是这样定义的。属性级任务要求的是“服务”这个方面是正面,“隔音”这个方面是负面。
如果把属性级数据直接丢给句子级模型训练,会出现一个典型行为:模型学到的是句子的整体情感偏置,而不是细粒度判断。因为训练样本里“服务不错”大概率是好评,“隔音糟糕”大概率是差评,模型会倾向于用整体情绪做短路判断。真正暴露问题的是“服务不错但隔音糟糕”这种矛盾情感句,句子级模型只能给出一个均值答案。这也是 ABSA 必须把 aspect 作为显式输入的原因——它不是让模型从句子反推属性,而是把属性作为条件给进去。
早期 ABSA 有一种做法是用两个编码器分别编码句子和属性,再做融合,效果不一定比拼接好,还多出一倍显存开销。我一般直接用拼接式,简单稳定,毕竟 ERNIE 的注意力机制足够把属性词和句中相关内容对齐。源代码里只要在 tokenizer 调用时传入text_pair就完成了这一步。
4.2 输入构造:text_pair 把 aspect 送进 token_type_ids
PaddleNLP 的 tokenizer 支持text_pair参数,这正好是为 ABSA 设计的输入接口。句子放text,属性词放text_pair,编码后的token_type_ids里句子是 0、属性词是 1,模型据此知道哪部分是对齐目标。
from paddlenlp.transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("ernie-3.0-base-zh", use_faster=False) text = "酒店的服务不错,但隔音很糟糕" for aspect in ["服务", "隔音", "价格"]: tokens = tokenizer(text, text_pair=aspect, max_length=32, truncation=True, padding="max_length") ids = tokenizer.convert_ids_to_tokens(tokens["input_ids"]) print(f"{aspect}: {ids}")这段代码会输出三组 token 序列。仔细看[SEP]的位置:句子在第 1 个[SEP]后结束,aspect 拼在它后面,序列结尾再补一个[SEP]。第二个[SEP]和 aspect 之间的 token 在token_type_ids上对应 1,正是模型区分“句子正文”和“待评属性”的边界。属性词不需要在句子中显式出现,是这个方案的另一个优势——评论里从头到尾没写“价格”两个字,但“太贵了”已经隐含了价格信息,把“价格”作为 aspect 拼进去,ERNIE 依然能给出合理预测,这是词频统计模型做不到的。
训练脚本和句子级几乎一样,只需要让 Dataset 同时读 text 和 aspect 两列,并在__getitem__里把 aspect 传给 tokenizer:
class AspectDataset(Dataset): def __init__(self, file_path, tokenizer, max_len=128): self.tokenizer = tokenizer self.max_len = max_len self.texts, self.aspects, self.labels = [], [], [] with open(file_path, "r", encoding="utf-8-sig") as f: next(f) for line in f: parts = line.rstrip("\n").split("\t") if len(parts) < 3: continue self.texts.append(parts[0]) self.aspects.append(parts[1]) self.labels.append(int(parts[2])) def __getitem__(self, idx): tokens = self.tokenizer( self.texts[idx], text_pair=self.aspects[idx], max_length=self.max_len, truncation=True, padding="max_length", ) return ( paddle.to_tensor(tokens["input_ids"], dtype="int64"), paddle.to_tensor(tokens["token_type_ids"], dtype="int64"), paddle.to_tensor(tokens["attention_mask"], dtype="int64"), paddle.to_tensor([self.labels[idx]], dtype="int64"), )collate_fn直接复用第 3 章版本。模型定义不变,num_classes依然取 2。唯一要注意的是max_length的分配:因为句子和 aspect 拼接,同一条样本会比句子级多占用若干 token。建议先用 2.3 的 tokenizer 脚本打印真实长度,再决定 max_length。我一般取训练集中 95% 分位的长度加 8,既不截断大多数样本,也不让 padding 浪费显存。
4.3 推理阶段:候选属性列表逐条跑,别一次塞进模型
训练完成后,ABSA 的推理方式和句子级有区别:线上来的是文本加一个未知的属性集合,你不知道文本里有哪些属性词,更不知道哪些属性在训练集里见过。常见做法是预置一个候选属性列表,把句子和每个候选属性分别拼一次,逐个得到极性分数。
import paddle from paddlenlp.transformers import AutoModelForSequenceClassification, AutoTokenizer model = AutoModelForSequenceClassification.from_pretrained("./models/ernie_absa", num_classes=2) tokenizer = AutoTokenizer.from_pretrained("./models/ernie_absa", use_faster=False) idx2label = {0: "负面", 1: "正面"} def predict_aspect(text, aspects): results = {} for aspect in aspects: tokens = tokenizer( text, text_pair=aspect, max_length=128, truncation=True, padding="max_length", return_tensors="pd", ) outputs = model(**tokens) logits = outputs.logits if hasattr(outputs, "logits") else outputs probs = paddle.nn.functional.softmax(logits, axis=-1) label_idx = int(paddle.argmax(logits, axis=-1)[0]) results[aspect] = (idx2label[label_idx], float(probs[0, label_idx])) return results if __name__ == "__main__": text = "酒店位置很好,但服务响应太慢,价格倒是不贵" print(predict_aspect(text, ["位置", "服务", "价格"]))这段代码有两个讲究。第一,padding 按"max_length"是为了让循环里的张量形状稳定,如果你追求性能,改成"longest"并对相同长度样本分组也可以,但第一版建议怎么稳怎么来。第二,softmax 后的置信度不要直接存成小数点后两位,我见过太多人对着 0.52 和 0.49 纠结“模型是不是随机猜”——情感分类的置信度经常落在 0.5 附近,边界样本本来就难判断,真正要看的是整体指标。候选 aspect 列表建议覆盖业务里出现频次最高的 20 到 50 个属性,冷启动阶段可以用词典挖掘句子里出现的候选词,后续再人工补齐。
5. ERNIE 情感分析避坑指南:本地训练最容易翻车的 5 个问题
这一章整理训练和交付中反复遇到、并且几乎每次都有人问的问题。每条按现象、原因、解决来写。如果你跑上面代码报错,优先对照这里。我踩过其中最深的坑是 token_type_ids,这不只是新手问题,换版本、换 tokenizer 之后老手也会再犯。
5.1 pip 装完 import 报错:paddle 和 paddlenlp 版本打架
现象:按顺序安装后,import paddlenlp报ModuleNotFoundError,或者from paddlenlp.transformers import AutoTokenizer时提示某个底层模块不存在。原因:paddlenlp 的不同大版本对 paddlepaddle 的版本有下限要求,pip 只保证依赖存在,不保证大版本兼容。解决:先pip show paddlepaddle paddlenlp看版本号,确认两者大版本一致(2.x 配 2.x、3.x 配 3.x),不一致就重装其中一方。如果不想动现有环境,用 venv 单独起一个项目环境,把训练和线上服务解耦。
5.2 数据集带 BOM 头,第一行文本出现 \ufeff
现象:训练时 loss 正常,但验证集里第一行数据预测总是错的,打印出来文本开头有个奇怪字符。原因:Windows 下用记事本或 Excel 另存的 TSV 带 UTF-8 BOM,open用"utf-8"读进来后,BOM 被当成普通字符留在字符串开头。解决:读取文件时统一用encoding="utf-8-sig",Python 会剥掉 BOM;如果数据已经混进去了,清洗时line.replace("\ufeff", "")兜底。
5.3 长文本被 max_length 截断,情感词正好丢在边界
现象:短文本验证集 acc 很高,长文本预测结果近乎随机,或者模型对某类超过 100 字的评论统一预测为同一类。原因:max_length 设置过小,情感词被截断,模型只能看到前半段的事实性描述。解决:不要拍脑袋设 128。先用 tokenizer 跑一遍训练集所有样本,按 input_ids 长度画分布,取 95% 分位作为 max_length。有时间可以改成“头尾截断”策略,保留首尾各 60%,对中文评论这种情感词常在结尾出现的场景,效果提升明显。属性级任务记得把 aspect 的长度也算进去,text_pair会占用额外 token。
5.4 属性级模型训练完,不同 aspect 预测结果完全一样
现象:ABSA 模型训完,同一句下“服务”“价格”“位置”三个方面输出相同极性,和句子级模型没有区别。原因:tokenizer 调用时没有传text_pair,或者 Dataset 里 aspect 列全为空,导致属性词没进输入,token_type_ids全部为 0。解决:回到 2.3 的验证脚本,打印一条样本的token_type_ids,确认句子部分是 0、aspect 部分是 1。格式正确后再看 Dataset 读取的三列切分是否正确,别贴错列索引。
5.5 标签不均衡,acc 虚高到 95% 但正样本全判错
现象:训练结束 acc 显示 95% 以上,拿去一测,负面评论几乎全部被判成正面。原因:数据里正样本占 95%、负样本占 5%,模型学到“全部分配给正样本”就能拿高 acc,分类头没有区分能力。解决:训练时打印混淆矩阵而不是只打印 acc,观察负样本召回率。缓解手段有三个:负样本上采样或正样本降采样;损失函数改用带类别权重的CrossEntropyLoss;如果两个类别样本量差距超过 10 倍,先按业务价值重新审视标注标准,而不是继续调模型。
6. 模型验证与部署:从 F1 到推理脚本,少走两步都不行
6.1 用混淆矩阵盯 F1,而不是盯 acc
情感分类是典型二分类任务,但“分得对不对”和“业务上好不好用”是两回事。验证阶段我习惯同时输出 acc、precision、recall 和 F1,其中 F1 和混淆矩阵最值得看。用 sklearn 是偷懒但正确的做法:
from sklearn.metrics import classification_report, confusion_matrix y_true = [0, 1, 1, 0, 1, 1, 0, 0, 1, 1] y_pred = [0, 1, 1, 0, 0, 1, 0, 1, 1, 1] print(classification_report(y_true, y_pred, target_names=["负面", "正面"])) print(confusion_matrix(y_true, y_pred))如果验证集上负面样本的 recall 明显低于正面,说明模型偏向于“默认好评”,这在中文评论场景非常常见。我的习惯是先看负类 recall,再看整体 F1,两个都稳了才敢上线。如果 F1 不理想,先回去检查数据切分有没有泄漏、token_type_ids 对不对,再调超参。
6.2 再往前走一步:从文本情感到多模态与生成式 ABSA
到这里训练和部署流程已经完整。文本 ABSA 还有一个天然短板:输入输出是“句子 + 属性 + 极性”的固定形态,遇到细粒度观点词抽取(比如提取“隔音糟糕”里的“隔音”)就会力不从心。进阶方向是生成式方案,用 ERNIE 的生成模型直接输出结构化情感标签,或者把音频、图像信息加进来做多模态情感分析。就我的实践感受,先把拼接式 ABSA 的准确率、速度、稳定性吃透,能覆盖 80% 的业务场景,再决定要不要上生成式。
说一个我自己的血泪经验:第一次做属性级情感分析时,我把 aspect 直接拼在句子末尾,没有用text_pair,也没有检查token_type_ids,训完只看到不同属性的预测全部一样。后来打印 token 序列才发现属性词混在句子里,模型根本没有把它当作独立部分。那次之后,凡是涉及 ABSA 的输入构造,我都会先打印token_type_ids再开始训练。这个习惯帮我省掉了大量无效调参,希望也能帮到你。
本文还有配套的精品资源,点击获取