简介:本资源面向深度学习与自然语言处理方向的本科或研究生毕业设计场景,提供一套基于融合对抗训练与注意力机制的Bi-LSTM网络,用于景区评论情感分析的完整Python实现。项目覆盖从数据标注、word2vec词向量训练、模型训练到未标注语句情感预测与评价分数生成的完整流程,适合具备一定Python与TensorFlow基础、希望完成情感分类课题或复现相关算法的学习者。压缩包共18个文件,约191KB,包含4个ipynb交互式笔记、4个py源码模块、word2vec词向量文件、语料与停用词数据、日志及模型保存目录等,其中config.py负责参数配置,dataSet.py完成数据预处理,model.py给出基线模型,tools.py提供序列长度与评价指标计算函数,main_load与main_save两个笔记分别支持加载预训练模型和读取训练数据进行预测。目前已有248人学习下载,可帮助读者快速理解对抗训练与注意力机制在Bi-LSTM中的融合方式,并借助现成脚本完成训练、预测与结果评估。
1. 景区评论情感分析:一条 Bi-LSTM 加注意力到底能解决什么
做景区运营的朋友经常丢给我一个 Excel,几千条来自 OTA 和点评平台的游客评论,问我能不能自动分出好评差评、再告诉我大家到底在骂什么。人工标注 500 条就要一整天,而且标准还会漂移。这个标题讲的方案,就是用 Python 搭一套「Bi-LSTM + 注意力机制 + 对抗训练」的情感分析模型,把这件事自动化。它适合三类人:做文本分类毕设的学生、需要舆情监控的文旅从业者、想搞懂注意力机制怎么落到代码里的 NLP 入门者。核心链路是:中文分词 → 词向量 → 双向 LSTM 编码 → 注意力加权 → 情感极性输出,再叠加对抗扰动提升泛化。下面按能复现的顺序拆开讲。
2. 数据准备与中文预处理的落地细节
景区评论和标准影评数据集最大的差别是:口语化严重、夹杂 emoji、有大量地名和方言词,还有「排队两小时」「厕所脏」这种短句。直接套用英文情感分析的流程会翻车,预处理这一步决定了后面模型的上限。
2.1 景区评论数据的采集与标注口径
数据来源常见有三类:OTA 平台导出的评论、景区自建问卷的开放题、社交平台的打卡文本。我一般先统一成两列:text和label,label 用 0/1 二分类(负面/正面),如果要做细粒度可以扩成 1-5 星再映射。标注口径必须提前定死,否则「一般般」这种中性评论会让两个人标出相反结果。
一个可复现的最小数据集结构如下:
data/ train.csv # 训练集,约 6400 条 dev.csv # 验证集,约 800 条 test.csv # 测试集,约 800 条 stopwords.txt # 中文停用词表划分比例按 8:1:1,注意要按时间或景区分层抽样,避免某个景区的评论全落在测试集里导致指标虚高。这一步没有代码,但比调参重要。
2.2 用 jieba 做分词与清洗的完整脚本
中文必须先分词。景区评论里「九寨沟」不能被切成「九寨」「沟」,所以要把景区名、常见地名加进 jieba 的自定义词典。
import jieba import re import pandas as pd # 加载自定义词典,保证景区专有名词不被切碎 jieba.load_userdict("data/user_dict.txt") def clean_text(text): # 去掉 URL、@用户、多余空白 text = re.sub(r"http\S+|@\S+", "", str(text)) text = re.sub(r"\s+", " ", text).strip() # 只保留中文、英文、数字 text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", "", text) return text def tokenize(text): text = clean_text(text) # 精确模式分词,过滤单字和停用词 words = [w for w in jieba.lcut(text) if len(w) > 1] return words df = pd.read_csv("data/train.csv") df["tokens"] = df["text"].apply(tokenize) print(df[["text", "tokens"]].head())逻辑说明:clean_text负责去噪,tokenize负责切词并过滤长度为 1 的字,因为单字在情感分析里噪声大于信号。参数上,len(w) > 1这个阈值可以调,如果你的数据里「差」「赞」这类单字情感词很关键,就改成>= 1并配合停用词表过滤。停用词表建议用哈工大停用词表打底,再手动补上「景区」「游客」这类高频但无情感指向的词。
2.3 词向量初始化:从零训练还是加载预训练
词向量有两种路线:一是用 Word2Vec 在自己的评论语料上训练,二是加载腾讯词向量或中文预训练词向量。景区领域词比较特殊,我一般用「预训练 + 领域微调」:先加载预训练向量覆盖大部分常用词,未登录词用随机初始化,然后在训练中一起微调。
import numpy as np from gensim.models import KeyedVectors def build_embedding_matrix(word2id, emb_path, emb_dim=200): # 加载预训练词向量 w2v = KeyedVectors.load_word2vec_format(emb_path, binary=False) vocab_size = len(word2id) matrix = np.random.normal(0, 0.1, (vocab_size, emb_dim)) hit = 0 for word, idx in word2id.items(): if word in w2v: matrix[idx] = w2v[word] hit += 1 print(f"命中率: {hit / vocab_size:.2%}") return matrix参数说明:emb_dim常用 100 或 200,维度越高表达力越强但显存吃紧;np.random.normal(0, 0.1, ...)是未登录词的初始化,标准差别设太大,否则训练初期梯度爆炸。命中率低于 70% 就要考虑换词向量或扩大领域语料,这个数字是判断词向量是否可用的第一指标。
3. Bi-LSTM 加注意力模型的搭建与训练
预处理做完,进入模型主体。这一章把网络结构、注意力计算、对抗训练三块讲清楚,每一块都给能直接跑的代码。
3.1 Bi-LSTM 编码层为什么比单向更适合评论
评论的情感往往由后半句决定,比如「风景很美,但是排队太久了」,单向 LSTM 读到「但是」时已经丢失了前面的信息。Bi-LSTM 用前向和后向两个 LSTM 分别编码,再把两个方向的隐状态拼接,每个词都能同时看到上下文。
import torch import torch.nn as nn class BiLSTMEncoder(nn.Module): def __init__(self, emb_matrix, hidden_dim=128, dropout=0.3): super().__init__() vocab_size, emb_dim = emb_matrix.shape # 用预训练矩阵初始化 embedding,并冻结前几轮 self.embedding = nn.Embedding.from_pretrained( torch.tensor(emb_matrix, dtype=torch.float32), freeze=False, padding_idx=0 ) self.lstm = nn.LSTM( emb_dim, hidden_dim, batch_first=True, bidirectional=True, num_layers=1 ) self.dropout = nn.Dropout(dropout) def forward(self, x): emb = self.dropout(self.embedding(x)) out, _ = self.lstm(emb) # out: [B, L, 2*hidden] return out参数说明:hidden_dim=128是单方向维度,双向拼接后是 256;num_layers=1对中小数据集够用,层数多了容易过拟合;freeze=False表示词向量参与微调,如果数据量小于 5000 条建议设成 True 冻结。padding_idx=0保证 padding 不参与梯度。
3.2 注意力机制:让模型自己挑出「排队」「脏」这些关键词
注意力机制的作用是给每个词的隐状态算一个权重,情感词权重高,中性词权重低。常见做法是加性注意力(Bahdanau)或点积注意力,这里用加性注意力,参数量小、对小数据集友好。
class Attention(nn.Module): def __init__(self, hidden_dim): super().__init__() self.proj = nn.Linear(hidden_dim * 2, hidden_dim * 2) self.query = nn.Linear(hidden_dim * 2, 1, bias=False) def forward(self, lstm_out, mask=None): # lstm_out: [B, L, 2H] score = self.query(torch.tanh(self.proj(lstm_out))) # [B, L, 1] score = score.squeeze(-1) # [B, L] if mask is not None: # padding 位置置为极小值,softmax 后权重趋近 0 score = score.masked_fill(mask == 0, -1e9) weight = torch.softmax(score, dim=1) # [B, L] context = torch.bmm(weight.unsqueeze(1), lstm_out) # [B, 1, 2H] return context.squeeze(1), weight逻辑说明:proj把隐状态映射到注意力空间,query打成一个标量分数,masked_fill是关键——不加 mask 的话 padding 也会分到权重,模型会学到「句子越长越负面」这种伪相关。返回的weight可以可视化,用来解释模型到底关注了哪些词,这在毕设答辩里是加分项。
3.3 融合对抗训练:FGM 扰动提升泛化
对抗训练的思路是在词向量上加一个微小扰动,让模型在「最坏情况」下仍然预测正确,从而提升鲁棒性。FGM(Fast Gradient Method)是最轻量的实现,几乎不增加训练时间。
class FGM: def __init__(self, model, eps=1.0): self.model = model self.eps = eps self.backup = {} def attack(self): for name, param in self.model.named_parameters(): if param.requires_grad and "embedding" in name: self.backup[name] = param.data.clone() norm = torch.norm(param.grad) if norm != 0: r_at = self.eps * param.grad / norm param.data.add_(r_at) def restore(self): for name, param in self.model.named_parameters(): if name in self.backup: param.data = self.backup[name] self.backup = {}参数说明:eps=1.0是扰动幅度,太大模型学不动,太小没效果,一般 0.5-1.0 之间调;只对 embedding 层做扰动是因为那里参数最多、最容易被攻击。训练循环里先正常前向反向,再attack()后二次前向反向,最后restore(),相当于每个 batch 训练两次。
3.4 训练循环与关键超参设置
把上面三块拼起来,训练循环要处理类别不平衡(差评通常少于好评),用加权交叉熵。
from torch.utils.data import DataLoader from sklearn.metrics import f1_score def train(model, loader, optimizer, criterion, fgm, device): model.train() for batch in loader: x, mask, y = [b.to(device) for b in batch] # 正常训练 logits = model(x, mask) loss = criterion(logits, y) loss.backward() # 对抗训练 fgm.attack() logits_adv = model(x, mask) loss_adv = criterion(logits_adv, y) loss_adv.backward() fgm.restore() optimizer.step() optimizer.zero_grad()超参建议:学习率 1e-3 配 Adam,batch size 64,训练 10-15 轮,早停看验证集 F1。类别不平衡时给少数类权重设为多数类的 2-3 倍。验证指标别只看准确率,景区评论里差评识别率(负面类的 recall)才是运营真正关心的。
4. 避坑与排查:这套模型最容易翻车的五个地方
模型跑通不难,难的是指标上不去还找不到原因。下面五条是我实际踩过的坑,按「现象 → 原因 → 解决」写。
4.1 验证集准确率 95% 但上线一塌糊涂
现象:本地验证集 F1 到 0.95,换一批新评论预测全是正面。原因:训练集和验证集来自同一批数据随机划分,同一景区的相似评论同时出现在两边,造成数据泄漏。解决:按景区或时间做分组划分,用GroupShuffleSplit而不是train_test_split,宁可指标掉 5 个点也要保证划分干净。
4.2 注意力权重全均匀,看不出重点
现象:可视化注意力权重,每个词几乎一样。原因:没加 mask,padding 稀释了权重;或者注意力层学习率太大,还没收敛。解决:确认masked_fill生效,把注意力层单独设小学习率(比如主网络的 0.1 倍),训练轮数加到 20 轮再看。
4.3 加了对抗训练反而掉点
现象:FGM 一开,验证 F1 比不加还低。原因:eps设太大,或者对抗训练和正常训练共用同一个 optimizer 导致梯度累积异常。解决:eps从 0.5 开始试,对抗那一步用单独的optimizer_adv,或者干脆把对抗损失乘 0.5 的系数再加进去。
4.4 显存爆了,batch size 只能开到 8
现象:CUDA out of memory。原因:序列长度没截断,景区评论有的长达 500 字,padding 到最大长度浪费显存。解决:按分位数截断,比如取 95 分位长度(通常 128-200),超长截断、不足补齐,batch_first=True下用pad_sequence动态 padding。
4.5 分词把情感词切没了
现象:模型对「不推荐」「没意思」这类否定词不敏感。原因:jieba 把「不推荐」切成「不」「推荐」,过滤单字时把「不」丢了,否定信息丢失。解决:把常见否定词加进自定义词典,或者用len(w) >= 1保留单字,再靠停用词表去掉真正无意义的字。
5. 从能跑到好用:注意力可视化与推理加速的两个技巧
模型训完只是开始,毕设答辩或实际部署时,能解释、能快速推理才是加分项。这里给两个我常用的进阶技巧。
第一个是注意力可视化,把每个词的权重画成热力图,直接看出模型关注了哪些词。做法是把Attention返回的weight存下来,和分词结果对齐:
import matplotlib.pyplot as plt def plot_attention(tokens, weights, save_path="attn.png"): # weights 是 [L] 的 numpy 数组 plt.figure(figsize=(max(6, len(tokens) * 0.5), 2)) plt.imshow(weights.reshape(1, -1), cmap="Reds", aspect="auto") plt.xticks(range(len(tokens)), tokens, rotation=45, ha="right") plt.yticks([]) plt.colorbar() plt.tight_layout() plt.savefig(save_path, dpi=150)注意weights要先把 padding 部分去掉再和 tokens 对齐,否则词和权重会错位。这个图放在论文或汇报里,比一堆指标数字更有说服力。
第二个是推理加速。Bi-LSTM 是序列模型,逐条推理慢,实际部署时可以做两件事:一是把模型转成 ONNX 或 TorchScript,二是对短文本做 batch 推理。我一般用 TorchScript:
model.eval() scripted = torch.jit.script(model) scripted.save("sentiment_model.pt") # 推理时 loaded = torch.jit.load("sentiment_model.pt") with torch.no_grad(): logits = loaded(x, mask)TorchScript 能省掉 Python 解释开销,在 CPU 上通常有 1.5-2 倍加速。如果还要更快,可以把 Bi-LSTM 换成 CNN 或蒸馏成小模型,但那是另一个话题了。
最后说个我自己的习惯:每次改完模型结构,先在一个 200 条的小子集上跑 2 轮,确认 loss 在降、注意力有权重、没有 shape 报错,再上全量数据。这个「小步快跑」的习惯帮我省了无数次等一晚上结果发现维度对不上的时间。希望帮到你。
本文还有配套的精品资源,点击获取