Week 06:交叉验证、模型选择与防止数据泄漏
Week 05 你已经会写一个固定 train / validation / test 的 baseline。Week 06 要解决更真实的问题:一次随机划分可能让模型排序碰运气,特征处理如果放错位置会造成数据泄漏,调参如果没有记录就无法复现。
本周目标是把“跑一次分数”升级为“可解释、可复现、可比较的模型选择流程”。命令默认 fish shell。
0. 本周详细教学:语法、规范、验收
本节不是追加在尾部的复习,而是本周正文的入口。先读这里,再做后面的命令和项目。
0.1 本周真正要学会什么
| 维度 | 要求 |
|---|---|
| 知识点 | Pipeline、ColumnTransformer、KFold、GridSearch、防泄漏 |
| 代码语法 | 能从空文件写出本周核心脚本,而不是只复制运行 |
| 程序规范 | 函数拆分、路径清楚、输入输出明确、错误能解释 |
| 交付物 | src/model_selection.py |
| 验收方式 | 从 fish 终端运行命令,得到可复查的文件或指标 |
0.2 代码语法精讲
下面的代码不是最终答案,而是本周必须理解的最小骨架:
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 本周和主线的连接
- 回到总计划:USTC AI / Quant 练习手册
- 查详细练习索引:技术练习详解
- 查质量评分:最终质量门槛
1. 本周目标
完成 src/model_selection.py 和 reports/week06_cv_report.md:
- 使用 KFold 或 StratifiedKFold 做交叉验证。
- 用 Pipeline 把标准化、编码、模型训练放在同一个流程里。
- 用 GridSearchCV 比较参数组合。
- 输出 classification 或 regression 指标表。
- 记录最佳模型、最佳参数、均值、标准差。
- 解释什么是数据泄漏,以及如何避免。
本周最重要的观念:
预处理必须只从训练折学习,再应用到验证折。
如果你先对全数据 fit scaler,再交叉验证,验证分数就被污染了。
2. 前置条件
你应该已经具备:
- Week 05 的
src/train_baselines.py能正常运行。 - 理解 train / validation / test 的角色。
- 会看 accuracy、precision、recall、F1、ROC AUC。
- 能用 VS Code 修改 Python 文件。
- 能用 Git 提交实验结果。
检查项目环境:
cd ~/Code/python-learning/week05-sklearn-baselines
source .venv/bin/activate.fish
python src/train_baselines.py
如果你想把 Week 06 单独放在新目录,也可以按下一节创建新项目。
3. 建立 Week 06 项目
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. 文件布局
建议结构:
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 的脚本复制一遍,而要明确体现:
cross validation + pipeline + grid search + result table
5. 为什么一次 validation 不够
Week 05 的流程是:
train -> valid -> choose model -> test
问题在于 validation 只有一份。如果某次随机划分刚好让某个模型“运气好”,你会误以为它更强。
K-fold cross validation 的想法:
把训练数据切成 K 份
第 1 次:第 1 份做验证,其余做训练
第 2 次:第 2 份做验证,其余做训练
...
第 K 次:第 K 份做验证,其余做训练
最后取 K 次分数的均值和标准差
你需要同时看:
| 指标 | 含义 |
|---|---|
| mean score | 平均表现 |
| std score | 稳定性,越小越稳定 |
| best params | 最优参数组合 |
| test score | 最终保留测试集上的一次评估 |
6. KFold 与 StratifiedKFold
6.1 KFold
适合回归任务,或分类任务中类别非常均衡的情况。
from sklearn.model_selection import KFold
cv = KFold(n_splits=5, shuffle=True, random_state=42)
6.2 StratifiedKFold
适合分类任务,尤其是类别不平衡时。它会尽量保持每一折的类别比例一致。
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 创建:
src/model_selection.py
粘贴下面代码:
from pathlib import Pathimport 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
Model Mean CV F1 Best 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
Metric Value 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. 运行脚本
source .venv/bin/activate.fish
python src/model_selection.py
预期输出结构类似:
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. 结果表怎么读
你会得到类似终端风格结果:
╭─ model selection ─────────────────────╮
│ model valid_score │
├────────────────────────────────────────┤
│ logistic_regression 0.9680 │
│ random_forest 0.9720 │
│ knn 0.9662 │
│ decision_tree 0.9371 │
╰────────────────────────────────────────╯
但是报告里不要只写一个分数。至少解释三件事:
- 最佳模型比第二名高多少?如果只高 0.002,可能没有实际差异。
- 标准差是否大?如果不同折波动很大,模型不稳定。
- 最优参数是否在网格边界?如果
C=10.0永远最好,说明还可以继续扩大搜索范围,但不要无止境调参。
当前示例脚本为了简洁没有打印每个参数组合的标准差。你可以扩展 search.cv_results_ 生成更完整表格。
11. 如何加入标准差
在 run_model_selection() 中,GridSearchCV 保存了 cv_results_。可以取最佳行的标准差:
best_index = search.best_index_
best_std = search.cv_results_["std_test_score"][best_index]
然后把结果字典改成:
{
"model": model_name,
"best_score": search.best_score_,
"best_std": best_std,
"best_params": search.best_params_,
"best_estimator": search.best_estimator_,
}
报告表就能写成:
| Model | Mean CV F1 | Std | Best Params |
这比只看均值更可靠。
12. 回归任务怎么改
如果目标是连续数值,比如房价、收益率、销量,不要用分类指标。改成:
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 可以用:
scoring="neg_mean_absolute_error"
注意 sklearn 的误差类指标常用负号,因为 GridSearchCV 默认“越大越好”。报告里要转回正的 MAE:
mean_mae = -search.best_score_
回归报告建议包含:
| 指标 | 含义 |
|---|---|
| MAE | 平均绝对误差,单位和目标变量一致,最容易解释 |
| MSE | 平方误差,对大误差更敏感 |
| RMSE | MSE 开根号,单位回到目标变量 |
| R² | 相对均值模型的解释程度 |
13. 自己数据中的类别特征
真实表格常有字符串列,比如:
city, gender, industry, education
不能直接喂给 LogisticRegression。Week 06 的正确方式是 ColumnTransformer:
from sklearn.compose import ColumnTransformer from sklearn.preprocessing import OneHotEncoder, StandardScalernumeric_features = [“age”, “income”, “score”] categorical_features = [“city”, “gender”]
preprocess = ColumnTransformer( transformers=[ (“num”, StandardScaler(), numeric_features), (“cat”, OneHotEncoder(handle_unknown=“ignore”), categorical_features), ] )
然后放进 Pipeline:
pipeline = Pipeline(
steps=[
("preprocess", preprocess),
("model", LogisticRegression(max_iter=3000)),
]
)
这样每一折都会只在训练折上学习均值、标准差和类别编码,验证折不会泄漏信息。
14. 报告必须回答的问题
打开报告:
code reports/week06_cv_report.md
补充下面内容:
## Model Choice 为什么选择这个模型?它是否明显优于其他模型?Metric Choice
为什么用 F1 / AUC / MAE / R²?这个指标和问题目标是否一致?
Leakage Prevention
哪些预处理被放进 Pipeline?test 集有没有参与调参?
Limitations
数据量、特征、划分方式、参数网格有哪些限制?
不要写成“GridSearchCV 得分最高,所以最好”。要说明实验设计。
15. Git 保存成果
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 包含:
.venv/
__pycache__/
.ipynb_checkpoints/
16. 本周练习
- 把
scoring="f1"改成scoring="roc_auc",观察模型排序是否改变。 - 把
n_splits=5改成 3 和 10,比较结果稳定性与运行时间。 - 给结果表增加
std_test_score。 - 在报告中解释为什么
StandardScaler必须放进 Pipeline。 - 用一个回归数据集重写脚本,输出 MAE、RMSE、R²。
- 设计一个小实验:故意在全数据上
fit_transform标准化,再比较分数,写下为什么这是错误示范。
17. 验收检查
在项目根目录执行:
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存在。- 使用了
StratifiedKFold或KFold。 - 使用了
Pipeline。 - 至少比较了 3 个模型。
- 至少有一个模型使用了参数网格。
- 报告里有模型、指标、CV 设计、结果表格、最佳参数。
- 报告明确说明如何防止数据泄漏。
- test 集只在最后评估一次。
18. 常见错误
错误 1:先标准化全数据再交叉验证
错误逻辑:
scaler.fit_transform(X)
然后再 cross_validate
这会泄漏验证折信息。修复:把 scaler 放进 Pipeline。
错误 2:分类任务使用普通 KFold 导致类别比例异常
如果数据类别不平衡,某一折可能正类很少,指标波动巨大。
修复:
StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
错误 3:忘记固定 random_state
没有固定随机种子,今天跑和明天跑结果不同,报告无法复现。
修复:统一设置:
RANDOM_STATE = 42
错误 4:参数网格过大
初学时不要一口气搜索几千种组合。先用小网格理解流程,再针对有希望的模型细化。
错误 5:把最高分当作唯一结论
如果两个模型均值接近,优先考虑:
- 稳定性。
- 可解释性。
- 训练时间。
- 是否容易部署。
- 是否和后续项目目标一致。
19. 下一步
进入 Week 07:统计建模项目启动。你会从练习脚本转向一个可展示的小项目:定义问题、整理数据卡、建立 baseline、记录错误分析,并为 Week 08 的成品报告做准备。
plain
気に入ったならばコメントを残してくださいね~