
简介这份资源面向NLP初学者与进阶开发者提供基于百度预训练大模型ERNIE完成情感分析的完整Python方案覆盖句子级与属性级两类任务。包内共24个文件以json数据、py脚本、pdf文档为主另有少量pyc、txt与license文件压缩包约25.54MB目录按数据集、模型配置、预处理、训练、评估与预测等模块组织结构清晰便于按流程学习。已有220人学习下载。读者可借助源码掌握ERNIE模型加载、文本预处理、特征提取与分类器构建的完整链路理解属性与观点抽取、属性级情感分类的实现思路并参考IMDB情感分析示例完成训练、验证与调优快速将预训练模型迁移到实际情感分析场景中。1. 从一句吐槽到属性级拆解ERNIE 情感分析到底在做什么电商评论里有一句典型的话「屏幕很惊艳但是续航太拉胯了」。句子级情感分析模型大概率会给你一个「中性」或者干脆判成「正面」因为「惊艳」这个词的权重压过了「拉胯」。但业务方要的不是一个笼统的极性标签而是想知道用户到底对「屏幕」满意、对「续航」不满——这就是属性级情感分析Aspect-Based Sentiment AnalysisABSA要解决的问题。这套基于预训练大模型 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_len128) print(encoded[input_ids][:20])这段代码做了两件事加载 ERNIE 的 tokenizer 和主干模型。max_seq_len128是常见起点中文评论一般不会太长128 个 token 能覆盖绝大多数句子如果评论普遍超过 200 字可以调到 256但显存占用会明显上升。ernie-3.0-base-zh是 base 规模参数量适中单张 8G 显存的卡就能微调如果追求精度可以换 large 版本但训练时间会翻倍。2.3 把数据转成 PaddleNLP 的 DatasetPaddleNLP 提供了MapDataset可以把原始 list 转成模型能直接迭代的格式。关键是把文本和标签分别编码标签转成 tensor。属性级任务里属性词要和文本拼在一起送进模型常见拼法是text [SEP] aspect让模型在编码阶段就能感知到属性和上下文的对应关系。import paddle from paddlenlp.datasets import MapDataset def convert_sentence_example(example, tokenizer, max_seq_len128): # 句子级文本编码 标签 encoded tokenizer( example[text], max_seq_lenmax_seq_len, truncationTrue, paddingmax_length ) encoded[labels] example[label] return encoded def convert_aspect_example(example, tokenizer, max_seq_len128): # 属性级文本和属性词拼接用 SEP 分隔 pair_text example[text] tokenizer.sep_token example[aspect] encoded tokenizer( pair_text, max_seq_lenmax_seq_len, truncationTrue, paddingmax_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))truncationTrue保证超长文本被截断而不是报错paddingmax_length让一个 batch 内所有样本长度一致省去动态 padding 的麻烦。属性级拼接时用tokenizer.sep_token而不是硬编码[SEP]因为不同模型的特殊符号可能不一样硬编码容易翻车。转换后的 dataset 可以直接丢给DataLoader配合batch_size16或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_classes3, dropout0.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_idsNone, attention_maskNone): # 取 [CLS] 位置的输出作为句子表示 sequence_output, _ self.ernie( input_ids, token_type_idstoken_type_ids, attention_maskattention_mask ) cls_output sequence_output[:, 0, :] logits self.classifier(self.dropout(cls_output)) return logits model ErnieSentenceClassifier(ernie, num_classes3)hidden_size从 ernie 的 config 里取base 版本一般是 768不要写死。dropout0.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_size16, shuffleTrue, collate_fncollate_fn) optimizer paddle.optimizer.AdamW(learning_rate3e-5, parametersmodel.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_idstoken_type_ids) loss criterion(logits, labels) loss.backward() optimizer.step() optimizer.clear_grad() if step % 50 0: print(fepoch{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 结束后算准确率和 F1F1 比准确率更能反映类别不均衡时的真实效果。如果验证集 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_idstoken_type_ids) pred paddle.argmax(logits, axis1) preds.extend(pred.numpy().tolist()) trues.extend(labels.numpy().tolist()) return accuracy_score(trues, preds), f1_score(trues, preds, averagemacro) acc, f1 evaluate(model, valid_loader) print(fvalid acc{acc:.4f} f1{f1:.4f})averagemacro表示对每个类别算 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_labels3, dropout0.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_idsNone, attention_maskNone, labelsNone): sequence_output, _ self.ernie( input_ids, token_type_idstoken_type_ids, attention_maskattention_mask ) logits self.classifier(self.dropout(sequence_output)) if labels is not None: # 训练时算 CRF lossattention_mask 用来屏蔽 padding 位置 loss self.crf_loss(logits, labels, attention_mask) return loss # 推理时用 Viterbi 解码出最优标签序列 preds self.viterbi(logits, attention_mask) return predsnum_labels3对应 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_len128, return_tensorspd) 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_len128, return_tensorspd) logits sent_model(pair_encoded[input_ids], pair_encoded[token_type_ids]) label paddle.argmax(logits, axis1).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_size4累积 4 步等效于 16显存占用按 4 算。Paddle 里可以用paddle.amp开混合精度显存能再省三成左右。5.5 现象同一句话多次推理结果不一致原因是模型还在 train 模式dropout 没关。推理前必须调model.eval()否则每次前向传播 dropout 随机丢弃神经元输出自然不稳定。这个坑很隐蔽因为 loss 和训练都正常只有推理时才发现。解决在推理脚本入口统一调model.eval()并且用paddle.no_grad()包住前向传播既关掉梯度计算省显存也避免意外更新权重。如果用了 BatchNormeval()还会切换成用滑动统计量进一步保证结果可复现。6. 进阶技巧用对抗训练和置信度过滤把属性级 F1 再抬一截流水线跑通之后如果想把属性级 F1 从 0.8 往 0.85 推我一般会加两个技巧对抗训练和置信度过滤。对抗训练是在 embedding 上加扰动让模型对输入的小变化更鲁棒中文评论里同义词替换、语序微调很常见对抗训练能明显提升泛化。Paddle 里可以用 FGMFast Gradient Method实现在反向传播时对 embedding 梯度做归一化再加回去。class FGM: def __init__(self, model, epsilon1.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 {}epsilon1.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 情感分析方案从数据到推理大概两三天能跑通属性级想调好需要多花一周在标注和阈值上。希望帮到你。本文还有配套的精品资源点击获取