随机森林预测泰坦尼克号旅客存活率,这个名字在机器学习圈子里几乎是个符号。每一个自学机器学习的人,第一次接触分类问题的时候几乎都会撞上它。数据量不大、字段清楚、标签简单,却能把你往后要反复用到的特征工程、模型选型、交叉验证、参数调优这些环节全部硬生生走一遍。我在带新人做这个项目时感触很深:这个项目真正有价值的不是最后那个准确率数字,而是它能让"从数据到模型"这条链路变得非常透明。这篇文章我就用随机森林来完成这个经典预测任务,先拆数据,再做特征工程,然后讲清楚随机森林为什么适合这种小表格数据,再给出一份可以直接跑通的完整代码链路,最后把我踩过的坑一次性交代清楚。适合刚学完Python语法、准备动手做第一个机器学习项目的读者,也适合拿它当课程设计或比赛热身模板的人。1. 项目拆解与整体设计思路开场先把这个项目拆清楚。泰坦尼克号数据集来自某个经典公开竞赛,任务是预测每位旅客是否生还。训练集里有891条记录,测试集里有418条记录。特征有十几个字段,对于新手来说这个规模刚刚好:不会像图片数据那样失控,又比皮肤样本种类多,足够展示特征工程的完整流程。1.1 泰坦尼克号数据集里到底藏着什么先把字段摸一遍。这是我在项目里做的第一件事,用pandas读进来之后逐个字段看,而不是急着建模。字段名含义用途提醒PassengerId乘客编号仅用于区分样本,不能当特征Pclass舱位等级 1/2/3等级本身有顺序,可保留数值Name乘客姓名主要为了提取称呼Sex性别需要编码为数值Age年龄存在缺失,需要处理SibSp同行的兄弟姐妹和配偶数量可以组合出新特征Parch同行的父母和子女数量可以组合出新特征Ticket票号杂讯很多,一般不直接用Fare票价存在缺失值Cabin客舱编号缺失严重,但别急着删Embarked登船港口 C/Q/S分类变量,需要编码Survived是否存活(1/0)这就是我们要预测的标签很多人第一次看到Name会直接忽略,心想"名字怎么能进模型",但其实姓名里暗藏玄机。乘客的名字里通常带着'Mr.''Mrs.''Miss.''Master'这类称呼,这些称呼隐含了性别、年龄阶段、社会身份,跟生存率有明显关联。我在后面专门做了称呼提取,效果很不错。PassengerId就没什么用了,它就是给每位乘客编的号,模型不可能从编号里学到任何规律,强行放进去只会增加随机噪声。Ticket字段比较脏,票号有不同格式,处理起来收益也不高,所以我在最终特征里没有用它。1.2 为什么是随机森林:备选模型逐个过一遍有人可能会问:这个分类问题为什么不选逻辑回归,或者干脆上XGBoost?我把这几个方案的考量说清楚。逻辑回归简单、可解释性强,作为基线没问题,但它是线性模型,很难自动捕捉Pclass、Sex、Age这些字段之间的交互关系。比如"女性+高舱位"这种组合条件,逻辑回归需要你手动构造交互特征,对新手来说又多了一层门槛。XGBoost在这个数据集上往往能刷到很高的分数,但它需要调的重点参数太多,学习率、树深、正则系数、子采样比例,任何一个没调好都可能翻车。对这个数据量,XGBoost的优势并不明显,乱调反而浪费时间。随机森林处于一个很舒服的位置:它能学习非线性关系,不需要数据标准化,对缺失值有一定容忍度,自带特征重要性评估,参数数量适中,调起来不容易过拟合。对刚入门的人来说,它是拿来做第一个完整项目的最稳妥选择。2. 数据清洗与特征工程:先把手里的牌打好很多人一上来就急着跑模型,结果分数上不去,回头一看,问题是数据预处理没做到位。泰坦尼克号竞赛里,特征工程基本决定你在公开榜上的上限,模型本身反而拉不开多大差距。2.1 缺失值统计:这数据比想象中更脏我第一步先统计缺失情况,这一步能避免很多后面才爆的雷:Age:训练集缺失177条左右,占比接近20%Cabin:训练集缺失约687条,占比接近78%Embarked:训练集缺失2条Fare:训练集无缺失,但测试集有1条缺失Cabin缺失这么严重,直接删列?我建议保留,但换一种用法。实际操作中我发现,把"Cabin是否缺失"作为一个二值特征,即有没有分配客舱编号,这个信息本身就很有预测力。它间接反映了旅客的登记流程和信息完备程度,在多个特征重要性列表里都排进过前列。Cabin列本身无法安全填充,但"是否存在"这个标志是有效的。Age缺失量说大不大说小不小,不能太粗暴。很多人图省事全局中位数填充,我实测过,这么做效果明显不如分组填充。原因是不同社会身份的乘客年龄分布相差很大,用全局中位数相当于把"船长"和"儿童"拉到同一个水平,信息损失不小。Embarked只有2条缺失,直接用众数填充即可。Fare在测试集里缺失了1条,用训练集的中位数填充就行。2.2 年龄和舱位:两个最麻烦的字段怎么处理年龄的处理我用了"按称呼分组求中位数"这个经典方案。先从Name里提取称呼,然后按每个称呼组分别算中位数,比如Mr组的中位数、Miss组的中位数,用对应组的中位数去填充该组缺失的Age。这样填充出来的年龄比全局中位数更贴近真实情况。我拿填充后的数据做过对比,验证集F1比全局中位数填充高了好几个百分点。舱位Pclass处理起来比较直接,保留数值1、2、3就行,因为舱位等级本身是有序的,一等舱、二等舱、三等舱之间存在天然的顺序关系。随机森林不依赖特征尺度,不需要做标准化。这里插一句:很多人问随机森林要不要做数据标准化,答案是通常不需要。树模型分裂时只关心特征的相对大小顺序,不关心绝对数值范围,所以Age是30还是0.3,在节点分裂上没有任何区别。这是树模型友好于新手的一个重要原因。2.3 从名字和家庭关系里挖出能用的特征这是整个特征工程里最出彩的部分。先说称呼提取。我用正则从Name字段里提取'Mr.'、'Mrs.'、'Miss.'、'Master.'这些称呼,然后把出现次数很少的稀有称呼映射成'Rare'。称呼信息之所以有效,是因为它同时包含了性别、年龄阶段和社会身份。比如Master通常指未成年的男性,Captain、Doctor这些身份在船上获得的救助优先级完全不同。我在实操中发现,光加上Title这一个特征,验证集准确率就能往上走一个台阶。家庭关系组合也很有讲究。SibSp和Parch是分散的两个字段,但把它们合成FamilySize之后,含义就清晰多了。FamilySize = SibSp + Parch + 1,加的这个1是乘客本人。为什么要构造这个特征?因为单独一人出行和全家同行的人在灾难中的生存模式差别很大,家庭能形成互助关系,但超大又没有组织的家庭可能行动不便。这种"分散字段组合成新语义"的思路,是特征工程里最核心的技法之一。我还加了一个IsAlone特征,当FamilySize等于1时取1,否则取0。这个特征的作用是把"独自一人"这个极端状态单独拎出来。很多实战帖子都验证过:独自出行标签的人生还率明显更低。2.4 最终特征清单与编码方案特征工程做完之后,最终进模型的特征就定下来了:Pclass:舱位等级,保留数值1/2/3Sex:性别,映射为0/1Age:填充后的年龄,保留连续值Fare:填充后的票价,保留连续值Embarked:登船港口,映射为0/1/2Title:称呼,映射为0到4的分类值FamilySize:家庭总人数IsAlone:是否独自一人HasCabin:是否有客舱编号Sex和HasCabin这种二分类字段用0/1编码足够了。Embarked三个港口之间没有大小顺序,我直接映射成数值,随机森林对这种简单分类编码并不敏感,不过你也可以尝试OneHot编码对比效果。最终我固定只用了9个特征,这符合"小数据集用精简特征"的直觉,特征太多反而容易把噪声学进来。3. 随机森林原理:为什么它又稳又省心做完整套特征工程之后,我觉得有必要先把随机森林的原理讲透。理解原理不是让你去背概念,而是调参的时候你知道自己在干什么,模型诊断的时候你知道该看什么。3.1 从一棵决策树说起随机森林的基座是决策树。决策树做的事情很直观:在根节点上,遍历所有特征的所有可能分裂点,选一个让分裂后数据最"纯"的分裂方式。衡量"纯度"的指标一般用Gini不纯度,分裂之后两类样本分得越开,Gini下降越多,这个特征和分裂点就越优先。决策树一个人做判断很容易走极端。树一旦长得足够深,它可以把训练集里每一条样本都完美背下来,但换个新数据就完全认不出来。这就是过拟合。单棵决策树的方差高,换个数据分布,结论可能发生剧烈变化。我说个生活化的类比:决策树像是一个只靠个人经验做决定的店长,给他很多数据他就能记住所有熟客的偏好,但遇到没见过的客人就懵了。随机森林则是拉了一群店长,每个人只随机看了其中一部分客人的资料,然后投票做决定。单个人可能猜错,但一群人投票出错的概率就低得多。3.2 两个随机:行采样与列采样随机森林对每棵树做两件随机关卡,这是它区别于单一决策树的核心。第一,Bootstrap采样,也就是有放回的行采样。每棵树训练时,从原始训练集里随机有放回地抽取同样数量的样本,这样每棵树的训练集都不完全一样,有的样本在树A里出现多次,在树B里可能完全没出现。没被抽中的样本就落在"袋外",后面用来做无偏评估。第二,特征随机。每次分裂节点时,不是从全部特征里挑最优,而是先从特征集里随机挑一个子集,再从子集里找最优分裂。参数max_features控制的就是这个子集大小。两个随机关卡的核心目的是一致的:让每棵树尽可能差异化。如果所有树长成一样,投票就失去意义了。这两个机制叠加起来的效果就是降低方差。单棵树对噪声敏感,但几百棵树平均下来,噪声会互相抵消,模型整体就稳了。3.3 袋外评估与特征重要性:免费附赠的两个工具随机森林有两个不需要额外拆分验证集就能使用的副产品。第一个是袋外分数,参数里对应oob_score。每棵树都有没用到的样本,把这些袋外样本扔进这棵树预测,然后汇总所有树的结果,就能得到一个类似交叉验证的效果。在训练集比较小的时候,这个评估方式非常实用,因为它不需要再单独扣出一部分数据做验证集。第二个是特征重要性。随机森林在节点分裂时会记录每个特征带来的Gini不纯度总下降量,把所有树的结果累计起来,再归一化,就能得到每个特征的重要程度。我在这个项目里跑出来的特征重要性排名,前几位通常是Sex、Fare、Age、Title这些。这个排名能帮你看清哪些特征真正在起作用,也能帮你在特征很多时做取舍。4. 模型训练与调参实战原理过了,接下来直接进入训练环节。我的调参策略不是一上来就丢一个网格搜索跑一晚上,而是分阶段、有目的地调。4.1 先跑一个默认参数模型:观察过拟合信号我先用默认参数建一个模型,目的是看看当前特征和模型处在什么状态。之前提到的特征工程做完,直接划分训练集和验证集:from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score X_train, X_val, y_train, y_val = train_test_split( X_feat, y_label, test_size=0.2, stratify=y_label, random_state=42 ) model = RandomForestClassifier(n_estimators=100, random_state=42) model.fit(X_train, y_train) print("训练集准确率:", accuracy_score(y_train, model.predict(X_train))) print("验证集准确率:", accuracy_score(y_val, model.predict(X_val)))这个结果如果出现训练集准确率在0.92以上,验证集只有0.78左右,那就说明模型过拟合了。默认参数下,树没有被限制深度,每片叶子可以只含一个样本,它能非常好地记忆训练数据,但在新数据上泛化能力就变差。看到这种信号不要慌,这正是后面调参要解决的问题。4.2 用分层K折交叉验证做可靠评估单次划分训练集和验证集有运气成分,划分方式不同,分数波动会很大。更可靠的方式是K折交叉验证。我强烈建议用分层K折,也就是StratifiedKFold。为什么要分层?因为这个数据集里生还样本比例大约38%,如果普通K折,某一折里可能恰好生还样本比例特别低或特别高,导致评估结果失真。分层K折会在每一折里保持和整体相同的类别比例,这才公平。from sklearn.model_selection import StratifiedKFold, cross_val_score kfold = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) cv_scores = cross_val_score( model, X_feat, y_label, cv=kfold, scoring="accuracy" ) print("每折准确率:", cv_scores) print("平均准确率:", cv_scores.mean())之后所有模型评估我都基于交叉验证,不再信任单次切分的结果。4.3 网格搜索调参的正确顺序网格搜索是调参的常用方式,但很多人一上来就写一个大网格,组合爆炸,在普通笔记本上跑半小时起步。我的做法是分阶段调。第一轮先调树的规模和深度,也就是n_estimators和max_depth。n_estimators太小模型不稳定,太大训练时间涨但精度收益趋缓,我在这个数据集上常用200到300。max_depth限制每棵树能长多深,是抑制过拟合最直接的参数,经验上在8到16之间搜索比较合理。第二轮调min_samples_leaf和min_samples_split。min_samples_leaf控制叶节点至少有多少样本,默认是1,这个值太小容易过拟合,设成2或4会平滑很多。min_samples_split控制内部节点分裂所需的最小样本数,默认是2,我在2到10之间搜索。第三轮调max_features。分类问题默认用sqrt,也就是特征总数的平方根,有时设成None让每棵树用全量特征,效果反而有差异。最后用GridSearchCV把这些参数组合扫一遍:from sklearn.model_selection import GridSearchCV param_grid = { "n_estimators": [200, 300], "max_depth": [8, 12, 16], "min_samples_leaf": [1, 2, 4], "min_samples_split": [2, 5, 10], "max_features": ["sqrt", None] } clf = RandomForestClassifier(random_state=42) grid = GridSearchCV( clf, param_grid, cv=kfold, scoring="roc_auc", n_jobs=-1, verbose=1 ) grid.fit(X_train, y_train) print("最佳参数:", grid.best_params_) print("最佳得分:", grid.best_score_)这个网格有2乘3乘3乘3乘2共108个组合,配合5折交叉验证,其实已经不少了。实际操作时我把n_estimators只放了两个值,其他参数也尽量精简,普通笔记本十几分钟能跑完。4.4 指标选择:准确率之外还要看什么很多教程只看准确率,但泰坦尼克号这个数据类别本身不平衡,生还率大约38%。如果你无脑预测所有人都不生还,准确率也能有62%左右,看着不低,但模型实际上什么都没学到。所以光看准确率是不够的。我在这个项目里通常重点关注三个指标:F1分数、ROC AUC和混淆矩阵。F1是精确率和召回率的调和平均,能平衡"预测生还的人里面有多少是准的"和"真正生还的人里有多少被找回来了"。ROC AUC衡量模型把生还和未生还样本分开的能力,不受具体阈值影响,更适合作为调参时的评分标准。from sklearn.metrics import f1_score, roc_auc_score, confusion_matrix y_pred = grid.best_estimator_.predict(X_val) y_prob = grid.best_estimator_.predict_proba(X_val)[:, 1] print("F1:", f1_score(y_val, y_pred)) print("AUC:", roc_auc_score(y_val, y_prob)) print(confusion_matrix(y_val, y_pred))调参时我在GridSearchCV里用scoring参数指定roc_auc,调完之后再用F1和混淆矩阵做最终评估,这样不容易被单一指标带偏。5. 完整代码链路:从数据到预测结果前面这些环节我拆开讲了,这一部分我把它们合到一起,给你一份可以直接跑起来的完整脚本。你只需要把train.csv和test.csv放到当前目录下,按顺序执行就行。5.1 加载数据与缺失值检查import pandas as pd import numpy as np from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import StratifiedKFold, GridSearchCV from sklearn.metrics import accuracy_score, f1_score, roc_auc_score train = pd.read_csv("train.csv") test = pd.read_csv("test.csv") print("train shape:", train.shape) print("test shape:", test.shape) print("train 缺失情况:") print(train.isnull().sum()) print("test 缺失情况:") print(test.isnull().sum())这一步的目的是确认缺失值位置,尤其是测试集的Fare列,容易漏掉。5.2 特征工程函数说明我强烈建议把特征工程写成函数,这样训练集和测试集复用同一套逻辑,不容易出现两边特征不一致的惨案。这个函数在训练集上计算填充统计量,在测试集上沿用训练集的统计量,这个细节很关键,后面避坑部分会细讲。def build_features(df, age_map=None, emb_mode=None, fare_med=None): data = df.copy() # 称呼提取 data["Title"] = data["Name"].str.extract(r" ([A-Za-z]+)\.", expand=False) data["Title"] = data["Title"].replace( ["Lady", "Countess", "Capt", "Col", "Don", "Dr", "Major", "Rev", "Sir", "Jonkheer", "Dona"], "Rare" ) data["Title"] = data["Title"].replace(["Mlle", "Ms"], "Miss") data["Title"] = data["Title"].replace("Mme", "Mrs") # 统计量 if age_map is None: age_map = data.groupby("Title")["Age"].median().to_dict() if emb_mode is None: emb_mode = data["Embarked"].mode()[0] if fare_med is None: fare_med = data["Fare"].median() # 缺失值填充 data["Age"] = data["Age"].fillna(data["Title"].map(age_map)) data["Embarked"] = data["Embarked"].fillna(emb_mode) data["Fare"] = data["Fare"].fillna(fare_med) # 新特征 data["HasCabin"] = data["Cabin"].notna().astype(int) data["FamilySize"] = data["SibSp"] + data["Parch"] + 1 data["IsAlone"] = (data["FamilySize"] == 1).astype(int) # 编码 data["Sex"] = data["Sex"].map({"male": 0, "female": 1}) data["Embarked"] = data["Embarked"].map({"C": 0, "Q": 1, "S": 2}) title_mapping = {"Mr": 0, "Mrs": 1, "Miss": 2, "Master": 3, "Rare": 4} data["Title"] = data["Title"].map(title_mapping) feature_cols = ["Pclass", "Sex", "Age", "Fare", "Embarked", "Title", "FamilySize", "IsAlone", "HasCabin"] return data[feature_cols] X_train = build_features(train) y_train = train["Survived"] X_test = build_features( test, age_map=train.groupby("Title")["Age"].median().to_dict(), emb_mode=train["Embarked"].mode()[0], fare_med=train["Fare"].median() )X_train构造完后,第一次调用build_features时会在函数内部计算train的title年龄映射、港口众数和票价中位数。X_test构造时把这些统计量传进去,这样测试集的填充完全基于训练集信息,不会引入测试集信息污染训练过程。5.3 调参搜索与模型评估kfold = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) param_grid = { "n_estimators": [200, 300], "max_depth": [8, 12, 16], "min_samples_leaf": [1, 2, 4], "min_samples_split": [2, 5, 10], "max_features": ["sqrt", None] } clf = RandomForestClassifier(random_state=42) grid = GridSearchCV( clf, param_grid, cv=kfold, scoring="roc_auc", n_jobs=-1, verbose=1 ) grid.fit(X_train, y_train) print("最佳参数:", grid.best_params_) print("交叉验证 AUC:", grid.best_score_) best_model = grid.best_estimator_ cv_acc = cross_val_score(best_model, X_train, y_train, cv=kfold, scoring="accuracy") cv_f1 = cross_val_score(best_model, X_train, y_train, cv=kfold, scoring="f1") print("交叉验证准确率:", cv_acc.mean()) print("交叉验证 F1:", cv_f1.mean())如果你的机器配置一般,这个网格可能需要跑十几分钟,想快一点就把n_estimators只放[200],或者把max_depth和min_samples_leaf的组合缩小。调参结果不会每次都完全一致,但大方向是n_estimators在200到300、max_depth在8到12、min_samples_leaf在2到4这个区间附近。5.4 生成预测提交文件最后一步是把模型应用到测试集,生成提交文件。注意测试集没有Survived列,模型预测之后按PassengerId对应输出。test_pred = best_model.predict(X_test) submission = pd.DataFrame({ "PassengerId": test["PassengerId"], "Survived": test_pred }) submission.to_csv("submission.csv", index=False) print(submission.shape) print(submission.head())最后这个submission.csv就是标准的提交格式,两列数据:PassengerId和Survived。这个文件可以直接用于参加公共竞赛排名。如果你只是做练习,也可以把测试集预测结果和训练集交叉验证的结果放在一起看,判断模型有没有异常。6. 常见问题与避坑实录做完整个项目之后,我复盘了几个反复出现的坑。有些坑不自己踩一遍,光看教程真看不到。6.1 用测试集统计量填充缺失值,造成隐性数据泄露这是最隐蔽的一个问题。很多网上的教程为了省事,把train和test拼到一起,然后统一算中位数、众数,填充完再拆开。这种做法如果只是为了最后交一个结果文件,影响不算大,但如果你想用交叉验证评估模型的真实水平,就会出问题。原因在于:填充用的统计量是在全量数据上算出来的,其中包含测试集的信息。交叉验证里每一折的训练过程,就等于间接"看过"了测试集的部分统计信息,评估结果会偏乐观。我在训练集上用这种方式对比过,交叉验证分数能比严格做法高零点几个百分点,这就是虚高的信号。我的做法是像第5章的代码那样,特征工程函数里传入训练集统计量,测试集只用训练集统计量来填充。这才是没有信息泄露的做法。6.2 训练集得分极高、验证集分数不达标,怎么办默认参数的随机森林,训练集准确率能跑到0.93以上,但验证集可能只有0.78。这种差距就是典型的过拟合信号。优先调节点限制参数:max_depth设成8到12,min_samples_leaf设成2或4。这两个参数的组合对抑制过拟合的效果最明显。我在实际项目里遇到过只调max_depth不调min_samples_leaf的情况,过拟合缓解了一些,但不彻底。把min_samples_leaf从1提到4之后,训练集和验证集的差距缩小了不少,而且验证集分数没怎么掉。这说明有时过拟合不是因为树太多,而是因为叶片太细碎,每个叶子只记一两条样本,把噪声当成了规律。6.3 类别特征的编码误区Embarked和Title这类分类字段,如果直接用数值映射,随机森林通常能处理,因为它在节点分裂时并不把特征值当成有大小关系的量。但这里有个细微点需要注意:Pclass可以当作有序数值用,因为1等舱确实优于2等舱,2等舱优于3等舱,这是领域知识告诉我们的。Embarked就不一样,三个港口没有排序关系,映射成0、1、2只是给每个类别贴标签,不代表0比1"小"。随机森林不像线性模型那么介意这种编码方式,为稳妥起见,你完全可以把Embarked改成OneHot编码再对比一次。代码逻辑上多一列少一列影响不大。6.4 随机种子、并行计算和各类小报错我在调试代码时经常遇到一个问题:两次跑同样的代码,交叉验证分数不一样。原因往往是没设random_state。交叉验证里shuffle、模型的random_state都定下来,结果才能复现,我代码里所有关键位置都固定了random_state=42。调试阶段这个习惯能帮你确定分数的变化到底是调参带来的还是随机性造成的。还有一个小坑是测试集Fare缺失。train里Fare没有缺失值,但test里有一条,如果你在特征工程时只检查了train的缺失,没检查test,在预测时就会报特征数量不一致之类的错误。建模型之前把两个表的缺失情况都扫一遍,能省很多事。另一个实用建议是n_jobs这个参数。GridSearchCV里设n_jobs=-1可以用满所有CPU核心,但如果你同时在跑其他程序,机器会明显卡顿。小型数据集不至于跑很久,普通双核笔记本设成n_jobs=1或2也能忍受。我个人的习惯是每完成一个阶段就记录一下当时的交叉验证得分。阶段一的基准分数、阶段二加上特征工程后的分数、阶段三调参后的分数,三个数字一摆,就能很清楚地看到每一步到底贡献了多少提升。这个习惯让我在写总结时特别有底气,也让我后来面对其他项目时不慌。这个项目做完一次,你基本就掌握了处理表格型数据的一整套套路:看数据、做特征、选模型、交叉验证、调参、评估、提交。后续再做房价预测、信用评分这类同类型项目,思路都是相通的。随机森林在这个数据集上不是绝对的最强模型,但它稳定、可控、容易上手,作为机器学习路的第一个完整闭环,用它来练手再适合不过。
随机森林预测泰坦尼克号旅客存活率,这个名字在机器学习圈子里几乎是个符号。每一个自学机器学习的人,第一次接触分类问题的时候几乎都会撞上它。数据量不大、字段清楚、标签简单,却能把你往后要反复用到的特征工程、模型选型、交叉验证、参数调优这些环节全部硬生生走一遍。我在带新人做这个项目时感触很深:这个项目真正有价值的不是最后那个准确率数字,而是它能让"从数据到模型"这条链路变得非常透明。这篇文章我就用随机森林来完成这个经典预测任务,先拆数据,再做特征工程,然后讲清楚随机森林为什么适合这种小表格数据,再给出一份可以直接跑通的完整代码链路,最后把我踩过的坑一次性交代清楚。适合刚学完Python语法、准备动手做第一个机器学习项目的读者,也适合拿它当课程设计或比赛热身模板的人。1. 项目拆解与整体设计思路开场先把这个项目拆清楚。泰坦尼克号数据集来自某个经典公开竞赛,任务是预测每位旅客是否生还。训练集里有891条记录,测试集里有418条记录。特征有十几个字段,对于新手来说这个规模刚刚好:不会像图片数据那样失控,又比皮肤样本种类多,足够展示特征工程的完整流程。1.1 泰坦尼克号数据集里到底藏着什么先把字段摸一遍。这是我在项目里做的第一件事,用pandas读进来之后逐个字段看,而不是急着建模。字段名含义用途提醒PassengerId乘客编号仅用于区分样本,不能当特征Pclass舱位等级 1/2/3等级本身有顺序,可保留数值Name乘客姓名主要为了提取称呼Sex性别需要编码为数值Age年龄存在缺失,需要处理SibSp同行的兄弟姐妹和配偶数量可以组合出新特征Parch同行的父母和子女数量可以组合出新特征Ticket票号杂讯很多,一般不直接用Fare票价存在缺失值Cabin客舱编号缺失严重,但别急着删Embarked登船港口 C/Q/S分类变量,需要编码Survived是否存活(1/0)这就是我们要预测的标签很多人第一次看到Name会直接忽略,心想"名字怎么能进模型",但其实姓名里暗藏玄机。乘客的名字里通常带着'Mr.''Mrs.''Miss.''Master'这类称呼,这些称呼隐含了性别、年龄阶段、社会身份,跟生存率有明显关联。我在后面专门做了称呼提取,效果很不错。PassengerId就没什么用了,它就是给每位乘客编的号,模型不可能从编号里学到任何规律,强行放进去只会增加随机噪声。Ticket字段比较脏,票号有不同格式,处理起来收益也不高,所以我在最终特征里没有用它。1.2 为什么是随机森林:备选模型逐个过一遍有人可能会问:这个分类问题为什么不选逻辑回归,或者干脆上XGBoost?我把这几个方案的考量说清楚。逻辑回归简单、可解释性强,作为基线没问题,但它是线性模型,很难自动捕捉Pclass、Sex、Age这些字段之间的交互关系。比如"女性+高舱位"这种组合条件,逻辑回归需要你手动构造交互特征,对新手来说又多了一层门槛。XGBoost在这个数据集上往往能刷到很高的分数,但它需要调的重点参数太多,学习率、树深、正则系数、子采样比例,任何一个没调好都可能翻车。对这个数据量,XGBoost的优势并不明显,乱调反而浪费时间。随机森林处于一个很舒服的位置:它能学习非线性关系,不需要数据标准化,对缺失值有一定容忍度,自带特征重要性评估,参数数量适中,调起来不容易过拟合。对刚入门的人来说,它是拿来做第一个完整项目的最稳妥选择。2. 数据清洗与特征工程:先把手里的牌打好很多人一上来就急着跑模型,结果分数上不去,回头一看,问题是数据预处理没做到位。泰坦尼克号竞赛里,特征工程基本决定你在公开榜上的上限,模型本身反而拉不开多大差距。2.1 缺失值统计:这数据比想象中更脏我第一步先统计缺失情况,这一步能避免很多后面才爆的雷:Age:训练集缺失177条左右,占比接近20%Cabin:训练集缺失约687条,占比接近78%Embarked:训练集缺失2条Fare:训练集无缺失,但测试集有1条缺失Cabin缺失这么严重,直接删列?我建议保留,但换一种用法。实际操作中我发现,把"Cabin是否缺失"作为一个二值特征,即有没有分配客舱编号,这个信息本身就很有预测力。它间接反映了旅客的登记流程和信息完备程度,在多个特征重要性列表里都排进过前列。Cabin列本身无法安全填充,但"是否存在"这个标志是有效的。Age缺失量说大不大说小不小,不能太粗暴。很多人图省事全局中位数填充,我实测过,这么做效果明显不如分组填充。原因是不同社会身份的乘客年龄分布相差很大,用全局中位数相当于把"船长"和"儿童"拉到同一个水平,信息损失不小。Embarked只有2条缺失,直接用众数填充即可。Fare在测试集里缺失了1条,用训练集的中位数填充就行。2.2 年龄和舱位:两个最麻烦的字段怎么处理年龄的处理我用了"按称呼分组求中位数"这个经典方案。先从Name里提取称呼,然后按每个称呼组分别算中位数,比如Mr组的中位数、Miss组的中位数,用对应组的中位数去填充该组缺失的Age。这样填充出来的年龄比全局中位数更贴近真实情况。我拿填充后的数据做过对比,验证集F1比全局中位数填充高了好几个百分点。舱位Pclass处理起来比较直接,保留数值1、2、3就行,因为舱位等级本身是有序的,一等舱、二等舱、三等舱之间存在天然的顺序关系。随机森林不依赖特征尺度,不需要做标准化。这里插一句:很多人问随机森林要不要做数据标准化,答案是通常不需要。树模型分裂时只关心特征的相对大小顺序,不关心绝对数值范围,所以Age是30还是0.3,在节点分裂上没有任何区别。这是树模型友好于新手的一个重要原因。2.3 从名字和家庭关系里挖出能用的特征这是整个特征工程里最出彩的部分。先说称呼提取。我用正则从Name字段里提取'Mr.'、'Mrs.'、'Miss.'、'Master.'这些称呼,然后把出现次数很少的稀有称呼映射成'Rare'。称呼信息之所以有效,是因为它同时包含了性别、年龄阶段和社会身份。比如Master通常指未成年的男性,Captain、Doctor这些身份在船上获得的救助优先级完全不同。我在实操中发现,光加上Title这一个特征,验证集准确率就能往上走一个台阶。家庭关系组合也很有讲究。SibSp和Parch是分散的两个字段,但把它们合成FamilySize之后,含义就清晰多了。FamilySize = SibSp + Parch + 1,加的这个1是乘客本人。为什么要构造这个特征?因为单独一人出行和全家同行的人在灾难中的生存模式差别很大,家庭能形成互助关系,但超大又没有组织的家庭可能行动不便。这种"分散字段组合成新语义"的思路,是特征工程里最核心的技法之一。我还加了一个IsAlone特征,当FamilySize等于1时取1,否则取0。这个特征的作用是把"独自一人"这个极端状态单独拎出来。很多实战帖子都验证过:独自出行标签的人生还率明显更低。2.4 最终特征清单与编码方案特征工程做完之后,最终进模型的特征就定下来了:Pclass:舱位等级,保留数值1/2/3Sex:性别,映射为0/1Age:填充后的年龄,保留连续值Fare:填充后的票价,保留连续值Embarked:登船港口,映射为0/1/2Title:称呼,映射为0到4的分类值FamilySize:家庭总人数IsAlone:是否独自一人HasCabin:是否有客舱编号Sex和HasCabin这种二分类字段用0/1编码足够了。Embarked三个港口之间没有大小顺序,我直接映射成数值,随机森林对这种简单分类编码并不敏感,不过你也可以尝试OneHot编码对比效果。最终我固定只用了9个特征,这符合"小数据集用精简特征"的直觉,特征太多反而容易把噪声学进来。3. 随机森林原理:为什么它又稳又省心做完整套特征工程之后,我觉得有必要先把随机森林的原理讲透。理解原理不是让你去背概念,而是调参的时候你知道自己在干什么,模型诊断的时候你知道该看什么。3.1 从一棵决策树说起随机森林的基座是决策树。决策树做的事情很直观:在根节点上,遍历所有特征的所有可能分裂点,选一个让分裂后数据最"纯"的分裂方式。衡量"纯度"的指标一般用Gini不纯度,分裂之后两类样本分得越开,Gini下降越多,这个特征和分裂点就越优先。决策树一个人做判断很容易走极端。树一旦长得足够深,它可以把训练集里每一条样本都完美背下来,但换个新数据就完全认不出来。这就是过拟合。单棵决策树的方差高,换个数据分布,结论可能发生剧烈变化。我说个生活化的类比:决策树像是一个只靠个人经验做决定的店长,给他很多数据他就能记住所有熟客的偏好,但遇到没见过的客人就懵了。随机森林则是拉了一群店长,每个人只随机看了其中一部分客人的资料,然后投票做决定。单个人可能猜错,但一群人投票出错的概率就低得多。3.2 两个随机:行采样与列采样随机森林对每棵树做两件随机关卡,这是它区别于单一决策树的核心。第一,Bootstrap采样,也就是有放回的行采样。每棵树训练时,从原始训练集里随机有放回地抽取同样数量的样本,这样每棵树的训练集都不完全一样,有的样本在树A里出现多次,在树B里可能完全没出现。没被抽中的样本就落在"袋外",后面用来做无偏评估。第二,特征随机。每次分裂节点时,不是从全部特征里挑最优,而是先从特征集里随机挑一个子集,再从子集里找最优分裂。参数max_features控制的就是这个子集大小。两个随机关卡的核心目的是一致的:让每棵树尽可能差异化。如果所有树长成一样,投票就失去意义了。这两个机制叠加起来的效果就是降低方差。单棵树对噪声敏感,但几百棵树平均下来,噪声会互相抵消,模型整体就稳了。3.3 袋外评估与特征重要性:免费附赠的两个工具随机森林有两个不需要额外拆分验证集就能使用的副产品。第一个是袋外分数,参数里对应oob_score。每棵树都有没用到的样本,把这些袋外样本扔进这棵树预测,然后汇总所有树的结果,就能得到一个类似交叉验证的效果。在训练集比较小的时候,这个评估方式非常实用,因为它不需要再单独扣出一部分数据做验证集。第二个是特征重要性。随机森林在节点分裂时会记录每个特征带来的Gini不纯度总下降量,把所有树的结果累计起来,再归一化,就能得到每个特征的重要程度。我在这个项目里跑出来的特征重要性排名,前几位通常是Sex、Fare、Age、Title这些。这个排名能帮你看清哪些特征真正在起作用,也能帮你在特征很多时做取舍。4. 模型训练与调参实战原理过了,接下来直接进入训练环节。我的调参策略不是一上来就丢一个网格搜索跑一晚上,而是分阶段、有目的地调。4.1 先跑一个默认参数模型:观察过拟合信号我先用默认参数建一个模型,目的是看看当前特征和模型处在什么状态。之前提到的特征工程做完,直接划分训练集和验证集:from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score X_train, X_val, y_train, y_val = train_test_split( X_feat, y_label, test_size=0.2, stratify=y_label, random_state=42 ) model = RandomForestClassifier(n_estimators=100, random_state=42) model.fit(X_train, y_train) print("训练集准确率:", accuracy_score(y_train, model.predict(X_train))) print("验证集准确率:", accuracy_score(y_val, model.predict(X_val)))这个结果如果出现训练集准确率在0.92以上,验证集只有0.78左右,那就说明模型过拟合了。默认参数下,树没有被限制深度,每片叶子可以只含一个样本,它能非常好地记忆训练数据,但在新数据上泛化能力就变差。看到这种信号不要慌,这正是后面调参要解决的问题。4.2 用分层K折交叉验证做可靠评估单次划分训练集和验证集有运气成分,划分方式不同,分数波动会很大。更可靠的方式是K折交叉验证。我强烈建议用分层K折,也就是StratifiedKFold。为什么要分层?因为这个数据集里生还样本比例大约38%,如果普通K折,某一折里可能恰好生还样本比例特别低或特别高,导致评估结果失真。分层K折会在每一折里保持和整体相同的类别比例,这才公平。from sklearn.model_selection import StratifiedKFold, cross_val_score kfold = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) cv_scores = cross_val_score( model, X_feat, y_label, cv=kfold, scoring="accuracy" ) print("每折准确率:", cv_scores) print("平均准确率:", cv_scores.mean())之后所有模型评估我都基于交叉验证,不再信任单次切分的结果。4.3 网格搜索调参的正确顺序网格搜索是调参的常用方式,但很多人一上来就写一个大网格,组合爆炸,在普通笔记本上跑半小时起步。我的做法是分阶段调。第一轮先调树的规模和深度,也就是n_estimators和max_depth。n_estimators太小模型不稳定,太大训练时间涨但精度收益趋缓,我在这个数据集上常用200到300。max_depth限制每棵树能长多深,是抑制过拟合最直接的参数,经验上在8到16之间搜索比较合理。第二轮调min_samples_leaf和min_samples_split。min_samples_leaf控制叶节点至少有多少样本,默认是1,这个值太小容易过拟合,设成2或4会平滑很多。min_samples_split控制内部节点分裂所需的最小样本数,默认是2,我在2到10之间搜索。第三轮调max_features。分类问题默认用sqrt,也就是特征总数的平方根,有时设成None让每棵树用全量特征,效果反而有差异。最后用GridSearchCV把这些参数组合扫一遍:from sklearn.model_selection import GridSearchCV param_grid = { "n_estimators": [200, 300], "max_depth": [8, 12, 16], "min_samples_leaf": [1, 2, 4], "min_samples_split": [2, 5, 10], "max_features": ["sqrt", None] } clf = RandomForestClassifier(random_state=42) grid = GridSearchCV( clf, param_grid, cv=kfold, scoring="roc_auc", n_jobs=-1, verbose=1 ) grid.fit(X_train, y_train) print("最佳参数:", grid.best_params_) print("最佳得分:", grid.best_score_)这个网格有2乘3乘3乘3乘2共108个组合,配合5折交叉验证,其实已经不少了。实际操作时我把n_estimators只放了两个值,其他参数也尽量精简,普通笔记本十几分钟能跑完。4.4 指标选择:准确率之外还要看什么很多教程只看准确率,但泰坦尼克号这个数据类别本身不平衡,生还率大约38%。如果你无脑预测所有人都不生还,准确率也能有62%左右,看着不低,但模型实际上什么都没学到。所以光看准确率是不够的。我在这个项目里通常重点关注三个指标:F1分数、ROC AUC和混淆矩阵。F1是精确率和召回率的调和平均,能平衡"预测生还的人里面有多少是准的"和"真正生还的人里有多少被找回来了"。ROC AUC衡量模型把生还和未生还样本分开的能力,不受具体阈值影响,更适合作为调参时的评分标准。from sklearn.metrics import f1_score, roc_auc_score, confusion_matrix y_pred = grid.best_estimator_.predict(X_val) y_prob = grid.best_estimator_.predict_proba(X_val)[:, 1] print("F1:", f1_score(y_val, y_pred)) print("AUC:", roc_auc_score(y_val, y_prob)) print(confusion_matrix(y_val, y_pred))调参时我在GridSearchCV里用scoring参数指定roc_auc,调完之后再用F1和混淆矩阵做最终评估,这样不容易被单一指标带偏。5. 完整代码链路:从数据到预测结果前面这些环节我拆开讲了,这一部分我把它们合到一起,给你一份可以直接跑起来的完整脚本。你只需要把train.csv和test.csv放到当前目录下,按顺序执行就行。5.1 加载数据与缺失值检查import pandas as pd import numpy as np from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import StratifiedKFold, GridSearchCV from sklearn.metrics import accuracy_score, f1_score, roc_auc_score train = pd.read_csv("train.csv") test = pd.read_csv("test.csv") print("train shape:", train.shape) print("test shape:", test.shape) print("train 缺失情况:") print(train.isnull().sum()) print("test 缺失情况:") print(test.isnull().sum())这一步的目的是确认缺失值位置,尤其是测试集的Fare列,容易漏掉。5.2 特征工程函数说明我强烈建议把特征工程写成函数,这样训练集和测试集复用同一套逻辑,不容易出现两边特征不一致的惨案。这个函数在训练集上计算填充统计量,在测试集上沿用训练集的统计量,这个细节很关键,后面避坑部分会细讲。def build_features(df, age_map=None, emb_mode=None, fare_med=None): data = df.copy() # 称呼提取 data["Title"] = data["Name"].str.extract(r" ([A-Za-z]+)\.", expand=False) data["Title"] = data["Title"].replace( ["Lady", "Countess", "Capt", "Col", "Don", "Dr", "Major", "Rev", "Sir", "Jonkheer", "Dona"], "Rare" ) data["Title"] = data["Title"].replace(["Mlle", "Ms"], "Miss") data["Title"] = data["Title"].replace("Mme", "Mrs") # 统计量 if age_map is None: age_map = data.groupby("Title")["Age"].median().to_dict() if emb_mode is None: emb_mode = data["Embarked"].mode()[0] if fare_med is None: fare_med = data["Fare"].median() # 缺失值填充 data["Age"] = data["Age"].fillna(data["Title"].map(age_map)) data["Embarked"] = data["Embarked"].fillna(emb_mode) data["Fare"] = data["Fare"].fillna(fare_med) # 新特征 data["HasCabin"] = data["Cabin"].notna().astype(int) data["FamilySize"] = data["SibSp"] + data["Parch"] + 1 data["IsAlone"] = (data["FamilySize"] == 1).astype(int) # 编码 data["Sex"] = data["Sex"].map({"male": 0, "female": 1}) data["Embarked"] = data["Embarked"].map({"C": 0, "Q": 1, "S": 2}) title_mapping = {"Mr": 0, "Mrs": 1, "Miss": 2, "Master": 3, "Rare": 4} data["Title"] = data["Title"].map(title_mapping) feature_cols = ["Pclass", "Sex", "Age", "Fare", "Embarked", "Title", "FamilySize", "IsAlone", "HasCabin"] return data[feature_cols] X_train = build_features(train) y_train = train["Survived"] X_test = build_features( test, age_map=train.groupby("Title")["Age"].median().to_dict(), emb_mode=train["Embarked"].mode()[0], fare_med=train["Fare"].median() )X_train构造完后,第一次调用build_features时会在函数内部计算train的title年龄映射、港口众数和票价中位数。X_test构造时把这些统计量传进去,这样测试集的填充完全基于训练集信息,不会引入测试集信息污染训练过程。5.3 调参搜索与模型评估kfold = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) param_grid = { "n_estimators": [200, 300], "max_depth": [8, 12, 16], "min_samples_leaf": [1, 2, 4], "min_samples_split": [2, 5, 10], "max_features": ["sqrt", None] } clf = RandomForestClassifier(random_state=42) grid = GridSearchCV( clf, param_grid, cv=kfold, scoring="roc_auc", n_jobs=-1, verbose=1 ) grid.fit(X_train, y_train) print("最佳参数:", grid.best_params_) print("交叉验证 AUC:", grid.best_score_) best_model = grid.best_estimator_ cv_acc = cross_val_score(best_model, X_train, y_train, cv=kfold, scoring="accuracy") cv_f1 = cross_val_score(best_model, X_train, y_train, cv=kfold, scoring="f1") print("交叉验证准确率:", cv_acc.mean()) print("交叉验证 F1:", cv_f1.mean())如果你的机器配置一般,这个网格可能需要跑十几分钟,想快一点就把n_estimators只放[200],或者把max_depth和min_samples_leaf的组合缩小。调参结果不会每次都完全一致,但大方向是n_estimators在200到300、max_depth在8到12、min_samples_leaf在2到4这个区间附近。5.4 生成预测提交文件最后一步是把模型应用到测试集,生成提交文件。注意测试集没有Survived列,模型预测之后按PassengerId对应输出。test_pred = best_model.predict(X_test) submission = pd.DataFrame({ "PassengerId": test["PassengerId"], "Survived": test_pred }) submission.to_csv("submission.csv", index=False) print(submission.shape) print(submission.head())最后这个submission.csv就是标准的提交格式,两列数据:PassengerId和Survived。这个文件可以直接用于参加公共竞赛排名。如果你只是做练习,也可以把测试集预测结果和训练集交叉验证的结果放在一起看,判断模型有没有异常。6. 常见问题与避坑实录做完整个项目之后,我复盘了几个反复出现的坑。有些坑不自己踩一遍,光看教程真看不到。6.1 用测试集统计量填充缺失值,造成隐性数据泄露这是最隐蔽的一个问题。很多网上的教程为了省事,把train和test拼到一起,然后统一算中位数、众数,填充完再拆开。这种做法如果只是为了最后交一个结果文件,影响不算大,但如果你想用交叉验证评估模型的真实水平,就会出问题。原因在于:填充用的统计量是在全量数据上算出来的,其中包含测试集的信息。交叉验证里每一折的训练过程,就等于间接"看过"了测试集的部分统计信息,评估结果会偏乐观。我在训练集上用这种方式对比过,交叉验证分数能比严格做法高零点几个百分点,这就是虚高的信号。我的做法是像第5章的代码那样,特征工程函数里传入训练集统计量,测试集只用训练集统计量来填充。这才是没有信息泄露的做法。6.2 训练集得分极高、验证集分数不达标,怎么办默认参数的随机森林,训练集准确率能跑到0.93以上,但验证集可能只有0.78。这种差距就是典型的过拟合信号。优先调节点限制参数:max_depth设成8到12,min_samples_leaf设成2或4。这两个参数的组合对抑制过拟合的效果最明显。我在实际项目里遇到过只调max_depth不调min_samples_leaf的情况,过拟合缓解了一些,但不彻底。把min_samples_leaf从1提到4之后,训练集和验证集的差距缩小了不少,而且验证集分数没怎么掉。这说明有时过拟合不是因为树太多,而是因为叶片太细碎,每个叶子只记一两条样本,把噪声当成了规律。6.3 类别特征的编码误区Embarked和Title这类分类字段,如果直接用数值映射,随机森林通常能处理,因为它在节点分裂时并不把特征值当成有大小关系的量。但这里有个细微点需要注意:Pclass可以当作有序数值用,因为1等舱确实优于2等舱,2等舱优于3等舱,这是领域知识告诉我们的。Embarked就不一样,三个港口没有排序关系,映射成0、1、2只是给每个类别贴标签,不代表0比1"小"。随机森林不像线性模型那么介意这种编码方式,为稳妥起见,你完全可以把Embarked改成OneHot编码再对比一次。代码逻辑上多一列少一列影响不大。6.4 随机种子、并行计算和各类小报错我在调试代码时经常遇到一个问题:两次跑同样的代码,交叉验证分数不一样。原因往往是没设random_state。交叉验证里shuffle、模型的random_state都定下来,结果才能复现,我代码里所有关键位置都固定了random_state=42。调试阶段这个习惯能帮你确定分数的变化到底是调参带来的还是随机性造成的。还有一个小坑是测试集Fare缺失。train里Fare没有缺失值,但test里有一条,如果你在特征工程时只检查了train的缺失,没检查test,在预测时就会报特征数量不一致之类的错误。建模型之前把两个表的缺失情况都扫一遍,能省很多事。另一个实用建议是n_jobs这个参数。GridSearchCV里设n_jobs=-1可以用满所有CPU核心,但如果你同时在跑其他程序,机器会明显卡顿。小型数据集不至于跑很久,普通双核笔记本设成n_jobs=1或2也能忍受。我个人的习惯是每完成一个阶段就记录一下当时的交叉验证得分。阶段一的基准分数、阶段二加上特征工程后的分数、阶段三调参后的分数,三个数字一摆,就能很清楚地看到每一步到底贡献了多少提升。这个习惯让我在写总结时特别有底气,也让我后来面对其他项目时不慌。这个项目做完一次,你基本就掌握了处理表格型数据的一整套套路:看数据、做特征、选模型、交叉验证、调参、评估、提交。后续再做房价预测、信用评分这类同类型项目,思路都是相通的。随机森林在这个数据集上不是绝对的最强模型,但它稳定、可控、容易上手,作为机器学习路的第一个完整闭环,用它来练手再适合不过。