Bi-LSTM + Attention文本分类实战:从原理到PyTorch实现
发布时间:2026/9/28 14:10:47来源:尧图网络
简介这是一份基于Python的深度学习课程作业完整方案以Bi-LSTM与Attention机制为核心面向计算机、人工智能、通信工程等专业学生也适合需要完成课设、毕设或入门序列建模的学习者。项目包含可直接运行的源码、课程论文、答辩PPT、训练好的模型权重与配套数据集并提供数据处理、模型构建、训练评估和结果可视化脚本配置项与运行日志一并打包便于快速复现实验流程。资源压缩包共25个文件以9个py源码、9个txt记录文件、2个pt模型文件为主另有PDF论文、PPT演示和README说明整包仅6.71MB结构清晰、迁移使用方便。目前已有160人浏览或学习代码经测试运行成功适合对照论文与日志逐模块理解双向LSTM加注意力机制在文本分类等任务上的完整实现。1. 一门深度学习课程作业为什么值得选 Bi-LSTM Attention要交一份深度学习课程作业了模型选来选去还是 Bi-LSTM Attention 最稳。它不像纯 CNN 那样忽略序列顺序也不像 BERT 那样需要昂贵的预训练权重代码量在几十行以内一张显卡就能跑动而且架构上足够讲出“为什么有效”。这套组合做文本分类、情感分析这类任务效果通常能超过普通循环神经网络一个档次又能让答辩老师看到你对序列建模的理解。这篇笔记按一套可复现的课程作业方案来写覆盖数据集组织、预处理、模型实现、调参、踩坑和验证适合正在赶课程作业或者想用动手方式吃透深度学习序列模型的人。2. 为什么是 Bi-LSTM Attention双向上下文与关键信息聚焦的互补逻辑2.1 单向 LSTM 的盲区看得到过去看不到未来LSTM 按时间步从左到右读取序列每一步的隐状态只携带此前所有信息。这个设计在语言模型里是合理的因为语言模型本来就不允许看未来但放到文本分类场景就吃亏了。句子“我说的是黄金不是黄铜”里“不是黄铜”四个字出现在后面如果只从左读模型在读到“黄金”时根本没机会知道后面还有个转折在等它。更典型的例子是“这家餐厅味道不错但服务很差”情感极性要先看“不错”又被“但服务很差”整体翻转。单向 LSTM 最后一步的隐状态里“很差”的信息密度会把前面的积极信号压制下去分类器拿到的是一个被末尾词主导的向量。双向 LSTM 的解法是把序列正着读一遍、倒着读一遍每个时间步同时得到前向隐状态 h_f 和后向隐状态 h_b拼接成 h_t [h_f; h_b]。这样每个词在表示自身时已经包含整句上下文句首和句尾信息在每一个时间步都在场而不是等到最后才被压缩成一个向量。在课程作业里讲 Bi-LSTM 的动机这就是最核心的一条它把“读到哪算哪”变成“每个位置都看全上下文”。2.2 Attention 的定位聚焦关键时间步Bi-LSTM 输出的是一个序列长度等于句子长度每个时间步都有 2H 维向量。接下来最常见做法是取最后一步或对所有步取平均但这两种都会把信息压扁——最后一步可能被句尾词主导平均池化又把“决定性词”稀释掉了。Attention 做的事是对每个时间步学一个权重用 softmax 归一化后加权求和让模型自己在训练里学会“哪些词对判断类别贡献大”。具体打分方式有很多课程作业里最常用的是加性注意力的一种简化形式把每个时间步的 h_t 送入一个线性层输出一个标量 score_t再用 softmax 得到权重 α_t最后 context Σα_t·h_t。这种做法的成本只多了一个 nn.Linear(2H, 1)训练量几乎没增加却给模型一个明确的可解释出口——每个词的权重可以直接打印出来。这也是作业答辩时最容易打动人的地方你能指出模型根据哪些词下了结论。对比多头注意力、flash attention 这类更复杂变体在这个场景里完全没有必要。课程作业的评分通常关注三件事架构是否讲得清、训练曲线是否合理、结果是否有分析。加性注意力在 PyTorch 里十几行就能写好行为可预期调参难度低是最贴合课程场景的选择。2.3 组合结构从 token 到分类头的完整数据流把两部分接起来一套标准的课程作业结构是输入 id 序列 → Embedding 层 → Bi-LSTM 双向编码 → Attention 加权 → 全连接分类头。具体来说输入形状为 [B, T]B 是 batch sizeT 是句子定长Embedding 把它变成 [B, T, E]Bi-LSTM 输出 [B, T, 2H]H 是单向隐层维度2 来自双向拼接Attention 在时间维上做加权平均得到 [B, 2H]最后过一个全连接层得到 [B, C] 的 logits。有几个细节值得在写代码前想清楚。第一Attention 施加在 Bi-LSTM 输出之上而不是 embedding 之上因为只有经过上下文编码的词向量才包含位置和邻居信息直接对 embedding 加权等价于对词频加权。第二fc 层的输入维度必须是 hidden_dim * 2很多新手在维度上报错都是漏了这个 2。第三padding 位置要参与 attention 打分但不应参与权重分配这属于主要踩坑点第 5 章会专门处理。第四num_layers 设为 1 通常就够课程作业只要在答辩中说明“加深层数可以提升抽象能力但会加剧过拟合”即可没必要为了显工程量大去叠两层以上。这里还要做一道选择题为什么不直接拿预训练模型。BERT 效果明显更好但对课程作业来说模型内部机制难以从零讲清楚训练和调参代价大且很容易变成“加载别人的权重做一个黑匣子”。Bi-LSTM Attention 的全部参数都在你掌握中损失函数、梯度、权重可视化都可以摊开讲这在评分场景里比“效果好一点”更有价值。这也是为什么这么多年过去这个组合仍然是深度学习课程作业里的常青树。3. 最小可跑工程从公开数据集到训练闭环的完整代码3.1 数据组织数据集选型与文件结构课程作业不推荐自己爬数据时间和质量都不可控。常见做法是选一个公开的文本分类数据集比如 THUCNews 子集、中文酒店评论语料或者 IMDB 数据集的中文翻译版本。我一般会把数据整理成最简单的三文件结构train / val / test每行一列标签一列文本用制表符分隔。格式越简单后面做实验越省心。data/ train.txt val.txt test.txt如果是自采集数据python 爬虫可以做但要保证类别数量均衡、长度分布合理还要人工抽检标注质量性价比往往不如直接采用公开数据集。另外提醒一句答辩时一定要说明数据集规模和类别分布这是文档说明和论文里必须写清楚的部分。3.2 预处理分词、词表、定长编码中文文本需要先分词。写代码前先定三件事padding 索引定为 0未登录词索引定为 1词汇从 2 开始编号max_len 统一截断文本分词后取前 max_len 个 token。下面这段代码把这三件事一次性做完。import jieba from collections import Counter def build_vocab(texts, max_vocab50000): counter Counter() for text in texts: counter.update(jieba.lcut(text)) # 0 给 padding1 给未登录词其余从 2 开始 vocab {pad: 0, unk: 1} for idx, (word, _) in enumerate(counter.most_common(max_vocab - 2)): vocab[word] idx 2 return vocab def encode(text, vocab, max_len64): tokens jieba.lcut(text)[:max_len] ids [vocab.get(w, 1) for w in tokens] # 未登录词映射到 1 if len(ids) max_len: ids [0] * (max_len - len(ids)) # 尾部 padding return idsbuild_vocab 用 Counter 统计全量数据词频取最高频的 max_vocab - 2 个词构成词表预留两个位置给 pad 和 unk。encode 先分词再按词表转 id没见过的词落到 1最后统一补齐到 max_len。两个参数很关键max_vocab 决定词表大小太大模型参数量上涨太小大量词掉进 unkmax_len 决定截断长度中文情感分类里 64 到 128 个词通常够用。调用方式很简单把 train.txt 读进来构造词表把每条文本编码成张量。注意训练和验证必须用同一个词表否则同一句话在两边映射成不同 id验证集等于换了任务。这个细节在课程作业里经常被忽视后面踩坑章节会再说。3.3 模型定义PyTorch 实现 Bi-LSTM Attention模型定义是整套方案的核心我在作业里都用同一个写法Embedding → BiLSTM → 线性打分 Attention → 全连接。下面这个类可以直接放进你的模型文件。import torch import torch.nn as nn class BiLSTMAttention(nn.Module): def __init__(self, vocab_size, embedding_dim100, hidden_dim128, num_classes2, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.bilstm nn.LSTM(embedding_dim, hidden_dim, num_layers1, batch_firstTrue, bidirectionalTrue) self.attn_fc nn.Linear(hidden_dim * 2, 1) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_dim * 2, num_classes) def forward(self, x): emb self.dropout(self.embedding(x)) # [B, T, E] lstm_out, _ self.bilstm(emb) # [B, T, 2H] attn_score self.attn_fc(lstm_out).squeeze(-1) # [B, T] attn_weight torch.softmax(attn_score, dim1) # [B, T] context torch.bmm(attn_weight.unsqueeze(1), lstm_out).squeeze(1) logits self.fc(self.dropout(context)) # [B, C] return logitsforward 里每个维度都标在注释里了。embedding 把 id 序列变成稠密向量batch_firstTrue 让 LSTM 输入输出都是 [B, T, H] 布局bidirectionalTrue 后每个时间步输出是前向和后向的拼接维度自动变成 2H所以 attn_fc 和 fc 的输入维度都写 hidden_dim * 2。attn_score 压缩到 [B, T] 后softmax 沿时间维归一化得到每个 token 的权重torch.bmm 完成加权求和得到整个句子的 context最后过全连接输出类别 logits。参数的默认值是我在课程级任务里常用的起点embedding_dim 100hidden_dim 128dropout 0.5。显存有限的话 hidden_dim 降到 64 也能跑效果略差但代价不大。注意这里还没有对 padding 做 masksoftmax 会给 padding 位置也分配权重这个问题的修复放第 5 章讲。3.4 训练主循环与模型保存训练代码不要写得太花哨。一个标准的 Adam CrossEntropyLoss 循环就够加上验证集上的早停保存最佳模型。下面是一份能直接使用的训练脚本骨架。from torch.utils.data import DataLoader, TensorDataset def evaluate(model, loader, device): model.eval() correct, total 0, 0 with torch.no_grad(): for x, y in loader: x, y x.to(device), y.to(device) logits model(x) preds logits.argmax(dim1) correct (preds y).sum().item() total y.size(0) return correct / total def train_model(model, train_loader, val_loader, epochs30, lr1e-3, devicecuda): model.to(device) optimizer torch.optim.Adam(model.parameters(), lrlr) criterion nn.CrossEntropyLoss() best_acc 0.0 for epoch in range(epochs): model.train() total_loss 0.0 for x, y in train_loader: x, y x.to(device), y.to(device) optimizer.zero_grad() logits model(x) loss criterion(logits, y) loss.backward() optimizer.step() total_loss loss.item() val_acc evaluate(model, val_loader, device) print(fepoch {epoch 1:02d}/{epochs} floss {total_loss / len(train_loader):.4f} fval_acc {val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pt)训练逻辑里有两个点要说明。一是为什么用 val_acc 而不是 val_loss 保存模型课程作业里准确率是评分最直观的指标但如果你面对的是类别不平衡数据后面第 4 章会换成加权指标。二是早停这里只做了“保存最佳模型”如果 val_acc 连续 5 个 epoch 不涨可以在循环里加一个 counter 直接 break能省不少训练时间。把数据切成 DataLoader 的时候batch_size 取 64shuffleTrue训练集顺序打乱对收敛很重要。验证集和测试集不要 shuffle。到这里数据集、模型、训练都齐了跑完 30 个 epoch 你手里就有一份能交的源代码和 best_model.pt。4. 从能跑到跑得动六个必调参数与两个进阶手段4.1 六个必须调平的参数代码能跑不等于能交差。课程作业里最容易被问的就是“你怎么确定这些超参数的”。下面这张表是实际训练中反复试出来的起点范围先说结论再逐个解释。参数推荐范围调小/调大的影响说明lr1e-3 ~ 1e-4调大梯度震荡调小收敛极慢Adam 配 1e-3 起步loss 不降再降到 1e-4batch_size32 ~ 128调大显存高、收敛不稳调小梯度噪声大情感二分类用 64 比较稳hidden_size64 ~ 256调小欠拟合调大过拟合且显存翻倍双向后实际隐层为 2 倍dropout0.3 ~ 0.5调小过拟合调大欠拟合训练时生效eval 时 PyTorch 自动关闭max_len32 ~ 128调小信息被截断调大 padding 过长先看数据集中文本长度分布再定epochs20 ~ 50调小没收敛调大容易过拟合配合早停不要硬跑满lr 是最敏感的参数。课程作业里很多人把学习率设成默认的 1e-2结果 loss 在 0.7 附近震荡怎么都下不去。用 Adam 时 1e-3 起步如果前 5 个 epoch 的 loss 完全不动不是调参问题是数据预处理或词表出了问题优先检查 id 是否全为 0。max_len 的设置要看数据分布我习惯先统计训练集文本长度的 95 分位数再取整。比如九成五的评论在 80 个词以内max_len 就定 80 上下而不是拍脑袋定 128。这个习惯对后面论文里“参数设置”一节很有用。还有一点参数不是孤立调的。dropout 调大后训练 loss 会升高这是预期内的验证集指标不降反升才是目的hidden_size 调大后应该同步把 dropout 调大一点否则过拟合会吃掉容量提升带来的收益。这种联动关系写在实验记录里答辩时非常加分。4.2 处理类别不平衡从加权损失到阈值校准课程作业如果选了真实场景的数据集类别往往不平衡比如好评 95%、差评 5%。模型学到的是“全预测多数类”验证集 acc 可以拿到 95%答辩老师一眼就能看穿问题。最简单的修正方案是给损失函数加类别权重让少数类的错误产生更大梯度。class_weight torch.tensor([1.0, 5.0], devicedevice) criterion nn.CrossEntropyLoss(weightclass_weight)类别权重的数值通常取多数类样本数与少数类样本数的比值或者按 1:5 这种经验值起步观察验证集上的召回率再微调。注意weight 一旦启用训练日志里 loss 的绝对数值会变大不要拿它和没加权的实验直接比大小要比较的是验证集的表现。另一个方案是用 WeightedRandomSampler 在取样本时直接让少数类多出现几次和加权损失作用类似但更直接from torch.utils.data import WeightedRandomSampler weights [class_weight[y] for y in labels] # 每个样本的采样权重 sampler WeightedRandomSampler(weights, num_sampleslen(labels), replacementTrue) loader DataLoader(dataset, batch_size64, samplersampler)加权损失和重采样选一个就够两个一起用反而容易让少数类过拟合。此外还有一个容易被忽视的环节预测时不要只靠 argmax。当少数类概率分布比较平的时候给少数类的 logit 加一个偏移量再做 argmax这个动作叫阈值偏移虽然粗暴但能在不重训的情况下把少数类召回率提上来答辩时提一句“我做了阈值校准”很加分。还有验证指标的问题类别不平衡时别只看 acc要同时看 macro-F1 或者少数类的 recall。很多课程作业的评分细则里明确写了“考核指标包括 F1”提前把 classification_report 打印出来比临时算要稳妥。4.3 checkpoint 的正确打开方式词表要一起存课程作业交稿时最尴尬的情况是模型文件单独拿出去加载时报错词表长度对不上。PyTorch 的 model.state_dict() 里只有张量没有词表如果别人用不同词表加载embedding 矩阵维度直接不匹配。所以保存模型时要把词表一起存进去。torch.save({ model: model.state_dict(), optimizer: optimizer.state_dict(), vocab: vocab, best_acc: best_acc, }, checkpoint.pt) # 加载 ckpt torch.load(checkpoint.pt, map_locationcpu) model BiLSTMAttention(len(ckpt[vocab]), ...) model.load_state_dict(ckpt[model])把 optimizer 状态也存下来是为了中断后能继续训练而不丢失学习率状态。如果你只做推理不需要 optimizer。注意加载时要用 ckpt[vocab] 的长度重建模型不能用代码里写死的 vocab_size。这个习惯在交付“源代码 文档说明 模型”给评阅人时特别重要直接决定对方能不能一键复现你的实验。对应地课程作业的文档说明和论文里建议按数据集、预处理、模型、实验、消融的结构组织和这份工程代码一一对应。5. 避坑记录这五个问题把我从深夜拖到天亮5.1 验证集 loss 一直降acc 纹丝不动现象训练 loss 稳步下降验证集准确率卡在一个较低水平不涨。原因类别不平衡时CrossEntropyLoss 被多数类主导。模型把所有样本预测为多数类loss 依然很低acc 也不会超过多数类占比。另一个常见原因是数据泄漏train 和 val 划分时类别分布不一致。解决先用 sklearn 的 train_test_split 按类别分层划分保证 train 和 val 里正负样本比例接近然后给损失函数加 class_weight或者用 WeightedRandomSampler 重新采样。加完权重后再看验证集acc 会先下降后上升这是正常的模型正在从多数类偏移到少数类。5.2 训练到第 10 轮 loss 突然变成 NaN现象前几轮一切正常loss 稳定下降某轮开始变成 NaN随后所有指标全乱。原因学习率偏大导致梯度爆炸或者数据里出现过长文本导致 LSTM 内部状态溢出。更隐蔽的原因是 padding 位置没有被 maskattention 权重虽然只分配到 padding 上但 LSTM 对 padding 位置仍然在更新隐状态产生无意义的超大梯度。解决把 lr 从 1e-3 降到 1e-4同时给 attention 打分加 mask把 padding 位置的 score 替换成极小值。mask 的写法如下。def forward(self, x, mask): emb self.dropout(self.embedding(x)) lstm_out, _ self.bilstm(emb) attn_score self.attn_fc(lstm_out).squeeze(-1) attn_score attn_score.masked_fill(~mask, -1e9) # mask 是 [B, T] 布尔张量 attn_weight torch.softmax(attn_score, dim1) context torch.bmm(attn_weight.unsqueeze(1), lstm_out).squeeze(1) logits self.fc(self.dropout(context)) return logitsmask 的构建在 Dataset 里完成原始 ids 不等于 0 的位置为 True传给 forward 即可。加了 mask 后padding 位置的注意力权重会无限接近 0加权平均不再被污染。另外如果自己初始化了 LSTM 的 h0 和 c0记得维度是 [2 * num_layers, B, H]漏了那个 2 会直接报维度错误。5.3 预测新句子时报错维度对不上现象训练正常验证正常换一条真实句子走推理forward 报维度错误。原因训练时数据都 padding 到同样的 max_len推理时输入句子长度不一致attention 的 softmax 和全连接层虽然能处理变长但 batch 内多条不同长度文本直接拼接就会失败。解决推理时对单条文本也走一遍 encode强制补齐到同样的 max_len保持 batch 维度为 1。不要为了省事把不同长度的文本塞进同一个 batch情愿写一个循环逐条推理。这个问题在课程作业里出现频率极高本质是训练和推理的数据预处理没有保持完全一致。5.4 bidirectional 的 hidden_size 被忽略了那个 2现象模型定义时 fc 输入维度写了 hidden_dim训练检查维度时报错mat1 和 mat2 维度不匹配。原因bidirectionalTrue 时LSTM 每个时间步的输出是前向和后向拼接的维度是 hidden_dim * 2。很多人按单向 LSTM 的维度写忘了乘 2。解决注意力线性层和分类全连接层的输入都写 hidden_dim * 2。不想每次手动乘 2就在init里定义一个 self.hidden_size hidden_dim * 2所有下游层都用这个变量。打印一次模型结构所有张量 shape 一目了然是排查这类维度问题最快的路径。5.5 attention 权重分布太平均可视化出来一片扁平现象attention 权重打到每个词上都接近均等分布看不出模型关注哪些词。原因原始 score 值域很小比如都在 -0.1 到 0.1 之间softmax 天然输出均匀分布。attention 退化成平均池化注意力机制没有起实际作用。解决在打分后除以 sqrt(d)d 为 LSTM 输出维度这个缩放能把 score 拉开softmax 后的分布更明显。更直接的办法是把 attn_fc 的输出经过 tanh 激活后再归一化。但缩放系数别调过头score 太大反而让 softmax 退化成 one-hot模型变成“只盯一个词”泛化变差。课程实验里一般用 sqrt(hidden_dim * 2) 作为缩放系数效果比较稳。6. 别让准确率骗了你三种验证手段与一个交付习惯准确率是最粗糙的验证指标它不告诉你模型错在哪里。课程作业里我会额外做三件事混淆矩阵、attention 可视化、消融实验。混淆矩阵用 sklearn 一行能算出来from sklearn.metrics import confusion_matrix, classification_report cm confusion_matrix(y_true, y_pred) print(classification_report(y_true, y_pred, target_names[neg, pos]))把报告里的 precision、recall、f1-score 抄进论文比只贴一个 acc 有说服力得多。attention 可视化则是这个项目最有价值的展示抽取一条样本把每个词的注意力权重打印出来你会清清楚楚看到模型在判断“不错”和“太差”时给了哪些词更高的权重。课程答辩里把这两张图放一起胜过十页文字描述。消融实验是最后一道说服力来源把 attention 换成平均池化跑一遍把双向 LSTM 换成单向跑一遍对比同一验证集上的指标。如果 attention 带来的提升很小说明你的数据本身不需要 attention这比强行解释“attention 很有效”更诚实也更容易通过。我自己的习惯是做完模型宁可承认模块在某些场景下无效也要把实验数字如实写进说明文档不玩玄学。这个习惯帮我躲过不少追问也希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网