资讯动态

微博情感分类实战:LSTM+Attention与BiGRU全链路

发布时间:2026/10/8 11:30:23 来源:尧图企业网站定制
简介一套面向NLP情感分析新人的微博内容情感分类实战源码项目覆盖数据分析、预处理、模型训练到测评的完整链路。压缩包共25个文件其中9个Python脚本承担配置、数据加载、预处理、模型定义与主流程7张图谱清晰展示不同超参数下的准确率与损失曲线另有5个txt数据或说明文件及json词表、bin词向量等配套资源包体大小3.06MB。已有3069人学习下载。资源自带中文停用词表、word2id映射、预训练词向量与训练/测试语料完整实现了LSTMAttention与BiGRU两种模型便于对照理解文本情感分类的关键步骤。结合博主配套文字教程可快速掌握从原始微博文本到可评估模型的全过程是NLP入门的实用参考。1. 拿一份带标注的微博语料把情感分类的完整链路跑通微博文本短、口语重、噪声大天然是 NLP 情感分析最好的练手场。这份源码包把「数据分析 → 预处理 → 词向量 → 模型训练 → 评估」全链路都串好了不是那种只丢一个 train.py 让你自己猜的玩具工程。里面对应了 LSTMAttention 和 BiGRU 两套模型配备了完整的预处理脚本和词向量训练脚本还带了一组不同超参组合的 Loss/Acc 曲线图方便你在训练前就对参数敏感性有个底。适合刚学完深度学习基础、想拿真实中文语料做一次完整情感分类项目的新人也适合需要一份能快速改造成二分类模板的从业者。我拆这份资源时最直接的感受是该有的工程文件一个不少坑也都藏在细节里下面一篇篇说清楚。2. 把微博文本变成模型能吃的输入清洗、分词与词表构建文本进入模型之前要经过三道工序清洗、分词、转 ID。这三步看起来基础实际是决定模型上限的关键。微博文本的特殊性在于有 URL 链接有用户有表情符号有大量网络口语词。如果直接丢给分词器词表会被噪声撑爆模型学到的全是无关模式。2.1 先看数据长的什么样子再决定怎么清洗打开train_org.txt和test_org.txt看原始标注格式常见形式是每行一条样本标签和文本用制表符或空格分隔。标签一般是0和1二分类或1/2/3三分类。label_distrubution.png这张图就是做这一步时生成的——观察标签分布能直接决定你的评估指标选 accuracy 还是 F1。import pandas as pd df pd.read_csv(train_org.txt, sep\t, headerNone, names[label, text]) print(df.head(10)) print(df[label].value_counts())这里的sep\t要对着你打开文件看到的实际分隔符来调如果数据是空格分隔就改成sep 。value_counts()看的是正负样本比例如果比例接近 1:1用 accuracy 做评估没问题如果是 9:1 这种明显不均衡后面训练就要考虑加权损失或在评估时多看 F1。我一般会先跑这一步再开始写预处理避免后面返工。2.2 清洗规则URL、用户、连续重复字preprocess.py里最值得参考的是一套正则替换规则。微博文本里的 URL 和用户名对情感判断没有贡献反而会引入大量 OOV词表外单词统一替换成固定 token 是常见做法。import re def clean_text(text: str) - str: text re.sub(rhttp[s]?://\S, URL, text) # 链接替换成 URL text re.sub(r\w, AT, text) # 用户替换成 AT text re.sub(r#.*?#, , text) # 话题标签删除 text re.sub(r\s, , text) # 空白字符压缩 return text代码里第一行http[s]?匹配 http 和 https 开头的链接\S顺带吃掉链接后的非空白字符第二行\w匹配 后跟单词字符第三行话题标签直接删掉因为话题内容本身可能是噪声第四行把多个空白字符压成一个。这套规则对微博文本足够用替换成URL和AT而不是直接删除是为了保留位置信息——模型能学到「提到链接的微博通常是营销号」这类隐含信号。老手会在此基础上补充 emoji 替换规则新人先照抄这套即可。2.3 分词与停用词jieba 分词后必须做一次词表裁剪中文分词用 jieba 是默认选择包里的中文停用词词表.txt提供了一份网络通用停用词表包含语气助词、标点、常见虚词。注意这份词表直接使用可能误杀部分情感词比如「太好笑了」里的「太」被删掉后「好」和「好笑」仍然保留情感。关键参数是HMM开关——微博新词很多开着 HMM 能识别「绝绝子」「YYDS」这类未登录词。import jieba import jieba.analyse def tokenize(text: str) - list: words jieba.lcut(text, HMMTrue) stopwords set() with open(中文停用词词表.txt, r, encodingutf-8) as f: for line in f: stopwords.add(line.strip()) return [w for w in words if w.strip() and w not in stopwords]jieba.lcut返回分词后的列表HMMTrue开启新词发现第二段读停用词表时注意encodingutf-8Windows 下文件可能是gbk编码读出来是乱码就直接encodinggbk过滤条件里w.strip()去掉空白字符w not in stopwords去掉停用词。这一步做完建议打印几条样本看看分词结果如果发现「吃饭了没」被切成一堆单独的字说明词典里缺词需要往jieba的用户词典里加词。分词质量不检查就往下走后面模型训练时 Loss 会异常难看。3. 词向量训练与 Embedding 加载word2vec 的参数和隐藏坑微博口语词多用通用预训练词向量如腾讯词向量经常匹配不到网络用语。用gensim_word2vec.py在自己的训练语料上重新训练一份词向量是这份资源里最实用的一环。训练好的word2vec.bin要和模型打通这中间有一个非常容易踩的坑gensim 是 numpy 生态PyTorch 是张量生态两者之间需要桥接。3.1 gensim 训练窗口大小和向量维度怎么选from gensim.models import Word2Vec sentences [...] # 上一章分词后的句子列表 model Word2Vec( sentences, vector_size128, window5, min_count2, sg1, workers4, epochs10 ) model.save(word2vec.bin)vector_size128和模型隐藏层维度h128是对应的如果你后面把 LSTM 的 hidden_size 改成 256这里也要改成 256否则from_pretrained时维度不匹配。window5是上下文窗口情感词通常和程度副词、否定词在 5 个词以内共现这个值足够。min_count2表示词频低于 2 的词不训练词向量——微博语料里大量只出现一次的人名、地名训练词向量只会让模型过拟合到噪声上。sg1表示用 skip-gram在小数据集上 skip-gram 比 CBOW 效果好epochs10对微博这种体量足够不用调更大。3.2 词表对齐word2id.json 的构建逻辑word2id.json是在训练词向量之前或之后生成的一张词到 ID 的映射表。核心逻辑是先统计全部语料的词频按频率排序截取前max_vocab_size个词作为词表再为每个词分配一个 ID。这里的 ID 分配顺序会影响后面的 Embedding 矩阵构建。import json word2id {PAD: 0, UNK: 1} for idx, (word, freq) in enumerate(vocab_counter.most_common(50000)): word2id[word] idx 2 with open(word2id.json, w, encodingutf-8) as f: json.dump(word2id, f, ensure_asciiFalse)PAD占 ID 0UNK占 ID 1这两个特殊 token 必须固定排在最前面。idx 2从 2 开始分配词 ID保证特殊 token 不被覆盖。most_common(50000)截断词表大小词表太大模型参数会爆炸太小 OOV 严重。实际跑的时候你会发现词频排名前 1000 的词里有一大半是「真的」「感觉」「今天」这类无情感词但它们作为上下文对情感判断有贡献不能一刀切。3.3 从 word2vec.bin 到 PyTorch Embedding梯度断流的坑这是整个项目里最容易翻车的一步。gensim 加载的词向量是 numpy 数组直接赋值给nn.Embedding的权重后训练时梯度无法回传到 Embedding 层。import torch import torch.nn as nn from gensim.models import Word2Vec w2v_model Word2Vec.load(word2vec.bin) embedding_matrix torch.randn(len(word2id), 128) for word, idx in word2id.items(): if word in w2v_model.wv: embedding_matrix[idx] torch.from_numpy(w2v_model.wv[word]) embedding nn.Embedding.from_pretrained(embedding_matrix, freezeFalse, padding_idx0)from_pretrained是关键 API它把 numpy 矩阵包装成模块参数freezeFalse表示后续训练会微调词向量如果语料够大可以设 True 冻结padding_idx0告诉模型 ID 为 0 的位置不参与梯度更新。这里有个血泪经验如果先nn.Embedding(50002, 128)再.weight.data.copy_(torch.from_numpy(...))虽然权重拷进去了但copy_之后的权重不在计算图里训练时这一层是死的。用from_pretrained就不会有这个问题。跑完这步可以打印embedding.weight.requires_grad确认是 True。4. 模型实现与训练LSTMAttention 和 BiGRU 的对比选型这套源码里有两个模型文件lstm_attention.py和bigru.py。两个模型都用 embedding 层接循环网络的结构区别在于特征抽取方式Attention 机制在长句子上能聚焦关键词BiGRU 在短文本上计算更高效。微博文本平均长度在 50 个词以内两套结构都适用但训练表现有差异。4.1 LSTMAttention注意力权重怎么算lstm_attention.py的结构是 Embedding → BiLSTM → Attention → Linear。注意力机制在这里做的是把 LSTM 每个时间步的输出做一个加权求和权重由当前时间步的隐状态和全局上下文向量共同决定。最简实现是 self-attention——把每个时间步的隐状态过一个线性层算得分softmax 归一化后加权。import torch import torch.nn as nn import torch.nn.functional as F class LSTMAttention(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, num_classes, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM( embedding_dim, hidden_size, batch_firstTrue, bidirectionalTrue, num_layers1 ) self.attn nn.Linear(hidden_size * 2, 1) self.fc nn.Linear(hidden_size * 2, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x, mask): emb self.dropout(self.embedding(x)) outputs, _ self.lstm(emb) scores self.attn(outputs).squeeze(-1) scores scores.masked_fill(mask 0, -1e9) weights F.softmax(scores, dim1) context torch.bmm(weights.unsqueeze(1), outputs).squeeze(1) return self.fc(context)nn.LSTM的batch_firstTrue表示输入形状是(batch, seq_len, embedding_dim)和 PyTorch 默认的(seq_len, batch, embedding_dim)不同忘了加这个参数会得到一堆维度报错。bidirectionalTrue让输出维度翻倍为hidden_size * 2。注意力层attn把每个时间步的隐状态压成标量得分masked_fill(mask 0, -1e9)是核心操作——把 padding 位置的得分设成极小数softmax 之后权重趋近于 0就不会把无意义的 padding 信息加权进来。torch.bmm做批量矩阵乘法weights.unsqueeze(1)的形状是(batch, 1, seq_len)outputs是(batch, seq_len, hidden*2)乘完得到(batch, 1, hidden*2)再 squeeze 掉中间维度。mask 从哪里来在 data_loader 里生成padding 位置为 0其余为 1。4.2 BiGRU双向拼接还是取最后一位bigru.py走的是另一条路不引入注意力直接把双向 GRU 的输出做拼接处理。bigru.cpython-36.pyc这个编译缓存文件说明作者调试时用的是 Python 3.6不影响使用但要注意如果你本机是 Python 3.9这个 pyc 文件可以直接删掉。import torch import torch.nn as nn class BiGRU(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, num_classes, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.gru nn.GRU( embedding_dim, hidden_size, batch_firstTrue, bidirectionalTrue, num_layers1 ) self.fc nn.Linear(hidden_size * 2, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x, mask): emb self.dropout(self.embedding(x)) outputs, h_n self.gru(emb) # 取最后一个有效时间步 lengths mask.sum(dim1) batch_size x.size(0) idx (lengths - 1).unsqueeze(-1).unsqueeze(-1).expand(batch_size, -1, hidden_size * 2) last_states outputs.gather(1, idx).squeeze(1) return self.fc(last_states)lengths mask.sum(dim1)统计每个样本的真实长度gather按索引取出最后一个有效时间步的输出——注意这里用的是outputs而非h_n因为h_n只包含每个方向最后一层的信息而双向 GRU 的h_n拼接顺序和outputs不一致直接用h_n容易取错方向。expand把索引广播到hidden_size * 2维度gather拿到每个样本最后一个有效位置的隐状态形状是(batch, hidden*2)。这种做法在文本分类里效果通常不差且比 Attention 少了注意力层的参数量训练更快。如果你只想跑通流程先跑 BiGRU如果追求精度LSTMAttention 更值得调试。4.3 训练主流程超参配置和动态调整策略configuration.py里集中管理了所有超参。从 images 目录里的文件名可以看出作者跑了多组配置e50_b64_h128_lr0.001_drop0.5_wd0.0005和e100_b64_h128_lr0.0001_drop0.5_wd0.0005等batch size 固定为 64hidden size 固定为 128变化的只有 epoch、学习率和 weight decay。这个设计很合理一次只动一个变量对比曲线才有意义。from configuration import Config from data_loader import load_dataset from lstm_attention import LSTMAttention import torch.optim as optim config Config() train_loader, val_loader load_dataset(config) model LSTMAttention( vocab_sizeconfig.vocab_size, embedding_dim128, hidden_size128, num_classes2, dropout0.5 ) optimizer optim.Adam(model.parameters(), lr0.001, weight_decay0.0005) scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience3 ) criterion nn.CrossEntropyLoss()ReduceLROnPlateau是这套配置里最重要的一行验证集 Loss 连续 3 个 epoch 不下降学习率自动乘 0.5。从曲线图来看lr0.001在 50 个 epoch 内能收敛lr0.0001则需要 100 个 epoch 才够。weight_decay0.0005是 L2 正则配合dropout0.5控制过拟合。训练时要打印每个 epoch 的 train_loss、val_loss、val_acc观察 val_loss 下降曲线是否平滑。如果训练 Loss 下降但验证 Loss 上升说明过拟合需要增大 dropout 或减小隐藏层维度如果两者都不降才是学习率或数据的问题。5. 常见问题排查把训练中遇到的五个坑连根拔起训练情感分类模型的时候会有一些非常典型的症状Loss 不降、验证集震荡、预测结果反向。这些坑每个人都会踩一遍下面按现象、原因、解决三个步骤写清楚。5.1 加载 word2vec 后模型梯度为 None训练时loss.backward()报错提示某个参数的grad为 None或者打印param.requires_grad看到False。原因是在 Embedding 层初始化时用了weight.data.copy_()直接拷贝 numpy 数组拷贝后的权重变成了普通张量脱离了计算图。解决方法是改用nn.Embedding.from_pretrained(embedding_matrix, freezeFalse)或者从word2vec.bin里提取矩阵兜底torch.tensor(w2v_model.wv.vectors)后再from_pretrained。5.2 词表外单词太多了验证集准确率上不去训练集上 Loss 正常下降但验证集准确率卡在 60% 左右上不去。看训练日志发现 UNK 占比极高很多微博口语词和网络新词在词表里不存在。原因有二一是min_count设太高过滤掉了大量低频但真实的情感词二是预处理时没有把 URL 和用户归一化。解决方法是把min_count降到 1 或 2同时检查preprocess.py里的正则替换是否真的执行了——有些代码里写了替换逻辑但调用顺序错了导致替换发生在分词之后OOV 问题依旧。5.3 Loss 训练到一半变成 NaN训练到第 20 个 epoch 左右Loss 突然变成 NaN之后所有参数都变成 NaN彻底报废。原因是学习率太大导致梯度爆炸微博长文本的梯度范数在反向传播过程中被放大超过数值精度范围。解决方法是加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0)放在loss.backward()之后、optimizer.step()之前。另一个备份方案是调低学习率lr0.001改成lr0.0005。从曲线图看作者用lr0.0001时 Loss 曲线平滑无毛刺新人无脑用lr0.0001踩坑概率最小。5.4 验证集 Loss 不断震荡收敛不了验证集 Loss 不是平滑下降而是锯齿状上下跳动训练集 Loss 也在小幅震荡。原因一般是 batch size 太小或学习率衰减策略没接对。微博文本长度差异大如果 batch 里同时出现极短和极长的样本梯度方向会被极端样本主导。解决方法是固定 batch size 为 64并检查scheduler.step()的调用位置——ReduceLROnPlateau需要在每个 epoch 结束后用验证 Loss 调用比如scheduler.step(val_loss)而不是用训练 Loss。另外可以加大patience到 5让学习率衰减更温和。5.5 模型把明显是正面情感的句子预测成负面test_pred.txt里出现大量「太开心了」「终于成功了」被预测为 0负面的结果。先别怀疑模型结构回看数据这类短句往往在训练集里没有出现完全相同的表达分词后 OOV 词多UNK 的 Embedding 向量是随机初始化的模型只能靠上下文瞎猜。另一个原因是正负样本不均衡负样本远多于正样本模型学了先验概率。解决方法是检查label_distrubution.png确认样本分布如果不均衡就用CrossEntropyLoss(weighttorch.tensor([1.0, 2.0]))给正样本更高的权重。6. 用训练好的模型推理新微博从保存参数到单条预测模型训好后不能每次预测都走一遍训练流程。PyTorch 标准做法是把state_dict存下来推理时加载权重然后写一个predict函数处理单条文本。main.py里虽然包含了评估逻辑但针对单条样本的完整推理脚本往往没写全这里补充完整流程。import torch import json import jieba from lstm_attention import LSTMAttention def predict(text: str, model, word2id, max_len64, devicecpu): tokens [w for w in jieba.lcut(text) if w.strip()] ids [word2id.get(w, word2id[UNK]) for w in tokens] if len(ids) max_len: ids ids[:max_len] # 超过 max_len 直接截断 else: ids ids [word2id[PAD]] * (max_len - len(ids)) x torch.tensor([ids], dtypetorch.long, devicedevice) mask (x ! 0).float() model.eval() with torch.no_grad(): logits model(x, mask) prob torch.softmax(logits, dim1) return prob.cpu().numpy()[0] model LSTMAttention( vocab_sizelen(word2id), embedding_dim128, hidden_size128, num_classes2, dropout0.5 ) model.load_state_dict(torch.load(best_model.pth, map_locationcpu)) prob predict(今天项目上线了团队聚餐太开心了, model, word2id) print(prob)model.eval()必须调用否则 dropout 层在推理时仍然随机失活每次预测结果都不一样torch.no_grad()切断梯度计算推理阶段不需要保存计算图省内存也能提速max_len64要和训练时的序列长度保持一致太大浪费算力太小丢失上下文。prob输出的形状是(2,)第一个值是负面概率第二个值是正面概率取np.argmax(prob)得到最终类别。如果你在预测时发现结果和训练时评估结果差异很大检查是否有model.load_state_dict(..., map_locationcpu)——在 GPU 上训练的模型加载到 CPU 推理时不写map_locationcpu会报设备不匹配错误。从那以后我每次做完项目都会先拿五条自己写的句子跑一遍推理确认情感方向和语感一致再交付代码希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑