1. 项目思路与方案选型
1.1 为什么拿Titanic练手而不是直接换个复杂项目
如果你刚接触机器学习,有一个数据集你迟早会碰到,那就是泰坦尼克号幸存者记录。我入行那年带我的前辈说了一句到现在我都觉得有道理的话:先用小数据把流程跑通,再用大数据去追求效果。Titanic数据集恰好满足这三个条件——数据量小、字段类型杂、目标清晰。
Titanic的原始数据不到900行,这个规模对决策树这种模型来说训练时间几乎可以忽略不计,哪怕你在网格搜索里试几百组参数,也就几秒钟的事。更重要的是,这个数据集不是一个已经帮你处理好的“干净表格”,它里面有缺失值、有类别型字段、有看起来没什么用的文本字段,比如乘客姓名和票号。这些脏数据的处理过程,才是这个项目真正的含金量所在。
决策树作为第一个正式的监督学习模型,对新手非常友好。它不像神经网络那样是个黑盒,也不像线性回归那样需要你先理解一堆统计假设。决策树本质上是一连串“如果……那么……”的判断,训练完看一眼图你就知道模型是怎么做决定的。这个特性在业务沟通中太重要了,很多时候你需要的不是准确率多0.5个百分点,而是能跟业务方说清楚“模型为什么这么做判断”。
所以这个项目最合适的场景是:你想完整走一遍“数据读取—清洗—特征工程—训练—调参—评估—可视化”的机器学习标准流程,并且想从此建立起对决策树这个模型的直观感觉。不管你是学生、转行做数据分析的同学,还是已经在做业务建模的工程师,拿它当第一个完整项目都不会觉得白费时间。
1.2 决策树原理只说人话版:猜人游戏和信息增益
决策树每次选一个特征做判断,选的依据是这个特征能多大程度把数据分开。我常用一个生活中的例子来类比:你参加一个猜人游戏,要在20个人里猜出目标是谁。你会先问什么?大多数人会先问性别,而不是问鞋码。为什么?因为性别能把人群分成两半,信息量最大;鞋码虽然也有区分度,但不如性别高效。
决策树的“选特征”逻辑就是干这个的。sklearn里的决策树用两个指标衡量一个特征的好坏:一个是基尼系数(gini),一个是信息熵(entropy)。你不用把这两个公式背下来,只需要知道它们的作用是度量“当前这堆数据有多混乱”。混乱度越低,说明这个特征切完之后的分类效果越好。每次做切分,模型会遍历所有特征的所有可能阈值,算出每个切分能把混乱度降低多少,然后选那个降得最多的方案。
这个“选最优先问的问题”的动作,从根节点一直重复到叶子节点,直到满足停止条件。所以你在最终得到的树里看到的第一层判断,通常就是区分度最高的特征。在Titanic这个场景里,性别几乎总是第一个被选中,这跟后来大家总结的“女性幸存率远高于男性”的现实规律是一致的。
1.3 为什么用sklearn而不是自己写或者换其他框架
用sklearn来实践决策树,几乎是必然选择。原因很朴素:它的接口设计是业界标准化的,fit训练、predict预测、score打分,三步就把一次完整实验闭环了。对于教学和快速验证来说,没有比这更稳的。你不需要自己实现树的构建逻辑,那部分涉及的递归、基尼系数计算和剪枝策略,虽然有趣,但对于想先理解模型行为的人来说反而是个干扰。
TensorFlow和PyTorch当然也能做分类,但它们是为大规模神经网络设计的,用来跑一棵决策树属于杀鸡用牛刀,还要引入额外的张量操作和计算图概念,徒增学习成本。而LightGBM和XGBoost虽然也是树模型,但它们主打的是提升方法,默认的无限制深度策略和正则化思路跟单棵决策树差别很大,不适合作为入门第一课。
所以我的结论是:这个项目用sklearn的DecisionTreeClassifier,是“在正确的时间选了正确的工具”。等你有空再回头去研究树的构建源码,那时候你对决策树的理解会和现在完全不同。
2. 数据层面的准备与特征工程
2.1 先把数据摊开看:有哪些字段,各是什么含义
拿到数据之后别急着写代码建模,先花几分钟把每个字段过一遍。这一步很多人跳过,后续调参的时候就会看不懂特征重要性结果。Titanic原始数据大致包含下面这些字段:
| 字段名 | 含义 | 常见类型 | 备注 |
|---|---|---|---|
| PassengerId | 乘客编号 | 整数 | 没有预测意义,用来当索引 |
| Survived | 是否幸存 | 0/1 | 标签列 |
| Pclass | 船舱等级 | 1/2/3 | 有顺序性的类别变量 |
| Name | 姓名 | 文本 | 需做特征工程才有价值 |
| Sex | 性别 | male/female | 二分类类别变量 |
| Age | 年龄 | 小数 | 存在缺失值 |
| SibSp | 同行的兄弟姐妹/配偶数 | 整数 | 可与Parch组合 |
| Parch | 同行的父母/子女数 | 整数 | 可与SibSp组合 |
| Ticket | 票号 | 文本 | 基数很高,直接使用会导致过拟合 |
| Fare | 票价 | 小数 | 连续变量,有少数缺失 |
| Cabin | 船舱号 | 文本 | 缺失率极高,直接填不是好选择 |
| Embarked | 登船港口 | C/Q/S | 类别变量,有少量缺失 |
看这张表的时候,你脑子里就应该开始做初步判断了。哪些字段可以直接用?性别、船舱等级、票价、年龄都有明显的生存率区分度。哪些字段有问题?年龄有缺失,船舱缺失了将近八成,票号和姓名看起来就很难直接丢进模型。这就是特征工程的起点。
2.2 处理缺失值:填均值还是填中位数
Titanic数据集中缺失最多的是Cabin字段,接近八成样本没有记录;其次Age,大概一百多行缺失;Embarked最少,只有两行缺失。不同字段的缺失处理策略完全不同,不能一刀切。
Age缺失我觉得最稳妥的做法是用中位数填充。为什么不是均值?因为年龄分布不是标准正态的,它偏向幼年和中青年,存在一些高龄异常值,均值会被拉偏。中位数能更好地代表数据的中间水平。你在实验中可以分别试一下均值和中位数,大概率会发现两者对最终精度影响很小,但中位数在理论上是更稳的选择。
Cabin缺失率太高了,填个“无”或者填一个特定的值都是给模型引入噪声。我之前见过有人给缺失Cabin填上“C85”,结果模型学到“C85这个船舱的人全部幸存”之类的虚假规律,这属于典型的过拟合。正确做法是把Cabin变成一个二值特征——有没有船舱记录。因为当时能分配到船舱号的人,通常意味着有明确的居住舱位,这在某种程度上跟票价和身份相关,实际跑下来这个新特征是有区分度的。
Embarked只有两行缺失,直接用众数填掉就行,这种数量级的缺失不值得花时间。
2.3 把文本变成数字:性别、港口和组合特征
sklearn的决策树本身不能直接处理字符串,所以需要把类别字段做编码。Sex最简单,把male和female映射成0和1,因为二分类变量用数值映射和One-Hot在决策树里的效果完全一样。
Embarked有三个取值:C、Q、S。最简单的方法是用pd.get_dummies做One-Hot,扩展出三列。当然也可以直接映射成0/1/2,决策树对数值型处理时会按阈值切分,这两种方案效果差不多,On-Hot的可解释性更好一点。
Pclass保留原始数值1/2/3即可,因为它本身就有顺序性,1等舱贵、2等舱次之、3等舱最便宜,这个顺序对树模型的切分是有意义的。
SibSp和Parch这两个字段很多人直接丢进模型就完事了,其实更好的做法是做个组合。比如生成一个新特征FamilySize = SibSp + Parch + 1,加1是把自己也算进去。另一个常见做法是生成IsAlone,FamilySize为1时取1,否则取0。原因很简单:单独出行的人和家庭出行的人在事故中的生存策略差别很大,单独把两个原始字段交给模型,它也能学出来,但组合特征能让模型更容易找到这个规律,尤其是数据量小的时候效果更明显。
Name这个字段别急着扔掉。从里面提取称呼(Mr、Mrs、Miss、Master等)其实是一个很有效的特征,因为这些称呼间接反映了性别、年龄和婚姻状态。不过对入门项目来说,这个特征不是必需的,属于锦上添花,我建议先把基础特征做好,回头再来试增强版本。
2.4 划分数据集时有一个新手必踩的坑
特征处理完之后,划分训练集和验证集看似简单,但里面有一个细节必须注意:使用train_test_split时给stratify参数传入Survived列。这样划分出来的训练集和验证集里,幸存者比例会跟原始数据保持一致,避免因为随机划分导致验证集里幸存者占比特别高或者特别低,否则你的模型评估结果会很不稳定。
另一个必须做的事是固定random_state,我习惯固定成42。不固定的话,每次运行代码划分出不同的数据子集,模型得分会有上下浮动,你会以为模型出了问题,其实只是随机种子在捣乱。固定随机种子不仅让自己复现实验结果,也让别人能按你的步骤还原,这是做实验的基本修养。
这里顺带提一个更隐蔽的坑:数据清洗时对缺失值填充、特征缩放这一类计算,都应该在划分数据集之后,分别对训练集和验证集做。也就是说,先用训练集算出中位数,再拿这个中位数去填验证集,不能先在全量数据上算好中位数再去划分,那样会造成信息泄漏,让验证集的评估结果虚高。
3. 决策树模型的构建与可视化
3.1 先跑一个基线模型看看底线在哪
不管特征工程做得再花哨,第一版模型都应该用默认参数跑通流程,目的不是为了拿到漂亮分数,而是为了验证数据管道没有bug。代码不长,核心就几行。
from sklearn.tree import DecisionTreeClassifier from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) clf = DecisionTreeClassifier(random_state=42) clf.fit(X_train, y_train) train_score = clf.score(X_train, y_train) test_score = clf.score(X_test, y_test) print(f"训练集准确率: {train_score:.4f}") print(f"验证集准确率: {test_score:.4f}")我实跑的结果是训练集准确率接近0.98,验证集准确率在0.78到0.82之间浮动。这个差距就是你接下来要解决的过拟合信号。训练集得分逼近满分说明模型把训练数据的规律和噪声一起记住了,验证集上泛化能力不足。从这里开始,调参就会变成一件有目标感的事,而不是瞎试。
3.2 把树画出来:一张图看懂模型的判断逻辑
决策树最大的优势之一就是可解释性,前提是你把它画出来。sklearn提供了plot_tree,用起来很方便。
import matplotlib.pyplot as plt from sklearn.tree import plot_tree plt.figure(figsize=(20, 12)) plot_tree( clf, max_depth=3, feature_names=feature_names, class_names=["Not Survived", "Survived"], filled=True, rounded=True, fontsize=10 ) plt.show()max_depth=3是故意限制的可视化深度,因为默认树的完整形态画出来是一大坨,根本没法看。限制到三层之后,每层展示的信息仍然够说明问题。
看图的时候重点关注几个字段。根节点显示判断条件、基尼系数、样本量和分类结果占比。比如根节点可能是Sex <= 0.5,左边是男性,右边是女性,基尼系数从整体数据的0.47左右开始,切分之后两边各自的基尼系数都有明显下降。继续往下看,男性的分支里下一层很可能根据Pclass和Age继续切,而女性分支可能更简单。这棵三层小树已经能把核心规律讲清楚了:性别是首要区分因素,等级和年龄在性别内部继续做分层。
这种可解释性在实际业务里价值极大。你不可能给业务方展示一堆模型参数,但你可以给他们看一棵树:“先看性别,再看船舱等级,最后看年龄”。对方一听就懂,模型的信任度建立起来,后面很多沟通障碍都会变小。
3.3 特征重要性排序:哪些变量真正起作用
DecisionTreeClassifier训练好之后,可以从feature_importances_属性里读出每个特征的重要性。sklearn把决策树每个节点分裂时减少的加权基尼系数累加到对应特征上,最后归一化,得到的就是特征重要性。
importance = pd.Series( clf.feature_importances_, index=feature_names ).sort_values(ascending=False) print(importance)在这个项目里排名靠前的特征通常包括Sex、Fare、Age、Pclass,这跟历史事实对得上:女性和儿童优先逃生,头等舱乘客离救生艇更近。排在最后的基本上是IsAlone、Embarked这一类。特征重要性能帮你验证特征工程的方向是否正确,也能帮你剔除一些没用的字段。但注意一点,特征重要性不是因果关系的证明,它描述的只是这个特征在当前模型里对预测的贡献程度,这点别过度解读。
3.4 剪枝才是决策树真正的技术活
默认参数会让树长到叶子节点不能再分或已经纯净为止,这颗树极大概率发生过拟合。决策树的调参,本质上是限制树的生长,让它不要走火入魔。
三个核心参数按优先级来说,max_depth是控制树的整体深度的,最直接;min_samples_leaf控制每个叶子节点最少包含多少个样本,间接限制了树的复杂度和末端细节;min_samples_split控制一个内部节点至少要有多少样本才继续分裂。实际经验是,先定max_depth,再调min_samples_leaf,min_samples_split的影响相对弱一些。
clf_pruned = DecisionTreeClassifier( max_depth=4, min_samples_leaf=6, random_state=42 ) clf_pruned.fit(X_train, y_train) print(f"剪枝后训练集准确率: {clf_pruned.score(X_train, y_train):.4f}") print(f"剪枝后验证集准确率: {clf_pruned.score(X_test, y_test):.4f}")剪枝后的训练集准确率通常会从0.98降到0.85左右,但验证集准确率往往能小幅提升,并且之前的“训练集和验证集差距过大”的问题会明显缓解。别看到训练集分数下降就觉得模型变差了,机器学习里训练集的得分高可不是什么好消息。真正的验证标准是验证集的泛化表现。
4. 超参数调优与模型评估
4.1 手动试参数不如网格搜索:把调参自动化
手动调参遇到的问题是,参数之间会互相影响。你可能试了很多组max_depth,但每次效果方差都很大,因为你没意识到min_samples_leaf才是限制过拟合更关键的参数。手动一个个试着改,效率太低,而且很容易被单一维度的结果带偏。
用GridSearchCV做网格搜索,一次把所有参数的组合都试一遍,交叉验证帮我选出最稳的一组参数。Titanic数据量小,几百组参数几分钟就能跑完,完全没有性能压力。下面这段代码是我在这个项目里实际用过的搜索范围:
from sklearn.model_selection import GridSearchCV param_grid = { 'max_depth': [3, 4, 5, 6, 8, 10], 'min_samples_split': [2, 5, 10, 20], 'min_samples_leaf': [2, 4, 6, 8, 10] } clf = DecisionTreeClassifier(random_state=42) grid = GridSearchCV( clf, param_grid, cv=5, scoring='accuracy', n_jobs=-1 ) grid.fit(X_train, y_train) print(f"最佳参数组合: {grid.best_params_}") print(f"交叉验证最优得分: {grid.best_score_:.4f}")cv=5意味着每组参数都会做5折交叉验证,最终选出的是在多个子集上表现都稳定的参数,而不是碰运气靠单次划分得到的偶然高分。我之前跑出来的一组典型最佳参数是max_depth=4, min_samples_split=10, min_samples_leaf=6,稳定好用。当然,具体数值会因特征工程方式不同而变化,你跑出来的可能不一样,这很正常。
4.2 别只盯准确率,混淆矩阵和召回率同样重要
Titanic的标签并不是完全平衡的,幸存者大约342人,遇难者约549人。比例大概是38对62。这种程度的不平衡下,准确率虽然不会过分失真,但会产生一个常见误导:模型什么都不做,全预测成“遇难”,准确率也能有0.62左右。
所以评估的时候要同时看混淆矩阵和精确率、召回率、F1值。召回率在这里的意思是:真实幸存的人里,有多大比例被模型正确找出来了。在泰坦尼克这个场景里,召回率尤其有业务含义——如果你想知道“到底哪些人有更大可能性生还”,模型漏掉幸存者带来的代价比误判一个遇难者更值得关注。
from sklearn.metrics import confusion_matrix, classification_report y_pred = clf_best.predict(X_test) print(confusion_matrix(y_test, y_pred)) print(classification_report(y_test, y_pred))classification_report会一次性输出两个类别的精确率、召回率、F1和样本量,内容很直观。我见过有人只打印准确率,看到0.82就收工了,其实可能某一个类别的召回率只有0.6,这个结果在业务上是不合格的。训练完模型之后,一定要养成分开看每一类指标的习惯。
4.3 训练集和验证集的差距才是泛化能力的镜子
调参完之后,我习惯把调参前后的结果摆在一起对比,看两件事。第一,验证集分数有没有提升;第二,训练集和验证集的差距有没有缩小。
| 模型版本 | 训练集准确率 | 验证集准确率 | 差距 |
|---|---|---|---|
| 默认参数 | 0.983 | 0.805 | 0.178 |
| 剪枝后 | 0.862 | 0.827 | 0.035 |
差距从0.17缩到0.03,说明模型不再把训练集的噪声当规律记了。这才是一个合格的模型状态。判断调参是否有效的标准不是训练集分数有多高,而是训练集和验证集之间的差距是否合理。
如果某种参数组合的验证集得分高了,但训练集和验证集差距反而更大,那要提高警惕,这组参数很可能是通过某些随机因素碰出来的,换个种子就不灵了。真正的泛化能力要建立在稳定的交叉验证结果上。
5. 常见问题与排查技巧实录
5.1 为什么画出来的树特别深、特别乱
默认参数训练出来的树,可能深达十几层,每个叶子节点几乎都能把样本分到完全纯净。看起来模型很厉害,各种规则能记住每一个样本,但你要意识到,树把数据集里那些只是巧合出现的模式也学进去了。比如训练集里恰好有两个乘客票价相同且都幸存了,树就会去记忆这个规则,但这种规则在验证集上毫无意义。
处理办法就是剪枝,优先限制max_depth和min_samples_leaf。我的经验是先从max_depth=4开始尝试,如果验证集分数没有进一步提升再逐层加深。min_samples_leaf的值可以根据训练集规模取一个相对稳健的默认值,比如6到10,让每个叶子至少基于一定数量的样本做决策。
5.2 特征编码方式对结果的影响
决策树跟线性模型不同,它对数值特征的比例和分布不敏感,只是按阈值切分,所以Sex映射成0/1和One-Hot效果完全一样。但高基数类别变量就不一样了。比如Ticket票号,几百个乘客几乎每个票号都不同,决策树会在某些票号上遇到“这个票号对应的三条记录恰好都幸存了”,于是生成一条该票号幸存的分支,这就是典型的过拟合。Name如果直接当类别特征用也一样离谱。
所以遇到高基数文本字段,要么提取中间结构(比如从姓名中提取称呼),要么干脆删掉。哪些字段应该丢、哪些字段要转换,需要你在特征工程阶段就做好权衡。
5.3 数据预处理顺序导致的信息泄漏
这是个非常容易被忽略的问题,我见过很多人在这上面栽过跟头。处理年龄缺失值时,如果先把全量数据的中位数算出来并填充,然后再划分训练集和验证集,验证集的数据就已经带着全量数据的统计信息了。这会让模型评估结果虚高,到了真实场景再用就露馅。
正确做法是,先把数据划分为训练集和验证集,然后在训练集中做特征工程的拟合,比如计算中位数、众数、进行编码映射,并把这些计算好的统计值保存下来,再用它们处理验证集。sklearn里的SimpleImputer配合Pipeline能很方便地解决这个问题。
5.4 random_state不固定导致的分数波动
这个问题在决策树里特别常见,因为树的构建过程本身在特征选择时会有随机性。如果不固定random_state,每次运行都会得到略有差异的树结构和准确率。你可能会发现这次跑出0.83,下次跑出0.79,然后误以为代码有bug。其实只是随机数在作怪。
所有涉及随机过程的环节,包括train_test_split、DecisionTreeClassifier、GridSearchCV,都尽量显式固定random_state。项目里的所有代码我都会固定好同一个种子值,这样才能保证整个实验可复现。
5.5 类别不平衡判断这件事别凭感觉
Titanic数据集的不平衡程度不算极端,但你要是只看准确率,仍然可能被表面的数字安慰到。举个极端例子:假如一种方案让验证集准确率从0.80提升到0.82,但你查看混淆矩阵后发现,提升只是靠把更多样本预测为多数类“遇难”实现的,幸存者检出率反而下降了,这就不是真提升。
处理类别不平衡有多种方法,Titanic这个规模下最简单的做法是调class_weight='balanced',让模型对少数类的错误分类给予更大的惩罚。你也可以用ROC曲线下的面积AUC来做评估,它比准确率更不容易被类别不平衡欺骗。不过在这个项目里,先把混淆矩阵和F1搞清楚,比急着上各种复杂处理手段更重要。
写在最后的一点经验
我自己第一次跑这个项目的时候,犯过最蠢的错误是直接用默认参数训练完就提交模型,看到训练集准确率0.98差点以为自己做完了,结果验证集直接打脸。后来老老实实把特征工程重新做了一遍,又花了不少时间调剪枝参数,才理解到决策树不是“喂进去就能出结果”的模型,它需要你反复控制和验证。
另外还有个容易被忽略的细节是模型结果的稳定性。Sklearn的决策树本身不是确定性算法,即使固定了random_state,在不同版本的sklearn库或者不同的运行环境下,分裂节点的选择细节还是可能有细微差异。所以一个严谨的实验应该把环境版本也记录下来。
如果你第一次跑通这个项目,后续还可以往几个方向继续扩展:一是用交叉验证的cross_val_score看模型在不同数据划分上的得分分布,二是在特征工程里加称呼提取、票价分箱等更细粒度的特征,三是换用随机森林或者梯度提升树对比一下效果,看看集成学习为什么通常比单棵决策树稳定。每走一步,你对模型的理解都会比上一步更扎实。