简介:这份资源是面向计算机、人工智能等专业学生与教师的高分毕业设计参考包,围绕LSTM网络实现中文文本情感分析,解决从数据预处理到模型训练与预测的完整流程问题。包内共8个文件,包含2个txt数据集(积极与消极各约8000条)、1个py源码、1个h5模型、1个pkl词向量文件、1个yml配置、1个md说明及1张png结构图,压缩包约6.62MB,覆盖数据、代码、模型与文档四类核心内容。项目重点处理中文分词与分字方案对比,并基于jieba分词完成训练,代码已测试运行成功,答辩评审平均分达96分。已有381人学习下载,适合需要快速搭建情感分析实验、理解LSTM文本分类流程或作为课设与毕设基础进行二次开发的读者,也可通过私聊获得远程运行指导。
1. 从 16000 条正负样本说起:LSTM 文本情感分析到底能跑出什么效果
电商评论里「东西不错,物流太慢」这种句子,正负情感同时出现,用词典法打分经常判反。基于 LSTM 网络实现文本情感分析,要解决的就是这种带语序、带转折的中文短文本极性判断问题。标题里给出的配置是积极和消极各 8000 条,共 16000 条样本,这个量级对课程设计和毕业设计来说刚好够用——太少模型学不到词序规律,太多单机 CPU 训练又拖时间。整套方案通常包含 Python 源码、文档说明、训练好的模型文件和数据集四部分,适合正在做 NLP 方向毕设、需要一份能跑通、能改、能写进论文的完整工程的人。下面按「数据怎么进、模型怎么搭、参数怎么调、坑在哪」的顺序拆开讲。
2. 数据准备与中文分词:16000 条样本怎么变成 LSTM 能吃的张量
2.1 为什么 LSTM 之前必须先做分词和词表映射
LSTM 的输入是定长或变长的数值序列,不是原始汉字。中文没有天然空格分隔,所以第一步是分词。常见做法是用 jieba 把每条评论切成词序列,再统计词频建词表,把每个词映射成整数 ID。这里有个容易忽略的点:词表大小直接决定嵌入层参数量。16000 条样本如果不去停用词、不设最小词频,词表可能膨胀到两三万,嵌入矩阵会变得又大又稀疏,训练时大部分行根本更新不到。我一般会把最小词频设成 2,低于 2 的词统一映射成<UNK>,词表控制在 8000 到 12000 之间比较稳。
分词之后还要统一序列长度。LSTM 虽然能处理变长序列,但批量训练时一个 batch 内必须等长,所以要么截断要么填充。中文情感分析里评论大多在 20 到 60 字之间,把最大长度设成 100 能覆盖绝大多数样本,超出的截掉尾部,不足的用<PAD>补零。注意<PAD>的 ID 必须是 0,后面嵌入层要设padding_idx=0,否则填充位也会参与梯度更新,白白引入噪声。
2.2 从原始文本到 DataLoader 的完整代码
import jieba import torch from torch.utils.data import Dataset, DataLoader from collections import Counter # 1. 读取正负样本,假设文件每行一条,正样本标 1,负样本标 0 def load_data(pos_path, neg_path): texts, labels = [], [] with open(pos_path, encoding='utf-8') as f: for line in f: line = line.strip() if line: texts.append(line) labels.append(1) with open(neg_path, encoding='utf-8') as f: for line in f: line = line.strip() if line: texts.append(line) labels.append(0) return texts, labels # 2. 分词并统计词频,最小词频 2 def build_vocab(texts, min_freq=2, max_size=12000): counter = Counter() tokenized = [] for text in texts: words = list(jieba.cut(text)) tokenized.append(words) counter.update(words) # 特殊符号固定占前几个 ID vocab = {'<PAD>': 0, '<UNK>': 1} for word, freq in counter.most_common(max_size): if freq >= min_freq and word not in vocab: vocab[word] = len(vocab) return vocab, tokenized # 3. 转 ID 并统一长度 def encode(tokenized, vocab, max_len=100): unk_id = vocab['<UNK>'] encoded = [] for words in tokenized: ids = [vocab.get(w, unk_id) for w in words][:max_len] ids = ids + [0] * (max_len - len(ids)) encoded.append(ids) return encoded class SentimentDataset(Dataset): def __init__(self, encoded, labels): self.encoded = encoded self.labels = labels def __len__(self): return len(self.encoded) def __getitem__(self, idx): return (torch.tensor(self.encoded[idx], dtype=torch.long), torch.tensor(self.labels[idx], dtype=torch.long)) # 组装 texts, labels = load_data('pos.txt', 'neg.txt') vocab, tokenized = build_vocab(texts) encoded = encode(tokenized, vocab) dataset = SentimentDataset(encoded, labels) loader = DataLoader(dataset, batch_size=64, shuffle=True) print('词表大小:', len(vocab), '样本数:', len(dataset))这段代码的逻辑链条是:读文件 → 分词 → 统计词频建词表 → 转 ID 补长度 → 包成 Dataset。参数上,min_freq=2是过滤低频词的门槛,max_size=12000是词表上限,max_len=100是序列长度,batch_size=64是批大小。这几个值不是死的,样本量小的时候可以把max_size降到 8000,显存或内存紧张就把batch_size降到 32。跑完打印词表大小,如果超过 15000,说明停用词没处理干净,可以再加一层停用词过滤。
提示:分词前建议把全角标点、表情符号统一替换掉,否则它们会各自占一个词表位置,稀释有效词频。
3. LSTM 模型搭建:嵌入层、隐藏层、分类头三个必调参数
3.1 为什么用 LSTM 而不是普通 RNN 或 TextCNN
普通 RNN 在长序列上梯度容易消失,评论里「虽然……但是……」这种转折结构,前面的情感词到后面才被否定,普通 RNN 记不住。LSTM 靠输入门、遗忘门、输出门三个机制控制信息流,能保留跨若干词的依赖关系,这正是情感分析里处理转折和程度副词需要的。TextCNN 靠卷积核抓局部 n-gram 特征,速度快,但对语序和长距离依赖的建模弱于 LSTM。毕设场景下选 LSTM,既能体现对序列建模的理解,代码量也可控,论文里好写清楚。
模型结构分三层:嵌入层把词 ID 变成稠密向量,LSTM 层提取序列特征,全连接层做二分类。嵌入维度一般取 128 或 256,隐藏层维度取 128 到 256,层数 1 到 2 层。层数不是越多越好,16000 条样本上堆 3 层 LSTM 很容易过拟合,验证集准确率反而掉。我一般先用单层 128 隐藏单元跑基线,再决定要不要加。
3.2 模型定义与训练循环代码
import torch.nn as nn class LSTMClassifier(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_dim=128, num_layers=1, num_classes=2, dropout=0.3): super().__init__() # padding_idx=0 保证填充位不参与梯度 self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers=num_layers, batch_first=True, bidirectional=False, dropout=dropout if num_layers > 1 else 0) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_dim, num_classes) def forward(self, x): emb = self.embedding(x) # [B, L, E] out, (h, c) = self.lstm(emb) # out: [B, L, H] # 取最后一个时间步的隐藏状态做分类 last = out[:, -1, :] return self.fc(self.dropout(last)) # 训练配置 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LSTMClassifier(vocab_size=len(vocab)).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(10): model.train() total_loss, correct, total = 0, 0, 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() # 梯度裁剪,防止 LSTM 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() correct += (logits.argmax(1) == y).sum().item() total += y.size(0) print(f'epoch {epoch+1} loss {total_loss/len(loader):.4f} acc {correct/total:.4f}')关键参数逐个说:embed_dim=128是词向量维度,样本量 16000 时 128 够用,再大容易过拟合;hidden_dim=128是 LSTM 隐藏状态维度,决定记忆容量;num_layers=1是层数,先单层跑通;dropout=0.3是丢弃率,缓解过拟合;lr=1e-3是 Adam 学习率,这个值在文本分类上比较通用。clip_grad_norm_的max_norm=5.0是梯度裁剪阈值,LSTM 训练时梯度爆炸是常见翻车点,加上这一行能稳很多。
注意:
out[:, -1, :]取的是最后一个时间步,但如果序列尾部全是<PAD>,这个位置取到的其实是填充位的输出。更稳妥的做法是用lengths配合pack_padded_sequence,或者取最后一个非零位置。样本长度差异大时,这个细节会明显影响准确率。
4. 训练过程排查:准确率卡在 50% 上不去的常见问题
4.1 现象、原因、解决三栏对照
| 现象 | 原因 | 解决 |
|---|---|---|
| 训练 loss 不降,准确率在 50% 附近晃 | 标签和文本没对齐,或正负样本读入顺序错乱 | 打印前 5 条样本和标签核对,确认 pos 对应 1、neg 对应 0 |
| 训练准确率 99%,验证集只有 60% | 过拟合,模型背下了训练集 | 加 dropout、减小 hidden_dim、加 L2 正则或早停 |
| loss 变成 NaN | 学习率太大或梯度爆炸 | 学习率降到 1e-4,确认梯度裁剪已加 |
| 验证集准确率一直不涨 | 词表太小,大量词变成<UNK> | 降低 min_freq 或增大 max_size,检查分词是否把词切碎 |
| 预测结果全是同一类 | 类别不平衡或最后一层偏置主导 | 检查正负样本数量,必要时用加权 CrossEntropyLoss |
4.2 验证集划分和早停的实际写法
很多人把 16000 条全拿去训练,然后拿训练集准确率当结果写进论文,这是血泪经验里最常见的翻车点。正确做法是先按 8:1:1 切成训练、验证、测试三份,验证集用来调参和早停,测试集只在最后跑一次。
from sklearn.model_selection import train_test_split # 先切出 10% 测试集,再从剩下的切 10% 验证集 X_train, X_test, y_train, y_test = train_test_split( encoded, labels, test_size=0.1, random_state=42, stratify=labels) X_train, X_val, y_train, y_val = train_test_split( X_train, y_train, test_size=0.1, random_state=42, stratify=y_train) # 早停逻辑 best_val_acc = 0 patience, wait = 3, 0 for epoch in range(30): # ... 训练一个 epoch ... # ... 在验证集上评估 ... val_acc = evaluate(model, X_val, y_val) if val_acc > best_val_acc: best_val_acc = val_acc torch.save(model.state_dict(), 'best_model.pt') wait = 0 else: wait += 1 if wait >= patience: print('早停触发,最佳验证准确率:', best_val_acc) breakstratify=labels保证切分后正负比例一致,random_state=42保证可复现,patience=3是连续 3 轮验证集不提升就停。保存best_model.pt而不是最后一轮的模型,是因为最后一轮往往已经过拟合。这套流程跑下来,16000 条样本上验证集准确率通常能到 85% 到 92%,具体取决于数据质量和分词效果。
5. 推理与部署:把训练好的模型接成能用的预测函数
5.1 单条文本预测的完整封装
训练完模型只是半成品,毕设答辩时老师大概率会让你现场输入一句话看结果。所以需要一个干净的预测函数,把分词、转 ID、补长度、前向计算串起来。
def predict(text, model, vocab, max_len=100, device='cpu'): model.eval() words = list(jieba.cut(text)) ids = [vocab.get(w, vocab['<UNK>']) for w in words][:max_len] ids = ids + [0] * (max_len - len(ids)) tensor = torch.tensor([ids], dtype=torch.long).to(device) with torch.no_grad(): logits = model(tensor) prob = torch.softmax(logits, dim=1) pred = logits.argmax(1).item() label = '积极' if pred == 1 else '消极' return label, prob[0][pred].item() # 加载模型 model = LSTMClassifier(vocab_size=len(vocab)).to(device) model.load_state_dict(torch.load('best_model.pt', map_location=device)) print(predict('质量很好,下次还来', model, vocab, device=device)) print(predict('发货太慢了,包装也破了', model, vocab, device=device))model.eval()关掉 dropout,torch.no_grad()省显存,softmax把 logits 转成概率。返回标签和置信度两个值,置信度低于 0.6 的样本可以标记为「不确定」,实际用的时候能减少误判。
5.2 模型保存与加载的两种方式对比
| 方式 | 代码 | 适用场景 |
|---|---|---|
| 只存参数 | torch.save(model.state_dict(), 'best_model.pt') | 推荐,文件小,加载时需重建模型结构 |
| 存整个模型 | torch.save(model, 'full_model.pt') | 方便但依赖类定义路径,换目录容易报错 |
我一般用第一种,配合一份config.json记录vocab_size、embed_dim、hidden_dim这些结构参数,加载时先读配置再建模型,避免结构对不上。
6. 让准确率再上一个台阶:三个我实际用过的调优技巧
第一个技巧是双向 LSTM。把bidirectional=True打开,前向和后向各跑一遍 LSTM,把两个方向的最后隐藏状态拼接起来再分类。中文里「虽然贵但好用」这种句子,后向扫描能更早看到「好用」,对转折句判断更准。代价是参数量翻倍,训练时间增加约 60%,16000 条样本上单 epoch 从 20 秒涨到 35 秒左右,但验证集准确率通常能提 2 到 4 个百分点。改的时候注意全连接层输入维度要变成hidden_dim * 2。
第二个技巧是冻结嵌入层。如果不想自己训词向量,可以加载预训练的中文词向量,把embedding.weight初始化成预训练值,然后设requires_grad=False冻结。这样小样本上收敛更快,也不容易过拟合。但要注意预训练词表的词和你的词表要对齐,对不上的词还是用随机初始化。
第三个技巧是学习率预热加衰减。前 2 个 epoch 用1e-4预热,之后每 3 个 epoch 乘 0.5。LSTM 刚开始训练时梯度不稳定,大学习率容易把嵌入层带偏,预热能让它先稳住再加速。配合早停一起用,基本不用手动调太多轮。
# 双向 LSTM 分类头改动 self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers=num_layers, batch_first=True, bidirectional=True) self.fc = nn.Linear(hidden_dim * 2, num_classes) # 学习率调度 scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=3, gamma=0.5) # 每个 epoch 训练完后调用 scheduler.step()最后说个习惯:每次改完参数,我都会把验证集准确率、训练时间、模型文件大小记在一个表格里,跑够五六组再决定用哪套配置。凭感觉调参是玄学,有记录才能复现。这套 LSTM 情感分析方案从数据到推理的链路不算长,但每一步的参数都有讲究,把上面这些细节吃透,毕设里该有的工作量和技术深度就都有了。希望帮到你。
本文还有配套的精品资源,点击获取