简介:这份资源是面向计算机相关专业在校学生、高校教师及初级程序员的目标意图识别毕业设计项目包,基于多种深度学习算法实现,涵盖非预训练Bi-LSTM与预训练ERNIE等模型方案,适合作为毕设、课程设计或期末大作业的完整参考,也便于有钻研需求的读者进行二次开发。压缩包共305个文件,约30.9MB,以110个Python源码为核心,辅以43个txt说明、33个csv数据集、18组out/label/in标注文件,以及json、pkl、vocab等模型配置与词表资源,另含md文档、sh脚本和少量图片,目录结构清晰,便于按模块检索。资源内附运行说明文档,按文档操作即可跑通,需注意项目路径避免中文,解压后重命名为英文再运行。目前已有199人学习下载,读者可获得完整算法实现、数据集与训练配置,并参考错误与正确结果对比文件理解模型差异,快速完成实验复现与排错。
1. 目标意图识别毕设:从数据集到多算法对比的完整落地路径
目标意图识别,说白了就是让模型看完一段输入(文本指令、行为序列、传感器时序,甚至图像里的目标关系),判断它背后想干什么。放到毕设场景里,它通常被包装成「多分类任务」:给定一条样本,输出它属于哪个意图类别。这个标题里最值钱的部分不是「深度学习」四个字,而是「多种算法」和「数据集」——它意味着你要在同一份数据上,把 CNN、LSTM、Transformer 这类结构跑一遍,做出可对比的结果。适合谁?适合正在做毕设、手里有 Python 基础、但不知道如何把「算法对比」做成一篇能过答辩的工程的人。我见过太多人卡在数据格式不统一、标签泄漏、训练集测试集划分随意这三件事上,最后跑出来的准确率自己都不敢信。这篇笔记就按「数据怎么进 → 模型怎么搭 → 结果怎么比 → 坑怎么躲」的顺序,把这条链路讲透。
2. 数据集处理:把原始样本变成模型能吃的张量
2.1 先搞清楚你的意图识别属于哪一类任务
目标意图识别在不同场景下数据形态完全不同。文本指令类(比如「打开空调」「查询余额」)是序列标注或句分类;行为序列类(用户点击流、操作日志)是变长时序分类;如果标题里的「目标」指的是图像中的对象,那它更接近「场景意图」——从检测框和类别组合推断整体行为。毕设里最常见的是前两种。你拿到数据集后第一件事不是写模型,而是确认三件事:样本长度分布、类别是否均衡、有没有重复样本跨集合出现。我一般会先跑一段统计脚本,把长度直方图和类别计数打出来,心里有数再动手。
import pandas as pd import matplotlib.pyplot as plt # 假设数据是 csv,两列:text, label df = pd.read_csv("intent_data.csv") print("类别分布:\n", df["label"].value_counts()) print("文本长度统计:\n", df["text"].str.len().describe()) # 画长度直方图,决定 padding 长度 df["text"].str.len().hist(bins=50) plt.xlabel("sequence length") plt.ylabel("count") plt.show()这段代码的作用是让你在写 Dataset 类之前,先确定max_len设多少。如果 95% 的样本长度在 40 以内,你设 128 就是浪费显存;如果长尾到 300,设 64 就会截断关键信息。类别分布那行输出如果最大类和最小类差 10 倍以上,后面训练必须加权重或做重采样,否则模型会偏向多数类,准确率虚高但召回惨不忍睹。
2.2 构建可复用的 Dataset 与 DataLoader
PyTorch 的Dataset是毕设里最值得写规范的一层。很多人把预处理塞进训练循环,导致每次 epoch 都重复分词,慢得离谱。正确做法是把分词、转 id、padding 全部放在__getitem__里,或者更彻底一点,离线预处理成 id 序列存下来。下面是一个文本意图识别的标准写法,词表用训练集构建,避免验证集词汇泄漏。
import torch from torch.utils.data import Dataset, DataLoader from collections import Counter class IntentDataset(Dataset): def __init__(self, texts, labels, vocab=None, max_len=64): self.texts = texts self.labels = labels self.max_len = max_len # 只用训练集构建词表 if vocab is None: counter = Counter() for t in texts: counter.update(t.split()) self.vocab = {w: i+2 for i, (w, _) in enumerate(counter.most_common(20000))} self.vocab["<pad>"] = 0 self.vocab["<unk>"] = 1 else: self.vocab = vocab def __len__(self): return len(self.texts) def __getitem__(self, idx): tokens = self.texts[idx].split()[:self.max_len] ids = [self.vocab.get(w, 1) for w in tokens] ids += [0] * (self.max_len - len(ids)) return torch.tensor(ids), torch.tensor(self.labels[idx]) # 划分时先固定随机种子 train_texts, val_texts, train_labels, val_labels = train_test_split( df["text"].tolist(), df["label"].tolist(), test_size=0.2, random_state=42, stratify=df["label"] ) train_ds = IntentDataset(train_texts, train_labels) val_ds = IntentDataset(val_texts, val_labels, vocab=train_ds.vocab) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True)关键点在vocab=train_ds.vocab这一句:验证集和测试集必须复用训练集的词表,否则同一个词在不同集合里 id 不一致,模型学到的 embedding 就乱了。stratify参数保证划分后类别比例一致,这对小数据集尤其重要。max_len截断策略是保留前 64 个 token,如果你的关键意图词常出现在句尾,得改成后截断或双向截断,这个细节答辩时老师一问就露馅。
2.3 标签编码与类别权重的计算
标签从字符串转成 0 到 N-1 的整数,看似简单,但顺序必须固定。我习惯用sklearn的LabelEncoder存一份映射表,推理时反查用。类别不均衡时,CrossEntropyLoss的weight参数直接传各类别频率的倒数。
from sklearn.preprocessing import LabelEncoder import numpy as np le = LabelEncoder() all_labels = le.fit_transform(df["label"]) class_counts = np.bincount(all_labels) weights = 1.0 / class_counts weights = weights / weights.sum() * len(class_counts) # 归一化 class_weights = torch.tensor(weights, dtype=torch.float32) criterion = torch.nn.CrossEntropyLoss(weight=class_weights)weights归一化后均值约为 1,避免整体 loss 尺度变化太大影响学习率。如果某个类只有个位数样本,权重会非常大,这时候更稳的做法是过采样而不是硬加权,否则模型对那几个样本过拟合。这一步做完,数据侧基本就干净了。
3. 多种深度学习算法的搭建与训练:CNN、LSTM、Transformer 怎么选
3.1 三个基线模型的代码结构与参数含义
毕设要求「多种算法」,不是让你堆十几个,而是选三到四个有代表性的结构做对比。文本意图识别里,TextCNN 抓局部 n-gram 特征,BiLSTM 抓时序依赖,Transformer 抓全局注意力,这三者刚好覆盖不同归纳偏置。下面给出三个模型的精简实现,共用同一个 embedding 维度。
import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim=128, num_classes=10): super().__init__() self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0) # 三个不同尺寸的卷积核,捕捉 2/3/4 元词组 self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, 128, k) for k in (2, 3, 4) ]) self.fc = nn.Linear(128 * 3, num_classes) self.dropout = nn.Dropout(0.3) def forward(self, x): x = self.embed(x).transpose(1, 2) # [B, E, L] x = [torch.relu(c(x)).max(dim=2)[0] for c in self.convs] x = torch.cat(x, dim=1) return self.fc(self.dropout(x)) class BiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden=128, num_classes=10): super().__init__() self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(embed_dim, hidden, batch_first=True, bidirectional=True) self.fc = nn.Linear(hidden * 2, num_classes) def forward(self, x): x = self.embed(x) out, _ = self.lstm(x) # 取最后一个时间步,配合 padding 应改用 mask 池化 return self.fc(out[:, -1, :])TextCNN 的padding_idx=0让 pad 的 embedding 不参与梯度更新,Conv1d的输入通道是 embedding 维度,输出通道 128 是超参,可以调成 64 或 256。BiLSTM 里out[:, -1, :]取最后一步在变长序列里其实取到了 pad 位置,正确做法是用 mask 做平均池化,这个坑后面避坑章节会细说。Transformer 直接用nn.TransformerEncoderLayer,注意加src_key_padding_mask。
3.2 训练循环里必须记录的指标与早停策略
训练循环不是只打印 loss。毕设答辩时老师会问「你怎么判断模型收敛了」,你需要有验证集准确率、F1、以及早停的耐心值。下面这个循环把关键逻辑都写清楚了。
def train_model(model, train_loader, val_loader, epochs=30, lr=1e-3, patience=5): optimizer = torch.optim.Adam(model.parameters(), lr=lr) criterion = nn.CrossEntropyLoss(weight=class_weights) best_f1, wait = 0, 0 for epoch in range(epochs): model.train() for x, y in train_loader: optimizer.zero_grad() loss = criterion(model(x), y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() # 验证 model.eval() preds, trues = [], [] with torch.no_grad(): for x, y in val_loader: preds.extend(model(x).argmax(1).tolist()) trues.extend(y.tolist()) f1 = f1_score(trues, preds, average="macro") print(f"epoch {epoch} macro-F1 {f1:.4f}") if f1 > best_f1: best_f1, wait = f1, 0 torch.save(model.state_dict(), "best.pt") else: wait += 1 if wait >= patience: print("early stop") break return best_f1clip_grad_norm_对 LSTM 和 Transformer 几乎是必加项,梯度爆炸在时序模型里太常见。早停 patience 设 5 意味着连续 5 个 epoch 验证 F1 不升就停,避免过拟合。保存best.pt而不是最后一个 epoch,是因为最后几个 epoch 往往已经在过拟合。学习率 1e-3 对 Adam 是安全起点,如果 loss 震荡就降到 5e-4。
3.3 多算法对比实验的组织方式
三个模型不能各跑各的然后手工抄数字。我一般写一个统一的实验入口,用配置字典控制模型类型,把结果写进同一个 csv。
configs = { "textcnn": {"lr": 1e-3, "batch": 32, "max_len": 64}, "bilstm": {"lr": 5e-4, "batch": 32, "max_len": 64}, "transformer": {"lr": 1e-4, "batch": 16, "max_len": 64}, } results = [] for name, cfg in configs.items(): model = build_model(name, vocab_size=len(train_ds.vocab)) f1 = train_model(model, train_loader, val_loader, lr=cfg["lr"]) results.append({"model": name, "macro_f1": f1, **cfg}) pd.DataFrame(results).to_csv("compare.csv", index=False)这样跑完你手里就有一张对比表,答辩时直接展示。注意每个模型的最优学习率不同,Transformer 通常需要更小的 lr 和更小的 batch,这不是玄学,是注意力机制对 batch 内统计量更敏感。如果时间允许,每个模型跑三次不同随机种子取均值,方差大的模型说明稳定性差,这也是一个可写的结论。
4. 避坑与排查:意图识别毕设里最容易翻车的五件事
4.1 验证集准确率比训练集还高
现象:训练 loss 正常下降,但验证准确率始终高于训练准确率,差距还不小。原因通常有两个:一是 Dropout 只在训练时开启,验证时关闭,模型在验证集上「发挥更稳」;二是数据划分时验证集太简单,或者训练集里混入了噪声标签。解决:先确认model.eval()在验证前调用了,再检查划分是否用了 stratify。如果还高,把训练集和验证集对调跑一次,如果结论反转,说明数据分布有问题,需要重新清洗。
4.2 模型只预测多数类
现象:不管输入什么,输出永远是样本最多的那个类别,准确率看着有 60% 但 F1 很低。原因就是类别不均衡加上没加权。解决:按 2.3 节算class_weights传给 loss,同时把评估指标从 accuracy 换成 macro-F1。如果加权后还是偏向多数类,检查权重是否被归一化抹平了差异,必要时直接用过采样把少数类补到和多数类同量级。
4.3 LSTM 取最后时间步取到了 padding
现象:BiLSTM 验证 F1 比 TextCNN 低一截,但理论上时序模型不该差这么多。原因:out[:, -1, :]在 batch 内序列长度不一时,短句后面全是 pad,最后一步取到的是 pad 的隐状态。解决:用 mask 做加权平均池化,或者取每个样本真实长度的最后一步。下面这段是修正写法。
def masked_mean(out, mask): # out: [B, L, H], mask: [B, L] mask = mask.unsqueeze(-1).float() return (out * mask).sum(1) / mask.sum(1).clamp(min=1e-9)mask从 DataLoader 里额外返回,pad 位置为 0,真实 token 为 1。这个改动通常能让 LSTM 的 F1 提升好几个点,属于血泪经验。
4.4 词表构建时把验证集词汇也统计进去
现象:线下验证 F1 很高,换一批新数据推理时效果暴跌。原因:构建词表时用了全量数据,验证集里的词在训练时已经见过 id,造成信息泄漏。解决:严格按 2.2 节的写法,词表只用训练集构建,验证和测试复用。这个坑在毕设里极其常见,因为很多人图省事直接对df["text"]做Counter。
4.5 随机种子没固定导致结果不可复现
现象:同一份代码跑两次,F1 差 3 个点,答辩时老师让你现场复现,结果对不上。原因:Python、NumPy、PyTorch 三处随机源都没固定。解决:在入口脚本最前面统一设置。
import random, numpy as np, torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = Truecudnn.deterministic = True会稍微降低训练速度,但换来确定性,毕设场景完全值得。设完之后 DataLoader 的shuffle也要用带种子的 generator,否则 batch 顺序仍然随机。
5. 把毕设做成可交付:推理封装、结果可视化与答辩技巧
5.1 写一个能直接调用的推理函数
训练完保存best.pt只是半成品,答辩演示时你需要一个输入一句话就输出意图的接口。下面这个封装把词表、标签映射、模型权重一起加载,避免现场手忙脚乱。
class IntentPredictor: def __init__(self, model_path, vocab, label_encoder, max_len=64): self.vocab = vocab self.le = label_encoder self.max_len = max_len self.model = build_model("textcnn", vocab_size=len(vocab)) self.model.load_state_dict(torch.load(model_path, map_location="cpu")) self.model.eval() def predict(self, text): ids = [self.vocab.get(w, 1) for w in text.split()[:self.max_len]] ids += [0] * (self.max_len - len(ids)) x = torch.tensor([ids]) with torch.no_grad(): logits = self.model(x) prob = torch.softmax(logits, dim=1) idx = prob.argmax(1).item() return self.le.inverse_transform([idx])[0], prob[0][idx].item()map_location="cpu"保证在没有 GPU 的演示机上也能跑。返回意图和置信度两个值,置信度低于 0.6 时可以提示「不确定」,这在答辩时是个加分项,说明你考虑了实际部署的边界情况。
5.2 用混淆矩阵和对比表撑起答辩结果页
答辩 PPT 里最忌讳只放一个准确率数字。你需要三样东西:多模型对比表、最优模型的混淆矩阵、以及两三个错误案例分析。混淆矩阵用sklearn一行出图,重点看哪些类别互相混淆,这往往能引出「为什么这两个意图在语义上接近」的讨论,比干巴巴念数字强得多。
| 模型 | 准确率 | macro-F1 | 训练耗时 |
|---|---|---|---|
| TextCNN | 0.91 | 0.89 | 2min |
| BiLSTM | 0.90 | 0.88 | 6min |
| Transformer | 0.92 | 0.90 | 15min |
这张表里的数字要来自同一份划分、同一个评估脚本。耗时也写上,体现你考虑了工程成本。如果 Transformer 只高一个点但慢三倍,结论就可以写「在毕设数据规模下 TextCNN 性价比最高」,这种有取舍的结论老师最爱听。
5.3 我踩过的坑与给你的习惯建议
最后说一个我自己的教训。第一次做这类毕设时,我把所有预处理都写在训练脚本里,每次调参都要重新分词,一个实验等二十分钟。后来改成离线预处理加缓存,同样的实验两分钟出结果,迭代速度完全不是一个量级。另一个习惯是每个实验都存一份配置和结果到 csv,不要相信自己的记忆,三天前跑的 lr 是多少你肯定记不住。还有,答辩前一定用全新环境跑一遍推理脚本,确认没有隐藏的路径依赖。这些事看起来琐碎,但决定了你是从容演示还是现场翻车。希望帮到你。
本文还有配套的精品资源,点击获取