1. 从零开始:为什么泰坦尼克号是机器学习的“Hello World”

如果你刚开始接触机器学习,面对一堆算法和代码库,是不是感觉有点无从下手?我刚开始学的时候也是这样,总觉得理论很抽象,不知道从哪里动手。后来我发现,最好的学习方法就是找一个经典项目,从头到尾做一遍。而泰坦尼克号生存预测,就是那个最完美的“入门级”实战项目,几乎每个数据科学家和机器学习工程师的简历里都有它。

为什么是它呢?首先,它的数据规模适中,训练集891条,测试集418条,数据量不大不小,用个人电脑就能轻松跑起来,不用担心算力问题。其次,它包含了机器学习项目中你会遇到的大部分典型问题:数据有缺失、特征有类别型和数值型、需要做特征工程、目标明确(预测生存与否)。最关键的是,它在Kaggle上是一个长期开放的入门竞赛,你可以随时提交结果,看到自己在全球排行榜上的位置,这种即时反馈对学习动力是巨大的鼓舞。

我记得我第一次在Kaggle上提交结果时,得分只有0.76左右,当时觉得“机器学习不过如此”。但后来我花了更多时间去深挖数据、尝试不同的特征构造和模型调优,看着分数一点点提升到0.8以上,那种成就感是无与伦比的。这个过程教会我的,远不止几个API的调用,而是对一个完整数据分析流程的深刻理解。所以,无论你是刚入门的小白,还是想巩固基础的老手,跟着我重新走一遍这个流程,你收获的将是一个扎实的、可复用的项目框架。

2. 数据清洗:给“脏数据”洗个澡

拿到原始数据,第一步千万别急着建模。我见过太多新手一上来就导入模型开跑,结果准确率惨不忍睹,其实就是忽略了数据清洗这个最关键的步骤。数据质量决定了模型效果的上限,模型和算法只是逼近这个上限的工具。泰坦尼克号的数据虽然经典,但“坑”也不少,我们得一个个填平。

2.1 认识你的数据:像侦探一样审视每一列

我们先导入数据,用pandasinfo()describe()方法快速扫一眼。

import pandas as pd
import numpy as np

# 加载数据
train_df = pd.read_csv('train.csv')
test_df = pd.read_csv('test.csv')

print("训练集信息:")
print(train_df.info())
print("\n训练集描述性统计:")
print(train_df.describe(include='all'))

运行后你会发现,Age(年龄)、Cabin(船舱号)、Embarked(登船港口)这几个字段存在缺失值。Cabin字段缺失率高达77%,这很正常,历史上很多乘客的舱位记录本就丢失了。面对缺失值,我们的处理策略不是固定的,得看情况。

2.2 处理缺失值:因地制宜的填充策略

对于Age字段,大约20%的缺失。直接用均值填充是最简单的方法,但未必是最好的。我试过用均值、中位数,也试过用Pclass(客舱等级)和Title(从姓名中提取的称谓,如Mr, Miss)分组后的中位数来填充,后者往往能带来微小的精度提升。这里我们先演示基础方法:

# 用全体乘客年龄的中位数填充(比均值对异常值更鲁棒)
train_df['Age'].fillna(train_df['Age'].median(), inplace=True)
test_df['Age'].fillna(test_df['Age'].median(), inplace=True)

对于Cabin字段,缺失太多,直接作为特征加入模型可能会引入噪声。一个更聪明的做法是,我们不使用具体的舱位号,而是从中提取出一个新特征:是否有舱位记录。因为拥有舱位记录的乘客,可能意味着其信息更全、社会地位不同,这本身可能就是有信息量的。

# 创建新特征:是否有Cabin记录
train_df['HasCabin'] = train_df['Cabin'].notnull().astype(int)
test_df['HasCabin'] = test_df['Cabin'].notnull().astype(int)
# 然后可以删除原Cabin列
train_df.drop('Cabin', axis=1, inplace=True)
test_df.drop('Cabin', axis=1, inplace=True)

对于Embarked(登船港口),只有2个缺失值。我们可以查看一下分布,用最常见的港口(‘S’)来填充。

print(train_df['Embarked'].value_counts())
# 输出显示'S'最多
train_df['Embarked'].fillna('S', inplace=True)
test_df['Embarked'].fillna('S', inplace=True)

还有一个容易被忽略的Fare(船票价格),在测试集中有一个缺失值,我们也需要处理。

test_df['Fare'].fillna(test_df['Fare'].median(), inplace=True)

2.3 异常值与一致性检查

数据清洗不只是补缺失值。我们还要检查是否有明显的异常值。比如Age为0或极大值,Fare为0或天价票。可以用箱线图或描述性统计快速查看。另外,要确保训练集和测试集在同一个特征上的处理逻辑完全一致,比如填充用的中位数,最好用训练集的统计量去填充测试集,避免数据泄露。这一步的细心程度,直接决定了后续模型是否稳健。

3. 特征工程:从原始数据中“炼金”

数据清洗干净了,接下来就是最体现数据科学家“手艺”的环节——特征工程。原始数据就像食材,特征工程就是烹饪,决定了最终菜肴的色香味。好的特征能让简单模型发挥出色,坏的特征则会让复杂模型也无能为力。

3.1 挖掘“姓名”里的宝藏:Title(称谓)

初看Name字段是一串字符串,好像没用。但仔细看:“Braund, Mr. Owen Harris”,里面包含了一个重要的社会称谓“Mr.”。这个称谓隐含了年龄、性别、甚至社会地位的信息。我们可以用简单的字符串提取来创造这个新特征。

# 从姓名中提取称谓
train_df['Title'] = train_df['Name'].str.extract(' ([A-Za-z]+)\.', expand=False)
test_df['Title'] = test_df['Name'].str.extract(' ([A-Za-z]+)\.', expand=False)

# 查看有哪些称谓
print(pd.crosstab(train_df['Title'], train_df['Sex']))

你会发现有Mr, Miss, Mrs, Master(对未成年男孩的尊称),还有罕见的Dr, Rev, Col等。我们可以将其归类为几大类:Mr, Miss/Mrs/Ms, Master, Rare(其他)。统计会发现,Master的生存率远高于Mr,这说明我们提取的特征是有效的。

# 将称谓归类
title_mapping = {'Mr': 'Mr', 'Miss': 'Miss', 'Mrs': 'Mrs', 'Master': 'Master',
                 'Dr': 'Rare', 'Rev': 'Rare', 'Col': 'Rare', 'Major': 'Rare',
                 'Mlle': 'Miss', 'Mme': 'Mrs', 'Lady': 'Rare', 'Countess': 'Rare',
                 'Don': 'Rare', 'Dona': 'Rare', 'Jonkheer': 'Rare', 'Sir': 'Rare',
                 'Capt': 'Rare', 'Ms': 'Miss'}
train_df['Title'] = train_df['Title'].map(title_mapping)
test_df['Title'] = test_df['Title'].map(title_mapping)

3.2 家庭规模:融合SibSp和Parch

SibSp(兄弟姐妹/配偶数量)和Parch(父母/子女数量)单独看可能信息有限,但加起来就能反映一个乘客的家庭规模。我试过直接相加,也试过根据规模大小分组(如独身、小家庭、大家庭)。

# 创建家庭规模特征
train_df['FamilySize'] = train_df['SibSp'] + train_df['Parch'] + 1  # +1 代表自己
test_df['FamilySize'] = test_df['SibSp'] + test_df['Parch'] + 1

# 进一步创建是否独身特征
train_df['IsAlone'] = 0
train_df.loc[train_df['FamilySize'] == 1, 'IsAlone'] = 1
test_df['IsAlone'] = 0
test_df.loc[test_df['FamilySize'] == 1, 'IsAlone'] = 1

数据分析往往会发现,独身旅客和家庭规模适中的旅客生存率有差异,大家庭生存率反而可能下降,这个特征非常有用。

3.3 票价分组与客舱等级结合

Fare(票价)是连续值,我们可以将其离散化,与Pclass(客舱等级)结合。因为同一等级内的票价差异也可能蕴含信息(比如是否购买了更贵的舱位)。我常用的方法是使用qcut(基于分位数切割)来创建票价等级。

# 将训练集和测试集的Fare合并以统一分箱区间
combined_fare = pd.concat([train_df['Fare'], test_df['Fare']])
train_df['FareBand'] = pd.qcut(train_df['Fare'], 4, labels=[1, 2, 3, 4])
test_df['FareBand'] = pd.qcut(test_df['Fare'], 4, labels=[1, 2, 3, 4])
# 或者简单处理:将Fare取对数,缓解偏态分布
train_df['Fare_log'] = np.log1p(train_df['Fare'])
test_df['Fare_log'] = np.log1p(test_df['Fare'])

3.4 年龄分组:将连续值转化为类别

和票价类似,Age也可以分组。儿童(特别是Master称谓的男孩)和老人的生存概率可能不同。我们可以根据历史常识(如儿童优先)或数据分布来划分年龄段。

# 将年龄分段
bins = [0, 12, 18, 35, 60, 100]
labels = ['Child', 'Teenager', 'Adult', 'Middle-aged', 'Senior']
train_df['AgeGroup'] = pd.cut(train_df['Age'], bins=bins, labels=labels)
test_df['AgeGroup'] = pd.cut(test_df['Age'], bins=bins, labels=labels)

做完这些,我们手上就有了比原始数据丰富得多的特征集。别忘了,在开始建模前,我们需要把那些文本型的类别特征(如Sex, Embarked, Title, AgeGroup)转换成模型能理解的数字。

4. 数据编码与准备:喂给模型前的最后一步

机器学习模型本质是数学公式,只能处理数字。所以我们必须把“男性/女性”、“S/C/Q港口”这样的文字信息转换成数值。这里有几个常用方法,用对了能提升效果。

4.1 标签编码与独热编码

对于只有两个类别的特征,如Sex,用标签编码(Label Encoding)简单映射为0/1即可。

train_df['Sex'] = train_df['Sex'].map({'male': 0, 'female': 1})
test_df['Sex'] = test_df['Sex'].map({'male': 0, 'female': 1})

对于超过两个类别且没有内在顺序的特征,如Embarked(S, C, Q)或我们刚创建的Title,常用独热编码。它能为每个类别创建一个新的二进制特征列。用pandasget_dummies函数可以轻松实现。

# 对Embarked进行独热编码
embarked_dummies_train = pd.get_dummies(train_df['Embarked'], prefix='Embarked')
embarked_dummies_test = pd.get_dummies(test_df['Embarked'], prefix='Embarked')
train_df = pd.concat([train_df, embarked_dummies_train], axis=1)
test_df = pd.concat([test_df, embarked_dummies_test], axis=1)
train_df.drop('Embarked', axis=1, inplace=True)
test_df.drop('Embarked', axis=1, inplace=True)

# 对Title进行独热编码
title_dummies_train = pd.get_dummies(train_df['Title'], prefix='Title')
title_dummies_test = pd.get_dummies(test_df['Title'], prefix='Title')
train_df = pd.concat([train_df, title_dummies_train], axis=1)
test_df = pd.concat([test_df, title_dummies_test], axis=1)
train_df.drop('Title', axis=1, inplace=True)
test_df.drop('Title', axis=1, inplace=True)

注意:务必确保训练集和测试集在独热编码后具有相同的列。如果测试集中出现了训练集没有的类别(反之亦然),会导致维度不一致而报错。通常的做法是先合并两个数据集一起编码,再拆分,或者确保编码函数能处理未见过的类别。

4.2 特征选择与相关性分析

特征不是越多越好。无关或冗余的特征会干扰模型,增加过拟合风险。在训练前,我们可以画一个相关性热力图,直观地看哪些特征与目标变量Survived相关性高。

import seaborn as sns
import matplotlib.pyplot as plt

# 计算相关系数矩阵
corr_matrix = train_df.corr()
# 绘制热力图
plt.figure(figsize=(12, 10))
sns.heatmap(corr_matrix, annot=True, fmt='.2f', cmap='coolwarm', square=True)
plt.title('特征相关性热力图')
plt.show()

你会看到Sex(性别)与生存的相关性最高(负相关,因为男性编码为0),Pclass(客舱等级)和Fare(票价)也显示出较强的相关性。像PassengerIdNameTicket(票号)这类显然与生存无关的标识符特征,应该果断删除。经过一系列处理,我们最终得到一个干净、全是数值型的特征矩阵X和目标向量y,可以正式交给模型了。

5. 模型选择与训练:找到你的“最佳队友”

特征准备好了,就像球员已经热身完毕,现在要派他们上场了。该派谁呢?逻辑回归、决策树、随机森林、梯度提升树……我的经验是,对于泰坦尼克号这种结构化数据,树模型(如随机森林、XGBoost)通常表现更好,但逻辑回归作为基线模型必不可少。

5.1 建立基线模型:逻辑回归

逻辑回归简单、可解释性强,是完美的起点。它能快速给你一个性能基准。

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score

# 准备特征和目标变量
# 假设我们最终选择的特征列列表是 feature_columns
X = train_df[feature_columns]
y = train_df['Survived']

# 划分训练集和验证集(注意:这里不是原始的测试集,是我们自己从训练数据中划分的验证集)
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42)

# 创建并训练逻辑回归模型
lr_model = LogisticRegression(random_state=42, max_iter=1000)
lr_model.fit(X_train, y_train)

# 在验证集上预测并评估
y_val_pred = lr_model.predict(X_val)
lr_accuracy = accuracy_score(y_val, y_val_pred)
print(f"逻辑回归模型在验证集上的准确率:{lr_accuracy:.4f}")

这个准确率(通常在0.78-0.82之间)就是你的基线。任何更复杂的模型都应该显著超越这个分数,否则就是白费功夫。

5.2 尝试集成模型:随机森林

随机森林通过构建多棵决策树并综合它们的投票结果,能有效降低过拟合,通常能比单棵决策树或逻辑回归取得更好的效果。

from sklearn.ensemble import RandomForestClassifier

rf_model = RandomForestClassifier(n_estimators=100,  # 树的数量
                                   max_depth=5,       # 树的最大深度,防止过拟合
                                   random_state=42,
                                   oob_score=True)    # 使用袋外样本评估
rf_model.fit(X_train, y_train)

y_val_pred_rf = rf_model.predict(X_val)
rf_accuracy = accuracy_score(y_val, y_val_pred_rf)
print(f"随机森林模型在验证集上的准确率:{rf_accuracy:.4f}")
print(f"随机森林袋外分数:{rf_model.oob_score_:.4f}")

随机森林还有一个超棒的功能:特征重要性评估。训练完成后,你可以查看哪个特征对模型决策贡献最大。

# 获取特征重要性
feature_importances = pd.DataFrame({
    'feature': feature_columns,
    'importance': rf_model.feature_importances_
}).sort_values('importance', ascending=False)

print(feature_importances)

这个列表能帮你验证特征工程的有效性,比如SexTitle_MrPclass通常排名靠前。如果某个你花大力气构造的特征重要性垫底,可能就需要反思了。

5.3 使用交叉验证评估模型稳定性

只用一次训练验证分割评估模型可能有偶然性。更稳健的方法是使用K折交叉验证。

from sklearn.model_selection import cross_val_score

# 对逻辑回归进行5折交叉验证
lr_cv_scores = cross_val_score(lr_model, X, y, cv=5, scoring='accuracy')
print(f"逻辑回归5折交叉验证平均准确率:{lr_cv_scores.mean():.4f} (+/- {lr_cv_scores.std()*2:.4f})")

# 对随机森林进行5折交叉验证
rf_cv_scores = cross_val_score(rf_model, X, y, cv=5, scoring='accuracy')
print(f"随机森林5折交叉验证平均准确率:{rf_cv_scores.mean():.4f} (+/- {rf_cv_scores.std()*2:.4f})")

交叉验证的分数更可靠,它能告诉你模型在不同数据子集上的平均表现和稳定性。如果交叉验证分数远低于单次验证分数,说明模型可能过拟合了。

6. 模型优化与调参:从“能用”到“好用”

如果你的模型表现已经不错,但还想再往上提一点分数,就需要进入调参阶段。这个过程有点像给汽车做精细调校,微调发动机参数以获得最佳性能。

6.1 网格搜索:自动化寻找最优参数组合

手动一个个试参数效率太低。Scikit-learnGridSearchCV可以帮你系统性地遍历多种参数组合,并用交叉验证评估每一组的效果。

from sklearn.model_selection import GridSearchCV

# 定义随机森林的参数网格
param_grid = {
    'n_estimators': [50, 100, 200],
    'max_depth': [3, 5, 7, None],
    'min_samples_split': [2, 5, 10],
    'min_samples_leaf': [1, 2, 4]
}

# 创建网格搜索对象
rf = RandomForestClassifier(random_state=42, oob_score=True)
grid_search = GridSearchCV(estimator=rf,
                           param_grid=param_grid,
                           cv=5,
                           scoring='accuracy',
                           n_jobs=-1,  # 使用所有CPU核心并行计算
                           verbose=1)  # 输出详细过程

# 在训练数据上执行网格搜索
grid_search.fit(X_train, y_train)

# 输出最佳参数和最佳分数
print(f"最佳参数:{grid_search.best_params_}")
print(f"最佳交叉验证分数:{grid_search.best_score_:.4f}")

# 用最佳参数重新训练最终模型
best_rf_model = grid_search.best_estimator_

网格搜索可能会运行一段时间,因为它要训练参数组合数 × 交叉验证折数个模型。对于更复杂的模型(如XGBoost)或更大的参数空间,可以考虑使用RandomizedSearchCV,它随机采样参数组合,效率更高。

6.2 集成学习:团结就是力量

单个模型再强,也可能有局限性。集成学习的思想是“三个臭皮匠,顶个诸葛亮”。我们可以把逻辑回归、随机森林、支持向量机等不同模型的预测结果结合起来。常用的方法有投票法

from sklearn.ensemble import VotingClassifier
from sklearn.svm import SVC

# 定义多个基学习器
estimators = [
    ('lr', LogisticRegression(random_state=42, max_iter=1000)),
    ('rf', RandomForestClassifier(n_estimators=100, max_depth=5, random_state=42)),
    ('svc', SVC(probability=True, random_state=42))  # 需要probability=True用于软投票
]

# 创建投票分类器(软投票:基于预测概率的平均值)
voting_clf = VotingClassifier(estimators=estimators, voting='soft')

# 训练投票分类器
voting_clf.fit(X_train, y_train)

# 评估
y_val_pred_vote = voting_clf.predict(X_val)
vote_accuracy = accuracy_score(y_val, y_val_pred_vote)
print(f"投票集成模型在验证集上的准确率:{vote_accuracy:.4f}")

在我的多次尝试中,一个精心调参的梯度提升树(如XGBoost或LightGBM)单模型往往就能达到很高的分数,但投票集成有时能带来额外的稳定性和微小的提升,尤其是在Kaggle竞赛中,前几名往往都是复杂集成的结果。

7. 结果提交与迭代:在Kaggle上验证你的成果

模型在本地验证集上表现良好后,最终的试金石是Kaggle的测试集。你需要用训练好的模型对真正的测试集test.csv进行预测,并按照要求格式提交。

7.1 生成提交文件

确保你对测试集test_df做了和训练集完全一样的特征工程和编码处理。然后使用你最优的模型进行预测。

# 假设 final_model 是你训练好的最佳模型(例如调参后的随机森林)
final_predictions = best_rf_model.predict(test_df[feature_columns])

# 创建提交DataFrame
submission = pd.DataFrame({
    'PassengerId': test_df['PassengerId'],
    'Survived': final_predictions.astype(int)
})

# 保存为csv文件
submission.to_csv('titanic_submission.csv', index=False)
print("提交文件已生成:titanic_submission.csv")

7.2 分析错误,持续迭代

第一次提交分数可能不理想,这太正常了。Kaggle会告诉你一个公开分数(基于测试集的一部分)。更重要的是,你要分析模型在哪里出错了。一个有用的技巧是:回到验证集,查看哪些样本被预测错了

# 找出验证集中预测错误的样本
val_results = X_val.copy()
val_results['True'] = y_val
val_results['Pred'] = y_val_pred_rf  # 假设是随机森林的预测
val_results['Correct'] = (val_results['True'] == val_results['Pred'])

misclassified = val_results[val_results['Correct'] == False]
print(f"验证集上错误分类的样本数:{len(misclassified)}")
print(misclassified[['True', 'Pred']].head())

仔细研究这些错分的样本。是不是某个特定人群(如特定年龄段的男性三等舱乘客)总是被错分?这可能提示你现有的特征还不足以区分他们,需要构造更有区分度的新特征,或者对模型进行针对性调整。机器学习项目就是一个“建模-评估-分析-改进”的循环,每一次迭代都能让你对数据和问题的理解更深一层。

更多推荐