从树到森林——决策树、随机森林与可解释性
一、为什么需要树模型?
线性模型优雅、透明,但它有一个致命假设:特征与目标之间是线性关系。现实世界却充满非线性、交互效应和分段规则:
- '如果年龄 > 60 且 血压 > 140,则高风险';
- '当用户点击过广告 A 且未购买,则推送优惠券 B'。
这些条件判断天然适合用'树'来表达。
二、决策树:用问答游戏做预测
1. 直觉:像玩'20 个问题'游戏
想象你在猜一个名人:
- '是男性吗?' → 是
- '还活着吗?' → 否
- '是科学家吗?' → 是
- ……
每一步都根据答案缩小范围,最终锁定目标。决策树正是如此:通过一系列 if-else 规则,将样本分到不同叶子节点,每个叶子给出一个预测值(分类标签或回归均值)。
2. 树的结构
- 根节点(Root):第一个判断条件;
- 内部节点(Internal Node):中间判断;
- 叶子节点(Leaf):最终预测结果;
- 分裂(Split):选择一个特征和阈值,将数据分为两组。
💡 决策树不需要特征缩放、能自动处理类别变量、对异常值鲁棒——这是它广受欢迎的原因。
三、如何构建一棵好树?——分裂准则
关键问题:在每个节点,该选哪个特征、哪个阈值来分裂?
目标:让子节点尽可能'纯净'(即同一类样本聚集在一起)。
1. 分类任务:基尼不纯度 vs 信息熵
基尼不纯度(Gini Impurity)
对于一个节点,若有 K 个类别,第 k 类占比为 p_k,则:
$$ \text{Gini} = 1 - \sum_{k=1}^{K} p_k^2 $$
- Gini = 0:完全纯净(所有样本属于同一类);
- Gini 最大:各类均匀分布。
信息熵(Entropy)
源自信息论:
$$ \text{Entropy} = -\sum_{k=1}^{K} p_k \log_2 p_k $$
- Entropy = 0:完全确定;
- Entropy 越大:不确定性越高。
✅ 实践中,基尼不纯度计算更快(无对数),效果与熵相近,sklearn 默认使用 Gini。
2. 回归任务:方差减少(Variance Reduction)
目标:让左右子节点的目标值方差之和最小。
分裂后的总方差:
$$ \text{Var}{\text{left}} \cdot \frac{n{\text{left}}}{n} + \text{Var}{\text{right}} \cdot \frac{n{\text{right}}}{n} $$
我们选择使该值最小的特征和切分点。
四、动手实现:从零写一个简易决策树(分类)
为简化,我们只处理数值型特征,并采用递归构建。
import numpy np
collections Counter
:
():
.feature = feature
.threshold = threshold
.left = left
.right = right
.value = value
():
.value
:
():
.min_samples_split = min_samples_split
.max_depth = max_depth
.n_feats = n_feats
.root =
():
.n_feats = X.shape[] .n_feats (.n_feats, X.shape[])
.root = ._grow_tree(X, y)
():
n_samples, n_features = X.shape
n_labels = (np.unique(y))
(depth >= .max_depth n_labels == n_samples < .min_samples_split):
leaf_value = ._most_common_label(y)
Node(value=leaf_value)
feat_idxs = np.random.choice(n_features, .n_feats, replace=)
best_feat, best_thresh = ._best_split(X, y, feat_idxs)
left_idxs, right_idxs = ._split(X[:, best_feat], best_thresh)
left = ._grow_tree(X[left_idxs, :], y[left_idxs], depth + )
right = ._grow_tree(X[right_idxs, :], y[right_idxs], depth + )
Node(best_feat, best_thresh, left, right)
():
best_gain = -
split_idx, split_thresh = ,
feat_idx feat_idxs:
X_column = X[:, feat_idx]
thresholds = np.unique(X_column)
th thresholds:
gain = ._information_gain(y, X_column, th)
gain > best_gain:
best_gain = gain
split_idx = feat_idx
split_thresh = th
split_idx, split_thresh
():
parent_gini = ._gini(y)
left_idxs, right_idxs = ._split(X_column, split_thresh)
(left_idxs) == (right_idxs) == :
n = (y)
n_l, n_r = (left_idxs), (right_idxs)
gini_l, gini_r = ._gini(y[left_idxs]), ._gini(y[right_idxs])
child_gini = (n_l / n) * gini_l + (n_r / n) * gini_r
ig = parent_gini - child_gini
ig
():
hist = np.bincount(y)
ps = hist / (y)
- np.(ps ** )
():
left_idxs = np.argwhere(X_column <= split_thresh).flatten()
right_idxs = np.argwhere(X_column > split_thresh).flatten()
left_idxs, right_idxs
():
counter = Counter(y)
counter.most_common()[][]
():
np.array([._traverse_tree(x, .root) x X])
():
node.is_leaf_node():
node.value
x[node.feature] <= node.threshold:
._traverse_tree(x, node.left)
._traverse_tree(x, node.right)


