☰
Bi-LSTM+Attention文本分类实战:原理、代码与调参指南
2026/10/7 1:22:02 网站建设 项目流程

简介:基于Python的深度学习课程作业完整项目,聚焦Bi-LSTM与Attention机制,面向计算机、人工智能等专业在校生及入门者,可用于课程设计、毕业设计或项目初期演示。资源压缩包共25个文件,体积仅6.71MB,包含9个Python源码文件、2个预训练模型权重、9个文本记录文件,以及论文PDF、项目PPT和README说明,覆盖从数据加载、模型搭建、训练评估到结果可视化的完整流程,并提供问题分类和AG新闻两组实验数据。项目代码经测试运行成功,答辩评审平均分达96分,附有训练与验证的损失、准确率日志,便于对照实验结果;源码模块划分清晰,配置文件与主程序分离,适合在此基础上修改参数或扩展功能。已有160人学习下载,下载后若遇运行问题,可联系作者获取远程教学支持。整体而言,是一套结构清晰、可直接运行的深度学习课设参考资源。

1. 基于 python 的深度学习课程作业:Bi-LSTM + Attention 为什么是稳妥选择

Bi-LSTM + Attention 是深度学习课程作业里出现频率最高、也最容易跑出稳定效果的组合。简单说,Bi-LSTM 负责把一句话的正反两个方向都读一遍,Attention 再从中挑出真正影响分类结果的词——对情感分类、意图识别、文本分类这类课程作业来说,这套结构既不会像 Transformer 那样吃显存,又能在准确率上明显压过纯 Bi-LSTM。这个资源包的价值在于给出一条完整路径:从源代码、文档说明、数据集、PPT、论文到训练好的模型文件,六类材料拼在一起才是能直接上交的作业。适合两类人:一是第一次碰深度学习课程作业、需要快速跑通并理解每一行代码的学生;二是想用一份成熟结构改改数据集就复用的入门工程师。

2. Bi-LSTM + Attention 的原理拆解:双向编码与注意力权重的分工

2.1 为什么先过双向 LSTM:单向模型丢掉了后文信息

文本分类场景里,一个词对分类结果的贡献常常由它后面的词决定。以情感分析里最经典的例子来说,“这部电影并不好看”中的“好看”是正面词,但前面的“并不”把它变成了负面表达。如果只用一个正向 LSTM 从前往后读,“好看”到达整个序列输出时确实会被“并不”影响,但在处理序列中每个位置独立表示时,正向隐状态只包含前文信息——对于“好看”这个位置,正向状态里根本没有“并不”的痕迹,这就是单向模型的结构性缺陷。

Bi-LSTM 的应对思路很直接:并行运行两个方向相反的 LSTM。一个按正序读取文本,产生正向隐状态序列;另一个按逆序读取,产生反向隐状态序列。在时刻 t,把两个方向的隐状态拼接起来,得到 h_t = [h_t^f; h_t^b],这样每个位置的表示同时携带了左侧上下文和右侧上下文的信息,语义完整度明显提升。课程作业里最常见的拼接方式是 concat,如果你看到有人在代码里写torch.cat((h_f, h_b), dim=-1),那就是这个操作。除了拼接,也有 sum 和 mean 两种压缩方式,但拼接保留的信息最完整,后续接 attention 时也最好调试。

有一个新手必经的坑:双向 LSTM 的隐层维度会翻倍。如果你设置hidden_size=128,模型实际输出的每个时间步特征是 256 维。很多人在写线性层时漏掉这个 2 倍系数,直接导致维度对不上报错,后面第 3 章的代码里我会再标记一次。

2.2 Attention 的挂载位置:替换掉“取最后时刻”的粗糙池化

很多课程作业的初版思路是拿 LSTM 最后一个时刻的隐状态接全连接层做分类。这在单层单向 LSTM 里勉强能跑,但换成 Bi-LSTM 之后,“最后一个时刻”本身就变得非常尴尬:正向 LSTM 的最后时刻在序列末尾,反向 LSTM 的最后时刻在序列开头,你取哪一个都不代表整句话。有些实现会把两个方向的最后隐状态拼接起来,但这等价于只用了文本的首尾两个位置,中间所有词的贡献都被丢掉了。

Attention 在这里的正确角色,是替换掉这种粗糙的池化操作。把 Bi-LSTM 输出的整个序列 H(形状为 batch × seq_len × 2h)交给 attention 层,它为每个时刻学到一个归一化权重,然后用加权求和得到一个固定维度的上下文向量:

  • context = Σ_t α_t · h_t
  • α_t = softmax(score(h_t))

这个 context 才是真正代表“整句话里与任务最相关的内容”的向量,后面再接一个全连接层分类。这里的亮点在于权重 α_t 是跟着训练自动学的:如果语料里“不好”总是和负面标签共现,attention 会把高权重分配给“不好”。这也是课程答辩时最大的加分项——把权重画出来,直接证明模型不是黑匣子。

2.3 attention score 的三种写法与一组稳定的初始参数

attention 的核心差异在 score 函数上,课程作业里常见三种写法:

  • 加性注意力:score(h_t) = v^T · tanh(W · h_t + b)。W 的作用是把向量映射到注意力空间,v 再压成标量。表达能力最强,小数据集上收敛稳定,是课程作业的默认选择。
  • 点积注意力:score(h_t) = h_t · u。计算量最小,但等价于对序列做线性加权,表达力弱一些,比较适合短文本或作为 baseline。
  • 缩放点积注意力:score(h_t) = (h_t · u) / sqrt(d)。d 是隐层维度,防止内积过大导致 softmax 饱和。这个写法通常配合小学习率使用,不然训练初期容易不稳。

我一般直接选用加性注意力,因为它在几千条样本的小数据集上表现最稳。配套参数可以这样起步:embedding 维度选 100 或 200,从零训练词向量时 200 更稳,用预训练词向量可以省到 100;hidden_size 从 128 起步,显存紧张就降到 64;dropout 取 0.3 到 0.5 之间,太小防不住过拟合,太大 attention 学不出有效分布;LSTM 层数只设 1,堆到 2 层以上在小数据集上几乎必过拟合,训练时间还翻倍。embedding 维度和 hidden_size 的比例保持在 1:1 到 1:1.5 之间效果比较稳定,这个比例差距过大会让 attention 权重发散,后面可视化的时候你会看到颜色一大片均匀分布,没有重点。

3. 用 PyTorch 搭建 Bi-LSTM + Attention:核心代码与参数逐行说明

3.1 课程作业的文件结构:源代码、文档、数据集、PPT、论文、模型怎么排布

这个资源包的核心是六类东西:源代码、文档说明、数据集、PPT、论文、模型文件。源代码是主线索,文档说明负责告诉老师环境怎么配、代码怎么跑,数据集决定任务类型,PPT 是答辩用,论文是课程报告,模型文件是训练产物。常见做法是七个子目录,提交前一眼就能检查有没有漏文件:

deep-learning-homework-bi-lstm-attention/ ├── code/ # 源代码 │ ├── model.py # Bi-LSTM + Attention 网络定义 │ ├── train.py # 训练主脚本 │ ├── predict.py # 单条样本预测脚本 │ ├── config.py # 超参数统一配置 │ └── requirements.txt # python 依赖清单 ├── data/ # 数据集 │ ├── train.txt │ ├── dev.txt │ └── test.txt ├── docs/ # 文档说明 ├── ppt/ # 答辩幻灯片 ├── paper/ # 课程报告/论文 └── models/ # 训练好的模型文件

requirements.txt 里至少要固定 python 版本和 torch 版本,常见组合是 python 3.8 配 torch 1.13 或更新版本。文档说明不要写“按我的环境一定能跑”,而是把pip install -r requirements.txt写清楚,并且在说明里列出 GPU 和 CPU 两种运行方式。PPT 里放一张模型结构图,直接复用 model.py 里的网络结构绘制,别用网上找的通用图,老师一眼就看得出是不是你自己的代码。

3.2 核心代码:Bi-LSTM + Attention 网络定义与关键参数说明

model.py 里最核心的网络定义代码如下,可以直接跑通二分类和多分类:

import torch import torch.nn as nn class BiLSTMAttention(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, num_classes, dropout_prob=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.lstm = nn.LSTM( input_size=embedding_dim, hidden_size=hidden_size, num_layers=1, batch_first=True, bidirectional=True, ) self.dropout = nn.Dropout(dropout_prob) # 加性注意力参数 self.W = nn.Linear(hidden_size * 2, hidden_size * 2) self.v = nn.Linear(hidden_size * 2, 1, bias=False) self.classifier = nn.Linear(hidden_size * 2, num_classes) def forward(self, x, mask=None): # x: (batch, seq_len) emb = self.embedding(x) # (batch, seq_len, embedding_dim) lstm_out, _ = self.lstm(emb) # (batch, seq_len, 2*hidden_size) # 加性注意力打分 hidden = torch.tanh(self.W(lstm_out)) # (batch, seq_len, 2*hidden_size) scores = self.v(hidden).squeeze(-1) # (batch, seq_len) if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) alpha = torch.softmax(scores, dim=-1) # (batch, seq_len) context = torch.sum(alpha.unsqueeze(-1) * lstm_out, dim=1) return self.classifier(self.dropout(context)), alpha

代码逻辑:embedding 层把 token id 映射成稠密向量;LSTM 设置batch_first=True后输入输出都保持 (batch, seq_len, dim) 排布;bidirectional=True的输出特征维度自动变成 2 倍 hidden_size,所以后面 W 和 classifier 都用hidden_size * 2。Attention 部分先把每个位置的向量过一层 tanh 激活的线性变换,再用 v 压成标量得到该位置的重要性分数,mask 为 0 的位置被填充成 -1e9,softmax 之后这些位置权重趋近于 0。最后加权求和得到整个句子的 context 向量,过一个 dropout 后进分类层。

几个重点参数:padding_idx=0表示 0 号单词是填充占位,后面所有 mask 操作都依赖这个约定。num_layers建议固定 1,这个配置在小数据集上最不容易过拟合。dropout_prob初始 0.5,如果训练集损失和验证集损失差距不大,可以降到 0.3。vocab_size取决于预处理阶段保留的词表大小,一般在 5000 到 20000 之间。

3.3 训练主循环:loss 计算、梯度裁剪与模型保存

train.py 里的训练主循环是课程作业最容易写糊弄的部分,我拆开写:

def train_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss = 0.0 for batch in dataloader: input_ids, labels = batch input_ids, labels = input_ids.to(device), labels.to(device) mask = (input_ids != 0).int() optimizer.zero_grad() logits, alpha = model(input_ids, mask) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)

这段逻辑里有两个容易被忽略的细节。第一,mask 直接用input_ids != 0生成,背后依赖的是 embedding 层的padding_idx=0,如果预处理时把 padding 符号的 id 改成了 0 以外的数字,这里也要同步改。第二,clip_grad_norm_传 5.0,这个操作对 Bi-LSTM 来说几乎是必备的,它能拦下绝大多数梯度爆炸,代价只是多几行代码。训练结束后的模型保存,我一般保存 state_dict 而不是整个模型对象,文件名带上 epoch 和 dev 准确率,例如model_epoch10_acc0.82.pth,加载的时候再用同样的模型定义恢复。这样既方便对比多个 checkpoint,也避免覆盖掉 dev 上表现最好的版本。

4. 数据集与训练流程:划分、预处理、损失函数与调参顺序

4.1 数据集划分与文本预处理:课程作业别把验证集和测试集混用

数据集的一般格式是每行一条样本,用 tab 分隔文本和标签。课程作业最常见的坑是只有一个 train 文件和一个 test 文件,中间没有任何验证数据。正确做法是拿出 train 里 10% 到 20% 作为 dev 集,用来选 epoch 和观察收敛情况;test 集只允许在最终评估时跑一次,否则你调参时反复碰 test,报告里的数字就没说服力了。预处理步骤通常是这样一组流水线:去掉 HTML 标签、全角转半角、分词、构建词表、截断或填充到固定长度。中文语料如果已经分好词,词表大小一般落在 5000 到 20000 之间;如果直接按字切分,词表会小很多,但模型不容易学到词级语义,课程作业还是建议按词来做。

序列长度 max_len 的选择也有讲究。先跑一个统计脚本,看语料里 95% 的样本长度落在哪个值,一般课程作业数据集在 50 到 100 之间。取这个值的 1.2 倍左右作为截断长度比较合理。注意填充方向,PyTorch 的 LSTM 默认从左到右读取,所以填充标记应该加在序列末尾,保证真实内容在序列前面。你在代码里看到pad_sequence或 DataLoader 的collate_fn时,检查它是不是按 batch 内最大长度补齐,并且把 padding token 设为 0。

4.2 损失函数与优化器的选择:分类任务别选错

文本分类任务用 CrossEntropyLoss 是默认选择。如果模型输出维度等于类别数,直接把它和标签丢给 CrossEntropyLoss 就行。这里有个常见的坑:二分类任务有人把输出维度设成 1 并配合 Sigmoid,然后用了 BCELoss,但课程作业里更省事的做法是输出维度设成 2,继续用 CrossEntropyLoss,避免在损失函数和激活函数的搭配上出错。

优化器我一般选 Adam,学习率初始 1e-3,weight_decay 设 1e-4。训练 15 到 30 个 epoch,用一个早停策略:连续 3 个 epoch dev loss 不下降就停,并恢复 dev 指标最好的那次模型参数。实际跑起来你会遇到一种现象:loss 下降很快但 dev 曲线开始震荡。这时候不要着急往下调学习率,先看 attention 权重是否发散。如果权重均匀撒在所有词上,说明模型根本没学到重点,有可能是序列长度太长或 hidden_size 过大,而不是学习率的问题。

4.3 调参顺序:先固定序列长度,再调 hidden_size,最后动 dropout

课程作业的时间有限,别把所有超参数放在一起瞎试。我常用的顺序是:

调参顺序参数常用值判断依据
1序列长度 max_len64~128覆盖 95% 样本长度
2学习率 lr1e-3dev loss 是否震荡
3hidden_size64~256dev acc 与显存占用
4dropout0.3~0.5训练损失与验证损失差值

先固定序列长度,因为它影响所有下游维度。学习率第二步调,因为大部分收敛问题都是学习率过大导致的。hidden_size 第三步,从 128 开始,向上加到 256,向下减到 64,每次只动这一个变量。dropout 最后一步调,而且只在训练损失明显低于验证损失、出现过拟合迹象时才动。不要一开始就把 dropout 设到 0.7,那会让 attention 权重学习得非常慢,看起来像模型没在学,其实是信号被丢得太多了。

5. Bi-LSTM + Attention 踩坑与排查:5 个常见翻车点

5.1 attention 权重堆在 padding 位置上:mask 没传进去

现象:训练 loss 正常下降,准确率也过得去,但把 attention 权重画出来一看,权重最高的几个位置全是填充符。

原因:forward 里没有接收 mask,padding 位置参与了 softmax 计算。padding 的向量是零向量,加性注意力算出来分数不低,容易被分配高权重。

解决:在 forward 里加 mask 参数,softmax 之前执行scores = scores.masked_fill(mask == 0, -1e9)。注意 mask 的数据类型要和 scores 一致,转成 float 再乘也行。这是 Bi-LSTM + Attention 最常见的低级错误,检查代码时第一个看这里。

5.2 双向 LSTM 维度翻倍被忽略导致矩阵维度报错

现象:跑到 classifier 前向那行报错,提示mat1 and mat2 shapes cannot be multiplied。

原因:bidirectional=True时 LSTM 输出的最后一个维度是2 * hidden_size,但分类层或 attention 层的输入维度还写着hidden_size。

解决:所有接在 LSTM 后面的线性层,输入维度统一写成hidden_size * 2。如果你在 3.2 节的代码基础上改,检查三处:self.W的 in_features、self.v的 in_features、self.classifier的 in_features。改完这处后训练能正常跑,但同样的错误在预测脚本里可能还会出现一次,所以 predict.py 里也要同步检查。

5.3 随机种子没固定:同一份代码跑两次结果差几个点

现象:答辩前几天发现,代码没改,只是重跑了一遍训练,准确率从 82% 变成 79%,无法解释。

原因:模型初始化、数据打乱顺序都依赖随机数。课程作业里如果不在训练脚本开头固定随机种子,每次结果都不一样,评审老师会认为实验结论不可靠。

解决:在 train.py 最前面固定三处随机源:torch.manual_seed(42)、np.random.seed(42)、random.seed(42)。如果用了 CUDA,torch.cuda.manual_seed_all(42)也要写。DataLoader 里的 shuffle 由内部的随机生成器控制,固定上面几个源后基本可复现。要是换到 GPU 上跑,结果还是会略有差异,这是浮点运算顺序导致的,属正常范围。

5.4 模型文件、论文参数与代码不一致:评审问倒你的不是算法

现象:论文里写 hidden_size=128,代码 config 里默认值却是 64,模型文件名又是另一个参数组合。答辩时老师照着论文问模型结构,你回答的和代码对不上。

原因:改过超参数后没有同步更新文档。课程作业资源包里的论文、PPT、文档说明和代码是四份独立文件,改动代码后很容易漏掉其中一份。

解决:把 config.py 里的超参数作为唯一事实源,所有文档和论文里的参数表都从这份 config 导出重写。模型文件名带上关键参数缩写,比如model_h128_ep10_acc0.82.pth,这样从文件名就能反推出训练配置。提交前花十分钟对照检查一遍:代码里的默认值、论文里的表格、PPT 里的模型图参数三者是否一致。

5.5 资源不足时训练太慢:CPU 上硬跑大模型会卡到怀疑人生

现象:只有 CPU 环境,数据集稍微大一点,一个 epoch 跑二十分钟,训练根本无法按时完成。

原因:没有合理压缩模型和数据规模,在 CPU 上硬跑 full-size 配置。

解决:先确认torch.cuda.is_available(),在文档说明里写明 GPU 和 CPU 两套配置参数。如果只有 CPU,把 hidden_size 降到 64,embedding_dim 降到 100,max_len 截断到 64,batch_size 降到 16。train.py 的 DataLoader 里num_workers=0在 Windows 上最稳,设成 4 以上反而可能因多进程启动问题报错。另外确认数据集里的样本是不是被重复加载了,有时候代码里循环嵌套写错,一个 epoch 里实际上跑了 5 遍数据,这个问题不看日志很难发现。

6. 让课程作业从“跑通”升级为“有亮点”:用 Attention 可视化验证模型行为

6.1 用 matplotlib 把 attention 权重对齐到每个词上

模型训练完,只报一个准确率已经很难让评审眼前一亮。把 attention 权重可视化,直接证明模型确实关注了和任务相关的关键词,是成本最低的加分技巧。抽取测试集里一条样本,把每个 token 的 attention 权重画成热力图:

import matplotlib.pyplot as plt import seaborn as sns model.eval() with torch.no_grad(): logits, alpha = model(input_ids.unsqueeze(0), mask.unsqueeze(0)) weights = alpha.squeeze(0).cpu().numpy() tokens = [idx2token[i] for i in input_ids.tolist()] plt.figure(figsize=(8, 4)) sns.heatmap([weights], yticklabels=False, xticklabels=tokens, cmap="Reds") plt.tight_layout() plt.savefig("attention_vis.png", dpi=150)

逻辑说明:sns.heatmap传的是一个二维数组[weights],所以热力图只有一行,列数是序列长度;xticklabels=tokens把每个 token 作为横轴标签;颜色越深代表 attention 权重越高。如果训练正常,你会看到“不”“差”“太”这类词颜色明显深,停用词和标点基本是浅色。如果颜色均匀分布没有重点,先查 mask 有没有漏传,再考虑是不是 hidden_size 偏大导致 attention 学习不充分。这张图放进 PPT,再配一两句解读,比堆一堆训练曲线更能说明你理解这套模型在做什么。我自己的习惯是每次训练结束先做一次可视化,权重分布不对就不急着调超参,先回去查数据预处理和 mask,很多问题在可视化面前藏不住。希望这个技巧能帮你的课程作业在展示环节加分。

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

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

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

立即咨询