简介:面向具备Python基础的开发者,这套基于Python的机器学习文本分类器源码,以可运行的实例完整演示了从语料清洗、分词去停用词、TF-IDF特征构建,到模型选择、网格搜索调参与性能评估的实用流程,覆盖了文本分类任务的主要环节。压缩包共30个文件,以27个Python脚本为主,另有README说明、停用词表及Git配置,整体仅19KB,结构轻量,脚本按功能拆分,便于对照学习。已有272人学习浏览,内容涉及线性模型、SVM、KMeans聚类、TF-IDF等常用方法,并配有分词统计、三维可视化、单变量分析等辅助脚本,可直接运行观察各步结果。借助这套代码,读者能直观理解不同算法在文本分类中的表现,节省搭建环境与代码框架的时间,并可将示例迁移到自己的数据集上,快速开启NLP分类实践。
1. 用文件列表看懂一个Python机器学习文本分类器的骨架
拿到一份名为“基于Python的机器学习文本分类器.zip”的项目压缩包,打开后是一堆.py脚本和一个page-classify-master目录。别被文件数量吓到,这套脚本实际覆盖了中文文本分类从预处理到模型评估的完整链路。其中preprocess.py负责清洗,cut_and_cal_tfidf.py算特征,binary_classify.py和scikit_learn_svm_demo.py是分类器,grid_search.py做调参,plot_3d_scatter.py和plot_3d_surface.py用来查看特征分布和决策边界。对想用 Python 快速落地文本分类的人,这包东西比单独看 sklearn 文档有用,因为你能看到工程上每个环节是怎么接起来的。
2. 预处理与分词:中文语料的清洗策略
2.1 语料读取与格式统一
在page-classify-master这类目录里,语料通常按类别分文件夹存放,每个文件是一篇文档。corpus.py的核心任务是把这些文件读进来,整理成“文本-标签”两列,方便后续处理。常见做法是:
import os import pandas as pd def load_corpus(root_dir): texts, labels = [], [] for label in os.listdir(root_dir): label_dir = os.path.join(root_dir, label) if not os.path.isdir(label_dir): continue # 跳过非目录文件 for fname in os.listdir(label_dir): path = os.path.join(label_dir, fname) with open(path, encoding='utf-8', errors='ignore') as f: texts.append(f.read()) labels.append(label) return pd.DataFrame({'text': texts, 'label': labels})这段代码用os.listdir遍历每个类别目录,把目录名当作标签。errors='ignore'是为了避免个别文件里出现非法编码导致整个语料加载失败。如果项目里的数据是 gbk 编码,就需要把encoding换成gbk,或者先检测编码再读取。实际项目里我倾向于用chardet逐个文件检测,避免“一错全错”。
读取后还要做一步检查:类别分布是否均衡。用df['label'].value_counts()打印一下,如果某个类别只有几十篇,而另一类几千篇,后面模型评估的准确率会失真。这时要考虑欠采样,或者在模型里设置class_weight。
2.2 清洗规则与停用词表
预处理不是越多步越好,关键看语料来源。preprocess.py里比较常见的清洗口径包括去掉 HTML 标签、压缩连续空格、去掉年份数字和无意义符号。对网页类文本,抓下来的内容经常带<p>、<a>之类标签,re.sub(r'<[^>]+>', '', text)一次就能去掉。
stopwords.txt的作用是过滤掉“的、了、是、在”这类高频却无类别区分度的词。注意不要直接套用网上下载的停用词表,要按自己的语料调整。比如做财经新闻分类时,“公司”“市场”可能每个类别都出现,但分类器并不靠它们区分,保留反而增加维度。常见做法是把训练集里出现频率最高的一批词抽出来人工看一遍,把明显无区分度的词加进stopwords.txt。
下面这段把清洗、停用词、分词合到一起:
import re import jieba def load_stopwords(path='stopwords.txt'): with open(path, encoding='utf-8') as f: return set(line.strip() for line in f) def preprocess(text, stopwords): # 去掉网页标签 text = re.sub(r'<[^>]+>', '', text) # 去掉英文和数字 text = re.sub(r'[a-zA-Z0-9]', '', text) text = re.sub(r'\s+', ' ', text) words = jieba.lcut(text) return [w for w in words if w.strip() and w not in stopwords]load_stopwords用集合存储,成员判断是 O(1),几万次匹配也不会慢。jieba.lcut返回list,比jieba.cut返回生成器更适合在特征提取前一次性拿到结果。过滤时加了w.strip(),防止分词结果里出现空格字符。
补充一点:不要过度清洗。像“机器学习”“文本分类”这种专业词汇一旦被去掉,类别特征就没了。清洗规则最好先跑一版,观察剩余词汇,再决定保留哪些。
2.3 分词与自定义词典
中文文本分类的分词策略直接影响特征维度。jieba默认词典对通用领域够用,但如果语料里有“自然语言处理”“BERT”这类词,直接lcut可能会被切开。我一般在preprocess.py里加一句jieba.load_userdict('user_dict.txt'),把业务词和专有名词写进去,每行一个词。自定义词典的优先级高于默认词典,分词稳定后 TF-IDF 的特征会更准。
分词后的文本最终要拼回空格分隔的字符串,因为 sklearn 的向量化器默认按空格分词:
def cut_and_join(text, stopwords, user_dict='user_dict.txt'): if user_dict: jieba.load_userdict(user_dict) # 加载自定义词典 return ' '.join(preprocess(text, stopwords))这里返回的字符串可以直接喂给TfidfVectorizer。如果后面还要用feature_extract.py做特征分析,建议保留成list形态,便于统计词频。
| 任务 | 常用方法 | 作用 |
|---|---|---|
| 去标签 | re.sub(r'<[^>]+>', '', text) | 去掉网页标签 |
| 去英文数字 | re.sub(r'[a-zA-Z0-9]', '', text) | 去掉无区分度的字符 |
| 压缩空白 | re.sub(r'\s+', ' ', text) | 减少噪声 |
| 分词 | jieba.lcut(text) | 切分为词列表 |
| 停用词过滤 | w not in stopwords | 去掉无意义词 |
3. TF-IDF特征提取与权重计算
3.1 为什么不直接用词袋
词袋模型统计每个词在文档中出现的次数,问题是长文本里词频高,但未必代表类别。TF-IDF 用“词频 × 逆文档频率”压制那些在太多文档里都出现的词。具体来说,TF 是某个词在单篇文档中的频率,IDF 是log(总文档数 / 包含该词的文档数)。某个词只在某类文本里高频,IDF 就高,权重会放大,分类器更容易捕捉到类别差异。cut_and_cal_tfidf.py和tfidf.py干的就是这件事,前者会调用预处理结果把原始语料切成词串,再交给 TF-IDF 计算模块;后者通常封装了向量化器的调用。
3.2 TfidfVectorizer 常用参数
用 sklearn 实现时,特征提取可以写成下面的函数:
from sklearn.feature_extraction.text import TfidfVectorizer def build_tfidf(corpus, min_df=2, max_df=0.8, ngram_range=(1, 2), max_features=50000): vectorizer = TfidfVectorizer( min_df=min_df, # 忽略文档频率低于该值的词 max_df=max_df, # 忽略文档频率高于该比例的词 ngram_range=ngram_range, sublinear_tf=True, # 用 1+log(tf) 平滑词频 max_features=max_features, norm='l2' ) X = vectorizer.fit_transform(corpus) return vectorizer, Xmin_df=2表示词至少在 2 篇文档中出现过,过滤只在单篇出现的词;max_df=0.8表示在 80% 以上的文档中都出现的词会被忽略,这类词通常是停用词;ngram_range=(1,2)会把“机器学习”这种组合词纳入特征;sublinear_tf=True用1+log(tf)代替原词频,避免长文本里频率差异被放大;max_features是最后一道维度压闸。这些参数看似简单,却是文本分类效果差距的主要来源。比如max_df设得太小会丢掉“虽然常见但对某些类别有指示性”的词,设太大又引入噪声,需要配合网格搜索一起调。
3.3 特征分析与类别特征词
feature_extract.py和class_feature.py里通常会做一类事情:按类别提取权重最高的词,帮助判断特征是否合理。训练完向量化器后,可以这样看每个类的特征词:
def top_features_by_class(vectorizer, X, y, top_k=10): import numpy as np feature_names = vectorizer.get_feature_names_out() label_to_indices = {} for idx, label in enumerate(y): label_to_indices.setdefault(label, []).append(idx) for label, indices in label_to_indices.items(): # 求每个类别在特征维度上的平均权重 mean_weight = X[indices].mean(axis=0) sorted_indices = np.argsort(mean_weight.A1)[::-1][:top_k] print(label, [feature_names[i] for i in sorted_indices])这里X[indices].mean(axis=0)求出某类别所有样本在每维特征上的平均权重,再取 TopK。如果输出中发现“的”“是”这类词排在最前,说明停用词表没过滤干净,或者max_df设置过高,需要回头调整。
class_feature.py如果做的是类别特征选择,常见做法是结合卡方检验,用SelectKBest(chi2, k=10000)从 TF-IDF 矩阵里挑出与标签相关性最高的 1 万个特征。虽然TfidfVectorizer本身已经按词频过滤过,但卡方选择能进一步去掉与类别独立的噪声词。要注意卡方检验要求特征非负,TF-IDF 的默认输出就是非负的,但使用sublinear_tf或norm时不会改变这一点,可以直接应用。
| 参数 | 取值建议 | 说明 |
|---|---|---|
min_df | 1-5 | 低于该文章频次的词舍去 |
max_df | 0.7-0.9 | 高于该比例的词舍去 |
ngram_range | (1,2) | 保留单个词和相邻双词 |
sublinear_tf | True | 用对数值平滑词频 |
max_features | 30000-80000 | 控制特征矩阵规模 |
4. 分类器选择与训练:SVM与逻辑回归
4.1 从二分类到多分类
binary_classify.py解决的是二分类,page_classify.py面向网页多分类。sklearn 里的LinearSVC和LogisticRegression对多分类都默认采用 one-vs-rest 策略,也就是“每个类别训练一个二分类器,新样本逐个打分,取分数最高的类别”。文本特征往往是高维稀疏矩阵,样本量不会太大,线性模型在这个场景下通常比树模型更稳。
4.2 用 LinearSVC 训练文本分类器
scikit_learn_svm_demo.py里的 SVM 示例可以直接扩展成完整训练流程:
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.svm import LinearSVC from sklearn.pipeline import Pipeline from sklearn.metrics import classification_report pipeline = Pipeline([ ('tfidf', TfidfVectorizer(min_df=2, max_df=0.8, ngram_range=(1, 2))), ('clf', LinearSVC(C=1.0, class_weight='balanced')) ]) pipeline.fit(X_train, y_train) y_pred = pipeline.predict(X_test) # 输出每个类别的精确率、召回率、F1 print(classification_report(y_test, y_pred, target_names=class_names))Pipeline把向量化和分类器绑在一起,调参时不会漏掉中间步骤。LinearSVC的C是误分类惩罚系数,C越小对错误分类的容忍越大,决策边界越简单;C越大模型越容易过拟合。文本分类里我一般从C=1.0开始,再用网格搜索微调。class_weight='balanced'会在类别不平衡时按样本量反比给权重,防止少数类全被吞掉。
LinearSVC本质上是带 L2 正则的线性模型,没有用到核函数。不要在文本特征上直接用SVC(kernel='rbf'),因为 TF-IDF 矩阵动辄几万维,RBF 核会把每个样本映射到无穷维,训练时间翻几十倍,效果还不如线性核。
4.3 逻辑回归与 SGDClassifier
逻辑回归是另一个合适基线。它比LinearSVC多一个天然的predict_proba,能输出置信度,后续做阈值调整和业务对接都方便。训练代码几乎完全一样,只需把分类器换掉:
from sklearn.linear_model import LogisticRegression # 替换分类器为逻辑回归,保留前面的向量化环节 pipeline.set_params(clf=LogisticRegression(C=1.0, max_iter=1000)) pipeline.fit(X_train, y_train)max_iter=1000是为了避开默认max_iter=100经常出现的“不收敛”警告。如果语料很大,可以用SGDClassifier(loss='log_loss')做在线学习,但要注意它需要稳定的特征缩放,在 TF-IDF 特征上要保留norm='l2',否则梯度更新不稳定。
4.4 分类指标的选择
| 模型 | 优点 | 需要注意 |
|---|---|---|
LinearSVC | 高维稀疏特征下训练快,边界清晰 | 无概率输出 |
LogisticRegression | 可输出概率,可解释性好 | 需要调max_iter |
SGDClassifier | 支持增量训练,适合大数据流 | 对特征缩放敏感 |
评估时不要只看 accuracy,多分类文本分类里各类别样本往往不均衡。打印classification_report看每个类别的 precision、recall、F1,同时计算宏平均 F1。调参时也要把评分函数从默认准确率改成f1_macro,否则少数类很容易被牺牲掉。
5. 网格搜索与超参数调优
5.1 想清楚调什么
文本分类里需要联合调的是特征提取和分类器两部分,而不是只调分类器。grid_search.py里常见的套路是把TfidfVectorizer的min_df、max_df、ngram_range和LinearSVC的C放进同一个参数空间。这样能避免“调好了 C,却因为max_df不合适导致效果上不去”的尴尬情况。
5.2 使用 Pipeline + GridSearchCV
代码如下:
from sklearn.model_selection import GridSearchCV param_grid = { 'tfidf__min_df': [1, 2], 'tfidf__max_df': [0.7, 0.8, 0.9], 'tfidf__ngram_range': [(1, 1), (1, 2)], 'clf__C': [0.1, 1.0, 10.0] } # cv=5 表示五折交叉验证,scoring 指定优化目标 gs = GridSearchCV(pipeline, param_grid, cv=5, scoring='f1_macro', n_jobs=-1, verbose=1) gs.fit(X_train, y_train) print(gs.best_params_) print(gs.best_score_)param_grid里每个键的名字要遵循“步骤名__参数名”的规则。cv=5表示五折交叉验证,scoring='f1_macro'让网格搜索以宏平均 F1 为标准,n_jobs=-1表示用满所有 CPU。这个组合会训练2*3*2*3*5 = 180个模型,如果语料到几十万篇,整个搜索会非常慢。可以先用max_features=20000缩小特征范围,把网格大致跑通,再加回去精调。
如果参数空间更大,可以换成RandomizedSearchCV,每轮从分布里抽样。文本特征维度高,网格组合爆炸很快,随机搜索往往能在同样时间内找到更优的参数。但要注意对min_df、max_df这类离散参数,尽量用列表而不是连续分布,避免搜出无意义的值。
5.3 搜索过程的常见坑
网格搜索最常见的坑是交叉验证数据泄漏。如果先在整个训练集上计算 TF-IDF 再切分,验证集的信息已经掺进特征统计里,结果会虚高。所以必须把向量化器放进Pipeline,让每一折都单独 fit。第二个坑是多分类时评分函数选错,比如默认accuracy在类别不平衡下完全没有意义。第三个坑是n_jobs=-1在共享内存环境下会同时复制多份特征矩阵,机器内存不够时直接 Out of Memory,可以先降成n_jobs=2。
| 参数 | 影响 | 推荐起点 |
|---|---|---|
min_df | 越高噪声越少,但可能丢失稀有类别词 | 2 |
max_df | 越低越能去掉公共词,太高保留噪声 | 0.8 |
ngram_range | 开启双词后特征数翻倍,训练变慢 | (1,2) |
C | 越小越正则,太大过拟合 | 1.0 |
scoring | 决定搜索方向 | f1_macro |
网格搜索结束后,不要直接拿best_score_当最终效果。最好再用固定随机种子的train_test_split跑一次独立的测试集,得到最终指标,否则容易高估模型泛化能力。
6. 可视化验证与决策边界观察
项目里plot_3d_scatter.py和plot_3d_surface.py这类脚本,目的是为了确认特征和模型有没有把类别“分开”。TF-IDF 特征动辄几千维,没法直接画图,常见做法是用 PCA 或TruncatedSVD降到三维,再撒点看。下面这段代码可以在小数据集上快速验证:
from sklearn.decomposition import TruncatedSVD import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D # 将稀疏 TF-IDF 矩阵降到 3 维便于观察 svd = TruncatedSVD(n_components=3, random_state=42) X_reduced = svd.fit_transform(X_tfidf) fig = plt.figure(figsize=(10, 8)) ax = fig.add_subplot(111, projection='3d') colors = {'体育': 'blue', '财经': 'green', '教育': 'red'} ax.scatter(X_reduced[:, 0], X_reduced[:, 1], X_reduced[:, 2], c=[colors[label] for label in y], s=5, alpha=0.6) plt.savefig('pca_text_class.png', dpi=100)这里TruncatedSVD适合稀疏矩阵,可以直接对 TF-IDF 矩阵降维。如果散点图里同一类别的点明显聚在一起,说明特征提取有效;如果不同类别完全重叠,问题多半出在预处理或参数上,不要急着换分类器。
plot_3d_surface.py那种曲面图,通常是在二维特征平面上画分类器的决策面。对文本分类,我会先降维到两维,再用网格生成坐标点,喂给训练好的模型预测类别,把预测结果画成背景颜色,原始样本点叠上去。这样能直观看到边界是否过拟合:边界太曲折,往往是C过大或min_df太小。
最后的技巧是,把网格搜索结果的可视化和特征降维图放在一起看。plot_linear.py里画的是回归/分类的直线拟合,虽然脚本很简单,但替换成gs.cv_results_里的数据点,就能画出参数与 F1 的折线图。比如查看C在[0.1, 1, 10]之间的变化趋势,如果C=10时训练 F1 很高、交叉验证 F1 反而下降,说明模型过拟合,此时应该回头调min_df或ngram_range,而不是继续加大C。这套“特征降维 + 边界可视化 + 调参曲线”的验证顺序,能把文本分类器的调参过程从试错变成可解释的路径。
本文还有配套的精品资源,点击获取