恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
决策树算法原理、Python实现与决策边界可视化详解
首页
资讯中心
/
决策树算法原理、Python实现与决策边界可视化详解
决策树算法原理、Python实现与决策边界可视化详解
发布时间:2026/10/13 19:52:17
1. 决策树到底在解决什么问题很多人学机器学习学到线性回归和逻辑回归之后下一步就会碰到决策树。原因很简单前两个模型本质上都是在找一个“线性分界面”但现实里大量问题并不是一条直线能切开的。决策树不一样它是通过一连串“如果……就……”的判断把样本一层层分到不同的叶子节点里。你可以把它理解成一份不断细化的流程图每个内部节点问一个问题每个分支代表一种答案最终落到叶子节点上得到分类或回归结果。我最初接触决策树的时候觉得它特别像医生看病。医生不会一上来就给你下一个综合结论而是先问“哪里不舒服”再问“疼了多久”接着问“有没有发烧”每一步都在缩小可能性范围。决策树干的也是这件事它从所有特征里挑一个最有区分度的特征来提问把数据切开然后在每个子集里继续挑下一个最有区分度的特征直到满足停止条件。这个算法之所以经典是因为它同时具备几个很实用的特点。第一可解释性强。你训练完一棵树可以直接把它画出来每个判断路径都能读懂这在风控、医疗辅助、工业质检等场景里非常重要。第二它几乎不需要对特征做归一化因为每次分裂只看某个特征内部的取值排序不像逻辑回归那样对尺度敏感。第三它能处理非线性关系也能捕捉特征之间的交互作用。第四它既是分类算法也是回归算法换一个不纯度度量方式就能切换任务。但决策树也有明显的短板。最典型的就是容易过拟合。如果你不加限制让树一直长到每个叶子节点只有一个样本那它在训练集上可以做到百分之百准确但一到测试集就崩了。所以真正用好决策树关键不在于“会不会调用API”而在于理解它怎么选分裂点、怎么控制复杂度、怎么评估一棵树到底学得好不好。这篇文章我会从原理、代码实现、决策边界绘制三个层面把决策树讲透。代码部分我会用Python和scikit-learn来演示同时也会手写一个简化版实现帮助你看清楚底层计算过程。决策边界绘制部分会重点讲清楚二维特征空间下怎么画出分类区域以及为什么决策树的边界总是横平竖直的。如果你已经学过线性模型想找一个更贴近人类判断方式的算法入手决策树是非常合适的下一站。2. 决策树的核心原理拆解2.1 树是怎么长出来的递归分裂的基本逻辑决策树的构建过程本质上是一个递归过程。假设你手上有训练集D包含若干样本每个样本有多个特征和一个标签。算法要做的事情是在当前节点上遍历所有特征的所有可能切分点找到一个切分方案使得切分后的子集“纯度”提升最大。然后对每个子集重复这个过程直到满足停止条件。这里有两个关键词切分点和纯度。对于离散特征切分点就是某个取值对于连续特征切分点通常是某个阈值比如“年龄是否大于30”。纯度衡量的是一个节点里样本标签的一致性。如果某个节点里全是正例那它纯度最高如果正负例各占一半纯度最低。决策树的目标就是通过不断分裂让子节点越来越纯。那怎么衡量纯度呢常用的有三种指标信息熵、基尼指数和分类误差率。分类误差率最简单就是1减去当前节点中多数类所占比例但它对概率变化不敏感实际用得少。信息熵和基尼指数是主流下面分别说。2.2 信息熵、信息增益与基尼指数信息熵来自信息论衡量的是随机变量的不确定性。对于一个二分类问题如果正例比例是p负例比例是1-p那么熵的定义是H(p) -p * log2(p) - (1-p) * log2(1-p)当p等于0或1时熵为0表示完全确定当p等于0.5时熵为1表示最不确定。多分类情况下就是把每个类别的比例乘上它的对数再求和取负。信息增益就是父节点的熵减去子节点熵的加权平均。加权权重是子节点样本数占父节点样本数的比例。信息增益越大说明用这个特征切分后不确定性下降越多这个特征就越值得选。但信息增益有一个缺点它偏向于取值较多的特征。极端情况下如果某个特征对每个样本都取不同值那按它切分后每个子节点只有一个样本熵全是0信息增益会非常大但这显然没有泛化意义。为了修正这个问题C4.5算法引入了信息增益率用信息增益除以特征自身的固有值。不过在实际工程中更常用的是基尼指数。基尼指数的定义是Gini(p) 1 - sum(pi^2)。对于二分类Gini 1 - p^2 - (1-p)^2。它和熵的形状很像但计算不需要对数速度更快。scikit-learn默认使用的就是基尼指数也就是CART算法。CART还有一个特点它生成的是二叉树每个节点只分成两支这对连续特征和离散特征都统一处理工程实现更简洁。注意信息熵和基尼指数没有绝对的优劣。信息熵对不纯度的惩罚更平滑基尼指数计算更快。实际项目中两者效果通常接近不用过度纠结。2.3 连续特征怎么处理阈值扫描与排序连续特征不能像离散特征那样直接按取值分叉因为取值是无限的。CART的做法是先把该特征的所有取值排序然后取相邻两个值的中点作为候选阈值。对于每个候选阈值把样本分成“小于等于阈值”和“大于阈值”两组计算加权不纯度选不纯度最低的那个阈值作为切分点。举个例子某个特征取值是[1, 2, 3, 4, 5]标签是[0, 0, 1, 1, 1]。候选阈值就是1.5、2.5、3.5、4.5。分别计算每个阈值切分后的基尼指数选最小的那个。这个过程看起来简单但计算量不小因为每个特征都要排序和扫描。不过好在排序可以预先做一次后续复用。这里有一个实操细节如果特征取值很多候选阈值会很多训练会变慢。scikit-learn内部做了一些优化比如只考虑标签发生变化的位置但整体复杂度仍然和特征取值数量相关。所以如果某个连续特征有几十万个不同取值建议先做分箱或者离散化既能加速也能防止过拟合。2.4 停止条件与剪枝防止树长疯如果没有任何限制决策树会一直分裂到每个叶子节点只有一个样本。这种树在训练集上误差为零但泛化能力极差。防止过拟合的手段主要有两类预剪枝和后剪枝。预剪枝是在树生长过程中提前停止。常见的控制参数包括最大深度、叶子节点最小样本数、内部节点最小样本数、最大叶子节点数、分裂所需的最小不纯度下降。这些参数在scikit-learn的DecisionTreeClassifier里都有对应字段。预剪枝的优点是简单高效缺点是可能停止得太早错过一些有用的分裂。后剪枝是先让树长到足够大然后再从下往上合并一些子树。代价复杂度剪枝是最经典的方法它通过一个复杂度参数alpha来平衡树的大小和拟合误差。alpha越大树越简单。scikit-learn提供了cost_complexity_pruning_path来帮助选择alpha但实际用的时候很多人还是靠交叉验证调预剪枝参数因为更直接。我个人的经验是先用预剪枝把树控制在一个合理范围比如max_depth设成3到10之间min_samples_leaf设成5到50之间然后用交叉验证选一组最好的。如果数据量很大再考虑后剪枝。不要一上来就追求理论最优先把基线跑通更重要。3. 手写一个简化版决策树3.1 定义不纯度函数与切分评估为了真正理解决策树我建议你至少手写一次。下面我用Python实现一个简化版只支持连续特征和二分类使用基尼指数。先定义基尼指数计算函数import numpy as np def gini(y): if len(y) 0: return 0 p np.mean(y) return 1 - p**2 - (1-p)**2这个函数接收一个标签数组返回基尼指数。接下来定义切分评估函数给定特征矩阵X、标签y、特征索引和阈值返回切分后的加权基尼指数。def split_gini(X, y, feat_idx, threshold): left_mask X[:, feat_idx] threshold right_mask ~left_mask n len(y) n_left np.sum(left_mask) n_right n - n_left if n_left 0 or n_right 0: return float(inf) gini_left gini(y[left_mask]) gini_right gini(y[right_mask]) return (n_left / n) * gini_left (n_right / n) * gini_right这个函数先根据阈值把样本分成左右两部分然后计算两边的基尼指数最后按样本比例加权。如果某一边为空说明这个切分无效返回无穷大。3.2 寻找最佳切分点有了评估函数接下来遍历所有特征和所有候选阈值找到加权基尼指数最小的那个组合。def best_split(X, y): best_feat None best_thresh None best_score float(inf) n_features X.shape[1] for feat_idx in range(n_features): values np.sort(np.unique(X[:, feat_idx])) if len(values) 1: continue thresholds (values[:-1] values[1:]) / 2 for thresh in thresholds: score split_gini(X, y, feat_idx, thresh) if score best_score: best_score score best_feat feat_idx best_thresh thresh return best_feat, best_thresh, best_score这里我用了相邻唯一值的中点作为候选阈值这是最常见也最稳妥的做法。注意如果某个特征只有一个唯一值就跳过因为它无法产生有效切分。3.3 递归建树与预测接下来定义树节点和递归建树函数。为了控制复杂度我加了最大深度和最小叶子样本数两个参数。class Node: def __init__(self, featureNone, thresholdNone, leftNone, rightNone, valueNone): self.feature feature self.threshold threshold self.left left self.right right self.value value def build_tree(X, y, depth0, max_depth5, min_samples_leaf2): if len(np.unique(y)) 1 or depth max_depth or len(y) 2 * min_samples_leaf: return Node(valuenp.mean(y) 0.5) feat, thresh, score best_split(X, y) if feat is None: return Node(valuenp.mean(y) 0.5) left_mask X[:, feat] thresh right_mask ~left_mask if np.sum(left_mask) min_samples_leaf or np.sum(right_mask) min_samples_leaf: return Node(valuenp.mean(y) 0.5) left build_tree(X[left_mask], y[left_mask], depth1, max_depth, min_samples_leaf) right build_tree(X[right_mask], y[right_mask], depth1, max_depth, min_samples_leaf) return Node(featurefeat, thresholdthresh, leftleft, rightright) def predict_one(node, x): if node.value is not None: return node.value if x[node.feature] node.threshold: return predict_one(node.left, x) return predict_one(node.right, x) def predict(tree, X): return np.array([predict_one(tree, x) for x in X])这段代码虽然简单但已经包含了决策树的核心逻辑递归分裂、停止条件、叶子节点投票。你可以用它跑一个小数据集看看效果。当然工业级实现要考虑更多东西比如缺失值处理、类别特征编码、多分类支持、剪枝等但原理是一样的。提示手写版的主要目的是理解原理不要用于生产。实际项目直接用scikit-learn稳定性和性能都好得多。4. 用scikit-learn实现决策树分类4.1 数据准备与基线模型下面用scikit-learn自带的鸢尾花数据集做演示。这个数据集有150个样本4个特征3个类别。为了后面画决策边界我只取前两个特征。from sklearn.datasets import load_iris from sklearn.tree import DecisionTreeClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score iris load_iris() X iris.data[:, :2] # 只取前两个特征 y iris.target X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy ) clf DecisionTreeClassifier(random_state42) clf.fit(X_train, y_train) y_pred clf.predict(X_test) print(训练集准确率:, clf.score(X_train, y_train)) print(测试集准确率:, accuracy_score(y_test, y_pred))默认参数下树会一直长到叶子节点纯度为1或者样本数小于2。你会发现训练集准确率通常是1.0但测试集可能只有0.7到0.8。这就是典型的过拟合。4.2 关键参数调优max_depth与min_samples_leaf控制过拟合最直接的两个参数是max_depth和min_samples_leaf。max_depth限制树的最大深度min_samples_leaf要求每个叶子节点至少包含多少个样本。下面用网格搜索找一组好参数。from sklearn.model_selection import GridSearchCV param_grid { max_depth: [2, 3, 4, 5, 6, 8, 10], min_samples_leaf: [1, 2, 3, 5, 10, 15], criterion: [gini, entropy] } grid GridSearchCV( DecisionTreeClassifier(random_state42), param_grid, cv5, scoringaccuracy, n_jobs-1 ) grid.fit(X_train, y_train) print(最佳参数:, grid.best_params_) print(最佳交叉验证分数:, grid.best_score_) best_clf grid.best_estimator_ print(测试集准确率:, best_clf.score(X_test, y_test))我实测下来鸢尾花前两个特征的数据集max_depth在3到5之间、min_samples_leaf在2到5之间通常效果最好。criterion用gini还是entropy差别不大但gini计算更快所以我一般默认用gini。这里有一个经验不要一次性把参数网格设得太大否则交叉验证会很慢。先粗调再细调。比如先试max_depth[2,4,6,8]找到大致范围后再试[3,4,5]。4.3 特征重要性与树结构导出scikit-learn训练完的决策树可以直接查看特征重要性这对特征筛选很有帮助。import pandas as pd importance pd.DataFrame({ feature: iris.feature_names[:2], importance: best_clf.feature_importances_ }).sort_values(importance, ascendingFalse) print(importance)特征重要性的计算方式是每个特征在所有分裂中带来的不纯度下降的加权总和再归一化。数值越大说明该特征对分类越重要。如果某个特征重要性接近0可以考虑删掉。你还可以用export_text把树的结构打印出来from sklearn.tree import export_text tree_rules export_text(best_clf, feature_namesiris.feature_names[:2]) print(tree_rules)输出会是一系列if-else规则非常直观。比如“如果花瓣长度小于等于2.45则是某类否则继续判断花瓣宽度”。这种可解释性是决策树最大的优势之一。5. 决策边界绘制让模型看得见5.1 二维网格预测与等高线填充决策边界是理解分类器行为最直观的工具。对于二维特征空间我们可以生成一个密集的网格对每个网格点预测类别然后用颜色填充。这样就能看到模型把平面划分成了哪些区域。import matplotlib.pyplot as plt from matplotlib.colors import ListedColormap def plot_decision_boundary(clf, X, y, title): x_min, x_max X[:, 0].min() - 0.5, X[:, 0].max() 0.5 y_min, y_max X[:, 1].min() - 0.5, X[:, 1].max() 0.5 xx, yy np.meshgrid( np.arange(x_min, x_max, 0.02), np.arange(y_min, y_max, 0.02) ) Z clf.predict(np.c_[xx.ravel(), yy.ravel()]) Z Z.reshape(xx.shape) plt.figure(figsize(8, 6)) plt.contourf(xx, yy, Z, alpha0.3, cmapListedColormap([#FFAAAA, #AAFFAA, #AAAAFF])) plt.scatter(X[:, 0], X[:, 1], cy, edgecolork, cmapListedColormap([#FF0000, #00FF00, #0000FF])) plt.xlabel(iris.feature_names[0]) plt.ylabel(iris.feature_names[1]) plt.title(title) plt.show() plot_decision_boundary(best_clf, X_train, y_train, 决策树决策边界)这段代码先生成一个步长为0.02的网格然后对每个点预测类别最后用contourf填充颜色。你会看到决策树的边界是由一系列水平和垂直线段组成的这是因为每次分裂只针对一个特征所以边界总是平行于坐标轴。5.2 不同深度下的边界对比把不同深度的树画在一起对比能非常直观地看到过拟合和欠拟合。fig, axes plt.subplots(1, 3, figsize(18, 5)) for ax, depth in zip(axes, [1, 3, 10]): clf DecisionTreeClassifier(max_depthdepth, random_state42) clf.fit(X_train, y_train) x_min, x_max X[:, 0].min() - 0.5, X[:, 0].max() 0.5 y_min, y_max X[:, 1].min() - 0.5, X[:, 1].max() 0.5 xx, yy np.meshgrid(np.arange(x_min, x_max, 0.02), np.arange(y_min, y_max, 0.02)) Z clf.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape) ax.contourf(xx, yy, Z, alpha0.3, cmapListedColormap([#FFAAAA, #AAFFAA, #AAAAFF])) ax.scatter(X_train[:, 0], X_train[:, 1], cy_train, edgecolork, cmapListedColormap([#FF0000, #00FF00, #0000FF])) ax.set_title(fmax_depth{depth}) plt.show()深度为1时树只有一个分裂点边界是一条直线模型欠拟合。深度为3时边界开始有折线能较好地分开三类。深度为10时边界变得非常碎出现很多小区域明显过拟合。这个对比图我每次给学生演示大家都能立刻理解深度控制的意义。5.3 边界绘制的常见坑画决策边界有几个容易踩的坑。第一网格步长不能太大否则边界看起来是锯齿状的不准确。0.02是一个比较通用的值如果特征范围很大可以适当放大。第二预测时要把网格点拉平成二维数组再传给predict否则会报维度错误。第三颜色映射要固定否则不同图之间颜色不一致没法对比。第四如果特征超过两个不能直接画二维边界需要先做降维或者固定其他特征。我一般用PCA降到二维再画但要注意PCA后的特征没有原始物理意义解释起来要小心。注意决策边界展示的是模型在特征空间中的划分不是真实世界的边界。它受训练数据分布影响很大样本稀疏的区域边界可能很不稳定。6. 常见问题与排查技巧实录6.1 训练集准确率100%但测试集很差怎么办这是决策树最常见的问题原因就是树太深把训练数据里的噪声也学进去了。解决办法按优先级排序第一限制max_depth通常3到10之间第二增大min_samples_leaf比如设成5到20第三增大min_samples_split控制内部节点分裂的最小样本数第四用交叉验证选参数不要用测试集调参第五如果数据量足够考虑后剪枝。我一般先设max_depth5和min_samples_leaf5跑一个基线然后再微调。6.2 决策树对特征尺度敏感吗不敏感。因为每次分裂只看一个特征的某个阈值不涉及距离计算所以不需要归一化或标准化。这一点和KNN、SVM很不一样。但如果你用决策树做特征选择然后喂给其他模型那其他模型可能仍然需要归一化。另外如果某个特征取值范围特别大候选阈值会很多训练会变慢这时候可以考虑分箱。6.3 类别特征怎么处理scikit-learn的决策树不支持直接输入字符串类别需要先编码。有两种常见做法一是独热编码把每个类别变成一个二元特征二是标签编码把类别映射成整数。对于树模型标签编码通常就够了因为树可以按阈值切分但缺点是会引入人为的顺序关系。如果类别没有顺序独热编码更安全但会增加特征维度。我一般先用标签编码跑一版如果效果不好再试独热。6.4 缺失值怎么处理scikit-learn的决策树不支持缺失值需要先填充。常见做法是用均值、中位数或众数填充或者用其他模型预测填充。更高级的做法是使用支持缺失值的实现比如某些梯度提升库但那是另一个话题了。如果缺失比例很低直接删掉缺失样本也可以。6.5 决策树和随机森林、梯度提升的区别决策树是单棵树随机森林是多棵树的bagging集成梯度提升是多棵树的boosting集成。随机森林通过随机采样特征和样本降低方差通常比单棵树稳定得多。梯度提升通过逐步拟合残差降低偏差通常在结构化数据上效果最好。但单棵树的优势是可解释性强画出来就能看懂。实际项目中我通常先用单棵树做基线再用随机森林或梯度提升提升效果最后用特征重要性做解释。问题可能原因解决办法训练集准确率远高于测试集过拟合限制深度、增大叶子最小样本数、剪枝训练集和测试集都差欠拟合增大深度、减小叶子最小样本数、增加特征训练速度慢特征取值太多分箱、降维、减少样本边界太碎树太深降低max_depth某特征重要性为0该特征没被用到检查特征是否有区分度、是否编码错误7. 决策树的适用场景与扩展方向决策树最适合的场景是特征和标签之间存在非线性关系、需要可解释性、特征尺度不统一、样本量中等。比如风控规则挖掘、医疗辅助诊断、工业设备故障分类、客户流失预测等。它也是很多集成算法的基础组件学好单棵树再学随机森林和梯度提升会轻松很多。如果你想继续深入我建议按这个顺序扩展第一把本文的手写版改成支持多分类和信息熵第二用scikit-learn的cost_complexity_pruning_path做后剪枝第三学随机森林理解bagging和特征随机第四学梯度提升理解boosting和残差拟合第五学特征重要性评估和SHAP值解释。每一步都能在前一步基础上自然延伸。我在实际项目里用决策树最多的场景是快速建立基线。因为训练快、可解释、不需要太多预处理通常半小时内就能跑出一个能看的模型然后根据特征重要性判断哪些特征值得保留再决定要不要上更复杂的模型。这个工作流我用了很多次效率很高。另外一个小技巧是画决策边界时把训练集和测试集用不同标记画在一起能同时看到拟合情况和泛化情况比只看准确率数字直观得多。