决策树建模实战:从特征选择到剪枝调参与随机森林对比
1. 从一张分类表到一棵树决策树建模到底在做什么先讲一个我特别常见的场景业务方甩给你一张客户表里面有年龄、收入、最近一次消费时间问你这些人里哪些会流失你没时间调一个神经网络更不敢交一个谁都看不懂的黑盒这时候决策树几乎是唯一能立刻上场、又能在两句话里跟业务解释清楚的办法。决策树干的事情本质就是一句话把特征空间不断切成小块每一块对应一个预测结果。它从根节点开始每次选一个特征和一个阈值把数据分成左右两支让划分之后的不纯度下降得最多。所谓不纯度分类任务里常用基尼指数Gini Impurity回归任务里常用均方误差MSE。这种贪心式递归划分一直重复到满足停止条件为止。我用挑西瓜来类比先看颜色是否青绿再看根蒂是否蜷缩最后听声音是否浑浊——每一层都在问一个是/否的问题最终落到一个结论上。一旦树建好你完全能把它的规则翻译成人话所有年龄小于35岁且最近消费超过90天的用户流失概率最高。这是线性模型给不了也是深度学习给不了的。当然这种可解释性的代价就是模型形状非常僵硬。决策树的预测结果是一段一段的常数函数无论底层真实关系是直线、曲线还是什么复杂形状它都只能用一堆横平竖直的矩形去逼近。这也是标题里那句决策树如何逼近真实曲线为什么经常有人问的原因——它不是天生曲线拟合器它是靠增加分段数量去一点点贴近目标。在动手编码之前还有一个概念必须建立决策树模型天生容易过拟合。它就像个记忆力超强的学生只要你不限制它会为了记住每一个样本而把树长得很深结果换一套新数据就露馅。所以后面所有实战环节里剪枝和参数控制占的戏份不会比建模本身少。这一节的后半段我们先把一件小事讲透决策树的特征选择机制。分类树每次选特征时会逐个计算所有候选划分带来的基尼不纯度降低量选择下降最多的那个作为分裂点。比如在两分类问题中某节点有100个样本其中60个A类40个B类基尼不纯度是 1 - (0.6² 0.4²) 0.48。如果按年龄30划分后左节点纯度接近1那么划分后的加权基尼会明显下降这个特征就胜出了。这里不需要你手工算scikit-learn 会帮你全包办但理解了这个过程后面调参数时才不会懵。2. 先把环境理顺scikit-learn 装对版本、避开 sklearn 包名陷阱这一节看着琐碎其实是我见过新手翻车率最高的地方。很多人跑案例时报错问题根本不在模型代码而是环境装错了。先说一个大坑安装命令到底用pip install sklearn还是pip install scikit-learn早期很多教程为了少打几个字符会让人装sklearn虽然也能导入import sklearn但那个包其实是旧时代留下的镜像官方早就声明sklearn这个 PyPI 包名已经弃用deprecated未来不会跟随scikit-learn主包更新。我最近一次新装环境时直接用pip install sklearn装完后跑sklearn.__version__居然还是0.20左右的古董版本很多新函数根本没有。所以别嫌麻烦老老实实用这个pip install scikit-learn如果你用的是 conda那更省事conda install scikit-learn装好之后立刻验证三件套是否能正常导入版本是否符合要求import numpy as np import pandas as pd import sklearn print(np.__version__) print(pd.__version__) print(sklearn.__version__)建议 Python 使用 3.8 以上版本scikit-learn 用 1.0 以上。实测在 1.1 和 1.2 版本上下面要写的决策树代码全部能跑通如果你还在用 0.22 这种压箱底版本光是plot_tree函数就跟你拜拜。还有几个环境层面的坑我顺手列在这里虚拟环境一定要用。很多人喜欢直接装在系统 Python 里后来装 PyTorch、TensorFlow一堆依赖冲突到怀疑人生。用python -m venv myenv或者 conda 新建环境决策树练习这种小项目五分钟就能装完。镜像站。网络慢的时候加-i https://pypi.tuna.tsinghua.edu.cn/simple但不要用任何非官方渠道纯粹是浪费感情。numpy 版本冲突。scikit-learn 对 numpy 版本有上下限要求如果安装时提示 Please install a compatible numpy version别硬刚用pip install -U numpy scikit-learn一起升级就好。装完之后跑一个最简单的导入测试from sklearn.tree import DecisionTreeClassifier print(load ok)没有报错你就算迈过门槛了。别小看这一步我见过太多人卡在这里后面所有东西都没跑起来。3. 用鸢尾花和收入预测两个案例走通数据到模型的最小闭环3.1 鸢尾花分类教科书案例的实战用法鸢尾花数据集Iris是决策树入门绕不开的数据集150个样本、4个特征、3个类别。虽然它老掉牙但它小到能让每一步都看得清清楚楚非常适合验证流程。完整的最小闭环代码长这样from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import accuracy_score, classification_report # 1. 加载数据 iris load_iris() X iris.data y iris.target # 2. 划分训练集和测试集 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy ) # 3. 训练决策树 clf DecisionTreeClassifier(max_depth3, random_state42) clf.fit(X_train, y_train) # 4. 在测试集上评估 y_pred clf.predict(X_test) print(accuracy:, accuracy_score(y_test, y_pred)) print(classification_report(y_test, y_pred, target_namesiris.target_names))有一点必须强调train_test_split里我特意加了stratifyy因为在数据量小、类别均衡时这没什么但如果你在做一个不平衡分类任务不分层抽样会把少数类全分到测试集去导致模型以为所有样本都是多数类。这个参数加上去不会错。然后我们可以把决策树的规则导出成文本让你亲眼看到模型学到的规则长什么样from sklearn.tree import export_text text_rules export_text(clf, feature_namesiris.feature_names) print(text_rules)输出大概是这样|--- petal length (cm) 2.45 | |--- class: 0 |--- petal length (cm) 2.45 | |--- petal width (cm) 1.75 | | |--- class: 1 | |--- petal width (cm) 1.75 | | |--- class: 2看到没一棵深度只有3的树就只用花瓣长度和花瓣宽度两个特征就把三种鸢尾花分开了。这也侧面说明决策树自带特征选择能力。3.2 收入预测用更贴近真实业务的数据走回归流程鸢尾花是分类任务但很多实际业务里你会遇到预测收入这类回归问题。我用一份简单构造的数据来示意特征包括年龄、教育年限、每周工作小时数目标变量是年收入。数据里还故意塞了几行缺失值和一个分类字符串字段让你看看真实场景里的预处理怎么做。import numpy as np import pandas as pd rng np.random.RandomState(42) n 300 df pd.DataFrame({ age: rng.randint(20, 60, n), education_years: rng.randint(8, 22, n), hours_per_week: rng.randint(20, 80, n), }) # 给部分样本制造缺失值 df.loc[rng.choice(n, 10, replaceFalse), age] np.nan # 构造目标收入与教育年限、年龄有关再加噪声 df[income] ( 20000 df[education_years] * 3000 df[age] * 800 rng.randn(n) * 5000 )处理缺失值的方式很简单数值列用中位数填充df[age] df[age].fillna(df[age].median())然后训练回归树from sklearn.tree import DecisionTreeRegressor from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score X_reg df[[age, education_years, hours_per_week]] y_reg df[income] Xr_train, Xr_test, yr_train, yr_test train_test_split( X_reg, y_reg, test_size0.3, random_state42 ) reg DecisionTreeRegressor(max_depth4, random_state42) reg.fit(Xr_train, yr_train) yr_pred reg.predict(Xr_test) print(MSE:, mean_squared_error(yr_test, yr_pred)) print(MAE:, mean_absolute_error(yr_test, yr_pred)) print(R2:, r2_score(yr_test, yr_pred))一个完整的模型开发流程到这里已经显现装环境、加载数据、划分数据集、训练、评估。别看只有这几步很多人第一次跑通时会卡在特征是字符串不知道怎么处理、有缺失值不知道怎么办这些问题上所以我专门加了上面的预处理片段。决策树本身对特征尺度不敏感所以不需要做标准化但字符串特征还是要编码成数值这点别搞混。4. 剪枝、深度与叶子节点让决策树逼近真实曲线而不是死记硬背4.1 为什么深度越大越容易翻车还是回到那个问题决策树如何逼近真实曲线。我见过太多次新手看到训练精度直接从0.8涨到1.0兴奋得不行结果测试集精度直线跳水。原因很简单树的深度增加等价于把特征空间切成极小的格子每个格子只要塞下一个训练样本就收工这跟背答案没什么区别。我用一组带噪声的正弦曲线数据故意展示这个问题X_sin np.linspace(0, 2 * np.pi, 200).reshape(-1, 1) y_sin np.sin(X_sin).ravel() rng.normal(0, 0.15, 200) for depth in [2, 5, 15]: reg DecisionTreeRegressor(max_depthdepth, random_state42) reg.fit(X_sin, y_sin) print(fdepth{depth}, train R2{reg.score(X_sin, y_sin):.3f})如果把不同深度的拟合曲线画出来你会很直观地看到深度为2时曲线被拟合成几段水平线每一段都离真实正弦曲线有距离这是欠拟合。深度为5时拟合出来的分段线已经大致贴合正弦曲线噪声点也基本被忽略这是理想的逼近状态。深度为15时曲线开始剧烈波动每个噪声点都被记下来了这是过拟合。所以决策树逼近真实曲线靠的不是把深度拉满而是在分段数量和泛化能力之间找到平衡点。这个平衡点就是下一段要讲的剪枝参数。4.2 预剪枝参数四个最常用的旋钮scikit-learn 的决策树剪枝最常用的是下面四个参数我一个个说人话解释参数作用通俗理解max_depth限制树的最大深度最多能问几个问题就停下min_samples_split内部节点再分裂所需的最少样本数样本不够就不要再切了省得过度细分min_samples_leaf叶子节点至少要有的样本数每个结论下面至少要站几个人max_leaf_nodes最多允许的叶子节点数量整个树最多允许多少个最终判断在回归树里这组参数的意义尤其明显。min_samples_leaf设大一点比如设成10就逼着模型每一段预测都要覆盖至少10个样本的平均自然会把那些噪声点抹平。我实践中一般先固定max_depth5左右再看min_samples_leaf需要多大最后决定要不要限制max_leaf_nodes。代码示例reg_tuned DecisionTreeRegressor( max_depth5, min_samples_split10, min_samples_leaf6, random_state42 ) reg_tuned.fit(Xr_train, yr_train) print(test R2:, reg_tuned.score(Xr_test, yr_test))从测试集 R² 变化看合理剪枝往往能带来几个百分点的提升更重要的是稳定性变高换一套测试数据不会崩。4.3 后剪枝成本复杂度剪枝了解一下除了预剪枝scikit-learn 还自带成本复杂度剪枝Cost Complexity Pruning属于让树先长满再从底部剪的后剪枝思路。核心是那个ccp_alpha参数alpha 越大砍掉的节点越多树越矮。一般流程是先画出 alpha 和树的关系再选一个合适的值from sklearn.tree import DecisionTreeClassifier clf_unpruned DecisionTreeClassifier(random_state42) path clf_unpruned.cost_complexity_pruning_path(X_train, y_train) ccp_alphas, impurities path.ccp_alphas, path.impurities clf_best DecisionTreeClassifier(ccp_alpha0.02, random_state42) clf_best.fit(X_train, y_train)但说实话实际项目里我把更多精力放在预剪枝上因为后剪枝需要额外画 alpha 曲线、对比不同 alpha 的性能收益和成本不成正比。只有当你发现预剪枝参数怎么调都不够稳时再考虑后剪枝。5. 精度评估的完整步骤混淆矩阵、交叉验证与指标选择5.1 别只会看 accuracy我见过很多人在跑完模型后只关心accuracy一个数字。这在类别均衡时没问题可一旦类别不平衡accuracy 就会骗人。比如100个样本里95个是负类5个正类你只要无脑全预测成负类accuracy 就是95%但这个模型毫无业务价值。所以分类任务的评估至少要配合混淆矩阵和分类报告一起看from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay cm confusion_matrix(y_test, y_pred) disp ConfusionMatrixDisplay(confusion_matrixcm, display_labelsiris.target_names) disp.plot(cmapBlues)分类报告则把 precision、recall、f1-score 一次性列出来便于逐类判断。precision精确率你预测为正类的样本里有多少是真正类。recall召回率真正的正类里有多少被你捞上来了。F1两者的调和平均用来在两者之间取平衡。如果是流失预测这类业务我们通常更关注 recall因为少抓一个流失客户可能损失很大如果是垃圾邮件过滤则更关注 precision因为误杀一封正常邮件比漏杀垃圾邮件更让人难受。评估指标的选择要跟着业务走而不是抄别人代码里的classification_report就完事。5.2 回归任务的评估指标回归任务里我一般同时看三个指标MAE平均绝对误差预测值和真实值差的绝对值的平均单位跟目标变量一样业务方最容易听懂。MSE均方误差把误差平方再平均对大误差更敏感适合你想惩罚离谱预测的场景。R²决定系数模型解释了多少比例的方差。R²1 是完美拟合R²0 相当于用均值当预测值。举个例子刚才的收入预测里假如 MAE 是 4820 元业务方一听就知道模型平均偏差大概五千块非常直观。而 MSE 是 2600万听着吓人其实只是单位不同而已。所以我经常同时打印这三个指标用不同的视角评估同一个模型。5.3 交叉验证单次划分靠不住很多人训练模型只做一次train_test_split然后就用测试集精度当最终结论。这在小数据集上特别危险因为你这次划到的测试集可能运气好、预测失败率低换个随机种子结果就不一样了。正确做法是加上交叉验证from sklearn.model_selection import cross_val_score scores cross_val_score( DecisionTreeClassifier(max_depth3, random_state42), X, y, cv5, scoringaccuracy ) print(cv scores:, scores) print(mean accuracy:, scores.mean())cv5意味着把数据切5份轮流出4份训练、1份做验证最终得到5个分数。平均值比单次划分的精度更能代表模型真实水平。我每次建模一定会跑一遍交叉验证哪怕最后还是要用单独的测试集做正式汇报心里也更有底。5.4 训练集和测试集一条铁律往大了说整个精度评估的核心铁律只有一条永远别拿训练集评估模型。模型在训练集上的分数没有任何参考意义因为你已经在这些样本上做过弊了。哪怕你只是用测试集调了很多次参数也可能间接过拟合到测试集上这就是为什么要留出一份真正的独立测试集或者用嵌套交叉验证。我看到过最离谱的情况是有人把X_train和y_train直接丢进score()然后给业务方汇报我的模型精度99%结果上线之后被真实数据打脸。这条铁律希望每个看到这里的人都刻在脑子里。6. 调参实战网格搜索与随机搜索的边界和取舍决策树参数说多不多说少不少。真正值得调的主要是max_depth、min_samples_split、min_samples_leaf、max_features以及分类树里的criteriongini或entropy。我用GridSearchCV走一遍再用RandomizedSearchCV补充顺便聊聊两者该怎么选。6.1 GridSearchCV小范围穷举网格搜索就是把每个参数给一个候选列表然后排列组合全部跑一遍from sklearn.model_selection import GridSearchCV param_grid { max_depth: [3, 5, 7], min_samples_split: [2, 5, 10], min_samples_leaf: [1, 2, 4], } grid GridSearchCV( DecisionTreeClassifier(random_state42), param_grid, cv5, scoringaccuracy, n_jobs-1 ) grid.fit(X_train, y_train) print(best params:, grid.best_params_) print(best cv score:, grid.best_score_) print(test score:, grid.score(X_test, y_test))这个组合数量是 3×3×327 组每组交叉验证5次总共135次训练。数据集小没问题但如果你的数据有几十万行网格搜索会慢到你怀疑人生。所以我的建议是小数据用网格大数据换随机搜索。6.2 RandomizedSearchCV大范围撒网随机搜索的思路是每个参数定义一个分布或候选列表在搜索循环里随机组合跑固定的迭代次数。它的优势是当参数空间很大时你不需要穷举所有组合就能覆盖到很多组合点from sklearn.model_selection import RandomizedSearchCV from scipy.stats import randint param_dist { max_depth: randint(2, 15), min_samples_split: randint(2, 20), min_samples_leaf: randint(1, 10), } random_search RandomizedSearchCV( DecisionTreeClassifier(random_state42), param_dist, n_iter50, cv5, scoringaccuracy, n_jobs-1, random_state42 ) random_search.fit(X_train, y_train) print(best params:, random_search.best_params_) print(best cv score:, random_search.best_score_)我自己的习惯是分两步走先用随机搜索跑一圈确定几个参数大致落在哪个区间再在比较优的区间附近用网格搜索微调。这样既不会漏掉远距离的优值也不会在明显很差的组合上空耗时间。6.3 调参时要守住的两个底线第一不要只看交叉验证分数就完事。best_score_是训练集的一部分上得到的反映的是调参过程中的表现最终还必须用独立测试集跑一次最终分数。第二不要追求逼平训练集分数。决策树的训练分数永远可以逼近100%但那不是目标目标是测试集的表现。我在项目里还发现一个容易被忽略的事情随机种子random_state对决策树的树形影响很大。同一个数据、同一组参数种子不一样树就可能完全不同。所以调参时一定要固定随机种子否则你得到的最优参数可能只是某个随机划分下的偶然结果。7. 决策树、随机森林与集成模型什么时候不该只用一棵树最后这一章我来说说什么情况下该放弃单棵决策树切换到随机森林这类集成模型。这也是热搜里总有人问随机森林和决策树区别的原因——它们的关系太密切了你需要真正理解背后的权衡而不是只背结论。7.1 一棵树的致命弱点高方差单棵决策树有个先天毛病不稳定。训练数据哪怕只有几个样本发生变化树的分裂结构就可能完全不同。这导致模型的方差很高在测试集上的表现容易波动。我刚入门时做过一个测试在同一个数据集上只用不同的随机种子划分训练集训练出两棵深度相同的树结果它们的规则文本差别大到像两个不同模型。从那时起我就记住了一件事如果业务方拿着树说你凭什么从年龄35开始切你得能解释清楚这只是这棵树在当前数据下的一个近似规律不一定具有全局因果意义。7.2 随机森林做了什么随机森林的做法是同时训练很多棵决策树每棵树用不同的随机子集bootstrap抽样和不同的特征子集来训练最后把它们的预测结果投票分类或取平均回归。这么做的核心效果只有一个大幅降低方差同时保持偏差基本不变。用代码对比一下最简单from sklearn.ensemble import RandomForestClassifier rf RandomForestClassifier( n_estimators200, max_depth3, random_state42, n_jobs-1 ) rf.fit(X_train, y_train) print(RF test acc:, rf.score(X_test, y_test)) print(DT test acc:, clf.score(X_test, y_test))在很多中小型表格数据上随机森林的精度普遍比单棵决策树高几个百分点而且更稳。不过代价是你失去了一棵树的完整规则。虽然可以算特征重要性但没有人能像读单棵树一样把200棵树的每一条规则都念出来。7.3 怎么选可解释性优先还是精度优先我自己的选型经验大致是如果业务方要求给我几条可以执行和落地的规则比如风控策略、诊断建议那优先选一棵深度控制在3~5的单决策树把规则可视化后跟业务方对口径。如果只要求模型效果比如流失预测、销量预测那选随机森林或者梯度提升树Gradient Boosting并配合特征重要性做粗粒度解释。如果数据维度很高树模型的表现会随特征子集调整出现明显波动优先随机森林因为它天然抗高维过拟合。再说回决策树如何逼近真实曲线这个话题。单棵决策树在曲线拟合上只能靠增加分段数量硬扛而随机森林通过多棵树的平均可以拟合出相对平滑的曲线但本质上依然是分段常数的加权平均。如果你想追求更平滑的曲线预测还可以试试梯度提升树或带光滑基函数的方法但那已经超出本文篇幅了。7.4 最后的操作建议我在实际项目里始终保留一个固定动作先用单棵决策树快速跑通流程看数据和特征有没有明显问题再用随机森林提升精度。这个过程帮我省过无数次在复杂模型上调参却被数据错误折腾得怀疑人生的时间。单棵决策树跑出来的结果如果有明显反直觉规律我会先回去查数据是不是有脏值、特征是不是构造错了而不是盲目调参。另外还有一个很实用的技巧把训练好的决策树用plot_tree导出成图片尤其适合在汇报PPT里直接展示。代码很简单from sklearn.tree import plot_tree import matplotlib.pyplot as plt plt.figure(figsize(16, 8)) plot_tree(clf, feature_namesiris.feature_names, class_namesiris.target_names, filledTrue) plt.show()当业务方看到一棵画满颜色的树比你讲十页算法原理都有效。做模型本来就是拿来找规律给业务用的不是用来炫技的。把数据流程梳理干净把指标定义对齐再用决策树这类高解释性模型建立起第一版基线你后面无论换什么模型心里都会踏实很多。