AI 学习第 8 课:1200 列纯噪声 CV 出 0.47,泄漏在筛特征那步

一个全随机、毫无信号的数据集,交叉验证跑出 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 开始,把前面手推的梯度交给框架算一遍对对数。

相关推荐
老金带你玩AI26 分钟前
我宣布!Magpie就是版本答案!
人工智能
Rocky Ding*34 分钟前
REPA-E技术深度解析:解冻 VAE 之后,扩散模型为什么需要选择梯度
论文阅读·人工智能·深度学习·机器学习·aigc·ai-native·repa-e
武雄(小星Ai)1 小时前
Ollama报llama-server binary not found排坑实录:服务活着引擎没了
人工智能·windows·大模型·ollama·排坑
bryant_meng1 小时前
【TNT】《Transformer in Transformer》
人工智能·深度学习·transformer·word embedding·tnt·sentence embed
jay神2 小时前
基于 YOLOv8 的轴承缺陷检测(Web 版)
前端·人工智能·yolo
叼烟扛炮2 小时前
[LangChain] 学习笔记(08):工具调用底层闭环与第三方搜索(Tavily)实战
人工智能·笔记·学习·langchain
DQQzero2 小时前
0.16 元/秒的短剧流水线:即梦短剧工作室把 AI 视频生产改成了什么样
人工智能·ai·aigc·视频生成
@追求卓越2 小时前
深度学习记录
人工智能·深度学习
武子康3 小时前
H3-World 为何在加载 LoRA 前,先检查导入的代码?
人工智能·深度学习·算法