卷 IV · 唯一的队形CH 16深度 16/23

三个动词:fittransformpredict

前面三卷里,每一层都有自己的一套规矩,而且都不太爱管你。这一卷不一样:scikit-learn 是全书唯一一层会当场拦住你的。它的要求极其苛刻——两种形状,三个动词,两百多个模型无一例外。苛刻带来的回报是:把一个模型换成另一个,代码只改一行。这一章先把这套契约摆清楚,它是后面四章的地基。

208 个估计器三个动词换模型改一行

▷ 先猜一下

一份 569 行、30 列的医学诊断数据(sklearn 自带的乳腺癌数据集)。你要试五个完全不同的模型:逻辑回归、K 近邻、决策树、随机森林、支持向量机。

问:这五段代码之间,有几处不同?

A 差不多完全不同。五个模型的数学原理天差地别 B 大约五六处。参数名不一样,接口大致相似 C 一处。只有模型的类名不同,其余一模一样 D 两处。类名,加上各自的超参数

整个库只有两种形状

◆ 主线

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)
模型测试集准确率
LogisticRegression95.91%
RandomForestClassifier95.32%
SVC95.32%
KNeighborsClassifier94.74%
DecisionTreeClassifier90.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)         # ★ 用【同一套】改造测试集

最后一行是关键。测试集必须用训练集的均值和标准差来标准化——因为在真实的预测场景里,你面对的是一个新样本,根本没有「测试集的均值」这种东西可算。

fittransform 拆开,就是为了让「学」和「用」能发生在不同的数据上。这个拆分是整个 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):把别的估计器包起来,比如 PipelineGridSearchCVVotingClassifier

注意别和 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 兼容」,你就知道它一定有 fitpredict,能塞进 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_neighborsmax_depthC……),但它们都是可选的,全部有默认值。上面那段代码里只有 max_iter=5000 一个(为了让逻辑回归收敛),其余全用默认。 D 「类名 + 超参数」——这个答案在实战中最接近真相:真要用好一个模型,超参数是要调的(第 19 章会讲怎么调才不作弊)。但「跑起来」确实只需要改类名,这正是这套接口的价值:让「试一下」的成本接近零。
⌗ 交接单
一张表(pandas 的 DataFrame 或 numpy 的二维数组)+ 一列标签。 X(n_samples, n_features)二维、全数字、无缺失y(n_samples,),一维。 sklearn 自己,而且它是全书唯一认真检查的一层。一维的 X 会抛 ValueError,没 fit 就 predict 会抛 NotFittedError,有 NaN 会抛异常,列数对不上会抛异常。下一章会把这道关卡的每一条报错都过一遍。

这一章的一句话

scikit-learn 用「两种形状 + 三个动词」换来了 208 个模型的可互换性;而其中最重要的设计是把 fittransform 拆开——因为「学」和「用」必须能发生在不同的数据上。

下一章处理进这道门之前的活:把世界压成一个矩形。一列城市名要变成四列 0 和 1;一列文本要变成四千列。而这里有一个特别现实的问题——训练时没见过的类别,预测时来了怎么办?sklearn 给了两个选项,一个抛异常,一个给你一行全零,而它们代表两种完全不同的生产策略。