简介:这份资源面向深度学习与自然语言处理方向的本科或研究生,尤其是正在准备情感分析类毕业设计的学生。它提供了一套基于融合对抗训练与注意力机制的Bi-LSTM网络,用于景区评论情感分析的完整Python实现,覆盖从数据标注、word2vec词向量训练、模型训练到未标注语句情感预测与评价分数生成的完整流程。压缩包共18个文件,约191KB,包含4个ipynb交互式笔记、4个py源码文件,以及word2vec词向量、语料、停用词、日志和模型保存等配套数据,config.py负责参数配置,dataSet.py完成数据预处理,tools.py提供序列长度与评价指标计算,model.py给出baseline模型,便于对照改进。目前已有248人学习下载。读者可据此掌握对抗训练与注意力机制在Bi-LSTM中的融合方式,理解情感分类全链路,并直接复用代码与预训练模型完成自己的实验与论文复现。
1. 景区评论情感分析:一条 Bi-LSTM 加注意力与对抗训练的落地路线
做景区评论情感分析,最让人头疼的不是模型结构,而是评论本身太“脏”。一条“风景绝了,就是排队两小时”里同时藏着正面和负面,普通 LSTM 把整句压成一个向量,最后往往被“排队”带偏。我在一个毕设项目里就翻过这个车:验证集准确率卡在 82% 上不去,换了几版词向量都没用。后来把双向 LSTM 叠上注意力机制,再引入对抗训练做扰动,才把这类混合情感样本的 F1 拉起来。这套方案的核心就是标题里的三件事:Bi-LSTM 负责双向上下文,注意力机制负责挑出真正决定情感的词,对抗训练负责让模型在噪声和口语化表达下不崩。它适合做中文短文本情感分类的从业者,尤其是手上有几千到几万条景区、酒店、电商评论,想从零搭一套可复现 pipeline 的人。下面我按“数据怎么进、模型怎么搭、坑怎么躲”的顺序讲透。
2. 数据准备与词向量:把景区评论喂进 Bi-LSTM 之前
2.1 景区评论的脏数据长什么样
景区评论和标准情感分析数据集差别很大。标准数据集像 ChnSentiCorp 大多是规整的书面语,而真实景区评论里混着 emoji、颜文字、拼音缩写、重复标点,还有“yyds”“绝绝子”这类网络热词。我拿到的原始数据大概一万两千条,来自几个点评渠道,字段只有“评论正文”和“星级”两列。星级 4 到 5 星标正面,1 到 2 星标负面,3 星直接丢掉——因为中性样本会稀释边界,毕设阶段没必要硬做三分类。
清洗我一般分四步走:去 HTML 标签和 URL、统一全半角、把连续重复标点压成一个、保留中文和基本标点。注意不要无脑去停用词,情感分析里“不”“没”“太”这些恰恰是决定极性的词,删了等于自断手脚。emoji 我建议映射成文字,比如“😭”转成“大哭”,比直接删掉保留更多情感信号。
import re def clean_text(text): # 去掉 HTML 标签和 URL text = re.sub(r'<[^>]+>', '', text) text = re.sub(r'http[s]?://\S+', '', text) # 全角转半角 text = ''.join([chr(ord(c) - 0xFEE0) if 0xFF01 <= ord(c) <= 0xFF5E else c for c in text]) # 连续重复标点压缩 text = re.sub(r'([!?。,])\1+', r'\1', text) # 只保留中文、数字和常用标点 text = re.sub(r'[^\u4e00-\u9fa50-9!?。,、;:]', '', text) return text.strip()这段逻辑的关键在最后一行正则:\u4e00-\u9fa5是中文区间,把英文和特殊符号过滤掉,减少词表噪声。参数上,重复标点压缩的正则([!?。,])\1+里\1+表示同一标点出现一次以上就合并,避免“太美了!!!”被切成多个 token。
2.2 分词与词表构建的取舍
中文必须分词。jieba 是最稳的选择,但景区名和网络热词容易切错,比如“九寨沟”可能被切成“九寨”“沟”。我的做法是加载一份自定义词典,把景区名、常见网络热词加进去。分词后统计词频,词表大小我一般卡在 8000 到 15000 之间,低于 2 次的词直接映射成<UNK>。序列长度取 95 分位数,景区评论大多在 60 字以内,超过的截断,不足的补<PAD>。
import jieba from collections import Counter jieba.load_userdict("scenic_dict.txt") # 自定义词典:景区名+热词 def build_vocab(texts, max_size=12000, min_freq=2): counter = Counter() for t in texts: counter.update(jieba.lcut(t)) # 按词频排序,保留高频词 vocab = [w for w, c in counter.most_common(max_size) if c >= min_freq] word2id = {w: i + 2 for i, w in enumerate(vocab)} # 0=PAD, 1=UNK word2id['<PAD>'] = 0 word2id['<UNK>'] = 1 return word2idmax_size和min_freq是两个必调参数。词表太大,嵌入矩阵稀疏,训练慢还容易过拟合;太小,很多情感词变<UNK>,模型学不到东西。我一般先跑一版看<UNK>占比,超过 5% 就放宽min_freq。
2.3 词向量:预训练还是从零学
毕设数据量不大,从零学 embedding 效果一般。常见做法是加载腾讯词向量或中文 Word2Vec 预训练权重,把词表里能对上的词初始化进去,对不上的随机初始化。如果拿不到预训练文件,用nn.Embedding从零学也行,但要把embedding_dim调到 200 以上,并在训练时对 embedding 层用较小的学习率。我一般会冻结 embedding 的前几轮,等分类头稳定后再解冻微调,这样收敛更稳。
3. Bi-LSTM 加注意力:模型结构怎么搭才不白费
3.1 为什么是 Bi-LSTM 而不是单向 LSTM
单向 LSTM 只能看到当前词之前的上下文。景区评论里“虽然门票贵,但是景色值”这种转折句,如果只从左往右读,“贵”先入为主,情感判断容易偏负。Bi-LSTM 用两个方向的 LSTM 分别扫一遍,把前向和后向的隐藏状态拼接,每个词就同时拥有左右两侧的信息。实现上就是把bidirectional=True打开,输出维度变成hidden_size * 2。
import torch import torch.nn as nn class BiLSTMEncoder(nn.Module): def __init__(self, vocab_size, embed_dim=200, hidden_size=128, num_layers=2, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM( embed_dim, hidden_size, num_layers=num_layers, bidirectional=True, batch_first=True, dropout=dropout if num_layers > 1 else 0 ) self.dropout = nn.Dropout(dropout) def forward(self, x): # x: [batch, seq_len] emb = self.dropout(self.embedding(x)) out, _ = self.lstm(emb) # out: [batch, seq_len, hidden*2] return outnum_layers=2是常见起点,层数再多在小数据上容易过拟合。dropout=0.3加在 embedding 和 LSTM 层之间,是防止过拟合的第一道闸。注意padding_idx=0必须设,否则<PAD>也会参与梯度更新,污染词向量。
3.2 注意力机制到底在挑什么
Bi-LSTM 输出的是每个时间步的向量,但分类需要句级表示。直接取最后一个隐藏状态会丢掉中间信息,平均池化又会把“不推荐”里的“不”稀释掉。注意力机制的做法是给每个时间步算一个权重,让模型自己决定哪些词重要。公式很简单:用一个小网络对每个时间步打分,softmax 归一化后加权求和。
class Attention(nn.Module): def __init__(self, hidden_dim): super().__init__() self.attn = nn.Linear(hidden_dim, 1, bias=False) def forward(self, lstm_out, mask=None): # lstm_out: [batch, seq_len, hidden*2] scores = self.attn(lstm_out).squeeze(-1) # [batch, seq_len] if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) # PAD位置置负无穷 weights = torch.softmax(scores, dim=-1) context = torch.bmm(weights.unsqueeze(1), lstm_out).squeeze(1) return context, weights这里mask是关键。如果不把<PAD>位置的分数压到负无穷,softmax 会给填充位分权重,句子越短被稀释越严重。bias=False是因为打分只需要方向,不需要偏置。训练完可以把weights打出来看,模型通常会把高权重给“绝了”“差”“排队”这些词,这也是注意力可解释性的来源。
3.3 对抗训练:给 embedding 加扰动的 FGM 实现
对抗训练的思路是在 embedding 上加一个微小扰动,让模型在“最坏情况”下也能分对。FGM(Fast Gradient Method)是最轻量的实现,每步训练先算一次梯度,沿梯度方向给 embedding 加扰动,再用扰动后的 embedding 算一次 loss 反传。它相当于免费的数据增强,对景区评论里的口语化噪声特别有效。
class FGM: def __init__(self, model, epsilon=1.0): self.model = model self.epsilon = epsilon 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.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 = {}epsilon=1.0是扰动幅度,太大模型学不动,太小没效果,我一般从 0.5 试到 1.5。attack只对 embedding 层动手,因为词向量扰动语义上最合理。训练循环里先attack再前向反传,最后restore恢复原始权重,顺序不能乱。
3.4 完整训练循环与参数配置
把上面几块拼起来,训练循环要处理 mask、梯度裁剪和对抗扰动。优化器用 AdamW,学习率 1e-3,weight decay 1e-4。batch size 64,训练 15 到 20 轮,早停看验证集 F1。
def train_epoch(model, loader, optimizer, criterion, fgm, device): model.train() for x, mask, y in loader: x, mask, y = x.to(device), mask.to(device), y.to(device) # 正常前向 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() # 梯度裁剪防爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() optimizer.zero_grad()clip_grad_norm_的max_norm=5.0是 LSTM 类模型的标配,不加的话对抗扰动容易让梯度爆炸。注意optimizer.zero_grad()放在最后,保证两次反传的梯度累积后再更新。
4. 避坑与排查:这套模型最容易翻车的五个地方
4.1 现象:验证集 loss 震荡不降,准确率来回跳
原因通常是学习率太大,或者对抗扰动的epsilon过高,导致每步更新方向被扰动带偏。解决是把学习率降到 5e-4,epsilon从 1.0 降到 0.5,同时把 batch size 提到 128 让梯度更平滑。如果还震荡,检查 mask 有没有正确传到注意力层,PAD 位没屏蔽会让短句子的表示极不稳定。
4.2 现象:训练集准确率 99%,验证集只有 80%
这是典型过拟合。景区评论数据量小、词表大,模型容易记住训练样本。解决分三层:先把dropout从 0.3 提到 0.5,再给 LSTM 加weight_decay,最后考虑冻结 embedding 前 5 轮。如果还不行,说明数据本身分布有问题,检查是不是正负样本比例超过 3:1,必要时对少数类做重采样。
4.3 现象:注意力权重几乎均匀,看不出重点词
原因可能是注意力打分网络的初始化太小,或者训练轮数不够。解决是把nn.Linear的初始化改成 Xavier,训练轮数加到 20 轮以上。另一个常见原因是序列太长,注意力被平均稀释,把最大长度从 128 降到 80 往往能让权重更集中。注意不要为了可解释性强行调温度系数,那会破坏训练稳定性。
4.4 现象:加了对抗训练后训练变慢一倍,效果没提升
FGM 每步多一次前向反传,慢是正常的,但效果没提升说明epsilon和任务不匹配。景区评论的噪声主要是口语化表达,扰动应该加在词向量上而不是所有参数。检查attack里是不是只筛了embedding,如果误伤了 LSTM 权重,模型会学不动。另外确认restore在optimizer.step()之前调用,顺序错了等于没恢复。
4.5 现象:推理时单条预测结果和批量预测不一致
多半是model.eval()没加,dropout 还在生效。另一个隐藏原因是 mask 的构造方式,训练时按 batch 内最大长度补 PAD,推理时如果按固定长度补,注意力权重会变。解决是推理时也按实际长度构造 mask,并确保torch.no_grad()包住前向。如果用了 BatchNorm,还要检查 running stats 是否被小 batch 带偏。
5. 进阶技巧:用注意力权重做错误分析与阈值调优
模型跑通只是起点,真正让毕设出彩的是错误分析。注意力权重是个现成的黑匣子探针:把验证集里分错的样本捞出来,看权重最高的几个词是什么。如果高权重落在“的”“了”这种虚词上,说明模型没学到情感线索,得回头检查分词和词表。如果高权重落在正确的情感词上但分类还是错,那多半是转折句,需要引入句子级或分句级的建模。
具体做法是写一个predict_with_attention函数,返回预测标签和权重,按置信度排序人工看前 50 条错例。
def predict_with_attention(model, text, word2id, device, max_len=80): model.eval() tokens = jieba.lcut(clean_text(text))[:max_len] ids = [word2id.get(w, 1) for w in tokens] mask = [1] * len(ids) # 补齐到 max_len ids += [0] * (max_len - len(ids)) mask += [0] * (max_len - len(mask)) x = torch.tensor([ids]).to(device) m = torch.tensor([mask]).to(device) with torch.no_grad(): logits, weights = model(x, m) pred = logits.argmax(dim=-1).item() # 取实际长度内的权重,和词对齐 w = weights[0][:len(tokens)].cpu().numpy() return pred, list(zip(tokens, w.round(3)))拿到权重后,我一般会做两件事。一是统计错例里高权重词的类型分布,如果负面词频繁出现在正面样本里,说明模型对否定和转折不敏感,可以加一层 Bi-LSTM 做分句编码。二是用验证集扫分类阈值,默认 0.5 不一定最优,景区评论里正面偏多,把阈值提到 0.55 往往能提升负面类的召回。
| 调优手段 | 作用对象 | 典型取值 | 预期收益 |
|---|---|---|---|
| 阈值扫描 | 分类头输出 | 0.45 到 0.6 | 少数类 F1 提升 2 到 4 个点 |
| 分句编码 | 长评论 | 按标点切分 | 转折句准确率提升明显 |
| 注意力温度 | 权重分布 | 0.8 到 1.2 | 可解释性增强,精度影响小 |
| 词表扩充 | 网络热词 | 加 200 到 500 词 | UNK 占比下降,召回提升 |
阈值扫描的代码很简单,把验证集预测概率存下来,遍历阈值算 F1 取最大。分句编码则是把长评论按逗号句号切开,每句过一遍 Bi-LSTM,再用一层注意力聚合句向量,代价是训练时间翻倍,数据量少于五千条时不建议上。
最后说个我自己的习惯:每次改完模型结构,先固定随机种子跑三遍,看 F1 的方差。方差超过 1 个点,说明改动不稳定,宁可回退也不要硬上。景区评论情感分析这套东西,结构不是越复杂越好,Bi-LSTM 加注意力加对抗已经能覆盖大部分场景,剩下的收益在数据和错误分析里。希望帮到你。
本文还有配套的精品资源,点击获取