最近不少读者在留言里提到:学完了sklearn的基础操作,接下来要找一个能完整走一遍分类决策树实战流程的数据集,问我有什么推荐。我的答案一直是同一个——Titanic数据集。这个数据集不复杂但是五脏俱全:有数值字段、类别字段、缺失值、弱相关的干扰字段,正好能把决策树建模的全流程练一遍。而且决策树最大的优点就是“可解释”,训练完之后你能亲手画出这棵树,看清楚模型每一步是根据什么下判断的。所以这篇就用Titanic数据集,完整走一遍sklearn分类决策树的实践流程。
1. 为什么拿Titanic练决策树:一个能看清模型“逻辑”的入门数据集
1.1 数据集长什么样
Titanic数据集来自一个全球知名的数据竞赛社区,是机器学习入门最经典的表格型数据之一。它的任务是预测船上乘客是否生还,属于典型的二分类问题。训练集有891条样本,每条样本包含了一批乘客的基础信息。我这里把核心字段列一下:
| 字段名 | 含义 | 类型 | 补充说明 |
|---|---|---|---|
| Survived | 是否生还 | 标签 | 0代表否,1代表是 |
| Pclass | 船舱等级 | 离散 | 1/2/3三个等级 |
| Sex | 性别 | 类别 | male / female |
| Age | 年龄 | 数值 | 有缺失 |
| SibSp | 同船的兄弟姐妹/配偶数 | 数值 | 整数 |
| Parch | 同船的父母/子女数 | 数值 | 整数 |
| Fare | 票价 | 数值 | 连续值 |
| Embarked | 登船港口 | 类别 | C / Q / S,有缺失 |
| Cabin | 客舱号 | 类别 | 缺失非常严重 |
| Ticket | 票号 | 类别 | 基本是噪音,实战中一般丢弃 |
这类数据的典型特征是:字段不多,但类型足够杂,覆盖了“数值字段如何处理”“类别字段如何编码”“缺失值怎么补”这几个数据预处理阶段的高频问题。891条的体量也刚刚好——模型训练秒级完成,你能在几分钟内反复迭代实验,而不是像深度学习那样跑一次等半天。
另外要注意,原始的乘客信息里还有Name和Ticket这种自由文本字段。网络上不少教程会把Name里的称呼(比如Mr、Mrs)拆出来当特征,这个属于锦上添花。第一次做决策树实践,建议先不做这种花活,把上面表格里的结构化字段处理好,就已经足够说明问题了。
1.2 为什么是决策树而不是别的模型
面对同一个分类问题,可以选的模型很多:逻辑回归、SVM、随机森林、神经网络都能做。但作为入门实践,决策树有几个不可替代的优势:
第一,决策树不需要对特征做标准化。神经网络和SVM对特征的尺度会很敏感,数值大的特征容易主导训练;而决策树每次切分只比较单个特征的阈值,特征本身的缩放不影响分类结果。这意味着拿到数据后少操心一道工序。
第二,决策树天然兼容数值与类别特征。虽然类别特征还是要编码成数字,但决策树对编码方式的宽容度远高于线性模型,哪怕你把有序的Pclass直接当成整数1、2、3来用,树也能通过合理的阈值切分把它玩好。
第三,决策树可视化之后几乎等于一份可读的规则说明书。你可以把整棵树的判断逻辑画出来,一格一格地读。这对理解机器学习“特征怎么影响预测结果”这件事,帮助远比一个冷冰冰的准确率数字大得多。
2. 数据处理:缺失值、离散字段与特征构造的实操细节
2.1 读数据与信息核查
实战第一步不是急着建模,而是先确认数据质量。拿到csv文件之后,先用pandas读进来,然后看基本结构:
import pandas as pd df = pd.read_csv("titanic_train.csv") print(df.shape) print(df.info()) print(df.isnull().sum())执行完以后应该能看到:Age有177个缺失,Cabin有687个缺失,Embarked有2个缺失。这三个缺失值情况完全不同,处理方式也应该区别对待,这是初学者最容易忽略的地方——以为“缺了就用均值补”,结果所有字段一视同仁,其实不对。
我的习惯是先想清楚每个字段缺失背后的业务含义,再决定处理方法。Cabin缺失这么严重,硬补是补不出来的;Age缺失属于局部缺失,可以用统计量填充;Embarked只缺2条,几乎不影响大局。
2.2 缺失值处理:中位数、众数与“有/无”特征
Age字段的处理,我推荐用中位数填充而不是平均数。原因是年龄分布偏态比较明显,个别年龄很大的乘客会把平均值拉高,而中位数更能代表“普通乘客”的水平。在sklearn里可以用SimpleImputer,但为了第一次看代码不头晕,直接用pandas的fillna也行:
df["Age"] = df["Age"].fillna(df["Age"].median()) df["Embarked"] = df["Embarked"].fillna(df["Embarked"].mode()[0])Embarked只有2个缺失,用众数S补齐即可。
这里必须插一句关于数据泄漏的提醒。理论上的严谨流程是:先把数据分成训练集和测试集,然后在训练集上计算出中位数,再用这个中位数去填充训练集和测试集。我上面的写法直接用了全量数据的中位数,这在891条数据上对年龄填充几乎不会有可感知的影响,很多基础教程也这么干。但如果你养成了“先看全量统计再填充”的习惯,以后处理规范化、标准化这类操作时,很容易把测试集的信息无意中代入训练过程,这是一个非常隐蔽的工程隐患。养成好习惯:涉及可学习的参数(均值、标准差、分位数定点)的预处理,一律先拆分,再fit_transform。
Cabin字段我不建议直接删掉,而是构造了一个衍生特征“是否有客舱记录”:
df["HasCabin"] = df["Cabin"].notna().astype(int) df = df.drop(columns=["Cabin", "Ticket", "Name", "PassengerId"])逻辑很简单:缺失比例超过70%的字段,直接填充只会制造大量虚假信息,但不等于这个字段没有预测力——有没有登记到客舱号,本身就代表着一种信息状态。这种“缺失也作为特征”的思路,在真实业务里经常能用上。
2.3 离散特征编码与手工特征构造
Sex和Embarked是类别特征,需要转成数值。Sex用最简单的映射,Embarked用one-hot:
df["Sex"] = df["Sex"].map({"male": 0, "female": 1}) df = pd.get_dummies(df, columns=["Embarked"], drop_first=True)关于Pclass,我特意没做one-hot,而是保留整数。有些教程一看到“类别”就习惯性one-hot,但决策树与线性模型不同,Pclass的1、2、3是有序的,保留整数后树可以切出“Pclass <= 1.5”这类对业务有明确含义的规则;而one-hot之后每一列单独参与分裂,反而丢掉了等级之间的顺序关系。逻辑回归对数值刻度敏感,这类有序变量需要谨慎处理,但在树模型里,直接用整数一般没有坏处。
再补两个手工特征:家庭规模、是否独身。SibSp和Parch单独看比较碎,合并之后信息更集中:
df["FamilySize"] = df["SibSp"] + df["Parch"] + 1 df["IsAlone"] = (df["FamilySize"] == 1).astype(int)这个特征是典型的领域常识驱动:船上救援时,有家人在身边和独自一人,生存概率会有系统性差异。这种“从业务常识里长出特征”的做法,比无脑堆更多字段重要得多。
2.4 特征列表定型
最后定下进入模型的特征:
features = ["Pclass", "Sex", "Age", "SibSp", "Parch", "Fare", "HasCabin", "FamilySize", "IsAlone", "Embarked_Q", "Embarked_S"] X = df[features] y = df["Survived"]到这里数据预处理就算完成了。这一套流程下来,原始14列被处理成了10个干净特征,每一步都能说出为什么,这比“对着网上的代码抄一遍”有价值得多。
3. 训练一棵决策树之前,先看懂剪枝参数的博弈逻辑
3.1 不纯度指标:gini与entropy
sklearn的DecisionTreeClassifier有两个常用的特征选择准则:criterion="gini"和criterion="entropy"。这俩到底怎么选?很多初学者会纠结。
简单理解,两者都在量化“这个节点有多不纯”。如果某个节点里两类样本各占一半,那它非常不纯;如果某个节点几乎全是同一类,那它已经很纯了。树的分裂过程,就是在不停寻找一个特征和阈值,把当前节点切成两个更纯的子节点。
基尼系数的直观解释是:从节点里随机抽两个样本,它们属于不同类别的概率。这个概率越小,节点越纯。信息熵则是信息论里的概念,衡量的是节点内部的混乱程度。实操上二者的差异通常很小,sklearn默认用gini主要是因为它不涉及对数运算,计算更快。所以第一次实验,直接用默认的gini就好,不要在这个选项上耗太多时间。
3.2 剪枝参数是怎么作用的
决策树最核心的问题只有一个:如何平衡拟合程度与泛化能力。一棵不限制生长的树,可以一直分裂到每个叶子节点只有一个样本,训练集准确率直接逼近100%,但测试集表现会大幅下滑。这就是过拟合。
控制过拟合,靠的就是一组剪枝相关参数。我把最常用的几个整理成一张对照表:
| 参数 | 默认值 | 作用 | 我的经验取值 |
|---|---|---|---|
| max_depth | None | 限制树的最大深度 | 3~8 |
| min_samples_split | 2 | 内部节点至少需要多少样本才继续分裂 | 5~20 |
| min_samples_leaf | 1 | 叶子节点至少要有多少样本 | 2~10 |
| max_features | None | 每次分裂考察的特征数量 | 默认即可 |
| random_state | None | 随机种子,影响结果可复现性 | 必须固定 |
这几个参数里,max_depth是最直观的“刹车”。设成3,树就最多做3层判断,规则简单清晰,画出来也不会糊。min_samples_leaf则相当于给每个最终结论设置了一个“最小调查样本数”,防止模型因为个别样本的巧合就单独开一条规则。我的习惯是先靠这两个参数稳住结构,再根据效果微调其他项。
3.3 先跑一个默认参数的基线
开头不要直接上网格搜索,先把一个默认参数的模型跑出来当作基线,后面所有调优才有对照:
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.3, random_state=42, stratify=y ) base_tree = DecisionTreeClassifier(random_state=42) base_tree.fit(X_train, y_train) print("train acc:", base_tree.score(X_train, y_train)) print("test acc:", base_tree.score(X_test, y_test))我这里跑出来的结果,训练集准确率接近0.98,测试集大概0.78。这个差距就是在告诉你:树已经严重过拟合了。它把训练数据里的噪声都背了下来,但在没见过的数据上表现明显下滑。默认参数接近无限生长的状态,几乎没有做任何剪枝。记住这个数字,下面所有操作都冲着“缩小训练集与测试集差距”这一个目标去。
4. 把训练好的树“画”出来:读树、找分裂点、发现过拟合
4.1 可视化一棵深度为3的树
限制深度以后,树就具备可读性了。这里用深度3做一个示例:
from sklearn.tree import DecisionTreeClassifier, plot_tree import matplotlib.pyplot as plt tree_vis = DecisionTreeClassifier(max_depth=3, min_samples_leaf=5, random_state=42) tree_vis.fit(X_train, y_train) plt.figure(figsize=(18, 10)) plot_tree( tree_vis, feature_names=features, class_names=["Not Survived", "Survived"], filled=True, rounded=True, fontsize=10 ) plt.show()运行这段代码前确认你的sklearn版本在0.21以上,否则没有plot_tree这个函数。如果版本太老,要么升级,要么退回用export_graphviz导出后用graphviz软件查看,后者麻烦不少。
4.2 怎么读一棵树
可视化出来之后,这棵树会告诉你很多信息。最顶端的根节点就是整个模型的第一个判断依据,也是全局最重要的特征。在Titanic这个数据集上,根节点的分裂特征几乎必定是Sex。为什么会是它?因为性别对生存率的区分度最强:女性生还比例远高于男性。决策树在每一步都会自动计算所有特征带来的不纯度下降,选出下降最多的那个,这本质上就是一个自动的特征重要性排序。
往下走一层,你会看到Sex分裂之后,男性分支下一个重要特征大概率是Age或Pclass,女性分支则可能是Fare或Pclass。这相当于树自己找到了数据里的规律:男性中更年轻或更高船舱等级的人群生还率更高,女性中舱位等级和票价也进一步区分了结果。
每一片叶子节点上的value数组和samples数量值得重点看。比如叶子节点显示[200, 30],意味着这个分组里200人未能生还、30人生还,于是这个叶子最终预测为“Not Survived”。你可以顺着一条路径读出一条人类语言规则:“男性,并且年龄大于13.5岁,并且舱位等级不是一等舱,预测为未生还。”这就是决策树可解释性的价值所在。
4.3 从画图里一眼看出过拟合
画图不仅能帮你理解模型,还能帮你直观感知过拟合。你把max_depth分别设成3和8,各画一棵树对比,会看到两个明显差异:深度8的树节点数量爆炸,很多叶子节点的samples只有1到2;深度3的树叶子节点通常有几十个样本。
叶子样本数量少,意味着模型在做极细粒度的区分。这不是在学规律,而是在背样本。当你感觉一棵树的图像已经密密麻麻看不清的时候,基本可以断定它过拟合了,即使不跑测试集也能判断个大半。所以我做决策树实验的一个习惯是:调参之前先画图,视觉上先淘汰一批明显不合理的配置。
5. 评估模型可信度:准确率之外还要看什么
5.1 划分数据时别忘了stratify
前面代码里我特意用了stratify=y,很多初学者不知道这个参数的意义。Titanic训练集中生还者的比例大约是38%,如果随机划分后碰巧测试集里生还者比例变成了50%,那你在测试集上评估的结果就有偏。stratify能让划分前后的类别比例保持一致,相当于给测试集做了一次“配额抽选”。
这在类别不平衡的场景下尤其重要。如果你的数据里正样本只有5%,随机划分后测试集甚至可能一个正样本都没有,评估结果完全失真。
5.2 混淆矩阵与精确率、召回率
准确率只是评估的起点。生还者比例本来就偏低,如果一个模型什么都预测为“未生还”,准确率也能到60%多。要想看得更细,需要混淆矩阵和分类报告:
from sklearn.metrics import confusion_matrix, classification_report y_pred = tree_vis.predict(X_test) print(confusion_matrix(y_test, y_pred)) print(classification_report(y_test, y_pred))以深度3加min_samples_leaf=5的模型为例,混淆矩阵可能是这样的:
| 预测未生还 | 预测生还 | |
|---|---|---|
| 实际未生还 | 152 | 16 |
| 实际生还 | 51 | 49 |
这个矩阵告诉我们:模型在“未生还”类别上表现很好,但在“生还”类别上,51个实际生还的人被漏判了。查全率(recall)低,意味着模型对生还者的识别不够敏感。如果你关心的是“尽可能找到所有生还者”,那只看准确率是不够的,必须盯住生还类别的recall指标。
分类报告里的precision和f1-score同样要看。f1-score是精确率和召回率的调和平均,适合在两者之间取平衡。每次实验后在纸上记录这些指标,比只记一个准确率高一个层级。
5.3 用交叉验证替代单次划分
train_test_split的结果有一个问题:随机性很大。同样的模型,换一次随机种子,测试集准确率可能差出三五个百分点。你很难判断这个波动到底是模型好坏,还是划分运气。
交叉验证能缓解这个问题。把数据分成k份,轮流拿其中1份当验证集、其余k-1份训练,最终得到k个分数,取平均。sklearn里直接一行:
from sklearn.model_selection import cross_val_score scores = cross_val_score( DecisionTreeClassifier(max_depth=3, min_samples_leaf=5, random_state=42), X, y, cv=5, scoring="accuracy" ) print(scores) # 每一折的分数 print(scores.mean()) # 平均分交叉验证的另一个好处是能观察方差。如果5折的分数分别是0.80、0.78、0.76、0.85、0.70,波动明显,说明模型在不同数据子集上稳定性不足,此时就算平均值好看,上线也要慎重。
6. 调参过程的完整记录与几个隐蔽的坑
6.1 网格搜索:参数候选与结果
手工一个个试参数太慢,直接上GridSearchCV做参数搜索:
from sklearn.model_selection import GridSearchCV param_grid = { "max_depth": [3, 4, 5, 6, 8], "min_samples_split": [2, 5, 10, 20], "min_samples_leaf": [1, 2, 5, 10] } grid_search = GridSearchCV( DecisionTreeClassifier(random_state=42), param_grid, cv=5, scoring="accuracy", n_jobs=-1 ) grid_search.fit(X_train, y_train) print(grid_search.best_params_) print(grid_search.best_score_)在我这边跑下来,最优参数基本会落在max_depth=4或5、min_samples_leaf=5左右的组合上。这时测试集准确率大概在0.82上下,比默认参数提升了4个百分点左右,而训练集准确率回落到0.87附近。训练集和测试集的差距从0.20缩小到0.05,说明过拟合得到了明显抑制。
网格搜索的本质是“拿计算资源换调参效率”。参数组合数不多的时候非常划算,但如果你要搜索的参数量再翻几倍,建议改用RandomizedSearchCV,它会在参数空间中随机采样,能在更少的实验次数里找到差不多的好参数。
6.2 实战中容易踩的几个坑
第一个坑是sklearn版本差异。plot_tree是0.21版本才加入的,如果环境里的sklearn是0.20甚至更老的版本,跑可视化会直接报错。GridSearchCV和cross_val_score这类基础API倒是老版本也有,但行为细节可能略有差异。建议建模之前先print(sklearn.__version__)确认版本。
第二个坑是类别特征编码不一致。如果你对训练集和测试集分别执行pd.get_dummies,两个数据集的列会不一致——比如某类别只出现在训练集中没出现在测试集中,列数就对不上了。实战中通常把原始数据一次性处理完再拆分,或者用Pipeline把预处理与建模绑定在一起,避免这种不一致。
第三个坑是不固定随机种子。同一个参数、同一份数据,你跑三次得到三个不同的准确率,然后你就会开始怀疑自己是不是操作错了。给自己定个习惯:所有涉及随机性的地方都写random_state=42。这样实验结果可复现,别人拿到你的代码也能跑出相同数字。
第四个坑是只看训练集分数就下结论。默认参数的决策树训练集几乎满分,这个分数没有任何参考价值。一定要在留出的测试集或交叉验证上做判断,否则你只是在自我感动。
6.3 调参之外:从单棵树到集成模型
网格搜完,单棵决策树在这个数据集上的表现基本到头了,准确率上限大概在0.83左右,再往上榨空间不大。原因很简单:单棵树学到的规律有限,而且对数据中的噪声很敏感。
这时候可以顺手试一下随机森林——把几百棵树的结果做投票。在同样的训练测试划分下,随机森林通常能把准确率抬到0.84到0.85。代码上只差一行:
from sklearn.ensemble import RandomForestClassifier rf = RandomForestClassifier(n_estimators=100, max_depth=5, random_state=42) rf.fit(X_train, y_train) print(rf.score(X_test, y_test))但要注意,集成模型的代价是牺牲了单棵树的直观可解释性。你没法像前面那样,画一棵树然后把判断规则一条条念出来。所以我个人建议:入门学习阶段,先把单棵决策树吃透,把树的生成逻辑、剪枝逻辑、可视化都玩明白,再去碰集成模型。否则你一上来就随机森林,看着特征重要性排序,却根本不知道这些重要性分数是怎么来的,反而不利于建立直觉。
我在实际跑完这个数据集之后最大的体会是:决策树的调参难度不在于参数多,而在于你得时刻盯着过拟合这个隐形敌人。每次调完都要问自己三个问题——训练集和测试集差距缩小了吗?测试集分数真的涨了吗?树的规则还能不能看懂?三个问题都答“是”,这次调参才算有效。只要能把这套思路迁移到其他数据集上,这一篇实践就没有白读。