ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

泰坦尼克号生存预测实战:特征工程、交叉验证与模型调参全记录

泰坦尼克号生存预测实战:特征工程、交叉验证与模型调参全记录

Kaggle上的泰坦尼克号生存预测,几乎每个学机器学习的人都会碰一次。这项目从2012年上线到现在,一直是很多人提交的第一个Kaggle内核,也是很多课程里拿来演示分类问题的固定案例。我把它作为自己的第02个完整实验来跑,目的倒不是为了刷一个多高的分,而是想完整走一遍从原始CSV到最终预测结果的全流程:特征工程、交叉验证、调参、提交。这中间有几个地方,表面看很简单,实际动手做才会发现坑比想象中多。

这个项目我用的数据就是Kaggle官方给的train.csv和test.csv,训练集891条,测试集418条,字段包括乘客ID、舱位等级Pclass、姓名Name、性别Sex、年龄Age、同乘兄弟姐妹/配偶数SibSp、同乘父母/子女数Parch、票号Ticket、票价Fare、客舱号Cabin、登船港口Embarked,目标列是Survived。整个任务就是根据这些信息判断乘客是否生还。今天这篇记录,就按我实际操作的顺序来还原整个实验过程,包括每一处改动的理由、每个参数选择的依据,以及踩过的一些坑。

1. 项目整体设计与思路拆解

1.1 为什么这个项目值得认真做一遍

泰坦尼克号这个题,从模型角度说并不是什么高难度挑战,它没有特别复杂的高维数据,也没有图像文本这种非结构化输入,纯粹就是一张二维表格。但恰恰是这种“看起来简单”的数据集,最适合用来检验你对机器学习流程的掌握程度。

我见过不少人跑这个项目,上来就调库,读数据,喂给随机森林,提交,分数0.75左右,结束。这样做当然也算学会了调包,但没太大意义。这个项目的价值在于:891条训练数据里,真正决定生死的特征隐藏在一些需要自己加工的字段中。比如乘客姓名里的头衔Title,船票号码Ticket,客舱号Cabin,这些字段原始状态下没法直接进模型,但经过处理后往往比原始数值特征更有区分度。换句话说,这个项目真正考察的是特征工程能力,而不是模型复杂度。

从我个人的实践经验来看,这个项目最适合三类人:刚学完Python和pandas、想练手数据处理的初学者;已经会建模但想系统梳理特征工程方法的中级学习者;以及准备参加更复杂Kaggle比赛、想拿这个简单项目练熟交叉验证和提交流程的人。

1.2 数据核心逻辑:哪些信息真正影响生存率

在动手写代码之前,我先对数据做了一次整体审视。这里有个关键经验:不要一上来就急着处理缺失值,先搞清楚每列的本质含义,以及它和目标之间的关系。我先把训练集里最重要的几个字段做了分布对比,很快就能看到几个非常明显的规律。

性别是决定性因素:女性存活率明显偏高,这符合泰坦尼克号“妇孺优先”的撤离原则。舱位等级Pclass同样影响显著,头等舱存活比例远高于三等舱,这背后对应的是船舱位置离救生艇甲板的距离,以及疏散优先级。年龄和票价也有影响,但不像前两者那么直观,需要进一步分箱观察。

这里有一个很容易被忽略的点:很多人在分析数据时只关注单个字段,但有些信息是藏在多个字段组合里的。比如SibSp和Parch这两个字段,单看都不算强特征,但如果合成一个“家庭成员数量FamilySize”,就能反映出“一个人独行”还是“和家人一起”的状态。在真实海难中,独行乘客往往更难被统计到、更容易被遗漏,这一点在数据里也确实有体现。这些组合特征的构造,才是这个项目能拉开分数差距的核心所在。

2. 数据清洗与特征工程实操

2.1 缺失值处理的几个选择及理由

训练集里最明显的缺失项是Age、Cabin和Embarked。处理方式我逐个说明,每个都记录了当时的考虑。

Age字段分布比较集中,缺失比例约为19.8%,不算特别严重,但直接删掉显然浪费信息。常见的做法有三种:用全局中位数填充、用均值填充、按其他维度分组后填充。我测试下来,效果最好的是按乘客头衔Title分组填充。原因是不同头衔对应的人群年龄区间差别很大,Master通常是男孩,Mr通常是成年男性,Miss和Mrs分别是未婚和已婚女性,用全局中位数会把不同群体的年龄特征抹平。我实际操作时用了groupby('Title')['Age'].transform('median'),这种方法既简单又合理,避免引入额外复杂度。

Cabin缺失量很大,训练集里超过77%的乘客没有客舱号。这种情况下直接用中位数或众数填充都不可取,因为“缺失”本身就是信息。我在实验里把Cabin处理成一个二元特征Has_Cabin,表示是否有客舱记录,同时保留了首个字母代表的舱区信息。这个思路的出发点是:有客舱记录通常意味着乘客的舱位信息被完整登记,这和乘客在船上的位置以及疏散过程中的状态可能有关系。

Embarked只有2个缺失值,用众数S填充就够了,这里不需要过度设计。

补充一个我在第一次做这个项目时踩过的坑:最开始我直接把Age填充为全数据的中位数,结果模型的交叉验证分数反而有所下降。原因并不难理解,891条数据里,缺失的年龄如果全填同一个值,等于人为制造了一个集中的假象,模型会倾向于认为这个值附近有大量样本,进而扭曲对年龄这个特征的判断。后来改成按Title分组填充,交叉验证分数有了小幅但稳定的提升。

2.2 从姓名、票号和客舱里挖出的有效特征

这是整个实验里我认为最值得详细记录的一部分,也是从“只会调包”到“会做特征”的分水岭。

先说Name字段。单纯看字符串没什么用,但可以从里面提取头衔Title。我用正则表达式从逗号后、句点前的位置提取了乘客的头衔,然后做了一个映射,把少数罕见头衔归类到常见类别里。提取后统计可以发现,Mr、Mrs、Miss、Master占了绝大多数,还有少量Don、Lady、Sir这类非常见头衔,样本极少,我直接归入Rare类别。得到的Title特征和生还率有非常明显的相关:Mrs和Miss存活率较高,Mr的存活率明显偏低,Master作为儿童头衔存活率也比较高。这个特征在后续模型中的重要性排名稳居前几位。

再说Ticket字段。这是很多人忽略的地方。表面看票号是一堆乱码,但有一个很重要的信息:同一票号往往代表同一组同行乘客。这类信息无法从SibSp和Parch中完全看出,因为朋友、同事、随行人员不一定在亲属关系字段里体现。我把票号里非数字部分清洗掉,留下纯数字部分,然后统计每个票号出现的次数,构造出TicketFrequency特征。这里有个很直接的逻辑:如果同票号的乘客人数多,说明这批人可能是团体出行,团体成员之间在疏散时更容易互相通知和协助。

最后是Cabin字段的进一步处理。除了前面说的Has_Cabin之外,我还提取了客舱区字母作为特征。泰坦尼克号的客舱编号首位字母对应不同甲板层,这个字母能在一定程度上反映乘客在船上的活动区域。我把这些字母直接作为类别特征使用,配合Pclass和Fare,比单纯用Pclass单一特征更能描绘乘客的船舱位置。

2.3 数值特征的分布处理与编码方式

Age和Fare这两个连续数值特征,原始值分布都偏斜。Age相对好一些,但Fare的分布很极端,少数高额票价把数据拉得很开,直接喂给线性模型会导致数值范围差异过大。我做了两种处理方式对比:一种是对Fare取对数log1p,另一种是分箱成类别特征。两种都能提升模型稳定性,最终我选择保留对数变换后的连续特征,因为分箱会丢失区间内的细微差异。

还有一个很多教程里不会提的问题:Pclass虽然数值是1、2、3,但它是有序类别变量,用数值直接进树模型没问题,但如果做线性模型,需要one-hot编码。我在实验里统一把它当成类别特征做编码处理,避免后续更换模型时还要回改。

Sex这个字段最简单,直接映射成0和1。Embarked同理做类别编码。这里我记录一个细节:类别编码方式的选择会影响模型表现,对于树模型来说标签编码和one-hot都可以,对于线性模型则倾向one-hot。我在最终管道里用了one-hot编码,因为后续基线模型中包含逻辑回归,这样能在不同模型间保持一致的分值对比口径。

还有一个值得记录的特征:把SibSp和Parch合并成FamilySize之后,我还进一步构造了IsAlone这个二值特征,判断乘客是否一个人出行。单独一个FamilySize也能用,但IsAlone把问题转化为“是否独行”,在实际预测中更直观。从数据统计结果来看,独行乘客的存活率低于结伴出行的乘客,这个差值在训练集里是真实存在的。

3. 模型选型与参数调优实录

3.1 基线模型的建立与复盘

我的习惯是:任何项目先跑通一个最简单的基线,用这个分数作为后续所有优化的基准参照。对这个数据集,我先用逻辑回归做了基线模型,只喂原始数值特征,不做任何特征工程,得到的交叉验证准确率大约是0.78。这个分数可以作为一个基准锚点。

然后逐步加上特征工程结果,同样是逻辑回归,交叉验证分数提升到0.80附近。这个提升幅度看起来不大,但考虑到逻辑回归对特征表达的要求比较高,特征工程的价值已经体现出来了。之后换成随机森林和梯度提升树,分数还会有进一步提升。

这里有个非常重要的原则:不要一上来就堆模型复杂度。先用简单的线性模型跑通整个流程,再用树模型做对照,这样你能分别看出特征工程和模型选择各自贡献了多少提升。如果直接上XGBoost,你会得到一个看似不错的分数,但你根本不知道这个分数里哪些来自特征工程,哪些来自模型本身的拟合能力。

3.2 交叉验证为何选择5折与分层抽样

泰坦尼克号数据集样本量很小,只有891条训练数据。如果只用一次train_test_split来评估模型,结果会非常不稳定,因为划分方式不同,分数波动可以达到0.02甚至更多。所以我采用K折交叉验证,这里我用的是5折。

为什么选5折而不是10折?样本量本身不大,如果做10折,每折只有89条验证数据,验证集上的指标方差会偏大。5折时每折约178条,样本量相对够用。同时5折的计算成本低,便于在调参过程中反复运行,节省了很多尝试时间。

交叉验证时我还做了分层抽样。由于训练集中死亡人数多于生存人数,比例大约是62%对38%,如果不做分层,某些折里可能出现生存样本过少的情况,导致评估结果出现不必要的偏差。用StratifiedKFold可以保持每一折中Survived的正负比例与全量数据一致,这样每个折的评估都是相对公平的。

这一段的经验直接来自于一次对比实验:我用普通K折跑出的分数波动接近0.025,换成StratifiedKFold之后,波动缩小到0.01以内。在小样本数据集上,这一点差别非常影响你判断模型改进是否有效。

3.3 调参思路:从网格搜索到针对性微调

网格搜索我实际用下来,效率只能说一般。4个超参组合出几百种情况,在小数据集上跑还能接受,但时间成本高,而且很多搜索结果在交叉验证分数上的差别其实在误差范围内。我的做法是先用粗网格定范围,再手工微调关键参数。

以随机森林为例,我先把n_estimators固定在300,搜索max_depth和min_samples_split的合理范围,锁定到某个区间后,再单独看max_features。等到各项参数都大致确定了,最后一次微调n_estimators。这种做法的思路是:随机森林的n_estimators只要足够大,模型表现就会趋于稳定,不需要花太多时间在上面精细搜索,真正影响大的是树的深度和分裂条件。

梯度提升类模型我同时测了XGBoost和LightGBM,在小样本上两者差距不大。我最终选择保留XGBoost作为主模型之一,和随机森林做集成投票。这里有个记录:在泰坦尼克这个数据集上,集成两个模型比单模型能稳定提升0.005到0.01,幅度不大,但对于追求排名的人来说已经值得保留。

最终模型我用的是RandomForest和XGBoost的软投票集成,逻辑回归作为对照组。软投票的含义是取每个模型预测的概率均值作为最终判断依据,而不是简单按投票数——这在概率校准较好时通常效果更好。用5折交叉验证评估,本地最终分数稳定在0.815左右。

3.4 特征重要性排序的解读

模型训练完之后,我导出了随机森林的特征重要性。排名靠前的是Sex、Title、Fare、Age、Pclass,这些和探索性分析的结论是一致的。值得注意的是,Title的特征重要性高于Pclass,这说明从Name里提取的头衔确实比原始舱位信息包含更多判别力。

很多初学者容易忽略一个现象:特征重要性高的特征不等于因果性强的特征。比如Title这个特征,在泰坦尼克数据集里它隐含了性别、年龄和婚姻状况的综合信息,所以它重要性高是合理的。但在做业务决策时,不能直接认为“头衔决定了生存”,这个逻辑要拎清楚。特征重要性只是给我们一个加工线索,提示哪些原始信息值得保留,而不是理解现实的依据。

4. 常见问题与坑点排查实录

4.1 过拟合的典型表现与应对手段

小数据集最容易犯的错就是过拟合。我在实验中观察到一个很典型的现象:当我把特征数量加得比较多,比如把Ticket处理成几百个独立编号的类别特征时,训练集上的准确率可以到0.95以上,但交叉验证分数反而掉到0.78以下。这是因为模型把训练集里的个案规律记住了,而不是学会了一般模式。

解决方式有三个:一是减少特征维度,去掉区分度过低的特征;二是限制树模型的深度,比如把max_depth限制在5以内;三是加强交叉验证的纪律,每次特征改动后用同一组折来评估,保证分数可比。我最终保留的特征数量是12个左右,这个维度对891条样本来说比较安全,既能表达信息,又不至于引入过多噪声。

4.2 提交格式与预测概率的坑

这是我第一次提交时踩过最不值得的坑。Kaggle要求提交的CSV文件只有PassengerId和Survived两列,且Survived必须是整数0或1。我当时直接用model.predict_proba(x_test)[:, 1]的结果写进CSV,结果生成的是连续概率值,提交后直接报错。

正确做法是将概率大于等于0.5的映射为1,其余为0。这里要提一点:0.5作为阈值并不总是最优的。我从交叉验证的ROC曲线上观察,最佳阈值大约在0.48左右,但差距很小,最终我还是用0.5作为阈值,方便复现和解释。如果想把阈值调整得更细,其实需要更多数据支撑,在这个数据集上去做微调,收益有限。

4.3 本地交叉验证分数与公共排行榜分数不一致的原因

这是一个非常多人会遇到的现象:本地交叉验证分数0.815,提交到Kaggle公共榜后分数可能是0.79,也可能更高到0.83,差别是正常的。原因是本地交叉验证只是用训练集的一部分来做评估,而公共榜分数是在真正的测试集上算出来的,两者的数据分布有一定差异,样本量又小,分数波动天然存在。

我的做法是:本地分数的主要目的是帮助对比不同特征和不同参数的相对优劣,而不是预测公共榜的绝对分数。只要某个改动在本地能稳定提升交叉验证分数,就值得保留;如果本地分数没变,即使公共榜分数波动,也不应该当作有效提升。

4.4 避免数据泄漏的几个禁区

这个话题值得单独说。数据泄漏的含义是在训练阶段使用了本不该出现的信息,在泰坦尼克这种小数据集上尤其容易犯这个错。

最典型的例子是用全部数据(包括test.csv)的分布去做预处理参数拟合。比如你用全量数据的Age中位数去做填充,那其实已经让测试集的信息渗透进了训练过程。正确的姿势是:对训练集做fit,再用训练集得到的参数去transform测试集。具体到实现上,就是先用训练集的Age中位数填充训练集缺失值,再用同一个值填充测试集缺失值。

另一个容易被忽略的泄漏源是在做交叉验证时,特征工程过程放错了位置。比如你先对全部训练数据做了Fare分箱或标准化,再做5折交叉验证,这其实已经有轻微泄漏了,因为每一折的验证数据在特征工程阶段已经参与过统计。正确做法是把特征工程放进交叉验证的每一折里,训练时只基于训练折的统计量,再变换验证折。这个方法在sklearn里可以通过Pipeline实现,也是我在这版代码里最终采用的结构。

5. 实验总结与几点个人经验

这个项目做完后,我最大的感受是:决定模型上限的不是模型本身,而是你对数据里每个字段背后信息的理解程度。泰坦尼克号的891条训练数据并不算多,但通过合理的特征加工,可以让一个普通的树模型达到0.80以上的本地准确率,而如果只是为了跑通流程,直接把所有原始字段塞进模型,可能只能停在0.75左右。

最后记录几条我后续再做类似比赛时会继续使用的经验。

第一,建模之前先花充足时间看数据分布,不要跳步。很多有效的特征工程思路来源于对字段的深入观察,比如从票号里找同组人这一招,如果不对Ticket做逐条排查,很难想到。第二,交叉验证的分层和固定随机种子必须从一开始就设置好,否则每次实验的分数都没有可比性,你根本没法判断某个改动是否有效。

第三,不要迷信公共排行榜分数。以Kaggle这个项目为例,公共榜的418条测试数据占约50%的评估权重,但由于样本量小,本地CV和公共榜分数之间本来就有波动。专注于重复可验证的本地评估,比盯着公共榜的一两分之差更有意义。第四,如果你准备用集成方法,一定要确认各个基模型的错误模式有一定差异性,否则集成带来的提升很不稳定——这个在泰坦尼克实验里我用随机森林和XGBoost得到的效果就是要好于随机森林和额外随机树的组合。

这个项目本身虽然已经很老了,但它作为机器学习的入门流程,完全不过时。后面如果有时间,我计划在这个基底上再尝试一下特征选择的方法对比,以及概率校准对提交结果的影响。每次做这个题都能发现新的可以改进的角落,这也是经典项目有意思的地方。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进