Skip to content

从零构建一个 ML 项目

本页速览 用泰坦尼克生存预测这一经典小项目,走通从数据加载、EDA、特征工程、基线模型到迭代改进、错误分析的完整流程:每一步给出可运行代码、解释、仓库目录结构、常见坑与进阶方向。

从零构建一个 ML 项目 ​

读十篇教程,不如完整跑通一个项目。本文带你用最经典、最小的「泰坦尼克生存预测」走一遍真实项目的全部环节——不是"调包出结果",而是把每一步为什么这么做讲清楚。

很多初学者学了一堆算法之后,仍然不知道一个 ML 项目到底长什么样:文件怎么组织?步骤的先后顺序是什么?哪些环节可以跳过、哪些一步都不能省?本文用一条主线(泰坦尼克生存预测)把完整的项目流程从头走到底。你得到的不是"这个数据集的 0.81 分",而是一套可以复用到任何表格型分类问题的流程骨架。

完整项目分为七个环节,先看整体地图:

原始数据
   │
   ▼
① 数据加载与探索(EDA)── 回答"数据长什么样、有什么问题"
   ▼
② 数据清洗与特征工程 ── 回答"哪些列能进模型、怎么变成数字"
   ▼
③ 划分训练/验证/测试 ── 回答"怎么保证评估是诚实的"
   ▼
④ 基线模型 ────────── 回答"最简单的解法能到多少分"
   ▼
⑤ 迭代改进 ────────── 回答"更复杂的模型能再多多少分"
   ▼
⑥ 评估与错误分析 ──── 回答"模型在哪里犯错、为什么"
   ▼
⑦ 结果汇报与可视化 ── 回答"怎么把结论讲清楚"

在整个流程中,我们要刻意遵守一条纪律:测试集只能在最后碰一次。这条纪律贯穿全文,也是常见陷阱里排第一位的"数据泄露"的防线。

前置知识

本文假设你已了解基本概念:什么是监督学习(监督学习)、模型如何评估(模型评估与验证)、特征工程是什么(特征工程)。如果你的基础还需要补,先读完什么是机器学习与总体架构解剖再回来。

一、项目选择:一个简单但真实的问题 ​

第一个问题是:练手项目选什么? 很多人的第一个项目是"用 LSTM 预测股票",这几乎注定失败——不是技术难,而是问题本身不可解。选项目有三个标准,缺一不可。

标准为什么违背的后果
有标签(有标准答案)你需要知道"正确答案"才能评估模型学得怎么样没有标签就无从评估,只能自我感觉良好
规模小(几百~几千样本)每轮迭代几分钟跑完,能把精力放在流程和概念上大数据项目一半时间在调 Spark,学不到建模
可解释(人能理解特征含义)你能靠常识判断特征是否合理,错误分析才做得下去图像/文本的"为什么错"需要额外工具,新手无从下手

按这个标准,三个经典练习项目对比:

项目标签规模可解释性适合度
泰坦尼克生存预测有(Survived)891 训练样本★★★ 特征全是人话(年龄、舱位、性别)★★★ 新手首选
波士顿/加州房价预测有(连续值)2 万左右★★☆ 特征有现实含义★★★ 回归练习首选
垃圾邮件分类有(spam/ham)5 千+ 封邮件★☆☆ 特征是词向量,需文本处理★★☆ 稍进阶

本文选泰坦尼克生存预测,理由三条:

  1. 标签清晰:每个乘客是否生还,是确定的历史事实,没有"标注主观性"问题。
  2. 特征丰富且可解释:年龄、性别、舱位等级(Pclass)、票价、登船港口……每列都能用常识解释,适合做错误分析。
  3. 规模小:891 个训练样本,任何模型都在秒级跑完,你可以放心地反复迭代。

数据集长什么样 ​

泰坦尼克数据集(来自 Kaggle 同名入门竞赛)的原始形态:

列名含义类型有无缺失
PassengerId乘客 IDint无
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 竞赛的训练集。真实项目里数据往往散落在不同系统、不同格式,加载和清洗会占去一半工作量——正因为如此,训练营式的"干净数据集"才适合学流程。数据集的寻找与评估方法见数据集与工具档案。

先定义"成功",再开始写代码

把问题翻译成机器学习任务时,必须回答三个问题:

  1. 任务类型:生存预测是二分类(生/死)。
  2. 评估指标:本数据集中生还者约 38%,属于类别不平衡。准确率会误导人(见指标误用),所以我们以 ROC-AUC 为主指标,辅以精确率/召回率。
  3. 基线:什么都不要学、"全部预测死亡"的模型准确率约 62%。我们的任何模型都必须显著超过这个数,否则说明没学到任何东西。

项目成功的定义先于代码写下,这是模型评估与验证的基本功。

二、环境搭建 ​

1. 版本要求 ​

本文代码基于以下版本(2024 年稳定版,向下兼容老版本):

软件版本用途
Python3.10+语言本身
pandas2.x数据加载与处理
scikit-learn1.3+建模、切分、评估、调参
matplotlib3.x可视化
jupyterlab4.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(标签),我们后面所有模型都吃这一对。

① 缺失值处理

列缺失率处理方式理由
Age20%用中位数填充(或按性别/舱位分组填充)年龄右偏,中位数比均值稳健
Embarked0.2%用众数(出现最多的 S)填充只有 2 条,影响极小
Cabin77%不直接填充,转为"有无船舱号"二值特征缺失本身可能含信息(无舱记录者生还率低),填假值反而引入噪声
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]}")

两处要点:

  1. stratify=y(分层抽样):保证三个集合里生还者的比例都和原始数据一致(约 38%)。如果不分层,随机切分可能让训练集里"女乘客比例失衡",评估就失真了。类别不平衡时分层是必须的。
  2. 先分测试集、再分验证集:两层切分必须串行做,而不是把数据切成三份后"随便指定一份当测试集"。上面的 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)真的生还者里,被找出来的比例
F12·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.50061.6%全预测死亡
逻辑回归0.850.8679.5%可解释
随机森林0.880.8781.1%需调参
随机森林(调参后)0.890.8882.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 年的乘客未必成立。数据的全生命周期管理见数据工程。

六、进阶方向 ​

泰坦尼克项目是流程的"最小完备集",扩展成真正拿得出手的项目可以从四个方向:

  1. 深化评估:加入交叉验证(取代单次留出)、置信区间、校准曲线,把评估从"报一个数"升级为"对分数有置信度"。实战指引见评估落地。
  2. 换问题换数据:做回归(房价)、多分类(花卉品种)、或换一个更大更脏的真实数据集,锻炼数据清洗能力。数据集清单见数据集与工具档案。
  3. 更系统的训练流程:用更完整的教程式文档把流程标准化,形成自己的脚手架。参见ML 教程与评估落地。
  4. 做成完整作品:加入实验追踪(对比多次运行)、模型序列化与部署(用 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]   # 返回生还概率

七、延伸阅读 ​

参考资料 ​