☰
Bi-LSTM + Attention文本分类实战:从原理到PyTorch实现
2026/9/28 14:10:45 网站建设 项目流程

简介:这是一份基于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 size,T 是句子定长;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_vocab=50000): counter = Counter() for text in texts: counter.update(jieba.lcut(text)) # 0 给 padding,1 给未登录词,其余从 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_len=64): 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 ids

build_vocab 用 Counter 统计全量数据词频,取最高频的 max_vocab - 2 个词构成词表,预留两个位置给 pad 和 unk。encode 先分词,再按词表转 id,没见过的词落到 1,最后统一补齐到 max_len。两个参数很关键:max_vocab 决定词表大小,太大模型参数量上涨,太小大量词掉进 unk;max_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_dim=100, hidden_dim=128, num_classes=2, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.bilstm = nn.LSTM(embedding_dim, hidden_dim, num_layers=1, batch_first=True, bidirectional=True) 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, dim=1) # [B, T] context = torch.bmm(attn_weight.unsqueeze(1), lstm_out).squeeze(1) logits = self.fc(self.dropout(context)) # [B, C] return logits

forward 里每个维度都标在注释里了。embedding 把 id 序列变成稠密向量,batch_first=True 让 LSTM 输入输出都是 [B, T, H] 布局;bidirectional=True 后每个时间步输出是前向和后向的拼接,维度自动变成 2H,所以 attn_fc 和 fc 的输入维度都写 hidden_dim * 2。attn_score 压缩到 [B, T] 后,softmax 沿时间维归一化,得到每个 token 的权重;torch.bmm 完成加权求和,得到整个句子的 context,最后过全连接输出类别 logits。

参数的默认值是我在课程级任务里常用的起点:embedding_dim 100,hidden_dim 128,dropout 0.5。显存有限的话 hidden_dim 降到 64 也能跑,效果略差但代价不大。注意这里还没有对 padding 做 mask,softmax 会给 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(dim=1) correct += (preds == y).sum().item() total += y.size(0) return correct / total def train_model(model, train_loader, val_loader, epochs=30, lr=1e-3, device='cuda'): model.to(device) optimizer = torch.optim.Adam(model.parameters(), lr=lr) 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(f'epoch {epoch + 1:02d}/{epochs} ' f'loss {total_loss / len(train_loader):.4f} ' f'val_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 取 64,shuffle=True,训练集顺序打乱对收敛很重要。验证集和测试集不要 shuffle。到这里,数据集、模型、训练都齐了,跑完 30 个 epoch 你手里就有一份能交的源代码和 best_model.pt。

4. 从能跑到跑得动:六个必调参数与两个进阶手段

4.1 六个必须调平的参数

代码能跑,不等于能交差。课程作业里最容易被问的就是“你怎么确定这些超参数的”。下面这张表是实际训练中反复试出来的起点范围,先说结论,再逐个解释。

参数推荐范围调小/调大的影响说明
lr1e-3 ~ 1e-4调大梯度震荡,调小收敛极慢Adam 配 1e-3 起步,loss 不降再降到 1e-4
batch_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], device=device) criterion = nn.CrossEntropyLoss(weight=class_weight)

类别权重的数值通常取多数类样本数与少数类样本数的比值,或者按 1:5 这种经验值起步,观察验证集上的召回率再微调。注意,weight 一旦启用,训练日志里 loss 的绝对数值会变大,不要拿它和没加权的实验直接比大小,要比较的是验证集的表现。

另一个方案是用 WeightedRandomSampler 在取样本时直接让少数类多出现几次,和加权损失作用类似,但更直接:

from torch.utils.data import WeightedRandomSampler weights = [class_weight[y] for y in labels] # 每个样本的采样权重 sampler = WeightedRandomSampler(weights, num_samples=len(labels), replacement=True) loader = DataLoader(dataset, batch_size=64, sampler=sampler)

加权损失和重采样选一个就够,两个一起用反而容易让少数类过拟合。此外还有一个容易被忽视的环节:预测时不要只靠 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_location='cpu') 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 位置没有被 mask,attention 权重虽然只分配到 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, dim=1) context = torch.bmm(attn_weight.unsqueeze(1), lstm_out).squeeze(1) logits = self.fc(self.dropout(context)) return logits

mask 的构建在 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 维度不匹配。

原因:bidirectional=True 时,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 很有效”更诚实,也更容易通过。我自己的习惯是,做完模型宁可承认模块在某些场景下无效,也要把实验数字如实写进说明文档,不玩玄学。这个习惯帮我躲过不少追问,也希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询