五次交接,一条线
前面二十一章是一层一层拆开讲的。这一章把它们接回去:一份三万行的订单数据,从读进来到交叉验证,中间要过五道关。每一次交接,上一层交出什么形状,下一层就必须认那个形状——而所有的返工,都发生在这五条竖线上。这一章全程打印形状,你会看到每一步数据长什么样、丢了多少、多了多少列。
一张订单表,五个特征列:金额、件数、会员天数(数值),城市、渠道(类别,各有 5 个和 3 个取值)。
问:交给 sklearn 之前,压出来的矩阵有几列?
全程
数据是我造的(三万行订单,真实规律是「金额高 + 会员久 + 用 App 的更可能复购」,另外 3% 的金额是缺失的)。七步:
| 步 | 做什么 | 出来的形状 | 哪一层 |
|---|---|---|---|
| 1 | 读进来 | (30000, 7) | pandas |
| 2 | 丢掉金额缺失的行 | (29087, 7) | pandas |
| 3 | groupby 汇总客单价 | (15, 3) | pandas |
| 4 | 长表交给画图 | (15, 3) | seaborn |
| 5 | 画出来 | 15 根柱子 | matplotlib |
| 6 | 压成矩形 | (29087, 11) | sklearn |
| 7 | 交叉验证 | 5 个 AUC | sklearn |
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()。这一条同时管住 dropna、merge(第 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, '列')"
改一改试试:把 dropna 的 subset 去掉、把 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,就变成了会替你报警的东西。
正确答案是 C:11 列。3 个数值 + 5 个城市 + 3 个渠道。
A 「有几个特征就是几列」——这正是最容易漏掉的一处形状变化。类别列进 sklearn 之前一定会膨胀,膨胀多少取决于有多少个不同取值。一列「商品 ID」能把 11 列变成四万列。 B 「8 列」——只算了城市,忘了渠道也是类别列。可以用get_feature_names_out() 把压出来的列名全部打印出来,这是压完之后唯一能找回列名的途径,也是核对形状最直接的办法。
D 「加一列未知」——handle_unknown="ignore" 不会加列。它遇到没见过的类别时给一行全零,列数保持不变。这一点很重要:列数是模型契约的一部分,上线后就冻结了(第 17 章)。要给「未知」留位置,得你自己在编码前把稀有类别合并成一个「其他」。
print(df.shape)——这就是这本书交给你的那件工具。
这一章的一句话
一次真实的分析是五次交接:每一次,上一层交出什么形状,下一层就必须认那个形状;而所有的返工都发生在这五条竖线上——所以最便宜的防御,是在每条线上打印一次形状。
最后一章收尾:把五层的交接单收成一张表,把这本书里那些「不报错但错了」的地方收成一张自查表,然后说说这条列往下还能通向哪里。