开发技巧

用 scikit-learn Pipeline 处理缺失值与类别变量:交叉验证和数据泄漏检查

《Intermediate Machine Learning》讲了缺失值、类别变量、Pipeline、交叉验证和数据泄漏。 这篇用一份我生成的合成二手车数据(有缺失值和类别列)把这些串起来:ColumnTransformer + Pipeline 做预处理和模型,5 折交叉验证打分;数据里故意放了一个“过户费”列——成交后才知道,脚本能发现它让误差异常下降并

BINBHO · 图文教程

《Intermediate Machine Learning》讲了缺失值、类别变量、Pipeline、交叉验证和数据泄漏。 这篇用一份我生成的合成二手车数据(有缺失值和类别列)把这些串起来:ColumnTransformer + Pipeline 做预处理和模型,5 折交叉验证打分;数据里故意放了一个“过户费”列——成交后才知道,脚本能发现它让误差异常下降并提示可能是目标泄漏。

先看清成品

  • 真实运行:正常特征 MAE 1.131 万元,加入“过户费”后 0.043 万元,脚本发出泄漏告警。
  • 真实复现:预测时遇到训练集里没有的城市“广州”,不加 handle_unknown="ignore" 会报错。
  • 4 项 pytest 通过。

合规提醒:Kaggle Learn 的课程和练习 Notebook 免费在线学习,内容版权归 Kaggle;本文不复制课程内容,只把课程讲的方法用别的数据重做一遍。用 Kaggle 数据集前看它的许可证;比赛数据只能按比赛规则使用,下载前要在网页上接受规则。一人一个账号,不共享账号或 API Token,不用多个账号绕过提交次数限制。

运行说明:本文代码全部在 box 上真实运行(2026-10-21,UTC+8),用的是 scikit-learn 1.9.1 自带数据或我自己生成的合成数据,没有从 Kaggle 下载任何数据。数据是用随机数生成的,价格公式是我编的,不代表任何真实市场。

做完之后你会得到什么?

成果 说明
make_data.py 生成合成数据
used_cars.csv 生成的 600 行数据
pipeline.py Pipeline + 交叉验证 + 泄漏检查
test_pipeline.py 4 项测试

需要准备哪些环境?

项目 说明
Python 3.9 以上,pip install pandas scikit-learn pytest(我用的是 scikit-learn 1.9.1)

关键事实

事实 来源
课程内容:缺失值、类别变量、Pipeline、交叉验证、XGBoost、数据泄漏 Kaggle Learn 课程页
ColumnTransformer 对不同列做不同预处理;放进 Pipeline 后,交叉验证每一折只在训练部分上拟合预处理 scikit-learn 文档 Pipelines and composite estimators
预处理在全部数据上拟合后再做交叉验证会造成信息泄漏 scikit-learn 常见陷阱
OneHotEncoder(handle_unknown="ignore"):遇到未见过的类别时编码为全 0,不报错 scikit-learn 文档

文件结构

88-kaggle-learn-B-src/
├── make_data.py
├── used_cars.csv
├── pipeline.py
└── test_pipeline.py

第 1 步:生成数据

"""生成一份合成的二手车数据(全部是我用随机数造的,不是真实市场数据)。
故意放进:缺失值、类别列,以及一个“泄漏”列——过户费,它是成交后才知道的,按成交价的 1% 收。"""
import numpy as np, pandas as pd


def make(n=600, seed=0):
    r = np.random.default_rng(seed)
    age = r.integers(1, 15, n)
    km = (age * r.normal(1.5, 0.4, n)).clip(0.1) * 10000
    brand = r.choice(["甲牌", "乙牌", "丙牌"], n, p=[.5, .3, .2])
    city = r.choice(["北京", "上海", "成都", "西安"], n)
    base = {"甲牌": 15, "乙牌": 22, "丙牌": 35}
    price = np.array([base[b] for b in brand]) * 0.88 ** age - km / 1e5 + r.normal(0, 1.2, n)
    price = price.clip(0.5).round(2)                     # 万元
    df = pd.DataFrame({"车龄": age, "里程": km.round(-2), "品牌": brand, "城市": city,
                       "过户费": (price * 100).round(0), "价格": price})   # 过户费(元)= 价格(万) × 1% × 10000
    for col, frac in (("里程", .08), ("城市", .05)):
        df.loc[r.random(n) < frac, col] = np.nan
    return df


if __name__ == "__main__":
    df = make(); df.to_csv("used_cars.csv", index=False, encoding="utf-8")
    print(df.head().to_string()); print("缺失:", df.isna().sum().to_dict())

第 2 步:Pipeline

"""Kaggle Learn《Intermediate Machine Learning》里的几件事,用一份合成数据做一遍:
缺失值填补、类别编码、用 Pipeline 把预处理和模型绑在一起、交叉验证、识别目标泄漏。"""
import sys
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import RandomForestRegressor
from sklearn.impute import SimpleImputer
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder

NUM = ["车龄", "里程"]; CAT = ["品牌", "城市"]


def build(num=NUM, cat=CAT):
    pre = ColumnTransformer([
        ("num", SimpleImputer(strategy="median"), num),
        ("cat", Pipeline([("imp", SimpleImputer(strategy="most_frequent")),
                          ("oh", OneHotEncoder(handle_unknown="ignore"))]), cat)])
    return Pipeline([("pre", pre), ("model", RandomForestRegressor(n_estimators=100, random_state=0))])


def score(df, num, cat=CAT):
    s = -cross_val_score(build(num, cat), df[num + cat], df["价格"], cv=5, scoring="neg_mean_absolute_error")
    return s.mean()


def main(path="used_cars.csv"):
    df = pd.read_csv(path)
    print(f"{len(df)} 行,缺失:{ {k: v for k, v in df.isna().sum().items() if v} }")
    ok = score(df, NUM); leak = score(df, NUM + ["过户费"])
    print(f"正常特征        5 折 MAE {ok:.3f} 万元")
    print(f"加上“过户费”    5 折 MAE {leak:.3f} 万元")
    if leak < ok * 0.5:
        print("⚠ 加一列后误差降了一半以上:先问这一列在预测时能不能拿到(过户费是成交后才有的,属于目标泄漏)")
    return ok, leak


if __name__ == "__main__":
    main(*sys.argv[1:])

第 3 步:测试

import pandas as pd, pytest
from sklearn.preprocessing import OneHotEncoder
import make_data, pipeline as p

@pytest.fixture(scope="module")
def df():
    return make_data.make()

def test_data_has_missing(df):
    assert df["里程"].isna().any() and df["城市"].isna().any()

def test_leak_detected(df):
    ok, leak = p.score(df, p.NUM), p.score(df, p.NUM + ["过户费"])
    assert leak < ok * 0.5

NEW = pd.DataFrame([{"车龄": 3, "里程": 40000, "品牌": "甲牌", "城市": "广州"}])

def test_unknown_city_ok(df):
    m = p.build().fit(df[p.NUM + p.CAT], df["价格"])
    assert m.predict(NEW)[0] > 0

def test_unknown_city_fails_without_ignore(df):
    m = p.build(); m.set_params(pre__cat__oh=OneHotEncoder())
    m.fit(df[p.NUM + p.CAT], df["价格"])
    with pytest.raises(ValueError, match="unknown categories"):
        m.predict(NEW)

第 4 步:运行

$ python make_data.py
   车龄        里程  品牌  城市    过户费    价格
0  12  207000.0  甲牌  北京  239.0  2.39
1   9  124500.0  乙牌  成都  729.0  7.29
2   8  129600.0  甲牌  上海  391.0  3.91
3   4   39800.0  甲牌  上海  736.0  7.36
4   5   91700.0  甲牌  成都  519.0  5.19
缺失: {'车龄': 0, '里程': 42, '品牌': 0, '城市': 29, '过户费': 0, '价格': 0}

# 2026-10-21 21:56 UTC+8
$ python pipeline.py
正常特征        5 折 MAE 1.131 万元
加上“过户费”    5 折 MAE 0.043 万元
⚠ 加一列后误差降了一半以上:先问这一列在预测时能不能拿到(过户费是成交后才有的,属于目标泄漏)

# 训练集里没有“广州”,预测时出现
ignore: [9.37]
ValueError Found unknown categories ['广州'] in column 1 during transform     ← 去掉 handle_unknown="ignore" 时
$ python -m pytest -q
4 passed

怎么检查结果?

  • 看前 5 行:过户费正好是价格 × 100(元),一眼就能看出它“知道答案”。真实数据里泄漏通常没这么明显,要逐列问“预测的那一刻能拿到这个值吗?”
  • 误差降到原来的 4% 时不要高兴,先怀疑泄漏。
  • 预测时出现新类别不报错,但这类样本的预测要谨慎。

调试示例

1. 新城市让模型直接报错。 训练好模型后,我拿一条城市是“广州”的新数据预测。默认的 OneHotEncoder() 报:

ValueError Found unknown categories ['广州'] in column 1 during transform

训练数据里只有北京、上海、成都、西安。在 Kaggle 比赛里,测试集出现训练集没有的类别很常见,这个错误会在你生成提交文件的最后一步才出现。加上 handle_unknown="ignore" 后,未知类别被编码为全 0,正常给出预测(9.37 万)。两种情况都写成了测试。

2. 泄漏的误差降得“太好了”。 加上过户费后 MAE 从 1.131 降到 0.043,下降了 96%。这个列在数据里看着很普通,但它是成交后按价格计算的,预测一辆还没卖出的车时根本没有。课程把这类情况叫目标泄漏。脚本里的告警规则(误差降了一半以上就提醒)是我自己定的经验阈值,只是提醒你去检查,不能自动判定。

常见问题

现象 原因 处理
unknown categories 报错 测试集有新类别 handle_unknown="ignore"
交叉验证分数特别好 可能有泄漏 逐列检查是否预测时可得
列顺序错乱 手动拼接特征 用 ColumnTransformer 按列名指定
缺失值报错 模型不接受 NaN 在 Pipeline 里加 SimpleImputer

模板规则

  • 预处理放进 Pipeline。
  • 用交叉验证评估。
  • 逐列检查是否泄漏。
  • 编码器要能处理未知类别。

项目资源

用途 地址
Kaggle Learn https://www.kaggle.com/learn
Kaggle 使用条款 https://www.kaggle.com/terms
scikit-learn 常见陷阱(官方) https://scikit-learn.org/stable/common_pitfalls.html
Intermediate Machine Learning(Kaggle Learn) https://www.kaggle.com/learn/intermediate-machine-learning
scikit-learn Pipeline 与 ColumnTransformer(官方) https://scikit-learn.org/stable/modules/compose.html

分数异常好的时候,最该检查的是数据。