决策树实战:用Python手写ID3算法解决贷款审批问题(附完整代码)

从零构建决策引擎:Python实战ID3算法剖析贷款审批逻辑

你是否曾好奇,银行或金融机构的自动化贷款审批系统背后,究竟是怎样一套逻辑在运作?面对一份包含年龄、收入、信用记录等信息的申请表,机器是如何像一位经验丰富的信贷员一样,做出“通过”或“拒绝”的判断?今天,我们不依赖任何现成的机器学习库,而是亲手用Python从零开始,实现经典的ID3决策树算法,并将其应用于一个模拟的贷款审批数据集。这不仅仅是一次代码练习,更是一次深入理解算法如何“思考”、如何将数据转化为可解释的决策规则的思想之旅。对于希望掌握算法底层原理,并能在实际业务场景中灵活应用的开发者而言,这次实战将是一次宝贵的经验。

1. 问题定义与数据准备:构建我们的“信贷沙盘”

在开始编码之前,我们必须清晰地定义问题,并准备好“原材料”——数据。我们的目标是构建一个模型,它能根据申请人的若干特征,预测其贷款申请是否应被批准。

1.1 理解业务场景与数据特征

我们假设有一个简化的贷款审批数据集,包含以下特征:

  • 年龄:离散值,分为青年中年老年
  • 收入:离散值,分为
  • 信用表现:离散值,分为优秀一般。这是评估申请人还款意愿和历史的关键指标。
  • 是否有房产:二值特征,。这通常被视为重要的风险缓释因素。
  • 目标变量审批结果,即我们最终要预测的标签,取值为通过拒绝

注意:ID3算法本身只能处理离散型特征。如果你的数据中包含连续值(如具体年薪),必须先进行离散化处理(例如,划分为“高”、“中”、“低”三个区间),这是应用ID3算法前必不可少的数据预处理步骤。

1.2 用Python创建与探索数据集

我们将数据直接硬编码为一个列表的列表,并利用pandas库进行直观展示和后续处理。虽然本例数据量小,但这种方法清晰地展示了数据结构。

import pandas as pd

# 定义数据集
data = [
    ['青年', '男', '高', '有', '优秀', '通过'],
    ['青年', '男', '中', '无', '一般', '通过'],
    ['青年', '男', '低', '无', '差', '拒绝'],
    ['青年', '女', '中', '有', '优秀', '通过'],
    ['青年', '女', '低', '无', '一般', '通过'],
    ['中年', '男', '高', '有', '一般', '拒绝'],
    ['中年', '男', '中', '无', '差', '拒绝'],
    ['中年', '女', '高', '有', '优秀', '通过'],
    ['中年', '女', '中', '有', '优秀', '通过'],
    ['中年', '女', '低', '无', '一般', '拒绝'],
    ['老年', '男', '高', '有', '优秀', '通过'],
    ['老年', '男', '低', '有', '差', '通过'],
    ['老年', '男', '低', '无', '差', '拒绝'],
    ['老年', '女', '中', '无', '一般', '通过'],
    ['老年', '女', '高', '有', '差', '拒绝'],
]

# 定义列名
columns = ['年龄', '性别', '收入', '房产', '信用表现', '审批结果']

# 创建DataFrame
df = pd.DataFrame(data, columns=columns)
print("贷款审批数据集预览:")
print(df)
print(f"\n数据集形状:{df.shape}")
print("\n各特征取值分布:")
for col in df.columns:
    print(f"{col}: {df[col].unique().tolist()}")

运行这段代码,你将看到一个清晰的表格。接下来,我们的核心任务就是教会计算机如何从这些数据中学习决策规则。

2. ID3算法核心:信息论驱动的特征选择

ID3算法的灵魂在于使用信息增益来选择最佳分裂特征。要理解信息增益,我们需要先掌握两个核心概念:信息熵和条件熵。

2.1 计算信息熵:度量数据的不纯度

信息熵描述了数据集“混乱”或“不纯”的程度。如果数据集中所有样本都属于同一类别,则熵为0,表示非常“纯”。如果类别均匀分布,熵达到最大值。

其计算公式为: H(D) = - Σ (p_i * log2(p_i)), 其中 p_i 是第 i 类样本在数据集 D 中所占的比例。

让我们用Python实现它:

import math
from collections import Counter

def calc_entropy(data_labels):
    """
    计算给定标签列表的信息熵。
    :param data_labels: 列表,包含数据集中所有样本的标签(如['通过', '拒绝', '通过'...])
    :return: 熵值 (float)
    """
    total_count = len(data_labels)
    if total_count == 0:
        return 0
    label_counts = Counter(data_labels) # 统计每个标签出现的次数
    entropy = 0.0
    for count in label_counts.values():
        p = count / total_count # 计算概率
        if p > 0: # 避免log2(0)的情况
            entropy -= p * math.log2(p)
    return entropy

# 测试:计算整个数据集的熵
all_labels = df['审批结果'].tolist()
total_entropy = calc_entropy(all_labels)
print(f"整个数据集('审批结果')的信息熵为: {total_entropy:.4f}")

2.2 计算条件熵与信息增益:寻找最佳提问方式

条件熵 H(D|A) 表示在已知特征 A 的条件下,数据集 D 的不确定性。信息增益 Gain(D, A) 则是原始熵与条件熵的差值,它衡量了特征 A 能为分类带来多少“信息”。

Gain(D, A) = H(D) - H(D|A)

H(D|A) = Σ (|D_v| / |D|) * H(D_v),其中 D_v 是特征 A 取值为 v 的子集。

我们的策略是:遍历所有特征,计算每个特征带来的信息增益,选择增益最大的那个特征作为当前节点的分裂依据。

def calc_info_gain(df, feature_name, target_name='审批结果'):
    """
    计算指定特征的信息增益。
    :param df: pandas DataFrame, 包含特征和目标列
    :param feature_name: 要计算的特征列名
    :param target_name: 目标标签列名
    :return: 信息增益值 (float)
    """
    # 计算整个数据集的熵
    total_entropy = calc_entropy(df[target_name].tolist())

    # 计算条件熵
    conditional_entropy = 0.0
    feature_values = df[feature_name].unique()

    for value in feature_values:
        sub_df = df[df[feature_name] == value] # 获取特征取值为value的子集
        sub_entropy = calc_entropy(sub_df[target_name].tolist())
        weight = len(sub_df) / len(df) # 子集的权重
        conditional_entropy += weight * sub_entropy

    # 信息增益 = 总熵 - 条件熵
    info_gain = total_entropy - conditional_entropy
    return info_gain

# 测试:计算“信用表现”特征的信息增益
gain_credit = calc_info_gain(df, '信用表现')
print(f"特征 '信用表现' 的信息增益为: {gain_credit:.4f}")

# 比较所有特征
print("\n所有特征的信息增益对比:")
features = ['年龄', '性别', '收入', '房产', '信用表现']
for feat in features:
    gain = calc_info_gain(df, feat)
    print(f"  {feat}: {gain:.4f}")

运行后,你会发现“信用表现”的信息增益远高于其他特征。这非常符合直觉——在信贷审批中,历史信用记录往往是最强有力的预测指标。ID3算法通过数学计算,量化并证实了这一业务常识。

3. 递归构建决策树:将算法逻辑转化为树形结构

有了选择最佳特征的能力,我们就可以递归地构建整棵决策树了。树中的每个节点代表一个特征测试,每个分支代表该特征的一个取值,而叶节点则代表最终的分类决策。

3.1 定义树节点与停止条件

我们需要一个数据结构来代表树。这里使用嵌套字典来实现,它足够直观且易于理解。

递归过程需要明确的停止条件(递归出口):

  1. 当前节点所有样本属于同一类:直接将该节点标记为该类别的叶节点。
  2. 没有剩余特征可用于划分:将节点标记为数据集中样本数最多的类别的叶节点。
  3. 特征的信息增益低于某个阈值(可选,用于防止过拟合):同样标记为多数类叶节点。
def get_majority_label(labels):
    """获取标签列表中出现次数最多的标签。"""
    from collections import Counter
    counter = Counter(labels)
    return counter.most_common(1)[0][0]

def build_id3_tree(df, features, target_name='审批结果', depth=0, max_depth=None):
    """
    递归构建ID3决策树。
    :param df: 当前节点的数据集
    :param features: 当前可用的特征列表
    :param target_name: 目标列名
    :param depth: 当前递归深度,用于控制树深度
    :param max_depth: 树的最大深度(预剪枝参数)
    :return: 代表决策树的字典
    """
    labels = df[target_name].tolist()

    # 停止条件1: 所有样本属于同一类
    if len(set(labels)) == 1:
        return labels[0] # 返回类别标签,作为叶节点

    # 停止条件2: 没有特征可用,或达到最大深度
    if len(features) == 0 or (max_depth is not None and depth >= max_depth):
        return get_majority_label(labels)

    # 选择最佳分裂特征
    best_feature = None
    best_gain = -1
    for feat in features:
        gain = calc_info_gain(df, feat, target_name)
        if gain > best_gain:
            best_gain = gain
            best_feature = feat

    # 停止条件3: 最佳特征的信息增益为0(或小于阈值epsilon)
    if best_gain < 1e-10: # 使用一个极小值作为阈值
        return get_majority_label(labels)

    # 开始构建树节点
    tree = {best_feature: {}}

    # 获取最佳特征的所有取值
    remaining_features = [f for f in features if f != best_feature]
    for value in df[best_feature].unique():
        # 创建分支,获取该分支下的子数据集
        sub_df = df[df[best_feature] == value]
        if len(sub_df) == 0:
            # 如果某个分支没有样本,则将其标记为父节点中的多数类
            tree[best_feature][value] = get_majority_label(labels)
        else:
            # 递归构建子树
            subtree = build_id3_tree(sub_df, remaining_features, target_name, depth+1, max_depth)
            tree[best_feature][value] = subtree

    return tree

# 构建完整的决策树
feature_list = ['年龄', '性别', '收入', '房产', '信用表现']
decision_tree = build_id3_tree(df, feature_list)
print("构建的决策树结构(字典形式):")
import pprint
pprint.pprint(decision_tree, indent=2)

3.2 解读生成的决策树

生成的嵌套字典可能看起来有些复杂,但它精确地刻画了决策路径。例如,树可能首先根据信用表现分裂:

  • 如果信用表现优秀,直接预测通过
  • 如果信用表现一般,则进一步查看年龄特征。
  • 如果信用表现,则进一步查看房产特征。

以此类推。这棵树就是我们的贷款审批模型,它封装了从数据中学到的所有规则。

4. 模型应用与评估:让决策树“工作”起来

构建好模型后,我们需要用它进行预测,并评估其性能。

4.1 实现预测函数

预测过程就是根据待预测样本的特征值,从树根开始,沿着对应的分支向下遍历,直到到达叶节点。

def predict(tree, sample):
    """
    使用构建好的决策树对单个样本进行预测。
    :param tree: 决策树字典
    :param sample: 字典,格式为 {'特征名': '特征值', ...}
    :return: 预测的类别标签
    """
    # 如果当前节点已经是叶节点(字符串标签)
    if not isinstance(tree, dict):
        return tree

    # 获取树的第一个(也是唯一一个)键,即当前节点的分裂特征
    feature_name = list(tree.keys())[0]
    feature_value = sample.get(feature_name)

    # 如果样本中该特征的值不在树的分支中,无法预测(可返回默认值或报错)
    if feature_value not in tree[feature_name]:
        # 这里简单返回一个默认值,实际应用中可能需要更复杂的处理
        return '未知'

    # 进入对应的分支子树
    sub_tree = tree[feature_name][feature_value]
    return predict(sub_tree, sample)

# 测试预测
test_sample_1 = {'年龄': '青年', '性别': '男', '收入': '中', '房产': '无', '信用表现': '一般'}
test_sample_2 = {'年龄': '中年', '性别': '女', '收入': '高', '房产': '有', '信用表现': '差'}

pred_1 = predict(decision_tree, test_sample_1)
pred_2 = predict(decision_tree, test_sample_2)

print(f"\n样本1预测结果: {pred_1}")
print(f"样本2预测结果: {pred_2}")

4.2 可视化决策树(可选但强烈推荐)

文字描述的树结构不够直观。我们可以使用 graphviz 库来生成优美的树形图。这能极大地帮助理解模型的决策逻辑。

首先,确保安装了graphviz库和软件(pip install graphviz,并需要从官网安装Graphviz软件)。

from graphviz import Digraph

def plot_tree(tree, dot=None, parent_name=None, edge_label=None):
    """
    递归地将决策树字典转换为graphviz的Dot对象。
    """
    if dot is None:
        dot = Digraph(comment='ID3 Decision Tree', format='png')
        dot.attr('node', shape='box', style='rounded')

    # 当前节点ID
    if isinstance(tree, dict):
        node_name = list(tree.keys())[0]
        current_id = str(id(tree))
    else:
        node_name = str(tree) + ' (Leaf)'
        current_id = str(id(tree))
        # 叶节点用不同的形状和颜色
        dot.node(current_id, label=node_name, shape='ellipse', fillcolor='lightblue', style='filled')

    if isinstance(tree, dict):
        dot.node(current_id, label=node_name)
        if parent_name is not None:
            dot.edge(parent_name, current_id, label=edge_label)

        feature_dict = tree[node_name]
        for value, subtree in feature_dict.items():
            child_id = plot_tree(subtree, dot, current_id, str(value))
            # 边已在递归中创建
    else:
        if parent_name is not None:
            dot.edge(parent_name, current_id, label=edge_label)

    return current_id

# 生成并保存可视化图形
dot = plot_tree(decision_tree)
dot.render('loan_decision_tree_id3', view=True) # 会生成一个名为'loan_decision_tree_id3.png'的图片文件
print("决策树可视化已生成,保存为 'loan_decision_tree_id3.png'。")

生成的图片将清晰地展示出完整的决策路径,哪个特征在什么情况下被使用一目了然。这对于向非技术背景的业务方解释模型决策过程至关重要。

4.3 模型评估与思考

由于我们的数据集很小,通常采用留出法交叉验证来评估。这里为了演示,我们简单地在训练集上计算准确率。

def evaluate(tree, df, target_name='审批结果'):
    """在给定数据集上评估决策树的准确率。"""
    correct = 0
    total = len(df)
    for idx, row in df.iterrows():
        # 将一行数据转换为字典,排除目标列
        sample = {col: row[col] for col in df.columns if col != target_name}
        true_label = row[target_name]
        pred_label = predict(tree, sample)
        if pred_label == true_label:
            correct += 1
    accuracy = correct / total
    return accuracy

train_accuracy = evaluate(decision_tree, df)
print(f"\n模型在训练集上的准确率: {train_accuracy:.2%}")

在训练集上达到100%准确率是可能的,但这可能意味着过拟合——模型完美记忆了训练数据,但可能对未知数据泛化能力差。这正是ID3算法的一个局限性:它倾向于生成深度大、分支多的树,容易过拟合。

5. 超越ID3:实战中的考量与优化

手写ID3算法让我们透彻理解了其原理,但在真实项目中,我们还需要考虑更多。

5.1 ID3的局限性及应对策略

局限性问题描述实战应对策略
无法处理连续特征ID3要求所有特征是离散的。对连续特征进行离散化(分箱),如等宽分箱、等频分箱或基于信息增益的分箱。
对缺失值敏感算法没有处理缺失数据的机制。数据预处理时进行缺失值处理:删除、填充(众数、中位数等),或使用像C4.5算法那样的概率分布法。
倾向于选择取值多的特征信息增益偏向于具有大量不同取值的特征(如“ID”号),这类特征通常没有泛化意义。使用信息增益率(C4.5算法)或基尼不纯度(CART算法)作为分裂标准。
容易过拟合可能生成过于复杂的树,对训练数据噪声拟合过度。实施剪枝:预剪枝(限制树深度、最小样本数等)或后剪枝(用验证集对已生成的树进行剪枝)。
只能用于分类无法直接处理回归问题(预测连续值)。对于回归任务,需使用CART(分类与回归树)或其他回归树算法。

5.2 引入信息增益率(C4.5算法的核心)

为了克服信息增益的偏好,我们可以实现信息增益率。它在信息增益的基础上,除以特征本身的“固有值”(Intrinsic Value),即特征熵 IV(A),用以惩罚取值多的特征。

GainRatio(D, A) = Gain(D, A) / IV(A) IV(A) = - Σ (|D_v|/|D| * log2(|D_v|/|D|))

def calc_info_gain_ratio(df, feature_name, target_name='审批结果'):
    """计算指定特征的信息增益率。"""
    info_gain = calc_info_gain(df, feature_name, target_name)

    # 计算特征A的固有值(IV)
    total_count = len(df)
    iv = 0.0
    feature_values = df[feature_name].unique()
    for value in feature_values:
        sub_count = len(df[df[feature_name] == value])
        p = sub_count / total_count
        if p > 0:
            iv -= p * math.log2(p)

    # 避免除以0,如果IV为0(即特征只有一个取值),则信息增益率定义为0或一个很大的数,通常不选择该特征
    if iv == 0:
        return 0
    gain_ratio = info_gain / iv
    return gain_ratio

# 比较信息增益和信息增益率
print("特征选择标准对比:")
print(f"{'特征':<8} {'信息增益':<12} {'信息增益率':<12}")
for feat in features:
    gain = calc_info_gain(df, feat)
    ratio = calc_info_gain_ratio(df, feat)
    print(f"{feat:<8} {gain:<12.4f} {ratio:<12.4f}")

你会发现,对于取值数量不同的特征,信息增益率的排序可能与信息增益不同,它能更好地平衡特征的选择。

5.3 一个简单的预剪枝示例

我们可以在 build_id3_tree 函数中轻松加入预剪枝逻辑。例如,限制树的最大深度:

# 使用之前定义的build_id3_tree函数,并传入max_depth参数
pruned_tree = build_id3_tree(df, feature_list, max_depth=2)
print("\n--- 经过预剪枝(max_depth=2)的树结构 ---")
pprint.pprint(pruned_tree, indent=2)
pruned_accuracy = evaluate(pruned_tree, df)
print(f"剪枝后模型在训练集上的准确率: {pruned_accuracy:.2%}")

限制深度后,树的结构会更简单,虽然训练集准确率可能略有下降,但泛化到新数据的能力通常会更强。

手动实现ID3算法的价值,远不止于得到一棵能分类的树。它强迫我们深入每一个计算步骤,理解信息论如何指导机器学习模型做出选择。当你下次使用sklearnDecisionTreeClassifier时,设置criterion='entropy',你就能清晰地知道背后发生了什么。这种从底层构建的认知,是灵活调优模型、解决复杂问题的基础。在实际项目中,你可能不会从头写起,但这次经历让你拥有了拆解任何“黑箱”模型的信心和能力。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值