ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

中文短文本情感分析实战:基于PyTorch的LSTM模型搭建与调优指南

中文短文本情感分析实战:基于PyTorch的LSTM模型搭建与调优指南 简介一套基于LSTM的中文短文本情感分析项目源码主要面向需要完成期末大作业、课程设计的高校学生也适合刚接触深度学习文本分类的Python开发者用于练手与拓展。压缩包共14个文件、大小约1.96MB内部包含Python源码脚本、训练与测试文本数据、CSV格式标注数据、Markdown/readme说明文档、PyTorch模型权重文件以及少量缓存和配置辅助文件代码按模块组织覆盖数据加载、中文分词、模型训练、测试评估等完整流程目录结构清晰便于定位和修改。项目已配置好相关依赖与默认参数下载后无需修改即可直接运行能快速产出情感分类结果满足期末高分作业或课程设计的交付要求。已有245人学习下载具备一定参考热度。通过阅读该项目既能掌握LSTM文本分类的完整代码实现也能学习中文语料预处理、数据集划分与模型保存加载等实用技巧对后续开展自然语言处理实训或毕业设计也有直接帮助。1. 中文短文本情感分析大作业为什么选 LSTM 而不是 CNN 或朴素 RNN期末大作业选了中文短文本情感分析身边不少同学第一反应是“用个简单模型赶紧交差”。但实际上用朴素 RNN 在短文本上做情感分类很容易因为梯度消失抓不住“虽然…但是…”这种转折CNN 虽然快又很难捕捉“不”“很”“太”这类词对整句情感方向的改变。LSTM 恰恰是那个能把“谁影响了谁”讲明白的模型无论答辩还是调参都有迹可循。这篇笔记按一条能直接复现的链路写数据准备、模型搭建、训练预测、避坑、答辩演示。核心代码量不大但每一步参数为什么这样设置都会交代清楚。如果你刚入门 Python还在纠结环境怎么装直接装 Anaconda 一步到位后面跟着跑就行。整篇代码基于 PyTorch 实现因为 PyTorch 的 LSTM 对数据形状更透明debug 时能一行一行看到张量变化比黑盒训练舒服得多。2. 数据准备与中文文本预处理从原始语料到模型能吃的张量情感分析的输入是中文句子但 LSTM 吃不了字符串只能吃固定长度的数值张量。这条链路是原始语料 → 清洗 → 分词 → 索引化 → 填充/截断 → 标签数字化 → 张量。每一环出错后面的模型训练结果都会变得不可信甚至变成“玄学”。2.1 数据集选型从哪里拿数据多少样本够期末大作业常见做法是使用公开中文情感分类语料最常用的是谭松波酒店评论语料ChnSentiCorp和微博情感语料网上有大量整理好的版本CSV 或 TSV 格式包含text和label两列1 表示正向0 表示负向。不建议自己写爬虫去采集因为期末大作业考察的核心是“模型能不能学出规律”不是“数据获取能力”。省下时间调模型性价比高得多。样本量方面我一般建议至少 1 万条以上。中文短文本情感分析没有想象中那么吃数据1 万条左右就能把“好”“坏”“但是”“很”这些高频词的组合规律学出来。数据太少LSTM 很容易过拟合到训练集上验证集准确率上不去。如果只能找到 6000 条也能跑但后面要在早停、Dropout、正则化上多花功夫。拿到数据后先看一眼分布import pandas as pd df pd.read_csv(senti_data.csv, encodingutf-8) print(df[label].value_counts()) label 分布 1 5120 0 4880这里要注意encodingutf-8是必填的否则 Windows 系统默认用 GBK 读文件很容易直接乱码。正负样本差距太大比如 9000 条正例、1000 条负例就不要直接开训后面 5.3 会专门讲这个问题。2.2 中文分词与清洗jieba 之外的几个细节中文不像英文天然按空格分词所以要先做分词。最省事的是 jieba精确模式足够用import re import jieba def tokenize(text: str) - list[str]: # 统一小写去标点、数字、emoji text text.lower() text re.sub(r[^\u4e00-\u9fa5a-zA-Z], , text) # 精确模式分词 words jieba.lcut(text) # 停用词表只放最高频虚词不要把否定词放进去 stopwords {的, 了, 是, 我, 你, 他, 很, 就, 都, 也, 啊, 呢} return [w for w in words if w not in stopwords and len(w.strip()) 0] print(tokenize(这家酒店的早餐很好吃但服务态度太差了)) # [这家, 酒店, 早餐, 好吃, 服务态度, 太差]这里有几个细节容易被忽略。re.sub里的正则保留中英文把标点、表情、数字全删掉好处是减少噪声坏处是丢掉了情感强度信息“太好了”和“太好了”在情感浓度上确实不同但从大作业角度看标点带来的收益覆盖不了它引入的维度去掉更省心。停用词表不要贪多只处理最高频的虚词就够了尤其不要把“不”“很”“太”放进去否则“不太好”会被切得只剩“好”模型直接学反。分词还有一个隐藏一致性问题训练和预测必须走同一个tokenize函数。很多同学训练时用jieba.lcut预测时随手写了个list(text)结果预测句子里的词全是UNK出来的结果就跟抽签一样。这个问题后面第 5 章还会再展开。2.3 构建词表与序列填充把中文句子变成 LSTM 能处理的张量分词完成后统计词频保留 top-N 作为词表。常见做法是vocab_size 20000或50000根据语料规模来定。词频太低的词统一映射为UNK同时预留PAD用来做 batch 内句子对齐。from collections import Counter import torch def build_vocab(tokenized_texts, vocab_size20000): counter Counter() for words in tokenized_texts: counter.update(words) # 预留 PAD 和 UNK vocab [PAD, UNK] [w for w, _ in counter.most_common(vocab_size - 2)] word2idx {w: i for i, w in enumerate(vocab)} return word2idx def encode_and_pad(text: str, word2idx: dict, max_len: int 32) - torch.Tensor: tokens tokenize(text)[:max_len] ids [word2idx.get(w, word2idx[UNK]) for w in tokens] if len(ids) max_len: ids [word2idx[PAD]] * (max_len - len(ids)) return torch.tensor(ids, dtypetorch.long)max_len怎么定我一般会先统计数据集的句子长度分布取 95% 分位作为上限。短文本通常 32 就够“这个酒店的早餐很好吃但服务态度太差了”这种带转折的句子20 个词已经能完整表达。max_len设太大会浪费算力设太小会截断关键信息尤其可能把转折的后半句截掉。要注意的是max_len必须在训练前确定后面预测也用它不能训练用 64、预测用 32。2.4 标签编码与数据划分训练集、验证集比例怎么定标签一般是字符串 “pos/neg” 或 “1/0”要转成长整型张量def encode_label(label: str) - int: return 1 if label in {1, pos, positive, 好} else 0数据划分用train_test_split按 8:1:1 分成训练、验证、测试。这里有个很典型的坑不要把原始数据直接split因为公开数据集里常有大量完全相同的句子比如“很好”出现几百次。必须先drop_duplicates()再去划分否则训练集和验证集里存在重复句子验证准确率虚高答辩时换真实数据立刻翻车。from sklearn.model_selection import train_test_split df df.drop_duplicates(subset[text]).reset_index(dropTrue) X_train, X_temp, y_train, y_temp train_test_split( df[text], df[label], test_size0.2, stratifydf[label], random_state42 ) X_val, X_test, y_val, y_test train_test_split( X_temp, y_temp, test_size0.5, stratifyy_temp, random_state42 )stratifydf[label]可以保证划分后正负样本比例和原始数据一致这个参数在样本不均衡时尤其重要。数据准备到这里已经完整了模型直接吃 2.3 节输出的张量形状是(batch_size, max_len)。3. 用 PyTorch 搭建 LSTM 情感分类模型从模型定义到训练闭环把文本变成张量之后接下来的任务就是让模型学会“看懂”这些数字。LSTM 的核心思路是每个词进来后会结合之前记忆的信息更新一个隐状态循环处理完整个句子后最后一个隐藏状态就是整句话的语义浓缩。这一章直接把模型结构和训练代码拆开讲。3.1 模型结构Embedding、LSTM、全连接层各自负责什么模型定义为一个nn.Module整体链路是Embedding - LSTM - 取最后一个时间步 - Dropout - Linear - 输出两个类别的 logit。import torch.nn as nn class LSTMClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim128, hidden_dim128, num_layers2, num_classes2, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM(embedding_dim, hidden_dim, num_layers, batch_firstTrue, dropoutdropout) self.fc nn.Linear(hidden_dim, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): # x 形状: (batch_size, seq_len) emb self.embedding(x) # (batch_size, seq_len, embedding_dim) out, _ self.lstm(emb) # (batch_size, seq_len, hidden_dim) last_hidden out[:, -1, :] # 取最后一个时间步 last_hidden self.dropout(last_hidden) logits self.fc(last_hidden) # (batch_size, num_classes) return logits逐个说参数为什么这么设。padding_idx0非常关键它告诉nn.Embedding层索引 0 对应的PAD位置不参与梯度更新否则填充位会变成噪声源。batch_firstTrue让输入形状变成(batch, seq_len, feature)这样out[:, -1, :]的语义就很直接——取每个样本最后一个时间步的输出。num_layers2能给模型更强的表达能力但数据只有几千条时两层 LSTM 很容易过拟合如果你的数据量不足 8000 条num_layers1的成功率反而更高。dropout0.5在 LSTM 层间和全连接前都起作用是防止过拟合的主力。这里还要说明一个很多人误解的点取最后一个时间步并不是唯一选择。另一种常见做法是对 LSTM 所有时间步的输出做平均池化或最大池化也就是torch.mean(out, dim1)。在短文本任务上平均池化往往更稳定因为它把整句话每个词的输出都用上了而不是只依赖最后那个时间步。我的建议是两种都跑一次哪个验证集准确率高用哪个。3.2 训练循环loss、准确率、学习率和三个必须监控的指标训练前要把数据包成DataLoader。batch_size32~64是一个安全区间太大每个 epoch 的优化步数变少模型学得慢太小梯度噪声大收敛也不稳。训练集 loader 记得开shuffleTrue每个 epoch 都打乱顺序防止模型记顺序。import torch from torch.utils.data import DataLoader, TensorDataset def make_loader(ids_list, labels, batch_size64, shuffleTrue): ids_tensor torch.stack(ids_list) # (N, max_len) labels_tensor torch.tensor(labels, dtypetorch.long) dataset TensorDataset(ids_tensor, labels_tensor) return DataLoader(dataset, batch_sizebatch_size, shuffleshuffle)训练循环是重头戏。定义一个evaluate函数先写出来训练时每个 epoch 结束都要用它输出验证集表现def evaluate(model, loader, criterion, devicecpu): model.eval() total_loss, correct 0.0, 0 with torch.no_grad(): for ids, labels in loader: ids, labels ids.to(device), labels.to(device) logits model(ids) loss criterion(logits, labels) total_loss loss.item() * ids.size(0) preds logits.argmax(dim1) correct (preds labels).sum().item() return total_loss / len(loader.dataset), correct / len(loader.dataset) def train_model(model, train_loader, val_loader, epochs10, lr1e-3, devicecpu): model.to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lrlr, weight_decay1e-5) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, factor0.5, patience2 ) for epoch in range(1, epochs 1): model.train() train_loss, correct 0.0, 0 for ids, labels in train_loader: ids, labels ids.to(device), labels.to(device) optimizer.zero_grad() logits model(ids) loss criterion(logits, labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() train_loss loss.item() * ids.size(0) correct (logits.argmax(dim1) labels).sum().item() train_acc correct / len(train_loader.dataset) val_loss, val_acc evaluate(model, val_loader, criterion, device) scheduler.step(val_acc) print(fEpoch {epoch:02d} | train_loss {train_loss/len(train_loader.dataset):.4f} | ftrain_acc {train_acc:.4f} | val_acc {val_acc:.4f} | flr {optimizer.param_groups[0][lr]:.2e})这段代码里三个点必须讲清楚clip_grad_norm_是 LSTM 训练的标准配置。LSTM 反向传播路径深很容易出现梯度爆炸loss 会突然冲到几万甚至变NaN。把梯度范数裁剪到 5.0 以内能有效预防这种“前一个 epoch 还好好的后一个 epoch 直接毁掉”的情况。ReduceLROnPlateau监控的是验证集准确率连续 2 个 epoch 不涨就把学习率减半。这是比固定学习率靠谱得多的策略因为训练后期用 1e-3 的大步长会在最优解附近震荡减半后能往更细的梯度方向走。weight_decay1e-5是 L2 正则配合 Dropout 一起防过拟合。如果你的验证集准确率明显低于训练集通常就是过拟合优先调大 Dropout 和weight_decay。3.3 从训练到预测为什么推理时也要走同一套 pipeline预测阶段最常见的后悔药就是“训练能跑预测报错”。原因是训练和预测走了两套不一样的文本处理流程。正确做法是把tokenize、encode_and_pad封装成同一个函数保证训练预测调用同一份代码def predict(text: str, model, word2idx, max_len32, devicecpu): model.eval() ids encode_and_pad(text, word2idx, max_len).unsqueeze(0).to(device) with torch.no_grad(): logits model(ids) prob torch.softmax(logits, dim1) pred prob.argmax(dim1).item() return { label: 正面 if pred 1 else 负面, positive_prob: prob[0, 1].item(), negative_prob: prob[0, 0].item(), }unsqueeze(0)把一维序列变成(1, max_len)相当于 batch size 为 1。返回概率而不只返回标签是一个被很多人忽略的加分点——如果正负概率都在 0.5 附近说明模型非常不确定硬归到某一类反而显得不够严谨。答辩时能说出“这个样本太模棱两可模型置信度低”比硬报准确率可信得多。到这里一个最小可用的训练-预测闭环已经成立了。第 4 章讲怎么把准确率往上提。4. 提高分的技巧预训练词向量、早停与预测可解释性很多期末大作业做到第 3 章就在 75% 左右停住了其实再往前一步就能到 85%。关键在于词向量怎么初始化、训练什么时候停、怎么解释模型的判断依据。4.1 预训练词向量的接入方式和收益边界从头训练 Embedding 时模型要同时学“每个词的意思”和“怎么组合词义”数据量小的时候很容易只记住训练集上的表面规律泛化能力不足。预训练词向量的作用是给模型一个语言先验。常见做法是加载腾讯中文词向量或者先用 gensim 在语料上训练一个轻量向量。加载到 PyTorch 模型里的方式如下def load_pretrained_embedding(word2idx, vector_file, embedding_dim200): matrix torch.randn(len(word2idx), embedding_dim) matrix[0] torch.zeros(embedding_dim) # PAD 保持全零 with open(vector_file, r, encodingutf-8) as f: for line in f: parts line.strip().split() word parts[0] if word in word2idx: vec torch.tensor([float(x) for x in parts[1:]], dtypetorch.float) matrix[word2idx[word]] vec return matrix # 加载后覆盖模型 embedding 权重 pretrained load_pretrained_embedding(word2idx, tencent_embedding.txt, embedding_dim200) model.embedding.weight.data.copy_(pretrained)接入之后要注意预训练向量的维度必须和embedding_dim一致。如果你的模型里设的是 128 维但词向量文件是 200 维直接复制会报形状错误。可以把embedding_dim改成 200或者在加载时对向量做 PCA 降维。收益边界也很清楚数据量越大预训练向量的相对收益越小。1 万条以下语料预训练词向量能带来 3 到 5 个百分点的提升10 万条以上效果差距会缩小到可以忽略。4.2 Early Stopping 和学习率调整避免期末答辩时翻车训练时最怕的不是不准而是过拟合。常见现象是训练 loss 不断下降验证集准确率却掉头向下。早停机制能自动在验证集最佳点停下来并把模型权重保存下来best_val_acc 0.0 patience 0 for epoch in range(epochs): # 省略训练循环train_model 中已有 val_acc ... # 验证集准确率 if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), best_model.pt) patience 0 else: patience 1 if patience 3: print(f早停触发连续 3 个 epoch 验证集未提升停在 epoch {epoch}) break之所以要保存最优权重而不是用最后一个 epoch 的权重是因为最后一个 epoch 的模型往往已经过拟合。torch.save(model.state_dict(), ...)只保存参数不保存整个模型结构这样文件小加载时也比较灵活。patience3是一个常用值如果数据量小2 更合适数据量大可以放到 4 或 5。学习率调度已经在第 3 章的ReduceLROnPlateau里做了这里补充一个建议不要在答辩前最后一刻调学习率。固定一套相对稳的超参数组合从训练开始跑到结束比中途反复改学习率要可靠得多。超参数组合参考参数推荐值说明embedding_dim128数据小就 128数据大可试 200hidden_dim128与 embedding_dim 持平num_layers1~2数据少于 8000 条用 1 层dropout0.5过拟合严重可调到 0.6batch_size32~64内存不够就用 32learning_rate1e-3Adam 默认推荐weight_decay1e-5防过拟合的细调项4.3 把模型输出可视化attention 或预测概率的可信解释答辩时被问“模型为什么把这句话判断成负面”如果只能说“因为 LSTM 输出的概率”说服力显然不够。最简单有效的做法是直接跑几个真实样本来演示预测输出samples [ 这家酒店的卫生非常好服务也很贴心, 房间味道大隔音差不会再来了, 位置不错但价格偏贵 ] for s in samples: result predict(s, model, word2idx) print(f{s} - {result[label]} | 正面概率: {result[positive_prob]:.3f})这样展示的好处是你能直观看到模型对“但”类转折句的判断是否符合直觉。更进一步可以打印每个词的隐状态能量变化但这在大作业里不是必需的。一个更专业的做法是定义“不确定”区间正负概率差小于 0.1 时标注为“中性/待人工确认”这个细节比单纯追求准确率高更能打动评委。5. 避坑指南中文情感分析最容易翻车的五个环节以下问题我基本都遇到过每一条按“现象 → 原因 → 解决”写照着检查能省下一个通宵。5.1 中文编码问题训练到一半才发现数据读进来的全是乱码现象print(df.head())时中文变成锘?之类的字符或者tokenize之后一个词都切不出来。原因文件本身是 UTF-8 编码但 Windows 下pd.read_csv默认用 GBK 解码有时候数据是从 Excel 另存出来的编码被改成了 ANSI。解决读文件时显式指定编码先试utf-8报错就试gbktry: df pd.read_csv(senti_data.csv, encodingutf-8) except UnicodeDecodeError: df pd.read_csv(senti_data.csv, encodinggbk)建议拿到数据后第一件事就打印前五行确认中文正常不要等到训练结束才发现数据是乱的。编码问题不解决后面所有分析和训练都是无用功。5.2 停用词表误删否定词模型把“不太好”学成了“好”现象验证集准确率一直上不去尤其是含“不”“没”“太”的句子几乎全判错。原因这里有个很隐蔽的坑——很多人认为停用词就是所有高频虚词于是把“不”“没”“别”这类词也加进了停用词表。结果“不太好”被切成了“太”“好”“不去了”被切成了“去了”情感方向完全颠倒。解决停用词表只放“的、了、是、我、你、他、啊、呢”这类纯粹的功能词把否定词和程度副词全部保留。如果你不确定某个词该不该删就把它留下。LSTM 对这种短文本不缺表达能力缺的是有效输入。5.3 样本不均衡模型全预测成多数类准确率却虚高现象训练准确率 90%但打印混淆矩阵发现负样本几乎全被预测成正样本或者反过来。原因数据集里正负样本比例严重失衡比如 9000 正例、1000 负例。模型只要把所有句子都预测成正类准确率就是 90%但这个模型毫无使用价值。解决优先用stratify分层抽样保证划分时比例一致其次可以考虑对少数类做上采样或者给损失函数加class_weightclass_weights torch.tensor([1.0, 3.0]).to(device) # 负类权重调高 criterion nn.CrossEntropyLoss(weightclass_weights)权重怎么设比如负样本只有正样本的三分之一就把负类权重设为 3.0。训练后看一下混淆矩阵确认少数类不再被忽略。5.4 训练 loss 不降或直接变成 NaN现象loss 一直停在 0.7 附近不动或者几个 epoch 后突然变nan然后整个训练报废。原因学习率太大导致梯度更新越过最优点在陡峭的 loss 曲面上来回震荡或者clip_grad_norm_没加LSTM 反向传播梯度爆炸。还有一种可能是预训练词向量加载后出现了nan大概率是词向量文件里某一行是空行或有非法字符。解决先把学习率降到3e-4再看确认nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0)在optimizer.step()之前调用加载完预训练向量后加一句校验assert torch.isfinite(model.embedding.weight).all(), 词向量中存在 NaN如果断言失败检查词向量文件里是否有空行。5.5 训练集和验证集有重复样本准确率虚高的假象现象验证集准确率 96%但直接把模型用到新句子上一塌糊涂。原因数据清洗时没有去重很多相同的句子同时出现在训练集和验证集里模型训练时已经“见过”验证集的内容评估自然虚高。这个问题在爬虫数据里尤其严重一个帖子被转载几十遍text 字段完全相同。解决划分数据前先drop_duplicates(subset[text])并且清理掉过短的样本比如长度小于 2 个词的句子这类样本通常没有情感倾向还会干扰训练。6. 把大作业变成可演示项目模型打包与命令行交互脚本训练完成、验证集达标之后别急着交代码。把模型打包成一个可交互的命令行工具答辩演示时比 Jupyter Notebook 更有说服力。打包时一起保存模型参数和词表torch.save({ state_dict: model.state_dict(), word2idx: word2idx, max_len: max_len, vocab_size: len(word2idx), }, model_package.pt)把word2idx和max_len一起存而不是只存模型权重这样加载时不需要重新构建词表和分词器减少了非常多的“参数对不上”问题。加载时按顺序恢复def load_model(checkpoint_path, devicecpu): ckpt torch.load(checkpoint_path, map_locationdevice) model LSTMClassifier( vocab_sizeckpt[vocab_size], embedding_dim128, hidden_dim128, num_layers2, num_classes2, dropout0.5, ) model.load_state_dict(ckpt[state_dict]) model.to(device).eval() return model, ckpt[word2idx], ckpt[max_len]然后写一个最简单的交互循环if __name__ __main__: model, word2idx, max_len load_model(model_package.pt) print(输入中文句子输入 q 退出) while True: text input( ) if text.strip().lower() q: break if not text.strip(): continue result predict(text, model, word2idx, max_lenmax_len) print(f判断{result[label]} | 正面概率 {result[positive_prob]:.3f})一个能实时输入、实时判断的命令行工具比一个训练脚本直观得多。答辩时可以直接现场输几句评委的话让模型实时分类效果比任何 PPT 都管用。最后说一点个人习惯我当年做这种大作业时最后悔的是没有把超参数记录成文件。每个版本改了什么参数完全不透明只能靠 git 提交记录里找。现在我习惯在训练脚本里加一个config.json把lr、hidden_dim、max_len、停用词表版本、词向量来源全都存下来。这样改参数有“后悔药”答辩时也能说清楚自己做了几组对比实验而不是拍脑袋定参数。把这个习惯带到大作业里项目完成后你会比大多数人更清楚自己做了什么。希望帮到你。本文还有配套的精品资源点击获取
返回列表