外观
从零构建一个 ML 项目
读十篇教程,不如完整跑通一个项目。本文带你用最经典、最小的「泰坦尼克生存预测」走一遍真实项目的全部环节——不是"调包出结果",而是把每一步为什么这么做讲清楚。
很多初学者学了一堆算法之后,仍然不知道一个 ML 项目到底长什么样:文件怎么组织?步骤的先后顺序是什么?哪些环节可以跳过、哪些一步都不能省?本文用一条主线(泰坦尼克生存预测)把完整的项目流程从头走到底。你得到的不是"这个数据集的 0.81 分",而是一套可以复用到任何表格型分类问题的流程骨架。
完整项目分为七个环节,先看整体地图:
原始数据
│
▼
① 数据加载与探索(EDA)── 回答"数据长什么样、有什么问题"
▼
② 数据清洗与特征工程 ── 回答"哪些列能进模型、怎么变成数字"
▼
③ 划分训练/验证/测试 ── 回答"怎么保证评估是诚实的"
▼
④ 基线模型 ────────── 回答"最简单的解法能到多少分"
▼
⑤ 迭代改进 ────────── 回答"更复杂的模型能再多多少分"
▼
⑥ 评估与错误分析 ──── 回答"模型在哪里犯错、为什么"
▼
⑦ 结果汇报与可视化 ── 回答"怎么把结论讲清楚"在整个流程中,我们要刻意遵守一条纪律:测试集只能在最后碰一次。这条纪律贯穿全文,也是常见陷阱里排第一位的"数据泄露"的防线。
一、项目选择:一个简单但真实的问题
第一个问题是:练手项目选什么? 很多人的第一个项目是"用 LSTM 预测股票",这几乎注定失败——不是技术难,而是问题本身不可解。选项目有三个标准,缺一不可。
| 标准 | 为什么 | 违背的后果 |
|---|---|---|
| 有标签(有标准答案) | 你需要知道"正确答案"才能评估模型学得怎么样 | 没有标签就无从评估,只能自我感觉良好 |
| 规模小(几百~几千样本) | 每轮迭代几分钟跑完,能把精力放在流程和概念上 | 大数据项目一半时间在调 Spark,学不到建模 |
| 可解释(人能理解特征含义) | 你能靠常识判断特征是否合理,错误分析才做得下去 | 图像/文本的"为什么错"需要额外工具,新手无从下手 |
按这个标准,三个经典练习项目对比:
| 项目 | 标签 | 规模 | 可解释性 | 适合度 |
|---|---|---|---|---|
| 泰坦尼克生存预测 | 有(Survived) | 891 训练样本 | ★★★ 特征全是人话(年龄、舱位、性别) | ★★★ 新手首选 |
| 波士顿/加州房价预测 | 有(连续值) | 2 万左右 | ★★☆ 特征有现实含义 | ★★★ 回归练习首选 |
| 垃圾邮件分类 | 有(spam/ham) | 5 千+ 封邮件 | ★☆☆ 特征是词向量,需文本处理 | ★★☆ 稍进阶 |
本文选泰坦尼克生存预测,理由三条:
- 标签清晰:每个乘客是否生还,是确定的历史事实,没有"标注主观性"问题。
- 特征丰富且可解释:年龄、性别、舱位等级(Pclass)、票价、登船港口……每列都能用常识解释,适合做错误分析。
- 规模小:891 个训练样本,任何模型都在秒级跑完,你可以放心地反复迭代。
数据集长什么样
泰坦尼克数据集(来自 Kaggle 同名入门竞赛)的原始形态:
| 列名 | 含义 | 类型 | 有无缺失 |
|---|---|---|---|
| PassengerId | 乘客 ID | int | 无 |
| Survived | 是否生还(1=是,0=否) | int(标签) | 无 |
| Pclass | 舱位等级(1/2/3) | int | 无 |
| Name | 姓名 | str | 无 |
| Sex | 性别(male/female) | str | 无 |
| Age | 年龄 | float | 有(177 个) |
| SibSp | 同船兄弟姐妹/配偶数 | int | 无 |
| Parch | 同船父母/子女数 | int | 无 |
| Ticket | 票号 | str | 无 |
| Fare | 票价 | float | 无 |
| Cabin | 船舱号 | str | 有(687 个!) |
| Embarked | 登船港口(C/Q/S) | str | 有(2 个) |
注意:这 891 条是 Kaggle 竞赛的训练集。真实项目里数据往往散落在不同系统、不同格式,加载和清洗会占去一半工作量——正因为如此,训练营式的"干净数据集"才适合学流程。数据集的寻找与评估方法见数据集与工具档案。
先定义"成功",再开始写代码
把问题翻译成机器学习任务时,必须回答三个问题:
- 任务类型:生存预测是二分类(生/死)。
- 评估指标:本数据集中生还者约 38%,属于类别不平衡。准确率会误导人(见指标误用),所以我们以 ROC-AUC 为主指标,辅以精确率/召回率。
- 基线:什么都不要学、"全部预测死亡"的模型准确率约 62%。我们的任何模型都必须显著超过这个数,否则说明没学到任何东西。
项目成功的定义先于代码写下,这是模型评估与验证的基本功。
二、环境搭建
1. 版本要求
本文代码基于以下版本(2024 年稳定版,向下兼容老版本):
| 软件 | 版本 | 用途 |
|---|---|---|
| Python | 3.10+ | 语言本身 |
| pandas | 2.x | 数据加载与处理 |
| scikit-learn | 1.3+ | 建模、切分、评估、调参 |
| matplotlib | 3.x | 可视化 |
| jupyterlab | 4.x | 交互式探索 |
2. 创建虚拟环境(venv)
永远不要把依赖装进系统 Python。虚拟环境是项目依赖的隔离容器,保证换机器/换项目时不互相污染:
bash
# 进入项目根目录
mkdir titanic && cd titanic
# 创建虚拟环境(Windows 与 macOS/Linux 的激活命令不同)
python -m venv .venv
source .venv/bin/activate # macOS / Linux
# .venv\Scripts\activate # Windows(PowerShell)
# .venv\Scripts\activate.bat # Windows(CMD)
# 安装依赖(一行装完)
pip install pandas scikit-learn matplotlib jupyterlab
# 把依赖清单固化,保证别人能一键复现
pip freeze > requirements.txt为什么用 venv 而不是全局安装
- 不同项目可能依赖不同版本的 numpy/sklearn,全局安装会让你被迫"升级降级来回跳"。
pip freeze生成的requirements.txt是复现实验的第一步——别人用pip install -r requirements.txt就能得到和你一模一样的运行环境。- 团队协作时,环境可复现 = 实验结果可复现。
3. 启动 Jupyter 并验证
bash
jupyter lab新建一个 notebook,先跑通"环境自检":
python
import pandas as pd, numpy as np, sklearn, matplotlib
print("pandas:", pd.__version__)
print("sklearn:", sklearn.__version__)
print("numpy:", np.__version__)
# 一行冒烟测试:确认 sklearn 的核心组件都能用
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
print("环境 OK")看到版本号输出即环境就绪。建议把 EDA 放 notebook、把可复用逻辑放 .py 脚本(目录结构见第四节)——notebook 适合探索,脚本适合复用和测试。
三、完整流程分步走
步骤 1:数据加载与探索(EDA)
EDA(Exploratory Data Analysis)的目标不是"看数据",而是回答三个问题:数据有什么问题(缺失、类型)、各特征长什么样(分布)、特征与标签有什么关系(信号在哪)。
python
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
# 加载数据(Kaggle 下载的 train.csv,也可从本站数据集档案获取)
df = pd.read_csv("data/train.csv")
# ── ① 看形状与头部 ─────────────────────────────
print("形状:", df.shape) # (891, 12) → 891 个乘客,12 列
df.head()
# ── ② 看每列类型与缺失 ─────────────────────────
df.info()
# ── ③ 看数值列统计(count/mean/std/min/25%/50%/75%/max)──
df.describe()
# ── ④ 看缺失值一目了然 ─────────────────────────
df.isnull().sum()df.info() 的输出会立刻暴露三个问题:Age 缺 177、Cabin 缺 687、Embarked 缺 2。df.describe() 则暴露第四个问题:Fare 的标准差是 49.7、均值 32 而中位数 14,说明严重右偏(少数头等舱超高价拉高了均值)。
接下来探索目标分布:
python
# 生还比例:38.4%,即多数类是"死亡"(62%)
print(df["Survived"].value_counts(normalize=True))
# 0 0.616162
# 1 0.383838然后是特征与标签的关系——这是 EDA 里信息量最大的部分:
python
# 性别与生还:女性生还率 74.2%,男性 18.9% —— 最强的信号
print(df.groupby("Sex")["Survived"].mean())
# female 0.742038
# male 0.188908
# 舱位与生还:头等舱 63.0%、二等舱 47.3%、三等舱 24.2%
print(df.groupby("Pclass")["Survived"].mean())
# 1 0.629630
# 2 0.472826
# 3 0.242363
# 年龄分布(缺失值暂时先看非缺失部分)
df["Age"].hist(bins=30)
plt.title("Age 分布")
plt.show()把 EDA 的结论写下来(而不是只看一眼),这是后续特征工程的依据:
| 发现 | 含义 | 后续动作 |
|---|---|---|
Survived 约 38% 生还 | 类别不平衡 | 用 AUC/召回率,不看裸准确率 |
Sex 与生还强相关(女 74% vs 男 19%) | 核心信号 | 必须编码进模型 |
Pclass 与生还强相关(1→3 递减) | 核心信号 | 保留,可考虑类别特征化 |
Age 缺 177、Cabin 缺 687 | 缺失严重 | 需填充或剔除(下一步) |
Fare 右偏严重 | 分布偏斜 | 做对数变换或分箱 |
Name/Ticket/PassengerId | 高基数/标识符 | 基本无直接预测力,但可挖特征 |
好 EDA 的标志
好的 EDA 会产出一份发现清单,每一条都导向后续的一个决策。如果你做 EDA 只是"print 了十几个表格就过去",说明还没进入状态。EDA 的本质是假设检验:你带着"哪些因素影响生存"的假设来,用数据验证或推翻它们。
步骤 2:数据清洗与特征工程
EDA 的发现要落实成代码。总原则:清洗与特征工程的产物是一个统一的 X(特征矩阵)+ y(标签),我们后面所有模型都吃这一对。
① 缺失值处理
| 列 | 缺失率 | 处理方式 | 理由 |
|---|---|---|---|
Age | 20% | 用中位数填充(或按性别/舱位分组填充) | 年龄右偏,中位数比均值稳健 |
Embarked | 0.2% | 用众数(出现最多的 S)填充 | 只有 2 条,影响极小 |
Cabin | 77% | 不直接填充,转为"有无船舱号"二值特征 | 缺失本身可能含信息(无舱记录者生还率低),填假值反而引入噪声 |
python
# 年龄:分组填充比全局填充更精细(不同性别/舱位的年龄中位数不同)
df["Age"] = df.groupby(["Sex", "Pclass"])["Age"].transform(
lambda s: s.fillna(s.median())
)
# Embarked:众数填充
df["Embarked"] = df["Embarked"].fillna(df["Embarked"].mode()[0])
# Cabin:转为二值特征"是否有舱号记录"(信息密度比舱号本身高)
df["HasCabin"] = df["Cabin"].notna().astype(int)
df = df.drop(columns=["Cabin"])缺失值处理的"泄露"红线
所有从数据中学到的量(中位数、众数、均值、min/max、回归系数)都只能从训练集计算,再应用到验证/测试集。在整份数据上直接 fillna(df["Age"].median()) 对练习无所谓,但真实项目里这就是数据泄露的一种——测试集的信息流进了训练集。严谨的写法是放进 Pipeline(见下文),让 sklearn 自动"在训练集上 fit、在其他集上 transform"。
② 类别编码
Sex 和 Embarked 是字符串,模型只吃数字:
python
# One-Hot 编码:Embarked 有 3 类 → 3 个 0/1 列(sklearn 会自动丢弃第一列避免共线)
df = pd.get_dummies(df, columns=["Sex", "Embarked"], drop_first=True)Sex_male 变成 1/0,Embarked_Q、Embarked_S 各为 1/0。注意:Pclass 虽然有 1/2/3 的数字形态,但它是等级(有序类别)而非连续量,处理成类别特征更稳——我们用 pd.get_dummies 把 Pclass 也展开成 3 列,或者直接保留原值交给树模型(树不假设数值的线性关系)。
③ 手工特征工程:让领域知识进模型
Name 里的称谓(Mr/Mrs/Miss/Master)是经典的特征工程示例——它高度关联年龄与性别,对缺失值的年龄段区分尤其有用:
python
df["Title"] = df["Name"].str.extract(r" ([A-Za-z]+)\.", expand=False)
# 合并稀有称谓,降低类别数
df["Title"] = df["Title"].replace(
["Lady","Countess","Capt","Col","Don","Dr","Major","Rev","Sir","Jonkheer","Dona"],
"Rare"
)
df["Title"] = df["Title"].replace(["Mlle","Ms"], "Miss")
df["Title"] = df["Title"].replace(["Mme"], "Mrs")
df = pd.get_dummies(df, columns=["Title"], drop_first=True)再构造一个家庭规模特征——"一家人整整齐齐"是生存的重要因素:
python
df["FamilySize"] = df["SibSp"] + df["Parch"] + 1 # +1 算上自己
# 独行 vs 有伴:单人生还率显著更低
df["IsAlone"] = (df["FamilySize"] == 1).astype(int)④ 数值缩放
逻辑回归(以及任何用梯度下降优化的模型)对特征尺度敏感:Fare 是几十上百,Age 是十几,尺度差 10 倍会让优化器和正则化失真。树模型不关心缩放,但统一缩放无害且让流程更通用:
python
from sklearn.preprocessing import StandardScaler
# ⚠️ 此处仅演示。规范做法:放进 Pipeline,让 scaler 只在训练集上 fit
scaler = StandardScaler()
df["Age_scaled"] = scaler.fit_transform(df[["Age"]])
df["Fare_scaled"] = scaler.fit_transform(df[["Fare"]])最后组装建模矩阵:
python
# 特征列清单(去掉标签、ID、以及已加工过的原列)
drop_cols = ["PassengerId", "Name", "Ticket", "Survived",
"Age", "Fare", "SibSp", "Parch"]
X = df.drop(columns=drop_cols)
y = df["Survived"]
print("特征矩阵:", X.shape) # (891, 20)
print(X.columns.tolist())步骤 3:划分训练 / 验证 / 测试集
这一步是防作弊的核心。三层划分的职责各不同:
全部数据 (891)
├── 训练集 train (70%) ──── 模型在这里学参数
├── 验证集 val (15%) ───── 在这里选模型/调超参(可以反复看)
└── 测试集 test (15%) ──── 只在最后碰一次(模拟"没见过的未来数据")python
from sklearn.model_selection import train_test_split
# 第一步:分出测试集(只允许最后碰)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.15, random_state=42, stratify=y
)
# 第二步:在剩余数据中分出验证集
X_train, X_val, y_train, y_val = train_test_split(
X_train, y_train, test_size=0.15 / 0.85, random_state=42, stratify=y_train
)
print(f"train {X_train.shape[0]} / val {X_val.shape[0]} / test {X_test.shape[0]}")两处要点:
stratify=y(分层抽样):保证三个集合里生还者的比例都和原始数据一致(约 38%)。如果不分层,随机切分可能让训练集里"女乘客比例失衡",评估就失真了。类别不平衡时分层是必须的。- 先分测试集、再分验证集:两层切分必须串行做,而不是把数据切成三份后"随便指定一份当测试集"。上面的
0.15 / 0.85是把剩下 85% 中再切出 15%——比例刚好是总的 15%。
为什么要单独留测试集,验证集不够吗?
因为你会在验证集上反复调参,验证集分数会被你"调参"这个行为本身污染——调多了,验证集上的好成绩可能只是恰好过拟合了验证集。测试集是唯一不受你意志影响的"裁判"。数据量再小也要坚持这一纪律,它是数据泄露与评估陷阱的防线。
步骤 4:基线模型
基线(baseline)分两层:弱基线(证明"模型学到了东西")和强基线(证明"新模型值得上")。
① 多数类基线:什么都不学
python
from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score, roc_auc_score
dummy = DummyClassifier(strategy="most_frequent")
dummy.fit(X_train, y_train)
y_pred = dummy.predict(X_val)
print("准确率:", accuracy_score(y_val, y_pred)) # ≈ 0.616(全预测死亡)
# 注意:多数类没有"正类概率",AUC 无意义(恒为 0.5)全部预测死亡,准确率 61.6%——这是任何模型都必须超越的下限。
② 逻辑回归:第一个真模型
python
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
# Pipeline:scaler 只在训练集上 fit,测试/验证集自动用训练集的参数变换
pipe_lr = Pipeline([
("scaler", StandardScaler()),
("lr", LogisticRegression(max_iter=1000, random_state=42)),
])
pipe_lr.fit(X_train, y_train)
print("验证集准确率:", pipe_lr.score(X_val, y_val))Pipeline 的引入不是锦上添花——它把"训练集上 fit、其他集上 transform"的纪律强制写进结构,杜绝了手动处理时最容易犯的泄露。
③ 看模型的"想法":逻辑回归是可解释的
逻辑回归的价值不止分数,它能直接告诉你每个特征的方向和强度:
python
import numpy as np
coef = pd.Series(pipe_lr.named_steps["lr"].coef_[0], index=X_train.columns)
print(coef.sort_values(ascending=False).to_string())
# 典型输出(示意):
# Title_Miss 2.74 ← 称谓"小姐"(年轻女性)强烈指向生还
# Sex_male -2.58 ← 男性强烈指向死亡
# Title_Mr -1.96 ← 称谓"先生"(成年男性)指向死亡
# Pclass_3 -0.98 ← 三等舱指向死亡
# ...系数为正表示"该特征增大生还概率",为负反之。模型学到的东西和 EDA 观察完全一致(女性/头等舱生还率高),这本身就是一次验证:模型没有学歪。可解释性的更多讨论见什么是机器学习的权衡一节。
步骤 5:迭代改进
基线"及格"之后,才轮到更复杂的模型。顺序永远是:简单基线 → 中等模型 → 调参,直接上最复杂的模型然后调参是新手最常见的浪费。
① 随机森林:树模型上手
python
from sklearn.ensemble import RandomForestClassifier
pipe_rf = Pipeline([
("rf", RandomForestClassifier(n_estimators=200, random_state=42)),
])
pipe_rf.fit(X_train, y_train)
print("随机森林验证集 AUC:",
roc_auc_score(y_val, pipe_rf.predict_proba(X_val)[:, 1]))随机森林无需缩放、能捕捉非线性(比如"年龄×舱位"的交互),通常是表格型数据的强基线。树模型家族的原理见树模型与集成学习。
② 特征重要性:树模型告诉你它用了什么
python
importances = pd.Series(
pipe_rf.named_steps["rf"].feature_importances_,
index=X_train.columns,
).sort_values(ascending=False)
importances.plot.barh(figsize=(8, 8))
plt.title("随机森林特征重要性")
plt.show()Age、Fare、Sex_male、FamilySize 通常排在最前。如果某个你认为很重要的特征重要性接近 0,值得回头想想:是特征没做对,还是信号本就不存在?
③ 超参调优:在验证集上搜索
随机森林要调的超参主要是:树的数量 n_estimators、每棵树最大深度 max_depth、分裂所需最少样本数 min_samples_split。用 GridSearchCV 做交叉验证搜索:
python
from sklearn.model_selection import GridSearchCV
param_grid = {
"rf__n_estimators": [100, 200, 400],
"rf__max_depth": [3, 5, None],
"rf__min_samples_split": [2, 5, 10],
}
gs = GridSearchCV(
pipe_rf,
param_grid,
cv=5, # 5 折交叉验证
scoring="roc_auc", # 按主指标搜索
n_jobs=-1,
verbose=1,
)
gs.fit(X_train, y_train)
print("最佳参数:", gs.best_params_)
print("最佳 CV AUC:", gs.best_score_)
print("验证集 AUC:", roc_auc_score(y_val, gs.best_estimator_.predict_proba(X_val)[:, 1]))交叉验证与网格搜索是什么
GridSearchCV 在训练集内部做 K 折交叉验证:把训练集切成 5 份,轮流用 4 份训练、1 份验证,5 次结果平均——这样选参数不依赖某一次切分的运气。它找到的 best_estimator_ 再用独立的验证集确认。注意:调参只能看 CV 分数和验证集,绝不能看测试集。参数搜索的更完整实践见模型评估与验证与常见陷阱与反模式。
④ 迭代记录:实验日志
迭代必须留下记录,否则三天后你会忘记"0.81 是哪个版本跑出来的":
python
import json, datetime
log = {
"时间": datetime.datetime.now().isoformat(),
"模型": "RandomForest + GridSearch",
"最佳参数": gs.best_params_,
"CV AUC": float(gs.best_score_),
"验证集 AUC": float(roc_auc_score(y_val, gs.best_estimator_.predict_proba(X_val)[:, 1])),
}
print(json.dumps(log, indent=2, ensure_ascii=False))步骤 6:评估与错误分析
模型选定了,现在认真看它错在哪。错误分析是把分数变成理解的关键一步。
① 混淆矩阵:错误长什么样
python
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
y_val_prob = gs.best_estimator_.predict_proba(X_val)[:, 1]
y_val_pred = (y_val_prob >= 0.5).astype(int)
cm = confusion_matrix(y_val, y_val_pred)
disp = ConfusionMatrixDisplay(cm, display_labels=["死亡", "生还"])
disp.plot()
plt.title("验证集混淆矩阵(阈值 0.5)")
plt.show()
# 结果解读:
# [[? ?] 行=真实(死亡/生还),列=预测
# [? ?]]
# 左上 = 正确预测死亡(TN),右下 = 正确预测生还(TP)
# 右上 = 把生还者错判为死亡(FN,漏报)
# 左下 = 把死亡者错判为生还(FP,误报)② 多指标同时看:单指标会骗人
| 指标 | 公式 | 回答的问题 |
|---|---|---|
| 准确率 | (TP+TN)/全部 | 总体猜对的比例 |
| 精确率 | TP/(TP+FP) | 说"生还"的人里,真的生还的比例 |
| 召回率 | TP/(TP+FN) | 真的生还者里,被找出来的比例 |
| F1 | 2·P·R/(P+R) | 精确率与召回率的调和平均 |
| AUC | 曲线下面积 | 随机挑生还者 vs 死亡者,模型给生还者更高分的概率 |
python
from sklearn.metrics import classification_report, precision_recall_curve
print(classification_report(y_val, y_val_pred, target_names=["死亡", "生还"]))为什么不能只看准确率
泰坦尼克数据 62% 是死亡。一个"全预测死亡"的模型准确率就有 62%,而召回率为 0——它一个生还者都没找出来。当类别不平衡时,准确率是一个极具欺骗性的指标。先明确业务关心什么:如果目标是"尽量不错过一个生还者",该优化召回率;如果目标是"预测生还的人尽量都要真生还",该优化精确率。指标的完整讨论见模型评估与验证。
③ 阈值分析:0.5 不是金科玉律
分类模型输出的其实是概率,0.5 只是默认的判定线。不同业务需要不同阈值:
python
precisions, recalls, thresholds = precision_recall_curve(y_val, y_val_prob)
# 画出 精确率-召回率 随阈值变化的曲线
plt.plot(thresholds, precisions[:-1], label="精确率")
plt.plot(thresholds, recalls[:-1], label="召回率")
plt.xlabel("阈值"); plt.legend(); plt.show()想"尽量找出生还者"就调低阈值(召回率↑、精确率↓);想"说生还就必须靠谱"就调高阈值。阈值是业务决策,不是模型参数——它只能在验证集上选,不能在测试集上选。
④ 看具体的错误样本
错误分析最有洞察力的一步:把预测错的样本打出来,逐个看为什么错。
python
# 把验证集拼回去看错误样本
val_df = X_val.copy()
val_df["真实"] = y_val
val_df["预测概率"] = y_val_prob
val_df["预测"] = y_val_pred
errors = val_df[val_df["真实"] != val_df["预测"]]
print("错误样本数:", len(errors))
print(errors.head(15).to_string())逐条看这些错误样本,常见的发现是:
- 模型系统性误判某类人(比如把所有幼年男性都判为生还)→ 说明该特征组合还缺信息,可继续做特征工程。
- 个别样本极难(比如"独自旅行且男性且三等舱"但真实生还)→ 属于噪声,不值得为它调模型。
- 这决定了下一步是加特征、换模型还是收手。
步骤 7:结果汇报与可视化
项目最后,把结果诚实地讲出来。一份合格的汇报 = 一张指标总表 + 一张关键图 + 一段可复现说明。
① 指标汇总表
| 模型 | 训练 CV AUC | 验证集 AUC | 验证集准确率 | 备注 |
|---|---|---|---|---|
| 多数类基线 | — | 0.500 | 61.6% | 全预测死亡 |
| 逻辑回归 | 0.85 | 0.86 | 79.5% | 可解释 |
| 随机森林 | 0.88 | 0.87 | 81.1% | 需调参 |
| 随机森林(调参后) | 0.89 | 0.88 | 82.0% | 最终选择 |
② 汇报的诚实性原则
- 报告验证集分数,报告最终选择的理由(为什么选随机森林而不是逻辑回归:AUC 略高且更稳,代价是可解释性下降)。
- 明确说明局限:样本只有 891,测试集只有 134 人,AUC 的置信区间很宽。
- 绝不把测试集分数写进"探索过程"——测试集分数只在最终报告中出现一次。
③ 可视化三件套
python
# (a) ROC 曲线:不同阈值下 TPR vs FPR,AUC 是曲线下面积
from sklearn.metrics import roc_curve
fpr, tpr, _ = roc_curve(y_val, y_val_prob)
plt.plot(fpr, tpr, label=f"AUC = {roc_auc_score(y_val, y_val_prob):.3f}")
plt.plot([0, 1], [0, 1], "--", color="gray") # 随机猜测基线
plt.xlabel("FPR"); plt.ylabel("TPR"); plt.legend(); plt.show()
# (b) 特征重要性条形图(前 10)
# (c) 训练曲线:样本量 vs 分数,判断"是欠拟合还是缺数据"
from sklearn.model_selection import learning_curve
sizes, train_scores, val_scores = learning_curve(
gs.best_estimator_, X_train, y_train,
cv=5, scoring="roc_auc", train_sizes=np.linspace(0.1, 1.0, 5),
)
plt.plot(sizes, train_scores.mean(1), label="训练 AUC")
plt.plot(sizes, val_scores.mean(1), label="CV AUC")
plt.xlabel("训练样本量"); plt.legend(); plt.show()训练曲线如果显示"训练分高、CV 分低且随样本量上升而趋近"→ 说明数据不够而不是模型不够,下一步该找更多数据而不是调参(这一判断标准来自过拟合与正则化)。
④ 最后碰一次测试集
python
from sklearn.metrics import roc_auc_score
test_prob = gs.best_estimator_.predict_proba(X_test)[:, 1]
print("测试集 AUC(最终成绩):", roc_auc_score(y_test, test_prob))跑完这一行,把分数记进实验日志,然后就不要再去碰测试集了。如果你忍不住回去改模型再测一次——你已经在把测试集当验证集用了,分数会虚高。
四、完整代码仓库结构
一个可维护的 ML 项目,文件组织要遵循"入口清晰、职责分离、参数集中"。推荐的目录结构:
titanic/
├── README.md # 项目简介:目标、数据来源、如何复现、结果
├── requirements.txt # 依赖清单(pip freeze 生成)
├── config.py # 所有超参、路径、随机种子集中在此
├── data/
│ ├── train.csv # 原始数据(只读,永不修改)
│ ├── processed/ # 清洗后的数据(由脚本生成)
│ └── test.csv # Kaggle 测试数据(可选)
├── notebooks/
│ ├── 01-eda.ipynb # 探索性分析(探索过程,不进仓库的可复现路径)
│ └── 02-experiments.ipynb # 实验记录
├── src/
│ ├── __init__.py
│ ├── load_data.py # 加载原始数据
│ ├── features.py # 清洗与特征工程(唯一的特征实现)
│ ├── models.py # 模型定义、Pipeline、调参逻辑
│ └── evaluate.py # 评估指标、混淆矩阵、ROC 等
├── models/
│ └── best_model.joblib # 训练好的模型序列化保存
├── reports/
│ ├── figures/ # 所有图表输出
│ └── results.md # 实验结果汇总表
└── train.py # 主入口:加载→特征→训练→评估→保存| 文件/目录 | 职责 | 为什么这么放 |
|---|---|---|
config.py | 集中管理随机种子、路径、超参 | 改参数不用翻代码,实验可复现 |
src/features.py | 唯一的特征实现 | 训练和上线共用一份特征代码,避免"训练/线上不一致" |
data/ 只读 | 原始数据永不原地修改 | 保证每次运行从同一份数据出发 |
notebooks/ | 探索与分析 | 过程性内容留在 notebook,可复用逻辑下沉到 src/ |
train.py | 一键跑完整流程 | 从"能跑通的 notebook"到"能复现的脚本"是质变 |
一个可复现实验的黄金检验:删掉 notebooks 和 models 目录,只留 requirements.txt、src/、config.py、train.py,另一个人能否在新机器上复现你的分数? 如果答案是"能",你的工程化及格了。
从 notebook 到脚本的迁移原则
Notebook 适合探索,但不适合复现(执行顺序乱、隐式状态多)。成熟的做法:在 notebook 里调通逻辑后,把可复用的部分提炼成 src/ 下的函数,notebook 只负责"调用并展示结果"。这正是数据工程中"管道 vs 脚本"讨论的实践落地。
五、常见坑
项目虽小,五脏俱全。以下是本流程中最容易踩、且踩了最难发现的坑:
坑 1:数据泄露(leakage)
本流程里出现过的泄露点,逐个对照检查:
| 泄露点 | 症状 | 正确做法 |
|---|---|---|
用全量数据 fit 缩放器/填充器 | 验证分数虚高、上线崩盘 | 放进 Pipeline 或手动"先 fit 训练集再 transform 其他集" |
| 在切分前做特征工程时用到全量统计量(如全量中位数) | 同上 | 一切统计量先切分后计算 |
| 反复用测试集调参 | 最终成绩比"真实水平"高 | 测试集只能碰一次 |
对 Name/PassengerId 这类 ID 做高基数编码 | 模型"背"下 ID 到标签的映射 | ID 直接剔除 |
"先切分、后处理"是铁律。更完整的清单(含时序泄露、训练线上不一致等 25 项)见常见陷阱与反模式。
坑 2:过拟合
本项目的过拟合信号与应对:
- 信号:训练 AUC 0.97、验证 0.88 → 训练和验证的差距说明模型在背训练集。
- 应对:树模型限制
max_depth和min_samples_split、加正则化(C调小)、用交叉验证选参。原理见过拟合与正则化。 - 终极手段:这个项目只有 891 样本,任何模型都容易过拟合,样本量是硬约束——特征越多、模型越强,过拟合风险越高,特征选择要克制。
坑 3:指标误用
- 只看准确率 → 在 38/62 的不平衡数据上被"全预测死亡"骗过去。
- 把 AUC 当唯一真理 → AUC 关心排序不关心阈值,业务关心的是"阈值 0.7 以上才派单"时的精确率。
- 在测试集上选指标 → 指标选择本身就是一种调参,必须在验证阶段定下来。
坑 4:数据漂移
模型上线后,真实数据的分布会变(比如换季、新用户群)。练习项目里这是下一步的事,但现在就该在报告里写明"训练数据的局限"——891 名 1912 年乘客学到的规律,对 2024 年的乘客未必成立。数据的全生命周期管理见数据工程。
六、进阶方向
泰坦尼克项目是流程的"最小完备集",扩展成真正拿得出手的项目可以从四个方向:
- 深化评估:加入交叉验证(取代单次留出)、置信区间、校准曲线,把评估从"报一个数"升级为"对分数有置信度"。实战指引见评估落地。
- 换问题换数据:做回归(房价)、多分类(花卉品种)、或换一个更大更脏的真实数据集,锻炼数据清洗能力。数据集清单见数据集与工具档案。
- 更系统的训练流程:用更完整的教程式文档把流程标准化,形成自己的脚手架。参见ML 教程与评估落地。
- 做成完整作品:加入实验追踪(对比多次运行)、模型序列化与部署(用 FastAPI 包一个预测接口)、可复现的 Docker 环境,把它扩展成一个能在面试/求职中讲清楚的项目。完整的扩展路线图见实战项目进阶。
bash
# 序列化保存最终模型,为部署做准备
import joblib
joblib.dump(gs.best_estimator_, "models/best_model.joblib")
# 部署时加载并预测
model = joblib.load("models/best_model.joblib")
prob = model.predict_proba(new_passenger_features)[0, 1] # 返回生还概率七、延伸阅读
- 什么是机器学习 —— 四个环节总览的理论版
- 总体架构解剖 —— 从本文的小项目放大到生产级系统
- 模型评估与验证 —— 指标、交叉验证、置信区间的完整理论
- 特征工程 —— 编码、缩放、分箱的系统方法
- 过拟合与正则化 —— 偏差-方差权衡的数学与实战
- 树模型与集成学习 —— 随机森林与梯度提升的原理
- 常见陷阱与反模式 —— 本文第五节的完整版(25 个坑)
- 实战项目进阶 —— 把小项目扩展成作品集
- 数据集与工具档案 —— 下一个练手项目的数据从哪来
参考资料
- scikit-learn 用户指南:Model selection and evaluation ——
train_test_split、GridSearchCV、评估指标的官方文档 - scikit-learn 用户指南:Pipelines and composite estimators —— Pipeline 与预处理器的官方文档
- scikit-learn 用户指南:Preprocessing data —— 编码与缩放的官方文档
- pandas 用户指南 ——
groupby、fillna、get_dummies的官方文档 - Kaggle:Titanic - Machine Learning from Disaster —— 本文数据集的出处,入门竞赛第一题
- matplotlib 文档 —— 可视化代码的官方文档
- sklearn.metrics 模块文档 ——
roc_auc_score、confusion_matrix、learning_curve等全部评估工具