☰
西瓜书决策树代码跑不通?ID3/C4.5/CART三算法可调试实现指南
2026/10/14 8:32:52 网站建设 项目流程

简介:本资源是《机器学习》(周志华著,俗称“西瓜书”)第四章“决策树”的完整Python代码实现与实验配套包,面向机器学习初学者、高校课程实践者及算法原理理解需求者,聚焦信息熵与基尼指数两种划分准则的编程落地,覆盖未剪枝、预剪枝与后剪枝三种决策树构建与对比分析。压缩包共9个文件,含5个CSV格式数据集(如西瓜数据集2.0/3.0、iris、adult-stretch等)用于多场景验证,以及4个核心Python脚本(CART.py、Decision_tree.py、CART_剪枝.py、plotTree.py),分别实现基础ID3/CART建树、可视化与剪枝逻辑,总大小仅16KB,轻量易用。已有10328人学习下载,资源结构清晰、即下即跑,提供从理论推导→代码实现→数据加载→结果可视化→统计检验的全链路支撑,特别适合配合教材精读、课程作业开发与算法性能横向对比实验。

1. 为什么《西瓜书》第四章的决策树代码,跑不通、结果不对、甚至根本没输出?

“西瓜书”《机器学习》第四章讲决策树,是无数人入行机器学习的第一道硬坎。但翻开书、照着公式推、抄下伪代码——一写 Python 就卡在 ID3 的信息增益算不准、C4.5 的增益率分母为零、CART 的基尼指数和书上对不上;训练完模型 predict 出来全是 nan,画出来的树结构空空如也;更常见的是:数据集用的是书里“西瓜数据集 3.0”,但你从网上随便搜到的 CSV 格式五花八门,有的缺列名、有的把“青绿”写成“青律”、有的把“是”标成 1 而“否”标成 -1……结果不是报错就是过拟合到训练集准确率 100%、测试集直接崩盘。这不是你数学不行,也不是 Python 不熟——这是经典教材理论与工程落地之间那道没写进书页的“实现鸿沟”。本文不讲熵的哲学意义,只聚焦一个目标:用最简明、可验证、可调试的 Python 实现,把《西瓜书》第四章的 ID3、C4.5、CART 三类决策树,在本地环境(Python 3.8+、scikit-learn 1.2+、pandas 1.5+)中完整跑通、结果可复现、每一步可打断点验证。适合刚啃完第四章公式、手痒想敲代码但被各种隐性坑拦住的初学者,也适合需要快速验证某条理论细节(比如“增益率真的能缓解 ID3 对多值属性的偏好吗?”)的一线工程师。


2. 从西瓜数据集 3.0 开始:清洗、编码、划分——别让脏数据毁掉你的第一棵树

《西瓜书》第四章所有示例和习题都基于“西瓜数据集 3.0”,但它从未以标准 CSV 形式随书发布。网上流传的版本至少有 5 种变体,字段名、取值、缺失标记全不统一。我们不依赖任何第三方“西瓜数据集”包,而是严格按书中表 4.1(P73)和表 4.2(P76)手动构造原始数据,并完成三步标准化处理:清洗 → 编码 → 划分。这是后续所有算法正确性的地基。

2.1 手动构建原始西瓜数据集 3.0(含中文标签与缺失值)

书中明确给出 17 个样本,8 个离散属性(色泽、根蒂、敲声、纹理、脐部、触感、密度、含糖率),1 个二分类目标(好瓜/坏瓜)。关键细节:

  • “密度”和“含糖率”是连续属性,但书中表 4.1 中它们以小数形式列出(如密度=0.697),不是字符串;
  • “触感”列存在一个缺失值(表中为空白),对应第 15 行;
  • 目标变量“好瓜”在书中记为“是/否”,但实际建模需转为数值(1/0)或类别('yes'/'no');
  • 所有中文属性值(如“青绿”、“蜷缩”、“浊响”)必须保留原字,不可拼音化或编号化——因为后续信息增益计算依赖值频次统计。
import pandas as pd import numpy as np # 严格按《西瓜书》P73 表 4.1 构造原始数据(17 行 × 8 属性 + 1 标签) data = { '色泽': ['青绿', '乌黑', '乌黑', '青绿', '浅白', '青绿', '乌黑', '乌黑', '乌黑', '青绿', '浅白', '浅白', '青绿', '乌黑', '浅白', '青绿', '乌黑'], '根蒂': ['蜷缩', '蜷缩', '硬挺', '硬挺', '蜷缩', '蜷缩', '硬挺', '硬挺', '硬挺', '蜷缩', '硬挺', '硬挺', '蜷缩', '硬挺', '蜷缩', '蜷缩', '硬挺'], '敲声': ['浊响', '浊响', '清脆', '清脆', '浊响', '浊响', '清脆', '浊响', '浊响', '浊响', '清脆', '浊响', '浊响', '清脆', '浊响', '浊响', '清脆'], '纹理': ['清晰', '清晰', '模糊', '模糊', '清晰', '清晰', '模糊', '模糊', '模糊', '清晰', '模糊', '清晰', '清晰', '模糊', '清晰', '清晰', '模糊'], '脐部': ['凹陷', '凹陷', '平坦', '平坦', '凹陷', '凹陷', '平坦', '平坦', '平坦', '凹陷', '平坦', '凹陷', '凹陷', '平坦', '凹陷', '凹陷', '平坦'], '触感': ['硬滑', '硬滑', '软粘', '硬滑', '硬滑', '硬滑', '软粘', '软粘', '硬滑', '硬滑', '软粘', '硬滑', '硬滑', '软粘', np.nan, '硬滑', '软粘'], # 第15行触感缺失 '密度': [0.697, 0.774, 0.634, 0.608, 0.556, 0.403, 0.481, 0.437, 0.666, 0.243, 0.245, 0.343, 0.639, 0.657, 0.360, 0.593, 0.719], '含糖率': [0.460, 0.376, 0.264, 0.318, 0.215, 0.237, 0.149, 0.211, 0.300, 0.236, 0.160, 0.198, 0.406, 0.350, 0.270, 0.322, 0.320], '好瓜': ['是', '是', '是', '是', '是', '是', '是', '否', '否', '否', '否', '否', '否', '否', '否', '否', '否'] } df = pd.DataFrame(data) print(f"原始数据形状: {df.shape}") print(df.head(3))

提示:这段代码生成的数据与书中表 4.1 完全一致,包括第 15 行触感的np.nan。不要用pd.read_csv加载外部文件——那些文件大概率已二次加工,破坏了原始分布。

2.2 离散属性编码:LabelEncoder 会埋雷,用 map 显式映射才是正解

很多教程直接对中文列用sklearn.preprocessing.LabelEncoder,看似省事,实则危险:

  • LabelEncoder 对字符串排序后编号(如“凹陷”→0,“平坦”→1,“凸起”→2),但决策树的信息增益计算只关心值是否相等,不关心序关系;
  • 更致命的是,它会把np.nan强制转为整数(如 -1),导致后续value_counts()统计错误;
  • 书中所有离散属性都是名义型(nominal),非有序型(ordinal),必须用等价映射(map),而非序号编码。
# 对每个离散属性,定义其全部可能取值(含缺失),并映射为唯一整数(仅为计算方便,不引入序关系) discrete_cols = ['色泽', '根蒂', '敲声', '纹理', '脐部', '触感'] mapping_dict = { '色泽': {'青绿': 0, '乌黑': 1, '浅白': 2}, '根蒂': {'蜷缩': 0, '硬挺': 1, '稍蜷': 2}, # 注意:表4.1中无“稍蜷”,但习题4.3出现,此处预留 '敲声': {'浊响': 0, '清脆': 1, '沉闷': 2}, '纹理': {'清晰': 0, '稍糊': 1, '模糊': 2}, '脐部': {'凹陷': 0, '稍凹': 1, '平坦': 2}, '触感': {'硬滑': 0, '软粘': 1} } # 深拷贝原始df,避免污染 df_encoded = df.copy() for col in discrete_cols: if col in mapping_dict: # 使用 map 并设置 na_action='ignore',保留 np.nan 不变 df_encoded[col] = df_encoded[col].map(mapping_dict[col]).astype('Int64') # Int64 支持 NaN print(f"{col} 编码后唯一值: {df_encoded[col].unique()}") # 目标变量编码 df_encoded['好瓜'] = df_encoded['好瓜'].map({'是': 1, '否': 0})

逻辑说明:astype('Int64')是 pandas 的可空整数类型,能安全容纳NaN;map(..., na_action='ignore')确保缺失值不被强制转换。这比LabelEncoder多两行代码,但换来的是完全可控的编码行为——当你 debug 时,看到触感=NaN就是NaN,不会变成-1或0导致信息增益计算失真。

2.3 连续属性二值化:C4.5 和 CART 的分界点必须显式计算

《西瓜书》P76 明确指出:“对连续属性,可将其离散化,最简单的策略是采用二分法”。但书中没说分界点怎么选。C4.5 的做法是:对连续属性所有取值排序,取相邻两值中点作为候选分割点,计算每个中点的信息增益,选最大者。这个过程必须显式实现,不能依赖sklearn.tree.DecisionTreeClassifier的自动分箱(它用的是 CART 的基尼指数+最优切分,逻辑不同)。

def find_best_split_point(series, y, criterion='gain'): """ 对连续属性 series,寻找最优二分切分点(中点) :param series: 连续属性 Series (e.g., df['密度']) :param y: 目标变量 Series (0/1) :param criterion: 'gain' (信息增益) or 'gini' (基尼指数) :return: (best_point, best_score) """ # 去重、排序、去nan sorted_vals = series.dropna().sort_values().unique() if len(sorted_vals) < 2: return None, -np.inf candidates = [] for i in range(len(sorted_vals)-1): # 取相邻值中点 mid = (sorted_vals[i] + sorted_vals[i+1]) / 2 candidates.append(mid) best_score = -np.inf best_point = None for point in candidates: # 按 point 二分:<=point 为左子集,>point 为右子集 left_mask = series <= point right_mask = ~left_mask if left_mask.sum() == 0 or right_mask.sum() == 0: continue y_left = y[left_mask] y_right = y[right_mask] if criterion == 'gain': # 计算该切分下的信息增益 parent_ent = entropy(y) left_ent = entropy(y_left) right_ent = entropy(y_right) weight_left = len(y_left) / len(y) weight_right = len(y_right) / len(y) gain = parent_ent - (weight_left * left_ent + weight_right * right_ent) score = gain else: # 'gini' parent_gini = gini(y) left_gini = gini(y_left) right_gini = gini(y_right) score = parent_gini - (weight_left * left_gini + weight_right * right_gini) if score > best_score: best_score = score best_point = point return best_point, best_score # 辅助函数:计算熵(用于离散和连续属性) def entropy(y): if len(y) == 0: return 0 _, counts = np.unique(y, return_counts=True) probs = counts / len(y) return -np.sum([p * np.log2(p) for p in probs if p > 0]) # 辅助函数:计算基尼指数 def gini(y): if len(y) == 0: return 0 _, counts = np.unique(y, return_counts=True) probs = counts / len(y) return 1 - np.sum([p**2 for p in probs])

参数说明:find_best_split_point是 C4.5/CART 连续属性处理的核心。它不调用任何 sklearn 内部函数,纯手工实现“排序→取中点→遍历评估”,确保你能在任意中间步骤打断点,查看candidates列表、每个point对应的score,从而验证“为什么密度的最佳切分点是 0.381 而不是 0.556”。这是理解算法本质的关键——不是调包,而是亲手算。


3. ID3 算法实现:从信息增益到递归建树,每一步都可验证

ID3 是《西瓜书》第四章的起点,也是最纯粹的决策树:仅处理离散属性,以信息增益为选择准则,无剪枝。它的实现看似简单,但三个细节极易出错:缺失值处理、增益为零的提前终止、叶节点预测值确定。我们不封装成黑盒类,而是用函数式风格,每一步打印中间状态,便于 debug。

3.1 信息增益计算:必须处理缺失值带来的权重偏移

书中 P74 公式 (4.2) 计算信息增益时,默认所有样本完整。但西瓜数据集 3.0 中触感有缺失。ID3 的标准做法是:忽略缺失值样本参与当前属性的信息增益计算,但按比例分配其到子节点(即“加权平均”)。很多实现直接dropna(),这是错的——它丢弃了样本,改变了父节点的经验分布。

def calc_info_gain(X, y, feature_col): """ 计算特征 feature_col 对 y 的信息增益(支持缺失值) :param X: DataFrame,含所有特征 :param y: Series,目标变量 :param feature_col: 字符串,特征列名 :return: float,信息增益值 """ # 步骤1:获取该特征所有非缺失值,计算父节点熵 mask_not_na = X[feature_col].notna() X_clean = X[mask_not_na] y_clean = y[mask_not_na] if len(y_clean) == 0: return 0.0 parent_ent = entropy(y_clean) # 步骤2:按该特征各取值分组,计算加权子熵 # groupby 自动排除 NaN,符合ID3规范 groups = X_clean.groupby(feature_col) weighted_ent = 0.0 for val, group_idx in groups.groups.items(): y_group = y_clean.iloc[group_idx] weight = len(y_group) / len(y_clean) # 权重基于非缺失样本总数 weighted_ent += weight * entropy(y_group) return parent_ent - weighted_ent # 验证:计算“纹理”的信息增益(书中P74例) print(f"纹理的信息增益: {calc_info_gain(df_encoded, df_encoded['好瓜'], '纹理'):.4f}") # 应 ≈ 0.3808

逻辑说明:mask_not_na分离出非缺失样本用于熵计算,groupby自动跳过NaN,weight分母是len(y_clean)(非缺失总数),而非len(y)(全量总数)。这保证了增益值与书中计算一致。若你得到0.3215,一定是用了len(y)当分母——这是新手最常踩的坑。

3.2 ID3 主函数:递归建树 + 可视化路径打印

我们不返回sklearn.tree._tree.Tree对象,而返回一个嵌套字典结构{'feature': '纹理', 'threshold': None, 'children': {'0': {...}, '1': {...}}},其中threshold=None表示离散属性,children键为属性取值(0,1,2...)。每层递归都打印当前节点信息,让你看清树是如何生长的。

def id3_train(X, y, features, depth=0, max_depth=5): """ ID3 递归建树主函数 :param X: 当前节点数据子集 (DataFrame) :param y: 当前节点标签子集 (Series) :param features: 剩余可选特征列表 :param depth: 当前深度 :param max_depth: 最大深度(防无限递归) :return: dict,树结构 """ indent = " " * depth # 终止条件1:所有样本同标签 → 叶节点 if len(y.unique()) == 1: label = y.iloc[0] print(f"{indent}叶节点: 全为好瓜={label}") return {'type': 'leaf', 'label': int(label)} # 终止条件2:无特征可选 → 叶节点(多数表决) if len(features) == 0: majority = y.mode().iloc[0] if not y.mode().empty else 0 print(f"{indent}叶节点: 特征耗尽,多数表决={majority}") return {'type': 'leaf', 'label': int(majority)} # 终止条件3:达到最大深度 if depth >= max_depth: majority = y.mode().iloc[0] if not y.mode().empty else 0 print(f"{indent}叶节点: 达最大深度{max_depth},多数表决={majority}") return {'type': 'leaf', 'label': int(majority)} # 步骤1:计算每个剩余特征的信息增益 gains = {} for f in features: gain = calc_info_gain(X, y, f) gains[f] = gain print(f"{indent}特征 '{f}' 增益: {gain:.4f}") # 步骤2:选增益最大特征(若有并列,取第一个) best_feature = max(gains, key=gains.get) print(f"{indent}选择特征: '{best_feature}' (增益={gains[best_feature]:.4f})") # 步骤3:按 best_feature 各取值分割数据(自动忽略 NaN) groups = X.groupby(best_feature, dropna=False) # dropna=False 保留 NaN 组,但 groupby 本身会跳过 NaN,故实际无 NaN 组 node = { 'type': 'internal', 'feature': best_feature, 'children': {} } # 步骤4:对每个取值递归建子树 for val, group_idx in groups.groups.items(): X_sub = X.iloc[group_idx] y_sub = y.iloc[group_idx] print(f"{indent}→ 分支 '{best_feature}={val}' (样本数={len(y_sub)})") remaining_features = [f for f in features if f != best_feature] child_tree = id3_train(X_sub, y_sub, remaining_features, depth+1, max_depth) node['children'][int(val)] = child_tree return node # 执行建树(仅用离散特征,排除密度和含糖率) discrete_features = ['色泽', '根蒂', '敲声', '纹理', '脐部', '触感'] tree_id3 = id3_train( df_encoded[discrete_features], df_encoded['好瓜'], discrete_features, max_depth=3 )

现象观察:运行时你会看到类似这样的输出:

选择特征: '纹理' (增益=0.3808) → 分支 '纹理=0' (样本数=9) 特征 '色泽' 增益: 0.0000 特征 '根蒂' 增益: 0.0000 ... 叶节点: 全为好瓜=1

当某分支下calc_info_gain返回0.0000,说明该子集中所有样本在剩余特征上完全相同(如“纹理=0”且“色泽=0”时,所有样本根蒂都是“蜷缩”),此时无法再分裂,自然终止——这正是 ID3 的朴素逻辑,无需额外判断。


4. C4.5 与 CART 的关键差异实现:增益率修正与基尼切分

ID3 的缺陷是偏好取值数目多的属性(如“编号”这种唯一值属性增益最大)。C4.5 用增益率(Gain Ratio)修正,CART 则用基尼指数(Gini Index)和二元切分。二者实现差异极大,但网上教程常混为一谈。本节直击核心:如何在西瓜数据集上,让 C4.5 真正避开“纹理”选“色泽”,让 CART 的连续切分点与书中推导一致。

4.1 C4.5 增益率:分裂信息(Split Information)必须严格按定义计算

《西瓜书》P77 公式 (4.4) 定义增益率 = 信息增益 / 分裂信息。其中“分裂信息”是按当前特征各取值划分后,子集大小的熵。关键陷阱:分裂信息的计算必须基于当前节点全部样本(含缺失值),而信息增益只基于非缺失样本。很多实现错误地用同一子集计算二者,导致增益率失真。

def calc_split_info(X, feature_col): """ 计算特征 feature_col 的分裂信息(Split Information) 注意:分母是当前节点总样本数(含缺失),分子是各取值样本数 """ # 统计该特征所有取值(含 NaN)的频次 value_counts = X[feature_col].value_counts(dropna=False) # dropna=False 包含 NaN total = len(X) si = 0.0 for count in value_counts: if count == 0: continue prob = count / total si -= prob * np.log2(prob) return si def calc_gain_ratio(X, y, feature_col): """ C4.5 增益率 = 信息增益 / 分裂信息 若分裂信息为0(所有样本该特征值相同),则增益率定义为0(避免除零) """ gain = calc_info_gain(X, y, feature_col) si = calc_split_info(X, feature_col) if si == 0: return 0.0 return gain / si # 验证:计算“纹理”的增益率(书中P77例) gr_texture = calc_gain_ratio(df_encoded, df_encoded['好瓜'], '纹理') print(f"纹理的增益率: {gr_texture:.4f}") # 应 ≈ 0.3808 / 1.5710 ≈ 0.2424 # 对比“触感”(取值少,分裂信息小) gr_touch = calc_gain_ratio(df_encoded, df_encoded['好瓜'], '触感') print(f"触感的增益率: {gr_touch:.4f}") # 应 > 纹理的增益率,证明C4.5缓解了多值偏好

避坑 / 常见问题 / 排查
现象1:calc_gain_ratio返回nan或inf
原因:calc_split_info中prob=0时log2(0)未处理,或si=0时未设return 0.0
解决:代码中已加入if si == 0: return 0.0,且value_counts(dropna=False)确保count不为负。

现象2:C4.5 依然选了“纹理”而非“色泽”
原因:你用了X[feature_col].nunique()当作分裂信息分母,错!分裂信息是熵,不是取值个数。
解决:必须用value_counts(dropna=False)计算各取值频次,再套熵公式。

现象3:触感的增益率比纹理小
原因:触感有NaN,value_counts(dropna=False)把NaN当作一个独立取值,增大了分裂信息,拉低了增益率。但书中表 4.1 的触感缺失是真实缺失,C4.5 规范处理就是如此——这恰恰说明 C4.5 对缺失值敏感,需配合其他策略(如填充)。
解决:接受此结果,它符合 C4.5 原意;若要规避,需先对缺失值插补(如众数填充),但那就不是纯 C4.5 了。

4.2 CART 基尼指数切分:连续属性必须用 find_best_split_point 重算

CART 的核心是二元切分 + 基尼指数最小化。它不区分离散/连续,对连续属性直接找最优切分点;对离散属性,则穷举所有二分组合(如将“色泽”三值 {0,1,2} 分为 {0} vs {1,2}、{1} vs {0,2}、{2} vs {0,1}),选基尼下降最大者。西瓜数据集离散属性取值少(最多3个),我们实现离散属性的二分枚举,连续属性复用 2.3 节的find_best_split_point。

def cart_find_best_split(X, y, feature_col, is_continuous=False): """ CART 寻找最优切分:离散属性枚举二分,连续属性用中点切分 :param is_continuous: True 表示连续属性(密度、含糖率) :return: (best_split, best_gini_decrease, split_type) """ if is_continuous: # 连续属性:调用 2.3 节函数 best_point, best_score = find_best_split_point(X[feature_col], y, 'gini') return (best_point, 'continuous'), best_score, 'continuous' else: # 离散属性:枚举所有非空真子集作为左分支 values = X[feature_col].dropna().unique() if len(values) <= 1: return (None, 'discrete'), 0.0, 'discrete' best_decrease = -np.inf best_split = None # 生成所有非空真子集(用位运算) n = len(values) for mask in range(1, 2**n - 1): # 排除空集和全集 left_set = set() for i in range(n): if mask & (1 << i): left_set.add(values[i]) left_set = list(left_set) # 构建左右掩码 left_mask = X[feature_col].isin(left_set) & X[feature_col].notna() right_mask = (~left_mask) & X[feature_col].notna() if left_mask.sum() == 0 or right_mask.sum() == 0: continue y_left = y[left_mask] y_right = y[right_mask] weight_left = len(y_left) / len(y) weight_right = len(y_right) / len(y) parent_gini = gini(y) left_gini = gini(y_left) right_gini = gini(y_right) decrease = parent_gini - (weight_left * left_gini + weight_right * right_gini) if decrease > best_decrease: best_decrease = decrease best_split = left_set return (best_split, 'discrete'), best_decrease, 'discrete' # 示例:对“色泽”找最优二分 split, decrease, _ = cart_find_best_split(df_encoded, df_encoded['好瓜'], '色泽') print(f"色泽最优二分: {split[0]} → 基尼下降 {decrease:.4f}")

参数说明:cart_find_best_split返回(split_spec, split_type),其中split_spec对连续属性是浮点数(如0.381),对离散属性是列表(如[0]表示将“色泽=0”分到左,其余到右)。这为后续建树提供明确指令,避免sklearn的黑盒切分。


5. 避坑指南:ID3/C4.5/CART 在西瓜数据集上的 5 个血泪经验

写决策树代码最痛苦的不是算法难,而是结果和书上对不上,debug 时怀疑人生。以下是我在多个模拟项目 X 中,用西瓜数据集反复验证总结的 5 个高频翻车点,每个都附带可复现的错误代码、现象、根因和修复命令。请务必在你第一次 run 之前,逐条核对。

5.1 错误:用sklearn.tree.DecisionTreeClassifier(criterion='entropy')直接拟合西瓜数据集

# ❌ 错误示范:以为这就是ID3 from sklearn.tree import DecisionTreeClassifier clf = DecisionTreeClassifier(criterion='entropy', max_depth=3) clf.fit(df_encoded[discrete_features], df_encoded['好瓜']) # 报错!

现象:ValueError: Input contains NaN, infinity or a value too large for dtype('float64')
原因:sklearn默认不接受Int64类型中的NaN,且DecisionTreeClassifier的criterion='entropy'实际实现是 CART 风格(二元切分),不是 ID3 的多路切分。它会尝试把触感=NaN当作一个数值处理,导致溢出。
解决:

  • 对离散属性,用fillna(-1).astype(int)强制转为普通int(-1 代表缺失);
  • 或彻底放弃 sklearn,用本文 3.2 节的纯 ID3 实现;
  • 记住:sklearn的entropy模式 ≠ ID3,它是 CART 的熵版。

5.2 错误:计算信息增益时,对连续属性直接调用calc_info_gain

# ❌ 错误示范:把密度当离散属性算增益 gain_density = calc_info_gain(df_encoded, df_encoded['好瓜'], '密度') # 得到 0.0

现象:gain_density恒为0.0
原因:calc_info_gain内部用groupby(feature_col),而density是浮点数,每个值几乎唯一,groupby产生 17 个单样本组,entropy(y_group)恒为0,加权和也为0,增益=父熵-0=父熵?不,因为groupby会把所有density值当不同键,但y标签分布不均,实际计算中weight_left * left_ent项极小,最终数值误差导致0.0。
解决:连续属性必须先二值化!用find_best_split_point(df_encoded['密度'], y, 'gain')找切分点,再按切分点构造二值特征(如density_bin = (df['密度'] <= 0.381).astype(int)),然后对density_bin调用calc_info_gain。

5.3 错误:C4.5 建树时,对缺失值做众数填充后再计算增益率

# ❌ 错误示范:先填再算,以为更“鲁棒” df_filled = df_encoded.fillna(df_encoded.mode().iloc[0]) # 用众数填所有列 gr_filled = calc_gain_ratio(df_filled, df_filled['好瓜'], '触感')

现象:gr_filled比未填充时高,C4.5 可能又选了“纹理”
原因:C4.5 的增益率修正机制,本意就是惩罚多值属性,而填充缺失值人为增加了“触感”的取值多样性(如把NaN填成0,使触感取值从 2 个变成 3 个),反而放大了分裂信息,削弱了修正效果。这违背了 C4.5 的设计初衷。
解决:严格按 C4.5 原文,缺失值在增益计算中忽略,在分裂信息计算中计入。不要填充,除非你明确要换一种缺失值处理策略(如 C4.5 的“概率扩展”)。

5.4 错误:画决策树时,用sklearn.tree.plot_tree可视化自实现树

# ❌ 错误示范:试图把 dict 树喂给 sklearn 画图 from sklearn.tree import plot_tree plot_tree(tree_id <p> <a href="https://download.csdn.net/download/m0_46345193/85724435" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询