一提到Kaggle入门,十个有九个都会推荐泰坦尼克号生还预测。这个项目火得有点过分,但说实话,大部分教程都停留在“跑通一份代码”的程度,很少有人把每一步背后的为什么讲清楚。这篇教程是“边境的悍匪”系列的第一篇实战内容,我会按照自己日常做数据建模的习惯,把整个流程从读题到提交拆给你看,数据怎么摸、特征怎么做、模型怎么调、提交怎么避坑,一次说透。
泰坦尼克号生还预测不是什么高深算法秀,它的数据量很小,字段也干净,但恰恰因为小,特别适合用来建立一套完整的模型思维。你学完这个项目,再看其他结构化数据比赛,套路都是一样的:理解数据、清洗数据、造特征、交叉验证、模型融合、提交结果。这篇文章适合三类人:刚接触机器学习的小白、准备打第一场Kaggle但不知道从哪下手的同学,以及已经跑过代码但分数卡在0.75左右想提升的老手。
1. 项目背景与整体思路拆解
1.1 为什么泰坦尼克号是Kaggle第一课
很多人以为这个项目简单,因为网上的高分代码一抓一大把,抄一抄就能到0.79。但简单的是数据,不简单的是这个项目强迫你去处理几个非常典型的现实问题:缺失值、文本特征、标签不平衡、提交文件格式。
泰坦尼克号数据集给的信息很有限,只有PassengerId、Survived、Pclass、Name、Sex、Age、SibSp、Parch、Ticket、Fare、Cabin、Embarked这12个字段。其中Survived是标签,其余是特征。你要根据这些有限信息预测乘客是否生还。
为什么说它是Kaggle第一课?因为这个项目几乎覆盖了机器学习建模全流程,而且每个环节都有足够的细节让你踩坑:
- 数据不是干净的,Age有缺失、Cabin有大量缺失、Embarked有少量缺失,你必须自己决定怎么补。
- 特征是混合类型的,有数值型、有分类型、有文本型,你得学会把不同形态的信息统一成模型能吃的数值。
- 标签是二分类,评估指标是准确率,看起来简单,但类别不均衡(生还者约38%)会直接影响模型表现。
- 提交要求是生成一个只有PassengerId和Survived两列的CSV,很多人折腾半天模型,最后挂在文件格式上。
这个项目不是让你拿第一名的,而是让你跑通“数据-特征-模型-提交”这条链路的每一环。真正吃透它之后,你再去参加房价预测、交易欺诈检测,会发现核心流程基本没变。
1.2 完整分析流程与工具选型
我自己的分析流程是固定的:数据探索、数据清洗、特征工程、建模验证、融合提交。这个流程不仅适用于泰坦尼克号,我后来做的所有表格类比赛都是这个套路。
工具方面,我推荐直接用Python,配合pandas、numpy、matplotlib、seaborn、scikit-learn。别一上来就上深度学习,这个项目树模型和逻辑回归就足够了。
环境不用太复杂,我建议在本地跑一个Jupyter Notebook,或者用VS Code。数据量只有几百行,不需要分布式,不需要GPU。这也是为什么它适合入门——配置环境几乎零门槛。
命令行里装依赖,我习惯用conda:
conda create -n titanic python=3.10 conda activate titanic conda install pandas numpy matplotlib seaborn scikit-learn如果你在云环境里跑,比如Colab,直接pip install也行。确认环境没问题后,第一步就是读数据。
import pandas as pd train = pd.read_csv('train.csv') test = pd.read_csv('test.csv') print(train.shape, test.shape) print(train.head())这里有个细节:train.csv其实是一个完整的训练集,test.csv是官方去掉标签的测试集,你需要提交test.csv里每个乘客的Survived预测值。train.shape一般是(891, 12),test.shape是(418, 11),少的那一列就是Survived。
看清这个结构之后,再明确任务:用train里已知的生还情况训练模型,然后对test里的418人做预测,最后生成一个CSV上传。
1.3 先定评估标准,别急着跑模型
很多人上来就fit,然后看一波accuracy,这是最典型的初学者动作。我建议你先想清楚:这个比赛的评估标准是什么?
Kaggle泰坦尼克号用的评估标准是Accuracy,也就是预测正确的比例。在这个数据集里,如果无脑预测全部死亡,准确率大概是0.618(因为死亡者占61.8%)。你的模型必须超过这个值才有意义。
但Accuracy也有陷阱。泰坦尼克号生还比例大约是38.4%,属于轻度不平衡。如果你纯粹追求Accuracy,模型可能会倾向于预测多数类,导致生还者召回率很低。这个时候,你要学会同时看混淆矩阵、precision、recall、F1。虽然比赛只看Accuracy,但在建模过程中多用几个指标做参考,能帮你发现模型是不是偏科。
下面这段代码可以把训练集的基本情况摸清楚:
print(train.info()) print(train.describe()) print(train['Survived'].value_counts(normalize=True))train.info()能告诉你每个字段有几列非空、数据类型是什么。你会立刻发现Age和Cabin有缺失,Cabin缺失特别严重。train.describe()给出数值列的统计分布,Fare的均值和中位数差很多,说明有极端值,Age在0到80之间分布。这一眼扫过去,后面该做什么心里就有数了。
2. 数据摸底与探索性分析
2.1 字段逐个看,先理解业务再动刀
泰坦尼克号数据不是一堆干巴巴的数字,每个字段背后都有实际含义。建模前建议先理解每个字段代表什么,否则很容易做出反常识的特征。
我习惯把字段分成三类:
| 字段 | 类型 | 含义 | 建模价值 |
|---|---|---|---|
| PassengerId | ID | 乘客编号 | 无意义,只能当索引 |
| Pclass | 类别 | 船舱等级(1/2/3) | 高价值,等级越高离逃生通道越近 |
| Name | 文本 | 乘客姓名 | 需要解析,可能包含家族信息和称呼 |
| Sex | 类别 | 性别 | 极高价值,女性优先逃生 |
| Age | 数值 | 年龄 | 高价值,儿童优先逃生 |
| SibSp | 数值 | 同行的兄弟姐妹/配偶数量 | 中价值,家庭结构影响逃生 |
| Parch | 数值 | 同行的父母/子女数量 | 中价值,同上 |
| Ticket | 文本 | 船票号码 | 可能有共享票号信息 |
| Fare | 数值 | 票价 | 中价值,与舱位等级相关 |
| Cabin | 文本 | 船舱编号 | 缺失较多,但可提取“是否有舱位” |
| Embarked | 类别 | 登船港口(C/Q/S) | 低价值,但能补信息 |
实际观察中,Sex是最强的单一特征,Pclass也很强,Age配合性别、舱位会产生高价值交叉特征。Name里藏着Mr、Mrs、Miss、Master这类称呼,可以提取出来当作新特征。Ticket和Cabin在传统教程里经常被忽略,但如果你往深层挖掘,共享票号和船舱区域也能提供信号。
不要沉迷于把所有字段都塞进模型。特征是拿来用的,不是拿来堆的。先逐个理解,再决定取舍。
2.2 用图表和统计验证直觉
数据探索不只是画几张图,更重要的是验证你的假设。比如“女性更容易生还”这句话,你得用数据说话。
我一般先画一个总览:
import seaborn as sns import matplotlib.pyplot as plt fig, axes = plt.subplots(2, 2, figsize=(12, 10)) sns.countplot(x='Sex', hue='Survived', data=train, ax=axes[0][0]) sns.countplot(x='Pclass', hue='Survived', data=train, ax=axes[0][1]) sns.histplot(x='Age', hue='Survived', data=train, ax=axes[1][0]) sns.countplot(x='Embarked', hue='Survived', data=train, ax=axes[1][1]) plt.tight_layout() plt.show()画完之后,你会很直观地看到几个现象:
- 男性死亡人数远高于女性,女性生还比例高很多。
- 头等舱生还比例明显高于三等舱,Pclass=1的人群生还曲线远高于Pclass=3。
- 年龄分布里,儿童(0-10岁)有一波生还高峰,这符合“妇女儿童优先”的规则。
- Embarked三个港口生还比例差异不大,但要结合舱位结构才能看出信号。
再做数值验证。把Sex、Pclass和Survived做交叉表,计算每组生还率。比如:
pd.crosstab(train['Sex'], train['Survived'], normalize='index')你会看到Female的生还率大约0.74,Male只有0.19。再看Pclass和Survived的组合,Pclass=1生还率约0.63,Pclass=3只有0.24。这些数字足以支撑你后面的特征选择。
再画一张Fare分布图,你会发现三等舱票价集中在很低的区间,头等舱则方差很大,票价最大能到500多。Fare这个字段和Pclass高度相关,建模时可以一起用,但如果做过拟合,可以考虑只保留一个或者提取交叉特征。
2.3 缺失值分布决定清洗策略
探索阶段有一件事特别重要:统计缺失值。
print(train.isnull().sum()) print(test.isnull().sum())你需要注意train和test都要统计,因为训练时要用test的数据分布来补缺失,如果只顾train,最后预测时可能连列都对齐不了。
泰坦尼克号数据里,缺失通常集中在Age、Cabin、Embarked这几个字段,test里Fare偶尔会缺一条。Age缺失约20%,Cabin缺失约77%,Embarked缺失只有2个。Cabin缺失太严重,你很难补出真实值,更适合拆成“是否有Cabin记录”这类特征。Age缺失不算少,需要认真补。Embarked只有两行缺失,可以用众数填。
缺失值处理没有统一答案,取决于字段含义和缺失比例。我的原则是:缺失比例低于5%的字段可以直接众数填充;缺失比例在20%-40%的字段需要结合其他字段预测填充;缺失比例超过60%的字段通常不适合直接填充原值,更适合变造为标志位。Cabin显然属于第三种。Age属于第二种。
这个判断过程本身就是建模能力的一部分。你应该每次遇到一个新数据集,都先花时间统计缺失,再制定策略,而不是无脑fillna。
3. 特征工程与数据预处理
3.1 Age缺失,不要无脑填均值
Age缺失是泰坦尼克号最经典的坑之一。你搜索网上的方案,有填均值的、有填中位数的、有随机采样的,还有用Title和Pclass分组填充的。我的建议是:不要用全局均值,因为不同舱位、不同称呼的人群年龄分布差异很大。
举个例子,Mr(成年男性)的年龄中位数和Master(小男孩)的年龄中位数完全不同。如果你用全局均值30岁去填充Master,等于把儿童特征抹掉了,模型会丢失很关键的信息。正确做法是分层填充。
先提取Name里的称呼:
train['Title'] = train['Name'].str.extract(r' ([A-Za-z]+)\.', expand=False) test['Title'] = test['Name'].str.extract(r' ([A-Za-z]+)\.', expand=False)然后看Title分布。常见的称呼有Mr、Mrs、Miss、Master,还有一些稀有的称呼,比如Dr、Rev、Countess等。稀有称呼数量很少,可以归为其他。把Mr、Mrs、Miss、Master这四大类单独保留,再做分组填充:
train['Age'] = train.groupby('Title')['Age'].transform(lambda x: x.fillna(x.median())) test['Age'] = test.groupby('Title')['Age'].transform(lambda x: x.fillna(x.median()))这样补出来的Age更接近真实分布。这一步做完,模型对儿童识别的能力会明显提升。
我还可以再进一步:把Age离散化成年龄段,比如0-10、11-20、21-30、31-40、41-50、51-60、60以上,或者保留连续值。连续值对树模型更友好,离散化对逻辑回归更友好。我的习惯是连续值和离散化都做,然后让模型自己选。
3.2 Cabin字段,拆成多个“有/无”特征
Cabin缺失太严重,直接填值没什么意义。但直接删掉也可惜,因为船舱编号其实能反映乘客在船上的位置,不同位置逃生难度不一样。
我常用的做法是把这个字段拆成多个二元特征:
- Cabin是否缺失(CabinKnown:0或1)
- Cabin的首字母代表船只区域(A、B、C、D、E、F、G)
先提取首字母:
train['CabinLetter'] = train['Cabin'].apply(lambda x: str(x)[0] if pd.notnull(x) else None)然后把CabinLetter变成类别变量。缺失的Cabin那就是None,如果直接当作一档也可以,但我更倾向于把“是否缺失”单独拎出来作为一个标志位。因为在这个数据集里,Cabin缺失可能意味着“没有分配到固定客舱”,这种信息本身就具有预测价值。
船票号码Ticket也可以提取一些信息,最典型的是“是否有共享票号”。同一个家庭或团体往往拥有相同的Ticket编号,共享票号的人数可以作为新特征。
train['TicketGroupSize'] = train.groupby('Ticket')['Ticket'].transform('count')如果TicketGroupSize大于1,说明这个乘客是跟别人一起订票的,背后可能代表家庭或团队,逃生时行动会相互影响。这个特征在部分模型里有微弱提升,可以做,也可以不做。
3.3 从Name里挖Title,从家庭关系里挖FamilySize
Name看起来没用,但它是这个数据集里最被低估的特征。通过正则提取称呼后,Title能反映一个人的社会身份和年龄。比如Miss可能是有孩子的已婚女性?其实Miss一般指未婚女性,Mrs指已婚女性,Master指少年男性。这些称呼和Age、Sex组合,能产生很强的信号。
我建议把稀有Title合并为“Other”,避免类别过多导致过拟合。然后对Title做独热编码。
家庭关系方面,SibSp和Parch是分开的,可以合并成一个FamilySize:
train['FamilySize'] = train['SibSp'] + train['Parch'] + 1FamilySize等于1表示单独出行,等于2或3表示小家庭,大于3可能是大家庭。单独出行和小家庭在逃生时的表现不同。数据分析会发现,FamilySize为2到4的乘客生还比例较高,FamilySize过大或等于1的乘客生还比例偏低。这个特征在树模型里非常稳定。
也可以做一个IsAlone标记:
train['IsAlone'] = (train['FamilySize'] == 1).astype(int)3.4 用Pclass和Fare做交叉,让模型区分更细
Pclass是离散类别,Fare是连续数值,两者高度相关,但又不是完全相等。你可以在建模时同时给模型,也可以构造一个“标准化票价”特征,按Pclass分组计算票价占比,看看哪些乘客的票价在该舱位里异常高或异常低。
具体做法是:
train['FarePerPerson'] = train['Fare'] / train['FamilySize'] if train['FamilySize'].all() != 0 else train['Fare']等等,如果FamilySize等于0,这个除法会出问题,所以实际计算时要先加1。这个特征的含义是“每个人平均付了多少钱”,比总票价更有解释力。因为在共享票号的家庭中,总票价是几个人一起买的,平均票价更能反映消费水平。
另外可以按Pclass分组对Fare做标准化或者分桶。比如把Fare切成5等份,看生还率差异。树模型对分桶不敏感,但逻辑回归对连续尺度敏感,适当归一化会有帮助。这里要记住:不是所有特征都要做标准化,树模型根本不在乎数值尺度。
4. 模型训练与参数调优
4.1 先做一个逻辑回归基线
很多人一上来就随机森林,恨不得直接XGBoost。我的建议是先做逻辑回归,因为它简单、稳定,能帮你验证特征工程有没有做错。
建模之前,需要把数据进行编码。先把类别字段变成数值,最通用的方式是pandas的get_dummies:
train = pd.get_dummies(train, columns=['Sex', 'Embarked', 'Title'], drop_first=False)Sex男性女性是两个类别,get_dummies后会变成两列。这里不需要drop_first,因为树模型可以处理多重共线性,逻辑回归可能需要避免,但这个小数据集影响不大。如果不喜欢两列都留,也可以用replace把Sex映射成0和1。
确定特征列后,划分训练集和验证集:
from sklearn.model_selection import train_test_split features = ['Pclass', 'Age', 'SibSp', 'Parch', 'Fare', 'FamilySize', 'IsAlone', 'Sex_male', 'Sex_female'] train_model_data = train[features + ['Survived']].dropna() X = train_model_data[features] y = train_model_data['Survived'] X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42)然后训练:
from sklearn.linear_model import LogisticRegression from sklearn.metrics import accuracy_score model = LogisticRegression(max_iter=1000) model.fit(X_train, y_train) y_pred = model.predict(X_val) print(accuracy_score(y_val, y_pred))我一般第一次跑这个大概在0.77到0.80之间。如果你的分数低于0.75,说明特征处理或者数据划分有问题,先回去检查数据。
这里有个细节:验证集的划分方式尽可能固定random_state,这样后续调参比较公平。如果你每次随机划分都不一样,对比模型性能就会失真。
4.2 随机森林与参数网格搜索
逻辑回归只是底线,接下来换随机森林。随机森林对特征交互的捕捉能力更强,尤其适合Pclass、Sex、Title这些类别特征。
先用默认参数跑一版:
from sklearn.ensemble import RandomForestClassifier rf = RandomForestClassifier(n_estimators=100, random_state=42) rf.fit(X_train, y_train) y_pred_rf = rf.predict(X_val) print(accuracy_score(y_val, y_pred_rf))默认参数的随机森林大概在0.78-0.81。然后做GridSearchCV找参数:
from sklearn.model_selection import GridSearchCV param_grid = { 'n_estimators': [100, 200, 400], 'max_depth': [None, 4, 6, 8], 'min_samples_split': [2, 5, 10], 'min_samples_leaf': [1, 2, 4] } grid = GridSearchCV(RandomForestClassifier(random_state=42), param_grid, cv=5, scoring='accuracy', n_jobs=-1) grid.fit(X_train, y_train) print(grid.best_params_) print(grid.best_score_)网格搜索不是越复杂越好。在小数据集上,盲目追求大参数空间容易过拟合。我的经验是:先用粗网格扫一遍,确定比较好的范围,再细调。比如max_depth先试5、6、7,min_samples_leaf先试2、3、4。核心思路是控制模型复杂度,不要让它把训练集背下来。
4.3 用交叉验证评估模型,别只看一次划分
单次划分验证的结果波动很大,你可能换一个random_state就涨跌3个百分点。所以更靠谱的做法是使用K折交叉验证。
K折交叉验证把训练集切成K份,每次用K-1份训练,1份验证,循环K次。最终分数是所有折的平均值。这样你评估出的模型能力更接近真实泛化水平。
我习惯把cross_val_score封装成函数,可以快速比较不同特征组合和不同模型:
from sklearn.model_selection import cross_val_score def evaluate_model(model, X, y): scores = cross_val_score(model, X, y, cv=5, scoring='accuracy') print(f"CV score: {scores.mean():.4f} ± {scores.std():.4f}")做特征组合实验时,不要只盯着Accuracy,也看std。如果一个模型均值很高但std很大,说明在某些折上翻车,泛化不稳定。
4.4 模型融合:简单的投票可能比单个模型更稳
模型融合不是高手的特权,最简单的方法是硬投票。把逻辑回归、随机森林、梯度提升树三个模型一起预测,少数服从多数。这样能平滑单个模型的偏差,在小数据集上往往比最好模型单干更稳。
这里要注意:融合的前提是三个模型都有不错的性能,如果有一个模型明显拉胯,它会拖累整体。所以融合前先单独验证每个模型。
也可以做加权投票,比如给随机森林权重高一点。不要迷信融合一定涨分,在数据量小的时候,有时融合效果反而不如单独的最佳模型。我自己常用的做法是:先看交叉验证结果,把明显不好的去掉,再融合。
4.5 如何防止过拟合
泰坦尼克号只有891条训练数据,非常容易过拟合。如果你在训练集上准确率高达0.95,但在验证集只有0.80,那基本就是过拟合了。解决过拟合的常见手段:
- 减少特征数量,去掉那些你也不确定意义的特征。
- 限制树模型深度,比如max_depth=4。
- 增加min_samples_leaf,强制每个叶子至少有几个样本。
- 用交叉验证而不是单次验证来评估。
特征选择上,我一般用随机森林的feature_importances_查看特征重要性,低于某一阈值的特征会果断删除。但这里有个坑:特征重要性和模型的评估指标不是一回事。如果某个特征有业务含义但重要性很低,也别急着删,可以试试不同的特征组合再判断。
5. 提交结果与常见问题
5.1 生成符合Kaggle格式的CSV
模型训练完成后,最后一公里是提交文件。很多人的模型分数正常,但提交后分数低得离谱,大部分原因就是文件格式不对。
Kaggle泰坦尼克号的提交格式是:两列,第一列PassengerId,第二列Survived。Survived只能是0或1。顺序要和test.csv里的PassengerId一一对应。
生成提交文件的代码:
test_model_data = test[features].fillna(0) # 如果还有缺失临时填充 test_pred = best_model.predict(test_model_data) submit = pd.DataFrame({ 'PassengerId': test['PassengerId'], 'Survived': test_pred }) submit.to_csv('submission.csv', index=False)保存前一定要检查:
print(submit.head()) print(submit['Survived'].unique())Survived不能出现小数,不能出现浮点形式的0.0和1.0?其实0.0也能被接受,但最好保证是整数类型。我通常加一行:
submit['Survived'] = submit['Survived'].astype(int)文件大小只有几KB,如果几百KB那肯定有问题。
5.2 同一个模型,为什么排行榜分数和本地验证对不上
这是最让人抓狂的问题:本地交叉验证0.82,提交上去只给0.76。原因有很多,常见的是:
- 你在特征处理时不自觉用了test的信息,导致数据泄漏?在泰坦尼克号这种小数据集,最常见的是用训练集做填充时没有把操作封装成可复用的流程,预测test时用了一些训练集特有统计量,导致分布漂移。
- train和test的某些字段取值不一样,比如Embarked在train里有三个取值,test里可能出现两个。如果编码时没有对齐,test预测就会错位。
- 提交CSV的PassengerId和test顺序不一致,模型分数当然崩。
- 验证集划分的随机性,你本地验证只跑了一次,恰好拿到高分。
解决办法是:把数据预处理写成函数,先定义fit_transform用于训练集,transform用于测试集。或者直接让pandas在同一个处理函数里跑train和test,但要防止泄漏。
最简单的做法是在清洗阶段就把train和test拼接起来处理:
# 先保留标签和ID y_train = train['Survived'] test_ids = test['PassengerId'] all_data = pd.concat([train.drop(['Survived'], axis=1), test], axis=0, ignore_index=True)在all_data上做特征工程和填充,最后再拆回训练集和测试集。这样做的好处是确保两边分布一致,缺失值填充也使用全局统计量。很多Kaggle高分notebook都采用这个方案。但要注意,这样做有一点泄漏风险,因为填充使用到了test的信息。不过在泰坦尼克号这种小数据里,泄漏风险远小于分布不一致的风险,实际操作中效果很好。
5.3 常见问题速查表
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 本地CV分数高,提交分数低 | 数据泄漏或预处理不一致 | train/test合并处理,统一特征工程流程 |
| 模型预测全部为0或全部为1 | 特征编码错误,模型学到偏向 | 检查Sex、Pclass编码,增加有效特征 |
| 提交文件报错 | 格式不对或列名不对 | 确保有PassengerId和Survived,Survived为整数 |
| 验证集分数波动很大 | 随机划分未固定 | 使用交叉验证评估模型 |
| 特征重要性出现NaN | fillna不完整 | 检查特征列是否还有缺失值 |
| 网格搜索耗时过长 | 参数空间太大 | 先用粗网格,锁定范围再细调 |
| 融合后分数反而下降 | 有弱模型拉胯 | 只融合表现良好的模型,或给权重 |
还有一个小技巧:在最终训练模型时,我把验证集和训练集合并在一起重新训练。因为确定好模型和超参数后,所有数据都参与训练通常会让提交分数稍微上涨。如果模型本身容易过拟合,这样做可能没帮助,但在随机森林上用通常效果不错。
5.4 最后分享一个检查提交文件的小技巧
提交之前,我一定会在命令行里手动读一遍CSV:
python -c "import pandas as pd; df = pd.read_csv('submission.csv'); print(df.shape); print(df.head()); print(df['Survived'].value_counts())"输出里,行数必须是418,Survived只有0和1两种取值,且第一行PassengerId是892,不是1。因为test.csv里的PassengerId是从892开始的,train里才是1到891。如果你看到的PassengerId是1开头,基本是文件顺序弄错了。
我踩过这个坑之后,现在每次写提交代码都会把PassengerId单独拿出来做对齐检查,避免因为排序问题丢掉不该丢的分数。
另外,如果使用pandas的fillna(0)来临时处理缺失,检查一下test还有没有真实缺失没有补干净。可以再跑一次:
print(test[features].isnull().sum())确保提交特征集没有NaN,否则模型预测结果会有偏差。
我个人做这个项目最深的体会是:泰坦尼克号虽然简单,但把这里的每个坑都踩一遍,后面遇到任何结构化数据项目都会更有底气。建议你把这次项目的代码整理成自己的模板,以后碰上分类问题,直接拿模板改字段名就行,比自己从零开始写要快得多。