简介:本资源面向人工智能与深度学习方向的本科或研究生毕业设计场景,提供一套基于融合对抗训练与注意力机制的Bi-LSTM网络,用于景区评论情感分析的完整Python实现。项目围绕情感分类全流程展开,涵盖数据标注与语句结构规范、word2vec词向量训练、标注语句模型训练、未标注语句情感预测以及结合预测值生成评价分数等环节,适合具备一定深度学习基础、希望系统实践文本情感分析的中高级学习者。压缩包共18个文件,约191KB,包含4个ipynb交互式笔记本、4个py源码文件,以及word2vec词向量、语料、停用词、日志、模型保存与结果文件等,config.py负责配置,dataSet.py完成数据预处理,tools.py提供序列长度与评价指标函数,model.py给出baseline模型,main_load与main_save分别支持加载预训练模型和读取训练数据进行预测。目前已有248人学习,可帮助读者快速理解对抗训练与注意力机制在Bi-LSTM中的融合方式,并掌握从词向量到评价分数的完整工程链路。
1. 景区评论情感分析为什么值得用融合对抗与注意力 Bi-LSTM 重做一遍
做景区评论情感分析的人,大多经历过一个尴尬时刻:模型在公开影评数据集上准确率刷到 92%,一换到自家爬下来的景区评论就掉到 70% 出头。原因不玄学——景区评论短、口语化、反讽多、标签噪声大,还夹着大量「排队两小时风景五分钟」这种前后情感冲突的长句。普通 Bi-LSTM 能记住上下文,却分不清哪几个词才是决定情感极性的关键;注意力机制能挑重点,但遇到标注样本少、分布偏斜时又容易过拟合到高频词上。
这个标题里的「融合对抗」和「注意力机制」正是冲着这两个痛点来的:对抗训练负责在嵌入层或隐状态上加扰动,逼模型学出更鲁棒的决策边界,缓解小样本和噪声标签;注意力机制负责在 Bi-LSTM 的双向输出上做加权池化,把「风景」「服务」「排队」这些真正影响打分的词拎出来。两者叠在一起,构成一套在景区评论这种脏数据上更稳的情感分析方案。
这套东西适合谁?适合正在做毕设、需要一份能跑通、能讲清原理、还能在答辩时扛住追问的学生;也适合手上有一批中文短文本、想快速验证情感分析落地效果的工程师。下面从数据准备一路讲到对抗扰动怎么加、注意力怎么接、坑在哪,代码全部给到可复现的程度。
2. 数据准备与标签体系:景区评论和影评到底差在哪
2.1 景区评论的四个数据特征决定了预处理方式
先别急着搭模型,景区评论的数据形态和标准情感分析数据集差别很大,处理方式选错,后面调参全是白费。
第一是长度分布。影评动辄几百字,景区评论大量集中在 10 到 40 字,比如「景色不错就是门票贵」「缆车排队一小时值了」。短文本意味着 Bi-LSTM 的序列长度可以压到 64 甚至 32,训练快,但也意味着单个词的信息权重被放大,一个否定词就能翻转整句极性。
第二是情感冲突句占比高。景区评论天然带「总分结构」:先夸再贬或先贬再夸。这类句子如果只做句子级分类,标签本身就模糊。常见做法是标注时以整体倾向为准,同时在预处理阶段保留分句信息,方便后续做注意力可视化时定位冲突片段。
第三是领域词密集。「索道」「摆渡车」「讲解员」「旺季」「淡季」这些词在通用词表里权重很低,但在景区场景里直接关联体验。所以分词后要维护一份领域词典,把这类词加入 jieba 的自定义词典,避免被切碎。
第四是标签噪声。爬来的评论常带默认好评、复制粘贴的模板句,甚至和评分不一致。对抗训练在这里的价值就体现出来了——它不追求拟合每一个噪声标签,而是学一个对扰动不敏感的表示。
2.2 从原始评论到模型输入的完整预处理链路
下面这段代码覆盖了清洗、分词、去停用词、标签映射和序列截断,是后面所有模型的地基。
import re import jieba import pandas as pd from sklearn.model_selection import train_test_split # 领域词典:景区场景高频词,避免被 jieba 切碎 DOMAIN_WORDS = ["索道", "摆渡车", "讲解员", "旺季", "淡季", "门票", "观景台", "缆车", "栈道"] for w in DOMAIN_WORDS: jieba.add_word(w) # 停用词表按需加载,这里给最小集合 STOPWORDS = set(["的", "了", "是", "在", "我", "有", "和", "就", "都", "也", "很"]) def clean_text(text): text = re.sub(r"<[^>]+>", "", str(text)) # 去 HTML 标签 text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9,。!?]", "", text) # 保留中文和基本标点 return text.strip() def tokenize(text): words = jieba.lcut(clean_text(text)) return [w for w in words if w not in STOPWORDS and len(w) > 0] def build_dataset(csv_path, max_len=64): df = pd.read_csv(csv_path) # 假设原始列:comment, label(0负面/1中性/2正面) df["tokens"] = df["comment"].apply(tokenize) df = df[df["tokens"].map(len) > 0] # 构建词表 vocab = {"<PAD>": 0, "<UNK>": 1} for tokens in df["tokens"]: for w in tokens: if w not in vocab: vocab[w] = len(vocab) # 转 id 并截断/补齐 def to_ids(tokens): ids = [vocab.get(w, 1) for w in tokens][:max_len] return ids + [0] * (max_len - len(ids)) df["input_ids"] = df["tokens"].apply(to_ids) return df, vocab df, vocab = build_dataset("scenic_comments.csv") train_df, test_df = train_test_split(df, test_size=0.2, stratify=df["label"], random_state=42) print("词表大小:", len(vocab), "训练集:", len(train_df), "测试集:", len(test_df))逻辑说明:clean_text先去掉 HTML 和特殊符号,景区评论里常混着表情符号和网址,不清掉会污染词表。jieba.add_word把领域词注册进去,否则「摆渡车」会被切成「摆渡」和「车」,语义就散了。build_dataset里词表从训练数据构建,<PAD>固定为 0、<UNK>固定为 1,这样补齐和未知词处理不会冲突。
参数说明:max_len=64是景区短评论的经验值,覆盖 95% 以上的样本长度;如果你的数据里有大量长游记,调到 128。test_size=0.2配合stratify保证三个类别在训练测试集里比例一致,避免某类样本太少导致评估失真。random_state固定住,方便复现和对比不同模型。
提示:词表一定要在划分训练集之后再从训练集构建,或者至少保证测试集里的词不会「偷看」进词表。很多毕设代码在这里翻车,评估指标虚高就是这么来的。
2.3 标签体系与类别不平衡的处理选择
景区评论的情感标签常见三分类(负面/中性/正面),也有做五分类对应评分星级的。三分类更适合短文本,因为中性评论本身边界模糊,五分类会把噪声放大。如果原始数据只有评分,常见做法是 1-2 星映射负面、3 星映射中性、4-5 星映射正面。
类别不平衡是常态——正面评论往往占 60% 以上。处理方式有三种:一是损失函数加类别权重,CrossEntropyLoss(weight=...)按类别频率倒数设置;二是过采样少数类,但短文本过采样容易过拟合;三是在划分时保证每类最少样本数。我一般先用类别权重,简单且不改变数据分布。权重计算方式是总样本数 / (类别数 * 该类样本数),直接传给损失函数即可。
3. 融合对抗训练与注意力 Bi-LSTM 的模型搭建
3.1 Bi-LSTM 打底:为什么双向比单向更适合中文评论
中文评论的情感判断经常依赖后文语境。「虽然排队久,但是风景真的绝了」——如果只看前半句,模型会判负面;双向 LSTM 的后向层能把「风景真的绝了」的信息带回前面的隐状态,最终表示才准确。
Bi-LSTM 的结构是前向 LSTM 和后向 LSTM 各跑一遍,然后把两个方向的隐状态拼接。设前向隐状态为 $\overrightarrow{h_t}$,后向为 $\overleftarrow{h_t}$,拼接得到 $h_t = [\overrightarrow{h_t}; \overleftarrow{h_t}]$。这样每个时间步的输出都同时包含左右上下文。
隐藏维度怎么选?景区评论词表通常在一万到三万之间,隐藏维度 128 或 256 都够用。层数上,单层 Bi-LSTM 在短文本上已经能打,两层容易过拟合,除非你的数据量过万。dropout 加在 LSTM 输出上,0.3 到 0.5 之间调。
3.2 注意力机制怎么接在 Bi-LSTM 输出上
注意力机制的作用是给每个时间步的隐状态分配权重,然后加权求和得到句子表示。公式很直接:
$$\alpha_t = \frac{\exp(u_t^T u_w)}{\sum_{i=1}^{T} \exp(u_i^T u_w)}$$
其中 $u_t = \tanh(W_w h_t + b_w)$ 是隐状态经过一层变换得到的注意力打分输入,$u_w$ 是可学习的上下文向量。最终句子表示 $s = \sum_t \alpha_t h_t$。
这里有个容易忽略的点:padding 位置必须 mask 掉。景区评论补齐到 64 后,大量位置是<PAD>,如果不 mask,注意力会把权重分到这些无意义位置上,模型学出来的东西就是错的。mask 的做法是在计算 softmax 前把 padding 位置的打分设成极小值(如 -1e9)。
多头注意力是不是更好?在 Bi-LSTM 之上,单头注意力已经能带来明显提升,多头注意力(比如 4 头)在数据量足够时能捕捉不同子空间的情感线索,但参数量增加,小数据集上反而容易过拟合。毕设场景建议先用单头跑通,再对比多头。
3.3 融合对抗:在嵌入层加扰动的实现细节
对抗训练的核心思想是:在输入或隐状态上叠加一个「最坏方向」的小扰动,让模型在这个扰动下仍然预测正确,从而学出更平滑的决策边界。最常用的是 FGM(Fast Gradient Method),实现简单,效果好。
FGM 的步骤是:正常前向传播算出损失,反向传播得到嵌入层的梯度,然后沿梯度方向对嵌入加扰动 $\delta = \epsilon \cdot g / |g|$,再用扰动后的嵌入重新前向传播算一次损失,两部分损失相加后更新参数。$\epsilon$ 是扰动幅度,通常取 0.5 到 1.0。
import torch import torch.nn as nn import torch.nn.functional as F class FGM: def __init__(self, model, epsilon=0.5): self.model = model self.epsilon = epsilon self.backup = {} def attack(self): # 只对 embedding 层加扰动 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 and not torch.isnan(norm): r_at = self.epsilon * 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 = {}逻辑说明:attack在第一次反向传播后调用,此时param.grad已经算好,直接用它构造扰动。restore在第二次前向传播后调用,把嵌入权重还原,避免扰动累积到下一轮。注意只对 embedding 层操作,对 LSTM 和全连接层加扰动收益不明显,还容易训练不稳定。
参数说明:epsilon=0.5是中文短文本的常用起点,太大(超过 1.0)会让训练损失震荡,太小(低于 0.1)几乎没效果。训练循环里,第一次前向传播用原始嵌入,attack后第二次前向传播用扰动嵌入,两次损失相加再backward。
3.4 完整模型代码:嵌入 + Bi-LSTM + 注意力 + 分类头
class AttentionBiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_dim=128, num_classes=3, dropout=0.4): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True, bidirectional=True) self.attn_w = nn.Linear(hidden_dim * 2, hidden_dim * 2) self.attn_u = nn.Linear(hidden_dim * 2, 1, bias=False) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_dim * 2, num_classes) def forward(self, x): mask = (x != 0) # padding mask emb = self.dropout(self.embedding(x)) out, _ = self.lstm(emb) # [B, T, 2H] u = torch.tanh(self.attn_w(out)) # [B, T, 2H] scores = self.attn_u(u).squeeze(-1) # [B, T] scores = scores.masked_fill(~mask, -1e9) # mask 掉 padding alpha = F.softmax(scores, dim=1) # [B, T] context = torch.bmm(alpha.unsqueeze(1), out).squeeze(1) # [B, 2H] return self.fc(self.dropout(context))逻辑说明:mask从输入 id 是否为 0 得到,masked_fill把 padding 位置的注意力打分压到极小,softmax 后这些位置权重接近 0。torch.bmm做批量矩阵乘法,把注意力权重和隐状态加权求和。分类头接在句子表示上,dropout 放在 LSTM 输出和分类头前各一次。
参数说明:embed_dim=128和hidden_dim=128是平衡表达力和训练速度的常用组合,显存紧张可以降到 64。dropout=0.4在短文本上比 0.5 更稳,因为本身序列短,过度 dropout 会丢信息。padding_idx=0让 embedding 层对补齐位不更新梯度。
3.5 训练循环:把对抗训练嵌进去
def train_one_epoch(model, loader, optimizer, criterion, fgm, device): model.train() total_loss = 0 for x, y in loader: x, y = x.to(device), y.to(device) optimizer.zero_grad() logits = model(x) loss = criterion(logits, y) loss.backward() # 第一次反向,得到梯度 fgm.attack() # 加扰动 logits_adv = model(x) loss_adv = criterion(logits_adv, y) loss_adv.backward() # 第二次反向,累加梯度 fgm.restore() # 还原嵌入 optimizer.step() total_loss += loss.item() + loss_adv.item() return total_loss / len(loader)逻辑说明:两次backward的梯度会累加,所以optimizer.step()放在最后。fgm.restore()必须在step之前调用,否则扰动会写进权重。criterion用带类别权重的CrossEntropyLoss。
参数说明:学习率用 1e-3 配 Adam,训练 10 到 20 轮,早停看验证集 F1。batch size 64 或 128,短文本显存占用低,可以开大一点加速。
4. 训练调参与评估:指标怎么读、参数怎么调
4.1 景区评论场景下该看哪些指标
准确率在类别不平衡时具有欺骗性。正面评论占 70% 的数据集,全预测正面也有 70% 准确率。景区评论情感分析必须看宏平均 F1(macro-F1),它把每个类别的 F1 等权平均,少数类的表现不会被多数类淹没。
另外建议单独看负面类的召回率。景区运营方最关心的是负面评论有没有被漏掉,漏掉一条负面评论的代价远大于误判一条正面。如果负面召回低于 0.7,说明模型对负面特征学得不够,要么加负面样本,要么调类别权重。
混淆矩阵也要看。三分类里,负面和中性最容易混,因为「还行」「一般」这类词本身就模糊。如果负面大量被预测成中性,考虑把中性类合并或者重新定义标注规则。
4.2 关键超参数的影响与调法
| 参数 | 常用范围 | 影响 | 调整建议 |
|---|---|---|---|
| embed_dim | 64-256 | 表达力与过拟合 | 数据少于 5000 条用 64-128 |
| hidden_dim | 64-256 | 上下文建模能力 | 与 embed_dim 同量级即可 |
| dropout | 0.3-0.5 | 正则化强度 | 验证集 loss 上升就调大 |
| epsilon (FGM) | 0.3-1.0 | 对抗扰动幅度 | 从 0.5 起调,损失震荡就降 |
| lr | 1e-4-3e-3 | 收敛速度 | Adam 配 1e-3,不收敛降到 5e-4 |
| max_len | 32-128 | 序列覆盖度 | 覆盖 95% 样本长度即可 |
调参顺序建议:先固定对抗关闭,把基础 Bi-LSTM + 注意力调到验证集 F1 稳定,再打开 FGM 调 epsilon。这样能分清涨点是对抗带来的还是注意力带来的,答辩时也讲得清楚。
4.3 注意力权重可视化:验证模型有没有学对
注意力权重是这套模型的可解释性来源。把测试样本的 alpha 权重和原词对齐打印出来,能直观看到模型关注了哪些词。
def show_attention(model, text, vocab, device, max_len=64): model.eval() tokens = tokenize(text)[:max_len] ids = [vocab.get(w, 1) for w in tokens] + [0] * (max_len - len(tokens)) x = torch.tensor([ids]).to(device) mask = (x != 0) emb = model.embedding(x) out, _ = model.lstm(emb) u = torch.tanh(model.attn_w(out)) scores = model.attn_u(u).squeeze(-1).masked_fill(~mask, -1e9) alpha = F.softmax(scores, dim=1).squeeze(0).cpu().detach().numpy() for w, a in zip(tokens, alpha[:len(tokens)]): print(f"{w}\t{a:.4f}") show_attention(model, "风景很美但是排队太久了", vocab, device)逻辑说明:这段代码复用了模型前向传播的中间结果,把注意力权重单独取出来。打印后如果「排队」「太久」权重明显高于「风景」,说明模型抓住了负面线索;如果权重均匀分布,说明注意力没学到东西,要检查 mask 是否正确或者注意力层初始化。
参数说明:max_len要和训练时一致,否则位置对不上。可视化只用于验证和答辩展示,不参与训练。
5. 避坑与排查:这套方案最容易翻车的五个地方
5.1 注意力权重全均匀,模型像没加注意力
现象:训练 loss 正常下降,但可视化出来每个词的注意力权重几乎一样,接近 1/T。
原因:最常见的是 mask 没做对,padding 位置参与了 softmax,把权重摊平了。其次是注意力层的初始化让打分输入方差太小,softmax 输出接近均匀。
解决:检查masked_fill是否在 softmax 之前,确认padding_idx=0和 mask 条件一致。如果 mask 没问题,把attn_w的初始化改成 Xavier 或者给打分乘一个温度系数,让分布更尖锐。
5.2 加了对抗训练后验证集指标反而下降
现象:不开 FGM 时验证集 F1 是 0.82,开了之后掉到 0.78,训练 loss 还震荡。
原因:epsilon 太大,扰动把嵌入推到了语义无关的区域,模型在学「怎么对抗噪声」而不是「怎么分类」。另一个可能是对抗损失和原始损失直接相加,权重没调,对抗项主导了梯度。
解决:把 epsilon 从 0.5 降到 0.2 试,或者给对抗损失乘一个 0.5 的系数。观察训练 loss 曲线,如果前几轮就剧烈震荡,基本是 epsilon 过大。对抗训练是锦上添花,不是万能药,基础模型没调好之前不要开。
5.3 负面评论召回率极低,模型偏向预测正面
现象:准确率看着不错,但负面类 F1 只有 0.5,大量负面被预测成正面或中性。
原因:类别不平衡 + 损失函数没加权。模型学到「预测多数类就能降低平均损失」的捷径。
解决:CrossEntropyLoss加weight参数,按类别频率倒数设置。同时检查标注质量,有些「负面」评论其实标注错了。如果加权后负面召回上来了但正面掉太多,说明两类特征确实重叠,考虑做数据增强或者引入外部情感词典特征。
5.4 训练集 loss 降到很低,测试集一塌糊涂
现象:训练 loss 到 0.1,训练集准确率 98%,测试集 F1 只有 0.65。
原因:过拟合。景区评论数据量通常不大,模型参数量相对过剩。也可能是词表从全量数据构建,测试集信息泄漏。
解决:加大 dropout 到 0.5,减小 hidden_dim,加 L2 正则。确认词表只从训练集构建。如果数据量少于 3000 条,考虑冻结 embedding 层或者用预训练词向量初始化。
5.5 换了批数据后模型完全失效
现象:在 A 景区数据上训好的模型,直接用到 B 景区评论上,F1 掉 20 个点。
原因:领域词分布不同。A 景区的「索道」是高频词,B 景区可能主打「古镇」「夜景」,词表覆盖不到。
解决:跨场景使用时,要么在目标场景数据上微调几轮,要么把领域词表做成可配置的,换场景时更新 jieba 词典和词表。对抗训练在这里能缓解一部分,但不能替代领域适配。
6. 进阶技巧:用对抗扰动做数据增强与模型集成
跑通基础版本之后,有两个方向能把效果再往上推一截,而且实现成本不高。
第一个是把 FGM 的扰动思路反过来用——不是加在嵌入上做对抗训练,而是用扰动生成「虚拟样本」做数据增强。具体做法是:对每条训练样本,在嵌入层加一个小扰动后前向传播,把得到的 logits 和原始 logits 做一致性约束(比如 KL 散度),相当于告诉模型「同一个句子的小扰动版本应该预测一致」。这比直接复制样本做增强更符合语义,因为扰动是在连续空间里做的,不会产生语法错误的句子。实现上就是在训练循环里多算一次前向,损失函数加一项KL(logits || logits_adv),权重取 0.3 到 0.5。
第二个是模型集成。注意力 Bi-LSTM 对初始化敏感,不同随机种子训出来的模型在测试集上差异能有 2 到 3 个点。把 3 到 5 个不同种子的模型做软投票(平均 softmax 概率),通常能稳定涨 1 到 2 个点。集成时注意每个模型要用相同的词表和预处理,否则输入对不齐。如果显存不够同时加载多个模型,可以先把每个模型的测试集概率存下来,最后离线平均。
还有一个验证技巧值得养成习惯:把测试集按评论长度分桶,分别看短句(小于 15 字)和长句(大于 40 字)的 F1。景区评论里短句往往是情绪最强烈的,如果短句 F1 明显低于长句,说明模型对短文本的注意力分配有问题,可以针对性调整 max_len 或者对短句单独做 padding 策略。
我自己踩过最深的坑是早期图省事,把词表从全量数据构建,测试集 F1 虚高到 0.9,答辩时被问「测试集里的词训练时见过吗」直接卡住。后来养成习惯,所有预处理步骤先划分再拟合,评估指标才可信。另一个习惯是每次调参只动一个变量,把结果记在表格里,不然改了三四个参数涨了点,根本不知道是哪个起的作用。这套融合对抗加注意力的方案,基础版本一天能跑通,调优到稳定需要反复对比,耐心比技巧重要。希望帮到你。
本文还有配套的精品资源,点击获取