☰
意图识别双路线实战:传统方法与深度学习Python源码拆解
2026/10/8 13:53:15 网站建设 项目流程

简介:这份资源面向意图识别方向的课程设计、毕业设计与入门研究者,提供传统机器学习与深度学习两条技术路线的完整Python实现,帮助读者在同一套数据上对比不同建模思路的效果差异。包内共282个文件,以96个py源码、39个txt说明、33个csv数据表为主,另含pdf与caj参考文献、json与iob标注文件、pkl与vocab词表等,压缩包约28.24MB,目录按数据集、模型与测试脚本分模块组织。资源覆盖ATIS与SNIPS两个英文数据集,前者含4978条训练、888条测试、22个类别,后者含13784条训练、700条测试、7个类别,并给出SVM、LR、Stack-Propagation、Bi-model with decoder、Bi-LSTM、JointBERT、ERNIE等模型的训练与测试入口,可输出训练时间与评测结果。已有166人学习,适合希望快速跑通基线、理解传统方法与预训练模型差异并完成实验报告的读者。

1. 意图识别双路线实战:从传统特征到深度学习,一份能跑通的 Python 源码拆解

拿到「分别基于传统方法和深度学习方法实现意图识别」这个题目时,很多人的第一反应是直接上 BERT 微调,但真正在业务里落过地的人都知道,意图识别(Intent Detection)从来不是「哪个模型强就用哪个」这么简单。它本质是一个短文本分类问题:用户说一句话,你要判断他到底想干什么——查天气、退订单、问价格、还是纯闲聊。传统方法用 TF-IDF 加 SVM 或朴素贝叶斯,几十行代码就能跑出一个基线;深度学习方法用 TextCNN、BiLSTM 或 BERT,精度能往上抬几个点,但代价是训练时间和显存。这份源码加数据集的价值,恰恰在于把两条路线放在同一个工程里对比,让你看清什么场景该用哪条路。如果你手头有几千条标注语料、想快速搭一个能上线的意图分类器,或者正在纠结要不要为了两个点的准确率上 GPU,这篇笔记会带你从数据格式一路走到模型评估,把参数和坑都摊开讲。

2. 传统方法路线:TF-IDF 加线性分类器的最小闭环

2.1 为什么先跑传统方法而不是直接上 BERT

我一般会建议团队先花半天时间把传统基线跑出来,原因有三个。第一,它能给你一个精度下限,后面深度学习模型如果只比它高一个点,你就要怀疑是不是数据量不够或者标注噪声太大。第二,传统方法的训练时间以秒计,你可以快速验证数据格式、标签体系、分词方案是否正确,不用等 GPU 排队。第三,线上推理时 TF-IDF 加线性模型的延迟通常在毫秒级,QPS 要求高的场景反而更实用。

意图识别的传统做法核心就两步:把句子转成向量,再喂给分类器。向量化常用 TF-IDF 或 CountVectorizer,分类器常用 LinearSVC、LogisticRegression 或 MultinomialNB。中文场景下,分词质量直接决定特征好坏,jieba 是最常见的选择,但要注意自定义词典——业务专有名词(比如「花呗」「工单」)如果不加进去,会被切碎成无意义的字。

2.2 数据格式与标签体系怎么定

源码里的数据集一般是 CSV 或 JSON 格式,两列:text 和 label。label 可以是字符串,也可以是整数 ID。我习惯在预处理阶段就把 label 映射成整数,同时存一份 id2label 的字典,方便后面推理时还原。

import pandas as pd from sklearn.preprocessing import LabelEncoder # 读取数据,假设是 CSV,两列 text 和 label df = pd.read_csv("data/intent_data.csv", encoding="utf-8") print(df.head()) print("类别分布:\n", df["label"].value_counts()) # 标签编码 le = LabelEncoder() df["label_id"] = le.fit_transform(df["label"]) id2label = {i: label for i, label in enumerate(le.classes_)} print("标签映射:", id2label)

这段代码做了三件事:读数据、看类别分布、把字符串标签转成整数。类别分布一定要打印出来,如果某个意图只有十几条样本,后面训练时要么做数据增强,要么直接合并到相近意图里,否则模型根本学不会。LabelEncoder的fit_transform会按字母序排列标签,所以id2label的映射关系要存下来,推理时不能搞反。

2.3 分词、TF-IDF 与分类器串联

中文分词是传统路线的第一个分水岭。jieba 的cut默认精确模式,但意图识别里更推荐用lcut配合自定义词典。下面是一个完整的训练脚本骨架:

import jieba from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.svm import LinearSVC from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report # 加载自定义词典,每行一个词 jieba.load_userdict("data/user_dict.txt") def tokenize(text): # 返回空格分隔的词串,供 TfidfVectorizer 使用 return " ".join(jieba.lcut(text)) df["tokens"] = df["text"].apply(tokenize) # 划分训练集和测试集,stratify 保证类别比例一致 X_train, X_test, y_train, y_test = train_test_split( df["tokens"], df["label_id"], test_size=0.2, random_state=42, stratify=df["label_id"] ) # TF-IDF 向量化 vectorizer = TfidfVectorizer( max_features=5000, # 控制特征维度,防止过拟合 ngram_range=(1, 2), # 加入二元词组,捕捉「退 订单」这类组合 min_df=2, # 至少出现两次的词才保留 max_df=0.9 # 出现在 90% 以上文档的词丢弃 ) X_train_vec = vectorizer.fit_transform(X_train) X_test_vec = vectorizer.transform(X_test) # 线性 SVM 训练 clf = LinearSVC(C=1.0, max_iter=2000) clf.fit(X_train_vec, y_train) # 评估 y_pred = clf.predict(X_test_vec) print(classification_report(y_test, y_pred, target_names=le.classes_))

TfidfVectorizer的几个参数值得展开说。max_features=5000是经验值,语料在几千到几万条时够用,太大反而引入噪声。ngram_range=(1,2)对意图识别帮助明显,因为很多意图靠词组区分,比如「查 天气」和「查 订单」。min_df=2过滤掉只出现一次的词,这些词多半是噪声或拼写错误。max_df=0.9去掉过于通用的词,类似停用词的效果。

LinearSVC的C参数控制正则强度,C 越大越容易过拟合,C 越小越偏向欠拟合。意图识别数据量不大时,C 取 0.5 到 2 之间比较稳。max_iter设 2000 是为了避免收敛警告,如果数据量大可以再调高。

2.4 传统路线的评估与保存

训练完不能只看 accuracy,意图识别是不平衡分类,要看每个类别的 precision、recall 和 f1-score。classification_report会直接输出这些指标。如果某个类别的 recall 特别低,说明模型把这类样本大量误判成其他类,需要检查标注质量或增加该类样本。

模型保存用 joblib,同时要把 vectorizer 和 label encoder 一起存,推理时缺一不可:

import joblib joblib.dump(vectorizer, "model/tfidf_vectorizer.pkl") joblib.dump(clf, "model/linear_svc.pkl") joblib.dump(le, "model/label_encoder.pkl")

推理时先分词,再用同一个 vectorizer 做 transform(注意不是 fit_transform),最后 predict 并用 label encoder 还原标签。这一步顺序错了,精度会直接崩掉。

3. 深度学习方法路线:TextCNN 与 BiLSTM 的工程实现

3.1 深度学习意图识别的选型逻辑

传统方法的天花板通常在 85% 到 90% 之间,再往上走就需要模型理解词序和上下文。深度学习路线里,TextCNN 和 BiLSTM 是两个最常用的基线。TextCNN 用不同尺寸的卷积核捕捉 n-gram 特征,训练快、参数少,适合数据量在几千到几万条的场景。BiLSTM 能建模长距离依赖,对「先……再……最后……」这类复杂句式更友好,但训练慢一些。BERT 类预训练模型精度最高,但需要 GPU 和更多数据,如果标注语料少于五千条,微调效果未必比 TextCNN 好。

这份源码里深度学习部分一般会提供 TextCNN 和 BiLSTM 两种实现,用 PyTorch 或 TensorFlow 写。我下面以 PyTorch 为例,把关键模块拆开讲。

3.2 词表构建与序列填充

深度学习模型不直接吃文本,要先建词表,把每个词映射成整数 ID。词表一般保留频率最高的前 N 个词,其余用<UNK>表示。句子长度不一时,用<PAD>填充到固定长度。

from collections import Counter import torch from torch.utils.data import Dataset, DataLoader # 构建词表 all_tokens = [jieba.lcut(text) for text in df["text"]] word_counter = Counter([w for tokens in all_tokens for w in tokens]) vocab = {"<PAD>": 0, "<UNK>": 1} for word, count in word_counter.most_common(10000): if count >= 2: vocab[word] = len(vocab) print("词表大小:", len(vocab)) # 数据集类 class IntentDataset(Dataset): def __init__(self, texts, labels, vocab, max_len=32): 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(w, self.vocab["<UNK>"]) for w in tokens] # 截断或填充 if len(ids) > self.max_len: ids = ids[:self.max_len] else: ids = ids + [self.vocab["<PAD>"]] * (self.max_len - len(ids)) return torch.tensor(ids), torch.tensor(self.labels[idx])

词表大小控制在 1 万到 3 万之间比较常见,太小会导致大量<UNK>,太大则 embedding 矩阵浪费显存。max_len=32对意图识别通常够用,因为用户指令一般很短,超过 32 个词的句子很少。如果业务里有长文本,可以调到 64 或 128,但要注意显存占用。

3.3 TextCNN 模型结构与关键参数

TextCNN 的核心是多个不同尺寸的卷积核并行提取特征,然后池化拼接。下面是一个可直接用的实现:

import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, filter_sizes, num_filters, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) # 每个尺寸的卷积核各 num_filters 个 self.convs = nn.ModuleList([ nn.Conv2d(1, num_filters, (fs, embed_dim)) for fs in filter_sizes ]) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(len(filter_sizes) * num_filters, num_classes) def forward(self, x): # x: [batch, seq_len] x = self.embedding(x) # [batch, seq_len, embed_dim] x = x.unsqueeze(1) # [batch, 1, seq_len, embed_dim] # 每个卷积核输出 [batch, num_filters, seq_len - fs + 1, 1] x = [F.relu(conv(x)).squeeze(3) for conv in self.convs] # 全局最大池化 x = [F.max_pool1d(i, i.size(2)).squeeze(2) for i in x] x = torch.cat(x, dim=1) x = self.dropout(x) return self.fc(x)

embed_dim一般取 128 或 256,数据量大时可以到 300。filter_sizes常用[2,3,4],对应二元、三元、四元词组特征。num_filters每个尺寸取 64 到 128。dropout=0.5是防止过拟合的关键,如果训练集小于一万条,可以调到 0.6 甚至 0.7。padding_idx=0让<PAD>的 embedding 不参与梯度更新。

3.4 训练循环与早停策略

训练循环里要监控验证集 loss,连续几轮不下降就早停,避免过拟合。下面是一个精简版训练脚本:

from torch.optim import Adam from sklearn.metrics import f1_score device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = TextCNN( vocab_size=len(vocab), embed_dim=128, num_classes=len(le.classes_), filter_sizes=[2, 3, 4], num_filters=64 ).to(device) optimizer = Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() best_f1 = 0.0 patience = 3 no_improve = 0 for epoch in range(20): model.train() total_loss = 0 for batch_x, batch_y in train_loader: batch_x, batch_y = batch_x.to(device), batch_y.to(device) optimizer.zero_grad() logits = model(batch_x) loss = criterion(logits, batch_y) loss.backward() optimizer.step() total_loss += loss.item() # 验证 model.eval() preds, trues = [], [] with torch.no_grad(): for batch_x, batch_y in val_loader: batch_x = batch_x.to(device) logits = model(batch_x) preds.extend(logits.argmax(dim=1).cpu().tolist()) trues.extend(batch_y.tolist()) val_f1 = f1_score(trues, preds, average="macro") print(f"Epoch {epoch+1}, loss={total_loss:.4f}, val_f1={val_f1:.4f}") if val_f1 > best_f1: best_f1 = val_f1 torch.save(model.state_dict(), "model/textcnn_best.pt") no_improve = 0 else: no_improve += 1 if no_improve >= patience: print("早停触发") break

学习率1e-3是 Adam 的常用起点,如果 loss 震荡可以降到5e-4。patience=3表示验证集 F1 连续三轮不提升就停。保存模型时存state_dict而不是整个模型,加载时先实例化结构再load_state_dict,这样更灵活。

3.5 BiLSTM 的适用场景与实现差异

BiLSTM 和 TextCNN 的主要区别在于它按顺序处理序列,能捕捉词序信息。实现上把卷积层换成 LSTM 层即可:

class BiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True, bidirectional=True) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_dim * 2, num_classes) def forward(self, x): x = self.embedding(x) out, _ = self.lstm(x) # 取最后一个时间步的输出 out = out[:, -1, :] out = self.dropout(out) return self.fc(out)

hidden_dim一般取 128 或 256,双向拼接后维度翻倍。BiLSTM 训练比 TextCNN 慢,但对手续类、多步指令类意图识别更准。如果数据里句子平均长度超过 20 个词,BiLSTM 的优势会更明显。

4. 两条路线的对比与选型避坑

4.1 精度、速度与数据量的三角权衡

把两条路线放在一起对比,结论会更清晰:

维度TF-IDF + LinearSVCTextCNNBiLSTMBERT 微调
训练时间秒级分钟级分钟级小时级
推理延迟毫秒级毫秒级毫秒级十毫秒级
数据需求千条级五千条以上五千条以上万条以上
典型精度85%~90%90%~93%91%~94%94%~97%
GPU 依赖无可选推荐必须

选型的核心不是追高精度,而是看你的数据量和延迟要求。数据少于三千条时,传统方法加规则兜底往往比硬上深度学习更稳。数据过万且允许 GPU 推理,再考虑 BERT。

4.2 避坑与常见问题排查

现象一:传统方法训练集精度 99%,测试集只有 70%。原因:TF-IDF 的max_features设太大,或者min_df=1导致稀有词被当成特征,模型记住了噪声。 解决:把max_features降到 3000 到 5000,min_df设为 2 或 3,同时检查是否有重复样本同时出现在训练集和测试集。

现象二:深度学习模型 loss 不下降,一直卡在某个值。原因:学习率太大导致震荡,或者词表里<UNK>比例过高,embedding 学不到有效表示。 解决:把学习率降到1e-4试一轮,同时统计<UNK>占比,如果超过 10%,说明词表太小或分词有问题,需要扩大词表或换分词工具。

现象三:验证集 F1 波动很大,每次训练结果差好几个点。原因:数据量小,划分训练验证集时类别分布不均匀。 解决:用stratify分层抽样,同时固定随机种子。如果还是波动,做 5 折交叉验证取平均。

现象四:推理时精度比测试时低很多。原因:推理时用了fit_transform而不是transform,或者分词词典和训练时不一致。 解决:把 vectorizer、label encoder、词表都保存成文件,推理时严格加载同一份,不要重新 fit。

现象五:某个意图的 recall 始终为 0。原因:该类样本太少,或者标注时和其他意图边界模糊。 解决:先看混淆矩阵,确认它被误判成了哪个类,然后要么补充样本,要么合并意图,要么加规则后处理。

5. 把模型推到线上:推理封装与持续迭代的一个习惯

训练完模型只是开始,真正让意图识别产生价值的是推理封装和迭代机制。我一般会写一个统一的Predictor类,把传统模型和深度学习模型的推理接口对齐,这样线上切换模型时不用改业务代码。

class IntentPredictor: def __init__(self, model_type, model_dir): self.model_type = model_type if model_type == "traditional": self.vectorizer = joblib.load(f"{model_dir}/tfidf_vectorizer.pkl") self.clf = joblib.load(f"{model_dir}/linear_svc.pkl") self.le = joblib.load(f"{model_dir}/label_encoder.pkl") else: self.vocab = torch.load(f"{model_dir}/vocab.pt") self.model = TextCNN(...) # 按训练时的参数实例化 self.model.load_state_dict(torch.load(f"{model_dir}/textcnn_best.pt")) self.model.eval() def predict(self, text): if self.model_type == "traditional": tokens = " ".join(jieba.lcut(text)) vec = self.vectorizer.transform([tokens]) pred_id = self.clf.predict(vec)[0] return self.le.inverse_transform([pred_id])[0] else: tokens = jieba.lcut(text) ids = [self.vocab.get(w, 1) for w in tokens][:32] ids += [0] * (32 - len(ids)) with torch.no_grad(): logits = self.model(torch.tensor([ids])) pred_id = logits.argmax(dim=1).item() return self.id2label[pred_id]

这个封装的关键点是:传统模型和深度学习模型对外都只暴露predict(text)一个方法,业务侧不关心底层用的是哪个。模型文件、词表、标签映射全部从同一个目录加载,避免版本错配。

上线后一定要记录推理日志,尤其是低置信度的样本。我习惯把置信度低于 0.6 的请求单独存一份,每周人工过一遍,把标注正确的补充进训练集,标注错误的用来发现新意图。这个习惯坚持三个月,意图识别的准确率通常能再涨三到五个点,比换模型管用得多。

最后说一个我踩过的坑:不要等到模型「完美」了才上线。先用传统方法跑一个能用的版本,收集真实流量里的 badcase,再用这些数据去训练深度学习模型,迭代节奏会快很多。希望帮到你。

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

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

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

立即咨询