一个全随机、毫无信号的数据集,交叉验证跑出 0.4748 的 R²。模型没变聪明,是验证集的答案提前漏进了训练。
第 7 课结尾我留了句话,说标准化得放进 CV 循环、只在训练折上 fit。这课把伏笔收掉,并且给出一个更精确的结论:泄漏的破坏力,取决于这一步有没有碰过 y。碰过的,能虚高 0.4 以上;没碰过的,实测只值 0.03,方向还不一定。
环境准备
本机 Windows 10 + Python 3.13.14,numpy 2.5.1,scikit-learn 1.9.1(2026-09-11 发布,第一个支持 Python 3.15 的版本)。
bash
pip install -U numpy scikit-learn
python
import numpy as np
from sklearn.feature_selection import SelectKBest, f_regression
from sklearn.impute import SimpleImputer
from sklearn.linear_model import Ridge
from sklearn.model_selection import KFold, cross_val_score
from sklearn.neighbors import KNeighborsRegressor
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, TargetEncoder
rng = np.random.default_rng(20261107)
cv = KFold(n_splits=5, shuffle=True, random_state=0)
n, p = 80, 1200
X = rng.normal(size=(n, p))
y = rng.normal(size=n) # 目标就是纯噪声,真实信号为 0
80 行样本、1200 列标准正态噪声,y 也是纯噪声。任何模型的 R² 都该 ≤0,跑出正数就是在作弊。
实验一:1200 列纯噪声,CV 也能跑出 0.47
两段代码只差一个位置:筛选特征放在 CV 外面,还是塞进 Pipeline。
python
safe = Pipeline([("sel", SelectKBest(f_regression, k=20)), ("ridge", Ridge(alpha=1.0))])
# 错误示范:先用全部 80 行的 y 筛出 20 列,再拿筛完的矩阵做 CV
sel = SelectKBest(f_regression, k=20).fit(X, y)
print("全量筛列 + CV : %+.4f" % cross_val_score(
Ridge(alpha=1.0), sel.transform(X), y, cv=cv, scoring="r2").mean())
print("Pipeline 内筛列: %+.4f" % cross_val_score(
safe, X, y, cv=cv, scoring="r2").mean())
输出:
text
全量筛列 + CV : +0.4748
Pipeline 内筛列: -0.2841
同一个模型、同一份数据,差 0.76。
原因不复杂。f_regression 给每一列算一个"这列和 y 的 F 值",SelectKBest 挑 F 值最大的 20 列。全量 fit 时参与计算的 80 行里,就包含了后面要当验证集的那 16 行------等于先偷看答案,把"碰巧和这批 y 相关"的噪声列挑出来。
Pipeline 版本里,SelectKBest.fit 每折只吃训练折的 64 行,验证折的 16 行从头到尾没参与挑列,所以分数掉到 -0.2841,这才是没有信号的模型该有的样子。
换 k,泄漏量跟着变
k=20 只是碰巧最肥的一档。把 k 从 5 扫到 50 看规律:
python
print(" k 全量筛列 Pipeline")
for k in (5, 10, 20, 50):
a = cross_val_score(Ridge(alpha=1.0),
SelectKBest(f_regression, k=k).fit_transform(X, y),
y, cv=cv, scoring="r2").mean()
b = cross_val_score(Pipeline([("s", SelectKBest(f_regression, k=k)),
("r", Ridge(alpha=1.0))]),
X, y, cv=cv, scoring="r2").mean()
print("%2d %+9.4f %+9.4f" % (k, a, b))
text
k 全量筛列 Pipeline
5 +0.2372 -0.4384
10 +0.3546 -0.3695
20 +0.4748 -0.2841
50 +0.2428 -0.6177
四行里正确的始终是负的,泄漏的始终是正的------这条边界比具体数值有用:CV 外面做过任何依赖 y 的列变换,分数就没有参考价值。
k 增大分数先升后降也有说法:列越多,靠偶然撞上相关的噪声列越多,泄漏越肥;但 k 到 50,20 个噪声列撑起的"假信号"被稀释,Ridge 压不住方差,回落到 0.2428。80 行样本配 1200 列,本身就是高危配置。
实验二:目标编码把答案写进特征
监督式筛选是"用 y 挑列",目标编码是"用 y 造值",后者更常见于业务表里的高基数类别列。
python
n3 = 120
cat = rng.integers(0, 60, size=n3).reshape(-1, 1) # 60 个类别,平均每类 2 行
y3 = rng.normal(size=n3)
enc = np.empty(n3)
for c in np.unique(cat): # 手写全量编码:每行的值里都有它自己的 y
enc[cat[:, 0] == c] = y3[cat[:, 0] == c].mean()
print("全量目标编码 : %+.4f" % cross_val_score(
Ridge(alpha=1.0), enc.reshape(-1, 1), y3, cv=cv, scoring="r2").mean())
te = Pipeline([("te", TargetEncoder(target_type="continuous",
cv=KFold(5, shuffle=True, random_state=0),
smooth=10.0)),
("ridge", Ridge(alpha=1.0))])
print("TargetEncoder: %+.4f" % cross_val_score(te, cat, y3, cv=cv, scoring="r2").mean())
text
全量目标编码 : +0.4179
TargetEncoder: -0.0497
平均每类只有 2 行,全量算类别均值时,每一行的编码里都掺着自己的 y。模型只要把这列抄一遍就能"预测",0.4179 全是自己告诉自己的。
手写这段的人常以为加个 smooth 就安全了,其实收缩只压小样本方差,不解决自泄漏------行还在训练集里,自己的 y 就还在均值里。
scikit-learn 官方的 TargetEncoder 走的是另一条路:fit_transform 在训练折内部再切 K 折做交叉拟合,每行的编码来自不含它自己的那部分数据,所以它在 Pipeline 里套 cross_val_score 是干净的,-0.0497 就是"这列没用"的正确答案。顺带一个 1.9 的变化:shuffle 和 random_state 已弃用,要固定内部切分得把 KFold 直接传给 cv,不然每次跑分数都不一样。
结论:目标编码别手写,用 TargetEncoder 并确保它在 Pipeline 里;手写版要做对得自己实现折内交叉拟合再补测试,不划算。
标准化该放哪:实测只差 0.026
回到第 7 课的伏笔。同样两段代码,只挪 StandardScaler 的位置:
python
n4 = 200
Xs = rng.normal(size=(n4, 6))
beta = np.array([1.0, -0.8, 0.6, 0.5, -0.4, 0.3])
y4 = Xs @ beta + rng.normal(scale=0.3, size=n4)
X4 = np.hstack([Xs, rng.normal(size=(n4, 6)) * 1e4]) # 追加 6 列纯噪声,量纲 1e4
knn = KNeighborsRegressor(n_neighbors=5)
print("不标准化 : %.4f" % cross_val_score(knn, X4, y4, cv=cv, scoring="r2").mean())
print("全量标准化 : %.4f" % cross_val_score(
knn, StandardScaler().fit_transform(X4), y4, cv=cv, scoring="r2").mean())
print("Pipeline 内: %.4f" % cross_val_score(
Pipeline([("sc", StandardScaler()), ("knn", KNeighborsRegressor(n_neighbors=5))]),
X4, y4, cv=cv, scoring="r2").mean())
text
不标准化 : -0.1581
全量标准化 : 0.5773
Pipeline 内: 0.6033
三行数字说的是两件事。
做不做标准化,值 0.76。6 列纯噪声的量纲是 1e4,KNN 算欧氏距离时被这 6 列绑架,R² 掉到 -0.1581。Ridge 这种对量纲不敏感的模型做不做几乎没差;KNN、SVM、带惩罚的线性模型,不做就是自找麻烦。
放哪儿,值 0.026,方向还往下:全量 0.5773 比折内 0.6033 低。第 7 课预告的"虚低"这次撞上了,但不是规律------StandardScaler 只用 X 的均值和标准差,没碰 y,泄漏的只是特征分布,方向随数据走。
所以别把标准化位置当分数虚高的头号嫌疑人,分数离谱时先查有没有用 y 造特征。
正确写法:一个 Pipeline 收掉所有 fit
把缺失值也加进来,看完整形态:
python
X4[0, 0] = np.nan # 挖一个缺失值
good = Pipeline([
("impute", SimpleImputer(strategy="median")), # 中位数只能来自训练折
("scale", StandardScaler()),
("knn", KNeighborsRegressor(n_neighbors=5)),
])
print("完整 Pipeline: %.4f" % cross_val_score(good, X4, y4, cv=cv, scoring="r2").mean())
text
完整 Pipeline: 0.6031
和上一节的 0.6033 基本一致:一个缺失值用中位数填几乎无影响,但位置对了------中位数只从每折训练的 160 行里算。
规则可以写死成一句:凡是有 fit 的步骤,一律进 Pipeline 。StandardScaler、SimpleImputer、SelectKBest、PCA、TargetEncoder、OneHotEncoder 全都算。手上只留三样东西------训练集 X、训练集 y、cross_val_score(pipe, X, y),中间不产生任何在全量数据上 fit 过的对象,就不会有位置问题。这比"记住每个 transformer 要不要放循环里"可靠得多。
踩坑记录:分数悄悄变成 nan
写探针时踩到一个更阴的坑:把没填缺失值的矩阵喂给 Ridge。
python
bad = Pipeline([("scale", StandardScaler()), ("ridge", Ridge(alpha=1.0))])
print("含 NaN 时的分数:", cross_val_score(bad, X4, y4, cv=cv, scoring="r2"))
text
含 NaN 时的分数: [nan nan nan nan nan]
真实报错被埋在两段 warning 里:
text
UserWarning: Scoring failed. The score on this train-test partition for these parameters will be set to nan.
ValueError: Input X contains NaN.
Ridge does not accept missing values encoded as NaN natively.
FitFailedWarning: 4 fits failed out of a total of 5.
五折全 nan,一折都没跑成:那行 NaN 在 4 折里落进训练集导致 fit 失败,剩 1 折落进验证集导致打分失败。
坑在于 cross_val_score 默认 error_score=np.nan,拟合失败不抛异常 ,只 warn 一句就把这折分数设成 nan。np.mean 一算,整条对比链全是 nan,而你还在盯着表格找"为什么模型这么差"。
定位方式:加 error_score="raise",栈立刻打出来。我现在的习惯是探针第一遍必带它,确认链路干净再去掉。
小结
这课能落地的三条:
- CV 外面做过任何依赖 y 的变换(筛选、目标编码、用 y 分箱),分数就作废,实测能从 0 以下抬到 +0.47;
- 目标编码用
TargetEncoder放 Pipeline 里,别手写groupby().mean(),收缩参数救不了自泄漏; - 标准化位置的影响是 0.03 量级、方向不定,照规矩放进 Pipeline 零成本。
入门阶段到这里收官:numpy 计算、梯度手推、数据清洗、评估指标、交叉验证、特征工程,一条能自己验证的链路通了。下一课(第 9 课)进进阶阶段,装 PyTorch,从张量与 autograd 开始,把前面手推的梯度交给框架算一遍对对数。