机器学习文本分类实战:从中文分词到TF-IDF与分类器对比
刚接触NLP的新人跑来问我十有八九会带同一个困惑“现在满世界都在聊大模型和深度学习我还需要学传统的机器学习文本分类吗”这个问题我在带新人和做实际项目时被反复问到。我的答案一直很明确需要而且非常需要。不管是经典的NLP入门任务还是真实业务里的新闻分类、评论情感分析、工单自动归档最底层的逻辑都一样——先把一段文字变成可计算的数字再交给一个可靠的分类器去判断。这篇超详细的实操记录就以“基于机器学习的文本分类”为线索从数据清洗、中文分词、词袋与TF-IDF特征构造到朴素贝叶斯、逻辑回归、SVM的实现对比最后把新手最容易踩的坑一并讲透。只要你有基础的Python知识这篇文章里的代码和思路就能直接拿去复现。1. 先想明白文本分类任务到底在解决什么问题1.1 任务目标其实很朴素文本分类说白了就是给一段文字贴标签。判断一条新闻属于体育还是娱乐判断一条商品评论是正向还是负向判断一封邮件是不是垃圾邮件本质都是同一个问题如何从“文本输入”映射到“类别输出”。这个映射规则怎么来不是靠人写死的关键词规则而是从大量已经标注好的样本里学出来。所谓标注样本就是每条文本后面已经给出了正确类别模型要做的就是“模仿”这种对应关系然后把这种能力泛化到没见过的文本上。这个任务适合作为NLP第一课是因为它把“让机器理解文字”这个宏大难题拆成了两个可以独立攻克的子问题一是如何把文本转成计算机能算的向量二是如何根据向量判断类别。当你分别搞懂这两个子问题后面再接触任何花哨的模型都不会觉得发怵。1.2 为什么这里刻意不用深度学习我知道很多同学一上来就想直接上BERT、上LSTM。但在这个任务里我非常不建议跳过传统机器学习。原因有三个。第一深度学习模型一旦跑起来数据量、显存、训练时间都会变成变量容易把真正的学习目标掩盖掉。你还没搞懂输入特征长什么样就先被训练loss和各种环境配置折磨得焦头烂额。第二传统机器学习方法尤其是线性模型有非常强的可解释性。你能直接看到每个词对分类结果的贡献方向和大小这在调错和做误差分析时是巨大的优势。比如“好吃”对“餐饮好评”这个类别贡献很大你一眼就能看出来从而判断模型学到的规律是不是合理的。第三很多真实业务场景的数据量远达不到深度模型充分训练所需的规模。朴素贝叶斯、逻辑回归、SVM在中小规模文本数据上依然非常能打。我见过不少线上项目为了控制成本和响应速度最终跑的还是轻量级模型。所以我对这个任务的定义是用最可控的方案把NLP的完整流程走一遍。它不是在教你“不要学深度学习”而是在帮你把地基夯实。1.3 评估指标别只盯着准确率很多新手跑完第一版看到准确率98%就欢呼。但准确率这个数字有很强的欺骗性。假设数据集中90%是体育类新闻你只要无脑全部预测成体育类准确率就有90%了。可你真正想要的是能把占比很少的那些类别也分对。所以文本分类中更常用的评估指标是精确率Precision、召回率Recall和F1值。多分类场景下通常还会算宏平均F1macro-F1和加权平均F1weighted-F1。这三个指标的区别用体育新闻的例子来说精确率你预测成“体育”的样本里有多少是真正的体育新闻。它衡量的是“我预测得准不准”。召回率所有真正的体育新闻里有多少被你成功找了出来。它衡量的是“我有没有漏掉”。F1是两者的调和平均在精确率和召回率互相冲突时给出一个综合分数。在实际任务中我不会只看单一指标而是把三个指标一起列出来再配合混淆矩阵做分析。后面的章节会展开讲这一点。2. 从文档到向量文本特征提取的完整链条2.1 第一步永远是清洗与分词原始文本不能直接喂给模型。在中文场景下第一道工序是清洗第二道是分词。清洗主要处理几类脏数据HTML标签和URL。爬下来的数据常带 这类标签还有大量链接基本和分类无关直接去掉。全角半角混乱。中文里容易混入英文标点可以用统一的转换脚本把全角字符转半角。无意义的短文本。比如只有几个字、没有实际内容的样本如果量不大可以考虑剔除。清洗之后是分词。中文不像英文天然有空格分词必须借助分词工具。目前最常用的是jieba简单可靠import jieba text 这家餐厅的菜味道不错下次还会再来。 tokens jieba.lcut(text) print(tokens) # [这家, 餐厅, 的, 菜, 味道, 不错, , 下次, 还会, 再来, 。]分词之后标点符号通常会被当成独立token。大部分情况下我们会把纯标点过滤掉因为标点对分类的帮助有限除非你要做情感分析感叹号可能有点意义但那是后续精细调优的事。这里要提醒一个经常踩的坑jieba词典对专业领域词汇可能切分不准。比如“机器学习”可能被切成“机器”和“学习”虽然这个例子碰巧也能表达含义但有些术语如“支持向量机”被切开后信息就丢了。解决办法有两种一是往jieba分词器里添加自定义词典二是先不做切词优化等模型效果不理想了再来处理。对任务一这种入门练习我的建议是先用默认配置跑通不要一上来就优化词典。2.2 词袋模型把每篇文章变成一个“词频清单”分词之后的下一步是把文本转成数值向量。最朴素的方法是词袋模型Bag of WordsBoW。词袋模型的想法非常直接先统计整个训练集中出现的所有词组成一个词表然后对每一篇文档统计每个词在这篇文档里出现了几次形成一个和词表等长的向量。举例说明假设词表是[“餐厅”, “好吃”, “电影”, “好看”]两句话“这家餐厅好吃” → 向量 [1, 1, 0, 0]“这部电影好看” → 向量 [0, 0, 1, 1]这个方法有两个明显的问题。第一向量维度等于词表大小几十万维是常事计算和存储压力不小。第二完全没有考虑词的顺序比如“不好吃”和“好吃不好”在词袋眼里可能差不多但实际上意思完全相反。不过词袋模型是理解文本特征工程的基石后面所有方法都是在这个基础上改进的。在scikit-learn里用CountVectorizer就能直接实现from sklearn.feature_extraction.text import CountVectorizer vectorizer CountVectorizer(max_features10000, min_df2) X_train vectorizer.fit_transform(train_df[text]) X_test vectorizer.transform(test_df[text])注意fit_transform和transform的区别先在训练集上fit建立词表再在测试集上只用transform保证测试集不会引入训练集里没见过的词这也是防止信息泄露的基本操作。2.3 TF-IDF给常用词降权给专属词升级词袋模型把“的”、“了”、“在”这种常见词也当成了和“好吃”、“电影”一样重要的词这显然不合理。“的”几乎每篇文档都有对分类毫无区分度。TF-IDF就是来解决这个问题的。TF-IDF全称是Term Frequency-Inverse Document Frequency由两部分组成TF词频即词在文档中出现的次数。IDF逆文档频率公式一般写作 log((总文档数)/(包含该词的文档数))再用平滑避免除零。IDF的核心思想是如果某个词在整个语料里到处都有它的IDF就低说明区分能力弱如果某个词只在少数文档中出现IDF就高说明这个词很“专属”区分能力强。TF-IDF就是两者的乘积。在scikit-learn里直接换用TfidfVectorizer即可from sklearn.feature_extraction.text import TfidfVectorizer vectorizer TfidfVectorizer(max_features10000, min_df2, ngram_range(1, 2)) X_train vectorizer.fit_transform(train_df[text])这里顺便说下max_features这个参数的取舍。限制最大特征数是防止维度爆炸的常用手段。在实际操作中我先用max_features5000跑一版再试10000、20000观察指标变化。通常超过某个值后效果提升变缓但内存和训练时间涨得飞快没必要无限往上加。2.4 N-gram让“不好”和“好吃”不再被拆散TF-IDF解决了词权重问题但依然没有解决词序问题。为了在不大幅增加复杂度的前提下保留局部词序信息可以用N-gram特征。N-gram就是把相邻的N个词合并成一个整体作为特征。以“非常好吃”为例unigram1-gram: “非常”, “好吃”bigram2-gram: “非常好吃”trigram3-gram: 再加一个词在TfidfVectorizer里设置ngram_range(1, 2)表示同时提取单个词和相邻两个词的组合作为特征。这样做的好处是“非常好吃”这个整体会成为特征模型能捕捉到比单独看“非常”和“好吃”更丰富的语义。代价是特征维度会膨胀。两个词的组合数量远多于单个词所以用N-gram时通常需要配合max_features或者min_df限制特征数量。一般来说文本分类任务里bigram就够用了trigram效果提升不大但维度涨得厉害性价比不高。3. 分类器实战四种模型在同一个数据集上的对比3.1 数据准备先固定训练集和测试集在进入模型之前有一个步骤必须做好数据集的划分。在NLP任务中我通常会按8:2或者7:3的比例划分训练集和测试集并且固定随机种子保证每次跑出来的结果一致方便对比不同模型的效果。from sklearn.model_selection import train_test_split train_df, test_df train_test_split( full_df, test_size0.2, random_state42, stratifyfull_df[label] )这里用stratify参数做分层采样让训练集和测试集中每个类别的比例保持一致。这是一个小细节但对不平衡数据集特别重要。如果数据集各类别样本量差别很大不做分层采样的话可能某次划分把稀有类别全分到了测试集导致训练时模型根本没见过这个类别。3.2 朴素贝叶斯简单但出奇好用的baseline朴素贝叶斯应该是文本分类任务里最经典的起手式了。它的核心是利用贝叶斯公式计算P(类别|文本)并假设特征之间相互独立。这个“独立假设”在现实中显然不成立因为词和词之间怎么可能独立呢“好吃”和“餐厅”明显有关联。但神奇的是即使假设不成立朴素贝叶斯在文本分类上依然表现不错。原因在于文本特征是高维稀疏的很多词确实很少同时出现在同一篇短文里独立假设的偏差在大量特征平均后会被抵消掉。在scikit-learn里MultinomialNB适用于词频或TF-IDF这种非负计数数据from sklearn.naive_bayes import MultinomialNB model MultinomialNB(alpha1.0) model.fit(X_train, train_df[label])MultinomialNB的超参数alpha是平滑项避免某个词在训练集中没出现过导致概率为0。默认alpha1.0也就是拉普拉斯平滑一般来说不用乱调。这个模型的优点是训练速度极快参数少几乎不需要调参。作为第一版baseline非常合适能快速验证整个数据流水线有没有问题。3.3 逻辑回归可解释性强输出概率自然逻辑回归虽然名字里带“回归”实际上是个分类器。它在线性模型的输出上套了一个sigmoid函数把结果映射到0到1之间表示属于某个类别的概率。多分类场景则用softmax做扩展。这个模型的优势有两个 一是训练速度快对高维稀疏特征友好 二是每个特征都有明确的权重系数可以直观解释哪些词对分类结果影响最大。from sklearn.linear_model import LogisticRegression model LogisticRegression(C1.0, max_iter500, random_state42) model.fit(X_train, train_df[label])正则化参数C需要重点关注。C越小正则化强度越大模型越不容易过拟合。文本分类的特征维度高我一般会先试默认的C1.0然后分别试0.1和10看看效果变化。如果训练集表现远好于测试集出现过拟合迹象就减小C如果两边都不行再考虑是特征工程的问题。3.4 线性SVM文本分类的经典强手很多有经验的算法工程师在中小规模文本分类任务上首选模型其实是线性SVMLinearSVC。SVM的核心思想是找一个超平面让不同类别的样本间隔最大化。对线性SVM来说它直接在原始特征空间中找这个平面。在文本分类这种高维稀疏场景下线性SVM配合TF-IDF特征几乎是黄金搭档。原因在于高维空间里样本往往线性可分或近似线性可分线性SVM不需要复杂的核函数就能取得不错的效果而且训练速度快。from sklearn.svm import LinearSVC model LinearSVC(C1.0, random_state42) model.fit(X_train, train_df[label])需要注意LinearSVC的loss函数默认是squared_hinge预测时输出的是决策函数值不是概率。如果你需要概率输出做进一步分析要么改用CalibratedClassifierCV包一层校准要么直接换逻辑回归。实际上在纯分类任务里SVM的决策函数值已经足够用于分类判断不一定非要概率。3.5 随机森林非线性模型的对照组上面三个模型都属于线性方法或概率方法。为了验证线性模型在文本特征上的有效性我通常还会加一个随机森林作为非线性对照。随机森林是由多棵决策树集成而成的模型能捕捉特征之间的非线性交互。但文本特征有两个特点让随机森林天然吃亏一是特征极度稀疏大量训练样本在某个维度上为0树模型的划分逻辑会倾向忽略这些“低频但关键”的特征二是不同词之间的重叠度很低树模型很难泛化到新的词组合。我用随机森林跑文本分类效果往往比线性模型差一截而且训练时间和内存消耗高得多。但这个对照非常有价值它能帮你确认当前数据集是否适合线性假设也方便在向别人汇报时说清楚“为什么不用树模型”。from sklearn.ensemble import RandomForestClassifier model RandomForestClassifier( n_estimators100, random_state42, n_jobs-1 ) model.fit(X_train, train_df[label])在对比实验中一个完整的做法是写一个简单的评估函数输出准确率、宏平均F1和加权平均F1from sklearn.metrics import classification_report def evaluate(model, X_test, y_test): y_pred model.predict(X_test) print(classification_report(y_test, y_pred, digits4))4. 结果分析混淆矩阵里藏着模型的真实短板4.1 不要只看F1混淆矩阵信息量更大跑完几个模型后初学者最容易做的事情就是比较F1分数选个最高的就完事。但真正有价值的分析才刚刚开始。我强烈建议每个模型都打印一下混淆矩阵。混淆矩阵是一个二维表行代表真实类别列代表预测类别。对角线上的数值是分对的样本数非对角线数值就是“把A类错分成了B类”的数量。这个矩阵能直接告诉你模型在哪些类别上混淆严重。比如在新闻分类里如果“娱乐”和“体育”经常互相误判说明这两个类别的文本特征有重叠模型可能把“明星”、“比赛”这些词混淆了。这个信息比一个孤立的F1值有价值得多。from sklearn.metrics import confusion_matrix import pandas as pd cm confusion_matrix(test_df[label], y_pred) cm_df pd.DataFrame( cm, index[f真实-{c} for c in label_names], columns[f预测-{c} for c in label_names] )4.2 误判样本分析数据质量决定模型上限看完混淆矩阵下一步是取出具体的误判样本人工阅读一遍。这一步看起来很原始但收获极大。我通常的做法是从测试集里随机抽取每个类别分错的样本逐条阅读记录下“为什么会分错”。常见原因有三种一是数据标注本身有误。真实项目里的标注质量不可能100%可靠如果发现测试集里某条样本的标签本身标错了那模型“分错”反而是合理的。这类样本要修而不是让模型去迁就。二是文本本身信息不足。一条文本只有“哈哈哈哈哈”四个字你要让它判断是娱乐还是生活人都不一定分得清模型当然也难。这种情况要么接受误差要么考虑融合更多上下文信息。三是特征表达有缺失。比如分词错误导致关键词语义丢失或者某些领域专有词不在词表里。这类问题可以通过加词典、加特征的方式解决。请记住一句话分析误判样本的时间永远值得花。它能告诉你模型和数据之间真正的问题在哪而不是让你盲目去调参。4.3 下一步怎么提升按优先级排序当基线模型跑通后继续优化通常遵循一个清晰的优先级顺序第一优先是数据。先检查类别的样本量分布再看有没有大量重复文本最后确认标注质量。数据没问题模型才有提升的空间。第二优先是特征。尝试将max_features调大或调小、把ngram_range从(1,1)改成(1,2)、对比CountVectorizer和TfidfVectorizer。每个改动跑一次对比实验记录指标。第三优先才是模型超参数。对逻辑回归调C对LinearSVC调C和loss对朴素贝叶斯调平滑参数alpha。超参数调优建议使用网格搜索GridSearchCV设定一组候选值让工具自动寻找最佳组合。调参的目标不是把测试集分数刷到最高而是在验证集上找到泛化能力最强的配置。如果为了测试集分数反复调参很容易过拟合测试集失去对真实数据的参考价值。5. 新手最容易翻车的五个细节与解决方案5.1 中文编码问题这个问题在Windows环境下特别常见。用pandas读取CSV或Excel文件时如果文件编码是UTF-8但读取时用了默认编码就会出现乱码。更尴尬的是有些文件本身混着多种编码读取时一部分正常一部分乱码。解决方案是导入数据时显式指定编码并对常见的几种编码做兼容检测df pd.read_csv(data.csv, encodingutf-8) # 如果报错尝试 df pd.read_csv(data.csv, encodinggbk) # 或者 df pd.read_csv(data.csv, encodingutf-8, errorsreplace)最稳妥的做法是拿到数据后先用一个简单的脚本统一转成UTF-8纯文本后续所有环节都基于统一编码处理能省下大量排查时间。5.2 停用词到底该不该去网上很多教程会告诉你加载停用词表把“的、地、得、了”这类词去掉。这个大方向没错但实际执行时要注意尺度。我踩过的坑是某些“停用词”恰恰是特定分类的关键词。比如“上”和“下”在新闻分类中可能是无关紧要的方位词但在某些特定类别里却是有信息量的。机械地套用一个通用停用词表有时候反而会删掉重要特征。我的建议是第一版流程里先不动停用词或者只删除纯标点符号。等做完误差分析如果发现模型确实被某些无意义高频词干扰再针对性地构造停用词表。这样每个操作都有依据而不是人云亦云。5.3 数据不均衡的陷阱假设一个数据集有10个类别其中一个类别的样本量只有其他类别的十分之一。模型为了追求整体准确率很可能会把稀有类别全部预测错因为它只要把大多数样本分对准确率数字就很好看。应对不均衡主要有几个思路使用宏观F1作为主要评估指标让每个类别对最终分数的权重一致对少数类做上采样或在训练时给少数类更高的样本权重如果测试集和训练集都来自同样的不均衡分布那么模型预测时输出的先验概率已经包含了分布信息只要不过度偏向多数类问题通常不大。在实战中我最常用的是在模型层面设置class_weight参数比如逻辑回归的class_weightbalanced它会根据类别频率自动调整权重。5.4 随机种子不固定实验结果无法复现这是个非常容易被忽视的问题。如果不设置随机种子每次运行train_test_split或者模型训练结果都可能略有不同。不同模型之间的差异可能被随机性掩盖导致你分不清哪个模型真正更好或者几个人合作时对不上结果。在代码开头统一设置随机种子是实验工程化的基本素养import random import numpy as np random.seed(42) np.random.seed(42)scikit-learn中的模型凡是有random_state参数的统一传同一个值。这样整个实验过程可以复现也方便后续对比不同特征组合、不同模型的效果。5.5 训练集和测试集的特征不一致这个问题我见过很多次。有些同学在训练集上做了TF-IDF向量化然后在测试集上也单独fit了一次这会导致词表不一致两个数据集的特征维度对不上模型直接报错或者效果骤降。正确的做法是只在训练集上fit然后用同一个已经fit好的向量器去transform测试集。这个过程在2.2节提到过我这里再强调一次任何一个基于统计的特征工程步骤比如计算TF-IDF、最大最小值归一化、缺失值填充都必须遵循“fit on train, transform on test”的原则。测试集的数据只能用来模拟模型上线后遇到的全新数据任何统计信息都不能提前泄漏给它。做这个任务最大的收获其实不是把准确率刷到多高而是亲手把“从文本到数据”、“从数据到模型”、“从模型到结论”这三段路径完整走了一遍。我在带新人时经常说一个合格的NLP工程师必须能回答三个问题你的特征在表达什么你的模型在学什么你的指标在衡量什么。这篇文章把这三个问题都过了一遍。如果你照着流程跑通了任务一并且能清楚地解释每一步为什么要这么做那你就已经比很大一部分只会上模型调库的人扎实多了。