news 2026/9/23 1:51:46

Bi-LSTM+注意力+对抗训练:景区评论情感分析实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Bi-LSTM+注意力+对抗训练:景区评论情感分析实战

简介:这份资源面向深度学习与自然语言处理方向的本科或研究生,尤其是正在准备情感分析类毕业设计的学生。它提供了一套基于融合对抗训练与注意力机制的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 word2id

max_sizemin_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 out

num_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 权重,模型会学不动。另外确认restoreoptimizer.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 加注意力加对抗已经能覆盖大部分场景,剩下的收益在数据和错误分析里。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/23 1:51:34

3招搞定还原魔方:从入门到精通避坑指南

3招搞定还原魔方:从入门到精通避坑指南 复制来的还原魔方代码跑不通,看着满屏报错却不知从何下手?别慌,这正是无数初学者从 入门到精通 路上必须迈过的一道坎。…

作者头像 李华
网站建设 2026/9/23 1:51:28

3步搞定dc电源线选型,这份速查手册让项目不再翻车

3步搞定dc电源线选型,这份速查手册让项目不再翻车 很多刚入行市政公用工程的兄弟,看着图纸上的DC电源线标识一头雾水,明明查了半天参数,一到现场布线还是频频出错。这种“懂理论却不会落地”的尴尬,我太熟悉了。为了帮大家省下大量试错成本,我整理了这份 dc电源线速查手册…

作者头像 李华
网站建设 2026/9/23 1:51:25

3个核心步骤搞定灰烬攻略,实战项目避坑指南

3个核心步骤搞定灰烬攻略,实战项目避坑指南 版本升级后 API 全变了,手里那个跑得好好的实战项目突然满屏红字报错,这种崩溃感谁懂?很多刚入行的朋友盯着控制台里的 404 和 TypeError ,以为是自己代码写得烂,其实往往是底层机制没吃透。今天咱们就借着 灰烬攻略…

作者头像 李华
网站建设 2026/9/23 1:51:02

指纹传感器底层源码解析:3个避坑点让你看懂原理

指纹传感器底层源码解析:3个避坑点让你看懂原理 刚接手嵌入式项目时,我盯着厂商提供的《指纹传感器用户指南》发了半小时呆。那份文档长达80页,密密麻麻全是寄存器定义和时序图,根本抓不住重点。更崩溃的是,调试时指纹识别率忽高忽低,换电池、擦传感器都没用,最后只能硬啃源码。…

作者头像 李华
网站建设 2026/9/23 1:50:41

基于Hadoop的云盘系统实战:HDFS原理、搭建与Java API实现

简介&#xff1a;基于 Hadoop 的百度云盘项目&#xff0c;附带源代码与文档说明&#xff0c;面向大数据、计算机及相关专业的在校学生、教师和企业学习者&#xff0c;尤其适合毕业设计、课程设计及 Hadoop 入门进阶。项目以百度云盘为业务场景&#xff0c;展示 Hadoop 分布式存…

作者头像 李华