Week 06:交叉验证、模型选择与防止数据泄漏

发布于 2026-07-26 06:00 4903 字 25 min read

Week 06:交叉验证、模型选择与防止数据泄漏。fish-first 终端教学,面向 CachyOS、VS Code、uv 和 Python 学习路线。
Oh My Pi / weekly tutorial / week06-cross-validation-model-selection
miku@cachyos:~/Code/python-learning$ omp teach week06-cross-validation-model-selection --fish-first --step-by-step
source659 行教学文档
weekWeek 06
shellfish-first 命令版
backlink20 周计划

Week 06:交叉验证、模型选择与防止数据泄漏

返回主线计划:USTC 统计学:AI / 量化金融 20 周终端式成长计划

Week 05 你已经会写一个固定 train / validation / test 的 baseline。Week 06 要解决更真实的问题:一次随机划分可能让模型排序碰运气,特征处理如果放错位置会造成数据泄漏,调参如果没有记录就无法复现。

本周目标是把“跑一次分数”升级为“可解释、可复现、可比较的模型选择流程”。命令默认 fish shell。

0. 本周详细教学:语法、规范、验收

本节不是追加在尾部的复习,而是本周正文的入口。先读这里,再做后面的命令和项目。

0.1 本周真正要学会什么

维度要求
知识点Pipeline、ColumnTransformer、KFold、GridSearch、防泄漏
代码语法能从空文件写出本周核心脚本,而不是只复制运行
程序规范函数拆分、路径清楚、输入输出明确、错误能解释
交付物src/model_selection.py
验收方式从 fish 终端运行命令,得到可复查的文件或指标

0.2 代码语法精讲

下面的代码不是最终答案,而是本周必须理解的最小骨架:

omppython
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_validate

pipe = Pipeline([ (“scaler”, StandardScaler()), (“model”, LogisticRegression(max_iter=1000)), ]) result = cross_validate(pipe, X, y, cv=5, scoring=[“accuracy”, “f1_macro”]) print(result[“test_f1_macro”].mean())

读代码时按四步检查:输入从哪里来;中间变量的类型和 shape 是什么;函数或脚本输出什么;哪些错误应该显式报出来。

0.3 本周程序规范

  • 所有路径用相对路径或 `pathlib.Path`,不要写死 `/home/miku/...`。
  • 核心逻辑进 `src/`,notebook 只做探索和解释。
  • 每个脚本能从 fish 终端运行,并在 README 写出命令。
  • 输出必须落盘到 `reports/`、`figures/` 或 `outputs/`,不能只在屏幕上看。

0.4 本周练习分层

层级任务不合格表现合格验收
最小练习手写上面的最小骨架只在 notebook 里运行终端运行成功
标准练习把逻辑拆成函数/模块一个大脚本从头写到尾至少 2 个函数,职责清楚
项目练习生成本周交付物 src/model_selection.py只有屏幕输出文件落盘,可复查
复盘练习写 3 个错误和修复只写“已解决”写清报错、原因、修复、预防

0.5 本周和主线的连接

1. 本周目标

完成 src/model_selection.pyreports/week06_cv_report.md

  • 使用 KFold 或 StratifiedKFold 做交叉验证。
  • 用 Pipeline 把标准化、编码、模型训练放在同一个流程里。
  • 用 GridSearchCV 比较参数组合。
  • 输出 classification 或 regression 指标表。
  • 记录最佳模型、最佳参数、均值、标准差。
  • 解释什么是数据泄漏,以及如何避免。

本周最重要的观念:

ompprompt
预处理必须只从训练折学习,再应用到验证折。
如果你先对全数据 fit scaler,再交叉验证,验证分数就被污染了。

2. 前置条件

你应该已经具备:

  • Week 05 的 src/train_baselines.py 能正常运行。
  • 理解 train / validation / test 的角色。
  • 会看 accuracy、precision、recall、F1、ROC AUC。
  • 能用 VS Code 修改 Python 文件。
  • 能用 Git 提交实验结果。

检查项目环境:

ompfish
cd ~/Code/python-learning/week05-sklearn-baselines
source .venv/bin/activate.fish
python src/train_baselines.py

如果你想把 Week 06 单独放在新目录,也可以按下一节创建新项目。

3. 建立 Week 06 项目

ompfish
cd ~/Code/python-learning
mkdir -p week06-cross-validation-model-selection
cd week06-cross-validation-model-selection
uv init
uv venv
source .venv/bin/activate.fish
uv add pandas numpy scikit-learn tabulate
mkdir -p src reports data/raw data/processed
code .

逐句解释:

命令 作用
mkdir -p week06-cross-validation-model-selection 建立本周项目目录
uv init 初始化 Python 项目配置
uv venv 创建隔离环境
source .venv/bin/activate.fish 激活 fish 专用虚拟环境
uv add ... 安装 sklearn 建模需要的包
mkdir -p src reports ... 创建代码、报告、数据目录
code . 用 VS Code 打开当前项目

4. 文件布局

建议结构:

ompprompt
week06-cross-validation-model-selection/
├── data/
│   ├── raw/
│   └── processed/
├── reports/
│   └── week06_cv_report.md
├── src/
│   └── model_selection.py
├── pyproject.toml
├── uv.lock
└── README.md

本周的主要产出是 model_selection.py。它不应该只是把 Week 05 的脚本复制一遍,而要明确体现:

ompprompt
cross validation + pipeline + grid search + result table

5. 为什么一次 validation 不够

Week 05 的流程是:

ompprompt
train -> valid -> choose model -> test

问题在于 validation 只有一份。如果某次随机划分刚好让某个模型“运气好”,你会误以为它更强。

K-fold cross validation 的想法:

ompprompt
把训练数据切成 K 份
第 1 次:第 1 份做验证,其余做训练
第 2 次:第 2 份做验证,其余做训练
...
第 K 次:第 K 份做验证,其余做训练
最后取 K 次分数的均值和标准差

你需要同时看:

指标 含义
mean score 平均表现
std score 稳定性,越小越稳定
best params 最优参数组合
test score 最终保留测试集上的一次评估

6. KFold 与 StratifiedKFold

6.1 KFold

适合回归任务,或分类任务中类别非常均衡的情况。

omppython
from sklearn.model_selection import KFold

cv = KFold(n_splits=5, shuffle=True, random_state=42)

6.2 StratifiedKFold

适合分类任务,尤其是类别不平衡时。它会尽量保持每一折的类别比例一致。

omppython
from sklearn.model_selection import StratifiedKFold

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

本周示例是分类任务,所以使用 StratifiedKFold

7. 数据泄漏是什么

数据泄漏不是语法错误,而是实验设计错误。它会让验证分数看起来很好,但真实泛化很差。

常见泄漏:

错误做法 为什么错
对全数据 fit_transform 标准化,再划分训练验证 验证集均值和方差信息提前进入训练过程
用全数据填补缺失值,再交叉验证 验证折的分布信息泄漏到训练折
特征选择时先看全数据与目标的相关性 目标信息通过特征选择进入验证折
调参时反复查看 test 分数 test 被污染,不能再代表最终泛化

正确做法:把预处理放进 Pipeline。交叉验证时,sklearn 会在每个训练折上 fit 预处理器,再对对应验证折 transform

8. 编写模型选择脚本

在 VS Code 创建:

ompprompt
src/model_selection.py

粘贴下面代码:

omppython
from pathlib import Path

import pandas as pd from sklearn.datasets import load_breast_cancer from sklearn.ensemble import RandomForestClassifier from sklearn.linear_model import LogisticRegression from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score, roc_auc_score from sklearn.model_selection import GridSearchCV, StratifiedKFold, train_test_split from sklearn.neighbors import KNeighborsClassifier from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.tree import DecisionTreeClassifier

RANDOM_STATE = 42 REPORT_PATH = Path(“reports/week06_cv_report.md”)

def load_data(): dataset = load_breast_cancer(as_frame=True) return dataset.data, dataset.target, list(dataset.target_names)

def train_test_holdout(X, y): return train_test_split( X, y, test_size=0.2, random_state=RANDOM_STATE, stratify=y, )

def build_search_spaces(): return { “logistic_regression”: { “pipeline”: Pipeline( steps=[ (“scaler”, StandardScaler()), (“model”, LogisticRegression(max_iter=3000, random_state=RANDOM_STATE)), ] ), “params”: { “model__C”: [0.1, 1.0, 10.0], “model__class_weight”: [None, “balanced”], }, }, “knn”: { “pipeline”: Pipeline( steps=[ (“scaler”, StandardScaler()), (“model”, KNeighborsClassifier()), ] ), “params”: { “model__n_neighbors”: [3, 5, 9, 15], “model__weights”: [“uniform”, “distance”], }, }, “decision_tree”: { “pipeline”: Pipeline( steps=[ (“model”, DecisionTreeClassifier(random_state=RANDOM_STATE)), ] ), “params”: { “model__max_depth”: [2, 3, 4, 5, None], “model__min_samples_leaf”: [1, 3, 5], }, }, “random_forest”: { “pipeline”: Pipeline( steps=[ (“model”, RandomForestClassifier(random_state=RANDOM_STATE)), ] ), “params”: { “model__n_estimators”: [100, 200], “model__max_depth”: [3, 5, None], “model__min_samples_leaf”: [1, 3], }, }, }

def evaluate_classifier(model, X_test, y_test): predictions = model.predict(X_test) probabilities = model.predict_proba(X_test)[:, 1] return { “test_accuracy”: accuracy_score(y_test, predictions), “test_precision”: precision_score(y_test, predictions), “test_recall”: recall_score(y_test, predictions), “test_f1”: f1_score(y_test, predictions), “test_roc_auc”: roc_auc_score(y_test, probabilities), }

def run_model_selection(X_train, y_train): cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=RANDOM_STATE) searches = []

plain for model_name, config in build_search_spaces().items(): search = GridSearchCV( estimator=config[“pipeline”], param_grid=config[“params”], scoring=“f1”, cv=cv, n_jobs=-1, refit=True, return_train_score=True, ) search.fit(X_train, y_train) searches.append( { “model”: model_name, “best_score”: search.best_score_, “best_params”: search.best_params_, “best_estimator”: search.best_estimator_, } )

plain return sorted(searches, key=lambda row: row[“best_score”], reverse=True)

def make_report(results, test_metrics, target_names): table_rows = [] for row in results: table_rows.append( f”| {row[‘model’]} | {row[‘best_score’]:.4f} | {row['best_params']} |” )

plain best = results[0] report = f"""# Week 06 Cross Validation Report

Dataset

  • Source: sklearn breast cancer dataset
  • Task: binary classification
  • Target names:

Validation Design

  • Holdout test size: 20%
  • Cross validation: StratifiedKFold with 5 folds
  • Model selection metric: F1
  • Leakage prevention: preprocessing is inside sklearn Pipeline

Model Selection Results

ModelMean CV F1Best Params
{chr(10).join(table_rows)}

Best Model

  • Selected model:
  • Selected by: highest mean cross-validation F1 on the training portion

Final Holdout Test Metrics

MetricValue
Accuracy{test_metrics[‘test_accuracy’]:.4f}
Precision{test_metrics[‘test_precision’]:.4f}
Recall{test_metrics[‘test_recall’]:.4f}
F1{test_metrics[‘test_f1’]:.4f}
ROC AUC{test_metrics[‘test_roc_auc’]:.4f}

Leakage Notes

  • StandardScaler is inside Pipeline, so it is fit only on each training fold.
  • The holdout test set is not used during GridSearchCV.
  • The chosen model is evaluated on the test set only after model selection.

Interpretation

Write your own explanation here:

  • Is the best CV model also clearly better than the others, or only slightly better?
  • Does the chosen metric match the business or scientific goal?
  • Are the best parameters reasonable, or do they suggest overfitting?
  • What should be checked with a larger dataset or repeated CV? """ return report

def main(): X, y, target_names = load_data() X_train, X_test, y_train, y_test = train_test_holdout(X, y)

plain results = run_model_selection(X_train, y_train) best_model = results[0][“best_estimator”] test_metrics = evaluate_classifier(best_model, X_test, y_test)

plain report = make_report(results, test_metrics, target_names) REPORT_PATH.parent.mkdir(parents=True, exist_ok=True) REPORT_PATH.write_text(report, encoding=“utf-8”)

plain display_rows = [ { “model”: row[“model”], “mean_cv_f1”: round(row[“best_score”], 4), “best_params”: row[“best_params”], } for row in results ] print(pd.DataFrame(display_rows).to_string(index=False)) print() print(“Best model:”, results[0][“model”]) print(“Test metrics:”, {key: round(value, 4) for key, value in test_metrics.items()}) print(“Report written to”, REPORT_PATH)

if name == “main”: main()

9. 运行脚本

ompfish
source .venv/bin/activate.fish
python src/model_selection.py

预期输出结构类似:

ompprompt
              model  mean_cv_f1                                         best_params
      random_forest      0.9720 {'model__max_depth': None, 'model__min_samples_leaf': 1, 'model__n_estimators': 200}
logistic_regression      0.9680                     {'model__C': 1.0, 'model__class_weight': None}
                knn      0.9662                 {'model__n_neighbors': 9, 'model__weights': 'uniform'}
      decision_tree      0.9371                     {'model__max_depth': 4, 'model__min_samples_leaf': 3}

Best model: random_forest Test metrics: {‘test_accuracy’: 0.9649, ‘test_precision’: 0.9589, ‘test_recall’: 0.9859, ‘test_f1’: 0.9722, ‘test_roc_auc’: 0.9951} Report written to reports/week06_cv_report.md

数值可能不同,但必须具备:模型名、CV 均值、最佳参数、最终 test 指标。

10. 结果表怎么读

你会得到类似终端风格结果:

ompprompt
╭─ model selection ─────────────────────╮
│ model                 valid_score      │
├────────────────────────────────────────┤
│ logistic_regression   0.9680           │
│ random_forest         0.9720           │
│ knn                   0.9662           │
│ decision_tree         0.9371           │
╰────────────────────────────────────────╯

但是报告里不要只写一个分数。至少解释三件事:

  1. 最佳模型比第二名高多少?如果只高 0.002,可能没有实际差异。
  2. 标准差是否大?如果不同折波动很大,模型不稳定。
  3. 最优参数是否在网格边界?如果 C=10.0 永远最好,说明还可以继续扩大搜索范围,但不要无止境调参。

当前示例脚本为了简洁没有打印每个参数组合的标准差。你可以扩展 search.cv_results_ 生成更完整表格。

11. 如何加入标准差

run_model_selection() 中,GridSearchCV 保存了 cv_results_。可以取最佳行的标准差:

omppython
best_index = search.best_index_
best_std = search.cv_results_["std_test_score"][best_index]

然后把结果字典改成:

omppython
{
    "model": model_name,
    "best_score": search.best_score_,
    "best_std": best_std,
    "best_params": search.best_params_,
    "best_estimator": search.best_estimator_,
}

报告表就能写成:

ompprompt
| Model | Mean CV F1 | Std | Best Params |

这比只看均值更可靠。

12. 回归任务怎么改

如果目标是连续数值,比如房价、收益率、销量,不要用分类指标。改成:

omppython
from sklearn.model_selection import KFold
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score

cv = KFold(n_splits=5, shuffle=True, random_state=42)

GridSearchCV 的 scoring 可以用:

omppython
scoring="neg_mean_absolute_error"

注意 sklearn 的误差类指标常用负号,因为 GridSearchCV 默认“越大越好”。报告里要转回正的 MAE:

omppython
mean_mae = -search.best_score_

回归报告建议包含:

指标 含义
MAE 平均绝对误差,单位和目标变量一致,最容易解释
MSE 平方误差,对大误差更敏感
RMSE MSE 开根号,单位回到目标变量
相对均值模型的解释程度

13. 自己数据中的类别特征

真实表格常有字符串列,比如:

ompprompt
city, gender, industry, education

不能直接喂给 LogisticRegression。Week 06 的正确方式是 ColumnTransformer

omppython
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder, StandardScaler

numeric_features = [“age”, “income”, “score”] categorical_features = [“city”, “gender”]

preprocess = ColumnTransformer( transformers=[ (“num”, StandardScaler(), numeric_features), (“cat”, OneHotEncoder(handle_unknown=“ignore”), categorical_features), ] )

然后放进 Pipeline:

omppython
pipeline = Pipeline(
    steps=[
        ("preprocess", preprocess),
        ("model", LogisticRegression(max_iter=3000)),
    ]
)

这样每一折都会只在训练折上学习均值、标准差和类别编码,验证折不会泄漏信息。

14. 报告必须回答的问题

打开报告:

ompfish
code reports/week06_cv_report.md

补充下面内容:

ompprompt
## Model Choice
为什么选择这个模型?它是否明显优于其他模型?

Metric Choice

为什么用 F1 / AUC / MAE / R²?这个指标和问题目标是否一致?

Leakage Prevention

哪些预处理被放进 Pipeline?test 集有没有参与调参?

Limitations

数据量、特征、划分方式、参数网格有哪些限制?

不要写成“GridSearchCV 得分最高,所以最好”。要说明实验设计。

15. Git 保存成果

ompfish
git status --short
git add pyproject.toml uv.lock README.md src reports
git commit -m "Add week06 cross validation model selection"

如果你从 Week 05 复制项目继续做,不要提交 .venv/。如果 git status --short 里出现 .venv/,先确认 .gitignore 包含:

ompprompt
.venv/
__pycache__/
.ipynb_checkpoints/

16. 本周练习

  1. scoring="f1" 改成 scoring="roc_auc",观察模型排序是否改变。
  2. n_splits=5 改成 3 和 10,比较结果稳定性与运行时间。
  3. 给结果表增加 std_test_score
  4. 在报告中解释为什么 StandardScaler 必须放进 Pipeline。
  5. 用一个回归数据集重写脚本,输出 MAE、RMSE、R²。
  6. 设计一个小实验:故意在全数据上 fit_transform 标准化,再比较分数,写下为什么这是错误示范。

17. 验收检查

在项目根目录执行:

ompfish
source .venv/bin/activate.fish
python src/model_selection.py
test -f reports/week06_cv_report.md; and echo "report exists"

人工确认:

  • src/model_selection.py 存在。
  • 使用了 StratifiedKFoldKFold
  • 使用了 Pipeline
  • 至少比较了 3 个模型。
  • 至少有一个模型使用了参数网格。
  • 报告里有模型、指标、CV 设计、结果表格、最佳参数。
  • 报告明确说明如何防止数据泄漏。
  • test 集只在最后评估一次。

18. 常见错误

错误 1:先标准化全数据再交叉验证

错误逻辑:

ompprompt
scaler.fit_transform(X)
然后再 cross_validate

这会泄漏验证折信息。修复:把 scaler 放进 Pipeline。

错误 2:分类任务使用普通 KFold 导致类别比例异常

如果数据类别不平衡,某一折可能正类很少,指标波动巨大。

修复:

omppython
StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

错误 3:忘记固定 random_state

没有固定随机种子,今天跑和明天跑结果不同,报告无法复现。

修复:统一设置:

omppython
RANDOM_STATE = 42

错误 4:参数网格过大

初学时不要一口气搜索几千种组合。先用小网格理解流程,再针对有希望的模型细化。

错误 5:把最高分当作唯一结论

如果两个模型均值接近,优先考虑:

  • 稳定性。
  • 可解释性。
  • 训练时间。
  • 是否容易部署。
  • 是否和后续项目目标一致。

19. 下一步

进入 Week 07:统计建模项目启动。你会从练习脚本转向一个可展示的小项目:定义问题、整理数据卡、建立 baseline、记录错误分析,并为 Week 08 的成品报告做准备。

plain

喜欢的话,留下你的评论吧~