这期是这套从基础到高级项目系列的第六篇。前几篇我们把分词、去停用词、词性标注这些基础模块一个个拆开讲过配套的脚本也都能跑了。但一直有读者私信说基础功能我都会用了可拿到一个真实项目还是不知道从哪下手。这篇文章就把这个卡点解决掉。我拿一个典型的文本分类需求——客服工单自动分派从原始语料开始一路做到模型评估完整走一遍。适合已经掌握Python基础、对NLP有基本概念、想通过完整项目把零散知识串起来的读者。1. 这一期要交付的东西一个能跑的客服工单自动分派系统1.1 为什么把文本分类当成第一个完整项目文本分类是NLP里应用面最广的任务之一。垃圾邮件识别、评论情感分析、新闻自动归档、工单自动流转本质都是分类问题。它的好处在于任务目标清楚、评价标准明确、从简单到复杂的跨度非常大。既能用传统机器学习方法快速实现一个可用版本又能在同一套数据上继续上深度学习方案非常适合作为基础到高级的过渡项目。前几篇学到的分词、停用词过滤、文本规范化在这里全部用得上前面提到过的词向量概念这一篇也会通过实际模型真正落地。项目做完之后你会对一个NLP项目的完整生命周期有直观认识之后再去看任何分类任务思路都会清晰很多不会被模型选型这个环节困住。1.2 场景设定与数据格式我用的场景是某电商平台的客服工单自动分派。用户提交反馈后系统需要自动判断工单属于哪个类别然后转到对应处理部门。类别我定了五个退款售后、物流配送、商品质量、账号安全、售前咨询。这是典型的短文本多分类问题类别之间有语义重叠比如商品坏了想退货既涉及质量又涉及退款处理起来比新闻分类这种类别界限清晰的场景更有挑战。类别标签类别含义典型表述refund退款售后买的东西有问题要退货退款logistics物流配送快递还没到查不到物流信息quality商品质量衣服掉色质量太差了account账号安全账号被盗有人用我的账号下单presale售前咨询这个商品支持分期吗数据格式用CSV两列label和text。实际干活的时候请保持这个习惯字段名用英文别用中文列名否则后续pandas处理时各种编码报错会找上门。label,text refund,收到的东西是坏的我要退货退款 logistics,快递都一周了还没送到客服电话也打不通 quality,衣服洗了一次就掉色质量太差了 account,我的账号被盗了有人用我的账号下单 presale,这个手机支持无线充电吗数据量建议至少准备一两万条。分类任务里数据量直接决定你后面能走多远太少的话传统方法可能还行深度学习方案基本没戏。没有现成数据的话可以自己构造一个模拟数据集或者找公开的中文语料做类别改写先把流程跑通再说。2. 语料清洗与预处理真正的第一道分水岭很多初学者把注意力全放在模型上这是我反复强调的问题文本分类项目里预处理阶段耗费的时间和精力经常超过建模本身。数据干净程度决定了模型效果的上限后面的模型只是在逼近这个上限。2.1 原始数据的编码、乱码与格式污染客服工单数据最常见的问题第一是编码混乱。拿到的CSV可能是UTF-8的也可能是GBK的有时候一个文件里两种编码混着来。我的做法是读取时先做编码探测不行就强制指定import pandas as pd try: df pd.read_csv(orders.csv, encodingutf-8) except UnicodeDecodeError: df pd.read_csv(orders.csv, encodinggbk, errorsreplace)errorsreplace这个参数值得记住它把无法解码的字节替换成替换符而不是让程序直接崩掉。排查阶段我宁可看到几个乱码占位符也不想让脚本中途挂掉。第二是文本里的格式污染。工单文本经常夹杂着电话号码、订单号、网址、邮箱、表情符号。这些对分类任务来说多数是噪声。电话号码和订单号是唯一标识出现一次和出现一百次对你的类别判断没有帮助反而会让模型学到有数字就是物流工单这种错误关联。我用正则统一清理import re def clean_text(s): s re.sub(r\d{6,}, [NUM], s) # 长数字串替换为占位符 s re.sub(rhttps?://\S, [URL], s) s re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9\s], , s) # 去掉特殊符号 return s注意这里的处理逻辑不是简单删除数字而是把订单号、电话号码这类长数字串替换成统一的占位符[NUM]。这样既保留了这个文本里有号码这个信息又避免了每个订单号变成独立特征导致维度爆炸。这个思路在真实项目里非常实用处理日志数据、客服记录时都通用。2.2 中文分词与停用词的实际操作中文没有天然空格分词是第一道绕不开的坎。我自己常用的方案是jieba兼容性和速度都够用。但jieba默认词典处理客服工单这种领域文本时效果一般建议把业务词加进自定义词典。比如售后七天无理由这些词被错误切分会影响分类效果。import jieba jieba.load_userdict(custom_dict.txt) # custom_dict.txt 每行一个词 # 七天无理由 # 售后 # 快递柜停用词表的选择比想象中影响大。网上随便找的停用词表有风险比如退货可能在某些通用停用词表里但在这个场景里它恰恰是最重要的特征词。我的建议是先不用停用词表跑一版模型然后看哪些高频词明显是噪声比如请问那个然后再针对性加进停用词表。上来就套个大停用词表很容易误伤业务关键词把模型最重要的信号源给掐了。2.3 标签分布检查与不均衡问题预处理阶段一定要做的一件事是检查标签分布。一行代码看每个类别的样本量print(df[label].value_counts())如果发现某个类别只占总量的一两个百分点后面训练时就要处理不均衡问题。最简单的处理方式是先跑基线模型看看效果如果少数类表现明显差再考虑类别加权或者过采样。不要一开始就上复杂的采样策略很多时候改一下损失函数的权重就够了。我在这个项目里就是先观察发现问题后采用了带权重的交叉熵具体在第四部分展开。3. 先用传统方法打出基线别一上来就上深度学习这是我在所有NLP项目里的固定习惯先做一套最简单的可运行方案用它当基线然后才有资格说深度学习更好。没有基线的情况下盲目上复杂模型你根本说不清效果好到底是模型的功劳还是数据处理的功劳。3.1 从词袋到TF-IDF为什么这一步决定了下限传统方法的第一步是把文本变成向量。词袋模型Bag of Words最简单统计每个词在文档中出现的次数。但纯词频有两个问题长文本天然词频高常用词的是在频率最高但没信息量。TF-IDF就是为了解决这两个问题出现的。TF-IDF的核心逻辑非常适合用一句话说透一个词在当前文档里出现越多越重要TF在整个语料里出现越少越稀有IDF两个一乘就是这个词对这个文档的代表性。退货在退款类工单里高频出现、但在整个语料里又不是每类都有它的TF-IDF值就会很高而的虽然每个文档都有但IDF接近0权重被压下来。sklearn的实现非常方便from sklearn.feature_extraction.text import TfidfVectorizer vectorizer TfidfVectorizer( token_patternr(?u)\b\w\b, ngram_range(1, 2), max_features50000, min_df2, ) X_train vectorizer.fit_transform(train_texts)几个参数的选择逻辑说一下。ngram_range(1, 2)表示同时使用单个词和相邻双词能捕捉一些词组信息比如物流配送拆开后是物流和配送双词组合能保留原有的词组语义。min_df2过滤掉只在一条样本里出现的词这些词要么是噪声要么是拼写错误留着只会增加维度。max_features50000给特征总数封顶防止高维稀疏矩阵把内存撑爆。3.2 线性分类器搭配TF-IDF的实践参数TF-IDF出来的是高维稀疏向量最适合和线性模型搭配。我通常先跑Logistic回归它训练快、可解释性强在文本分类上表现通常很不错。from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score model LogisticRegression(max_iter1000, C1.0, solverliblinear) scores cross_val_score(model, X_train, y_train, cv5, scoringf1_macro) print(scores.mean())solverliblinear在高维稀疏数据上速度优势明显max_iter一定要调大不然默认值经常不收敛直接报警。C值控制正则化强度默认1.0可以后面可以做个简单的网格搜索。我用这组参数在模拟工单数据上做了快速验证五折交叉验证的宏平均F1在0.86左右。对于一套不到50行代码的基线方案这个成绩不丢人。关键是它跑完只需要几十秒让你能快速知道数据的底子好不好。3.3 基线模型的评价逻辑基线模型的价值不体现在最后的数字上而在于它给了你一把尺子。后续任何改进——不管是清洗规则的加强、特征工程的变化还是换成深度学习模型——都要拿这个数字当锚点。改进后如果F1没有明显提升那说明你的改动方向可能错了。另一个容易被忽略的点基线模型还能帮你发现数据问题。我跑基线时发现售前咨询类别的F1只有0.5去翻数据才发现模拟数据里这类工单的文本长度特别短很多就四个字可以退货吗特征太少。这让我意识到短文本分类需要额外的处理思路也为后面深度学习方案怎么设计指明了方向。4. 进阶方案词向量加TextCNN的完整实现传统基线跑通之后上深度学习。选择TextCNN而不是更复杂的RNN或Transformer是因为它在短文本分类上效果扎实、训练速度快、代码量小作为从传统方法到深度学习的桥梁再合适不过。4.1 词嵌入的选择预训练还是自己训练深度学习方法的第一步是把文本变成词向量序列。这里有一个重要决策用别人预训练好的词向量还是自己从头训练。我的建议是分情况。如果语料规模不大几万条以内直接使用预训练的中文词向量如果你的业务场景词汇很特殊比如大量专业术语可以考虑在大规模领域语料上自己训练。工程实践里大多数人走的是预训练词向量微调的路子。在模拟项目里我选择自己训练词向量用gensim的Word2Vec这样整个流程可控也方便教学演示。嵌入维度取100窗口大小取5训练5个epochfrom gensim.models import Word2Vec sentences [jieba.lcut(text) for text in train_texts] w2v Word2Vec(sentences, vector_size100, window5, min_count2, epochs5) w2v.save(w2v.model)min_count2同样是为了过滤低频词。训练完之后把词向量矩阵保存下来作为Embedding层的初始权重。没在训练语料里出现过的词随机初始化即可用UNK统一标记。4.2 TextCNN网络结构与参数设计TextCNN的思路可以这样理解把文本矩阵当成一张图像用多个不同宽度的卷积核去扫描然后再池化。每个卷积核相当于在查找某种局部模式比如质量差这种三连词模式或者退款这种双词搭配。多个卷积核并行就能捕捉到不同长度和位置的特征。用PyTorch实现一个简洁版本import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, num_filters128): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, kernel_sizek) for k in (2, 3, 4) ]) self.dropout nn.Dropout(0.5) self.fc nn.Linear(num_filters * 3, num_classes) def forward(self, x): x self.embedding(x) # (batch, seq_len, embed_dim) x x.transpose(1, 2) # 转置为 (batch, embed_dim, seq_len) conv_out [] for conv in self.convs: h torch.relu(conv(x)) # (batch, num_filters, seq_len-k1) h h.max(dim2).values # 全局最大池化 conv_out.append(h) x torch.cat(conv_out, dim1) x self.dropout(x) return self.fc(x)三个卷积核宽度(2, 3, 4)分别对应二元、三元、四元词组模式128个卷积核是常用配置。为什么用最大池化不用平均池化因为分类任务关心的是有没有这个特征而不是这个特征出现的平均强度。只要某个局部模式出现过一次就应该强烈激活最大池化正好做这件事。4.3 训练过程的关键细节训练神经网络细节决定成败。这里说三个最容易出问题的点。第一是学习率。深度学习模型对学习率极其敏感学习率大了loss直接震荡或炸掉小了训练半天看不到变化。我习惯先设一个较小的初始学习率比如1e-3搭配学习率调度器在训练过程中逐步衰减。optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, factor0.5, patience3 )第二是早停Early Stopping。训练时监控验证集F1连续几个epoch没有提升就停止训练并保存最佳模型。这个机制能有效防止过拟合在训练数据只有一两万条的场景下尤其重要。第三是类别不均衡的损失函数。前面基线阶段发现的售前咨询类别偏少在深度学习阶段我用带权重的交叉熵损失来缓解。权重按类别样本量的反比计算让模型对少数类错判付出更大代价。class_weights torch.tensor([w_refund, w_logistics, ...], dtypetorch.float) criterion nn.CrossEntropyLoss(weightclass_weights)跑了20轮训练后TextCNN在验证集上的宏平均F1到了0.91左右比基线提升了约5个百分点。这个提升主要来自词向量对语义的建模能力比如钱包和账号这两个词TF-IDF眼里毫无关系但在词向量空间里距离很近因为它们在相似的上下文里出现过这个语义相似性被卷积层直接利用上了。5. 模型评估与错误分析决定项目能否落地的一步模型训练完不是终点。很多初学者看到准确率0.9就觉得完事了实际上一个分类项目能不能真正上线取决于你对模型错误的理解程度。5.1 准确率以外的指标精确率、召回率、F1在工单分派场景中准确率Accuracy是极具迷惑性的指标。假设售前咨询只占5%就算模型把这一类全部判错准确率也只损失5%看起来还是有95%的准确。所以分类评估必须看精确率Precision、召回率Recall和F1。用生活化的话说精确率是你说是退款的那些工单有多少真的是退款召回率是真正的退款工单有多少被你识别出来了。两者通常此消彼长F1是它们的调和平均。多分类时还要区分宏平均macro和微平均micro。我建议关注宏平均F1它把每个类别的表现平等对待不会让大类掩盖小类的问题。这也是我在前面一直用f1_macro作为评估指标的原因。5.2 混淆矩阵能告诉你什么混淆矩阵是分类评估里信息量最大的可视化工具。看它一眼模型系统性犯错的地方一目了然from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay import matplotlib.pyplot as plt cm confusion_matrix(y_true, y_pred) disp ConfusionMatrixDisplay(cm, display_labelsclass_names) disp.plot() plt.show()我在项目里发现了一个有意思的现象模型经常把商品质量判成退款售后。仔细想想这很合理——用户说衣服质量差想退货语义上确实同时涉及两个类别。这不完全是模型的问题而是工单标注本身存在类别定义重叠。解决办法有两个方向一是调整类别定义把退款售后重新整理成退款流程问题这种更聚焦的类别二是接受这种重叠在模型输出端加一个阈值判断对高置信度但不明确的样本转人工处理。5.3 逐条错误样本的人工复盘最后一步是抽错误样本逐条看。我习惯从混淆矩阵的每个非对角单元格里各抽5到10条打印出预测标签、真实标签和文本内容人工判断错误原因。原因通常分三类第一类是标注噪声也就是原始标签标错了模型学到的错误其实没错。这类不用管反而说明模型学到了正确规律。第二类是特征缺失。比如我的快递怎么还没到这条被判错了可能因为训练数据里物流类文本大量出现快递包裹而这条开头用了我的包裹恰好包裹在训练数据中不够常见模型没建立起关联。这说明预处理阶段对同义词归类做得不够。第三类是语言歧义。你们怎么还不发货到底是在催物流还是问预售时间人类也需要上下文才能判断。这类错误是模型的天花板单靠模型本身解决不了需要业务侧提供更多特征。错误分析做完你会对项目的可落地性有个明确判断哪些可以改进哪些是客观限制。6. 踩坑记录与后续扩展思路6.1 三个印象最深的坑第一个坑是内存爆炸。TF-IDF的max_features没设上限之前我把几万条工单的语料直接向量化特征维度到了上百万训练时内存直接打满程序卡死。后来加了特征数量上限和min_df过滤内存占用降到原来的十分之一不到。高维稀疏矩阵看着数据量小实际内存开销远超想象。第二个坑是分词错误导致模型效果上不去。让我印象特别深的是七天无理由退货这个高频短语jieba默认把它切成了七天/无/理由/退货七天和无变成两个独立特征后语义全丢了。加载自定义词典后这个短语被完整保留几个类别的区分度立刻提升。好的分词不是越细越好而是越准确越好。第三个坑是Embedding层和预训练词向量的词表对齐问题。我自己训练的Word2Vec词表和训练时构建的词汇表ID顺序不一致加载权重时全部错位模型效果惨不忍睹。排查了很久才发现是词表映射写错了。后来我统一用同一个词表生成函数先建词表再训词向量问题彻底解决。这个错误很隐蔽但发生频率不低大家做的时候多留个心眼。6.2 这个项目还能怎么升级如果想把项目往高级再推一步建议按这几个方向走数据层面把模拟数据换成真实采集的数据或者做数据增强比如同义词替换、回译把中文翻译成英文再翻译回来能有效扩充训练集。模型层面把TextCNN换成预训练语言模型在这个任务上通常能进一步把F1推到0.95以上但要注意推理速度和资源成本需要做权衡。工程层面加上模型版本管理、在线推理接口、人工复核队列就是一套基本完整的工业级文本处理流水线。方案宏平均F1训练耗时代码复杂度TF-IDF Logistic回归0.86约30秒低Word2Vec TextCNN0.91约5分钟中我自己做完这个项目最大的体会是文本分类的真正难点不在模型选型而在对数据的理解深度。同样的数据预处理细致程度不同模型效果的差距远超模型本身带来的差距。这个项目做下来预处理的每一步我几乎都返工重写过每一次返工都对数据多了一层理解这种被数据教育的过程恐怕是学习NLP最值钱的部分了。下一步我打算在同样一套数据上试试预训练语言模型的效果到时候再写一篇对比看看传统方案和前沿方案之间的差距到底有多大。