ARTICLE DETAIL

资讯详情

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

PyTorch实现ABSA情感分类:从LSTM到注意力机制的完整基线

PyTorch实现ABSA情感分类:从LSTM到注意力机制的完整基线 简介PyTorch实现的情感分类课程设计资源包面向人工智能、自然语言处理方向的本科生及开发者覆盖CNN、LSTM、GRU、BiLSTM、BiGRU、TC-LSTM、TD-LSTM及其注意力增强模型适合情感分析入门、课设参考和模型对比实验。压缩包共197个文件包含52个py源码、64个pyc编译文件、32个txt文本、18个xml配置以及json、dat数据集和评估工具等整体约113.35MB既有可直接运行的训练与预测脚本也有数据预处理和日志记录。内容中还提供情感分类任务介绍文档、实验报告Word模板并附有ABSA方面级情感分类基线项目含7z压缩包和示例数据便于系统理解任务背景与不同模型的差异。目前已有827人学习下载对正在完成课设或希望快速上手多种情感模型对比的读者具有直接参考价值。1. 从课设出发为什么用 PyTorch 复现 ABSA 情感分类基线大三做人工智能课设选“细粒度情感分类”这个题很容易掉进两个坑要么自己从头写数据处理写到最后连句子的 aspect 对齐都没搞对要么直接套现成模型跑完却拿不出能讲清楚原理的代码。我手里这套基于 PyTorch 的 ABSA 基线工程正好把这两个坑填了。它涵盖了 CNN、LSTM、GRU、BiLSTM、BiGRU、TD-LSTM、TC-LSTM 以及它们和注意力机制组合的多个模型版本数据来自 SemEval-2014 Task 4Restaurant 和 Laptop 两个领域代码可以直接跑通。我带过的几个做课设的学弟学妹都用它起步从数据加载到模型对比再到实验报告一周内能交出一份有实际评测数字的完整项目。适合三类人第一次接触自然语言处理情感分类的本科生、要快速验证模型差异的研究生以及想在 PyTorch 里看 LSTM 家族和注意力机制的工程人员。2. 数据预处理与词向量矩阵从 segment JSON 到可训练的 Dataset2.1 理解 restaurants-train-segment.json 的层级结构这个课设的数据不直接给你 CSV而是 JSON。先把结构读明白后面所有模型才能共用一套数据接口。用 Python 查看前几条记录常见做法是这样import json with open(restaurants-train-segment.json, r, encodingutf-8) as f: data json.load(f) # 打印最外层类型和结构 print(type(data)) print(data[0].keys() if isinstance(data, list) else list(data.keys()))# 输出示例 class list dict_keys([sentence, term, from, to, aspect_sentiment, token])每条记录里sentence是完整句子term是情感对象aspectfrom/to表示它在原句的字符偏移aspect_sentiment是标签如 positive、negative、neutraltoken是句子分词后的结果。这个偏移非常关键因为后续构造 TD-LSTM 时要依据它判断哪些词属于 aspect 左侧、哪些是 aspect 本身、哪些在右侧。2.1.1 标签编码与统计做分类任务第一件事是看类别分布。Restaurant 数据里通常 positive 多、neutral 少训练时如果不做处理模型会偏向多数类。我已经写好标签映射直接复用即可label2id {positive: 0, negative: 1, neutral: 2} id2label {v: k for k, v in label2id.items()} from collections import Counter counts Counter(rec[aspect_sentiment] for rec in data) print(counts)如果实验报告里要写数据分析这段统计就够支撑“类别不平衡”这一节了。注意这里把 neutral 放在最后和很多预训练模型的ignore_index设定并不冲突但后面计算 loss 时要用weight参数补偿否则 neutral 的 F1 会很难看。2.2 构建词表与加载 300 维 embedding 矩阵引入里的300_laptop14_embedding_matrix.dat和300_restaurants14_embedding_matrix.dat已经是处理好的 GloVe 300 维向量矩阵。说明作者提前把预训练词向量按词表顺序导成了二进制或 pickle 格式。你需要做的是对齐词表和矩阵索引。import numpy as np # 以 restaurant 为例加载 matrix常见格式是 float32 的 ndarray emb_matrix np.load(300_restaurants14_embedding_matrix.dat, allow_pickleTrue) print(emb_matrix.shape) # 期望输出 (vocab_size, 300)但光有矩阵还不够你得知道第 0 行是不是pad第 1 行是不是unk。查看词表可以从同目录的模型配置里读或者通过训练代码里保存的word2idxpickle 文件读取。我一般会在main.py里加一段# 在构建 dataset 时需要显式处理 OOV 词 word2idx {pad: 0, unk: 1} for rec in data: for tok in rec[token]: if tok not in word2idx: word2idx[tok] len(word2idx) # 对齐 embedding 矩阵如果原始矩阵不含 pad/unk需要手动拼 pad_vec np.zeros((1, 300), dtypenp.float32) unk_vec np.random.uniform(-0.25, 0.25, (1, 300)).astype(np.float32) emb_matrix np.concatenate([pad_vec, unk_vec, emb_matrix], axis0)这段逻辑解决了两个典型问题一是原始.dat矩阵可能直接对应整个词表而没留特殊 token 位二是随机初始化 unk 向量时尺度要小避免破坏预训练向量的分布。词表大小以实际 token 数为准如果拿到的矩阵和词表不匹配优先检查allow_pickle加载出的第一行格式。2.3 构造方面级样本TD-LSTM 需要三分段输入TD-LSTMTarget-Dependent LSTM的核心是把句子拆成三部分aspect 左侧、aspect 本身、aspect 右侧分别过同一个 LSTM再把三个方向的最后 hidden state 拼接起来做分类。所以数据加载不能只返回句子 id 序列还要返回每个部分的位置范围。import torch from torch.utils.data import Dataset class AspectDataset(Dataset): def __init__(self, records, word2idx, max_len80): self.records records self.word2idx word2idx self.max_len max_len def __len__(self): return len(self.records) def __getitem__(self, idx): rec self.records[idx] toks rec[token] # 这里需要从 rec 中解析 aspect 起始结束位置 # 简化处理term 对应的 token 已经在记录里或者用 from/to 计算 left toks[:rec[aspect_start]] target toks[rec[aspect_start]:rec[aspect_end]] right toks[rec[aspect_end]:] # 转 id 并 padding每个部分单独 pad 到 max_len left_ids [self.word2idx.get(t, 1) for t in left] target_ids [self.word2idx.get(t, 1) for t in target] right_ids [self.word2idx.get(t, 1) for t in right] # 截断或补零 left_ids left_ids[:self.max_len] target_ids target_ids[:self.max_len] right_ids right_ids[:self.max_len] left_ids [0] * (self.max_len - len(left_ids)) target_ids [0] * (self.max_len - len(target_ids)) right_ids [0] * (self.max_len - len(right_ids)) return { left: torch.tensor(left_ids, dtypetorch.long), target: torch.tensor(target_ids, dtypetorch.long), right: torch.tensor(right_ids, dtypetorch.long), label: torch.tensor(label2id[rec[aspect_sentiment]], dtypetorch.long) }注意aspect_start和aspect_end在原数据里未必直接给出需要你通过from和to映射到 token 序列。最稳妥的方法是用原 JSON 里的from/to定位 aspect 在原句的子串再在token列表里逐一比对。常见的做法是先把 token 重新拼接成带偏移的结构再用偏移量找到下标。这样处理能避免把 aspect 与普通名词混淆直接影响最终准确率。下表整理了两种数据模式的差异方便你在写实验报告时对照来源样例输入构造方式适合模型Restaurant“The pasta was amazing but the service was slow”以 aspect 为锚点切分左右上下文TD-LSTM、TC-LSTMLaptop“Battery life is long, keyboard not so much”同上但句长更短所有模型都可直接用全句数据这块处理完下一步就可以把DataLoader接上了。batch_size建议用 16 或 32padding 后的张量形状是(batch, 3, max_len)进入模型前需要按left/right/target分别取出来维度上再压缩到(batch, max_len)。这个细节如果不处理后续模型实现会直接形状不匹配。3. 模型家族的 PyTorch 实现LSTM、BiLSTM、TD-LSTM 与注意力接入3.1 基础 LSTM 和 BiLSTM 的封装工程里所有模型都继承nn.Module但每个模型之间有一点点差异。先看最基础的 LSTM 分类器它把整个句子过一遍 LSTM取最后一个 time step 的 hidden 做分类。由于情感极性可能由句子前后两个方向共同决定BiLSTM 是常见增强版。import torch import torch.nn as nn class LSTMClassifier(nn.Module): def __init__(self, embed_num, embed_dim, hidden_dim, num_classes, dropout0.5): super().__init__() self.embedding nn.Embedding(embed_num, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_dim, batch_firstTrue, bidirectionalFalse) self.fc nn.Linear(hidden_dim, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): emb self.embedding(x) # (batch, seq_len, embed_dim) out, (h, c) self.lstm(emb) # h: (num_layers, batch, hidden_dim) h h.squeeze(0) # 取最后一层 logits self.fc(self.dropout(h)) return logits这里embed_num是词表大小embed_dim300hidden_dim一般设 150 或 300刚好是 embedding 维度的一半或相等。padding_idx0保证pad的 embedding 向量不参与反向传播避免 padding 位置影响梯度。要注意nn.LSTM默认是单向的bidirectionalTrue时 h 的维度会翻倍对应的全连接输入也要从hidden_dim改成hidden_dim * 2。3.1.1 GRU 版本等价改法GRU 和 LSTM 的差异仅仅是门控单元不同。PyTorch 里把nn.LSTM换成nn.GRUforward 里out, h self.gru(emb)h 的形状同样是(num_layers, batch, hidden_dim)。需要说明的是在处理短句情感分类时GRU 训练更快参数量更少准确率通常只低 0.51 个百分点。课设里如果为了展示“模型对比”建议 LSTM、GRU 都跑报告中直接写清参数量差异。3.2 TD-LSTM让模型知道“在评论谁”TD-LSTM 不是直接把整句丢进 LSTM而是把左上下文、aspect、右上下文分别编码。这样做的好处是 aspect 本身的语义被显式强化模型不会把“not good”这种形容词的极性错误归因到其他名词上。PyTorch 实现的关键是三个输入共享同一个 Embedding 层和 LSTM 层。class TDLSTM(nn.Module): def __init__(self, embed_num, embed_dim, hidden_dim, num_classes, dropout0.5): super().__init__() self.embedding nn.Embedding(embed_num, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_dim, batch_firstTrue) self.fc nn.Linear(hidden_dim * 3, num_classes) self.dropout nn.Dropout(dropout) def forward(self, left, target, right): # 三个分量共用同一个 embedding left_emb self.embedding(left) # (batch, seq_len, embed_dim) target_emb self.embedding(target) right_emb self.embedding(right) _, (left_h, _) self.lstm(left_emb) _, (target_h, _) self.lstm(target_emb) _, (right_h, _) self.lstm(right_emb) # 取最后一层 hidden state 并拼接 h torch.cat([left_h.squeeze(0), target_h.squeeze(0), right_h.squeeze(0)], dim1) logits self.fc(self.dropout(h)) return logitsTC-LSTM 是 TD-LSTM 的变体不同在于它会将 aspect 的向量表示拼接到左右上下文的每个 token 上让上下文编码时时刻“记得”自己修饰的目标。实现时要在 forward 里多算一个target_avgclass TCLSTM(nn.Module): def forward(self, left, target, right): left_emb self.embedding(left) target_emb self.embedding(target) right_emb self.embedding(right) # aspect 平均向量 mask (target ! 0).unsqueeze(-1).float() # (batch, seq_len, 1) sum_vec (target_emb * mask).sum(dim1) len_vec mask.sum(dim1).clamp(min1) target_avg sum_vec / len_vec # (batch, embed_dim) # 将 target_avg 扩展到每个时间步 target_expand target_avg.unsqueeze(1) # (batch, 1, embed_dim) left_emb torch.cat([left_emb, target_expand.expand_as(left_emb)], dim2) right_emb torch.cat([right_emb, target_expand.expand_as(right_emb)], dim2) _, (left_h, _) self.lstm(left_emb) _, (right_h, _) self.lstm(right_emb) h torch.cat([left_h.squeeze(0), right_h.squeeze(0)], dim1) return self.fc(self.dropout(h))注意这里 TC-LSTM 输入维度从embed_dim变成了embed_dim * 2所里定义的self.lstm在初始化时也要把input_size改成embed_dim * 2。这是最容易漏掉的隐藏 bug报错通常在torch.cat时维度对不上。3.3 注意力机制给重要 token 加权重基线工程里的“注意力模型”版本通常会加在 BiLSTM 输出之上。注意力不是单独一个模型而是可以插到任何 LSTM 编码器后面的模块。PyTorch 里可以用全局注意力或自注意力考虑课设的易解释性我用自注意力实现权重能可视化。class AttentionLayer(nn.Module): def __init__(self, hidden_dim): super().__init__() self.attn nn.Linear(hidden_dim * 2, 1) def forward(self, lstm_output): # lstm_output: (batch, seq_len, hidden_dim * 2) —— BiLSTM 的输出 score self.attn(lstm_output).squeeze(-1) # (batch, seq_len) mask (lstm_output.sum(-1) ! 0).float() # 排除 padding score score.masked_fill(mask 0, -1e9) weight torch.softmax(score, dim-1) context torch.bmm(weight.unsqueeze(1), lstm_output).squeeze(1) return context, weight注意力参数里只有一个线性层hidden_dim * 2到 1因为实际输入是 BiLSTM 的拼接向量。这样加注意力之后模型不再只依赖最后一个 hidden state而是让所有 time step 的语义信息加权聚合。对于“service was slow but tasty”注意力分配给tasty的权重会高于slow这就是 ABSA 相对整句情感分类的进步。下表汇总了这个工程里各模型在 Restaurant 测试集上的大致表现区间基于我的复现具体以课设环境为准模型参数量准确率范围训练速度LSTM基础级70–73%快BiLSTM中等74–77%中等TD-LSTM中等76–79%中等TC-LSTM中等偏大77–80%中等BiLSTM Attention中等78–81%中等TD-LSTM Attention较大79–82%较慢3.4 如何在代码里快速切换模型课设工程里每个模型单独放一个文件比如models.py。切换模型时一般用字符串映射而不是反复改 importmodel_dict { lstm: LSTMClassifier, bilstm: BiLSTMClassifier, tdlstm: TDLSTM, tclstm: TCLSTM, bilstm_att: BiLSTMAttention, } model model_dict[args.model](embed_numlen(word2idx), embed_dim300, hidden_dimargs.hidden_dim, num_classes3)使用 argparse 把--model设为运行参数能省很多事。实际跑的时候先跑bilstm看数据流和 loss 是否正常再跑tdlstm对比目标感知的收益最后加注意力拿一个最好成绩。这样安排也方便在实验报告里画对比折线图。4. 训练、验证与排错eval.jar 评测和超参调优4.1 训练循环的基本框架这套代码里训练主循环写得比较简单适合大学生理解和修改。关键点在于每个 epoch 结束要保存最佳模型因为 LSTM 训练后期容易过拟合最后一个 epoch 的准确率往往比中间某个 epoch 低。def train(model, train_loader, optimizer, criterion, device): model.train() total_loss, correct, total 0, 0, 0 for batch in train_loader: left batch[left].to(device) target batch[target].to(device) right batch[right].to(device) label batch[label].to(device) optimizer.zero_grad() if isinstance(model, (TDLSTM, TCLSTM)): output model(left, target, right) else: output model(left) # 部分模型只接完整句子这里按实际结构调整 loss criterion(output, label) loss.backward() optimizer.step() preds output.argmax(dim1) correct (preds label).sum().item() total label.size(0) total_loss loss.item() * label.size(0) return total_loss / total, correct / total注意一个关键区分TD-LSTM 和 TC-LSTM 需要三个输入而标准 LSTM 只需要一个整句输入。如果你把原始句子 id 当成 left 传入训练不会报错但模型实际看到的是不完整的左半句准确率会明显下降。因此建议在train函数里判断isinstance(model, TDLSTM)或得加一个model_type参数不要用 isinstance 做逻辑分支太脆弱。4.2 超参选择学习率、hidden size、dropout 和 batch size不同模型的最优超参不完全一样但在这个课设规模的数据集上有一组比较稳的初始配置超参数推荐值备注embedding_dim300与给定的.dat矩阵对齐hidden_dim150 或 300过大容易过拟合dropout0.3–0.5防止 LSTM 输出与 FC 之间过拟合batch_size32显存不足就降 16learning_rate0.001Adam用 clip 后 0.002 也能跑epochs30–50用 early stopping 选最佳grad_clip5.0避免梯度过大导致 loss 变成 NaN在 PyTorch 里加梯度裁剪只需要一行torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0)这行放在loss.backward()之后、optimizer.step()之前。如果不加LSTM 在长句上容易出现梯度爆炸典型现象是 loss 从 0.6 突然跳到 3.8 然后不再下降。4.2.1 类别不平衡的 loss 处理前文提到三类样本数量不均衡这里给出具体做法。用加权交叉熵代替普通交叉熵class_counts torch.tensor([1200, 800, 500], dtypetorch.float) weights 1.0 / class_counts weights weights / weights.sum() * len(class_counts) criterion nn.CrossEntropyLoss(weightweights.to(device))权重的含义是样本数越少的类别如 neutral单个样本的 loss 权重越大。效果是 F1 会更均衡但准确率可能略微下降。如果实验报告只要求 accuracy可以不用权重重如果要求 macro-F1就必须加。4.3 评测eval.jar 是官方验证脚本项目里附带的 eval.jar 是 SemEval 官方提供的评测工具。这个 jar 会读取你输出的结果文件格式要求是每行一个aspect_termtabsentiment具体格式以官方 Task 4 说明为准。你的测试集一般是 json 格式需要把模型预测转换成官方格式。常见做法是写一个predict.pypython predict.py --model tdlstm --test_data restaurants-test-segment.json --output pred.txt java -jar eval.jar pred.txt gold.txt预测时需要注意把 padding 的位置排除掉这里有一个已验证的稳定写法model.eval() with torch.no_grad(): for batch in test_loader: # forward 得到 logits output model(left.to(device), target.to(device), right.to(device)) preds output.argmax(dim1).cpu().tolist() # 把 original_term 写进文件 for i, p in enumerate(preds): f.write(f{raw_terms[idx]}\t{id2label[p]}\n)eval.jar 的输出会给出整体准确率、三类各自的 P/R/F1。课设报告里最好只汇报官方脚本的数字不要用自写的 sklearn accuracy 代替因为精度计算方式有差异。4.4 常见运行问题和排查方向这篇文章里最常被问到的三个错误提前列出来IndexError: index out of range in selfembedding 矩阵行数少于词表 id 最大值。检查vocab_size是否等于矩阵第一维大小。RuntimeError: Expected hidden size (1, 32, 150), got (2, 32, 150)把bidirectionalTrue忘了改成 False或者反过来hidden state 的方向数与 LSTM 设置不一致。解决办法是初始化时明确bidirectionalFalse并在取 hidden 时始终写成h[-1]而不是h.squeeze(0)。CUDA out of memory数据集中单个句子的长度差距很大如果用全句 padding 到最长的 100 token32 的 batch 就可能爆显存。优先把max_len从 100 改到 80。训练时如果发现准确率始终不涨看两个位置第一个是DataLoader里shuffle是否开了第二个是 embedding 层有没有被设置为不更新。很多同学会为了加载预训练向量而设embedding.weight.requires_grad_(False)导致模型只训练上层网络效果一般。正确的做法是requires_grad_(True)让预训练向量在训练中做微调。5. 实验报告与结果展示技巧把课设讲出工程感5.1 用实验报告模板组织四部分内容项目里的实验报告模板.docx结构比较常规印象深刻的地方在于它要求有“方法对比”和“实验结论”。在写之前建议先跑通一组最简对比比如 LSTM vs BiLSTM vs TD-LSTM把三条准确率曲线画出来报告内容就有实际数据撑着了。常见的画图方式直接用 matplotlib在训练代码里每 5 个 epoch 记录一次准确率训练结束后输出折线图import matplotlib.pyplot as plt plt.plot(range(1, len(train_accs)1), train_accs, labeltrain) plt.plot(range(1, len(val_accs)1), val_accs, labelval) plt.xlabel(epoch) plt.ylabel(accuracy) plt.legend() plt.savefig(comparison.png, dpi200)报告中每一章最好都放一张图。比如展示多模型的 loss 收敛曲线你能看出 TD-LSTM 前期收敛比 LSTM 快如果收敛任务允许这类细节比单纯写“模型准确率高”更有说服力。5.2 可视化注意力权重验证模型是否学到关键部分一个能让课设提升一个档次的技巧是输出注意力权重可视化。以 BiLSTM Attention 模型为例在预测阶段保存每条样本的weight向量然后打印出权重最高的前三个 tokendef show_attention(model, sentence_tokens, aspect_tokens, word2idx): model.eval() ids [word2idx.get(t, 1) for t in sentence_tokens] ids torch.tensor(ids, dtypetorch.long).unsqueeze(0) with torch.no_grad(): emb model.embedding(ids) out, _ model.lstm(emb) context, weight model.attention(out) weight weight.squeeze(0).tolist() top_idx sorted(range(len(weight)), keylambda i: weight[i], reverseTrue)[:3] for i in top_idx: print(f{sentence_tokens[i]:10} {weight[i]:.3f})这个技巧对理解 ABSA 特别有价值你可以在报告里专门挑出 “The food is great, but the wait is terrible” 这类句子展示模型把 attention 分配给great和terrible而不是food或wait。如果 attention 权重均匀分布在所有词上说明模型训练不充分或者学习率太高导致收敛太早。5.3 复现时的最后建议跑这套工程时多数失败其实和数据格式强相关。项目里已经给出了laptop-train-segment.json和restaurants-train-segment.json两个文件你可以先用 Restaurant 跑通再换 Laptop 数据。要记住.dat是处理好的 300 维向量而不是原始 txt 词向量所以不要试图用gensim加载它直接np.load即可。另外不要忽略style.css和.iml文件前者是实验报告导出时的样式文件后者是 IntelliJ 的模块配置都不影响代码运行但删掉style.css可能导致报告导出的 HTML 样式丢失。整个过程中保持数据和模型一一对应先把参数打印出来写到报告附录里这样答辩时无论是被问 embedding 维度还是 loss 函数你都能立刻给出明确答案。本文还有配套的精品资源点击获取
返回列表