ARTICLE DETAIL

资讯详情

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

多类别文本分类Python实战:从TF-IDF到模型调参的完整方案

多类别文本分类Python实战:从TF-IDF到模型调参的完整方案 简介基于Python实现多类别文本分类的课程设计完整资源包面向需要完成NLP文本分类项目的Python学习者与数据科学初学者。项目从数据预处理、特征工程到朴素贝叶斯、SVM、深度学习等模型均有覆盖包含完整代码组织与可运行的Python脚本适合作为毕业设计或课程设计的参考实现。压缩包共24个文件以9个Python脚本为核心涵盖配置、数据加载、模型构建、预测与可视化辅以5个txt标注数据及说明、3个csv数据集、1个TF-IDF向量文件、1个LDA主题模型与词向量bin等整体约27.89MB目录结构清晰便于按模块调用。已有520人学习下载。资源包不仅提供从数据处理到模型训练评估的完整链路还通过lda模型、w2v.bin和tfidf文件帮助理解主题建模与文本向量化细节同时附带混淆矩阵等可视化图片可直接对照学习多类别文本分类流程、特征提取与超参数调优方法对开展NLP课程设计或入门文本分类实践具有较强参考价值。1. 多类别文本分类一个zip要解决的现实问题客服工单自动分派、新闻资讯自动打标、评论内容分类背后都是同一个问题给定一段文本从预定义的多个类别中选出唯一正确的那个。这个zip里的Python方案做的就是这件事——把原始文本读进来经过清洗、特征抽取、模型训练最终输出一个能对新文本预测类别的pipeline。你不需要自己从零搭框架但需要理解每一步的参数和边界。适合有Python基础、想快速跑通多类别分类基线又不想只调包不调参的开发者。2. 数据准备与预处理把原始文本变成模型能吃的样子2.1 标签体系设计多类别 vs 多标签先别搞混多类别分类multi-class每个样本只有一个正确标签多标签分类multi-label每个样本可以同时属于多个类别。标题里的“多类别”通常指的是前者。如果把多标签数据硬按多类别做模型会学出错误的条件分布因为一个样本的多个标签彼此不是互斥的。打开zip里的数据文件前先确认原始标注是哪种格式。我接触过不少项目标注人员用逗号分隔多个标签直接用LabelEncoder编码就会把一个组合当成一个独立类别类别数膨胀到几百个模型基本不可用。如果确认要做多类别建议先把数据整理成两列text和label。其中label必须是离散的字符串或整数。用pandas快速探查一下类别分布import pandas as pd df pd.read_csv(news.csv, sep,) print(df.head()) print(df[label].value_counts()) print(f样本总数: {len(df)}类别数: {df[label].nunique()})这段代码输出每个类别的样本量目的是确认类别数量是否在可接受范围以及是否严重不均衡。如果某个类别只有个位数样本后续就需要做欠采样/过采样或调整损失函数。注意label列如果存在前后空格或大小写差异要先统一做strip否则同一类会被拆成多个类。另外类别名称尽量不要用中文直接传进sklearn有些模型要求标签是[0, n_classes-1]的连续整数建议统一用LabelEncoder做一次映射并把映射表存成JSON方便预测时还原。from sklearn.preprocessing import LabelEncoder import json le LabelEncoder() df[label_id] le.fit_transform(df[label]) print(dict(zip(le.classes_, le.transform(le.classes_)))) with open(label_map.json, w, encodingutf-8) as f: json.dump({str(c): int(i) for i, c in enumerate(le.classes_)}, f, ensure_asciiFalse)fit_transform会把字符串类别按字母排序后转成整数索引。保存映射表的目的是让训练脚本和线上预测脚本使用同一个映射避免线下和线上标签错位。很多翻车现场都是因为在两个环境里分别fit了一次导致同一类别被编成不同数字。2.2 文本清洗与分词中文场景下的最小必要操作文本清洗不是越复杂越好。对于多类别分类很多项目翻车不是因为没上BERT而是清洗逻辑污染了原始信息。我一般只做四件事去HTML标签如果有爬虫数据、统一全角半角、去空白字符、按业务需要决定是否去停用词。不要一上来就删标点符号——中文里“你赢了。”和“你赢了”的情感完全不同但对主题分类影响不大。如果你做的是情感极性分类问号和感叹号可能是有用的信号这时候保留标点反而更好。分词是中文特有的步骤。常见做法是使用jieba停用词表按场景维护。下面是一个标准预处理函数import re import jieba import joblib stopwords set() with open(stopwords.txt, r, encodingutf-8) as f: for line in f: stopwords.add(line.strip()) def clean_text(text): text re.sub(r[^], , text) # 去HTML text text.replace(\u3000, ) # 全角空格 text text.strip() return text def cut_text(text): words jieba.lcut(clean_text(text)) return .join([w for w in words if w not in stopwords and w.strip()]) # 测试 print(cut_text(这款手机续航很好但屏幕容易摔坏。))jieba.lcut返回分词列表用空格拼接成字符串方便后续CountVectorizer或TfidfVectorizer直接处理。注意停用词表不是必须的在短文本分类中过度去停用词可能把否定词删掉如“不”导致语义翻转。建议保留一个最小停用词表只去掉无意义的语气词和标点。参数说明jieba.lcut默认使用精确模式“容易摔坏”会被切成“容易/摔坏”。如果业务词表包含专业术语需要加载自定义词典jieba.load_userdict(userdict.txt)否则“深度学习”可能被切成“深/度/学习”。另外cut_text每次都会重新加载停用词表如果样本量大建议把停用词表设为全局变量或者用functools.lru_cache缓存分词结果。2.3 训练集/验证集/测试集划分分层抽样的代码实现多类别分类里随机划分会让小类别在训练集和验证集中分布不均最好的做法是分层抽样。sklearn里的train_test_split支持stratify参数from sklearn.model_selection import train_test_split X_train, X_temp, y_train, y_temp train_test_split( df[text], df[label_id], test_size0.3, stratifydf[label_id], random_state42 ) X_val, X_test, y_val, y_test train_test_split( X_temp, y_temp, test_size0.5, stratifyy_temp, random_state42 ) print(f训练集: {len(X_train)}验证集: {len(X_val)}测试集: {len(X_test)})这里先把总体的30%划出作为temp再从temp中按50%划分验证集和测试集最终比例是70%训练、15%验证、15%测试。stratify必须传入与样本一一对应的标签序列这样每个子集中各类别占比与原数据集一致。如果训练集只有几千条且类别很多可以适当调大test_size但要保证每个类别在测试集里至少出现一次。划分后不要再动原始数据。后续特征提取必须在训练集上fit验证/测试集只做transform避免信息泄漏。我见过有人先对整个数据集做TF-IDF再划分训练测试集结果测试集里的词频统计已经偷看了训练集信息导致验证指标虚高。正确做法是把向量化器和模型放进同一个Pipeline让fit和predict的流程自动隔离。3. 特征工程与模型选型从TF-IDF到深度学习的取舍3.1 TF-IDF经典管线为什么仍然值得用对于多类别文本分类TF-IDF加线性模型是性价比最高的基线。它把分词后的文本转成稀疏向量每个词根据它在当前文档中的出现频率和在整个语料中的逆文档频率加权。优点是训练极快、可解释性强、对硬件要求低。在没有GPU的机器上处理几万条样本通常只要几十秒。sklearn实现中的关键参数是ngram_range和max_features。ngram_range(1,2)能捕捉“不_好”这类否定结构但会线性增加特征维度。一般先试(1,1)如果欠拟合再扩展。下面是完整管线from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline pipeline Pipeline([ (tfidf, TfidfVectorizer( ngram_range(1, 2), max_features50000, sublinear_tfTrue, min_df2, max_df0.9 )), (clf, LogisticRegression(max_iter1000, multi_classmultinomial)) ]) pipeline.fit(X_train, y_train) val_acc pipeline.score(X_val, y_val) print(f验证集准确率: {val_acc:.4f})min_df2表示至少在2个文档中出现的词才保留用于过滤低频噪音max_df0.9表示在90%以上文档中都出现的词会被忽略因为这些词往往没有区分度如“的”“了”如果之前没去掉的话。sublinear_tfTrue对词频做log缩放避免长文档的词频爆炸。LogisticRegression用multinomial模式本质是softmax回归在多分类场景下的表现通常比一对多模式更稳定。这里的Pipeline把向量化和分类器绑在一起好处是后面做交叉验证或网格搜索时参数名可以按tfidf__max_features、clf__C的方式直接传给GridSearchCV。如果你的机器内存有限max_features可以降到20000但要注意高频词的覆盖率。通常先跑一次fit后用len(pipeline.named_steps[tfidf].vocabulary_)看实际特征数再决定是否调小。3.2 词向量与预训练模型什么时候值得上BERT当数据量很少几千条且类别很多时TF-IDF往往比微调BERT更稳因为预训练模型在小数据上容易过拟合且类别间的模式还没被充分学到。当数据量达到数万条、类别语义相近如“娱乐八卦”和“娱乐新闻”词向量模型能更好利用上下文。常见做法是先用sentence-transformers或BertModel提取句向量再接一个逻辑回归。好处是不用微调整个BERT训练速度可控。示例from sentence_transformers import SentenceTransformer from sklearn.linear_model import LogisticRegression # 第一次会下载模型之后会缓存 model SentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2) X_train_vec model.encode(X_train.tolist(), show_progress_barTrue) X_val_vec model.encode(X_val.tolist(), show_progress_barTrue) clf LogisticRegression(max_iter1000) clf.fit(X_train_vec, y_train) print(f验证集准确率: {clf.score(X_val_vec, y_val):.4f})这里用多语言MiniLM作为句向量模型中文效果不错且体量小。encode返回的是固定维度的向量该模型为384维不需要额外做词表。缺点是推理时仍要跑一次BERT单条CPU约几十毫秒高并发场景要考虑GPU或换更小的蒸馏模型。如果机器内存有限建议对向量做归一化或PCA降维。我自己试过降到128维后准确率几乎不变训练时间却缩短一半。另外句向量模型在短句上表现好如果输入是长新闻直接encode会把整篇压成一个向量容易丢失局部关键信息。这时候不如分段编码再取平均或者继续用TF-IDF路线。3.3 三类模型的对比与选择矩阵模型路线训练速度可解释性小样本表现长文本硬件要求TF-IDF LR极快高稳定需截断纯CPUTF-IDF 树模型快中稳定需截断纯CPU预训练句向量 LR中低易过拟合支持长文本建议GPU推理如果你的数据是短文本小于100字TF-IDF加LR足够。如果是新闻等长文本并且标题、正文都有先拼接后截断到512字或分段取平均。不要盲目上深度学习先把TF-IDF基线做出来它能告诉你任务的下限有多高。一个经验值是TF-IDF基线在大多数中文新闻分类上能做到0.85以上的宏平均F1这时再上BERT可能只提升1-2个点却要付出十倍训练成本。4. 训练与调参多类别分类的损失函数和关键参数4.1 softmax与交叉熵多类别分类的标准配置在神经网络中多类别分类一般输出层用softmax损失用交叉熵。softmax把所有类别的预测分数变成概率分布交叉熵衡量真实标签分布与预测分布的差异。如果是自定义PyTorch模型代码套路是这样import torch import torch.nn as nn import torch.nn.functional as F class TextClassifier(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.fc nn.Linear(embed_dim, num_classes) def forward(self, x): emb self.embedding(x).mean(dim1) # 平均词向量 logits self.fc(emb) return logits model TextClassifier(vocab_size50000, embed_dim128, num_classes20) loss_fn nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3)注意nn.CrossEntropyLoss已经内置了softmax所以模型的output层不需要额外加softmax。训练时传入的logits和真实标签索引即可。如果自己组合softmax和交叉熵容易遇到数值不稳定问题建议直接用内置损失。padding_idx0让填充符的向量不参与梯度避免把无效位置的信息带入平均池化。这里的mean(dim1)是简单的平均池化它假设每个词对分类的贡献相同。如果想要更强的特征交互可以换成注意力池化或最大池化。但基线阶段平均池化够用了重点是先把训练管线跑通。4.2 类别不均衡class_weight与Focal Loss多类别分类最常踩的坑是类别不均衡。比如投诉工单里“咨询”类占90%“投诉”类占5%剩下5%分布到多个小类。如果直接用默认参数模型会学到“全预测咨询”也能达到90%准确率。常规解法有三种。第一种是给交叉熵加class_weight。sklearn的LogisticRegression可以直接传class_weightbalancedclf LogisticRegression( max_iter1000, multi_classmultinomial, class_weightbalanced )balanced会自动按n_samples / (n_classes * np.bincount(y))给每个类别加权小类别获得更高权重。这是最简单有效的手段但也可能让小类别过度放大导致在精确率与召回率间摇摆。所以调完class_weight后一定要看宏平均F1不要只看准确率。第二种是采样。对训练集做少数类过采样如SMOTE或多数类欠采样。文本分类中直接对原始文本过采样容易过拟合常见做法是先TF-IDF再对向量做SMOTE。但要小心SMOTE要在训练集划分后再做否则会制造出同时出现在训练集和测试集中的合成样本导致指标虚高。第三种是Focal Loss。它修改交叉熵对高置信度的正确样本降权让模型更关注难分样本。PyTorch实现如下class FocalLoss(nn.Module): def __init__(self, gamma2.0, alphaNone): super().__init__() self.gamma gamma self.alpha alpha def forward(self, logits, targets): ce F.cross_entropy(logits, targets, reductionnone) p torch.exp(-ce) loss (1 - p) ** self.gamma * ce if self.alpha is not None: alpha_t self.alpha[targets] loss alpha_t * loss return loss.mean()gamma越大对易分类样本的抑制越强。alpha是每个类别的权重向量类似class_weight。Focal Loss在目标检测里常用在文本分类中适合小类非常稀少且难以区分的情况。但要注意如果小类本身噪音很大加权后模型会强行学噪音导致验证集虚高、线上翻车。4.3 调参策略网格搜索还是随机搜索对于TF-IDF LR这种轻量管线网格搜索完全能跑完。关键参数是C正则化强度、tfidf__ngram_range、tfidf__min_df。示例from sklearn.model_selection import GridSearchCV param_grid { tfidf__ngram_range: [(1, 1), (1, 2)], tfidf__min_df: [1, 2, 5], tfidf__max_df: [0.9, 1.0], clf__C: [0.1, 1, 10] } grid GridSearchCV( pipeline, param_grid, cv3, scoringf1_macro, n_jobs-1, verbose1 ) grid.fit(X_train, y_train) print(最佳参数:, grid.best_params_) print(最佳得分:, grid.best_score_)这里用了scoringf1_macro在多类别不平衡任务中宏平均F1比准确率更能反映模型对小类的处理能力。cv3是三层交叉验证每个组合训练3次搜索空间是232*336组总共训练108次。对万级样本可能需要几分钟到十几分钟。如果资源紧张改用RandomizedSearchCV先随机跑20组观察参数范围再收窄。一个血泪经验是网格搜索得到的最佳参数在验证集上往往略好于测试集但如果增大C导致训练集F1为1.0而验证集低很多那就是过拟合了需要降低C或增大min_df。5. 多类别文本分类的5个真实踩坑记录5.1 预测结果全是同一个类别现象训练完成后测试集中每个样本的预测结果都是“其他”类准确率等于该类占比。原因最可能是类别严重不均衡模型已经过拟合到多数类或者损失函数没有给少数类足够权重。其次是标签编码出错比如LabelEncoder在训练集和测试集上分别fit导致类别编号错位。解决先检查pipeline.classes_和label_encoder.classes_是否完全一致。再看训练集的value_counts()如果最大类占比超过85%先做分层抽样和class_weightbalanced。如果仍然无效检查训练过程中loss是否下降——如果loss从一开始就不降大概率是批次内标签全部相同梯度方向被多数类带偏。5.2 验证集F1很高线上预测效果崩了现象离线验证F1 0.92上线后新文本准确率不到0.8。原因预处理逻辑不一致。比如训练时把文本做了繁体转简体线上没做或者训练时用的是空格连接分词结果线上接口直接用原始文本也可能是线上传入了长文本被截断截断位置影响关键信息。解决把清洗和分词函数单独抽成preprocess.py在训练脚本和线上服务中引用同一个模块。写一个单元测试用5条固定文本分别跑训练时预处理和线上预处理对比输出字符串是否一致。我一般会在模型包里附带一份preprocess_note.txt记录每个样本的清洗前后示例方便排查。5.3 类别数量多导致训练慢、显存占用大现象类别从10个扩展到500个后训练时间增加不是5倍而是20倍甚至OOM。原因模型输出层尺寸等于类别数Linear(vocab_size, num_classes)的内存和计算量随类别数线性增长。同时softmax要对每个类计算指数反向传播梯度矩阵也变大。如果类别数上千训练每个batch都要算全体的概率。解决先确认是不是真的需要那么多类别。很多时候500个类别中有300个是低频且相似的可以先合并成10个粗类再做二级分类。如果必须用多类考虑把输出层换成Label Smoothing并开启混合精度训练。在工程上最常用的是“先粗分类再细分类”的两级模型训练和推理都可以并行。5.4 网格搜索卡死以为程序挂掉了现象GridSearchCV(n_jobs-1, verbose1)运行半小时还在第一个参数组CPU占用100%。原因n_jobs-1在Windows上会启动多个Python进程如果数据量大或特征维度高内存会先崩而不是提高速度。此外如果自定义分词器里有全局锁多进程不但不加速反而会互相等待。解决先不要用n_jobs-1改成n_jobs1跑一组小参数验证可行性。确认单组时间后用RandomizedSearchCV替代网格搜索并设置n_iter10。在Linux服务器上再开多进程。如果用的是jieba分词建议在搜索前先离线把文本切好词用空格拼接存入新列向量化时直接传分词后的字符串这样搜索时无需重复分词。5.5 新文本出现训练时没见过的词或类别现象模型对新词预测仍给一个类别但明显是错的或者业务方临时新增一个类模型无法输出。原因词表外词OOV被直接忽略文本特征向量全是零模型只能输出偏置最大的类。新增类别需要重新训练因为输出层维度变了。解决对OOV最简单的做法是保留一个UNK词把所有不在词表内的词映射到它。对于新增类别不要继续finetune整个模型而是把新类别数据与旧数据合并后重新训练或者采用增量学习方案。在工程上更稳妥的是预先约定类别体系上线前冻结类别列表新增类别走审批流程而不是临时改模型。6. 效果验证与落地混淆矩阵和分类报告怎么看6.1 用混淆矩阵定位易混淆类别多类别分类只看准确率不够。用混淆矩阵能直观看到哪些类别互相打架from sklearn.metrics import confusion_matrix, classification_report import matplotlib.pyplot as plt import seaborn as sns y_pred pipeline.predict(X_test) cm confusion_matrix(y_test, y_pred) plt.figure(figsize(10, 8)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues) plt.xlabel(Predicted) plt.ylabel(True) plt.show()如果cm[i][j]较高说明真实类别i的样本经常被预测成j那么这两个类别语义上可能过于接近。常见做法是回到原始数据看这些样本的文本分布也许需要增加特征或合并类别。6.2 宏平均与微平均不平衡数据该看哪个classification_report会输出每个类别的precision/recall/f1以及两个汇总指标。micro是全样本统计受大类别主导macro是各类别指标的平均小类别和大多数类权重相同。对于工单分类这种长尾分布我建议以macro f1为主同时打印每个类别的支持量。如果某个小类别recall为0不要急着调参先看它是否训练样本太少少到无法学出规律。6.3 模型导出与预测管线当验证达标后把pipeline和标签映射一起保存import joblib joblib.dump(pipeline, model.pkl) joblib.dump(label_encoder, label_encoder.pkl)预测时加载并调用text 我的订单三天没发货客服也没回复 cut cut_text(text) prob pipeline.predict_proba([cut])[0] top3 prob.argsort()[-3:][::-1] for idx in top3: print(label_encoder.inverse_transform([idx])[0], prob[idx])注意predict_proba返回的是类别索引的概率而不是类别名的排序务必通过label_encoder做映射。如果还想要模型上线后的可观测性可以把每次预测的top3概率写入日志遇到低置信度样本再人工复核。我自己的习惯是上线前一定留一份全量测试集的预测结果和真实标签CSV每跑一版留存方便回归对比。多类别文本分类的迭代是慢工出细活希望帮到你。本文还有配套的精品资源点击获取
返回列表