1. 从一张月牙图开始:为什么我推荐用二维数据入门分类
很多人学 sklearn 分类时,习惯是拿一个现成数据集,敲三行代码:import、fit、score,然后看着准确率数字点点头,以为会了。直到自己面对一份没有标准答案的原始数据,或者需要向别人解释“为什么这个模型会这样分类”时,才发现心里没底。
我写这一篇,是希望把 sklearn 分类算法的学习方式换一个角度。不是从“调库”入手,而是从“看清模型行为”入手。所以我特意选了一个二维、两分类、非线性可分的模拟数据集:二维意味着可以用坐标图完整画出每个模型的分类规则,非线性可分意味着逻辑回归这类线性模型会明显吃亏,决策树、KNN、SVM、朴素贝叶斯之间也会产生肉眼可见的差异。
这篇内容适合谁?一是已经学过 Python 基础、NumPy 和 Pandas,准备进入机器学习实战的读者;二是能用 sklearn 跑通流程、但不太理解模型之间为什么效果不同的学习者。我会把完整的建模流程走一遍:数据准备、训练测试集划分、特征标准化、五个常用分类器的训练与评测,最后用决策边界可视化把每个模型的“性格”画出来。全程只用真实可跑的代码,不搞花架子。
在一头扎进去之前,先交代一个核心方法:建模的起点不是“哪个模型准确率高”,而是“这份数据到底长什么样”。所以我先花时间构造和理解数据,再让模型上场。
1.1 为什么二维数据适合讲分类
机器学习模型在高维特征空间里的行为其实很难直观理解,但绝大多数分类算法在二维空间里都会留下清晰的几何痕迹。比如逻辑回归画出来是一条直线,决策树画出来是一组横平竖直的切分线,KNN 画出来像一块块不规则拼图。把数据降成二维,可以让我们用眼睛直接“看见”算法在做什么。
这也是我反复推荐用二维模拟数据做初学练习的原因。先用make_moons这类内置生成器得到一个形状明确的分类问题,把训练、预测、评估、可视化全流程跑通,再迁移到真实数据集上时,至少知道每一步在干什么、出了问题往哪里查。真实数据通常有十几个甚至上百个特征,你根本没法画图,那时候的调试难度完全不一样。
如果你已经懂这些基础,也可以从这一节之后跳到第 3 章看分类器的对比,但如果你刚接触 sklearn,老老实实从第 2 章走一遍,收益更大。
1.2 一个看似“简单”却很有代表性的月牙形数据集
分类模型练手的经典问题之一是“月牙形”数据:两类样本分布在两条交错的弧线上,像上下两瓣月牙嵌在一起,中间还有交叉缠绕的区域。这个数据看起来简单,实际上非常能体现模型差异,因为类别的分界是弯曲的,线性分类器无法用一条直线切干净,必须靠非线性手段处理。
我用make_moons生成它,参数只有三个:样本数量设为 800,噪音设为 0.25,随机种子固定为 42。噪音参数直接决定两类样本之间的重叠程度,噪音越大,交叉区域的样本越多,分类难度越高。0.25 是一个比较适中的值,既让线性模型明显吃亏,又不至于让所有模型都翻车。
from sklearn.datasets import make_moons import matplotlib.pyplot as plt X, y = make_moons(n_samples=800, noise=0.25, random_state=42) plt.figure(figsize=(6, 6)) plt.scatter(X[y == 0, 0], X[y == 0, 1], c="#4C72B0", edgecolor="white", s=20, label="class 0") plt.scatter(X[y == 1, 0], X[y == 1, 1], c="#DD8452", edgecolor="white", s=20, label="class 1") plt.xlabel("feature_1") plt.ylabel("feature_2") plt.legend() plt.show()跑完这段代码你会看到两条弧形点云,彼此交错但不完全重叠。这就是接下来所有分类器要面对的考题。记住这个图形,后面画的每一张决策边界图都会和它对比。
2. 五步建模流程:从原始数据到可以比较的模型
很多入门教程直接把数据丢进fit,然后输出准确率,中间省掉了好几个关键步骤。但这几部恰恰是实际项目中最容易出问题的地方。我总结成五步,每一节单独说。
2.1 数据准备:不要把所有数据直接丢给模型
拿到一份数据后,先别急着建模,第一步应该是检查形状、缺失值、类别分布。虽然是模拟数据,这个习惯也要养成。比如我的月牙数据是 800 行、2 列特征,类别 0 和类别 1 各占一半。这个类别比例很重要,如果严重不均衡,后续评估方式就得换一套。
实际上真实项目中,你拿到的数据可能包含缺失值、重复样本、类别标签分布失衡等问题。在拟合任何模型之前,至少要跑一遍data.info()、data.describe()、data["label"].value_counts()这三板斧。缺失值怎么处理、要不要删异常值,这些属于特征工程范畴,本文不展开,但请记住:模型只是管道末端,前面烂了,末端结果一定烂。
在代码层面,我先把特征和标签分离:
X = X # 特征矩阵,形状 (800, 2) y = y # 标签向量,形状 (800,)2.2 训练测试集划分:stratify 是保护伞
接下来要划分训练集和测试集。这一步的核心目的,是用一部分数据训练模型,用另一部分它从未见过的数据检测泛化能力。如果拿全部数据训练,再用同一份数据评分,模型会“背答案”,成绩再高也说明不了问题。
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y )stratify=y这个参数容易被忽略,但特别重要。它会在划分时保持训练集和测试集中每个类别的占比与原数据一致,防止随机划分造成某一类在测试集里比例失衡。对于月牙数据这种类别均衡的情况,加了和没加差别不大,但换成 99% 正类、1% 负类的数据时,不加stratify很可能把负类全部留在训练集,测试集里一个负样本都见不到,评估直接失真。建议每次划分都养成加stratify的习惯。
划分后,训练集 560 个样本,测试集 240 个样本,比例 7:3,这是一个常规配置。如果你的数据量有几万条,可以调成 8:2,甚至在数据量极大时用 9:1。
2.3 特征标准化:为什么 KNN 和 SVM 特别敏感
接着是特征标准化。这一步在月牙数据上看起来“多此一举”,因为两个特征的取值范围本来就差不多,但换成真实数据后,年龄、收入、点击次数这些特征可能差好几个数量级,不标准化会让某些模型彻底失灵。
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)这里有一个细节我必须反复强调:fit_transform只对训练集调用,测试集只调用transform。原因是标准化要使用训练集计算出的均值和方差,如果让测试集也参与fit,等价于模型在训练时“瞥见”了测试集的信息,这叫数据泄漏。虽然在这个小例子里影响不大,但一旦养成了错误习惯,放到真实项目里就是灾难。
为什么 KNN 和 SVM 对标准化特别敏感?KNN 靠距离判断相似度,如果 A 特征取值 0~1、B 特征取值 1000~10000,那么距离计算基本被 B 特征统治,A 特征等于作废。SVM 的核函数计算同样依赖特征之间的内积或距离,量纲差异会直接扭曲超平面位置。逻辑回归和决策树对标准化不那么敏感,但统一标准化没有坏处,所以我的习惯是:除了树模型,其他模型一律先标准化。
3. 逐个击破:五个常用分类器的原理、参数和手写代码
这一章进入核心部分。每个分类器我会给出核心原理的一句话概括、关键参数的说明、可直接运行的代码,以及在实际使用中的坑。最后用一个表格横向对比。
3.1 LogisticRegression:线性分类的基础盘
逻辑回归虽然名字里带“回归”,但它是一个非常经典的线性分类器。它的核心思路是:把特征加权求和后,通过 sigmoid 函数压到 0~1 之间,再按 0.5 阈值划分类别。它能给出概率输出,这是它最实用的优点之一。
from sklearn.linear_model import LogisticRegression lr = LogisticRegression(max_iter=1000) lr.fit(X_train_scaled, y_train) train_acc = lr.score(X_train_scaled, y_train) test_acc = lr.score(X_test_scaled, y_test) print(f"LogisticRegression train_acc={train_acc:.3f}, test_acc={test_acc:.3f}")跑出来的准确率可能不太好看,因为月牙数据的真实分界线是弧线,逻辑回归只能用一条直线硬切。理论准确率大概在 0.85 左右,取决于噪音大小。这很正常,也很有教育意义:模型再调参也补不了线性假设的先天不足。
关键参数里,max_iter是我每次都手动指定的,因为 sklearn 新版默认迭代次数可能不足,数据复杂时会抛 ConvergenceWarning。如果你在项目中看到这个警告,说明需要增大max_iter,而不是忽略它。C是正则化强度的倒数,值越小惩罚越强,默认 1.0 是常规起点。
逻辑回归适应的场景是:特征与目标大致呈线性关系、需要输出概率、需要快速训练和稳定基线。在工业界,逻辑回归经常当“baseline 之王”,先跑一个线性模型,再去看复杂模型能带来多少提升。
3.2 KNN:懒惰的邻居投票法
KNN 可能是思想最简单的分类器:找一个新样本,看它周围最近的 K 个邻居,少数服从多数,邻居全是哪个类,它就归哪个类。它没有明显的“训练”过程,更像是在预测时临时翻数据,所以常被称为“懒惰学习器”。
from sklearn.neighbors import KNeighborsClassifier knn = KNeighborsClassifier(n_neighbors=5) knn.fit(X_train_scaled, y_train) train_acc = knn.score(X_train_scaled, y_train) test_acc = knn.score(X_test_scaled, y_test) print(f"KNN train_acc={train_acc:.3f}, test_acc={test_acc:.3f}")KNN 在月牙数据上通常表现不错,因为它可以贴合局部边界。但它的缺点也很明显:预测时需要计算新样本到所有训练样本的距离,样本量大时非常慢;对特征标准化高度敏感;n_neighbors取值很讲究,取小了容易过拟合,取大了会把边界磨平。
我个人的调参经验是:优先尝试 3、5、7 这类奇数邻居,避免平票;然后用交叉验证搜索一个最合适的 K 值。KNN 没有训练成本,但存储和推理成本高,所以它更适合中小规模数据,或者作为对比模型出现。
3.3 DecisionTree:在“切箱子”中寻找规律
决策树是一个很直观的模型:它把特征空间一块块切成矩形区域,每一块对应一个类别。你问它为什么这样分,它可以给你一套“如果特征 A 小于 2.5,再看特征 B 大于 -1.3,则判为 class 1”的规则,可解释性是所有模型里最强的。
from sklearn.tree import DecisionTreeClassifier tree = DecisionTreeClassifier(max_depth=3, random_state=42) tree.fit(X_train_scaled, y_train) train_acc = tree.score(X_train_scaled, y_train) test_acc = tree.score(X_test_scaled, y_test) print(f"DecisionTree train_acc={train_acc:.3f}, test_acc={test_acc:.3f}")这里我特意把max_depth设成 3。为什么?因为决策树不加限制的话,会一直切分到每个叶子节点都“纯”为止,训练集准确率能冲到 100%,但测试集表现反而变差,这就是过拟合。max_depth=3相当于给树的生长装了笼头,让它只画少数几条切分线,边界看起来就是几个横平竖直的大方块。后面画决策边界时你会清楚地看到这一点。
决策树适合需要向业务方解释逻辑的场景,比如风控审批、医疗辅助判断。它不需要特征标准化,单棵树效果通常一般,但它是随机森林、梯度提升树这些网红模型的基础组件,掌握它的切分机制特别重要。
3.4 SVC:核技巧让边界弯起来
SVM 的思想是找一个能把两类样本尽量分开、同时让边界间隔最大的超平面。线性可分时它很厉害,但遇到月牙数据这种非线性可分的情形,就必须借用“核技巧”,把数据映射到高维空间再切分。我们用到的 RBF 核是最常见的一种非线性核。
from sklearn.svm import SVC svm = SVC(kernel="rbf", C=1.0, gamma="scale") svm.fit(X_train_scaled, y_train) train_acc = svm.score(X_train_scaled, y_train) test_acc = svm.score(X_test_scaled, y_test) print(f"SVC train_acc={train_acc:.3f}, test_acc={test_acc:.3f}")RBF 核的效果通常让月牙数据的测试准确率明显高于逻辑回归,因为它能画出平滑弯曲的边界。但 SVM 的参数调整需要一些经验:C是误分类惩罚系数,越大越严格,容易过拟合;gamma控制单个样本的影响力半径,gamma太小边界太平滑、分不开,太大则每个样本都圈出一个小领地、边界锯齿化。gamma="scale"是 sklearn 的自动推断模式,新手从它开始非常稳妥。
SVM 的短板是训练复杂度随样本量上升明显,几万条还行,百万级数据基本不推荐。需要标准化、调参门槛高、又难以解释,所以它更像一把“精工武器”,适合中小规模、特征边界复杂的数据。
3.5 GaussianNB:假设独立,速度飞快
朴素贝叶斯的逻辑是:假设特征之间条件独立,然后分别计算每个特征在不同类别下的概率分布,最后用贝叶斯公式算出新样本属于每个类的后验概率。赶上一个“朴素”的假设,换来的是极快的训练和预测速度。
from sklearn.naive_bayes import GaussianNB nb = GaussianNB() nb.fit(X_train_scaled, y_train) train_acc = nb.score(X_train_scaled, y_train) test_acc = nb.score(X_test_scaled, y_test) print(f"GaussianNB train_acc={train_acc:.3f}, test_acc={test_acc:.3f}")高斯朴素贝叶斯假设每个特征在每个类别下服从正态分布,在月牙数据上它的边界会是二次曲线形状,因为它等价于学了一个二次判别函数。效果可能比逻辑回归略好,但通常到不了 SVM 和 KNN 的高度,毕竟月牙数据特征之间明显有交互,独立假设站不住脚。
它最擅长的领域是文本分类、垃圾邮件过滤这类特征维度极高、特征之间近似独立的场景,速度快、内存省、实测效果也很稳。它可以作为非线性模型的低成本替代品出现在对比列表中。
3.6 五个模型的横向对比
| 模型 | 边界形状 | 核心优势 | 主要短板 | 适用场景 |
|---|---|---|---|---|
| LogisticRegression | 直线/线性超平面 | 训练快、有概率输出、适合当基线 | 线性假设束缚,复杂边界分不开 | 特征与目标近线性关系 |
| KNN | 局部不规则拼图 | 无需训练、实现直观 | 推理慢、受量纲影响大、需要调K | 中小规模数据、局部模式明显 |
| DecisionTree | 横平竖直的矩形切分 | 可解释性超强、不用标准化 | 单棵树容易过拟合 | 需要向业务解释规则 |
| SVC (RBF) | 平滑弯曲曲线 | 非线性能力强、泛化稳 | 调参难、大数据集慢 | 中小规模非线性问题 |
| GaussianNB | 二次曲面 | 训练极快、高维数据友好 | 独立假设往往不成立 | 文本分类、高维稀疏场景 |
4. 决策边界可视化:把模型的“喜好”画在图上
准确率只是一个数字,但决策边界是一张图,能直接告诉你模型到底是怎么理解这份数据的。
4.1 画决策边界的通用函数
绘制决策边界的思路是:在特征平面上密铺一个网格点阵,让模型预测每个网格点的类别,然后填充颜色形成色块。由于我们把数据标准化了,画图时需要注意一个细节:网格点是在原始坐标里生成的,但模型期望的是标准化后的坐标,所以预测之前必须用训练好的scaler.transform转换网格点。
import numpy as np import matplotlib.pyplot as plt from matplotlib.colors import ListedColormap def plot_decision_boundary(clf, X, y, scaler, ax=None): if ax is None: ax = plt.gca() cmap_points = ListedColormap(["#4C72B0", "#DD8452"]) cmap_area = ListedColormap(["#c5d0e6", "#f2ddd2"]) x_min, x_max = X[:, 0].min() - 0.3, X[:, 0].max() + 0.3 y_min, y_max = X[:, 1].min() - 0.3, X[:, 1].max() + 0.3 xx, yy = np.meshgrid( np.linspace(x_min, x_max, 300), np.linspace(y_min, y_max, 300) ) grid = np.c_[xx.ravel(), yy.ravel()] grid_scaled = scaler.transform(grid) Z = clf.predict(grid_scaled) Z = Z.reshape(xx.shape) ax.contourf(xx, yy, Z, cmap=cmap_area, alpha=0.4) ax.scatter(X[:, 0], X[:, 1], c=y, cmap=cmap_points, edgecolor="white", s=15) ax.set_title(clf.__class__.__name__) ax.set_xlabel("feature_1") ax.set_ylabel("feature_2")为什么要在scaler.transform之后才预测?如果不做这一步,模型接收的网格点范围与训练时完全不同,预测结果必然混乱。这是一个初学者很容易踩的坑:训练时用了标准化,可视化和上线预测时却忘了用同一个scaler转换。把scaler作为参数传进绘图函数,是从一开始就规避这种错误的办法。
跑出四张子图的代码大致如下:
fig, axes = plt.subplots(2, 3, figsize=(15, 9)) for ax, clf in zip(axes.ravel(), [lr, knn, tree, svm, nb]): plot_decision_boundary(clf, X_test, y_test, scaler, ax) plt.tight_layout() plt.show()在测试集上画边界会更有说服力,因为你看到的是模型在未见过的数据上的表现,而不是训练集上的“背答案”表现。当然如果你想对比训练测试差异,也可以同时画两张图。
4.2 五种边界背后的模型性格
画完图之后,你能直观看到五张完全不同的“性格画像”:
逻辑回归的边界是一条斜切的直线,无论数据怎么弯延,它都坚持一刀切,所以总有一块区域的样本被误分类。这提醒我们,线性模型的价值上限由数据本身决定。
KNN 的边界像一块块细碎的马赛克,每一个局部都由最近邻居投票产生,能够紧贴数据密度变化,但随着邻居数变小,边界会出现大量“孤岛”和锯齿。n_neighbors=5时它仍然保持着明显的不规则性。
决策树的边界是一组整齐的横线和竖线,像切豆腐块,条数由max_depth决定。深度为 3 时,边界只有几道阶梯,很多局部都被“一刀切”抹平了。这就是可解释性的代价:规则简单,但拟合细粒度不够。
SVM 的边界是最顺滑的弯曲曲线之一,能够自然贴合月牙形状,局部又不会过于纠结。它看起来是五个模型里“最懂”这份数据的,这也是 RBF 核的典型表现。
朴素贝叶斯的边界是二次曲线,它试图用椭圆形区域拟合每个类的分布。由于它假设特征独立,所以它画出的曲线往往无法完全贴合数据的不规则走向,总在某一侧多出一块“多余领地”。
看懂了这些图,你以后面对新数据时,第一步不是急着调参比分数,而是先在心里预估一个“合适的模型形状”:这份数据是线性可分、还是有弧线交织、还是呈现团体簇状。用这个预估去选择模型,比盲目堆多个模型有效得多。
5. 别只看准确率:不平衡数据下的评估与补救
如果回归到实际业务,你会很快发现一个现象:很多数据的类别比例根本不是 1:1。比如商品退货预测,99% 的订单不会退货;比如故障检测,绝大多数设备运转正常;比如金融反欺诈,异常交易占比极低。这时候,“准确率”会变成一个极具欺骗性的指标。
5.1 混淆矩阵:准确率之外的四个格子
准确率 = 预测正确的样本数 / 总样本数。如果负类占 99%,一个什么都不预测、永远输出“负类”的模型,准确率也有 99%。但它没有任何实用价值,因为真正关心的正类(诈骗、故障、退货)它一个也找不出来。
所以我建议把混淆矩阵作为分类评估的第一动作。它把结果分成四个格子:真正例、假正例、真负例、假负例。真正例和真负例是预测对的,假正例是误报,假负例是漏报。
from sklearn.metrics import confusion_matrix, classification_report y_pred_lr = lr.predict(X_test_scaled) print(confusion_matrix(y_test, y_pred_lr)) print(classification_report(y_test, y_pred_lr))classification_report会给出三个派生指标:精确率、召回率、F1 分数。精确率回答的是“你预测的正类里,有多少是对的”,召回率回答的是“真正的正类里,你找回来了多少”。F1 是两者的调和平均,在类别不均衡时,它比准确率更能代表模型的真实水平。
5.2 不平衡数据的标准补救手段
我构造一份人工不平衡数据来演示补救手段。用make_classification生成 1000 条样本,90% 是类别 0,10% 是类别 1,然后训练两个逻辑回归,一个不处理类别权重,一个设置class_weight="balanced"。
from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler X_imb, y_imb = make_classification( n_samples=1000, n_features=2, n_informative=2, n_redundant=0, n_clusters_per_class=1, weights=[0.9, 0.1], flip_y=0.02, random_state=42 ) X_imb_train, X_imb_test, y_imb_train, y_imb_test = train_test_split( X_imb, y_imb, test_size=0.3, random_state=42, stratify=y_imb ) scaler_imb = StandardScaler() X_imb_train_scaled = scaler_imb.fit_transform(X_imb_train) X_imb_test_scaled = scaler_imb.transform(X_imb_test) lr_plain = LogisticRegression(max_iter=1000) lr_plain.fit(X_imb_train_scaled, y_imb_train) lr_balanced = LogisticRegression(max_iter=1000, class_weight="balanced") lr_balanced.fit(X_imb_train_scaled, y_imb_train) print("default model:") print(classification_report(y_imb_test, lr_plain.predict(X_imb_test_scaled))) print("balanced model:") print(classification_report(y_imb_test, lr_balanced.predict(X_imb_test_scaled)))运行两次你会发现,默认逻辑回归看起来准确率挺高,但正类的召回率低得可怜;设置class_weight="balanced"之后,模型会自动给少数类更高的误分类惩罚,正类召回率明显上升,虽然总体准确率可能略有下降,但对业务而言,召回率往往比做一个“只会猜多数类”的模型有价值得多。
除了class_weight,还有几种常规补救手段。第一是采集更多少数类样本,这是釜底抽薪的办法。第二是过采样少数类或欠采样多数类,用imblearn库可以快速实现。第三是在预测时降低判定阈值,模型默认按 0.5 概率划分正负类,但你不一定要遵循这个默认值,比如把阈值降到 0.3,召回率会上升,误报率也会随之上升。到底怎么权衡,取决于业务里“漏掉正类”和“误报负类”哪个代价更高。
最后一点,也是我踩过好多次坑才总结出来的:不要拿着默认准确率就下结论。每次拿到一个分类结果,先看一眼混淆矩阵,算一下类别比例,再决定下一步怎么调。这 30 秒的额外成本,能帮你避免一大堆“假性成功”的模型。