尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

从原理到实战:手写CART决策树实现银行贷款风险评估

从原理到实战:手写CART决策树实现银行贷款风险评估 当你第一次接触机器学习分类问题时面对逻辑回归、支持向量机、神经网络等一堆算法是不是感觉有点无从下手它们要么对数据分布有假设要么像黑盒一样难以解释。有没有一种方法既能直观理解又能快速上手甚至能直接告诉你“为什么这么分”决策树Decision Tree就是这样一个“白盒”利器。它不像深度学习那样需要复杂的调参也不像某些统计方法那样要求严格的数据前提。它的核心思想简单到可以用一句话概括通过一系列“是/否”问题像流程图一样把数据分门别类。但正是这种简单让它成为了机器学习入门必学、工业界广泛应用的基石算法。然而很多初学者在实现决策树时往往会陷入两个误区一是过度依赖sklearn的fit和predict对背后的分裂逻辑、停止条件、剪枝策略一知半解二是自己动手实现时被递归、信息增益计算、树结构存储等细节卡住最终只能跑通一个“玩具”例子。本文将彻底解决这两个问题。我们不仅会清晰拆解ID3、C4.5、CART这些核心算法的原理差异更会从一个真实的“银行贷款风险评估”案例出发带你从零手写一棵完整的CART分类树。你会看到每一步的数学计算、每一次的递归分裂、以及如何避免过拟合的剪枝操作。读完本文你不仅能回答面试官关于“信息熵与基尼系数区别”的问题更能拥有将决策树真正落地到一个业务场景中的实战能力。1. 决策树要解决的核心问题从“拍脑袋”到“数据驱动决策”在深入技术细节前我们首先要明白决策树到底在干什么。想象一下一位银行信贷员在审批贷款申请。他可能会问一系列问题申请人年收入是否高于20万是否有房产抵押历史信用记录是否良好根据这些问题的答案是/否最终做出“批准”或“拒绝”的决定。这个过程本质上就是一个决策树。决策树算法的目标就是利用历史数据大量已知结果的申请记录自动找出最佳的问题提问顺序和判断阈值让机器能模仿甚至超越人类专家的决策流程。它解决了传统“拍脑袋”或基于简单规则决策的三大痛点可解释性差神经网络为什么拒绝某个申请很难说清。决策树可以清晰地展示从根节点到叶子节点的完整判断路径。难以处理混合型数据数据中既有收入数值型又有职业类别型还有信用等级有序类别。决策树能自然地处理这种混合类型。规则挖掘自动化人工总结规则耗时费力且不全面。决策树可以从数据中自动学习出复杂且有效的规则组合。因此决策树特别适合那些需要清晰解释决策原因的场景如金融风控、医疗诊断、客户细分也是构建更强大集成模型如随机森林、XGBoost的基础组件。2. 核心原理如何构建一棵“好”的树构建决策树的核心是回答两个问题1. 选择哪个特征进行分裂 2. 在特征的哪个值上分裂不同的算法给出了不同的答案。2.1 关键概念纯度、熵与基尼系数树的分裂目标是让分裂后的子节点尽可能“纯”——即同一个节点内的样本尽可能属于同一类别。如何度量“不纯度”主要有两种指标信息熵Entropy来源于信息论表示系统的混乱程度。熵越大越混乱。公式对于样本集合D其熵定义为$Ent(D) -\sum_{k1}^{K} p_k \log_2 p_k$其中$K$是类别数$p_k$是第$k$类样本所占的比例。熵为0表示所有样本属于同一类最纯熵最大表示各类样本均匀分布最不纯。基尼系数Gini Index来源于经济学度量一个随机选中的样本被错误分类的概率。公式$Gini(D) \sum_{k1}^{K} p_k (1-p_k) 1 - \sum_{k1}^{K} p_k^2$同样值越小纯度越高。选择哪个在实践中两者效果通常相似。但基尼系数的计算不涉及对数运算稍快一些。CART算法默认使用基尼系数而ID3和C4.5使用信息增益基于熵。2.2 核心算法对比ID3, C4.5 与 CART这是理解决策树家族的关键。特性ID3C4.5CART分裂准则信息增益信息增益率基尼系数分类/平方误差最小化回归特征类型分类特征分类与数值特征分类与数值特征树结构多叉树多叉树二叉树主要改进基础算法解决ID3对多值特征的偏好支持连续值和缺失值处理支持分类和回归二叉树结构更简单是后续集成学习的基础剪枝无悲观剪枝代价复杂度剪枝通俗解释ID3像个单纯的学生哪个特征能让信息混乱度降得最多信息增益最大就选哪个。但它有个缺点特别偏爱取值种类多的特征如“用户ID”这种特征对预测新数据毫无用处。C4.5是ID3的升级版。它引入了“信息增益率”相当于给信息增益除以一个关于特征本身值的惩罚项从而克服了对多值特征的偏好。它更健壮。CART目前最主流的算法。它只生成二叉树每个节点只问“是/否”问题对于连续特征通过寻找一个最佳分割点来实现。它的设计非常简洁高效并且天然地同时支持分类树和回归树。我们的案例将实现CART分类树因为它是Scikit-learn中DecisionTreeClassifier的默认算法也是理解更复杂模型的基础。3. 环境准备与数据说明我们将使用Python进行实现。无需复杂的深度学习框架只需要基础的科学计算库。3.1 环境配置# 建议使用虚拟环境 python -m venv dt_env source dt_env/bin/activate # Linux/Mac # dt_env\Scripts\activate # Windows # 安装依赖 pip install numpy pandas matplotlib scikit-learn3.2 案例数据银行贷款风险评估我们构造一个简单的数据集来模拟银行贷款审批场景。每个申请人有四个特征和一个审批结果标签。import pandas as pd import numpy as np # 构造数据集 data { 年龄: [青年, 青年, 青年, 青年, 青年, 中年, 中年, 中年, 中年, 中年, 老年, 老年, 老年, 老年, 老年], 有工作: [否, 否, 是, 是, 否, 否, 否, 是, 是, 是, 是, 是, 是, 否, 否], 有房子: [否, 否, 否, 是, 否, 否, 否, 是, 是, 是, 是, 是, 是, 否, 否], 信用评级: [一般, 好, 好, 一般, 一般, 一般, 好, 好, 非常好, 非常好, 非常好, 好, 好, 一般, 一般], 类别: [拒绝, 拒绝, 批准, 批准, 拒绝, 拒绝, 拒绝, 批准, 批准, 批准, 批准, 批准, 批准, 拒绝, 拒绝] # 标签 } df pd.DataFrame(data) print(数据集预览:) print(df) print(\n数据集形状:, df.shape)输出:数据集预览: 年龄 有工作 有房子 信用评级 类别 0 青年 否 否 一般 拒绝 1 青年 否 否 好 拒绝 2 青年 是 否 好 批准 3 青年 是 是 一般 批准 4 青年 否 否 一般 拒绝 5 中年 否 否 一般 拒绝 6 中年 否 否 好 拒绝 7 中年 是 是 好 批准 8 中年 是 是 非常好 批准 9 中年 是 是 非常好 批准 10 老年 是 是 非常好 批准 11 老年 是 是 好 批准 12 老年 是 是 好 批准 13 老年 否 否 一般 拒绝 14 老年 否 否 一般 拒绝 数据集形状: (15, 5)数据解读共15条样本4个特征均为类别型1个目标标签批准或拒绝。我们的任务就是让决策树从这些数据中学习审批规则。4. 手撕CART决策树核心流程拆解我们将分步实现一个简化但完整的CART分类树。整个过程是递归的计算当前节点数据集的基尼系数。遍历所有特征及其可能的分裂点对于类别特征是子集划分对于连续特征是阈值计算分裂后的加权基尼系数。选择使基尼系数下降最多的特征和分裂点作为最佳分裂。根据最佳分裂点将数据集划分为左右两个子集。对左右子集递归执行步骤1-4直到满足停止条件。创建叶子节点其类别为当前节点数据中样本数最多的类。4.1 第一步计算基尼系数def calculate_gini(y): 计算标签集合y的基尼系数。 参数: y: 类标签数组如 [批准, 拒绝, 批准, ...] 返回: gini: 基尼系数值 if len(y) 0: return 0 # 统计每个类别的数量 unique_classes, counts np.unique(y, return_countsTrue) probabilities counts / len(y) gini 1 - np.sum(probabilities ** 2) return gini # 测试 test_labels np.array([批准, 批准, 拒绝, 批准]) print(f标签集 {test_labels} 的基尼系数为: {calculate_gini(test_labels):.4f}) # 输出: 0.3750 (因为3个批准1个拒绝不纯度较高) pure_labels np.array([批准, 批准, 批准]) print(f纯标签集 {pure_labels} 的基尼系数为: {calculate_gini(pure_labels):.4f}) # 输出: 0.00004.2 第二步寻找最佳分裂特征与分割点这是决策树学习的核心。对于类别特征最佳分割点是将其所有可能取值划分成两个子集。我们需要遍历所有可能的二分组合。def find_best_split(X, y): 在数据集(X, y)中寻找最佳的分裂特征和分割点。 参数: X: 特征DataFrame y: 标签数组 返回: best_feature: 最佳特征名 best_split_value: 最佳分割值对于类别特征是左子集的取值列表 best_gini: 分裂后的最小加权基尼系数 best_gini float(inf) best_feature None best_split_value None current_gini calculate_gini(y) # 遍历每个特征 for feature in X.columns: unique_values X[feature].unique() # 对于类别特征遍历所有可能的二分组合排除空集和全集 # 技巧对于k个值只需考虑2^(k-1)-1种划分这里用遍历子集简化演示 # 生成所有非空真子集作为左分支 from itertools import chain, combinations value_list list(unique_values) # 生成所有可能的左子集候选跳过空集和全集 for i in range(1, len(value_list)): # 左子集大小从1到k-1 for left_subset in combinations(value_list, i): left_subset set(left_subset) # 分割数据 left_mask X[feature].isin(left_subset) 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 # 计算加权基尼系数 n_left, n_right len(y_left), len(y_right) n_total n_left n_right weighted_gini (n_left / n_total) * calculate_gini(y_left) \ (n_right / n_total) * calculate_gini(y_right) # 更新最佳分裂 if weighted_gini best_gini: best_gini weighted_gini best_feature feature # 存储左子集对应的值列表用于后续数据划分 best_split_value list(left_subset) # 计算基尼系数下降值信息增益的类似物 gini_decrease current_gini - best_gini if best_feature is not None else 0 return best_feature, best_split_value, best_gini, gini_decrease # 在我们构造的数据集上测试暂时只使用前两个特征以简化输出 X_sample df[[年龄, 有工作]] y_sample df[类别] best_feat, best_split, best_g, gini_dec find_best_split(X_sample, y_sample) print(f最佳分裂特征: {best_feat}) print(f最佳分裂值(左子集包含): {best_split}) print(f分裂后加权基尼系数: {best_g:.4f}) print(f基尼系数下降值: {gini_dec:.4f})输出可能类似:最佳分裂特征: 有工作 最佳分裂值(左子集包含): [是] 分裂后加权基尼系数: 0.2857 基尼系数下降值: 0.3429这意味着根据“有工作”这个特征将“有工作是”的样本分到左子集其余分到右子集能得到最大的纯度提升。5. 构建决策树递归与节点定义现在我们需要一个数据结构来保存树。每个节点要么是内部节点包含分裂规则要么是叶子节点包含预测类别。5.1 定义树节点类class TreeNode: 决策树节点类 def __init__(self, featureNone, split_valueNone, leftNone, rightNone, labelNone): 参数: feature: 用于分裂的特征名内部节点 split_value: 分裂值对于类别特征表示左子节点应满足的特征值列表 left: 左子节点 right: 右子节点 label: 叶子节点存储的预测类别 self.feature feature self.split_value split_value self.left left self.right right self.label label def is_leaf(self): 判断是否为叶子节点 return self.label is not None def __repr__(self): if self.is_leaf(): return fLeafNode(label{self.label}) else: return fInternalNode(feature{self.feature}, split_on{self.split_value})5.2 递归构建决策树def build_tree(X, y, max_depth3, min_samples_split2, current_depth0): 递归构建决策树。 参数: X: 特征DataFrame y: 标签数组 max_depth: 树的最大深度 min_samples_split: 节点分裂所需的最小样本数 current_depth: 当前节点深度 返回: node: 构建好的树节点 # 停止条件1: 当前节点所有样本属于同一类 if len(np.unique(y)) 1: return TreeNode(labely.iloc[0]) # 创建叶子节点 # 停止条件2: 达到最大深度 if current_depth max_depth: majority_label y.mode()[0] # 取众数 return TreeNode(labelmajority_label) # 停止条件3: 样本数少于最小分裂阈值 if len(y) min_samples_split: majority_label y.mode()[0] return TreeNode(labelmajority_label) # 寻找最佳分裂 best_feature, best_split_value, best_gini, gini_decrease find_best_split(X, y) # 停止条件4: 无法找到有效的分裂基尼系数下降为0或特征用完 if best_feature is None or gini_decrease 0: majority_label y.mode()[0] return TreeNode(labelmajority_label) # 根据最佳分裂划分数据 left_mask X[best_feature].isin(best_split_value) 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 build_tree(X_left, y_left, max_depth, min_samples_split, current_depth 1) right_subtree build_tree(X_right, y_right, max_depth, min_samples_split, current_depth 1) # 创建并返回内部节点 return TreeNode(featurebest_feature, split_valuebest_split_value, leftleft_subtree, rightright_subtree) # 构建决策树 X df[[年龄, 有工作, 有房子, 信用评级]] y df[类别] decision_tree build_tree(X, y, max_depth3) print(决策树根节点:, decision_tree)5.3 预测函数def predict_one_sample(tree_node, x): 对单个样本x进行预测。 参数: tree_node: 当前树节点 x: 一个样本是一个Series索引为特征名 返回: 预测的类别标签 # 如果是叶子节点直接返回类别 if tree_node.is_leaf(): return tree_node.label # 否则根据分裂规则走向左子树或右子树 feature_value x[tree_node.feature] # 判断当前样本的特征值是否在分裂时定义的“左子集”中 if feature_value in tree_node.split_value: return predict_one_sample(tree_node.left, x) else: return predict_one_sample(tree_node.right, x) def predict(tree_node, X): 对数据集X进行预测 return [predict_one_sample(tree_node, row) for _, row in X.iterrows()] # 在训练集上测试预测 predictions predict(decision_tree, X) print(预测结果:, predictions) print(真实标签:, y.tolist()) print(准确率:, np.mean(np.array(predictions) y.values))6. 运行结果与树结构可视化让我们看看这棵树长什么样并用一个简单的文本形式打印出来。def print_tree(node, indent, feature_namesNone): 以文本形式打印决策树 if node.is_leaf(): print(indent 预测类别:, node.label) return # 打印分裂规则 if feature_names is None: feat_name node.feature else: feat_name feature_names.get(node.feature, node.feature) left_desc f{feat_name} 在 {node.split_value} 中? print(indent left_desc - 是:) print_tree(node.left, indent , feature_names) print(indent left_desc - 否:) print_tree(node.right, indent , feature_names) print(*50) print(构建的决策树结构:) print(*50) print_tree(decision_tree) print(*50)输出示例: 构建的决策树结构: 有房子 在 [是] 中? - 是: 预测类别: 批准 有房子 在 [是] 中? - 否: 有工作 在 [是] 中? - 是: 预测类别: 批准 有工作 在 [是] 中? - 否: 信用评级 在 [好, 非常好] 中? - 是: 预测类别: 批准 信用评级 在 [好, 非常好] 中? - 否: 预测类别: 拒绝 树规则解读首先判断“有房子”是否为“是”。如果是直接批准贷款叶子节点。如果没有房子则判断“有工作”是否为“是”。如果有工作批准。如果既没房子也没工作则看“信用评级”。如果信用是“好”或“非常好”批准否则信用“一般”拒绝。这棵树非常符合我们的业务直觉房产和工作是强担保信用是最后的防线。7. 关键问题过拟合与剪枝我们构建的树在训练集上准确率可能是100%但它可能过拟合了——它过于完美地记住了训练数据中的噪声导致在新数据上表现很差。如何判断和解决过拟合7.1 识别过拟合训练集准确率远高于验证集/测试集准确率。树的结构非常深叶子节点很多甚至有些节点只包含一两个样本。7.2 预防与解决剪枝Pruning剪枝是决策树对抗过拟合的核心技术。主要有两种预剪枝在构建过程中提前停止。我们代码中的max_depth和min_samples_split就是预剪枝参数。max_depth: 限制树的最大深度。min_samples_split: 节点至少需要这么多样本才考虑分裂。min_samples_leaf: 叶子节点至少需要的样本数。优点简单高效计算开销小。缺点可能“剪过头”欠拟合。后剪枝先构建一棵完整的树然后自底向上考察非叶子节点。如果将其替换为叶子节点用该节点下样本的众数类别作为预测能提升验证集性能则进行剪枝。优点更精确通常能得到泛化能力更强的树。缺点计算开销大需要额外的验证集。后剪枝简化示例思路def prune_tree(node, X_val, y_val): 后剪枝的简化演示递归思路。 实际CART使用代价复杂度剪枝(CCP)更复杂。 if node.is_leaf(): return node # 先递归剪枝子树 if not node.left.is_leaf(): node.left prune_tree(node.left, X_val, y_val) if not node.right.is_leaf(): node.right prune_tree(node.right, X_val, y_val) # 计算当前节点作为子树时的验证集准确率 # ... (需要实现验证集在当前节点规则下的预测和准确率计算) # 计算当前节点如果变成叶子节点用训练数据众数的验证集准确率 # ... (需要计算并比较) # 如果变为叶子节点能提升验证集准确率则剪枝 # if 剪枝条件: # return TreeNode(labelmajority_label_of_node) # else: return node实践中我们通常使用Scikit-learn提供的ccp_alpha参数进行代价复杂度剪枝它是后剪枝的一种高效实现。8. 最佳实践与工程建议在实际项目中使用决策树或基于树的模型时请注意以下几点8.1 数据预处理处理缺失值决策树本身可以处理缺失值如C4.5算法但在Scikit-learn的实现中需要先填充如用中位数、众数或删除。编码类别特征对于无序类别特征如颜色必须使用独热编码One-Hot Encoding避免给类别赋予错误的顺序关系。对于有序类别如信用评级“一般”、“好”、“非常好”可以使用标签编码或映射为有序数字。连续特征离散化有时将连续特征分箱如将年龄分为青年、中年、老年能提升模型的可解释性和稳定性但可能会损失信息。8.2 参数调优使用Scikit-learn的DecisionTreeClassifier时关键参数如下from sklearn.tree import DecisionTreeClassifier from sklearn.model_selection import GridSearchCV # 定义参数网格 param_grid { criterion: [gini, entropy], # 分裂准则 max_depth: [3, 5, 10, None], # 树的最大深度 min_samples_split: [2, 5, 10], # 内部节点再划分所需最小样本数 min_samples_leaf: [1, 2, 4], # 叶子节点最少样本数 max_features: [None, sqrt, log2] # 寻找最佳分裂时考虑的特征数 } dt DecisionTreeClassifier(random_state42) grid_search GridSearchCV(dt, param_grid, cv5, scoringaccuracy) # grid_search.fit(X_train, y_train) # print(grid_search.best_params_)max_depth和min_samples_leaf是控制过拟合最有效的参数。random_state固定随机种子确保结果可复现决策树在特征排序相同时可能涉及随机选择。8.3 模型解释与可视化决策树最大的优势是可解释性。一定要利用起来sklearn.tree.plot_tree: 直接可视化树结构。from sklearn.tree import plot_tree import matplotlib.pyplot as plt plt.figure(figsize(20,10)) plot_tree(dt_model, filledTrue, feature_namesX.columns, class_names[拒绝, 批准]) plt.show()特征重要性通过model.feature_importances_获取。重要性是基于该特征在所有分裂中被使用的次数以及它带来的纯度提升加权计算的。这是做特征筛选和业务解释的强力工具。8.4 决策树的局限性不稳定性训练数据的微小变化可能导致生成完全不同的树。解决方案使用集成方法如随机森林。容易过拟合特别是当树很深时。必须使用剪枝和交叉验证。对线性关系不敏感它通过平行于坐标轴的直线进行划分难以捕捉特征间的复杂线性或非线性关系如y x1 x2。外推能力差对于超出训练数据范围的预测不可靠。因此单棵决策树很少作为最终模型投入使用。它更多是作为“基础学习器”用于构建随机森林、梯度提升树如XGBoost, LightGBM等强大的集成模型。9. 总结与进阶方向通过本文我们从“银行贷款审批”这个具体场景出发不仅理解了ID3、C4.5、CART算法的原理区别更重要的是亲手实现了一个可运行的CART决策树。你掌握了从计算基尼系数、寻找最佳分裂、递归建树到预测的完整流程。下一步你可以这样深化学习升级到回归树将分裂准则从基尼系数改为均方误差MSE实现CART回归树用于预测房价、销量等连续值问题。实现完整后剪枝深入研究代价复杂度剪枝CCP算法并添加到你的代码中。探索集成学习理解Bagging和Boosting。尝试用你的决策树作为基学习器实现一个简单的随机森林通过自助采样和特征随机选择。使用工业级库深入研究Scikit-learn中DecisionTreeClassifier和RandomForestClassifier的源码对比与你实现的差异学习其工程优化如使用数组存储、高效搜索分割点等。应用于真实项目在Kaggle或天池找一个结构化数据分类项目如泰坦尼克号生存预测完整走一遍数据清洗、特征工程、决策树/随机森林建模、调参和评估的流程。决策树是机器学习中“大道至简”的典范。它用清晰的逻辑规则将复杂的决策过程透明化。虽然单棵树能力有限但正是这种简单和可解释性使其成为构建现代机器学习模型不可或缺的基石。理解它是你打开集成学习与可解释AI大门的第一把钥匙。建议收藏本文在后续学习随机森林、GBDT、XGBoost时不时回看这棵“树”的根基。
返回列表