02-机器学习基础: 监督学习——决策树
·

决策树:可解释的规则学习
一、决策树要解决什么问题?
1.1 从人类决策到机器学习
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier, plot_tree
from sklearn.metrics import accuracy_score
import warnings
warnings.filterwarnings('ignore')
print("=" * 60)
print("决策树:模仿人类决策过程的算法")
print("=" * 60)
# 人类决策示例
fig, ax = plt.subplots(figsize=(12, 8))
ax.axis('off')
# 绘制决策树结构
def draw_tree(ax):
# 根节点
root = plt.Rectangle((0.35, 0.7), 0.3, 0.1, facecolor='lightblue', ec='black')
ax.add_patch(root)
ax.text(0.5, 0.75, '天气如何?', ha='center', va='center', fontsize=11)
# 左分支
ax.annotate('', xy=(0.25, 0.6), xytext=(0.4, 0.7),
arrowprops=dict(arrowstyle='->', lw=2))
left_node = plt.Rectangle((0.1, 0.5), 0.3, 0.1, facecolor='lightgreen', ec='black')
ax.add_patch(left_node)
ax.text(0.25, 0.55, '晴天', ha='center', va='center', fontsize=10)
# 右分支
ax.annotate('', xy=(0.75, 0.6), xytext=(0.6, 0.7),
arrowprops=dict(arrowstyle='->', lw=2))
right_node = plt.Rectangle((0.6, 0.5), 0.3, 0.1, facecolor='lightgreen', ec='black')
ax.add_patch(right_node)
ax.text(0.75, 0.55, '雨天', ha='center', va='center', fontsize=10)
# 叶子节点
ax.annotate('', xy=(0.15, 0.4), xytext=(0.2, 0.5),
arrowprops=dict(arrowstyle='->', lw=1))
leaf1 = plt.Rectangle((0.05, 0.3), 0.2, 0.1, facecolor='lightyellow', ec='black')
ax.add_patch(leaf1)
ax.text(0.15, 0.35, '去打球', ha='center', va='center', fontsize=9)
ax.annotate('', xy=(0.35, 0.4), xytext=(0.3, 0.5),
arrowprops=dict(arrowstyle='->', lw=1))
leaf2 = plt.Rectangle((0.25, 0.3), 0.2, 0.1, facecolor='lightyellow', ec='black')
ax.add_patch(leaf2)
ax.text(0.35, 0.35, '待家里', ha='center', va='center', fontsize=9)
ax.annotate('', xy=(0.65, 0.4), xytext=(0.7, 0.5),
arrowprops=dict(arrowstyle='->', lw=1))
leaf3 = plt.Rectangle((0.55, 0.3), 0.2, 0.1, facecolor='lightyellow', ec='black')
ax.add_patch(leaf3)
ax.text(0.65, 0.35, '去打球', ha='center', va='center', fontsize=9)
ax.annotate('', xy=(0.85, 0.4), xytext=(0.8, 0.5),
arrowprops=dict(arrowstyle='->', lw=1))
leaf4 = plt.Rectangle((0.75, 0.3), 0.2, 0.1, facecolor='lightyellow', ec='black')
ax.add_patch(leaf4)
ax.text(0.85, 0.35, '看书', ha='center', va='center', fontsize=9)
draw_tree(ax)
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.set_title('人类决策过程:一系列if-else规则', fontsize=14)
plt.tight_layout()
plt.show()
print("\n💡 决策树的核心思想:")
print(" 将复杂的决策问题分解为一系列简单的判断")
print(" 每个节点问一个问题,根据答案走向不同分支")
print(" 最终到达叶子节点,得到决策结果")
二、决策树的构建原理
2.1 如何选择最佳分裂特征?
def explain_split_criteria():
"""解释分裂标准:信息增益和基尼系数"""
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# 1. 熵的概念
ax1 = axes[0, 0]
p = np.linspace(0.01, 0.99, 100)
entropy = -(p * np.log2(p) + (1-p) * np.log2(1-p))
gini = 1 - (p**2 + (1-p)**2)
ax1.plot(p, entropy, 'b-', linewidth=2, label='熵')
ax1.plot(p, gini, 'r-', linewidth=2, label='基尼系数')
ax1.set_xlabel('P(类别1)')
ax1.set_ylabel('不纯度')
ax1.set_title('熵 vs 基尼系数')
ax1.legend()
ax1.grid(True, alpha=0.3)
# 2. 信息增益公式
ax2 = axes[0, 1]
ax2.axis('off')
ax2.set_title('信息增益', fontsize=12)
info_gain_text = """
📐 信息增益 = 父节点熵 - 加权子节点熵
其中:
• 熵 H = -Σ p_i log₂(p_i)
• 加权子节点熵 = Σ (n_child/n_parent) × H_child
选择使信息增益最大的特征分裂!
示例:
父节点: [5个A, 5个B] → H_parent = 1.0
分裂后:
左子节点: [4个A, 1个B] → H_left = 0.72
右子节点: [1个A, 4个B] → H_right = 0.72
加权平均 = 0.5×0.72 + 0.5×0.72 = 0.72
信息增益 = 1.0 - 0.72 = 0.28
"""
ax2.text(0.05, 0.95, info_gain_text, transform=ax2.transAxes, fontsize=9,
verticalalignment='top', fontfamily='monospace')
# 3. 基尼系数
ax3 = axes[1, 0]
ax3.axis('off')
ax3.set_title('基尼系数', fontsize=12)
gini_text = """
📐 基尼系数 = 1 - Σ p_i²
特点:
• 取值范围 [0, 1]
• 0: 纯节点(所有样本同类)
• 1: 最不纯(均匀分布)
基尼增益 = 父节点基尼 - 加权子节点基尼
CART算法默认使用基尼系数
"""
ax3.text(0.05, 0.95, gini_text, transform=ax3.transAxes, fontsize=9,
verticalalignment='top', fontfamily='monospace')
# 4. 分裂效果对比
ax4 = axes[1, 1]
# 模拟不同分裂的效果
categories = ['不好分裂', '好分裂', '完美分裂']
gain_values = [0.05, 0.35, 0.75]
colors = ['lightcoral', 'lightblue', 'lightgreen']
bars = ax4.bar(categories, gain_values, color=colors)
ax4.set_ylabel('信息增益')
ax4.set_title('不同分裂效果对比')
ax4.set_ylim(0, 1)
for bar, gain in zip(bars, gain_values):
ax4.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.02,
f'{gain:.2f}', ha='center', va='bottom')
plt.suptitle('决策树分裂标准:信息增益 vs 基尼系数', fontsize=14)
plt.tight_layout()
plt.show()
explain_split_criteria()
2.2 信息增益计算示例
def calculate_information_gain():
"""详细计算信息增益示例"""
print("\n" + "=" * 60)
print("信息增益计算示例")
print("=" * 60)
# 示例数据:是否去打球
# 特征:天气(晴天/雨天)、温度(高/中/低)、湿度(高/正常)
data = {
'天气': ['晴天', '晴天', '雨天', '晴天', '雨天', '雨天', '晴天', '雨天'],
'温度': ['高', '高', '中', '低', '中', '低', '中', '高'],
'湿度': ['高', '高', '高', '正常', '正常', '正常', '正常', '高'],
'打球': ['否', '否', '是', '是', '是', '是', '是', '否']
}
# 计算父节点熵
labels = data['打球']
n_total = len(labels)
n_yes = labels.count('是')
n_no = labels.count('否')
p_yes = n_yes / n_total
p_no = n_no / n_total
parent_entropy = -(p_yes * np.log2(p_yes) + p_no * np.log2(p_no))
print(f"父节点: 总样本={n_total}, 是={n_yes}, 否={n_no}")
print(f"父节点熵: {parent_entropy:.4f}")
# 按天气分裂
print("\n" + "-" * 40)
print("按'天气'分裂:")
weather_values = ['晴天', '雨天']
weighted_entropy = 0
for weather in weather_values:
subset_indices = [i for i, w in enumerate(data['天气']) if w == weather]
subset_labels = [labels[i] for i in subset_indices]
n_sub = len(subset_labels)
n_sub_yes = subset_labels.count('是')
n_sub_no = subset_labels.count('否')
if n_sub > 0:
p_sub_yes = n_sub_yes / n_sub
p_sub_no = n_sub_no / n_sub
sub_entropy = -(p_sub_yes * np.log2(p_sub_yes + 1e-10) +
p_sub_no * np.log2(p_sub_no + 1e-10))
else:
sub_entropy = 0
weight = n_sub / n_total
weighted_entropy += weight * sub_entropy
print(f" {weather}: {n_sub}个样本, 是={n_sub_yes}, 熵={sub_entropy:.4f}")
info_gain = parent_entropy - weighted_entropy
print(f"加权平均熵: {weighted_entropy:.4f}")
print(f"信息增益: {info_gain:.4f}")
calculate_information_gain()
三、从零实现决策树
3.1 完整实现
class DecisionTreeNode:
"""决策树节点"""
def __init__(self, feature_idx=None, threshold=None, left=None, right=None,
value=None, is_leaf=False):
self.feature_idx = feature_idx # 分裂特征索引
self.threshold = threshold # 分裂阈值
self.left = left # 左子节点
self.right = right # 右子节点
self.value = value # 叶子节点的预测值
self.is_leaf = is_leaf # 是否是叶子节点
class DecisionTreeFromScratch:
"""从零实现的决策树分类器"""
def __init__(self, max_depth=None, min_samples_split=2, criterion='gini'):
"""
参数:
max_depth: 最大深度
min_samples_split: 最小分裂样本数
criterion: 分裂标准 ('gini' 或 'entropy')
"""
self.max_depth = max_depth
self.min_samples_split = min_samples_split
self.criterion = criterion
self.root = None
self.n_features = None
def _calculate_impurity(self, y):
"""计算不纯度"""
if len(y) == 0:
return 0
unique, counts = np.unique(y, return_counts=True)
probs = counts / len(y)
if self.criterion == 'gini':
# 基尼系数: 1 - Σ p²
return 1 - np.sum(probs ** 2)
else:
# 熵: -Σ p log₂(p)
return -np.sum(probs * np.log2(probs + 1e-10))
def _information_gain(self, y, y_left, y_right):
"""计算信息增益"""
parent_impurity = self._calculate_impurity(y)
n = len(y)
n_left, n_right = len(y_left), len(y_right)
weighted_child_impurity = (n_left/n) * self._calculate_impurity(y_left) + \
(n_right/n) * self._calculate_impurity(y_right)
return parent_impurity - weighted_child_impurity
def _best_split(self, X, y):
"""找到最佳分裂点"""
best_gain = -1
best_feature = None
best_threshold = None
for feature_idx in range(self.n_features):
# 获取该特征的所有唯一值
feature_values = np.unique(X[:, feature_idx])
for threshold in feature_values:
# 分裂
left_mask = X[:, feature_idx] <= threshold
right_mask = ~left_mask
y_left = y[left_mask]
y_right = y[right_mask]
if len(y_left) == 0 or len(y_right) == 0:
continue
# 计算信息增益
gain = self._information_gain(y, y_left, y_right)
if gain > best_gain:
best_gain = gain
best_feature = feature_idx
best_threshold = threshold
return best_feature, best_threshold, best_gain
def _build_tree(self, X, y, depth):
"""递归构建决策树"""
n_samples = len(y)
n_classes = len(np.unique(y))
# 停止条件
if (self.max_depth is not None and depth >= self.max_depth) or \
n_samples < self.min_samples_split or \
n_classes == 1:
# 返回叶子节点,值为多数类
unique, counts = np.unique(y, return_counts=True)
value = unique[np.argmax(counts)]
return DecisionTreeNode(value=value, is_leaf=True)
# 找到最佳分裂
feature_idx, threshold, gain = self._best_split(X, y)
if feature_idx is None or gain <= 0:
unique, counts = np.unique(y, return_counts=True)
value = unique[np.argmax(counts)]
return DecisionTreeNode(value=value, is_leaf=True)
# 分裂数据
left_mask = X[:, feature_idx] <= threshold
right_mask = ~left_mask
X_left, y_left = X[left_mask], y[left_mask]
X_right, y_right = X[right_mask], y[right_mask]
# 递归构建子树
left_subtree = self._build_tree(X_left, y_left, depth + 1)
right_subtree = self._build_tree(X_right, y_right, depth + 1)
return DecisionTreeNode(feature_idx=feature_idx, threshold=threshold,
left=left_subtree, right=right_subtree)
def fit(self, X, y):
"""训练决策树"""
self.n_features = X.shape[1]
self.root = self._build_tree(X, y, 0)
return self
def _predict_one(self, x, node):
"""预测单个样本"""
if node.is_leaf:
return node.value
if x[node.feature_idx] <= node.threshold:
return self._predict_one(x, node.left)
else:
return self._predict_one(x, node.right)
def predict(self, X):
"""批量预测"""
return np.array([self._predict_one(x, self.root) for x in X])
def score(self, X, y):
"""计算准确率"""
y_pred = self.predict(X)
return np.mean(y_pred == y)
# 测试从零实现的决策树
print("\n" + "=" * 60)
print("从零实现的决策树测试")
print("=" * 60)
# 生成数据
from sklearn.datasets import make_moons
X, y = make_moons(n_samples=300, noise=0.2, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3)
# 训练不同深度的决策树
depths = [2, 5, 10, 20]
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
for idx, depth in enumerate(depths):
ax = axes[idx // 2, idx % 2]
dt = DecisionTreeFromScratch(max_depth=depth, criterion='gini')
dt.fit(X_train, y_train)
train_acc = dt.score(X_train, y_train)
test_acc = dt.score(X_test, y_test)
# 绘制决策边界
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.linspace(x_min, x_max, 200),
np.linspace(y_min, y_max, 200))
Z = dt.predict(np.c_[xx.ravel(), yy.ravel()])
Z = Z.reshape(xx.shape)
ax.contourf(xx, yy, Z, alpha=0.3, cmap='RdBu')
ax.scatter(X_train[y_train==0, 0], X_train[y_train==0, 1],
c='blue', alpha=0.5, s=15)
ax.scatter(X_train[y_train==1, 0], X_train[y_train==1, 1],
c='red', alpha=0.5, s=15)
ax.set_title(f'深度={depth}\n训练准确率={train_acc:.3f}, 测试准确率={test_acc:.3f}')
ax.set_xlabel('特征1')
ax.set_ylabel('特征2')
ax.grid(True, alpha=0.3)
plt.suptitle('决策树深度对模型复杂度的影响', fontsize=14)
plt.tight_layout()
plt.show()
print("\n📊 观察结论:")
print(" 深度=2: 欠拟合,决策边界太简单")
print(" 深度=5: 合适,较好的泛化")
print(" 深度=10-20: 过拟合,决策边界过于复杂")
四、剪枝:防止过拟合
4.1 预剪枝 vs 后剪枝
def visualize_pruning():
"""可视化剪枝策略"""
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# 1. 预剪枝
ax1 = axes[0]
ax1.axis('off')
ax1.set_title('预剪枝(Pre-pruning)', fontsize=12)
pre_pruning_text = """
🌳 预剪枝:在构建过程中提前停止
停止条件:
• 达到最大深度
• 节点样本数小于阈值
• 信息增益小于阈值
• 节点纯度达到要求
优点:
✅ 计算效率高
✅ 防止过拟合
缺点:
❌ 可能过早停止(欠拟合)
"""
ax1.text(0.05, 0.95, pre_pruning_text, transform=ax1.transAxes, fontsize=10,
verticalalignment='top', fontfamily='monospace')
# 2. 后剪枝
ax2 = axes[1]
ax2.axis('off')
ax2.set_title('后剪枝(Post-pruning)', fontsize=12)
post_pruning_text = """
🌳 后剪枝:先完全生长,再剪除枝叶
步骤:
1. 构建完整决策树
2. 从下往上评估每个节点
3. 如果剪掉子树能提升验证集精度,则剪枝
常用方法:
• 代价复杂度剪枝(CCP)
• 最小误差剪枝(MEP)
• 悲观剪枝(PEP)
优点:
✅ 通常效果更好
缺点:
❌ 计算量大
"""
ax2.text(0.05, 0.95, post_pruning_text, transform=ax2.transAxes, fontsize=10,
verticalalignment='top', fontfamily='monospace')
plt.suptitle('决策树剪枝策略', fontsize=14)
plt.tight_layout()
plt.show()
visualize_pruning()
4.2 剪枝效果演示
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import cross_val_score
# 生成数据
X, y = make_moons(n_samples=500, noise=0.2, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3)
# 不同剪枝策略的效果
max_depths = range(1, 21)
train_scores_no_prune = []
test_scores_no_prune = []
train_scores_prune = []
test_scores_prune = []
for depth in max_depths:
# 无剪枝(仅限制深度)
dt_no_prune = DecisionTreeClassifier(max_depth=depth, random_state=42)
dt_no_prune.fit(X_train, y_train)
train_scores_no_prune.append(dt_no_prune.score(X_train, y_train))
test_scores_no_prune.append(dt_no_prune.score(X_test, y_test))
# 带剪枝(min_samples_leaf)
dt_prune = DecisionTreeClassifier(max_depth=depth, min_samples_leaf=10, random_state=42)
dt_prune.fit(X_train, y_train)
train_scores_prune.append(dt_prune.score(X_train, y_train))
test_scores_prune.append(dt_prune.score(X_test, y_test))
plt.figure(figsize=(12, 5))
plt.subplot(1, 2, 1)
plt.plot(max_depths, train_scores_no_prune, 'b-', label='训练集(无剪枝)', linewidth=2)
plt.plot(max_depths, test_scores_no_prune, 'r-', label='测试集(无剪枝)', linewidth=2)
plt.xlabel('最大深度')
plt.ylabel('准确率')
plt.title('无剪枝:深度增加导致过拟合')
plt.legend()
plt.grid(True, alpha=0.3)
plt.subplot(1, 2, 2)
plt.plot(max_depths, train_scores_prune, 'b-', label='训练集(有剪枝)', linewidth=2)
plt.plot(max_depths, test_scores_prune, 'r-', label='测试集(有剪枝)', linewidth=2)
plt.xlabel('最大深度')
plt.ylabel('准确率')
plt.title('有剪枝(min_samples_leaf=10):过拟合缓解')
plt.legend()
plt.grid(True, alpha=0.3)
plt.suptitle('剪枝对决策树泛化能力的影响', fontsize=14)
plt.tight_layout()
plt.show()
print("\n💡 剪枝参数说明:")
print(" max_depth: 最大深度(预剪枝)")
print(" min_samples_split: 节点最小分裂样本数")
print(" min_samples_leaf: 叶子节点最小样本数")
print(" min_impurity_decrease: 最小不纯度减少量")
五、使用scikit-learn实现
from sklearn.tree import DecisionTreeClassifier, plot_tree
from sklearn.datasets import load_iris
# 加载数据
iris = load_iris()
X, y = iris.data, iris.target
feature_names = iris.feature_names
class_names = iris.target_names
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
# 训练决策树(带剪枝)
dt = DecisionTreeClassifier(
max_depth=4, # 最大深度
min_samples_split=5, # 最小分裂样本数
min_samples_leaf=2, # 叶子节点最小样本数
criterion='gini', # 分裂标准
random_state=42
)
dt.fit(X_train, y_train)
train_acc = dt.score(X_train, y_train)
test_acc = dt.score(X_test, y_test)
print("\n" + "=" * 60)
print("scikit-learn 决策树结果")
print("=" * 60)
print(f"训练集准确率: {train_acc:.4f}")
print(f"测试集准确率: {test_acc:.4f}")
print(f"特征重要性: {dict(zip(feature_names, dt.feature_importances_))}")
# 可视化决策树
plt.figure(figsize=(20, 10))
plot_tree(dt, filled=True, feature_names=feature_names,
class_names=class_names, rounded=True, fontsize=10)
plt.title('决策树可视化(鸢尾花分类)', fontsize=14)
plt.tight_layout()
plt.show()
# 特征重要性可视化
plt.figure(figsize=(10, 6))
plt.barh(feature_names, dt.feature_importances_)
plt.xlabel('特征重要性')
plt.title('决策树特征重要性分析')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
六、决策树的优缺点
def pros_cons_summary():
"""决策树优缺点总结"""
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# 1. 优点
ax1 = axes[0]
ax1.axis('off')
ax1.set_title('✅ 决策树的优点', fontsize=12)
pros = """
1. 可解释性强
- 规则直观,易于理解
- 可可视化展示
2. 无需特征缩放
- 不受量纲影响
- 不需要归一化
3. 处理混合类型数据
- 数值型 + 类别型
4. 自动特征选择
- 只使用重要特征
5. 非参数模型
- 不假设数据分布
"""
ax1.text(0.05, 0.95, pros, transform=ax1.transAxes, fontsize=10,
verticalalignment='top', fontfamily='monospace')
# 2. 缺点
ax2 = axes[1]
ax2.axis('off')
ax2.set_title('❌ 决策树的缺点', fontsize=12)
cons = """
1. 容易过拟合
- 不剪枝时很严重
2. 不稳定
- 数据微小变化可能导致完全不同的树
3. 偏向多值特征
- 取值多的特征容易被选
4. 决策边界是轴平行的
- 无法学习对角线边界
5. 处理缺失值困难
"""
ax2.text(0.05, 0.95, cons, transform=ax2.transAxes, fontsize=10,
verticalalignment='top', fontfamily='monospace')
plt.suptitle('决策树的优缺点', fontsize=14)
plt.tight_layout()
plt.show()
pros_cons_summary()
七、代码讲解与总结
def code_explanation():
"""代码讲解"""
print("\n" + "=" * 60)
print("代码关键点讲解")
print("=" * 60)
explanations = {
"熵的计算": """
def entropy(y):
probs = np.bincount(y) / len(y)
return -np.sum(probs * np.log2(probs + 1e-10))
理解: 熵越高,数据越混乱
""",
"信息增益": """
gain = parent_entropy - weighted_child_entropy
选择信息增益最大的特征分裂
""",
"递归构建": """
def build_tree(X, y, depth):
if stop_condition:
return leaf_node
feature, threshold = find_best_split(X, y)
left = build_tree(X[left], y[left], depth+1)
right = build_tree(X[right], y[right], depth+1)
return node(feature, threshold, left, right)
""",
"剪枝参数": """
max_depth: 限制树的最大深度
min_samples_split: 节点最少样本数
min_samples_leaf: 叶子节点最少样本数
增大这些参数 → 预剪枝加强 → 防止过拟合
"""
}
for title, content in explanations.items():
print(f"\n📌 {title}")
print(content)
code_explanation()
八、总结
决策树核心要点:
| 组件 | 作用 | 常用方法 |
|---|---|---|
| 分裂标准 | 选择最佳特征 | 信息增益、基尼系数 |
| 停止条件 | 防止无限生长 | 深度限制、最小样本数 |
| 剪枝 | 防止过拟合 | 预剪枝、后剪枝 |
分裂标准对比:
| 标准 | 公式 | 特点 |
|---|---|---|
| 信息增益 | H(parent) - Σ加权H(child) | ID3算法 |
| 基尼系数 | 1 - Σp² | CART算法(默认) |
参数调优建议:
- 先用默认参数
- 过拟合 → 增加max_depth限制、增加min_samples_split
- 欠拟合 → 减少限制、增加深度
记住:
- 决策树是可解释性最好的模型
- 理解决策树是理解随机森林的基础
- 剪枝是防止过拟合的关键
- 特征重要性可以帮助特征选择
更多推荐

所有评论(0)