ARTICLE DETAIL

资讯详情

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

从TF-IDF到BERT:中文意图识别双路线实战

从TF-IDF到BERT:中文意图识别双路线实战 简介这套意图识别源码覆盖传统机器学习与深度学习两类技术路线面向自然语言处理方向的课程设计、毕业设计以及算法对比研究提供从数据到模型评估的完整可复现方案。压缩包共282个文件、大小28.24MB内容以Python源码96个、文本数据与说明、序列标注文件、模型权重文件及相关学术论文资料为主目录按不同方法单独组织能够快速定位到对应模型的训练入口。目前已有166人学习适合希望系统掌握意图识别并比较多种模型性能的读者。资源内置ATIS与SNIPS两个公开英文数据集其中ATIS含22个意图类别、SNIPS含7个意图类别提供SVM、LR、Stack-Propagation、Bi-model、Bi-LSTM、JointBERT、ERNIE等模型的训练与预测代码并配有错误分析CSV和项目说明读者可直观对比传统特征工程与预训练模型在意图分类任务上的差异同时学习数据预处理、模型训练、结果评估的完整流程对完成毕业设计或课题研究很有帮助。1. 意图识别不只是分出个标签这个项目想让你同时跑通两条建模路线周六晚上被运营拉进群说用户问“发票怎么改”“发票抬头错了能换吗”“刚下的单能补发票吗”全都返回了同一句复读人工客服越积越多。那个晚上我被迫把意图识别从头到尾捋了一遍给定一句中文先把意图归类到“修改发票”“补开发票”“查物流”“退换货”这种离散标签然后再谈槽位抽取和回复模板。后来我把这套方案整理成了一个带 python 源码和数据集的完整项目也正是在整理时才真正意识到绝大多数团队一上来就跳进 BERT 微调等训练脚本跑起来才后悔没先做传统基线。这个方向适合刚开始做 NLP、手上只有几万条甚至几千条标注数据、需要在算法效果和线上可维护性之间做取舍的人。下面我就按数据准备、传统方法、深度学习、避坑、评测对比的完整链路把两条路线都讲透。2. 数据准备与标签体系意图识别项目最先要定的三件事2.1 意图识别数据集的统一格式从原始语料到干净 CSV不管是拿公开的 SNIPS、ATIS还是自己从客服系统导出的对话记录第一步不是建模而是把所有文本统一成最简单的一行一条格式text一列label一列。项目里推荐的语料格式长这样text,label 我要改一下发票抬头,修改发票 发票金额能改吗,修改发票 刚下的单可以补开发票吗,补开发票 退款多久能到账,退款进度 物流一直停在转运中心,物流查询这种格式看起来笨但好处是后续所有脚本都能通用pandas 读进来sklearn 切分torch Dataset 包装每一步都不需要为数据格式写适配代码。意图识别最怕一开始做多标签、多意图嵌套句子拆不开标注也拆不开初版最好一个句子对应一个意图标签真正遇到“帮我改发票顺便看看快递”这种复合意图直接拆成两句再标。类别数量也必须在数据准备阶段定死。我做客服机器人的经验是15 到 30 个意图类别最合适。少于 10 类规则就能覆盖大半多于 30 类类别之间的边界会快速模糊比如“修改发票”和“发票作废”这种意图对连标注员都经常打架。每个类别的标注样本量最低也要 200 条少于这个数的类别别指望模型能学好更应该考虑合并进“其他”兜底类。2.2 分层切分与标签编码让评测集有说服力的最小操作很多人在意图识别上踩的第一个坑是不分层切分直接train_test_split(df, test_size0.2)结果少数类别在测试集里只有两三条准确率曲线剧烈抖动。正确做法是stratify按标签比例切分并且先切测试集再切验证集保证三个集合的类别分布一致。完整切分代码import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import LabelEncoder df pd.read_csv(data/intent_dataset.csv) df df.dropna(subset[text, label]) df[text] df[text].astype(str).str.strip() # 先按 7:3 把训练验证和测试分开测试集不参与任何后续拟合 train_val, test train_test_split( df, test_size0.3, stratifydf[label], random_state42, ) # 再把 train_val 按 5:5 拆成训练集和验证集最终比例约 70% / 15% / 15% train, val train_test_split( train_val, test_size0.5, stratifytrain_val[label], random_state42, ) print(train[label].value_counts(normalizeTrue))注释里写清楚了test_size0.3先切出 30% 做测试剩下的 70% 再切一半最后得到约 70% 训练、15% 验证、15% 测试。随机种子固定成 42 是为了两次实验之间可比。标签编码也必须在这一步完成注意只能在训练集上fitle LabelEncoder() y_train le.fit_transform(train[label]) y_val le.transform(val[label]) y_test le.transform(test[label]) # 保存类别映射后面深度学习、推理服务都要用这一份 label_map dict(zip(le.classes_, range(len(le.classes_)))) print(label_map)注意全量数据上先fit再做切分属于标签泄漏的一种验证集指标会被抬高。只要是做传统方法对比、深度学习对比都必须先从训练集上学习编码。2.3 意图体系设计类别数、别名表与“其他”类数据格式定完紧接着要定意图体系这决定了后续所有工作的上限。意图识别项目最常犯的错是类别粒度不一致“开发票”“补开发票”“修改发票抬头”“查询发票列表”散成四个类数据分散、边界重叠任何模型都做不好。我一般建议三步定体系先把训练语料里所有出现过的用户说法打散按“用户真正想办的事”聚类而不是按字面聚类合并相似意图比如“改发票”“换发票抬头”“发票信息变更”统一为“修改发票”显式增加一个“其他”意图类吸纳闲聊、骂人、无意义输入否则真实线上流量会把模型逼疯。别名表也要单独维护不用进模型特征。例如“快递”“快件”“包裹”“单号”都是同一个事物的别名这些词在 TF-IDF 特征里会被拆成不同维度白白浪费稀疏数据。我习惯把它们写进同义词词典在预处理阶段先做一次归一化深度学习方法对这类归一化不敏感但对传统机器学习方法帮助明显。3. 传统方法路线TF-IDF 逻辑回归跑出第一个可用基线3.1 为什么传统方法先做成本、可解释性与可控性大部分教程上来就是 BERT 微调但真正做工程的人会先答一遍传统方法的题机器能不能跑、训练要多久、线上出错了能不能说清楚为什么。传统机器学习的优势不在准确率上限而在成本和可控性。TF-IDF 加逻辑回归的训练在几万条语料上秒级完成CPU 足够。特征是词频权重模型是线性加和出错时拉出权重最大的几个词就能解释判断依据。这对需要向业务方交代的团队特别重要。更关键的是传统方法能提供一个稳定基线。深度模型准确率再高也必须在同一份测试集上比传统基线高出一截才值得上线。我见过不少项目BERT 微调后的准确率只比 TF-IDF 高出两三个百分点推理时间却涨了几十倍这种提升在业务上是划不来的。先跑基线再谈深度学习这是意图识别项目里最稳的推进路径。3.2 TF-IDF 逻辑回归可直接替换数据的 sklearn 流水线传统方法路线的核心代码是一条 sklearn Pipeline从原始文本到预测标签一步到位。先分词再算 TF-IDF最后接逻辑回归。中文场景下分词用 jieba需要提前pip install jiebaimport jieba from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline from sklearn.model_selection import GridSearchCV def chinese_tokenizer(text): return [w for w in jieba.lcut(text) if w.strip()] pipeline Pipeline([ (tfidf, TfidfVectorizer( tokenizerchinese_tokenizer, ngram_range(1, 2), # 考虑相邻两个词的组合 max_features30000, min_df1, )), (clf, LogisticRegression( max_iter1000, C1.0, multi_classmultinomial, # 多分类用 softmax 形式 )), ]) param_grid { tfidf__min_df: [1, 2, 3], # 过滤掉只出现一两次的生僻词 clf__C: [0.5, 1.0, 2.0], # 正则强度越小越防过拟合 } search GridSearchCV( pipeline, param_grid, cv5, scoringf1_macro, # 类别不均衡时比 accuracy 更可信 n_jobs-1, ) search.fit(train[text], y_train) print(best params:, search.best_params_)这段代码里两个参数最值得调。min_df直接决定特征空间里保留哪些词设成 2 会把只在一条语料里出现过的词全部丢掉噪音大减对只有几千条的小数据集min_df3也值得试。C是逻辑回归的正则强度数值越小越保守小数据集上 C1.0 通常比 2.0 更稳。f1_macro作为网格搜索评分意味着少数类表现不好时分数会被拉低避免模型全部押注在大类上。分词器写成chinese_tokenizer函数传入而不是直接用 jieba 默认行为是因为项目里往往还要叠加同义词替换。在函数里加一行替换逻辑整个流水线不用改。3.3 关键词与正则兜底规则算法混排的第一层防线传统方法并不只有向量化加分类器这一条路。实际业务里很多高频意图用正则和关键词就能确定根本不需要走模型。比如查物流用户说到“快递”“物流”“包裹”“到哪了”基本就是物流查询。把这些规则放在模型之前优先命中规则模型只处理规则覆盖不到的灰区输入线上稳定性会好很多。import re RULE_PATTERNS { 物流查询: [快递, 包裹, 物流, 到哪了, 转运], 修改发票: [改发票, 抬头错, 发票信息改, 重开发票], } def rule_first_predict(text): 规则优先命中就直接返回意图没命中返回 None 交给模型 for intent, keywords in RULE_PATTERNS.items(): for kw in keywords: if kw in text: return intent # 同时匹配正则比如纯数字订单号 if intent 物流查询 and re.search(r[0-9]{6,20}, text): return intent return None规则层的设计要点是“宁缺毋滥”。规则只负责召回非常确定的意图不要想着用规则覆盖所有类别否则规则之间会互相冲突后面根本维护不动。线上推理时规则层返回None再走模型模型输出置信度低于阈值也进入“其他”类形成两层保底结构。3.4 混淆矩阵定位意图对哪些类天生拆不开传统基线的价值不止准确率还包括一套诊断工具。训练完成后立刻打印分类报告和混淆矩阵能直观看到模型在哪几个意图之间摇摆from sklearn.metrics import classification_report, confusion_matrix y_pred search.predict(test[text]) print(classification_report(y_test, y_pred, target_namesle.classes_)) cm confusion_matrix(y_test, y_pred) # 找出预测错最多的意图对 import numpy as np np.fill_diagonal(cm, 0) pairs np.argwhere(cm 0) for i, j in sorted(pairs, keylambda x: -cm[x[0], x[1]])[:5]: print(f{le.classes_[i]} 被误判为 {le.classes_[j]}次数 {cm[i, j]})这一步会暴露很多标注阶段埋下的问题。比如“补开发票”和“修改发票”频繁互混多半是标注标准没写清楚而不是模型能力问题。把混淆矩阵里的高频错分对拉出来重新看语料必要时调整类别定义再重训一次基线。传统方法迭代成本低正好适合做这种数据质量清洗。4. 深度学习方法路线BiLSTM 与 BERT 微调的完整落地4.1 按数据量和硬件选型BiLSTM、TextCNN 还是 BERT深度学习路线的第一步不是写模型而是定阈值你的数据量和算力撑不撑得起预训练模型。常见做法是按数据量分三档5 万条以内BiLSTM 或 TextCNN词向量用随机初始化或预训练好的中文词向量即可训练分钟级效果能略超 TF-IDF 基线5 万到 20 万条BERT 微调开始有优势但必须加早停和 dropout否则很容易过拟合到训练集20 万条以上BERT 微调性价比最高同一份测试集上通常能比传统基线高出 8 到 15 个百分点。硬件上BERT 微调在纯 CPU 上不是不能跑但每个 epoch 耗时太长几万条数据可能要等半天实际项目里至少要一张普通英伟达显卡显存 6G 以上。BiLSTM 则是 CPU 就能轻松跑完的训练规模。选型原则一句话总结先看自己有多少标注数据和多少训练时间再决定投入哪条路线。4.2 PyTorch 实现 BiLSTM 意图分类器结构、参数与训练循环BiLSTM 是深度学习意图识别的入门标配。它比规则强得多能学会同义表达又不需要预训练模型那么重的算力。最小实现代码如下import torch import torch.nn as nn class BiLSTMIntent(nn.Module): def __init__(self, vocab_size, num_labels, embed_size100, hidden_size128, dropout0.3): super().__init__() self.embed nn.Embedding(vocab_size, embed_size, padding_idx0) self.lstm nn.LSTM( embed_size, hidden_size, bidirectionalTrue, # 双向同时看前文和后文 batch_firstTrue, num_layers1, ) self.dropout nn.Dropout(dropout) self.classifier nn.Linear(hidden_size * 2, num_labels) def forward(self, input_ids, seq_len): emb self.dropout(self.embed(input_ids)) packed nn.utils.rnn.pack_padded_sequence( emb, seq_len, batch_firstTrue, enforce_sortedFalse) _, (h_n, _) self.lstm(packed) # 拼接最后一层的正向和反向隐状态 last_hidden torch.cat((h_n[-2], h_n[-1]), dim-1) return self.classifier(last_hidden)几个关键参数说明padding_idx0要求预处理时把 padding 位置置为 0这样 pad 部分不会参与 embedding 更新bidirectionalTrue后隐状态输出变成正向和反向两份所以分类层输入维度是hidden_size * 2。模型结构简单真正决定效果的是训练循环里的两个细节长度打包和早停。pack_padded_sequence必须配合每句真实长度使用否则模型会学到一堆 padding 噪音。训练循环一般写成loss_fn nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(30): model.train() total_loss 0 for batch_text_ids, seq_lens, labels in train_loader: logits model(batch_text_ids, seq_lens) loss loss_fn(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() # 每个 epoch 结束在验证集上算准确率连续 3 轮不升就早停 val_acc evaluate(model, val_loader) print(fepoch {epoch} loss{total_loss:.3f} val_acc{val_acc:.3f})这里lr1e-3是 Adam 的常用起点如果验证集 loss 震荡降到5e-4再看。训练 30 轮不是真的都要跑完我一般第 8 轮到第 15 轮就能看到收敛后面基本就是过拟合。验证集指标连续三轮不涨就保存当前权重退出这是深度学习路线最重要的习惯。4.3 用 transformers 微调 BERT 意图模型完整训练流程当数据量和算力都到位BERT 微调是当前意图识别准确率上限最高的一条路。核心思路是加载一个已经在海量中文语料上预训练好的 BERT 模型在它上面加一个分类头用你的标注数据做几轮微调。用 Hugging Face Transformers 实现非常直接from transformers import ( AutoTokenizer, AutoModelForSequenceClassification, TrainingArguments, Trainer, ) from datasets import Dataset tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) model AutoModelForSequenceClassification.from_pretrained( bert-base-chinese, num_labelslen(le.classes_)) def preprocess_function(examples): return tokenizer( examples[text], truncationTrue, max_length64, # 意图识别句子短64 足够省显存 paddingmax_length, ) train_ds Dataset.from_pandas(train[[text, label]]).map(preprocess_function, batchedTrue) val_ds Dataset.from_pandas(val[[text, label]]).map(preprocess_function, batchedTrue) args TrainingArguments( output_dir./intent_bert_checkpoints, num_train_epochs5, per_device_train_batch_size16, per_device_eval_batch_size64, evaluation_strategyepoch, save_strategyepoch, learning_rate2e-5, weight_decay0.01, logging_steps50, ) trainer Trainer( modelmodel, argsargs, train_datasettrain_ds, eval_datasetval_ds, ) trainer.train()参数里learning_rate2e-5是 BERT 微调最关键的数字。预训练模型已经学得很好了学习率大了会直接冲刷掉原有知识所以只能用小学习率做“微调”而不是重新训练。max_length64是我为意图识别专门控制的客服句子大多不到 30 个字截断到 64 不会损失信息但显存占用和推理时间能省将近一半。num_train_epochs5看起来很短但对 BERT 来说一般 3 到 5 轮就收敛再多就是在背训练集。上述代码执行前需要pip install transformers datasets torch如果你用的是 CPU请把per_device_train_batch_size调小到 8 或 4否则 BERT 模型和中分词的显存占用会把内存撑爆。4.4 置信度阈值与“未知意图”深度学习路线的保底箱深度学习模型输出的不是一个硬标签而是每个类别上的概率分布。实际推理时不能直接把argmax当作最终意图因为模型碰到完全没见过的问题时也会强行给出一个最大概率类别而这个概率可能只有 0.3。缺失的置信度阈值带来的线上体验是灾难级的机器人对每一条输入都有信心但十句里有三句答非所问。import torch def predict_with_threshold(model, tokenizer, text, threshold0.6): inputs tokenizer(text, return_tensorspt, truncationTrue, max_length64) model.eval() with torch.no_grad(): logits model(**inputs).logits probs torch.softmax(logits, dim-1) prob, idx torch.max(probs, dim-1) if prob.item() threshold: return 其他, prob.item() return le.classes_[idx.item()], prob.item()阈值怎么定我习惯先在验证集上跑一遍统计所有正确预测的置信度分布然后选一个能让 95% 正确样本通过的数值通常在 0.5 到 0.7 之间。阈值太高会把大量正确输入丢进“其他”太低又会放进来一堆错误预测。真实项目里还要把低于阈值的句子单独落日志每周人工看一遍积累成新的训练语料这是意图识别系统持续变好的核心循环。5. 四个避坑记录意图识别实验最容易翻车的地方5.1 样本不均衡让整体准确率失真现象与分层处理现象训练完打印测试集整体准确率有 97%但打开分类报告一看“修改发票”这个意图的召回率只有 12%它恰好是业务上最核心的意图。原因样本切分没按标签分层测试集里这个类别只分到几条数据模型还把所有输入都押到了样本量最大的意图上因为这样整体损失最小。解决切分数据集时stratifydf[label]必须加上评测指标不要只看 accuracy用f1_macro或f1_weighted。如果某个意图的测试样本不足 50 条重新去补充这一类的真实线上语料不要让模型在不完整的数据上自欺欺人。5.2 脏标签污染测试集模型学对的你训错了现象传统基线在本地测试集上 F1 达到 89%一上线直接掉到 60%。原因测试集本身包含标注错误的样本。比如“发票开错了能作废吗”被标成“补开发票”人工复核时又没发现模型学的是“作废”特征测试集却要求它输出“补开”。解决标注规范里要明确同义词和易混类别的判定口径标注完成后随机抽出 10% 做二次复核。训练过程里把混淆矩阵里高频错分的句子全部打印出来人工过一遍错误标签修正后重新切分和训练。不要相信任何没经过抽检的标注数据。5.3 中文分词不一致让传统基线翻车现象、原因、解决现象同一个句子在训练时切分结果是“发票/抬头”在推理服务里变成“发/票/抬头”TF-IDF 特征对不上预测结果全乱。原因训练和推理用的 jieba 版本或用户词典不一致导致切分变化传统方法里分词直接决定特征差一个词结果就差很多。解决把训练阶段的分词结果直接保存成文本特征文件推理阶段严格加载同一个 jieba 词典更省事的做法是把 TF-IDF 的analyzer改成字符级直接用单字和二字词作为特征彻底绕开分词差异。意图识别任务对字符级特征的损失很小但工程稳定性提升明显。5.4 小数据硬上 BERT 过拟合冻结与早停的止损手段现象训练集 loss 一直降验证集准确率第 4 轮开始不涨反跌最终测试准确率还不如 TF-IDF 基线。原因只有几千条标注数据却让 BERT 的全部参数自由更新模型把训练集里的字面表达记熟了没学会泛化。解决先试三招第一招冻结前几层 embedding只微调后几层和分类头第二招把学习率降到1e-5第三招严格用evaluation_strategyepoch配合早停验证集不涨就停止训练。如果这三招做了仍然过拟合就说明数据量真的不够上 BERT回头用 BiLSTM 或传统方法不要让深度学习路线卡死整体进度。6. 把两条模型结果并排摆到同一张错误表前做最终决策6.1 让两种方法共享同一份测试集导出错误案例传统方法和深度学习方法最怕各测各的自说自话。我的习惯是两条模型训练完后写同一个脚本读取同一份测试集把预测结果和真实标签一起导出成 TSV再按错误逐条人工看results [] for text, true_label in zip(test[text], y_test): rule_out rule_first_predict(text) y_trad search.predict([text])[0] if rule_out is None else -1 try: y_dl, conf predict_with_threshold(bert_model, tokenizer, text) except Exception: y_dl, conf 其他, 0.0 results.append([text, le.classes_[true_label], 规则: str(rule_out), 传统: le.classes_[y_trad], 深度: y_dl]) import csv with open(pred_compare.tsv, w, encodingutf-8) as f: writer csv.writer(f, delimiter\t) writer.writerow([text, true, rule, ml, dl]) writer.writerows(results)导出的错误表直接丢给业务同事看让他们判断哪些错误更不可接受。意图识别不是一个纯算法问题业务方更清楚“把退换货判成物流查询”和“把物流查询判成其他”哪个代价更大。6.2 两条路线的适用性对比与线上策略维度传统方法深度学习方法训练成本CPU 分钟级GPU 小时级推理延迟毫秒级毫秒级但模型体积大小样本表现1 万条稳定容易过拟合新增意图难度加规则加数据即可需要重新微调错误可解释性高查权重词即可低黑匣子准确率上限中等高线上落地时我不是二选一而是三明治结构规则层优先拦截确定意图传统模型处理规则没覆盖的常见表达深度学习模型再兜一层难例最后统一过置信度阈值没把握的进人工。这个结构的好处是每换一层都能评估增量收益哪层效果不明显就下线哪层。6.3 自己重跑这个项目时的建议如果按这个项目方向重新走一遍我的建议是先别在深度学习方法上花时间用一到两天把数据切分、TF-IDF 基线和规则层跑通拿到第一份混淆矩阵。然后才上 BiLSTM 或 BERT用同一份测试集对比。很多时候你会发现传统基线已经帮业务解决了 80% 的问题剩下的 20% 难题恰好卡在标注质量和意图体系设计上而不是模型不够强。意图识别做得越久越会确认一件事数据版本管理和标注规范比换模型带来的收益更稳定。把每次实验的数据、代码、参数、指标都归档成可复现目录三个月后回来看你会感谢当时这个习惯。希望这篇文章给你的意图识别项目省下一些摸索时间帮到你。代码和数据集均按开源协议使用训练前把data/intent_dataset.csv换成你自己的语料即可复现全文流程。本文还有配套的精品资源点击获取
返回列表