从零构建CART决策树模型:原理详解与Python实战指南

1次阅读
没有评论

共计 4431 个字符,预计需要花费 12 分钟才能阅读完成。

image.webp

1. 决策树基础与 CART 特性

决策树通过一系列规则对数据进行分类或回归。CART(Classification and Regression Trees)与 ID3/C4.5 的主要差异在于:

从零构建 CART 决策树模型:原理详解与 Python 实战指南

  • 分裂标准 :CART 使用基尼系数(Gini Index),而 ID3/C4.5 使用信息增益(Information Gain)
  • 二叉树结构 :CART 每个节点只产生两个分支(二元分裂),而 ID3/C4.5 可产生多分支
  • 功能完备性 :CART 同时支持分类和回归任务

基尼系数计算公式如下:

$$ Gini(D) = 1 – \sum_{k=1}^{K} p_k^2 $$

其中 $p_k$ 是第 $k$ 类样本在数据集 $D$ 中的比例。基尼系数越小,数据纯度越高。

2. 数据预处理实战

2.1 处理缺失值

import numpy as np
import pandas as pd

# 示例:用中位数填充数值型缺失值
def fill_missing_values(df):
    for col in df.select_dtypes(include=np.number):
        median_val = df[col].median()
        df[col].fillna(median_val, inplace=True)
    return df

2.2 类别变量编码

from sklearn.preprocessing import LabelEncoder

# 类别变量转为数值
def encode_categorical(df, columns):
    encoders = {}
    for col in columns:
        le = LabelEncoder()
        df[col] = le.fit_transform(df[col])
        encoders[col] = le
    return df, encoders

3. 核心算法实现

3.1 基尼系数计算(向量化优化)

def gini_index(y):
    _, counts = np.unique(y, return_counts=True)
    probabilities = counts / len(y)
    return 1 - np.sum(probabilities ** 2)

3.2 决策树节点类设计

from dataclasses import dataclass
from typing import Union, Optional

@dataclass
class TreeNode:
    feature_idx: Optional[int] = None  # 分裂特征索引
    threshold: Optional[float] = None  # 分裂阈值
    left: Optional['TreeNode'] = None  # 左子树
    right: Optional['TreeNode'] = None # 右子树
    value: Optional[Union[int, float]] = None  # 叶节点预测值 

3.3 递归分裂实现

class DecisionTreeClassifier:
    def __init__(self, max_depth=5, min_samples_split=2):
        self.max_depth = max_depth
        self.min_samples_split = min_samples_split
        self.root = None

    def _best_split(self, X, y):
        best_gini = float('inf')
        best_feature, best_threshold = None, None

        for feature_idx in range(X.shape[1]):
            thresholds = np.unique(X[:, feature_idx])
            for threshold in thresholds:
                left_mask = X[:, feature_idx] <= threshold
                gini = self._weighted_gini(y[left_mask], y[~left_mask])
                if gini < best_gini:
                    best_gini = gini
                    best_feature = feature_idx
                    best_threshold = threshold
        return best_feature, best_threshold

    def _weighted_gini(self, left_y, right_y):
        n = len(left_y) + len(right_y)
        return (len(left_y)/n)*gini_index(left_y) + (len(right_y)/n)*gini_index(right_y)

    def fit(self, X, y, node=None, depth=0):
        if node is None:
            self.root = TreeNode()
            node = self.root

        # 终止条件
        if (depth >= self.max_depth 
            or len(y) < self.min_samples_split 
            or len(np.unique(y)) == 1):
            node.value = np.argmax(np.bincount(y))
            return

        # 寻找最佳分裂
        feature_idx, threshold = self._best_split(X, y)
        if feature_idx is None:  # 无法继续分裂
            node.value = np.argmax(np.bincount(y))
            return

        # 递归构建子树
        node.feature_idx = feature_idx
        node.threshold = threshold
        left_mask = X[:, feature_idx] <= threshold

        node.left = TreeNode()
        self.fit(X[left_mask], y[left_mask], node.left, depth+1)

        node.right = TreeNode()
        self.fit(X[~left_mask], y[~left_mask], node.right, depth+1)

4. 高级优化技巧

4.1 连续特征分箱

# 等频分箱示例
def equal_freq_binning(series, n_bins=5):
    percentiles = np.linspace(0, 100, n_bins+1)
    bin_edges = np.percentile(series.dropna(), percentiles)
    return pd.cut(series, bins=bin_edges, include_lowest=True)

4.2 代价复杂度剪枝

def prune_tree(node, alpha):
    if node.left is None and node.right is None:
        return node

    # 递归剪枝子树
    if node.left:
        node.left = prune_tree(node.left, alpha)
    if node.right:
        node.right = prune_tree(node.right, alpha)

    # 计算剪枝前后的损失
    if node.left and node.right:
        # 这里简化计算,实际应使用验证集误差
        before_prune_loss = gini_index(np.concatenate([
            node.left.value_samples,
            node.right.value_samples
        ])) + alpha * 2  # 2 个叶节点

        after_prune_loss = gini_index(np.concatenate([node.left.value_samples, node.right.value_samples])
        ) + alpha * 1  # 合并为 1 个节点

        if after_prune_loss <= before_prune_loss:
            node.left = None
            node.right = None
            node.value = np.argmax(np.bincount(np.concatenate([node.left.value_samples, node.right.value_samples])
            ))
    return node

5. 模型评估与对比

在 MNIST 数据集上的性能测试:

from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier as SklearnTree

# 加载数据
digits = load_digits()
X, y = digits.data, digits.target
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)

# 自定义模型
my_tree = DecisionTreeClassifier(max_depth=10)
my_tree.fit(X_train, y_train)
my_score = np.mean(my_tree.predict(X_test) == y_test)

# sklearn 模型
sk_tree = SklearnTree(max_depth=10, criterion='gini')
sk_tree.fit(X_train, y_train)
sk_score = sk_tree.score(X_test, y_test)

print(f'自定义模型准确率: {my_score:.4f}')
print(f'sklearn 模型准确率: {sk_score:.4f}')

6. 延伸思考

  1. 随机森林扩展 :通过 bootstrap 采样构建多棵 CART 树,最终投票决策
  2. 高维数据优化 :
  3. 特征预筛选(如方差过滤)
  4. 使用近似算法加速最佳分裂点查找
  5. 对稀疏数据采用特殊存储格式(如 CSR)

7. 可视化决策树

使用 graphviz 可视化(需安装 graphviz 软件):

import graphviz
from sklearn.tree import export_graphviz

dot_data = export_graphviz(
    sk_tree,
    out_file=None, 
    feature_names=[f'pixel_{i}' for i in range(64)],
    class_names=[str(i) for i in range(10)],
    filled=True,
    rounded=True
)
graph = graphviz.Source(dot_data)
graph.render('digits_tree')  # 保存为 PDF

总结

本文从理论到实践完整实现了 CART 决策树模型,关键收获包括:

  1. 理解了基尼系数作为分裂标准的工作原理
  2. 掌握了递归构建二叉决策树的实现方法
  3. 学会了应对实际工程问题的技巧(剪枝、分箱等)
  4. 通过对比 sklearn 实现验证了模型正确性

决策树作为基础算法,是理解随机森林、GBDT 等复杂模型的重要基础。建议读者尝试在更大数据集上应用,并观察不同超参数对模型性能的影响。

正文完
 0
评论(没有评论)