随机森林实战:用Python从零构建一个分类模型(附泰坦尼克数据集完整代码)

如果你刚开始接触机器学习,可能会被各种算法搞得眼花缭乱。线性回归、逻辑回归、支持向量机……每个听起来都挺复杂。但有一种算法,它既强大又好用,甚至被许多数据科学家称为“开箱即用”的利器,那就是随机森林。我第一次在Kaggle竞赛中用它时,惊讶地发现,没做太多调优,它的表现就轻松超过了之前精心调整的其他模型。这让我意识到,对于很多实际问题,尤其是当你手头数据有些“杂乱”、特征关系不那么清晰时,随机森林往往能提供一个稳健且高效的起点。

这篇文章,我们就来亲手搭建一个随机森林分类模型。我不会过多纠缠于背后的数学公式——那些当然重要,但实践出真知。我们将以经典的泰坦尼克号生存预测数据集为战场,用Python和scikit-learn库,从数据加载、清洗、探索,一步步走到模型训练、评估和调优。你会发现,构建一个可用的模型并不神秘,关键在于理解每个步骤背后的“为什么”,以及如何避开那些新手常踩的坑。无论你是想快速完成一个课程项目,还是为更复杂的预测任务打基础,这篇手把手的指南都希望能给你带来实实在在的帮助。

1. 环境准备与数据初探

在开始写任何代码之前,确保你的工作环境已经就绪。我强烈推荐使用Jupyter Notebook或JupyterLab进行这类探索性数据分析(EDA)和建模工作,它的交互式特性非常适合一步步查看数据变化和模型结果。

首先,我们需要安装核心的库。打开你的终端或Anaconda Prompt,执行以下命令:

pip install numpy pandas scikit-learn matplotlib seaborn
  • numpypandas 是数据处理的双子星,一个负责高效数值计算,一个提供灵活的数据结构(DataFrame)。
  • scikit-learn 是我们今天的主角,它提供了随机森林以及其他几乎所有主流机器学习算法的实现。
  • matplotlibseaborn 用于数据可视化,帮助我们直观理解数据分布和特征关系。

安装完成后,在Notebook的第一个单元格中,导入这些库:

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

# 设置绘图风格,让图表更好看
sns.set_style("whitegrid")
plt.rcParams['font.sans-serif'] = ['SimHei']  # 用来正常显示中文标签
plt.rcParams['axes.unicode_minus'] = False  # 用来正常显示负号

# 从sklearn导入我们即将用到的模块
from sklearn.model_selection import train_test_split, cross_val_score, GridSearchCV
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from sklearn.preprocessing import LabelEncoder, StandardScaler
from sklearn.impute import SimpleImputer

# 设置随机种子,确保结果可复现
np.random.seed(42)

接下来,加载泰坦尼克数据集。这个数据集在Kaggle和许多机器学习教程中都很常见。假设你已经将train.csvtest.csv下载到了当前工作目录下的data文件夹中。

# 加载训练集和测试集
train_df = pd.read_csv('./data/train.csv')
test_df = pd.read_csv('./data/test.csv')

# 先看一眼训练数据长什么样
print("训练集形状:", train_df.shape)
print("\n训练集前5行:")
display(train_df.head())
print("\n训练集信息:")
train_df.info()

运行这段代码,你会看到数据的基本情况:891条乘客记录,12个特征列(包括目标列Survived)。info()方法能快速告诉我们每列的数据类型和非空值数量,这是数据质量检查的第一步。

注意:test.csv中没有Survived列,那是我们需要预测的目标。在真正的Kaggle比赛中,你需要将预测结果提交到test.csv对应的乘客ID上。

一个非常关键但常被忽视的步骤是:将测试集放在一边,暂时不要看它。我们所有的数据探索、特征工程和模型训练,都应该只在训练集(或从训练集划分出的验证集)上进行。过早地让测试集信息影响决策,会导致模型在未知数据上的表现被高估,这被称为数据泄露,是机器学习项目中的大忌。

2. 深入数据清洗与特征工程

原始数据很少是完美无缺的。泰坦尼克数据集虽然经典,但也包含了缺失值、分类变量需要编码等问题。这一步做得好不好,直接决定了模型性能的上限。

2.1 处理缺失值

我们先系统性地检查一下缺失值:

# 计算并可视化缺失值比例
missing_train = train_df.isnull().sum().sort_values(ascending=False)
missing_percent_train = (missing_train / len(train_df)) * 100
missing_table_train = pd.concat([missing_train, missing_percent_train], axis=1, keys=['缺失数量', '缺失比例'])
print("训练集缺失值统计:")
print(missing_table_train[missing_table_train['缺失数量'] > 0])

# 对测试集也做同样检查
missing_test = test_df.isnull().sum().sort_values(ascending=False)
missing_percent_test = (missing_test / len(test_df)) * 100
missing_table_test = pd.concat([missing_test, missing_percent_test], axis=1, keys=['缺失数量', '缺失比例'])
print("\n测试集缺失值统计:")
print(missing_table_test[missing_table_test['缺失数量'] > 0])

你会发现,Cabin(船舱号)缺失严重(约77%),Age(年龄)缺失约20%,Embarked(登船港口)缺失极少。对于Fare(船票价格)在测试集中也有个别缺失。

  • Age(年龄):这是一个重要的连续特征,直接删除缺失行会损失大量信息。常见的填充策略是用中位数(对异常值不敏感)或均值。更精细的做法是利用其他特征(如Pclass客舱等级、Title称呼)分组进行填充。这里我们采用一个简单有效的方法:用乘客所属PclassSex分组下的年龄中位数来填充。

    # 定义一个函数来填充年龄
    def fill_age(df):
        # 按Pclass和Sex分组,计算年龄中位数
        age_median = df.groupby(['Pclass', 'Sex'])['Age'].median()
        
        def fill_age_row(row):
            if pd.isnull(row['Age']):
                return age_median[row['Pclass'], row['Sex']]
            return row['Age']
        
        df['Age'] = df.apply(fill_age_row, axis=1)
        return df
    
    train_df = fill_age(train_df)
    test_df = fill_age(test_df)
    
  • Cabin(船舱号):缺失太多,直接作为特征使用价值有限,且处理起来复杂(包含字母和数字)。一个巧妙的思路是,我们不一定需要具体的舱号,可以从中提取一个更有用的信息:该乘客是否有记录的船舱信息。这可能暗示了乘客的社会地位、购票渠道等信息。

    train_df['HasCabin'] = train_df['Cabin'].apply(lambda x: 0 if pd.isnull(x) else 1)
    test_df['HasCabin'] = test_df['Cabin'].apply(lambda x: 0 if pd.isnull(x) else 1)
    # 之后可以删除原始的Cabin列
    train_df.drop('Cabin', axis=1, inplace=True)
    test_df.drop('Cabin', axis=1, inplace=True)
    
  • Embarked(登船港口):只有两个缺失值,直接用众数(出现最频繁的港口)填充。

    embarked_mode = train_df['Embarked'].mode()[0]
    train_df['Embarked'].fillna(embarked_mode, inplace=True)
    # 测试集Embarked没有缺失,但为了代码健壮性也可以检查填充
    if test_df['Embarked'].isnull().any():
        test_df['Embarked'].fillna(embarked_mode, inplace=True)
    
  • Fare(船票价格):测试集中有一个缺失,用中位数填充。

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

2.2 创造新特征与编码分类变量

机器学习模型,包括随机森林,虽然能处理分类变量,但scikit-learn的实现通常要求输入是数值。我们需要将文本类别的特征(如Sex, Embarked)转换为数字。同时,从现有特征中挖掘新信息,往往能显著提升模型表现。

  • Name中提取Title(称呼):姓名中包含了Mr., Mrs., Miss, Master等称呼,这与社会地位、年龄、婚姻状况高度相关,是一个强特征。

    # 提取称呼的函数
    def extract_title(name):
        title_search = re.search(' ([A-Za-z]+)\.', name)
        if title_search:
            return title_search.group(1)
        return ""
    
    import re
    for df in [train_df, test_df]:
        df['Title'] = df['Name'].apply(extract_title)
        # 将一些不常见的称呼归为'Rare'
        df['Title'] = df['Title'].replace(['Lady', 'Countess','Capt', 'Col', 'Don', 'Dr', 'Major', 'Rev', 'Sir', 'Jonkheer', 'Dona'], 'Rare')
        df['Title'] = df['Title'].replace('Mlle', 'Miss')
        df['Title'] = df['Title'].replace('Ms', 'Miss')
        df['Title'] = df['Title'].replace('Mme', 'Mrs')
    
    # 查看称呼分布
    print(train_df['Title'].value_counts())
    
  • 创建FamilySize(家庭规模):将SibSp(兄弟姐妹/配偶数)和Parch(父母/子女数)相加,并加1(乘客自己)。家庭规模可能影响互助和逃生优先级。

    for df in [train_df, test_df]:
        df['FamilySize'] = df['SibSp'] + df['Parch'] + 1
        # 进一步,可以根据家庭规模创建类别特征,例如是否独自一人
        df['IsAlone'] = 0
        df.loc[df['FamilySize'] == 1, 'IsAlone'] = 1
    
  • 对分类特征进行标签编码(Label Encoding):对于有序类别不多的特征,如Sex(男/女)、Embarked(S/C/Q)、Title,我们可以用LabelEncoder将其转换为0,1,2...这样的数字。

    label_cols = ['Sex', 'Embarked', 'Title']
    le_dict = {}
    for col in label_cols:
        le = LabelEncoder()
        # 在训练集上拟合编码器,并同时转换训练集和测试集
        le.fit(pd.concat([train_df[col], test_df[col]], axis=0))
        train_df[col] = le.transform(train_df[col])
        test_df[col] = le.transform(test_df[col])
        le_dict[col] = le  # 保存编码器以备后用(如果需要反向转换)
    
  • 删除不再需要的列PassengerId是索引,NameTicket(船票号)信息已被提取或过于杂乱,可以删除。SibSpParch已被融合到新特征中。

    drop_columns = ['PassengerId', 'Name', 'Ticket', 'SibSp', 'Parch']
    train_df.drop(drop_columns, axis=1, inplace=True)
    # 测试集需要保留PassengerId用于最终提交,所以先存起来
    test_passenger_ids = test_df['PassengerId']
    test_df.drop(['PassengerId', 'Name', 'Ticket', 'SibSp', 'Parch'], axis=1, inplace=True)
    

现在,再次检查处理后的数据:

print("处理后的训练集形状:", train_df.shape)
print("\n处理后的训练集列名:", train_df.columns.tolist())
print("\n训练集前5行:")
display(train_df.head())

你应该看到一个干净、全是数值型的DataFrame,准备就绪,可以喂给模型了。

3. 构建与评估基础随机森林模型

数据准备好了,我们终于可以开始建模了。首先,将数据划分为特征(X)和目标(y),然后进一步划分出训练集和验证集。

# 分离特征和目标
X = train_df.drop('Survived', axis=1)
y = train_df['Survived']

# 划分训练集和验证集(80%训练,20%验证)
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)

print(f"训练集大小: {X_train.shape}")
print(f"验证集大小: {X_val.shape}")

这里使用了stratify=y参数,确保训练集和验证集中生存与未生存的比例与原始数据集一致,这在类别不平衡时很重要。

现在,创建我们的第一个随机森林分类器。我们先使用默认参数,看看它的基线表现如何。

# 初始化随机森林分类器(使用默认参数)
rf_base = RandomForestClassifier(random_state=42, n_jobs=-1) # n_jobs=-1 使用所有CPU核心加速

# 在训练集上训练模型
rf_base.fit(X_train, y_train)

# 在训练集和验证集上进行预测
y_train_pred = rf_base.predict(X_train)
y_val_pred = rf_base.predict(X_val)

# 计算准确率
train_accuracy = accuracy_score(y_train, y_train_pred)
val_accuracy = accuracy_score(y_val, y_val_pred)

print(f"基础模型 - 训练集准确率: {train_accuracy:.4f}")
print(f"基础模型 - 验证集准确率: {val_accuracy:.4f}")

你可能会看到训练集准确率接近100%,而验证集准确率在80%出头。训练集准确率远高于验证集,这是典型的过拟合(Overfitting)迹象。模型把训练数据中的噪声和特定细节都学得太好了,导致在新数据上泛化能力下降。别担心,这正是我们需要调优的原因。

除了准确率,我们还需要更细致的评估指标,尤其是当类别不平衡时(泰坦尼克数据集中生存率约为38%)。classification_report和混淆矩阵能提供更多信息。

print("\n验证集分类报告:")
print(classification_report(y_val, y_val_pred))

# 绘制混淆矩阵
from sklearn.metrics import ConfusionMatrixDisplay
disp = ConfusionMatrixDisplay.from_estimator(rf_base, X_val, y_val, cmap=plt.cm.Blues)
plt.title("验证集混淆矩阵")
plt.show()

classification_report会给出精确率(Precision)、召回率(Recall)和F1分数。例如,对于“生存”这个类别,高召回率意味着模型能找出更多实际生存的人,高精确率意味着模型预测生存的人中,确实生存的比例高。根据你的业务目标(比如是宁可错救也不愿遗漏,还是确保救援资源精准投放),可以侧重不同的指标。

3.1 特征重要性分析

随机森林一个非常强大的功能是能够评估特征重要性。它衡量了每个特征在森林中所有树上,对于减少不纯度(如基尼不纯度)的平均贡献。这不仅能帮助我们理解模型决策的依据,还能用于特征筛选。

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

print("特征重要性排序:")
print(feature_importances)

# 可视化特征重要性
plt.figure(figsize=(10, 6))
sns.barplot(x='importance', y='feature', data=feature_importances)
plt.title('随机森林特征重要性')
plt.xlabel('重要性得分')
plt.tight_layout()
plt.show()

你可能会发现Fare(票价)、Title(称呼)、Sex(性别)和Age(年龄)是最重要的特征。这非常符合直觉:票价和称呼反映了社会经济地位,而“妇女儿童优先”的逃生原则使得性别和年龄至关重要。如果某些特征的重要性极低(例如我们创建的HasCabinIsAlone),在后续的迭代中可以考虑移除它们,以简化模型。

4. 模型调优与性能提升

默认模型已经不错,但我们知道它过拟合了。现在,我们通过超参数调优来尝试找到泛化能力更强的模型。随机森林有许多超参数可以调整,主要分为两类:控制森林整体结构的控制单棵决策树生长的

参数类别 参数名 说明 一般调整方向
森林结构 n_estimators 森林中树的数量。 增加通常能提升性能,但计算成本增加,收益递减。常见值100-500。
max_features 寻找最佳分割时考虑的最大特征数。 减少此值可以增加树的多样性,降低过拟合。常用‘sqrt’或‘log2’。
bootstrap 是否使用bootstrap抽样构建每棵树。 默认为True。设为False则使用整个数据集,可能降低多样性。
单树生长 max_depth 树的最大深度。 限制深度是防止过拟合最有效的手段之一。可以从None(不限制)开始调小。
min_samples_split 分裂内部节点所需的最小样本数。 增大此值会使树更保守,防止学习过于局部的模式。
min_samples_leaf 叶节点所需的最小样本数。 增大此值可以平滑模型,对异常值更鲁棒。
min_impurity_decrease 分裂需要达到的最小不纯度减少量。 增大此值会提前停止分裂,简化树。

调优策略通常有两种:网格搜索(GridSearchCV)随机搜索(RandomizedSearchCV)。网格搜索会遍历所有参数组合,计算量大但彻底;随机搜索则在指定的参数分布中随机抽样,用更少的尝试找到不错的组合,效率更高。对于随机森林这种参数空间较大的模型,通常先进行随机搜索缩小范围,再进行精细的网格搜索。

# 首先定义参数网格(范围可以设得宽一些)
param_dist = {
    'n_estimators': [100, 200, 300],
    'max_depth': [None, 10, 20, 30],
    'min_samples_split': [2, 5, 10],
    'min_samples_leaf': [1, 2, 4],
    'max_features': ['auto', 'sqrt', 'log2'],
    'bootstrap': [True, False]
}

# 初始化随机森林
rf_for_tuning = RandomForestClassifier(random_state=42, n_jobs=-1)

# 使用RandomizedSearchCV,设置迭代次数和交叉验证折数
from sklearn.model_selection import RandomizedSearchCV
random_search = RandomizedSearchCV(estimator=rf_for_tuning,
                                   param_distributions=param_dist,
                                   n_iter=50,  # 随机尝试50组参数
                                   cv=5,       # 5折交叉验证
                                   scoring='accuracy',
                                   verbose=1,
                                   n_jobs=-1,
                                   random_state=42)

# 在训练集上执行随机搜索(注意:这里用完整的X_train, y_train,交叉验证会自己划分)
random_search.fit(X_train, y_train)

# 输出最佳参数和最佳得分
print("随机搜索最佳参数:", random_search.best_params_)
print("随机搜索最佳交叉验证得分: {:.4f}".format(random_search.best_score_))

随机搜索完成后,我们得到了一个表现更好的参数组合。接下来,可以围绕这个最佳组合,定义一个更小的、精细的网格,进行网格搜索

# 基于随机搜索的最佳结果,定义精细网格
best_params_rs = random_search.best_params_
param_grid = {
    'n_estimators': [best_params_rs['n_estimators'] - 50, best_params_rs['n_estimators'], best_params_rs['n_estimators'] + 50],
    'max_depth': [best_params_rs['max_depth']] if best_params_rs['max_depth'] is None else [best_params_rs['max_depth'] - 5, best_params_rs['max_depth'], best_params_rs['max_depth'] + 5],
    'min_samples_split': [best_params_rs['min_samples_split'] - 1, best_params_rs['min_samples_split'], best_params_rs['min_samples_split'] + 1],
    'min_samples_leaf': [best_params_rs['min_samples_leaf'] - 1, best_params_rs['min_samples_leaf'], best_params_rs['min_samples_leaf'] + 1],
    'max_features': [best_params_rs['max_features']], # 通常固定这个
    'bootstrap': [best_params_rs['bootstrap']] # 通常固定这个
}

# 移除可能为None或无效的值
param_grid = {k: v for k, v in param_grid.items() if v and all(x is not None and x > 0 for x in v if isinstance(x, (int, float)))}

grid_search = GridSearchCV(estimator=RandomForestClassifier(random_state=42, n_jobs=-1),
                           param_grid=param_grid,
                           cv=5,
                           scoring='accuracy',
                           verbose=1,
                           n_jobs=-1)
grid_search.fit(X_train, y_train)

print("网格搜索最佳参数:", grid_search.best_params_)
print("网格搜索最佳交叉验证得分: {:.4f}".format(grid_search.best_score_))

现在,我们用得到的最佳模型在验证集上进行最终评估:

# 获取最佳模型
best_rf = grid_search.best_estimator_

# 在验证集上评估
y_val_pred_best = best_rf.predict(X_val)
val_accuracy_best = accuracy_score(y_val, y_val_pred_best)
print(f"调优后模型 - 验证集准确率: {val_accuracy_best:.4f}")
print("\n调优后模型分类报告:")
print(classification_report(y_val, y_val_pred_best))

比较一下调优前后的验证集准确率。理想情况下,调优后的模型在验证集上应该有提升,并且训练集和验证集准确率的差距应该缩小,说明过拟合得到了缓解。

最后,我们可以用这个调优好的模型对真正的测试集(test_df)进行预测,生成Kaggle提交所需的格式。

# 确保测试集的特征列与训练集完全一致(顺序和内容)
# 我们的特征工程步骤已经保证了一致性,但最好再检查一下
assert set(X_train.columns) == set(test_df.columns), "训练集和测试集特征列不一致!"
test_df = test_df[X_train.columns] # 按训练集的列顺序重排

# 进行预测
test_predictions = best_rf.predict(test_df)

# 创建提交文件
submission = pd.DataFrame({
    'PassengerId': test_passenger_ids,
    'Survived': test_predictions
})
submission.to_csv('titanic_random_forest_submission.csv', index=False)
print("提交文件已保存为 'titanic_random_forest_submission.csv'")

至此,一个完整的、从数据到预测的随机森林分类项目就完成了。回顾整个过程,数据清洗和特征工程花费了最多精力,但这恰恰是机器学习项目中最有价值的部分。模型调优带来提升,但前提是数据本身提供了足够的信息。随机森林的稳健性让我们在特征工程时可以有更多尝试,即使有些特征不那么完美,它也能通过集成的方式抵消一部分噪声。在实际项目中,你还可以尝试更多,比如使用交叉验证更稳健地评估模型、尝试其他集成方法如梯度提升树(如XGBoost, LightGBM)进行对比,或者将多个模型的预测结果进行融合(Stacking)。机器学习是一个迭代的过程,每一次代码运行和结果分析,都会让你对数据和模型有更深的理解。

更多推荐