泰坦尼克号生存预测这个项目,基本是每个学机器学习的人都会碰到的第一道完整题目。数据量不大、字段有数值有类别有缺失、模型跑完还有明确的检验指标,特别适合拿来把随机森林从“会用”变成“用明白”。不过我见过很多新手跑完代码,只是记住了“导入 RandomForestClassifier 然后 fit”这个动作,至于为什么这样补缺失、为什么调树深度、为什么最后分数涨了模型却未必更好,完全没有概念。
这篇文章不会去讲下载数据、注册账号那些外围操作,只聚焦一件事:用 sklearn 的随机森林把泰坦尼克号生存预测从头到尾跑通,并且把每一步背后的理由讲清楚。内容包括可以直接运行的完整代码、特征工程里容易被忽略的细节、超参数调优的正确打开方式,以及调参之后为什么不要被单次分数迷惑。适合两类人:刚学完 sklearn 基础想做个完整项目的新手,和已经跑过案例、但一直觉得调参像玄学的同学。
1. 数据预处理与特征工程:随机森林为什么会被这些细节影响
1.1 先盘数据:哪些字段能用,哪些缺失严重
随机森林虽然是集成模型里比较“皮实”的,但它有一个硬性要求:输入必须是数值型、不能有缺失值。所以动手建模前,第一件事不是调参数,而是把这张表整理干净。
我用 seaborn 内置的泰坦尼克号数据集作为主流程,方便你本地不下载文件也能直接跑。如果你用的是竞赛平台的 train.csv,列名基本一样,个别字段略有差别,后面我会单独说差异点。
import pandas as pd import numpy as np import seaborn as sns # seaborn 自带版本,运行前确保装了 seaborn df = sns.load_dataset('titanic') df.head()这个数据集一共 891 条记录,包含的字段有存活标记、舱位等级、性别、年龄、兄弟姐妹/配偶数量、父母/子女数量、票价、登船港口、甲板信息等。先看一眼缺失情况:
df.isnull().sum()典型版本下,缺失分布大概是:
| 字段 | 缺失数量 | 占比 | 处理方式 |
|---|---|---|---|
| Age | 177 | 约 19.9% | 中位数填充 |
| Embarked | 2 | 极少 | 众数填充 |
| deck(Cabin 首字母) | 688 | 约 77.2% | 转换为“是否缺失”特征 |
这个缺失分布说明了一件事:年龄缺口不小,不能用删除行解决;甲板信息大量缺失,直接丢掉浪费信息,直接填“未知”又会让模型觉得所有缺失样本都一样。这两种情况要用不同的思路处理。
1.2 缺失值和脏数据的处理:不是填空那么简单
年龄字段,我推荐用中位数而不是均值。原因很简单:年龄分布是右偏的,有几个高龄乘客会把均值拉高,中位数更能代表这批人的“典型年龄”。更精细一点的做法是按性别和舱位分组后再取中位数,因为头等舱女性和三等舱男性的年龄中位数有区别:
df['Age'] = df['Age'].fillna(df['Age'].median()) # 更精细版本:按性别和舱位分组填充 # df['Age'] = df.groupby(['Sex', 'Pclass'])['Age'].transform(lambda s: s.fillna(s.median()))Embarked 只有 2 个缺失值,直接用众数填充即可。它表示登船港口,三个港口之间存在经济阶层混杂,本身是一个有预测力的类别特征。
Cabin 是另一个争议点。一种做法是把缺失值填成 “Unknown”,然后做 one-hot;一种做法是把“是否有舱房记录”转成 0/1。我建议在泰坦尼克号这个场景里优先用“是否有舱房记录”,因为那个时期只有较高经济地位的乘客才有独立舱房,所以“有舱房”本身和生存率高度相关。具体到 seaborn 数据集,deck 列其实就是 Cabin 的首字母,可以这样处理:
df['has_cabin'] = df['deck'].notna().astype(int)性别字段转成 0/1 编码;Embarked 做 one-hot。这里解释一个迷思:决策树和随机森林不需要特征缩放,所以 Age、Fare 这些连续值不需要标准化。树模型只关心“切分点在哪里”,特征的单位和绝对值大小不影响分裂结果。这与逻辑回归、SVM 完全不同。
1.3 衍生特征:FamilySize、IsAlone 和 Title 的价值
泰坦尼克号的原始字段里,SibSp 是兄弟姐妹/配偶数量,Parch 是父母/子女数量。这两个字段单独看都不太稳定,但组合成家庭规模后,生存率会出现比较明显的规律:单人行动生存率偏低,小家庭(2-4 人)生存率较高,大家庭(超过 4 人)生存率又往下掉。所以:
df['FamilySize'] = df['SibSp'] + df['Parch'] + 1 df['IsAlone'] = (df['FamilySize'] == 1).astype(int)你对这两个字段做一个最简单的交叉统计,就能看到它们为什么有用。我用典型数据跑出来的生存率大致如下:
| 特征 | 分组 | 生存率参考 |
|---|---|---|
| Sex | male / female | 男约 18.9%,女约 74.2% |
| Pclass | 1 / 2 / 3 | 约 62.9% / 47.3% / 24.2% |
| FamilySize | 1 / 2-4 / >4 | 约 30% / 50% 以上 / 25% 左右 |
| has_cabin | 0 / 1 | 无记录约 30%,有记录约 66% |
注意最后一个:有无舱房记录其实是一个社会经济地位的代理变量,生存率差异非常明显,这就是为什么我不建议直接把缺失的 Cabin 丢掉。
如果你手里是官方版本的 train.csv,里面还有 Name 和 Ticket 字段,那可以多提取一个 Title 特征:
# 仅当使用官方 CSV 数据时需要这部分 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')Title 之所以有用,是因为它浓缩了性别、年龄和社会身份:Master 是男孩,Mr 是成年男性,Mrs/Miss 是已婚或未婚女性,Rare 则说明这个人可能有军官、贵族等特殊身份。
至于 Ticket,新手阶段可以跳过。票号前缀在幸存者里有一些规律,但类别太碎、噪音大,很容易做出一个只对训练集有效的特征,得不偿失。
2. 第一版模型:跑通 sklearn 随机森林,并选定评估指标
2.1 先切分,再建模:分层抽样在这个项目里比较重要
特征构造完之后,进入建模。一个容易被忽略的细节是切分数据时要用 stratify 参数做分层抽样。泰坦尼克号的存活比例大约是 38% 存活、62% 死亡,如果不分层,随机划分可能在某一折里分到很少的女性或头等舱样本,这会让 5 折交叉验证的分数波动非常大。891 条样本本来就不多,分层是为了让训练集和验证集的类别比例尽量接近原始分布。
feature_cols = ['Pclass', 'Sex', 'Age', 'SibSp', 'Parch', 'Fare', 'FamilySize', 'IsAlone', 'has_cabin'] df_ml = pd.get_dummies(df[feature_cols + ['Embarked']], columns=['Embarked'], drop_first=True) df_ml['Survived'] = df['survived'].astype(int) X = df_ml.drop(columns=['Survived']) y = df_ml['Survived'] from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) print(X_train.shape, X_test.shape, y_train.mean(), y_test.mean())关于预处理和切分的先后顺序,我多说一句。上面代码里填充 Age 中位数用的是全量数据,严格来说这算是“轻微泄漏”,因为统计量是在包含测试集的情况下算出来的。在 891 条数据上,这种影响通常只有零点几个百分点,但养成一个好习惯是永远先 split,再用训练集的统计量去 transform 测试集。后面有一节我会专门展开这个话题。
2.2 第一版基准模型:默认参数下的真实水平
我建议任何项目都先跑一个默认参数的模型,作为后续调参的基准。这样你才知道超参数调整到底带来了多少提升,而不是凭感觉觉得“调过之后肯定更好”。
from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import accuracy_score, roc_auc_score, classification_report rf_default = RandomForestClassifier( n_estimators=100, random_state=42, oob_score=True, n_jobs=-1 ) rf_default.fit(X_train, y_train) y_pred = rf_default.predict(X_test) y_pred_proba = rf_default.predict_proba(X_test)[:, 1] print("训练集准确率:", accuracy_score(y_train, rf_default.predict(X_train))) print("测试集准确率:", accuracy_score(y_test, y_pred)) print("OOB score:", rf_default.oob_score_) print("测试集 ROC AUC:", roc_auc_score(y_test, y_pred_proba))我随手跑一次,典型输出类似:
训练集准确率: 0.986 测试集准确率: 0.807 OOB score: 0.801 测试集 ROC AUC: 0.845看到训练集准确率接近 99%,很多人会慌,以为模型严重过拟合。其实这就是随机森林在小样本上的正常现象:单棵决策树不限制深度时,几乎可以把训练集背下来;但森林里每棵树背的是不同 bootstrap 样本,投票时彼此抵消了大部分记忆,最终泛化能力靠的是这种平均效应。所以不要用“训练集 99%”来判断随机森林有问题,应该看 OOB score 和测试集分数。
2.3 为什么不要只盯着准确率
泰坦尼克号的类别分布是约 62% 死亡、38% 存活。如果有个傻瓜模型直接预测所有人死亡,准确率也有 62%。所以“测试集准确率 80%”并没有看上去那么美好,关键要看你比基线提升了多少。
我一般会在基准模型上同时看三个指标:准确率、ROC AUC、交叉验证均值。AUC 衡量的是模型把存活乘客排在死亡乘客前面的能力,不受分类阈值影响;交叉验证看的是模型稳定性,而不是一次随机划分的运气。
from sklearn.model_selection import cross_val_score cv_scores = cross_val_score(rf_default, X_train, y_train, cv=5, scoring='roc_auc') print("5折CV AUC: %.4f (+/- %.4f)" % (cv_scores.mean(), cv_scores.std()))第一次跑基准模型时,如果发现 CV 均值和单次测试集分数差得比较多,不用太惊讶。891 条样本,每一次重新划分都会带来约 1-2 个百分点的波动,这就是后面要说到的随机性问题。
3. 特征重要性与模型解释:排名可以看,但不能盲信
3.1 用 feature_importances_ 看模型在靠什么决策
随机森林有一个很吸引人的卖点:训练完成后可以直接输出特征重要性。这个在很多业务场景里比预测分数本身还有价值,因为你能回答“到底什么因素在驱动生存概率”这个问题。
importances = pd.Series( rf_default.feature_importances_, index=X_train.columns ).sort_values(ascending=False) print(importances)一次典型输出的重要度排序大概是:
Sex 0.218 Age 0.189 Fare 0.178 Pclass 0.143 FamilySize 0.078 SibSp 0.057 Parch 0.043 Embarked_S 0.032 IsAlone 0.025 has_cabin 0.021性别排第一完全符合预期,头等舱、票价、年龄这些特征也在前列。但你看到 Age 和 Fare 的排名这么高,不能直接得出“票价和年龄比舱位更重要”的结论。原因在下一小节。
3.2 Gini 重要性的两个局限:连续变量偏好和相关特征抢功
scikit-learn 默认的feature_importances_是基于 Gini impurity 的平均下降量计算的。这个指标有两个非常实际的缺陷:
第一,连续变量天然容易被多选几次。模型每次分裂时,Age 和 Fare 可以切出很多不同的阈值,每次都能带来一点 impurity 下降,累计起来数值自然偏高。而 Sex 这种 0/1 变量,虽然决定性很强,但它只能被选有限次数,raw importance 反而没那么夸张。
第二,如果两个特征高度相关,模型会随机选择其中一个来分裂,导致重要性被分散。比如 Pclass 和 Fare 高度相关,模型可能有时用票价、有时用舱位,它俩的重要性都会被稀释。
如果你想让重要性的结论更经得起推敲,可以用置换重要性(permutation importance),思路是:把某一列打乱,再看模型性能掉多少。掉得多的才是真正有价值的特征。
from sklearn.inspection import permutation_importance perm_res = permutation_importance( rf_default, X_test, y_test, n_repeats=10, random_state=42 ) perm_importance = pd.Series( perm_res.importances_mean, index=X_train.columns ).sort_values(ascending=False) print(perm_importance)置换重要性算完之后,很多时候 Sex 会稳居第一,Age、Fare 依然重要,但排序和 Gini 重要性并不完全一致。我的经验是:你可以在文中报告这两种排序,但不要拿任何一个作为唯一真理。如果非要选一个参考,置换重要性更贴近“真实预测价值”。
3.3 树的棵数和 OOB 收敛:少调那些不重要的旋钮
默认参数下的 OOB score 是 0.80,这个分数本身就是一套“免费”的验证指标。OOB 的意思是:每棵树只用了约 63% 的 bootstrap 样本训练,剩下约 37% 的数据对那棵树来说是“没见过的”,可以用来测试。随机森林把所有树的 OOB 预测汇总,就得到了一个不需要划分验证集的评估分数。
我建议你在调参之前,先花一分钟看看 OOB 分数随树数量的变化趋势:
def oob_for_n(n): clf = RandomForestClassifier( n_estimators=n, random_state=42, oob_score=True, n_jobs=-1 ) clf.fit(X_train, y_train) return clf.oob_score_ for n in [50, 100, 200, 300, 500]: print(n, oob_for_n(n))典型输出类似:
50 0.797 100 0.801 200 0.810 300 0.814 500 0.815看到没有,50 棵树到 200 棵树之间有进步,300 颗树之后基本趋平。所以在泰坦尼克号这种小数据量项目里,把 n_estimators 从 300 调到 3000 纯属浪费计算资源。真正影响模型表现的往往不是树的棵数,而是单棵树长多深、叶子最少要多少样本。
4. 超参数调优:从理解参数含义到网格搜索落地
4.1 先搞清楚每个参数在控制什么,再谈搜索
很多调参教程直接给你一份参数网格,照抄之后也不知道为什么。我觉得这个项目最值得理解的参数就六个,先把它们的作用说透:
| 参数 | 控制什么 | 对泰坦尼克号项目的意义 | 常见范围 |
|---|---|---|---|
| n_estimators | 树的数量 | 越多越稳定,但收益递减明显 | 100-500 足够 |
| max_depth | 单棵树最大深度 | 限制树太深,减少单棵树的过拟合 | None 或 8-16 |
| min_samples_split | 内部节点继续分裂的最少样本数 | 值越大,树越保守 | 2-20 |
| min_samples_leaf | 叶子节点最少样本数 | 防止叶子只剩一两个样本,提高稳定性 | 1-10 |
| max_features | 每次分裂随机选择的特征数 | 控制每棵树的多样性 | sqrt 或 log2 |
| bootstrap | 是否行采样 | 随机森林的根基,一般保持 True | 必须 True |
在这几个参数里,我最看重的是min_samples_leaf和max_depth。泰坦尼克号只有几百条有效样本,如果让树无限生长,叶子节点最后可能只覆盖两三名乘客,这些叶子本身就是噪音。把min_samples_leaf调到 4 左右,等于强制每片叶子至少有 4 人投票,单棵树的预测就会更稳妥。
max_features是这个项目里容易忽略的关键。随机森林和普通 bagging 的区别就在于每次分裂都随机抽一部分特征。泰坦尼克号特征本来就少,如果每次都只看全部特征,树和树之间会非常像,集成效果就打折。默认的sqrt是个不错的起点,把log2也放进搜索范围不亏。
4.2 先用 OOB 粗筛,别什么都丢给网格搜索
网格搜索看着省心,但参数量稍微一多,计算量就是组合爆炸。所以我的习惯是先手动做一轮粗筛,把明显没希望的参数范围划掉。
比如先用固定n_estimators=300,分别试max_depth为 8、12、16、None,通过 OOB 分数快速比较。这一步只需要几秒钟,能帮我把最终的网格搜索范围缩小一圈。同理可以单独试min_samples_leaf的 2、4、6、8。OOB 在这里最大的价值是“免费评估”,不用额外从训练集里切验证集,粗筛阶段足够用。
4.3 GridSearchCV:完整的网格搜索配置
粗筛后,进入正式搜索。我用 roc_auc 作为评分指标,理由是它比 accuracy 在网格搜索时更稳定。泰坦尼克号的类别不是极端不平衡,如果只看准确率,某些参数组合可能在验证集上刚好跨过 0.5 阈值,分数跳得很厉害;用 AUC 来评估概率排序,搜索过程会更平滑。
from sklearn.model_selection import GridSearchCV param_grid = { 'n_estimators': [200, 300], 'max_depth': [8, 12, 16], 'min_samples_split': [4, 8], 'min_samples_leaf': [2, 4], 'max_features': ['sqrt', 'log2'] } grid = GridSearchCV( RandomForestClassifier(random_state=42, oob_score=True, n_jobs=-1), param_grid=param_grid, cv=5, scoring='roc_auc', n_jobs=-1, verbose=1 ) grid.fit(X_train, y_train) print("最佳参数:", grid.best_params_) print("最佳得分(5折AUC):", grid.best_score_)这个搜索空间是 2×3×2×2×2,共 48 组参数组合,每组合做 5 折交叉验证。如果你的电脑有多核,几分钟到十几分钟内基本能跑完。如果时间紧,可以把n_estimators固定成 200,只搜后面四个参数,速度会快很多。
跑完之后,grid.best_estimator_就是交叉验证得分最高的模型。网格搜索内部已经用refit=True在完整训练集上重新训练了这份最佳参数组合,所以你不需要再手动 fit 一次。
4.4 调优结果怎么读:提升 1-3 个点都很正常
我用一组典型结果说明问题。假设搜索完得到的最佳参数是:
best_params_: {'max_depth': 12, 'max_features': 'sqrt', 'min_samples_leaf': 4, 'min_samples_split': 4, 'n_estimators': 300} best_score_: 0.846注意这个best_score_是交叉验证的平均 AUC,不是测试集准确率。用它做最终模型,在测试集上再看一遍:
best_rf = grid.best_estimator_ y_pred_best = best_rf.predict(X_test) y_proba_best = best_rf.predict_proba(X_test)[:, 1] print("调优后测试集准确率:", accuracy_score(y_test, y_pred_best)) print("调优后测试集AUC:", roc_auc_score(y_test, y_proba_best)) print("调优后OOB:", best_rf.oob_score_)默认参数和调优后的对比可以参考这个表格:
| 模型 | 测试集准确率 | 测试集AUC | OOB score |
|---|---|---|---|
| 默认参数 | 0.807 | 0.845 | 0.801 |
| 网格搜索调优 | 0.826 | 0.877 | 0.820 |
这不是一个“调参封神”的故事。准确率涨了约 2 个百分点,AUC 涨了约 3 个百分点,这是小样本数据上调参的正常回报。如果你投入很多时间把准确率从 0.826 练到 0.83,实际意义不大——那点差异完全可能在换一个随机种子后反转。
5. 调参后值得较真的三件事:数据泄漏、随机波动与可复现性
5.1 数据泄漏的隐蔽来源:你可能在不知不觉中“看了答案”
数据泄漏是机器学习项目里最阴险的问题,因为它会给你一个虚高的分数,你还会高兴好几天。在泰坦尼克号这个项目里,常见的泄漏来源有三个:
第一,用全量数据计算缺失值统计量。比如我前面用全量数据的 Age 中位数来填充训练集和测试集,严格来讲这使用了测试集信息。在 891 条数据上影响很小,但如果换成更大的项目,这个坏习惯会让你在线上部署时翻车。正确的做法是先 split,然后只在训练集上 fit 填充器,再 transform 测试集。
第二,在特征工程中使用了标签信息。比如你先统计存活者的平均年龄,再据此把年龄切成“有利区间”和“不利区间”,这就等于把答案喂给了模型。泰坦尼克号新手最容易犯这个错误,尤其是做 Age 分箱和 Fare 分箱时。
第三,反复在测试集上验证调参。网格搜索在训练集内部做交叉验证,这个没问题;但你每次调参都到测试集上看分数,并根据这个分数继续调,测试集就退化成第二个训练集了。此时测试集分数会被“污染”,最终模型的真实泛化能力会低于你的预期。这也是为什么我建议:网格搜索用训练集内部验证,测试集只在最后看一次。
5.2 随机波动:为什么“分数涨了 0.01”不值得庆祝
泰坦尼克号的样本量决定了任何分数差异都带着很大的随机性。你换一个random_state,比如从 42 换成 2024,重新划分训练测试集后,默认模型的准确率可能在 0.804 到 0.819 之间波动。调优模型也可能从 0.826 掉到 0.812。
这不是代码出问题了,而是小样本模型的正常现象。所以我评估模型的顺序是:OOB 和交叉验证的均值排第一,标准差排第二,单次测试集准确率排第三。如果你发现某个参数组合的 5 折 CV 均值比另一个高,但标准差也大很多,我会选择那个标准差更小、更稳妥的模型,而不是均值最高的那个。
5.3 固定随机种子与保存模型:确保结果能复现
为了让训练结果可复现,至少要保证两处random_state一致:数据切分时的random_state,以及模型实例的random_state。如果只固定模型,不固定切分,每次跑出来的分数肯定不一样;如果两个都固定,理论上同一套代码、同一份数据会得到同一套结果。
保存模型时也要保存特征列名,这个细节经常被忽略:
import joblib model_pack = { 'model': best_rf, 'columns': X_train.columns.tolist() } joblib.dump(model_pack, 'titanic_rf_model.pkl')以后做预测时,先把新数据整理成同样的列,再按保存的columns顺序对齐,最后model.predict_proba()。列名顺序不一致是 sklearn 预测阶段最常见的报错原因之一,尤其是你手动做了get_dummies之后,列顺序很容易漂移。
5.4 我现在的调参习惯:网格搜索只是打开视野,不是终点
这些坑踩过几轮之后,我现在跑随机森林项目的流程基本固定了:先默认参数跑通管线,用 OOB 做粗筛,再缩小范围做一轮网格搜索,最后回到 5 折交叉验证上比较均值和标准差。如果两个候选模型分数差不多,我会选更简单的那个——更浅的树、更大的min_samples_leaf,因为小数据上简单模型更扛得住分布变化。
最后分享一个小技巧:如果你在泰坦尼克号上已经拿到 0.82 左右的准确率,再花时间调参不如回头做特征。用官方 CSV 提取 Title、把 Fare 做成基于舱位的相对价格、对 Cabin 提取甲板前缀,这些操作带来的提升通常比把max_depth从 16 改成 14 更明显。随机森林在这个项目上已经很强了,剩下的空间不在模型的旋钮里,而在你对业务特征的理解里。