简介:面向自然语言处理课程实践与初学者的方面级别情感分析(Aspect-Level Sentiment Analysis)Python实现资源,着力解决如何借助预训练语言模型对文本中特定方面进行情感极性判定的问题,适合NLP课程设计、期末作业或入门项目参考。压缩包共7个文件,体积仅14KB,包含两个核心Python脚本:process_data.py用于数据预处理与格式转换,fine_tune_predict.py用于模型微调与预测;同时附有run.sh一键运行脚本、requirements.txt依赖清单、README.md说明文档及License等辅助文件,目录结构简明,便于快速定位。该资源目前已有368人学习,属于轻量但完整的课程作业示例。通过阅读和运行代码,可以掌握从原始数据处理到模型训练、评估与预测的完整实现链路;也能按需修改数据集路径、超参数或模型结构,直接复现到自己的实验中,减少搭建环境和排查依赖的时间成本,对完成课程作业或理解方面级情感分析有直接帮助。
1. 把“方面级别情感分析”做对,先得跳出“整句打分”的惯性
一条“牛排很嫩,但上菜太慢,下次不会来了”的评论,整句情感是中性偏负,可商家真正需要知道的是“牛排”被夸、“上菜速度”被骂。方面级别情感分析(Aspect-Based Sentiment Analysis, ABSA)就是把评价对象拆出来,逐个对象判断正负极性。这个项目标题看起来很“课程作业”,实际落到生产里就是电商评论洞察和口碑归因的最小可行方案。
本文按一套能直接跑通的实现路径来写:任务建模、数据预处理、PyTorch实现、参数调优和错误分析。选型理由只有一个——课程作业的场景里,复现门槛、算力开销和排错成本必须同时低。所以主模型选定 BERT + 线性分类头,数据用公开的 Laptop/Restaurant 评论集,代码量控制在两百行以内,环境只需要transformers、torch和pandas。读完你能独立把准确率做到 0.75 以上,也知道每个分数背后模型到底在犯什么错。
2. 建模方式与任务划分:两阶段流水线 vs 联合抽取
ABSA 不是单一任务,它由两个子任务构成:方面词抽取(Aspect Term Extraction, ATE)和方面级情感分类(Aspect Sentiment Classification, ASC)。前者负责从原文里找出“牛排”“上菜速度”这样的对象词,后者负责给每个对象词判定 Positive / Negative / Neutral。如果数据里还带意见词,那又多一个维度,但课程作业通常不需要做完整三元组抽取。
2.1 为什么课程作业里两阶段流水线比端到端联合模型更稳
联合抽取模型在论文里很常见,本质是把两个子任务放进同一套编码器,输出端各自接 CRF 或指针网络。这种结构在数据量充足、标注完整的前提下确实优雅,但课程作业的典型场景是几百到几千条标注样本,联合模型极易出现抽取和分类互相拖累:方面词漏抽,后头的分类再准也拿不到分。
我一般会建议先落两阶段流水线:第一阶段用规则或序列标注把方面词切出来,第二阶段把切出的每个方面词拼接回原句,单独做情感分类。好处至少有两条:每阶段都能独立验证、独立调参;阶段二的输入干净,分类准确率更容易做高。缺点是错误会累积,但这在课程作业的评估框架里反而容易解释清楚,答辩时“误差传播”本身就是可以展开讲的内容。
2.2 三个可直接对照的任务定义与数据形态
阶段一(ATE)可以建模为 BIO 序列标注:B-Aspect 表示方面词开头,I-Aspect 表示延续,O 表示无关词。用transformers里的BertForTokenClassification就能跑。阶段二(ASC)建模为单句分类:把每个候选方面词包装成特殊格式后送入BertForSequenceClassification,输出三类极性。
实际操作里,我推荐先用数据集自带的方面词标注直接喂给阶段二,把抽取部分用规则兜底或干脆使用标注而非预测,这样整体准确率更可控,也能减少 debug 战线。数据形态如下表:
| 字段 | 类型 | 示例 | 用途 |
|---|---|---|---|
| sentence | str | The steak is juicy but the service is slow. | 模型输入 |
| aspect | str | steak | 待分类对象 |
| polarity | int | 2(1 负 2 中 3 正,视标签映射而定) | 监督信号 |
| from/to | int | 4, 9 | 原句中方面词起止位 |
阶段一如果要做,就把 sentence 按词切分后对每个 token 打 BIO 标签。阶段二则完全依赖 (sentence, aspect) 成对输入。
2.3 为什么不建议在这个阶段引入 LSTM + Attention
LSTM + Attention 在 ABSA 里确实是经典解法,很多课程参考资料也这么写,但它的短板在实现时才会暴露:注意力权重难解释、长句依赖弱、对词向量质量敏感。如果用gensim加载预训练词向量,还得处理 OOV 词和词形还原,整套流程做下来,留给模型调参的时间所剩无几。BERT 生成的是上下文相关表示,“bank”在“river bank”和“bank account”里是不同向量,这在方面级任务里直接解决了歧义问题。
预算有限时选bert-base-uncased;如果是中文数据,选bert-base-chinese,这两者在transformers里权重名固定,不需要额外适配。显存不够就上distilbert-base-uncased,效果低 1-2 个点但显存占用小一半。
3. Python 数据管线:从原始评论到 BERT 输入序列
课程作业的数据格式通常来自 SemEval-2014 或爬取的电商评论。拿到的原始数据一般是 JSON 或 XML,每个句子下挂着若干 aspect 节点。第一步永远是把数据转成三列 DataFrame:sentence、aspect、polarity,后面所有预处理都在这张表上完成。
3.1 从原始标注到干净的训练集
先看原始标注结构,以 SemEval-2014 Laptop 的 XML 为例:
import xml.etree.ElementTree as ET import pandas as pd def parse_semeval_xml(path): rows = [] tree = ET.parse(path) root = tree.getroot() for sentence in root.iter('sentence'): text = sentence.find('text').text or '' for asp in sentence.iter('aspect'): term = asp.get('term') polarity = asp.get('polarity') if term is None: # 有些标注没有显式方面词,跳过或记为隐含方面 continue rows.append({ 'sentence': text.strip(), 'aspect': term.strip(), 'polarity': 1 if polarity == 'negative' else ( 2 if polarity == 'neutral' else 3) }) return pd.DataFrame(rows) train_df = parse_semeval_xml('Laptop_Train.xml') test_df = parse_semeval_xml('Laptop_Test.xml') print(train_df.groupby('polarity').size())这段代码只做两件事:遍历 XML 里每个sentence节点下的aspect子节点,把term和polarity提取出来并完成字符串到数值的映射。注意term == None的样本,这是 SemEval 里的“隐含方面”,课程作业可以直接丢弃,否则需要对整个句子做分类,和任务定义不符。
提示:polarity 映射顺序最好和模型损失函数、评估脚本统一。代码里 1/2/3 对应 negative/neutral/positive,后续计算 F1 时按 index 对齐。
3.2 构造方面词感知的输入序列
原始文本不能直接喂给 BERT。BertTokenizer会把句子切成 subword,因此需要把方面词的位置在 token 级别固定下来。这里有个关键技巧:不要直接把方面词和句子简单拼接,而是给方面词加特殊标记,让模型显式知道“你要分类的对象在句子里的边界”。
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained('bert-base-uncased') CLS = tokenizer.cls_token SEP = tokenizer.sep_token ASP_S = '[unused1]' # 用 unused token 做方面词起点 ASP_E = '[unused2]' # 做终点,避免和原文冲突 def build_input(row, max_len=128): sent = row['sentence'] asp = row['aspect'] # 把方面词包进特殊标记,再和原句拼接 text = f'{CLS} {asp} {ASP_S} {sent} {ASP_E} {SEP}' enc = tokenizer.encode_plus( text, max_length=max_len, truncation=True, padding='max_length', return_tensors='pt' ) return enc['input_ids'].squeeze(0), enc['attention_mask'].squeeze(0) sample_input, sample_mask = build_input(train_df.iloc[0])[unused1]和[unused2]是 BERT 词表里预留给下游任务的空位,不会和原文语义冲突,比直接用[ASP]字符串安全。把方面词放在句子前,相当于给模型一个“先读对象,再扫上下文”的顺序,这个顺序在 ATT 类数据集上实测比放在句子后高 1 到 3 个点。
3.3 数据集规模太小时的增强手段
课程作业常见问题是标注数据少,Laptop 训练集也就 3045 条。数据增强我建议用两种低成本方式。
第一种是方面词替换:把句子里某个方面词换成同义词,生成新样本,标签不变。比如“battery life is long”换成“battery endurance is long”。第二种是回译增强,但中文数据用回译效果更好,英文数据用同义词替换更快。别用对抗式增强或 GAN 那套,课程作业阶段投入产出比太差。
增强后的数据分布如果出现某个极性严重偏少,比如 negative 只有 12%,可以在损失函数里加类别权重,或在采样器里做过采样,二选一,不要叠加。
4. 基于 PyTorch 的训练实现与关键参数调优
数据管线就绪后,进入模型实现。这里直接给一套在主流的 Kaggle 或课程机器上都能跑通的代码。模型用BertForSequenceClassification,输出 3 类,损失函数用带权重的交叉熵。
4.1 完整的训练循环代码
import torch from torch.utils.data import DataLoader, TensorDataset from transformers import BertForSequenceClassification, AdamW from transformers import get_linear_schedule_with_warmup from sklearn.metrics import f1_score model = BertForSequenceClassification.from_pretrained( 'bert-base-uncased', num_labels=3 ) # 类别权重:按训练集样本数反比计算,避免大类主导 counts = torch.tensor([800, 1400, 845], dtype=torch.float) weights = 1.0 / counts weights = weights / weights.sum() * len(counts) loss_fct = torch.nn.CrossEntropyLoss(weight=weights.cuda()) epochs = 4 optimizer = AdamW(model.parameters(), lr=2e-5, eps=1e-8) total_steps = len(trainloader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps ) model.cuda() model.train() for epoch in range(epochs): for batch in trainloader: input_ids, attention_mask, labels = batch input_ids = input_ids.cuda() attention_mask = attention_mask.cuda() labels = labels.cuda() outputs = model( input_ids=input_ids, attention_mask=attention_mask, labels=labels ) loss = loss_fct(outputs.logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad()这里用了两个容易被忽略的细节。第一个是clip_grad_norm_,BERT 微调时梯度爆炸不常见,但会出现个别 batch 产生超大梯度导致 loss 突然变 NaN,梯度裁剪是成本最低的保险丝。第二个是scheduler用线性衰减带 warmup,warmup 比例设为 10%,能避免预训练权重在前几步被大幅扰动。AdamW的参数eps=1e-8保持默认即可。
严格来说labels参数传给模型时,它会内部计算交叉熵,但这里手动传入loss_fct是因为要套类别权重。如果数据均衡,直接用参数里的labels更简洁。
4.2 训练 batch、学习率与 epoch 的实测参考
| 参数 | 值 | 说明 |
|---|---|---|
| batch_size | 16 | 显存不够时降到 8,配合梯度累积 |
| learning_rate | 2e-5 | 3e-5 会更快但更容易震荡 |
| epochs | 4 | BERT 微调超过 4 轮容易过拟合 |
| max_seq_len | 128 | Laptop 评论短,128 覆盖 99% 样本 |
| warmup_ratio | 0.1 | 前 10% step 线性提升学习率 |
| weight_decay | 0.01 | AdamW 默认推荐值 |
F1 分数在 3 个 epoch 后基本收敛,第 4 个 epoch 只提升 0.5 个点左右;再往上加 epoch 会出现训练 loss 持续下降但验证 F1 回落的反向走势,这是典型的微调过拟合。
4.3 显存不够时的省内存方案
课程机器常见配置是 4G 显存或干脆无 GPU。两个方案帮你在有限资源里跑完。
梯度累积是最容易实施的:每 4 个 batch 做一次优化器更新。实现时在loss.backward()后判断step % 4 == 0再执行 optimizer 和 scheduler 更新。它不改变梯度的数学期望,等价于把 batch_size 放大四倍。
冻结部分是更进阶的手段:BERT 的低层编码语法特征很通用,不用微调。常见的措施是把前三层 transformer block 的梯度关掉。
for name, param in model.bert.named_parameters(): if name.startswith('encoder.layer.0.') or \ name.startswith('encoder.layer.1.') or \ name.startswith('encoder.layer.2.'): param.requires_grad = False冻结前三层可以把可训练参数量压缩 30% 左右,训练速度和显存都有改善,精度影响在 0.5 个点以内。越底层的层越通用,这个结论在 BERT 的诸多消融实验里都被验证过。
5. “分数即证据”:从错误样本反推模型缺陷的检查清单
训练完进入验证阶段。但准确率和 F1 只是结果,模型在哪些样本上犯了错、为什么犯错,才是课程作业能拿高分的分水岭。这一步叫错误分析,是 NLP 任务里最被低估却最有信息量的环节。
5.1 对测试集 predictions 做样本级回读
多分类任务只看 classification_report 远远不够。我习惯把test_df原样复制一份,把模型预测结果按行写回 DataFrame,然后筛出预测错误的所有样本,逐条阅读模型输入和输出。
from sklearn.metrics import classification_report model.eval() preds = [] with torch.no_grad(): for batch in testloader: input_ids, attention_mask = batch[0].cuda(), batch[1].cuda() logits = model(input_ids, attention_mask).logits preds.extend(torch.argmax(logits, dim=-1).cpu().tolist()) test_df['pred'] = preds test_df['correct'] = test_df['polarity'] == test_df['pred'] errors = test_df[~test_df['correct']] print(errors[['sentence', 'aspect', 'polarity', 'pred']].head(20))逐条看这些错误,你会发现模式高度集中。最常见的一类是隐式否定:“not good but not bad”这种表达,模型倾向于判 negative,实际是 neutral。第二类是方面词跨句指代:“The battery is decent, but the screen cracks easily”,模型能正确分类 battery,但会漏掉屏幕这个隐含对比。第三类是形容词歧义:“unpredictable”在键盘场景是负面,在剧情场景可能是正面。
5.2 把错误模式转成行动项
识别出错误模式后,行动项按成本从低到高排列。修改特殊标记格式是最廉价的,比如把方面词放在句子前后都试一遍,F1 波动能到 2 个点。增加多任务头属于中等成本,让模型同时预测方面词情感和整句情感,用共享表示引入全局信息,类别不平衡时收益明显。数据增强只针对错误中占比最高的那类样本,不要全量增强。
提示:课程作业里可以只做前两档。把 aspect 位置从“仅句首”改成“句首 + 句中原始位置各一份”做数据扩增,往往能让 F1 提升 1 个百分点以上,且不改动模型结构。
5.3 一个容易被忽略的评估细节:按方面词数量分组看分数
将测试集按句子中包含的方面词数量分组,分别计算 F1。单方面词句子的 F1 通常比多方面词句子高 5-8 个点。如果差距过大,说明模型对多对象场景下的上下文区分不足,而这是 ABSA 任务的核心难点。
这个分组评估结果在课程报告里非常加分,因为它说明你已经理解了 ABSA 和普通情感分类的本质区别:不是“这句话正负面”,而是“在这句话里,哪个对象被如何评价”。
本文还有配套的精品资源,点击获取