ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

意图识别实战:从TF-IDF到BERT的两条技术路线

意图识别实战:从TF-IDF到BERT的两条技术路线 简介这份资源是针对自然语言处理中意图识别任务的Python源码与配套数据集压缩包面向希望系统学习文本分类、对话系统或查询理解的初中级开发者。包内同时给出了传统机器学习路径与深度学习路径的完整实现覆盖SVM、朴素贝叶斯、LSTM、CNN以及BERT等预训练模型的应用思路并配有项目说明文件可直接对照理解数据预处理、特征提取、模型训练与评估流程。全包共229个文件大小约27.2MB以py脚本、txt说明、csv数据集、md文档为主其中包含大量标注训练与测试数据如SNIPS格式样本以及vocab词表、json配置等辅助文件便于复现实验。目前已有872人学习下载。通过对比不同方法的效果差异读者既能掌握传统特征工程技巧也能理解深度学习端到端建模的优越性是适合NLP入门进阶与课程设计参考的实用资料。1. 意图识别从 TF-IDF 到 BERT这份源码把两条技术路线都走通了用户说了一句帮我定明天晚上七点的双人桌系统要在几十毫秒内判断这是订餐还是别的意图——这就是意图识别要解决的核心问题。这份基于 SNIPS 数据集的 Python 源码包同时给出了传统机器学习TF-IDF SVM和深度学习LSTM / BERT两套完整实现训练集、验证集、测试集齐全还带一份可直接运行的推理链路tokenizer.cc 与 seq_cls_infer.cc和预测输出 predict.csv。适合正在入门 NLP、想做智能客服或语音助手的开发者也适合想给现有系统换模型的老手拿来做基线对比。源码、数据集、项目说明都打包在一起解压后按顺序跑就能看到两条路线在同一份数据上的差异。2. 传统机器学习路线TF-IDF 特征加上 SVM先跑通再谈优化2.1 先摸清数据文件SNIPS 的 train/dev/test 与 predict.csv 怎么对应打开压缩包先别急着 run文件之间的角色要先分清楚。这个包里的数据以 SNIPS 为基础7 类意图AddToPlaylist、BookRestaurant、GetWeather、PlayMusic、RateBook、SearchCreativeWork、SearchScreeningEvent每类大约 2000 条训练集总计约 13000 条。文件清单如下文件角色说明snips_train_df.csvSNIPS 原始训练集7 类意图约 13000 条字段通常是 text 与 labelsnips_test_df.csvSNIPS 原始测试集约 700 条用于最终评估train.csv模型训练集从 SNIPS 整理出来的训练划分dev.csv验证集调参时用不要在训练时碰它test.csv测试集最后评估用只在验证完所有方案后跑一次predict.csv推理输出模型对无标签样本的预测结果通常包含 text 与 predicted_label有个细节要留意包里 train.csv 和 test.csv 出现了两次分别对应传统方法与深度学习方法各自的目录。两份文件字段结构基本相同但数据划分可能不完全一致跑之前先打开看一眼。字段一般是两列text 是原始句子label 是意图标签。如果发现 label 是字符串而不是数字需要先做 LabelEncoder 映射成 0 到 6 的整数后面 SVM 和 LSTM 都要用。2.2 文本预处理去停用词、小写化、词干提取做多了反而降分意图识别的文本预处理比想象中要克制。常见做法是小写化、去标点和数字、去停用词这三步在 SNIPS 这类英文数据上基本是安全的。词干提取PorterStemmer这类操作就要谨慎了——booking 被还原成 book 没问题但 player 还原成 play 就可能把音乐播放的意图往别的方向带偏。意图标签的边界有时候就靠一个词根差异撑着。import re from nltk.corpus import stopwords from nltk.stem import PorterStemmer STOPWORDS set(stopwords.words(english)) def clean_text(text: str, use_stem: bool False) - str: text text.lower() text re.sub(r[^a-z0-9\s], , text) words text.split() words [w for w in words if w not in STOPWORDS] if use_stem: stemmer PorterStemmer() words [stemmer.stem(w) for w in words] return .join(words)逻辑说明先统一小写再用正则把非字母数字字符替换成空最后按空白切词并过滤停用词。use_stem参数默认关掉这是一个刻意的保守选择——在意图识别场景里我一般先跑一版不做词干提取的结果再对比开词干后的效果如果准确率没有提升就不保留。SNIPS 数据本身比较规整预处理做太狠反而会把语义信息洗掉。参数说明re.sub(r[^a-z0-9\s], , text)里的\s保留空白符避免把 seven oclock 这种带撇号的表达直接拼成一个畸形词。如果你后面要处理中文数据这套正则不适用得换成 jieba 分词但这份资源的数据集是英文按英文管线走最顺。2.3 特征工程与模型训练TfidfVectorizer LinearSVC 跑通基线传统方法的核心是把文本转成数值向量然后用分类器做决策。这步我推荐 TfidfVectorizer LinearSVC 的组合不推荐带 RBF 核的 SVC。文本经过 TF-IDF 转出来是高维稀疏向量RBF 核在这种数据上训练慢而且样本量上万之后线性核的效果通常不比 RBF 差却能省掉一大截训练时间。import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.svm import LinearSVC from sklearn.metrics import classification_report train_df pd.read_csv(train.csv) dev_df pd.read_csv(dev.csv) vectorizer TfidfVectorizer( ngram_range(1, 2), max_features20000, sublinear_tfTrue, min_df2 ) X_train vectorizer.fit_transform(train_df[text]) X_dev vectorizer.transform(dev_df[text]) model LinearSVC(C1.0, class_weightbalanced) model.fit(X_train, train_df[label]) pred model.predict(X_dev) print(classification_report(dev_df[label], pred, digits4))逻辑说明fit_transform在训练集上学习词表并做向量化transform在验证集上只做映射保证验证集不参与词表构建这是防止数据泄漏的基本要求。LinearSVC 用合页损失配合线性核对高维稀疏文本特征收敛快训练完直接predict拿到验证集预测标签。参数说明ngram_range(1, 2)表示同时用单词和相邻双词作为特征像 book a table 这种相邻词组合会被保留book 和 table 单独出现时的歧义能缓解一部分。max_features20000限制特征维度SNIPS 训练集约 13000 条20 万维特征会明显过拟合截断到 2 万是稳妥的起点。sublinear_tfTrue用 1 log(tf) 平滑词频减少高频词对分类的垄断。min_df2过滤掉只在一条样本里出现的词。C1.0是 SVM 的惩罚系数调小比如 0.1加强正则调大让模型更贴近训练数据。class_weightbalanced让少数类获得更高权重SNIPS 各类样本量接近但加上没坏处。2.4 评估准确率之外宏平均 F1 与混淆矩阵才是意图识别的关键意图识别里准确率是一个会骗人的指标。如果某个意图在测试集里占 30%模型把全部样本都预测成这一类准确率也有 30%SNIPS 各类还算均衡但上线后真实分布会变。宏平均 F1 对每个类一视同仁再取平均少数类表现差会直接拉低分数比准确率诚实得多。import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay cm confusion_matrix(dev_df[label], pred, labelsmodel.classes_) disp ConfusionMatrixDisplay(cm, display_labelsmodel.classes_) disp.plot(xticks_rotation45) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150) # 观察哪些类互相串比如把 RateBook 误判成 SearchScreeningEvent逻辑说明混淆矩阵的行是真实标签列是预测标签对角线越亮说明该类别分得越准对角阵之外的非零格子就是误判集中区。结合 classification_report 里的 macro F1 和每个类的 recall能快速定位是哪些句子在打架。参数说明labelsmodel.classes_保证矩阵的行列顺序和模型输出一致xticks_rotation45防止 7 个意图名挤在一起。SNIPS 上传统方法跑到 95% 上下的 macro F1 是常见水准如果明显低于这个值先看预处理和 ngram 设置再怀疑模型配置。3. 深度学习方法LSTM 与 BERT 两条路怎么选怎么训3.1 为什么意图识别优先考虑 LSTM而不是把 CNN 照搬过来意图是句级别的整体语义往往由序列里几个关键片段共同决定而且依赖词序。播放周杰伦的歌 和 周杰伦播放歌 不是一回事。LSTM 按顺序逐个读取词通过门控机制把前文信息带进当前步天然适合建模这种依赖关系。CNN 用滑动窗口提取局部特征窗口小抓不到跨词依赖窗口大又丢位置细节在文本分类里不是首选。后来 Transformer 兴起LSTM 更多承担理解序列模型原理、低资源基线的角色但它仍然是把深度学习跑通的最短路径。3.2 PyTorch 实现Embedding LSTM 分类头15 分钟跑通LSTM 路线在工程上分三步构造词表、编码句子、定义模型训练。词表用Counter统计训练集词频只保留出现最多的 20000 个词其余映射到unk。SNIPS 的句子普遍短长度超过 32 的按 32 截断这在 97% 以上的样本上不会丢信息。from collections import Counter import torch from torch.utils.data import Dataset, DataLoader from torch.nn.utils.rnn import pad_sequence def tokenize(text: str): return text.lower().split() counter Counter() for text in train_df[text]: counter.update(tokenize(text)) vocab {w: i 2 for i, (w, _) in enumerate(counter.most_common(20000))} vocab[pad] 0 vocab[unk] 1 def encode(text: str) - torch.LongTensor: ids [vocab.get(w, 1) for w in tokenize(text)] return torch.LongTensor(ids[:32]) # 截断到32个token class IntentDataset(Dataset): def __init__(self, df): self.texts [encode(x) for x in df[text]] self.labels df[label].astype(category).cat.codes.to_numpy() def __len__(self): return len(self.labels) def __getitem__(self, idx): return self.texts[idx], torch.tensor(self.labels[idx], dtypetorch.long) def collate_fn(batch): texts, labels zip(*batch) padded pad_sequence(list(texts), batch_firstTrue, padding_value0) return padded, torch.stack(labels)逻辑说明pad对应 0unk对应 1词从 2 开始编号这个映射在后面 tokenizer.cc 对齐时也要完全一致。encode里vocab.get(w, 1)让词表外的词落进unkids[:32]做长度截断。collate_fn用pad_sequence把一个 batch 内长短不一的句子补齐到等长padding_value0告诉模型这些位置是填充词不参与语义计算。参数说明词表上限 20000 覆盖了 SNIPS 的绝大部分词汇设太大会让 Embedding 层臃肿且容易过拟合设太小 OOV 比例上升。max_len32不是拍脑袋可以先train_df[text].str.split().str.len().quantile(0.95)看一眼长度分布再决定截断点。import torch.nn as nn class LSTMIntent(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim128, num_layers2, num_classes7, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_dim, num_layersnum_layers, batch_firstTrue, dropoutdropout, bidirectionalFalse) self.dropout nn.Dropout(dropout) self.classifier nn.Linear(hidden_dim, num_classes) def forward(self, x): emb self.dropout(self.embedding(x)) out, (h, c) self.lstm(emb) last_hidden h[-1] # 取最后一层最后一个时间步的隐状态 return self.classifier(self.dropout(last_hidden))逻辑说明embedding把 token ID 映射成稠密向量lstm按时间步处理序列h[-1]取最后一层 LSTM 的最终隐状态作为整个句子的表示接一个线性层输出 7 类 logits。两层 LSTM 比单层能捕捉更高阶的组合模式但在 13000 条训练数据上超过两层收益很小还容易过拟合。参数说明embed_dim128是词向量维度太小表达力不足太大训练慢hidden_dim128控制隐状态容量num_layers2、dropout0.3是常见组合。注意 LSTM 层内的dropout只对非最后一层生效。训练循环用 Adam 优化器lr1e-3配合交叉熵损失跑 10 个 epoch每轮看验证集 F1保留最好的 checkpoint。3.3 BERT 微调HuggingFace Transformers 的迁移学习路线到了 BERT 这层特征工程和词表构建都不需要手动做了。AutoTokenizer负责把句子切分成 WordPiece 子词AutoModelForSequenceClassification在预训练模型顶上加一个分类头。SNIPS 这类任务用bert-base-uncased微调是稳妥选择uncased 意味着所有文本转小写跟传统路线的预处理保持一致。from transformers import (AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments) tokenizer AutoTokenizer.from_pretrained(bert-base-uncased) model AutoModelForSequenceClassification.from_pretrained( bert-base-uncased, num_labels7 ) train_enc tokenizer(train_df[text].tolist(), truncationTrue, paddingTrue, max_length48) dev_enc tokenizer(dev_df[text].tolist(), truncationTrue, paddingTrue, max_length48) training_args TrainingArguments( output_dir./bert_intent, learning_rate2e-5, per_device_train_batch_size16, per_device_eval_batch_size32, num_train_epochs3, warmup_ratio0.1, evaluation_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, metric_for_best_modeleval_f1, )逻辑说明tokenizer负责把句子转成input_ids、attention_mask和token_type_ids三件套truncationTrue超长截断paddingTrue补齐到 batch 内等长。Trainer把训练、评估、checkpoint 保存包成一套标准流程省去手写循环的麻烦。参数说明learning_rate2e-5是 BERT 微调的标准量级比 LSTM 的 1e-3 小一个量级以上——预训练权重已经在一个不错的位置学习率太大会把学到的语义冲掉。num_train_epochs3是分类任务的惯例微调超过 5 轮往往开始过拟合。warmup_ratio0.1让前 10% 步数里学习率从 0 线性升到 2e-5避免开局震荡。batch_size16是 24G 显存以下的安全值显存吃紧可以降到 8。3.4 传统与深度怎么选数据量、训练开销、可解释性的综合权衡维度TF-IDF LinearSVCLSTMBERT 微调最少数据量几千条可跑建议上万推荐上万训练时间秒级分钟级GPU 上几十分钟到小时级CPU 推理速度极快快有延迟需优化可解释性可查特征权重弱弱效果上限SNIPS95% 上下略高最高选型没有绝对答案但有清晰的适用边界样本只有几千条、算力有限、上线要解释模型行为老老实实用 TF-IDF SVM这个组合在 SNIPS 上已经很能打数据量上万、追求准确率上限、有 GPU直接上 BERT 微调LSTM 处于中间态适合学习序列模型原理或者在没有 GPU 又要引入深度特征的场景做过渡。不要一上来就 BERT先把 SVM 基线跑出来后面换模型才知道收益到底来自哪一层。4. 从训练到部署tokenizer.cc 与 seq_cls_infer.cc 里的推理链路4.1 先对齐 tokenizer训练脚本和 tokenizer.cc 必须用同一套规则Python 训练时用空格切词、转小写、查词表拿 ID。线上 C 服务做推理时tokenizer.cc 干的是同一件事。训练和推理的 tokenize 规则一旦不一致模型输入分布偏移线上效果立刻打折。最常见的翻车是Python 端用 BERT 的 WordPiece 切出了##ing这类子词C 端却还在按空格切两边 ID 对不上。拿到 tokenizer.cc 第一件事是确认它的切词规则和训练脚本一致。// tokenizer.cc 核心逻辑示意 #include algorithm #include sstream #include unordered_map #include vector std::vectorint Tokenize(const std::string raw, const std::unordered_mapstd::string, int vocab, int max_len, int unk_id) { std::vectorint ids; std::istringstream iss(raw); std::string token; while (iss token) { std::transform(token.begin(), token.end(), token.begin(), ::tolower); auto it vocab.find(token); ids.push_back(it vocab.end() ? unk_id : it-second); if (ids.size() static_castsize_t(max_len)) break; } return ids; }逻辑说明istringstream按空白切分输入文本每个词转小写后查词表命中取对应 ID未命中返回unk_id。这个逻辑要和 Python 端text.lower().split()完全等价。参数说明max_len必须和训练时的截断长度一致——训练用 32部署不能改成 64否则模型见过的序列长度分布变了padding 位置的处理也会不一致。unk_id对应 Python 词表里的unkID通常固定为 1。词表本身建议导出成一份文件txt 或 json训练和推理端共用不要各维护一份。4.2 seq_cls_infer.cc 的推理主流程从 token 序列到意图 ID模型训练完导出的权重文件要能脱离 Python 环境运行。seq_cls_infer.cc 就是这条 C 推理链路的主流程读入 token 序列前向计算得到 logitsargmax 取意图 ID再对 logits 做 softmax 得到置信度。// seq_cls_infer.cc 推理主流程示意 std::vectorint token_ids Tokenize(input_text, vocab, MAX_LEN, UNK_ID); std::vectorfloat logits model.Forward(token_ids); // model 内部做 embedding lstm/transformer int intent_id ArgMax(logits); std::vectorfloat probs Softmax(logits); float confidence probs[intent_id]; std::string intent_name label_map.at(intent_id);逻辑说明Tokenize输出的token_ids喂给模型Forward返回 7 类 logitsArgMax取最大值的下标即预测意图类别Softmax把 logits 转成概率分布最大值就是模型对预测结果的信心程度。label_map是意图名字符串 ID 到类名的映射表推理输出最后一步要转回可读的字符串。参数说明label_map的映射顺序必须和 Python 训练时LabelEncoder的类别顺序一致训练时类别 0 是 AddToPlaylist部署端就不能对调成 PlayMusic。这类问题不会报错只会静默出错属于最难排查的一类问题。建议把label_map和词表一起导出成配置文件训练脚本直接读取避免手工维护两份。4.3 用 predict.csv 做回归验证训练与推理一致性的最简单检查predict.csv 在设计上有两个作用一是记录模型对一批无标签样本的预测结果方便下游业务查看二是可以作为训练与推理一致性的回归测试基底。具体做法是从测试集抽 100 条样本Python 端跑一遍预测脚本C 端跑一遍 seq_cls_infer两边的意图 ID 和置信度全部比对。一致率 100%说明 tokenizer、模型权重、label 映射全链路对齐不一致逐条定位差异出在哪个环节。# 比对脚本示意 paste (python predict.py sample.txt) (./seq_cls_infer sample.txt) \ | awk $1 ! $2 {print NR: $0}逻辑说明paste把两个程序的输出按行拼在一起awk筛选出两边意图 ID 不一致的行。如果输出的不一致行超过 1%先怀疑 tokenizer 规则差异再看权重量化带来的精度损失。参数说明sample.txt每行一条文本两个程序的输入保持一致。这个比对脚本可以做成 CI 的一环每次改模型或改代码后强制跑一遍防止训练时和部署时用的不是同一个模型这种黑匣子问题。5. 意图识别避坑与常见问题五个最常翻车的地方5.1 训练集准确率 99%验证集只有 80%现象模型在训练集上几乎全对验证集掉一大截。原因过拟合。模型把训练集中出现过的特有词或短语和标签做了硬绑定换一批数据就不认识了。TF-IDF 路线尤其容易在max_features设大后过拟合LSTM 不加 dropout 也会在 10 个 epoch 之后开始死记训练集。解决SVM 把C从 1.0 往 0.1 方向调同时在TfidfVectorizer里把min_df提到 3过滤低频稀有词LSTM 把 dropout 提到 0.4训练时用早停patience3监控验证集 F1。先确认验证集没有泄漏进训练流程——如果 dev.csv 被fit_transform过这个对比就没意义了。5.2 预测结果大面积偏向高频类别现象模型把大部分样本都预测成 GetWeather 这种样本量最大的类。原因类别不均衡让模型倾向于输出先验概率高的类。LinearSVC 没有显式的概率校准更容易出现这种偏向。SNIPS 各类比较均衡但自己收集的数据往往不是这样。解决LinearSVC(class_weightbalanced)是第一步评估指标切换到宏平均 F1不再盯着 accuracy如果少数类还是拉不起来对少数类做简单过采样SMOTE 或直接重复样本或给损失函数加权。5.3 长句子预测错短句子基本对现象输入超过 20 个词识别率明显下滑。原因训练时max_len截断过短关键意图词落在了截断区间之外。比如 please book a table for two at seven pm tomorrow 的意图核心在 book a table但前面堆了 please 和一堆状语截断后核心词可能被切掉。解决先统计训练集长度分布按 95 分位设置max_len不要拍脑袋定 32。实在受限用保留开头和结尾各一段的截断策略而不是一刀切截断。LSTM 路线里 padding 位置还会参与隐状态计算把max_len调大一点通常立竿见影。5.4 新词、拼写变体预测错现象线上遇到词表外的新词模型做出离谱预测。原因固定词表把 OOV 全映射到unk信息完全丢失。BERT 这类 subword 模型能把 book 和 booking 拆出公共子词“book”天然抗 OOV传统方法对 OOV 毫无办法。解决传统路线可以保留高频 OOV 词作为特征或者把词表扩容到 50000 深度路线直接切到 BERT。SNIPS 是固定数据集OOV 问题不明显但自己标注的业务数据里经常出现产品名、地名变体这一条迟早会碰到。5.5 重构代码后效果变了但谁都说不清哪里变了现象同一份数据重新训练结果忽高忽低甚至断言数据有问题。原因随机种子没固定、tokenizer 规则被无意改动、训练集和验证集划分方式变了。环境一换Embedding 初始化不同LSTM 的结果就会抖动。解决全局固定random.seed(42)、np.random.seed(42)、torch.manual_seed(42)tokenizer 规则和词表统一放进配置文件每次 release 前跑一遍 4.3 节的一致性比对。这一条是血泪经验做过一次线上意图识别模型交接的人应该都知道我在说什么。6. 进阶用置信度阈值给意图识别加一个拒识兜底6.1 拒识为什么是意图识别的刚需OOD 输入与概率错觉模型对训练分布外的输入同样会输出一个最高概率这不代表它有信心。比如用户说了一句跟 7 类意图都不相关的话softmax 还是会把概率分配出去。给系统加一个我不知道的出口比硬猜一个意图更安全尤其是客服场景里猜错意图的代价远高于拒识后转人工。6.2 用现有模型输出加一层阈值判断不需要重新训练import numpy as np def predict_with_reject(model, text, threshold: float 0.7, fallback: str UNKNOWN, topk: int 5): probs model.predict_proba([text])[0] sorted_idx np.argsort(probs)[::-1][:topk] if probs[sorted_idx[0]] threshold: return fallback, 0.0, sorted_idx.tolist() return label_map[sorted_idx[0]], probs[sorted_idx[0]], sorted_idx.tolist()逻辑说明取 softmax 概率最大的类别如果最大概率低于阈值返回UNKNOWN兜底意图否则返回正常意图和置信度。topk参数让调用方看到前几名候选方便做转人工时的提示话术。参数说明LinearSVC 没有predict_proba这一步要么换LogisticRegression要么用CalibratedClassifierCV(LinearSVC())包一层做概率校准否则拿不到概率输出。LSTM 和 BERT 的输出层本身就带 softmax直接取概率即可。6.3 阈值怎么定看正确类与 OOD 的置信度分布重叠区阈值不是拍脑袋定的。我的习惯是从测试集统计每个正确预测的置信度分布再找一批真实场景里不该被识别的句子统计模型的置信度分布取两组分布重叠最少的点作为阈值。通常 0.6 到 0.8 之间会有明显分界。如果正确类的置信度中位数是 0.95OOD 样本集中在 0.4 到 0.6阈值选 0.7 就留出了足够缓冲。从那以后我每次上线意图识别模型都会强制走一遍三件事绘制正确类与 OOD 的置信度直方图确认阈值落在重叠区之外保存一份与训练词表完全一致的 tokenizer 配置用 predict.csv 做一次训练推理一致性回归。这套流程救过我不少次也让接手的人少踩很多坑。希望帮到你。本文还有配套的精品资源点击获取
返回列表