简介:面向自然语言处理情感分析的新人,这是一套微博内容情感分类的完整实战源码,覆盖数据分析、文本预处理、模型训练与效果测评全流程,并配有博主文字教程,能帮助初学者快速打通从数据到模型的实践链路。压缩包共二十五份文件,以脚本文件为主,其中脚本涵盖数据清洗、词表构建、词向量训练、模型训练与评估等环节,辅以可视化图片、停用词表与说明文档、预训练词向量及映射文件,整体仅三兆字节左右,轻量易下载。项目中实现了长短期记忆网络加注意力机制、双向门控循环单元等经典情感分类模型,训练过程中保存了多组超参数下的损失与准确率曲线图,便于对比学习率、隐藏层维度等设置对结果的影响,同时可用于课程设计、毕业设计入门或算法复现。该资源已有三千零七十人学习,是经过验证的新手友好型实战素材。
1. 微博情感分类实战:这份资源把 NLP 入门的四个大坑一次填平
做中文 NLP 情感分析的新人,最先撞上的往往不是模型,而是数据:分词结果乱七八糟、词表里全是低频噪声、训练时 loss 一路横盘、好不容易收敛了换个数据集立刻失效。这份微博情感二分类实战源码,把「数据分析 → 预处理 → word2vec 训练 → 两个模型训练 → 测评」的完整链路一次走通,适合想用深度学习做中文情感分析、又不想从零踩坑的入门者。代码结构清晰,带配置文件和训练图表,照着跑一遍就能建立起对 NLP 项目的整体手感。
2. 数据预处理:从原始微博到 word2id,先过中文分词的坎
2.1 数据集的四份文件与标签分布
项目里有train_org.txt、train.txt、test_org.txt、test.txt四份文本。带org后缀的是原始文本,不带的是预处理后的版本,训练与测试分开存放,避免了标签泄漏的问题。如果你想复现到这个数据集,打开任意一份原文件就能看到每一行的存储格式:前半部分是标签,后半部分是微博正文,两者用分隔符隔开。这种"一行一条样本"的格式是文本分类最常见的数据组织方式,后续做 batch 读取时最省事。
数据处理前第一件事是看标签分布。项目中images/label_distrubution.png就是干这个的,它直接画出了正负样本的比例。做情感二分类时,如果正负样本接近 1:1,可以直接用准确率做主要指标;如果某一类明显偏多,就要考虑用 F1 值,或者在后处理时调整分类阈值。我一般处理微博这类社交文本时会顺手检查一下样本里有没有重复文本、有没有空行——这类脏数据经常让训练集的 loss 表现很好,测试集却莫名其妙地崩。
2.2 停用词表与分词:别把情感词也过滤掉
中文停用词词表.txt是项目自带的过滤底表,preprocess.py会按顺序完成原始文本的清洗、分词和停用词过滤。常见的清洗规则包括:去掉 URL 链接、去掉 @用户昵称、把数字统一换成占位符、过滤掉长度过短的残句。这些看起来琐碎,但对结果影响很大——一条微博里大量出现的无效字符如果原样进入词向量训练,会白白占掉词表的位置。
分词阶段,项目常用的做法是调用结巴这类中文分词工具。跑preprocess.py时要注意一个参数:是否保留停用词表之外的词。停用词的本意是过滤掉「的、了、在」这类无信息量词,但「不要」「不太」「绝了」中的「不」「太」恰恰是情感转折的关键词,如果在停用词表里直接删掉它们,句子的情感极性可能被反转。项目自带的停用词表对新手来说可以先用,但等你换到自己的业务文本时,一定得把「不」「没」「别」这类否定词从停用词表中捞回来。
2.3 用 gensim 训练词向量:word2vec.bin 的选型参数
gensim_word2vec.py负责从预处理后的语料里训练出word2vec.bin。对微博这种短文本,word2vec 的静态词向量依然是性价比最高的选择:不需要 GPU,几分钟就能训练完,而且后续接 LSTM 或 BiGRU 时直接用预训练权重初始化 Embedding 层,收敛速度会明显快于随机初始化。
# 参考词向量训练配置,与项目中的 gensim_word2vec.py 思路一致 from gensim.models import Word2Vec # 语料是 preprocess.py 输出后的分词列表 sentences = [line.strip().split() for line in open('train.txt', encoding='utf-8')] model = Word2Vec( sentences, vector_size=128, # 词向量维度,与模型 hidden_size 匹配 window=5, # 上下文窗口,微博短文本用 5~7 都可以 min_count=2, # 低于 2 次的词直接丢弃,控制词表规模 sg=0, # 0 用 CBOW,1 用 Skip-gram;小语料推荐 CBOW workers=4, # 多线程训练 epochs=10 ) model.save('word2vec.bin')这里最关键的参数是vector_size。项目中模型的hidden_size是 128,词向量维度也设成 128,这样 Embedding 层初始化时可以直接互相映射,不需要额外做维度线性变换。min_count=2是一个适合微博语料的保守值——微博噪声词多,词频太低的词学了也是噪声,不如直接丢弃。sg的选择上,我自己的习惯是语料小于 10 万句用 CBOW,大于 50 万句用 Skip-gram,前者在小语料上更稳,后者对大语料的长尾词更友好。
2.4 word2id 构建与 OOV 兜底策略
word2id.json是训练语料中每个词和数字 ID 的映射表。构建它看起来只是给每个词排个序,但有个细节必须处理:为填充符和未知词预留 ID。常见的做法是0留给<PAD>,1留给<UNK>,其余词从2开始编号。这并不是多余的仪式感——batch 内句子长度不一,padding 的 token 要有一个专门 ID;测试集里出现训练集没见过的词,映射时也要落到<UNK>而不是直接报错。
# 构建 word2id,保留 PAD 和 UNK 的固定位置 UNK_TOKEN, PAD_TOKEN = '<UNK>', '<PAD>' word2id = {PAD_TOKEN: 0, UNK_TOKEN: 1} for line in open('train.txt', encoding='utf-8'): for word in line.strip().split(): if word not in word2id: word2id[word] = len(word2id) # 超过词表上限的词也可映射为 UNK,避免词表过大 max_vocab_size = 50000 word2id = dict(list(word2id.items())[:max_vocab_size]) import json with open('word2id.json', 'w', encoding='utf-8') as f: json.dump(word2id, f, ensure_ascii=False, indent=2)加一个max_vocab_size上限的做法可能和项目代码不完全一致,但值得保留在思路上:微博语料会产出大量只出现一两次的人名、数字串和品牌名,全部收进词表只会拖慢训练。超过上限的词统一映射到<UNK>,效果往往比把一个超大稀疏词表喂进模型更好。
3. 数据加载与超参配置:把 batch、维度、正则项的边界划定
3.1 data_loader.py:padding、shuffle 与 tensor 化的顺序
data_loader.py做的是把word2id.json映射后的序列切成 batch,并补齐到统一长度。很多新人会忽略一个顺序问题:应该先对原始序列做截断或 padding,再转 tensor,最后放进 batch。如果先转 tensor 再 padding,不同长度的 tensor 无法直接堆叠,代码就会在torch.stack处报错。
# 参考 data_loader 的 batch 构造逻辑 import torch from torch.utils.data import Dataset, DataLoader class WeiboSentimentDataset(Dataset): def __init__(self, texts, labels, word2id, max_len=128): self.texts = texts self.labels = labels self.word2id = word2id self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): tokens = self.texts[idx].strip().split() # 截断超长文本,保留前半部分 tokens = tokens[:self.max_len] token_ids = [self.word2id.get(w, self.word2id['<UNK>']) for w in tokens] # 短文本补 PAD,保证 batch 内形状一致 token_ids = token_ids + [self.word2id['<PAD>']] * (self.max_len - len(token_ids)) return torch.tensor(token_ids, dtype=torch.long), torch.tensor(self.labels[idx], dtype=torch.long) # 组合 DataLoader,shuffle 只对训练集开启 loader = DataLoader(dataset, batch_size=64, shuffle=True, num_workers=4)max_len=128对微博正文来说是够用的量级,绝大多数文本在这个长度内已经能表达完整情感。pad操作的本质是制造"假数据",所以后续代码里必须配合torch.nn.utils.rnn.pack_padded_sequence或 attention mask 把 PAD 的位置遮掉,否则模型会把这些无意义位置也当成有效输入来学习。新手常犯的一个错误是只看 input 的形状对不对,忽略了 PAD 位置参与运算导致的噪声——这会让训练曲线非常难看。
3.2 configuration.py:每个超参数是给哪一层用的
项目中的configuration.py把模型相关的参数聚合在一起,文件里能看到epoch、batch_size、hidden_size、lr、dropout、weight_decay这些字段。资源中的训练图表命名直接暴露了默认参数组合,比如e100_b64_h128_lr0.0001_drop0.5_wd0.0005表示训练 100 轮、batch 大小 64、隐藏层维度 128、学习率 0.0001、dropout 0.5、权重衰减 0.0005。把这些参数和代码对应起来,你才知道调参时动的是哪一块。
| 参数名 | 对应模块 | 作用边界 |
|---|---|---|
hidden_size=128 | LSTM / GRU 隐藏维度 | 决定句子表示向量的容量,过大容易过拟合 |
lr=0.0001~0.001 | Adam 优化器 | 0.001 适合起步,loss 震荡时降到 0.0001 |
dropout=0.5 | Embedding 输出后、分类层之前 | 抑制过拟合,0.3~0.5 是文本分类常见区间 |
weight_decay=0.0005 | Adam 的 L2 正则 | 给权重加衰减,间接防过拟合 |
batch_size=64 | DataLoader | 太小 loss 震荡大,太大收敛慢;微博短文本 64 适中 |
weight_decay是一个容易被忽略的参数。对 Adam 优化器来说,如果不设置weight_decay,默认是 0,意味着模型在训练后期几乎是靠 dropout 在孤立地防过拟合。项目中同时启用 dropout 0.5 和 wd 0.0005,属于双保险思路,适合样本量不高的场景。你可以对比一下e50_b64_h128_lr0.001_drop0.5_wd0.0005_Loss.png和e100_b64_h128_lr0.0001_drop0.5_wd0.0005_Loss.png这两张图,前者 lr 高但轮数少,后者 lr 低但轮数多,观察 loss 曲线的下降斜率差异,就能直观感受到 lr 对收敛速度的影响。
3.3 func.py:训练辅助函数里的"后悔药"逻辑
func.py是工具函数集合,其中至少包含两类东西。一类是训练过程中的指标记录函数,把每个 epoch 的 loss 和 acc 记录下来,并调用 matplotlib 画图输出到images/目录;另一类是模型保存与加载逻辑。训练时只看最后一个 epoch 的参数是新手常犯的错误,正确做法是在训练过程中记录验证集上表现最好的那一次参数,训练结束后再加载回来。这种"中途存档、结束后取最优"的做法就是模型的后悔药——不用重跑训练,也能确保你拿到的是整个训练过程中最稳的模型。
# 参考 func.py 中最优模型保存逻辑 def save_best_model(model, optimizer, epoch, valid_acc, best_acc, save_path): if valid_acc > best_acc: best_acc = valid_acc state = { 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'best_acc': best_acc, } torch.save(state, save_path) # 覆盖写同一个文件,避免磁盘囤积 return best_acc保存时把optimizer_state_dict也存下来是值得保留的习惯。如果在之后想从断点继续训练,只有模型权重没有优化器状态,Adam 的动量信息就丢了,恢复训练的初始几步 loss 会出现明显跳动。这个文件和main.py配合的典型流程是:main.py里循环 epoch → 每个 epoch 结束计算验证集指标 → 调用func.py中的保存函数。
4. 模型设计对比:LSTM-Attention 与 BiGRU 的分类能力拆解
4.1 为什么情感分类不能只靠词向量
如果只用 word2vec 把一句话里的词向量取平均,你得到的是一个静态句子表示,词序信息完全丢失。「我不喜欢」和「喜欢不我」会得到几乎相同的向量,分类器很难区分。循环神经网络的作用就是把每个词的上下文信息和词序编码进 hidden state,让最终的句子表示是「动态」的。项目中的两个模型文件都遵循这个思路:词向量负责提供静态语义,循环层负责在上下文中重组语义,最后接全连接层输出二分类概率。
输入到模型的张量形状是[batch_size, seq_len, embedding_dim]。对每个 batch,seq_len 由max_len统一,embedding_dim 与词向量维度一致。这里要强调的是,词向量可以用预训练的冻结参数,也可以在训练中微调。项目里更推荐的做法是让词向量参与训练——微博语言和通用语料差异较大,微调能让 Embedding 学到微博语境下的特殊语义。
4.2 LSTM-Attention 的结构拆解
lstm_attention.py的核心是用注意力机制替代"取最后一个 hidden state"的做法。LSTM 在每个时间步都会产出一个 hidden state,如果只取最后一个,句子前半部分的信息经过多步传递后会弱化。Attention 的思路是给每个时间步的 hidden state 算一个权重,最后把所有 hidden state 按权重加权求和,得到一个携带全句重点信息的上下文向量。
# LSTM-Attention 的核心计算片段,参考项目结构示意 import torch import torch.nn as nn import torch.nn.functional as F class LSTMAttention(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_size=128, num_classes=2, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(embed_dim, hidden_size, batch_first=True, bidirectional=False) # 注意力权重参数,把 hidden_size 映射成标量 self.attn = nn.Linear(hidden_size, 1, bias=False) self.fc = nn.Linear(hidden_size, num_classes) self.dropout = nn.Dropout(dropout) def forward(self, x): embeds = self.dropout(self.embedding(x)) # [batch, seq_len, embed_dim] lstm_out, (h_n, c_n) = self.lstm(embeds) # lstm_out: [batch, seq_len, hidden] attn_scores = self.attn(lstm_out).squeeze(-1) # [batch, seq_len] attn_weights = F.softmax(attn_scores, dim=1) # 在序列维度上归一化 context = torch.bmm(attn_weights.unsqueeze(1), lstm_out).squeeze(1) # 加权求和 logits = self.fc(self.dropout(context)) return logits注意力权重在F.softmax之前可以加上 mask,把 PAD 位置对应的分数替换成一个极小的负数,这样 softmax 之后 PAD 位置的权重趋近于零,不会污染上下文向量。这个细节在 3.1 中提到过,在模型实现里同样重要。训练结束后,你可以把测试集的attn_weights取出来按 token 打印,能直观看到模型把注意力给了哪些词——通常会发现「太」「很」「垃圾」「喜欢」这类强情感词权重最高。这也是 Attention 机制除了提精度之外的另一层价值:给模型的判断提供了一个可解释的参考依据。
4.3 BiGRU 的对称结构与参数量优势
bigru.py里实现的是双向 GRU。GRU 相比 LSTM 少了门控中的 cell state,参数量更小,在小数据集上更不容易过拟合。双向的含义是把文本从前往后和从后往前各跑一遍循环网络,把两个方向的最后一个 hidden state 拼接成一个2 * hidden_size的向量。对微博这种短文本,后文对前文的语义修正同样重要,「电影好看但是票价贵」这种转折句,反向编码能把「贵」的负面信号有效保留下来。
# BiGRU 参考实现,重点关注双向输出的拼接 class BiGRUClassifier(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_size=128, num_classes=2, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.bigru = nn.GRU(embed_dim, hidden_size, batch_first=True, bidirectional=True) self.fc = nn.Linear(hidden_size * 2, num_classes) self.dropout = nn.Dropout(dropout) def forward(self, x): embeds = self.dropout(self.embedding(x)) gru_out, h_n = self.bigru(embeds) # 取最后一个时间步的前向与后向 hidden 拼接 batch_size = x.size(0) h_forward = h_n[-2, :, :] # 前向最后一步 h_backward = h_n[-1, :, :] # 后向最后一步 h_concat = torch.cat((h_forward, h_backward), dim=1) # [batch, hidden*2] logits = self.fc(self.dropout(h_concat)) return logits文本长度超过 30 时,双向 GRU 直接取最后一个 hidden state 的效果往往不如 Attention 加权平均,因为长距离信息在两个方向上传递都会产生损耗。这属于一个可以诚实面对的现象:不是模型越复杂效果越好,而是模型类型和数据长度之间存在匹配关系。项目把两个模型放在一起,本身就提供了对比实验的素材——同一个数据集上跑两份代码,比对自己的具体数据场景做选型判断,比网上的"标准答案"可靠得多。
4.4 选型建议:从训练速度和可解释性两个角度选
根据项目代码给出的默认配置,LSTM-Attention 在参数量上略大于 BiGRU,训练时间也会相应变长。如果训练资源有限,先跑 BiGRU 拿到一个合理的 baseline 分数,再跑 LSTM-Attention 验证是否带来提升——这是文本分类调参的标准流程。反过来,如果你后续要做注意力权重的可视化分析,或者需要在报告里说明模型"看了哪些词",LSTM-Attention 是更合适的选择。注意两份代码都要从pretrained参数处检查一下word2vec.bin是否正确加载到 Embedding 的weight.data——我见过不少复现失败的案例,最后发现是模型根本没读到预训练向量,Embedding 层从头到尾都在随机初始化。
5. 训练避坑指南:Loss 不降、Acc 震荡、过拟合的排查清单
跑通这份源码并不难,但要训练出像images/目录里那样平滑的曲线,有几个坑几乎每个新人都躲不掉。这里列的是我复现同类项目时的真实排障记录,按「现象 → 原因 → 解决」展开。
5.1 训练 loss 下降了,验证集 acc 却越来越差
这个现象几乎可以定义为文本分类的第一道坎。训练集上 loss 一路走低,验证集上 acc 却在一个 epoch 后开始下滑,说明模型已经进入过拟合状态。微博语料本身的噪声多、表达方式碎片化,模型很容易记住训练集中的高频微博句式,却没有学到泛化性的情感模式。解决思路有两条路线:第一,增大dropout到 0.6 并把weight_decay从 0.0005 提到 0.001,增强正则;第二,检查max_len是否过长——对短文本任务,超长的 padding 区域反而给了模型更多记忆噪声的空间。项目中同时存在e50和e100的图表,对比同名参数的两个文件就能看出,e100 版本后半段曲线出现抖动,这是典型的过拟合信号。
5.2 模型训练完,加载保存的模型去预测,效果不如最后一次训练的
这是「保存时机」问题。如果main.py里只在全部 epoch 结束后保存一次,你拿到的是训练末尾的模型;A 同学最常见的改法是每个 epoch 都保存,然后手动挑一个最好的,但这样磁盘会存下几十份权重文件。项目里func.py提供的模式是「验证集表现最好时覆盖保存」,直接把最优状态锁在一个文件中,既省空间又保证效果。你需要检查保存条件是valid_acc > best_acc还是train_acc > best_train_acc——后者会让模型选择过程也过拟合,必须换成验证集指标做判断。
5.3 加载 word2vec.bin 时提示文件格式不兼容
word2vec.bin是 gensim 训练出的二进制模型,gensim 4.x 和旧版本在加载接口上有差异。如果你用高版本 gensim 直接执行Word2Vec.load('word2vec.bin')报错,多半是版本不匹配(项目代码在 Python 3.6 环境下编译,__pycache__目录里的.pyc后缀为cpython-36,说明原作者的运行环境比当前主流版本旧)。解决方式是在加载时用KeyedVectors.load_word2vec_format读取,或者在预处理阶段直接用 gensim 在线训练替代预加载。另外.pyc缓存文件不会自动适配当前 Python 版本,如果 import 报错,把__pycache__整个目录清掉再跑。
# 兼容 gensim 版本差异的加载写法 from gensim.models import KeyedVectors try: model = Word2Vec.load('word2vec.bin') pretrained_vecs = model.wv except TypeError: # 旧格式模型用下面这种方式读取向量部分 pretrained_vecs = KeyedVectors.load('word2vec.bin', mmap='r') embedding_matrix = pretrained_vecs.vectors # 按 word2id 顺序重排后赋给模型5.4 测试集上准确率不差,但新来的微博预测效果很差
这是 OOV 问题的外延。训练语料覆盖有限,新微博里会出现训练词表中不存在的词,它们被统一映射为<UNK>。如果<UNK>在测试集里的占比过高,模型就失去了判断依据。排查方式是统计测试集中 UNK 的占比;如果超过 10%,需要在预处理阶段补充领域词,或者降低min_count到 1 以扩容词表。另一个容易忽略的坑是:在训练前的预处理里,不要把「哈哈哈」「笑死」这类微博高频语气词全部清洗掉——它们在情感分类中是强信号词,过滤掉等于自断一臂。
| 排查点 | 判断方式 | 处理方向 |
|---|---|---|
| 过拟合 | 训练 acc 高但验证 acc 低 | 增大 dropout / wd,缩短 max_len |
| 收敛过慢 | loss 曲线长时间不降 | lr 从 0.0001 提到 0.001,检查是否加载词向量 |
| UNK 过多 | 统计 OOV 比例超 10% | 重训词向量,min_count 下调,扩充领域语料 |
| 训练不稳定 | 同参数多次训练结果方差大 | 固定随机种子,检查 DataLoader shuffle 设置 |
6. 进阶用法:把这份微博情感项目迁移到自己的文本场景
跑通项目之后,你要做的不是停在这里,而是把整套流程移植到自己的场景里。迁移时最重要的判断是「哪些能复用、哪些必须重造」。词向量和停用词表必须基于新语料重新生成,因为不同领域的用词习惯差异非常大;而word2id的构建逻辑、模型结构、训练循环这些代码骨架可以原样保留。比如把目标换成电商评论的情感分析,你只需要修改数据加载路径和标签字段,配置好新的停用词表,重新跑一遍preprocess.py和gensim_word2vec.py,两个模型就能直接训练出适配新任务的分类器。如果你连标注数据都没有,也有一个实用技巧:拿这份微博语料做预训练,让模型先学会「情感语言的基本表达」,再拿几十条人工标注的电商评论做微调,最终效果比从零训练好得多。
超参数迁移也有可参考的方向。项目默认的 lr 0.001 面向的是规模不大的微博语料;如果你迁移到长文本场景,max_len调到 256 或 512,注意力机制带来的收益会更明显。训练时固定随机种子是一个值得养成的习惯——不固定 seed 的话,同一个模型跑两次,acc 可能相差两个百分点,你根本分不清调参是否真的有效。批量跑不同超参组合时,把每次实验对应的 Loss 图和 Acc 图按「参数名 + 时间戳」命名保存,比看一组数字直观得多。项目中的图表命名本身就演示了这个规范:e100_b64_h128_lr0.0001_drop0.5_wd0.0005_Loss.png一目了然,你完全可以沿用这套命名逻辑。作为验证手段,我最后会从测试集里抽 20 条样本,把注意力权重打印出来,看看模型在负面样本上是否真的关注到了「欺骗」「垃圾」「气愤」这类核心词——这个检查每年都帮我抓住过至少一次数据预处理层面的疏漏。从那以后,每次换新数据集我都强制走一遍「分词检查 → OOV 统计 → 最优模型回载 → 注意力可视化」四步流程,这几个动作加在一起,基本锁死了复现项目时的翻车区间。希望帮到你。
本文还有配套的精品资源,点击获取