ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

方面级情感分析课程作业:BiLSTM+Attention实现与避坑指南

方面级情感分析课程作业:BiLSTM+Attention实现与避坑指南 简介面向自然语言处理课程设计的一体化Python实现聚焦方面级别情感分析Aspect-Level Sentiment Analysis任务属于NLP课程作业范畴适合正在完成课程设计的学生、需要快速搭建情感分析基线系统的研究者以及希望掌握预训练模型在细粒度情感分类中应用的学习者。压缩包共7个文件大小仅14KB包含两个核心Python脚本分别承担数据预处理和模型微调预测另有一键运行脚本、依赖清单、使用说明及许可证等辅助文件轻量而结构完整。目前已有368人浏览学习具备较高的课程设计参考价值。代码允许使用预训练语言模型完整覆盖从数据清洗、特征组织、模型微调到情感极性预测的全流程通过简单命令即可复现实验能够帮助理解方面级别情感分析中数据构造、训练策略与结果评估等核心环节项目体量小、依赖清晰也便于扩展修改或嵌入到更大的系统中。1. 方面级情感分析这个 NLP 课程作业到底在做什么同样是五星好评“服务热情但上菜慢”这句里面顾客对“服务”和“上菜速度”的态度其实是相反的。普通情感分析Sentiment Analysis只能给整句话打一个分而方面级别情感分析Aspect-Based Sentiment AnalysisABSA会把句子拆成若干个方面词情感极性二元组服务员是正面的上菜速度是负面的。这份基于 Python 的 NLP 课程作业核心就是用一套可运行的代码完成这个拆解——先从评论里找出被评价的对象方面词再对每个对象分别判 positive、neutral 还是 negative。对正在上 NLP 课、需要交课程作业的学生来说这是一份可以直接复现的 baseline对做数据分析、评论挖掘的从业者来说这个任务能把“用户到底满意什么”从笼统的好评率里拆出来比单纯算情感得分实用得多。这篇文章就按我平时做这个作业的思路把一个完整方案的原理、数据预处理、模型代码和踩坑点讲清楚。2. ABSA 任务拆解为什么一个句子能同时给你 positive 和 negative2.1 输入输出长什么样和普通情感分析差在哪普通情感分析的任务是给整个句子一个极性模型读入“The battery life is great, but the screen is too dim.”只会输出一个标签要么 positive 要么 negative。这个结果对决策几乎没有帮助因为用户既夸了电池又骂了屏幕你到底改哪个ABSA 把输入输出重新定义了一下输入仍然是整个句子但输出变成一组结构化的二元组——(“battery life”, positive) 和 (“screen”, negative)。每一个二元组里前半部分是方面词Aspect Term也就是被评价的对象后半部分是模型对这个对象的态度判断。课程作业里最常见的要求就是做这一步术语叫 Aspect Term Polarity。这个拆解对很多场景都非常直接。电商评论可以拆出“物流”和“质量”两个维度的态度餐饮评论可以把“口味”“环境”“服务”分开评价近几年热门的多模态情感分析、视频人物情感分析也沿用这个思路——把方面词从文本扩展到视频里的人物或物体区域再分别判断情感极性。但不管外面怎么包装核心还是“先定位被评价对象再判断态度”文本 ABSA 是所有这些方向的地基。做这个作业时我一般建议先把文本这条链路彻底跑通再去碰多模态。2.2 标签体系与评估指标neutral 不是垃圾桶方面词的情感极性课程作业里通常只保留三个标签positive、neutral、negative。SemEval-2014 Task 4 这个公开评测任务里本来还有一个 conflict既褒又贬但绝大多数课程作业会把它删掉因为数量太少而且标注本身就很模糊留着只会让模型更难收敛。评估方式上有一个很经典的坑只看准确率ACC会被 neutral 骗过去。Laptop 和 Restauant 这两个常用数据集中neutral 的比例不低模型只要把所有样本都预测成 neutral准确率就能到 60% 甚至更高。所以课程评分和论文报告里一定要同时看两个数ACC 和 macro-F1。macro-F1 对三个类别分别算 F1 再取平均任何一个类别被放弃都会在分数上暴露。我自己的习惯是训练结束直接打印 sklearn 的 classification_report把 precision、recall、F1 都留到实验记录里。表 2-1 是课程作业里最常用的两个数据集的基本形态文本数据可以在各大数据公开渠道找到数据集领域标签数典型标注条目格式常见难点Laptop笔记本电脑评论3正/中/负(“battery life”, positive)专业术语多方面词词形变化多Restaurant餐饮评论3正/中/负(“service”, negative)口语化表达多省略主语的情况多训练时还有一个细节值得注意不要直接把 positive/neutral/negative 做成三个独立文件夹或者三个 0/1/2 的整数就完事。课程作业评分时会看你对“类别不平衡”有没有意识所以 loss 函数里最好带上类别权重或者在报告里明确写出各类别的样本占比并说明你针对不平衡做了什么处理。2.3 建模路线先用 pipeline 保底joint 只作加分项ABSA 有两条主流路线。第一条叫 pipeline流水线先跑一个方面词抽取模型把句子里的方面词边界找出来然后拿着这些方面词对每个方面词单独做一次情感分类。第二条叫 joint联合模型用一个序列标注模型同时输出方面词的边界和极性比如给每个 token 打一个 BIO 标签标签里直接带上 positive/negative/neutral。课程作业我强烈建议用 pipeline。原因很实际pipeline 的每一步都可以单独调试方面词抽错了你能立刻看出来是哪一步的问题评分时也容易讲清楚。joint 模型虽然能避免误差累积但解码逻辑和损失函数的设计复杂度翻倍作业周期内很容易翻车。表 2-2 是我做选择时常用的对比对比维度Pipeline抽取 分类Joint 联合序列标注实现难度低两个模块独立高解码和标签体系要统一设计排错难度低每一步有中间结果高错误来源不好定位课程评分友好度高可以分模块讲中需要额外解释标签体系指标上限受误差累积影响理论更高但需要数据量支撑无论选哪条路线课程作业都建议先跑一个最终可以复现的数字再去优化。先把 pipeline 做通拿一个 baseline后面想上 joint 或者 BERT 微调都是在这个基础上加戏。3. 数据准备以 SemEval-2014 为例把 XML 变成模型能吃的样本3.1 XML 解析先拿到 aspectTerm 的字符偏移SemEval-2014 Task 4 的数据集是 XML 格式。每个 sentence 节点里有原始文本下面挂着若干 aspectTerm 子节点每个 aspectTerm 都带 term方面词原文、polarity极性、from 和 to在原始文本中的字符偏移。解析代码用 Python 标准库就行import xml.etree.ElementTree as ET def parse_semeval2014(xml_path): tree ET.parse(xml_path) samples [] for sentence in tree.getroot().iter(sentence): text sentence.findtext(text) aspects [] # sentence.find(aspectTerms) 可能返回 None需要兜底 for at in sentence.find(aspectTerms) or []: if at.tag ! aspectTerm: continue aspects.append({ term: at.get(term), polarity: at.get(polarity), from: int(at.get(from)), to: int(at.get(to)), }) samples.append({text: text, aspects: aspects}) return samples这段代码的逻辑不复杂但有两个地方值得注意。第一findtext(text)拿到的字符串是带原始空格的不要用 strip() 去头尾否则后面所有字符偏移都会错位。第二find(aspectTerms)在没有方面词的句子上会返回 None直接用or []兜底是最省事的写法。解析完成后每个样本的形式就是一条原始文本和它携带的若干方面词标注。接下来要做的是把这些字符偏移转换到模型需要的 token 位置。3.2 把字符偏移对齐成 token 位置别用字符串 find很多初学者在这个环节直接写text.find(term)用方面词字符串在句子里的首次出现位置当作标记。这在“屏幕”出现两次、或者“Battery”和“battery”大小写不一致的句子里立刻翻车找出来的位置根本不是标注想要的那个。最可靠的做法是用标注自带的 from/to 字符偏移再和分词器输出的 token span 做对齐。import spacy nlp spacy.load(en_core_web_sm) def align_aspect_tokens(text, token_spans, char_from, char_to): token_spans: 分词后的 [(start_char, end_char), ...] char_from, char_to: 标注里 aspectTerm 的字符偏移区间 返回命中的 token 下标列表 hit_idx [] for idx, (span_start, span_end) in enumerate(token_spans): if span_start char_from and span_end char_to: hit_idx.append(idx) return hit_idx sentence The battery life is great, but the screen is dim. token_spans [(t.idx, t.idx len(t.text)) for t in nlp(sentence)] aspect_idx align_aspect_tokens(sentence, token_spans, 4, 16) print(aspect_idx) # 输出是 battery life 覆盖的 token 下标对齐逻辑看起来只是两个整数比较但里面有一个关键原则token 的区间必须完全落在字符区间内部。因为 spacy 这类分词器会把 “battery” 和 “life” 拆成两个独立 token它们的 span 都在 [4, 16) 里面。如果某个 token 只有一半落在区间里例如词形变化把词干切开了直接丢弃它并打印一条 warning不要硬算。硬算出来的 aspect mask 往往是错的这是后面模型白训的常见根源。3.3 训练集/验证集划分按句子切别按方面词切SemEval-2014 官方本来提供了 train/test 划分但课程作业通常要求自己再切一个验证集用来调参。这时候有一个非常隐蔽的坑一个句子里可能有多个方面词如果你直接对样本列表做随机切分同一个句子的两个不同方面词可能会被分到训练集和验证集两侧造成信息泄露。import random from collections import defaultdict def split_by_sentence(samples, valid_ratio0.1, seed42): # samples: parse 出来的列表每个元素包含 text 和 aspects random.seed(seed) id2samples defaultdict(list) for s in samples: id2samples[s[sentence_id]].append(s) all_ids list(id2samples.keys()) random.shuffle(all_ids) valid_count max(1, int(len(all_ids) * valid_ratio)) valid_ids set(all_ids[:valid_count]) train_data, valid_data [], [] for sid, sample_list in id2samples.items(): if sid in valid_ids: valid_data.extend(sample_list) else: train_data.extend(sample_list) return train_data, valid_data这里先按句子 id 分组再对整个句子做随机划分保证同一个句子的所有方面词要么全在训练集、要么全在验证集。如果你们课程作业里还有测试集测试集应该是完全独立的一份数据验证集只负责调参不要在验证集上反复试到指标满意再去动测试集。3.4 构造模型输入token 序列 aspect mask 标签预处理最后一步是把原始句子转成模型能吃的三个张量。对一个训练样本假如句子 token 序列是[the, battery, life, is, great, ...]方面词是 “battery life”那么我需要三样东西token 对应的词表 id一个和序列等长的 aspect mask方面词位置为 1其他位置为 0还有一个极性标签。def build_model_input(text, token_spans, aspect_idx, label2id, vocab): tokens [text[s:e] for s, e in token_spans] token_ids [vocab.get(t.lower(), vocab[UNK]) for t in tokens] aspect_mask [0] * len(tokens) for idx in aspect_idx: aspect_mask[idx] 1 return { token_ids: token_ids, aspect_mask: aspect_mask, label: label2id[label], }构建的时候要注意大小写归一化词表里统一存小写形式查不到的词落到UNK。aspect_mask保持和 token 序列等长后面在模型里会用它去提取方面词的隐藏状态。这一步做好之后数据预处理就完成了可以进模型。4. 用 BiLSTM Attention 跑通 ABSA 的最小实现4.1 选型为什么 BiLSTM Attention 比直接调 BERT 更适合作业课程作业的评分重点通常不是“你指标多高”而是“你能不能讲清楚模型为什么这样设计”。BERT 微调确实能刷高分数但它把这个问题的内在逻辑藏在了预训练权重里你很难向老师解释“为什么 BERT 知道 battery life 是方面词”。BiLSTM Attention 的好处是每个模块都能单独解释LSTM 负责建模上下文attention 负责让模型把“battery life”周围的上下文信息聚合起来。这个结构在 ABSA 领域有大量公开论文支撑实现起来代码量也小不需要下载预训练权重跑一个 CPU 小数据集都能出结果。所以这个作业我一般直接用 BiLSTM target-aware attention 作为 baseline。4.2 模型结构target-aware attention 是怎么让模型“盯着”方面词的这里的注意力机制不是普通的 self-attention而是 target-aware attention。核心思想是把句子编码成隐藏状态后先取方面词位置的隐藏状态做一个平均向量然后把这个向量拼接到句子的每一个词向量上再去算注意力分数。这样模型在计算每个词的重要程度时会额外参考“我正在评价的对象是谁”注意力会集中在和方面词语义相关的上下文上。import torch import torch.nn as nn import torch.nn.functional as F class ABSA_BiLSTM(nn.Module): def __init__(self, vocab_size, emb_dim300, hidden_dim256, dropout0.3, num_labels3, padding_idx0): super().__init__() self.emb nn.Embedding(vocab_size, emb_dim, padding_idxpadding_idx) self.lstm nn.LSTM(emb_dim, hidden_dim, num_layers1, batch_firstTrue, bidirectionalTrue) # 注意力打分网络输入是 句向量 aspect 向量拼接 self.attn nn.Sequential( nn.Linear(hidden_dim * 4, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, 1), ) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_dim * 2, num_labels) def forward(self, token_ids, aspect_mask): # token_ids: (batch, seq_len) emb self.emb(token_ids) # (B, L, emb_dim) h, _ self.lstm(emb) # (B, L, 2 * hidden_dim) mask aspect_mask.unsqueeze(-1).float() # (B, L, 1) # 取方面词位置隐藏状态的均值当作方面向量 aspect_vec (h * mask).sum(dim1) / (mask.sum(dim1) 1e-8) aspect_vec aspect_vec.unsqueeze(1).expand(-1, h.size(1), -1) attn_input torch.cat([h, aspect_vec], dim-1) # (B, L, 4 * hidden_dim) attn_weights F.softmax(self.attn(attn_input).squeeze(-1), dim1) attended torch.bmm(attn_weights.unsqueeze(1), h).squeeze(1) logits self.fc(self.dropout(attended)) return logits模型的关键在 forward 的中间几步。aspect_mask的 shape 是 (batch, seq_len)经过unsqueeze(-1)变成 (batch, seq_len, 1)这样才能和隐藏状态 h 做乘法。(h * mask).sum(dim1)把方面词位置向量加和起来除以mask.sum(dim1)得到平均这就是一个简单的 aspect 池化不需要额外参数。后面把平均向量广播到每个 token 位置去拼接attention 打分网络就能同时看到“当前词”和“当前评价对象”两个信息。最后的加权求和是用torch.bmm把注意力权重作用到隐藏状态序列上。几个默认参数说一下emb_dim300是词向量的通用维度和 GloVe 300d 对齐hidden_dim256是 LSTM 单方向的隐藏维度双向后实际是 512dropout 0.3 对课程作业的数据量来说比较稳妥。如果你的训练集很小可以把 hidden_dim 缩到 128dropout 加到 0.5。4.3 训练循环与关键参数跑通一个能交作业的 baseline模型定义好之后训练代码其实和普通文本分类没有太大区别。唯一要注意的是 loss 要处理类别不平衡。这里直接用 CrossEntropyLoss 的weight参数按照训练集各类别占比的倒数设置权重。import torch.optim as optim from sklearn.metrics import classification_report model ABSA_BiLSTM(vocab_sizelen(vocab), num_labels3) optimizer optim.AdamW(model.parameters(), lr2e-3, weight_decay1e-2) class_weight torch.tensor([1.2, 0.7, 1.5], dtypetorch.float32) # 按占比反调 criterion nn.CrossEntropyLoss(weightclass_weight) model.train() for epoch in range(20): for batch in train_loader: token_ids batch[token_ids] aspect_mask batch[aspect_mask] labels batch[label] optimizer.zero_grad() logits model(token_ids, aspect_mask) loss criterion(logits, labels) loss.backward() # LSTM 梯度容易爆炸clip 是必修课 nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step()训练循环里有两个容易忽略的点。一个是clip_grad_norm_LSTM 在序列长、梯度回传路径长的时候很容易梯度爆炸不裁剪的话可能训到第三个 epoch loss 突然变成 nan。另一个是weight_decay1e-2对 LSTM 做轻微正则防止在几千条样本的小数据集上过拟合。评估阶段不要只算准确率。把验证集的预测结果收集起来直接调用classification_report输出每一类的 precision、recall、F1 和宏平均 F1。课程作业提交的时候这个报告就是你的实验结果表。20 epoch 是最常见的设置如果你的训练集特别小可以加一个early stopping比如连续 3 个 epoch 验证集 macro-F1 不涨就停止。参数大致是lr 2e-3、batch size 32、epoch 20、hidden 256、dropout 0.3这几个值在 SemEval-2014 数据集上都能跑到一个说得过去的 baseline。5. 避坑指南ABSA 作业里最容易翻车的 5 个地方5.1 字符偏移对不齐方面词 mask 全零模型成了“瞎子”现象训练 loss 下降很慢最后模型对大多数输入都输出 neutralvalidation 的 macro-F1 只有 0.2 左右。原因预处理时没有用标注里的 from/to而是用text.find(term)去找方面词位置。遇到同一个词在句子里出现多次或者 “battery” 在句首是大写、在标注里是小写查找结果就和真实标注对不上。aspect_mask 要么全零要么标错了位置。全零的话模型根本提取不到 aspect 信息只能从整个句子的噪声里瞎猜。解决回到 3.2 节的对齐函数严格依赖字符偏移。对齐失败的时候不要静默跳过打印一条 warning手动检查那一批样本。我给自己的代码加了一个断言凡是解析出来的 aspect_idx 为空但标注里明确有方面词的样本直接抛异常终止宁可停下来也不要让模型在错误数据上练。5.2 neutral 太多准确率看着 80%实际一塌糊涂现象训练日志里 acc 到了 0.82你兴高采烈拿去验证集一跑macro-F1 只有 0.34。原因Laptop 数据集中 neutral 占比接近 1/3模型发现无脑输出 neutral 就能拿到高准确率于是把所有样本都推到 neutral 类别里。准确率这个指标在这些分布不均匀的分类任务里就是摆设。解决评估指标只用 macro-F1或者报告里同时列出 ACC 和 macro-F1让读者看到差别。训练时给 CrossEntropyLoss 传类别权重这个操作简单但老师会很认可。如果类别差异实在太悬殊可以对 neutral 做下采样但一定只动训练集验证集和测试集保持原始分布。5.3 同一个句子多个方面词一去重就丢了一大半样本现象解析出来的标注明明有 3000 条预处理后只剩 1800 条训练样本。原因构造数据集时把“句子”当作最小单位同一个句子里的多个方面词被合并或者直接丢弃了。比如 “Battery is good but screen is bad” 这个句子被存成一条样本只有第一个方面词被保留。解决一条样本的粒度应该是“句子 单个方面词”而不是一个句子一条。同一个句子的 token 序列可以共享每个样本拥有自己独立的 aspect_mask 和 label。做一个断言预处理后的样本数应该等于所有方面词标注的数量而不是句子的数量。5.4 验证集和测试集指标差距巨大一换数据就翻车现象验证集上 macro-F1 0.78拿老师给的测试集一测变成 0.61差距大到不像是正常泛化误差。原因划分训练/验证集时没有按句子分组同一个句子里的两个不同方面词被随机分到了两个集合。模型在训练时已经见过那句完整的话验证时只是换了一个方面词去预测相当于开了透视。解决3.3 节的split_by_sentence必须是标准操作。以后不管拿到什么数据集第一件事就是检查有没有 sentence_id 这类分组字段有就按组切分。没有显式 id 的话就把整句文本的 hash 当作分组依据。5.5 环境问题python 版本和 torch 对不上代码一跑就报错现象代码逻辑看起来完全没问题但import torch报错或者一训练就 CUDA out of memory。原因在 pycharm 或 vscode 里配置 python 环境时装了最新版本的 torch但本地 python 版本太老或者 torch 直接装了 CPU 版而代码里误用了.cuda()。课程作业机器通常没有显卡强行调用 CUDA 会在第一行就崩溃。解决新开一个虚拟环境python 版本固定 3.9 或 3.10先装 CPU 版本的 torch把一个小 batch 跑通再谈性能。代码里所有.cuda()调用都换成device变量作业代码不需要绑定 GPU。这里也建议把依赖写进 requirements.txttorch、spacy、scikit-learn 三个库就够了别在课程作业里引入一堆跟 ABSA 无关的重量级包。6. 进阶验证把注意力权重拉出来看看模型到底在盯哪个词训练结束不能只看指标我一般会加载一个验证集的样本把模型的 attention 权重打印出来看看模型到底把注意力放在哪些词上。这一步是最直观的“模型解释”也是课程答辩时最加分的部分。model.eval() sample_text Battery life is great, but the screen is too dim. # 预处理得到 token_ids 和 aspect_mask with torch.no_grad(): logits model(token_ids, aspect_mask) _, h model.lstm(model.emb(token_ids)) aspect_vec ... # 按 4.2 的公式计算 attn_input torch.cat([h, aspect_vec.expand(-1, h.size(1), -1)], dim-1) attn_weights F.softmax(model.attn(attn_input).squeeze(-1), dim1) token_list [the, battery, life, is, great, ...] weights attn_weights.squeeze(0).tolist() top3 sorted(zip(token_list, weights), keylambda x: x[1], reverseTrue)[:3] print(top3)打印出来的结果通常有两种情况理想状态下注意力前几名集中在 “battery”, “life”, “great” 这些和方面词直接相关的词上如果模型把高分给了 “the”, “is” 这类停用词说明它学到的是位置偏置而不是语义就要回头看 aspect_mask 是不是对齐错了。这个诊断方法对 BiLSTM 有效换成 BERT 微调后也建议做同样的事。最后说一个我自己压箱底的习惯做这类课程作业永远先跑通一个很小的实验比如只读 200 条数据、训练 3 个 epoch确认代码链路没有断再全量训练。每次改动只动一个变量训练日志、验证报告、模型 checkpoint 都按时间戳保存一份。这个习惯救了我很多次希望帮到你。本文还有配套的精品资源点击获取
返回列表