三个动词:fit、transform、predict
前面三卷里,每一层都有自己的一套规矩,而且都不太爱管你。这一卷不一样:scikit-learn 是全书唯一一层会当场拦住你的。它的要求极其苛刻——两种形状,三个动词,两百多个模型无一例外。苛刻带来的回报是:把一个模型换成另一个,代码只改一行。这一章先把这套契约摆清楚,它是后面四章的地基。
一份 569 行、30 列的医学诊断数据(sklearn 自带的乳腺癌数据集)。你要试五个完全不同的模型:逻辑回归、K 近邻、决策树、随机森林、支持向量机。
问:这五段代码之间,有几处不同?
整个库只有两种形状
X 的形状是 (n_samples, n_features),y 的形状是 (n_samples,)。
一行一个样本,一列一个特征。这是整个 scikit-learn 唯一接受的输入形状,两百多个估计器全部遵守。
注意 X 是二维的,即使只有一个特征。y 是一维的。这一点是新手最常撞的墙——下一章会看到那条报错的原文。
然后是三个动词。它们的分工非常干净:
| 动词 | 做什么 | 谁有 | 输出形状 |
|---|---|---|---|
fit(X, y) | 从数据里学东西,存进自己 | 全部 | 返回自己 |
transform(X) | 用学到的东西改造数据 | 转换器(84 个) | 一张新的 X |
predict(X) | 用学到的东西给出答案 | 分类器 / 回归器(99 个) | 一列 y |
本机数了一下 scikit-learn 1.9.0 里有多少个:
全部估计器 208 分类器 44 回归器 55 转换器 84
208 个类,同一套接口。这不是巧合,是 sklearn 从 2010 年那篇 API 设计论文起就定下的规矩:任何一个估计器都必须是一个「有 fit 的对象」。
于是换模型就是换一行
回到开头的问题。本机把五个模型跑在同一份数据上:
from sklearn.linear_model import LogisticRegression # ← 只有这两行 model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=5000)) model.fit(X_train, y_train) model.score(X_test, y_test)
| 模型 | 测试集准确率 |
|---|---|
| LogisticRegression | 95.91% |
| RandomForestClassifier | 95.32% |
| SVC | 95.32% |
| KNeighborsClassifier | 94.74% |
| DecisionTreeClassifier | 90.64% |
五段代码之间只有类名不同(其中三个还要包一层 StandardScaler,因为它们对特征尺度敏感——这也是下一章的话题)。
顺带看一眼这张表本身:最简单的逻辑回归赢了随机森林。这不是意外,在几百行的表格数据上这很常见。不过更要紧的是——这五个数字,其实一个都不该信。为什么,是第 19 章的内容。
「学到的东西」存在哪:带下划线的属性
sklearn 有一条铁律:fit 之后学到的东西,全部存成以下划线结尾的属性。
LogisticRegression().fit(X, y) 之后,多出来这些: classes_ 有哪些类别 coef_ 每个特征的系数 intercept_ 截距 n_features_in_ 进来时有几列 n_iter_ 迭代了多少次
这条命名约定不只是风格,它是可以依赖的契约:
- 没有下划线的属性 = 你设的超参数(
model.max_iter),构造时就有。 - 有下划线的属性 = 从数据里学的(
model.coef_),fit之后才有。
所以 sklearn 能靠「有没有下划线属性」判断一个模型是否训练过。没训练就 predict:
NotFittedError: This LogisticRegression instance is not fitted yet. Call 'fit' with appropriate arguments before using this estimator.
这是全书第一条真正意义上的「有人替你把关」。前面十五章里,形状不对、索引错位、行数翻倍,全都是静默的。到了这一层,终于有东西会拦住你。
为什么是 fit / transform 而不是一个函数
这个设计初看有点绕:为什么标准化不能写成 standardize(X) 一个函数?因为转换器也要「学」。
scaler = StandardScaler() scaler.fit(X_train) # 学:算出训练集每一列的均值和标准差 scaler.transform(X_train) # 用这套均值和标准差改造训练集 scaler.transform(X_test) # ★ 用【同一套】改造测试集
最后一行是关键。测试集必须用训练集的均值和标准差来标准化——因为在真实的预测场景里,你面对的是一个新样本,根本没有「测试集的均值」这种东西可算。
把 fit 和 transform 拆开,就是为了让「学」和「用」能发生在不同的数据上。这个拆分是整个 sklearn 设计里最重要的一处,也是第 18 章那个招牌实验的全部要害。那里会看到:把这两步合成一步,一份纯随机的数据能给出 98.9% 的准确率。
有一个便捷方法叫 fit_transform(X),等价于先 fit 再 transform。它只能用在训练集上:
X_train_s = scaler.fit_transform(X_train) # ✓ X_test_s = scaler.transform(X_test) # ✓ 注意没有 fit X_test_s = scaler.fit_transform(X_test) # ✗ 泄漏!第 18 章
估计器(estimator)是 sklearn 里最基础的词,指任何有 fit 方法的对象。它不一定是模型——StandardScaler 是估计器,SelectKBest 是估计器,Pipeline 本身也是估计器。
往下分三类:
- 转换器(transformer):有
transform,把 X 变成新的 X。 - 预测器(predictor):有
predict,把 X 变成 y。分类器和回归器都属于这类。 - 元估计器(meta-estimator):把别的估计器包起来,比如
Pipeline、GridSearchCV、VotingClassifier。
注意别和 Transformer 架构混了——那是 2017 年的神经网络模型(GPT 的 T),和 sklearn 的转换器毫无关系,只是撞名。
「换模型只改一行」这件事,亲手跑一遍最有说服力:
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import SVC
X, y = load_breast_cancer(return_X_y=True)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3,
random_state=0, stratify=y)
for name, m in [
("LogisticRegression", make_pipeline(StandardScaler(), LogisticRegression(max_iter=5000))),
("KNeighborsClassifier", make_pipeline(StandardScaler(), KNeighborsClassifier())),
("DecisionTreeClassifier", DecisionTreeClassifier(random_state=0)),
("RandomForestClassifier", RandomForestClassifier(random_state=0)),
("SVC", make_pipeline(StandardScaler(), SVC(random_state=0))),
]:
m.fit(Xtr, ytr)
print("%-24s %.4f" % (name, m.score(Xte, yte)))
再把那 208 个数出来,以及看看「学到的东西」长什么样:
from sklearn.utils import all_estimators
print(len(all_estimators()),
len(all_estimators(type_filter="classifier")),
len(all_estimators(type_filter="regressor")),
len(all_estimators(type_filter="transformer"))) # 208 44 55 84
lr = LogisticRegression(max_iter=5000).fit(StandardScaler().fit_transform(Xtr), ytr)
print(sorted(a for a in vars(lr) if a.endswith("_") and not a.startswith("_")))
# ['classes_', 'coef_', 'intercept_', 'n_features_in_', 'n_iter_']
LogisticRegression().predict(Xte) # NotFittedError
python3 -c "from sklearn.utils import all_estimators;print(len(all_estimators()),'个估计器')"
装:pip install scikit-learn(本书用 1.9.0)。load_breast_cancer 是内置数据,不用联网。
- 这套接口已经成了事实标准。XGBoost、LightGBM、CatBoost 全都提供 sklearn 风格的包装类;imbalanced-learn、category-encoders、skorch(PyTorch 的 sklearn 包装)也是。只要一个库自称「sklearn 兼容」,你就知道它一定有
fit/predict,能塞进Pipeline,能被GridSearchCV调参。统一接口的复利就在这里。 - fit / transform 的拆分,就是「训练时」与「推理时」的分界。生产环境里模型上线,上线的正是那些下划线属性(均值、标准差、系数、树的结构)。整个 MLOps 领域处理的核心问题,就是「训练时学到的那套东西,怎么原封不动地搬到推理时」——所谓 training–serving skew,本质就是这两处用了不同的参数。
- Kotlin 里的接口约定。一个
interface Estimator<X, Y> { fun fit(x: X, y: Y): Model }——sklearn 用的是 duck typing(有这些方法就行),没有显式接口,但契约的严格程度不输静态类型。它靠的是文档 +check_estimator这个自动化测试工具。 - 为什么深度学习框架没有走这条路。PyTorch 的训练循环是手写的(前向、算损失、反向、更新),因为深度学习需要控制每一步。sklearn 的
fit是一个黑盒,这既是它的优点(简单)也是它的边界(想改训练过程就得换框架)。
「机器学习的重点是选对模型。先把几个模型都试一遍,挑准确率最高的那个。」
「都试一遍」这件事本身没错——sklearn 的统一接口就是为了让它变得便宜。错的是「挑准确率最高的那个」。上面那张表里,五个模型的差距是 90.64% 到 95.91%;而第 19 章会量出来:同一个模型、同一份数据,只换一次随机划分,准确率在 93.57% 到 100.00% 之间跳。
换句话说,划分带来的波动(6.43 个百分点)比模型之间的差距(5.27 个百分点)还大。拿一个测试集上的单次分数去挑模型,挑到的多半是「在这次划分上运气好的那个」。
而在模型选择之前,有两件事的收益高得多:特征怎么来(第 17 章)和评估怎么做(第 18、19 章)。这一卷剩下四章讲的全是后者,因为那才是数据泄漏和虚假分数的产地。
正确答案是 C:一处——只有模型的类名不同(外加 import)。
A 「原理天差地别,代码当然不同」——原理确实天差地别:逻辑回归在解一个凸优化问题,K 近邻根本不训练(只是把训练集存起来),决策树在递归地找分割点。但 sklearn 的设计目标正是把这些差别全部藏在fit 后面。这是一次非常彻底的抽象。
B 「参数名不一样」——超参数确实各不相同(n_neighbors、max_depth、C……),但它们都是可选的,全部有默认值。上面那段代码里只有 max_iter=5000 一个(为了让逻辑回归收敛),其余全用默认。
D 「类名 + 超参数」——这个答案在实战中最接近真相:真要用好一个模型,超参数是要调的(第 19 章会讲怎么调才不作弊)。但「跑起来」确实只需要改类名,这正是这套接口的价值:让「试一下」的成本接近零。
X:(n_samples, n_features),二维、全数字、无缺失;y:(n_samples,),一维。
sklearn 自己,而且它是全书唯一认真检查的一层。一维的 X 会抛 ValueError,没 fit 就 predict 会抛 NotFittedError,有 NaN 会抛异常,列数对不上会抛异常。下一章会把这道关卡的每一条报错都过一遍。
这一章的一句话
scikit-learn 用「两种形状 + 三个动词」换来了 208 个模型的可互换性;而其中最重要的设计是把 fit 和 transform 拆开——因为「学」和「用」必须能发生在不同的数据上。
下一章处理进这道门之前的活:把世界压成一个矩形。一列城市名要变成四列 0 和 1;一列文本要变成四千列。而这里有一个特别现实的问题——训练时没见过的类别,预测时来了怎么办?sklearn 给了两个选项,一个抛异常,一个给你一行全零,而它们代表两种完全不同的生产策略。