卷 V · 合上书之后CH 22深度 22/23

五次交接,一条线

前面二十一章是一层一层拆开讲的。这一章把它们接回去:一份三万行的订单数据,从读进来到交叉验证,中间要过五道关。每一次交接,上一层交出什么形状,下一层就必须认那个形状——而所有的返工,都发生在这五条竖线上。这一章全程打印形状,你会看到每一步数据长什么样、丢了多少、多了多少列。

七步全程报形状五次交接AUC 0.7119

▷ 先猜一下

一张订单表,五个特征列:金额、件数、会员天数(数值),城市、渠道(类别,各有 5 个和 3 个取值)。

问:交给 sklearn 之前,压出来的矩阵有几列?

A 5 列。有几个特征就是几列 B 8 列。3 个数值 + 5 个城市 C 11 列。3 个数值 + 5 个城市 + 3 个渠道 D 13 列。还要给每个类别加一列「未知」

全程

数据是我造的(三万行订单,真实规律是「金额高 + 会员久 + 用 App 的更可能复购」,另外 3% 的金额是缺失的)。七步:

做什么出来的形状哪一层
1读进来(30000, 7)pandas
2丢掉金额缺失的行(29087, 7)pandas
3groupby 汇总客单价(15, 3)pandas
4长表交给画图(15, 3)seaborn
5画出来15 根柱子matplotlib
6压成矩形(29087, 11)sklearn
7交叉验证5 个 AUCsklearn
01 读进来        (30000, 7)
02 丢缺失        (29087, 7)      丢了 913 行(3.04% 的金额缺失)
03 groupby       (15, 3)         5 个城市 × 3 个渠道
04 长表          (15, 3)         列:城市 / 渠道 / 客单价
05 画出来        15 根柱子       1 个 Axes
06 压成矩形      (29087, 11)     5 列 → 11 列
07 交叉验证      AUC 0.7119 ± 0.0085
                 五折:0.7150 0.7234 0.7023 0.7049 0.7137

现在逐条走那五道关,每一道都对应这本书前面的某一章。

交接一(第 2 步):pandas 里的形状变化

clean = raw.dropna(subset=["金额"])          # 30000 → 29087

丢了 913 行。这里写了 subset=["金额"]——只在乎这一列。如果写成裸的 dropna(),会连带丢掉其他列缺失的行;第 8 章那个例子里,10 列各缺 5% 就能丢掉 39.3%。

可以带走的动作:任何一次可能改变行数的操作,前后各打一次 len()这一条同时管住 dropnamerge(第 10 章那个 5 → 11)、groupby(第 9 章那个静默丢行)、和布尔过滤。

交接二(第 3 到 4 步):pandas → seaborn

by_city = clean.groupby(["城市", "渠道"], observed=True)["金额"].mean().reset_index()
long = by_city.rename(columns={"金额": "客单价"})       # (15, 3)

注意那个 .reset_index()groupby 出来的东西,分组键在 index 上;而 seaborn 要的是「一行一个观测,一列一个变量」,分组键必须是没有这一行,x="城市" 就找不到那一列。

这就是第 11 章那一整章:「城市」必须真的是一列,seaborn 才有东西可指。

交接三(第 5 步):seaborn → matplotlib

g = sns.catplot(data=long, x="城市", y="客单价", hue="渠道", kind="bar")
bars = [p for a in g.figure.axes for p in a.patches if p.get_height() > 0]
len(bars)          # 15 —— 和长表的行数一致

15 行数据 → 15 根柱子。这个对应关系值得验一次:如果柱子数对不上行数,通常意味着某些组合缺数据,或者 observed 参数让空类别也画了出来。

拿到 g.figure.axes 之后,第 13 章那套改 Artist 的能力全部可用。

交接四(第 6 步):pandas → sklearn

pre = ColumnTransformer([
    ("num", StandardScaler(),                       ["金额", "件数", "会员天数"]),
    ("cat", OneHotEncoder(handle_unknown="ignore"), ["城市", "渠道"]),
])
5 列  →  11 列
  num__金额  num__件数  num__会员天数
  cat__城市_上海  cat__城市_北京  cat__城市_广州  cat__城市_成都  cat__城市_深圳
  cat__渠道_App  cat__渠道_Web  cat__渠道_小程序

3 + 5 + 3 = 11

这就是开头那个问题的答案:11 列。三个数值列原样保留(只是被标准化),两个类别列分别展开成 5 列和 3 列。

那个 handle_unknown="ignore" 是第 17 章那个决定:上线后来了「杭州」,给一行全零而不是抛异常——前提是你配了监控。

交接五(第 7 步):全部进 Pipeline

pipe = Pipeline([("pre", pre), ("clf", LogisticRegression(max_iter=5000))])
cv = cross_val_score(pipe, feat, y, cv=StratifiedKFold(5, shuffle=True,
                                                       random_state=0),
                     scoring="roc_auc")
print("%.4f ± %.4f" % (cv.mean(), cv.std(ddof=1)))     # 0.7119 ± 0.0085

这一行是第 18 和 19 章的全部:

  • 预处理在 Pipeline 里,所以每一折的标准化和独热编码都只用那一折的训练数据 fit——没有泄漏。
  • 报的是均值和标准差,不是一个数。0.7119 ± 0.0085 比「0.7119」诚实得多。
  • 用的是 AUC 不是准确率,因为这份数据的正例占 44.5%——虽然不算不平衡,但 AUC 与阈值无关,更适合做模型比较(第 20 章)。

最后看一眼模型学到了什么:

系数最大的三个
  num__金额        +0.654
  num__会员天数    +0.532
  cat__渠道_Web    −0.206

金额和会员天数正相关,Web 渠道负相关——和我生成数据时写下的规律一致(生成用的是「App 加 0.35」,模型学出来的是「Web 减 0.206」,同一件事的两种参照)。这类「对得上吗」的核对,是每个建模流程该做的最后一步。

把这条线画成一张检查表

交接一句话检查对应章节
pandas 内部行数变了吗?len() 前后各打一次8、9、10
pandas → seaborn要画的那个变量,是不是一列?(reset_index()11、14
seaborn → matplotlib图元数和数据行数对得上吗?12、13
pandas → sklearn二维吗?全数字吗?没有 NaN 吗?没见过的类别怎么办?16、17
数据 → 分数每一步都在 Pipeline 里吗?报的是分布还是一个数?18、19、20
⌨ 自己跑一遍

整条线,四十行。这是全书最后一段代码:

import numpy as np, pandas as pd, seaborn as sns
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, StratifiedKFold

rng = np.random.default_rng(22)
n = 30_000
raw = pd.DataFrame({
    "订单号": np.arange(n),
    "城市": rng.choice(["北京", "上海", "广州", "深圳", "成都"], n, p=[.3,.25,.2,.15,.1]),
    "渠道": rng.choice(["App", "Web", "小程序"], n, p=[.5,.3,.2]),
    "金额": np.round(rng.gamma(3, 60, n), 2),
    "件数": rng.integers(1, 9, n),
    "会员天数": rng.integers(0, 2000, n),
})
logit = (-2.4 + 0.006*raw["金额"] + 0.0009*raw["会员天数"] + 0.35*(raw["渠道"]=="App"))
raw["复购"] = (rng.random(n) < 1/(1+np.exp(-logit))).astype(int)
raw.loc[rng.random(n) < 0.03, "金额"] = np.nan          # 造点脏数据

print("01", raw.shape)
clean = raw.dropna(subset=["金额"]).copy();      print("02", clean.shape)
by = clean.groupby(["城市","渠道"], observed=True)["金额"].mean().reset_index()
print("03", by.shape)
long = by.rename(columns={"金额": "客单价"});    print("04", long.shape, list(long.columns))
g = sns.catplot(data=long, x="城市", y="客单价", hue="渠道", kind="bar")
print("05", len([p for a in g.figure.axes for p in a.patches if p.get_height() > 0]))
plt.close("all")

feat = clean[["金额","件数","会员天数","城市","渠道"]]
y = clean["复购"].to_numpy()
pre = ColumnTransformer([("num", StandardScaler(), ["金额","件数","会员天数"]),
                         ("cat", OneHotEncoder(handle_unknown="ignore"), ["城市","渠道"])])
print("06", pre.fit_transform(feat).shape)
pipe = Pipeline([("pre", pre), ("clf", LogisticRegression(max_iter=5000))])
cv = cross_val_score(pipe, feat, y, cv=StratifiedKFold(5, shuffle=True, random_state=0),
                     scoring="roc_auc")
print("07 AUC %.4f ± %.4f" % (cv.mean(), cv.std(ddof=1)))

pipe.fit(feat, y)
names = pipe.named_steps["pre"].get_feature_names_out()
coefs = pipe.named_steps["clf"].coef_[0]
for k, v in sorted(zip(names, coefs), key=lambda kv: -abs(kv[1]))[:3]:
    print("  %-18s %+.3f" % (k, v))

把每一行的 print 都留着。这不是调试代码,这是流程的一部分——每一步报出形状,出问题时你一眼就知道断在哪一道关上。

python3 -c "print('3 个数值 + 5 个城市 + 3 个渠道 =', 3+5+3, '列')"

改一改试试:把 dropnasubset 去掉、把 reset_index() 删掉、把 ColumnTransformer 从 Pipeline 里拿出去先 fit_transform 全量数据——三种改法各对应一道关卡,各有各的症状。

▸ 在现实里
  • 「特征工程 80%,建模 20%」这句老话说的就是这张表。七步里只有最后一步是「建模」,前六步全是形状的搬运。而返工也几乎全在前六步——模型换一个只要一行。
  • 数据管线的自动化测试,测的就是这五条竖线。Great Expectations、pandera、dbt tests 这类工具的核心断言只有几种:行数在预期范围内、某列不为空、某列的取值在枚举里、主键唯一。它们把这一章的 print 变成了会报警的 assert
  • 为什么 notebook 里的代码上线总要重写。notebook 里那些「跑一下看看形状」的中间单元格,正是这一章的 print——它们是流程的一部分,却常常在整理成脚本时被删掉。更好的做法是把它们变成 assert 留下来。
  • 模型上线之后的监控,监控的还是同一批东西。输入行数、缺失率、类别分布、未知类别占比、预测分布——和这一章打印的形状是同一套东西,只是从「跑一次看一眼」变成了「每天看一眼」。
✗ 这个直觉是错的

「数据处理是体力活,把数据洗干净了,剩下的建模才是技术含量所在。」

这一章七步里,「建模」只占一步,而且是最不容易出错的一步——换一个模型改一行,第 16 章量过。真正会静默出错的全在前面:dropna 丢多少行、merge 放大多少倍、忘了 reset_index()、类别编码在 Pipeline 外面做了。

而这些错误的共同特征是:它们全都不报错。数据还在,代码跑通了,图画出来了,分数也出来了——只是那个分数在回答另一个问题。整本书讲了二十二章,讲的其实就是这一件事。

唯一有效的对策,是这一章那七行 print形状是最便宜的检查:一个数字,一秒钟,覆盖了这本书里绝大多数事故。把它们写成 assert,就变成了会替你报警的东西。

◇ 揭晓

正确答案是 C11 列。3 个数值 + 5 个城市 + 3 个渠道。

A 「有几个特征就是几列」——这正是最容易漏掉的一处形状变化。类别列进 sklearn 之前一定会膨胀,膨胀多少取决于有多少个不同取值。一列「商品 ID」能把 11 列变成四万列。 B 「8 列」——只算了城市,忘了渠道也是类别列。可以用 get_feature_names_out() 把压出来的列名全部打印出来,这是压完之后唯一能找回列名的途径,也是核对形状最直接的办法。 D 「加一列未知」——handle_unknown="ignore" 不会加列。它遇到没见过的类别时给一行全零,列数保持不变。这一点很重要:列数是模型契约的一部分,上线后就冻结了(第 17 章)。要给「未知」留位置,得你自己在编码前把稀有类别合并成一个「其他」。
⌗ 交接单
一个 CSV 文件、一个数据库查询结果,或者一份别人给你的表。 一个数字(或者一张图),以及它经过的每一次形状变化的记录 五道关卡里,只有 sklearn 那一道会主动拦你。其余四道全靠 print(df.shape)——这就是这本书交给你的那件工具。

这一章的一句话

一次真实的分析是五次交接:每一次,上一层交出什么形状,下一层就必须认那个形状;而所有的返工都发生在这五条竖线上——所以最便宜的防御,是在每条线上打印一次形状。

最后一章收尾:把五层的交接单收成一张表,把这本书里那些「不报错但错了」的地方收成一张自查表,然后说说这条列往下还能通向哪里。