拓冰建站拓冰建站
首页 / 资讯中心 / 正文

PyTorch情感分类:TextCNN与BiLSTM课设指南

简介面向大三人工智能课程设计的情感分类任务资源基于PyTorch实现完整覆盖CNN、LSTM、GRU、BiLSTM、BiGRU、TC-LSTM、TD-LSTM以及对应的注意力机制变体代码均可直接运行适合NLP方向学生、入门研究者快速进行对比实验也可作为课设答辩的完整方案参考。压缩包内共197个文件大小约113MB以Python源码、编译后的pyc文件及txt文本数据为主另含JSON/XML标注、seg分段、dat词向量、PDF文档、docx实验报告模板等并配套了方面级情感分析ABSA的SemEval-2014任务描述与多个基线的实现涵盖laptop与restaurants两大数据集的预处理和评估工具同时还提供了任务介绍和实验报告Word版本方便理解和复用。已有827人学习目录结构清晰从模型训练到结果分析一应俱全既可以直接复现课程设计中的全部结果也能在此基础上改进模型或调整参数显著减少环境配置和编码调试的时间成本对完成课设或初探情感分类研究都很有帮助。1. 情感分类课设的分数往往不取决于你选了哪个模型做了几年 NLP 相关的工作再回头看课程设计里最常见的“pytorch 情感分类”这个题目会发现一个有点反直觉的结论决定这份课设是 85 分还是 95 分的通常不是你有没有用 BERT而是你对训练集和验证集之间分布差异的处理以及你对训练过程的观测习惯。情感分类Sentiment Classification本质上是文本分类的一个特例输入一段文本输出一个离散的极性标签正面/负面或者更细的星级。在 PyTorch 里做这件事工程路径非常稳定——构建 Dataset、写 collate_fn、定义模型、写训练循环、做评估。它适合作为课设是因为它足够小、足够经典也足够让你在答辩时把“为什么这样设计”讲清楚。这篇文章就顺着这条路径展开从数据集的构造和分词讲起落到用 PyTorch 实现 TextCNN 和 BiLSTM 两个模型的具体代码再到训练过程的观测和调参最后聊几个只有亲手写过才会注意到的细节。无论你是第一次用 PyTorch 做完整的 NLP 任务还是想把手里的课设做得更扎实一些都可以照着这里面的步骤走通。2. 先把数据整理明白从原始文本到 batch 的完整链路2.1 情感分类任务的第一件事定义标签体系情感分类的标签体系决定了你模型输出的维度也决定了损失函数怎么选。最常见的课设场景是二分类正面/负面但如果你用的是电商评论数据集往往会出现“1 星到 5 星”的多级评分这时候一般有两种处理方式直接把 5 个星级当作 5 类做多分类或者把 1-2 星归为负面、4-5 星归为正面做二分类。我一般会建议课设里做三分类负面/中性/正面这样既避免了二分类里中性样本被强行归边的尴尬又比五分类更容易把准确率做得好看。确定标签之后给每个标签分配一个整数索引并固定下来LABEL2ID {负面: 0, 中性: 1, 正面: 2} ID2LABEL {v: k for k, v in LABEL2ID.items()}这里把标签映射写死而不是用程序动态生成是因为训练、验证、推理三个阶段必须使用同一套映射。动态生成的映射一旦遇到数据集中某个标签缺失就会出现训练时有 3 类、推理时只有 2 类的错位。2.2 中文文本的处理分词与词表构建中文情感分类不像英文那样天然有空格分词需要先做分词。最简单可靠的做法是使用 jieba 分词虽然在 2024 年之后的很多工程实践里已经转向更大的预训练语言模型但课设这个场景下 jieba 完全够用而且答辩时能讲清楚它的原理import jieba import json from collections import Counter def tokenize_and_build_vocab(texts: list[str], min_freq: int 2) - dict: counter Counter() for text in texts: tokens jieba.lcut(text) counter.update(tokens) # 保留出现频率不低于 min_freq 的词过滤拼写错误的噪声 token vocab {[PAD]: 0, [UNK]: 1} for token, freq in counter.items(): if freq min_freq: vocab[token] len(vocab) return vocab这段代码里有两个容易被忽略的设计。一是[PAD]必须占索引 0后面在模型里做 masked softmax 或者填充时能直接利用零向量不用额外判断二是min_freq参数用来过滤掉只在某一条样本里出现过的生僻词这些词放入词表只会让 Embedding 层变大却学不到有效的语义信息。2.3 Dataset 与 collate_fn把变长文本变成定长 batchPyTorch 的 DataLoader 在加载变长文本时需要一个自定义的collate_fn来做 padding 和 batch 组装。这是整个数据链路里最关键的一步也是很多课设代码出错的地方import torch from torch.utils.data import Dataset, DataLoader class SentimentDataset(Dataset): def __init__(self, texts, labels, vocab, max_len64): self.texts texts self.labels labels self.vocab vocab self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): tokens jieba.lcut(self.texts[idx]) ids [self.vocab.get(t, self.vocab[[UNK]]) for t in tokens[:self.max_len]] return ids, self.labels[idx] def collate_fn(batch, vocab, max_len64): texts, labels zip(*batch) padded [] lengths [] for ids in texts: lengths.append(len(ids)) if len(ids) max_len: ids ids [vocab[[PAD]]] * (max_len - len(ids)) padded.append(ids) return torch.tensor(padded), torch.tensor(labels), torch.tensor(lengths)注意__getitem__返回的是原始长度的 id 列表真正做 padding 是在collate_fn里。这种延迟填充的做法好处在于单条样本不用存储 padding 后的冗余数据另外每次返回lengths张量是给后面的 BiLSTM 用的——它需要真实的序列长度来做 pack_padded_sequence这一点到模型部分会再讲。3. 用 TextCNN 实现一个高基线模型代码结构与训练细节3.1 为什么课设里 TextCNN 是性价比最高的起点TextCNN 是 Yoon Kim 在 2014 年提出的模型结构核心思路是用多个不同尺寸的卷积核在文本序列上滑动提取 n-gram 级别的局部特征。它和 CNN 在图像上的区别是卷积核只在词向量维度上做一维的宽度滑动高度方向始终覆盖 Embedding 的维度。这意味着每个卷积核学到的是一组相邻词的组合模式例如“非常”“喜欢”这两个词紧挨着出现时卷积核能捕捉到这种词序信息。对于情感分类任务TextCNN 的假设是情感极性的判断主要依赖于若干个关键的局部短语而不是整句的长距离依赖。这个假设在大多数评论类文本上是成立的——比如“味道不错”“送餐很快”“包装破损”都是局部决定整体。这也是为什么 TextCNN 在准确率上能接近甚至超过 BiLSTM而训练速度快一个数量级。3.2 完整的 TextCNN 模型实现import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim128, num_filters100, filter_sizes(3, 4, 5), num_classes3, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) # 三种尺寸的卷积核每种 size 对应 num_filters 个卷积核 self.convs nn.ModuleList([ nn.Conv2d(1, num_filters, (kernel_size, embed_dim)) for kernel_size in filter_sizes ]) self.dropout nn.Dropout(dropout) self.fc nn.Linear(num_filters * len(filter_sizes), num_classes) def forward(self, x, lengthsNone): # x: (batch_size, seq_len) - (batch_size, seq_len, embed_dim) embedded self.embedding(x) # - (batch_size, 1, seq_len, embed_dim)为 Conv2d 增加 channel 维度 embedded embedded.unsqueeze(1) conv_outs [] for conv in self.convs: # conv 输出形状: (batch_size, num_filters, conv_seq_len, 1) conv_out conv(embedded) # 去掉最后一维再对序列长度维度做全局最大池化 conv_out conv_out.squeeze(3) pooled F.max_pool1d(conv_out, conv_out.size(2)).squeeze(2) conv_outs.append(pooled) # 把三种尺寸池化结果在特征维上拼接 combined torch.cat(conv_outs, dim1) logits self.fc(self.dropout(combined)) return logits这里有一个参数值得单独说明filter_sizes(3, 4, 5)对应的是中文里的词窗口大小。3 对应“太差”4 对应“不是很差”5 对应“没有想象中好”——不同尺寸的卷积核分别捕捉不同长度的短语模式。如果你的语料里口语化表达多比如“绝绝子”“yyds”这类网络热词可以加入尺寸 2 的卷积核如果语料偏向长句书面语可以把尺寸放大到 6 或 7。num_filters100是常见的默认配置更大的值如 200能提升一点点准确率但训练时间和显存占用会线性增加。3.3 训练循环的正确写法很多课设代码把训练和验证写在同一个循环里这没有问题但有三个细节经常被忽略每个 epoch 结束后要在验证集上评估并保存最优模型、要把模型切换到 eval 模式、要关闭梯度计算。def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss 0 correct 0 total 0 for texts, labels, lengths in dataloader: texts, labels texts.to(device), labels.to(device) optimizer.zero_grad() logits model(texts) loss criterion(logits, labels) loss.backward() optimizer.step() total_loss loss.item() preds logits.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / len(dataloader), correct / total这里argmax(dim1)直接从 logits 取预测类别的索引而不是在 logits 上先做 softmax——因为 softmax 是单调函数argmax 的结果不变省去一次计算。验证时的评估函数和训练循环结构一致但需要加上model.eval()和torch.no_grad()上下文管理器def evaluate(model, dataloader, criterion, device): model.eval() total_loss 0 correct 0 total 0 with torch.no_grad(): for texts, labels, lengths in dataloader: texts, labels texts.to(device), labels.to(device) logits model(texts) loss criterion(logits, labels) total_loss loss.item() preds logits.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / len(dataloader), correct / total注意验证集在with torch.no_grad()里面跑这个上下文管理器使所有参与运算的张量不记录梯度验证过程的内存占用会大幅下降。如果不加这个batch 数据在验证时同样会构建计算图显存很容易溢出。训练主循环里每个 epoch 结束做一次验证并维护一个最佳准确率的记录best_acc 0.0 for epoch in range(10): train_loss, train_acc train_one_epoch( model, train_loader, optimizer, criterion, device ) val_loss, val_acc evaluate(model, val_loader, criterion, device) print(fEpoch {epoch1}: train_loss{train_loss:.4f}, train_acc{train_acc:.4f}, fval_loss{val_loss:.4f}, val_acc{val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pt)这里把模型参数而不是整个模型对象保存下来文件更小而且加载时只需要重新实例化一个相同结构的模型再load_state_dict。这个习惯在部署场景里几乎是强制要求。4. 升级到 BiLSTM 与注意力机制让模型理解长距离依赖4.1 从 TextCNN 到 BiLSTM 的动机TextCNN 的感受野受限于卷积核尺寸即使多层堆叠也只能扩展有限的上下文。对于“这家店我以前很喜欢但现在服务水平下降了很多不过味道还是不错的”这种带有转折和对比的句子局部 n-gram 特征很难捕捉到“虽然服务水平下降但味道不错”这种跨长度的语义抵消关系。这时就需要 RNN 家族出场——LSTM 通过门控机制逐词扫描序列把每一时刻的历史信息压缩进隐藏状态。双向 LSTM 在此基础上增加了一个反向扫描的路径使得每个位置的输出同时包含左侧和右侧上下文的信息。以“味道还是不错的”为例“不错”这个词需要看到后面的“的”才能确认它修饰的是味道而非服务水平反向 LSTM 提供的未来信息正好解决这个问题。4.2 pack_padded_sequence 的正确使用BiLSTM 处理变长输入时不能直接把 padding 后的序列喂进去。padding 到 64 个 token 的 batch 里实际长度为 12 的样本在剩余 52 个 token 上全是[PAD]直接让 LSTM 扫描这些 pad 位置会把无意义的隐状态混入最终的输出。pack_padded_sequence的作用是压缩掉这些 pad 位置只让 LSTM 扫描真实长度。class BiLSTMAttn(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim128, num_layers2, num_classes3, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_dim, num_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout) self.attention nn.Linear(hidden_dim * 2, 1) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_dim * 2, num_classes) def forward(self, x, lengths): embedded self.embedding(x) # (batch, seq_len, embed_dim) # 按长度降序排列pack 和 unpack 都要求这一点 sorted_lengths, sorted_idx torch.sort(lengths, descendingTrue) embedded_sorted embedded[sorted_idx] packed nn.utils.rnn.pack_padded_sequence( embedded_sorted, sorted_lengths.cpu(), batch_firstTrue ) packed_output, (h_n, c_n) self.lstm(packed) # unpack 回 (batch, seq_len, hidden_dim*2) 的 padding 张量 output, _ nn.utils.rnn.pad_packed_sequence( packed_output, batch_firstTrue ) # 恢复原始 batch 顺序 _, unsorted_idx torch.sort(sorted_idx) output output[unsorted_idx] attn_weights F.softmax(self.attention(output).squeeze(-1), dim1) context torch.bmm(attn_weights.unsqueeze(1), output).squeeze(1) logits self.fc(self.dropout(context)) return logitspack_padded_sequence接受batch_firstTrue这意味着你的输入张量的第 0 维是 batch 而不是序列长度。lengths必须降序排列所以我先做了 sort如果输入本身已经是降序可以跳过排序直接传。在 unpack 之后需要把 batch 顺序恢复成原始输入的顺序否则后续的标签对应关系全是错的。这个恢复操作是我见过踩坑最多的地方——LSTM 的输出本身没有要求但 loss 计算需要 logits 和 labels 一一对应一旦顺序错乱训练 loss 直接发散而且很难排查。这里用了sorted_idx的反向映射unsorted_idx来恢复。4.3 把注意力机制讲清楚代码里的注意力实现是加性注意力的一种简化形式用一个线性层把每一个时间步的隐状态映射成一个标量分数然后对所有时间步做 softmax得到每个位置的重要性权重最后加权求和得到一个句向量。它的直观含义是有些词对情感判断的贡献大“完美”“极差”有些词只是语法成分“的”“了”注意力让模型学会把权重集中到前者上。蒸菜里常见的两种注意力区别是加性注意力和点积注意力后者在 Transformer 里被大规模使用。课设答辩时可以主动对比这两种机制展示自己对注意力的理解程度。5. 课设答辩前必做的三类验证实验5.1 用 100 条数据做过拟合测试这个习惯是从工程实践里带来的用来检验模型的学习能力和实现是否有 bug。取训练集前 100 条样本在单 batch 上反复训练 50 个轮次如果你的模型实现正确准确率应该从随机水平3 分类就是 33%逐步提升到接近 100%。如果在这个小集合上都无法过拟合说明模型结构、学习率或数据链路有 bug。常见的问题包括标签映射写反了、loss 计算时 logits 和 labels 维度不匹配、Embedding 层没有设置padding_idx导致 pad 位置也在学习更新。small_train torch.utils.data.Subset(train_dataset, range(100)) small_loader DataLoader(small_train, batch_size16, shuffleTrue, collate_fnlambda b: collate_fn(b, vocab))训练小集合时建议把shuffleFalse方便逐条审视 Bad Case。过拟合测试通过后再切换到全量数据训练这样能快速区分“模型不对”和“数据不够”两个问题。5.2 对比 TextCNN 和 BiLSTM 的收敛曲线与参数量答辩或报告里如果只有一张准确率表格显得单薄。我把同一个训练集分别用 TextCNN 和 BiLSTM 训练后记录下两个模型的验证准确率曲线并统计参数量模型参数量单 epoch 训练时长秒验证准确率TextCNN (100 filters, 3 sizes)约 130 万120.842BiLSTM (hidden 128, 2 layers)约 260 万310.853BiLSTM Attention约 261 万330.861从表格里能读出的结论是BiLSTM 参数量约为 TextCNN 的两倍准确率提升约 1-2 个百分点但训练时长接近三倍。如果你的课设报告里有这个对比可以在讨论部分给出一个判断对于短视频平台评论区这种短文本TextCNN 可能已经够用对于长评论或社交媒体上的长文BiLSTM 的序列建模优势才会显现出来。用model.parameters()可以计算参数量def count_parameters(model): return sum(p.numel() for p in model.parameters() if p.requires_grad)5.3 用混淆矩阵定位错误模式准确率无法告诉你模型在哪个类别上最容易犯错。我一般在验证集上计算混淆矩阵如果发现“中性”样本大量被误判为“正面”通常的原因有两个训练集中性样本数量偏少类别不平衡或者中性样本的标注本身存在主观性——因为“一般”“还行”这样的词在不同人眼里的倾向并不同。应对方法是调整类别权重在CrossEntropyLoss里传入weight参数class_weights torch.tensor([1.0, 2.0, 1.0]).to(device) criterion nn.CrossEntropyLoss(weightclass_weights)这里的第二个类别权重 2.0 表示把中性样本的损失放大两倍让模型更认真地对待这类样本。在训练集确实类别不平衡时这比手动过采样更省事而且迁移到新数据集时只需要重新统计类别分布。5.4 画 loss 曲线观察学习率是否合适训练时每个 epoch 打印的 loss 值只给了 5-10 个点不够判断训练状态。我看的是每个 batch 后的 loss 滑动平均——用一个队列维护最近 100 步的 lossplt.plot绘出来。如果 loss 曲线出现剧烈震荡且不下降通常是学习率过大如果下降速度接近直线说明学习率偏小可以尝试从 2e-3 提高到 5e-3。Adam 优化器在 NLP 分类任务上默认学习率 1e-3 是一个安全的起点早停的 patience 设置 3 个 epoch 足够。5.5 把最后的推理代码封装成一个独立模块课设答辩时大概率会被要求现场演示“输入一段文本输出情感类别”。如果推理代码和训练代码耦合在一起演示时还需要传入 batch 数据、比较麻烦。我通常单独写一个predict.py风格的封装def predict(model, text, vocab, max_len64, id2labelID2LABEL): model.eval() tokens jieba.lcut(text) ids [vocab.get(t, vocab[[UNK]]) for t in tokens][:max_len] ids ids [vocab[[PAD]]] * (max_len - len(ids)) x torch.tensor([ids]).to(device) with torch.no_grad(): logits model(x) pred_id logits.argmax(dim1).item() return id2label[pred_id]这里的细节是把 padding 补到和训练时一致的max_len64而不是只补到当前文本的长度。模型训练时输入的序列长度全部等于 max_len推理时如果长度不同Conv2d 或 LSTM 的输出张量形状和训练时不一致某些层比如全局池化还能正常工作但有些模型结构会报错。另外torch.no_grad()在推理阶段是必需的它节省显存并提升响应速度——对一个演示场景来说几毫秒和几十毫秒的差距观众感受不到但你的笔记本风扇转速差异会被听出来。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门