从零构建决策引擎: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 定义树节点与停止条件
我们需要一个数据结构来代表树。这里使用嵌套字典来实现,它足够直观且易于理解。
递归过程需要明确的停止条件(递归出口):
- 当前节点所有样本属于同一类:直接将该节点标记为该类别的叶节点。
- 没有剩余特征可用于划分:将节点标记为数据集中样本数最多的类别的叶节点。
- 特征的信息增益低于某个阈值(可选,用于防止过拟合):同样标记为多数类叶节点。
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算法的价值,远不止于得到一棵能分类的树。它强迫我们深入每一个计算步骤,理解信息论如何指导机器学习模型做出选择。当你下次使用sklearn的DecisionTreeClassifier时,设置criterion='entropy',你就能清晰地知道背后发生了什么。这种从底层构建的认知,是灵活调优模型、解决复杂问题的基础。在实际项目中,你可能不会从头写起,但这次经历让你拥有了拆解任何“黑箱”模型的信心和能力。
&spm=1001.2101.3001.5002&articleId=153676961&d=1&t=3&u=3374ddf56017482e93dbdf4ce82e41f0)

被折叠的 条评论
为什么被折叠?



