简介:基于Python的文本分类系统源码包,面向机器学习初学者和自然语言处理入门者,可用于课程设计、毕业设计,或快速搭建文本分类基线。项目依托K近邻、朴素贝叶斯、支持向量机、逻辑回归、决策树与随机森林六种经典算法,完整覆盖文本预处理的去空格、转小写、分词、词性标注和词形还原,并利用TF-IDF完成特征提取,对多个模型进行训练与准确率对比,还能将中间数据保存为便于后续处理的表格格式。压缩包共8个文件,以Python脚本、文本样本、处理后的数据文件及说明文档为主,整体仅3.7MB,结构清晰、运行门槛低。已有69人学习下载,适合希望系统掌握传统机器学习文本分类实操流程的读者;借助源码可以直观看到从原始文本到模型评估的每一处实现细节,并对比不同分类器的效果差异,为后续调优或扩展提供基础。
1. 一套能落地的 Python 文本分类系统,代码只是最后一步
下载一个"基于 Python 的文本分类系统"源码包很快,但真正让它在你自己的数据上产出可用结果,靠的不是某段神奇代码,而是数据清洗、特征工程、模型训练、评估调参和上线验证这条完整链路。文本分类是自然语言处理里最常被问到的落地场景:客服工单自动分派、新闻或评论归类、垃圾内容过滤,本质都是给一段文本打上预定义标签。这套源码包通常已经带好了脚本、示例数据和模型文件,但换到真实语料时,训练集质量、分词效果、类别分布和特征维度才是决定系统好坏的地方。下面按工程视角拆这条链路,覆盖从原始文本到特征向量、从模型训练到评估调参、从模型导出到接口验证的每一步,适合正在搭建第一个分类模型、或者拿到了源码包但不知道怎么改造和排错的开发者。
2. 文本分类的数据链路:清洗、分词与特征向量化
大多数文本分类源码包在自带数据集上效果不错,换到用户自己的语料就崩,原因基本不在模型,而在数据预处理。真实文本里有 HTML 标签、URL、全角符号、乱码编码和大量噪音词,这些都会直接进入特征空间。所以这一章先讲数据链路的三段式处理:文本清理、中文分词、特征向量化。这三步做扎实,后面模型训练才有意义。
2.1 先处理数据再谈模型:文本清理的三个必做动作
第一件必须做的是统一编码。用open()读取语料时指定utf-8,遇到坏字节用errors="ignore"兜底,避免一个乱码字符让整个进程抛异常。第二件是去除与分类无关的结构噪音,包括 HTML 标签、URL、邮箱地址。第三件是归一化空白和全角字符,中文文本里经常混入全角空格和特殊空白字符,不处理会分裂同一个词。
import re def clean_text(raw: str) -> str: if not isinstance(raw, str): return "" raw = raw.replace("\u3000", " ") # 全角空格归一为半角 raw = re.sub(r"<[^>]+>", " ", raw) # 去掉 HTML 标签 raw = re.sub(r"https?://\S+", " ", raw) # 去掉 URL raw = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", " ", raw) # 只保留中英文与数字 raw = re.sub(r"\s+", " ", raw).strip() # 压缩连续空白 return raw这段函数的处理顺序是有讲究的:先去标签再去 URL,因为 URL 里可能包含<>这类字符;最后才做字符白名单过滤,避免正则把已经清理过的文本再次破坏。第四行正则会把所有标点符号替换成空格,对大部分中文分类任务影响不大,但如果你的类别依赖表情符号或特定标点,比如判断评论情绪时!!!是信息量,这一行就要去掉。清洗函数写好后,对全量语料跑一遍并保存成新文件,训练和预测阶段必须使用同一个清洗函数,这是源码包改造里最容易漏的一步。
2.2 中文分词:jieba 的默认行为与两个工程参数
中文不像英文按空格切词,分类系统里几乎逃不开分词环节。常见做法是用 jieba,开源、轻量、对工业场景够用。但默认设置有两个坑:一是会打印大量 INFO 日志,训练时刷屏,用setLogLevel(20)关掉;二是默认开了 HMM 新词发现,对同一段文本每次切分可能出现细微差异,在需要结果可复现的测试环境里,显式传HMM=False更稳妥。
import jieba jieba.setLogLevel(20) # 关闭 INFO 日志,避免训练时刷屏 def tokenize(text: str) -> list[str]: # lcut 直接返回 list,cut 返回生成器 return [w for w in jieba.lcut(clean_text(text), HMM=False) if w.strip()]领域词库是这个环节最值得投入的优化点。电商数据的"退货包运费"、IT 工单里的"蓝屏代码",让通用词典去切通常会被拆碎。jieba.load_userdict("domain_words.txt")可以从外部文件加载领域词,每行格式为"词 词频 词性",词频越大越倾向被识别为独立词。另一个常用参数是停用词表,把"的、了、吗、呢"这类无区分度的词过滤掉,减少特征维度:
STOP = set(line.strip() for line in open("stopwords.txt", encoding="utf-8")) def tokenize_with_stop(text: str) -> list[str]: return [w for w in tokenize(text) if w not in STOP and len(w) > 1]注意len(w) > 1会过滤掉单个汉字,这对大部分场景是对的,因为单字通常是噪音;但如果你的语料里有"退""换"这类单字强信号词,需要单独评估。停用词表不要直接抄网上的通用版本,收集自己训练集里Counter频率前 100 的词,人工扫一遍,把和分类任务无关的高频词加进去,比任何现成表都有效。
2.3 特征向量化:TfidfVectorizer 的工程参数
拿到分词结果后,需要把变长文本转成模型能吃的定长向量。虽然现在有很多预训练 embedding 方案,但 TF-IDF 仍然是文本分类性价比最高的起点:训练快、可解释、在小数据集上经常比深度模型更可靠。scikit-learn 的TfidfVectorizer封装了词频统计、逆文档频率计算和 L2 归一化,工程上只需要关注几个参数。
from sklearn.feature_extraction.text import TfidfVectorizer vectorizer = TfidfVectorizer( tokenizer=tokenize_with_stop, ngram_range=(1, 2), min_df=2, max_df=0.8, max_features=20000, sublinear_tf=True, ) X = vectorizer.fit_transform(corpus_texts) # corpus_texts 是清洗后的原始文本列表 print(X.shape) # (样本数, 特征数)参数选择直接决定特征空间的规模和有效性,几个核心参数的推荐值如下表:
| 参数 | 作用 | 推荐设置 | 调参方向 |
|---|---|---|---|
ngram_range | 是否纳入相邻词组合 | (1, 2) | 数据量小且领域词固定时尝试(1, 2);数据量大再退回(1, 1) |
min_df | 低于该文档频率的词被丢弃 | 2或3 | 语料大时提到5,有效降噪 |
max_df | 高于该比例的词被丢弃 | 0.8或0.9 | 去除"的、我"这类全语料高频词 |
max_features | 保留的最大特征数 | 20000 | 配合min_df使用,过大会拖慢训练 |
sublinear_tf | 词频取对数压缩 | True | 对长文本效果明显,保持开启 |
这里有个容易踩的性能坑:TfidfVectorizer(tokenizer=callable)时,sklearn 会对每个样本调用一次 Python 函数,分词成了全流程最慢的环节。语料几十万条时,建议先离线把清洗和分词结果写入joblib或 parquet 文件,再用tokenizer=str.split读取预切分好的词列表,训练速度能提升一个数量级。
3. 用 scikit-learn 构建文本分类核心:模型选择与训练实现
特征向量就绪后进入模型环节。源码包里常见的模型文件有.pkl、.joblib、onnx等格式,但拿来主义通常行不通,因为默认模型是在别人的语料上训练的。正确做法是理解模型选型逻辑,用自己的数据重新训练。这一章从任务类型、模型对比、数据划分和 Pipeline 四个层面讲清楚训练实现。
3.1 先认清任务类型:多分类、多标签还是层次分类
动手前先看一眼自己数据的标签结构。每一条样本只对应一个类别的叫多分类,比如新闻归类;一条样本可以同时命中多个类别的叫多标签,比如一篇技术文章同时属于"Python"和"数据库"。这两个任务在输出层有本质区别,多分类用 softmax,多标签用 sigmoid。源码包里如果只写了predict()而没有predict_proba(),大概率只支持多分类。
另一个容易混淆的是层次分类,比如先分"技术/非技术",再在技术下细分语言方向。层次分类可以用一个多分类器硬做,但类别数量多且样本不均衡时,常见做法是先训一级分类器、再对每个一级类别训练二级分类器。判断依据很简单:把标签展开成集合,看看有没有一个样本同时出现两个互斥类别的标签,如果有,先按多标签处理。
标签本身直接存字符串,sklearn 的模型和评估函数都支持字符串标签,省去了LabelEncoder再解码的一层麻烦。但要注意把完整标签列表存下来,因为predict()返回的是 numpy 数组,上线时需要有标签表才能把序号映射回可读类别。
3.2 三个性价比最高的模型
在动手跑深度学习之前,先用经典模型把基线打出来。对大部分中小规模文本分类任务,下面三个模型足够覆盖从快速验证到线上服务的大部分需求:
| 模型 | 核心特点 | 适合场景 | 注意事项 |
|---|---|---|---|
| 逻辑回归 LogisticRegression | 有概率输出,可解释性好 | 通用文本分类,默认首选 | 特征维度高时记得调C |
| 线性 SVM LinearSVC | 决策边界更硬,小样本表现好 | 类别边界清晰的短文本 | 没有predict_proba,缺置信度 |
| 多项式朴素贝叶斯 MultinomialNB | 训练极快,适合高维稀疏特征 | 短文本、情感极性判断 | 特征分布假设强,长文本易失真 |
深度模型在文本分类里的优势需要足够数据支撑。几千条样本的中文分类任务,预训练模型不一定打得过调好参的逻辑回归,还会把训练和推理成本拉高一个量级。如果确实想试更强方案,fastText 是经典模型和深度模型之间一个折中,训练快,效果通常比朴素贝叶斯好。但不管选哪个,第一版系统的核心目标是把链路跑通,而不是追求精度小数点,这在工程决策上很重要。
3.3 数据划分:分层抽样保证小类别不丢失
训练集和测试集的划分方式直接决定评估结果可信度。如果原始数据里 90% 是"正常"类、10% 是"异常"类,用默认的随机划分,测试集里"异常"类可能只有几十条,评估指标的波动会非常大。stratify参数可以按原始标签比例做分层抽样,保证训练集和测试集的类别分布一致。
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( texts, # 原始文本列表 labels, # 对应标签列表 test_size=0.2, # 20% 留作测试 random_state=42, # 固定随机种子,保证结果可复现 stratify=labels, # 按类别比例分层抽样 ) print("训练集类别分布:", {c: sum(y_train == c) for c in set(labels)})random_state固定下来之后,每次跑出来的结果才可比较,调参时不会因为随机波动误判参数好坏。分层抽样对多标签任务不适用,这种场景退化为train_test_split默认随机划分,同时人工检查每个标签在测试集里是否仍有足够样本。划分完成后,训练集和测试集之间避免任何信息泄漏,比如全量文本先做了fit_transform再做划分,这种顺序错误的源码在不少开源包里能见到,特征统计会把测试集信息带进训练过程,造成虚高的评估分数。
3.4 用 Pipeline 把向量化和模型串成一体
在源码改造里,最常见的结构问题是没有把预处理、向量化、模型串起来,导致训练时先fit_transform特征、再训模型,预测时又单独对输入跑一遍transform,漏掉任何一个环节线上就会报特征数量不匹配。scikit-learn 的Pipeline就是为这件事设计的:
from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression pipeline = Pipeline([ ("vect", TfidfVectorizer( tokenizer=tokenize_with_stop, ngram_range=(1, 2), min_df=2, max_df=0.8, sublinear_tf=True, )), ("clf", LogisticRegression(max_iter=1000, C=1.0)), ]) pipeline.fit(X_train, y_train) # 内部自动完成 fit_transform + fit y_pred = pipeline.predict(X_test) # 内部自动完成 transform + predictPipeline 的价值在于把"训练阶段拟合的向量化器状态"和模型绑定成一个对象。以后换参数、换模型、做交叉验证,都只需要操作pipeline这一个变量。TfidfVectorizer在训练时学会了每个词的 IDF 值,预测时如果新文本里出现训练集没有的词,transform会自动忽略,这个兼容逻辑由 Pipeline 保证,手工分步实现时容易写错。
4. 文本分类的评估调参:指标、参数与过拟合排查
模型训练完只是第一步,源码包能不能用于生产,取决于评估和调参是否系统。这一章讲清楚三个环节:分类报告怎么看、三个必调参数怎么设、样本不均衡怎么处理。跳过这一章直接调参,容易陷入盲目试参数的死循环。
4.1 不看准确率,先看分类报告
准确率在类别不均衡时是欺骗性最强的指标。90% 样本是"A 类"的数据集,全预测成"A 类"就有 90% 准确率,但这个模型没有任何使用价值。正确的打开方式是打印分类报告:
from sklearn.metrics import classification_report, confusion_matrix y_pred = pipeline.predict(X_test) print(classification_report(y_test, y_pred, digits=3))输出里每一行会给出该类别的精确率、召回率和 F1 值。精确率回答"预测成这个类别的样本里有多少是对的",召回率回答"这个类别的真实样本里有多少被找回来了"。对工单分派这种场景,漏掉一个"投诉"工单比误分一个普通咨询代价高,这时优先看召回率;对内容过滤场景,误杀正常内容代价更高,优先看精确率。classification_report最后两行给出 macro avg 和 weighted avg,macro 是每个类别的 F1 直接平均,不受样本量影响,类别不均衡时应以它为主要参考。
再配合混淆矩阵定位具体错误:
import numpy as np cm = confusion_matrix(y_test, y_pred) # 找出错误最严重的类别对,优先判断是特征问题还是标签噪声混淆矩阵能直接看出哪些类别经常互相混淆。比如"投诉"和"咨询"大量互混,通常是训练语料里这两类标注边界模糊;如果真的分不开,考虑把这两个类合并,或者引入新的特征字段。
4.2 三个必调的参数:ngram、特征规模和正则强度
文本分类调参不需要玄学,线性模型下影响最大的就三个维度:特征窗口、特征规模、正则强度。把它们放进网格搜索,一次跑出结果:
from sklearn.model_selection import GridSearchCV param_grid = { "vect__ngram_range": [(1, 1), (1, 2)], # 是否引入相邻词组合 "vect__max_features": [10000, 20000], # 特征数量上限 "clf__C": [0.1, 1.0, 10.0], # 逻辑回归正则强度 } grid = GridSearchCV( pipeline, param_grid, cv=5, # 5 折交叉验证 scoring="f1_macro", # 不均衡数据用 macro F1 n_jobs=-1, # 并行跑,内存够才开 ) grid.fit(X_train, y_train) print("best params:", grid.best_params_)参数名里的vect__和clf__前缀对应 Pipeline 里定义的环节名称,网格搜索靠这个前缀定位参数属于哪个环节。C是正则强度的倒数,C越小正则越强、模型越保守,特征维度高、噪音大的场景C=0.1通常比默认的1.0效果更好。ngram_range从(1,1)提到(1,2)能捕获"不_满意"这类局部词序信息,中文场景经常带来 2 到 3 个点的提升,但特征量会成倍上涨,所以和max_features一起调。
网格搜索的维度不要一开始就铺满。先固定ngram_range调C,再放开ngram_range,最后压max_features,比一次跑几十组组合更节省时间。如果语料超过十万条,GridSearchCV的 5 折交叉会训练 5 倍的模型,跑不动可以把cv降到 3,或者改用RandomizedSearchCV随机采样参数组合。
4.3 样本不均衡:class_weight 与阈值移动
多数真实语料是不均衡的,投诉工单占 2%,剩余 98% 是普通咨询。predict默认按概率最大值判类别,少数类几乎永远被淹没。第一招是用class_weight:
pipeline.set_params(clf__class_weight="balanced") # 按类别频率自动加权 pipeline.fit(X_train, y_train)class_weight="balanced"会让少数类的错分代价自动放大,代价是多数类的误报率可能上升,这是可以接受的交换。第二招是阈值移动:不直接用argmax,而是对少数类单独设定一个置信度门槛,低于门槛就判为多数类。这个门槛用验证集扫描得到,而不是拍脑袋定。
proba = pipeline.predict_proba(X_test) rare_idx = pipeline.classes_.tolist().index("投诉") for thr in np.arange(0.3, 0.8, 0.05): y_conv = (proba[:, rare_idx] >= thr).astype(int) # 在这里计算召回率和误报率,画曲线选拐点这里不推荐一上来就用 SMOTE 之类的过采样。文本特征空间是稀疏高维的,人工合成样本容易产生噪音,优先把标注数据补一补,或者用加权方式先看基线,过采样放在最后对比验证时再决定。
5. 把文本分类系统跑起来:模型导出、接口与回归验证
训练和调参告一段落后,源码包的价值最后体现在模型能不能被其他服务调用。这一章处理三个具体问题:如何把模型和配套状态完整持久化,如何暴露一个最小可用的预测接口,以及上线前如何用回归用例验证模型行为没有漂移。
5.1 用 joblib 一次性保存模型、向量器和标签表
Pipeline 已经把所有状态内聚到一个对象里,保存时连标签表一起打包是工程上更稳的做法:
import joblib pack = { "pipeline": pipeline, # 完整的 向量化 + 模型 "classes": list(pipeline.classes_), # 类别顺序,预测时映射用 "feat_names": vectorizer.get_feature_names_out(), # 排查特征时用 } joblib.dump(pack, "text_clf.joblib", compress=3) print("saved, size:", round(__import__("os").path.getsize("text_clf.joblib")/1024/1024, 2), "MB")compress=3会在序列化时压缩,模型文件通常能缩小一半以上。加载侧要记得做一次"保存后重载"验证,确认joblib.load出来的对象预测结果和内存里的 Pipeline 完全一致:
loaded = joblib.load("text_clf.joblib")["pipeline"] assert (loaded.predict(X_test[:10]) == pipeline.predict(X_test[:10])).all()注意 Pipeline 在fit之后才会生成classes_属性,保存前确认这个属性存在。跨 Python 版本加载 joblib 文件偶尔会报兼容性错误,生产环境建议用 Docker 固定 Python 和 scikit-learn 版本,不要随手升级环境。
5.2 用 Flask 暴露一个最小预测接口
模型落盘后,常见的做法是用一个轻量 HTTP 服务包一层,让业务系统通过 JSON 调用。Flask 足够处理这种标注场景:
from flask import Flask, request, jsonify import joblib app = Flask(__name__) pack = joblib.load("text_clf.joblib") pipe, classes = pack["pipeline"], pack["classes"] @app.post("/predict") # 只接受 POST,避免 GET 缓存干扰 def predict(): body = request.get_json(force=True) text = body.get("text", "") if not text.strip(): return jsonify({"error": "empty text"}), 400 proba = pipe.predict_proba([text])[0] # probs 顺序与 classes_ 一致 idx = int(proba.argmax()) return jsonify({ "label": classes[idx], "confidence": round(float(proba[idx]), 4), }) if __name__ == "__main__": app.run(host="127.0.0.1", port=8000)启动后在另一个终端可以直接验证:
curl -X POST http://127.0.0.1:8000/predict \ -H "Content-Type: application/json" \ -d '{"text": "打印机连接不上公司网络"}'接口里最容易踩的坑是类别顺序。predict_proba返回的每一列顺序与classes_严格一致,保存时把classes一起存下来、返回时用它做映射,就不会出现"标签张冠李戴"的问题。并发量上来之后,Flask 内置服务器撑不住,常见做法是换 gunicorn 起多 worker,但要注意每个 worker 都会加载一份模型副本,内存按 worker 数线性增长,机器内存小就把 worker 数压到 2。
5.3 上线前跑一遍回归用例
模型上线的最后一道关卡是回归验证。准备一组覆盖每个类别、包含边界情况的黄金样本,在每次模型更新或环境变更后跑一遍,确认预测结果和预期一致:
GOLDEN = [ ("打印机无法连接公司网络", "IT 工单"), ("退款什么时候到账", "财务"), ("今天天气怎么样", "其他"), ] def predict_one(text: str) -> str: proba = pipe.predict_proba([text])[0] return classes[int(proba.argmax())] for text, expect in GOLDEN: got = predict_one(text) assert got == expect, f"{text}: got {got}, expect {expect}" print("all golden checks passed")回归用例的价值体现在你更新了分词词典、升级了 scikit-learn 版本或者改了清洗正则之后。任何一个环节的行为变化都会在这个用例集上暴露出来,定位问题的成本远低于线上出了事故再查。如果预训练 embedding 或分词工具升级,先用这批用例跑一遍旧模型再替换新模型,能省掉大部分线上问题排查时间。
本文还有配套的精品资源,点击获取