决策树理论
第一部分:基本流程与核心思想
1.1 分而治之(Divide-and-Conquer)
蜷缩……最终得出一个结论。就是决策树的学习过程本质上是一个递归地选择最优特征,并根据该特征对训练资料进行分割的过程。我们在判断一个西瓜是否是“好瓜”时,往往会进行一系列的子决策:先看色泽,如果是青绿,再看根蒂,如果
一个决策树包括三个主要部分:
- 根结点 (Root Node):包括样本全集。
- 内部结点 (Internal Node):对应于属性测试(Feature Test)。
- 叶结点 (Leaf Node):对应于决策结果(Label)。
1.2 算法流程(伪代码逻辑)
决策树生成的伪代码如下:
- 输入:训练集 D={(x1,y1),...,(xm,ym)}D = \{(x_1, y_1), ..., (x_m, y_m)\}D={(x1,y1),...,(xm,ym)},属性集 A={a1,...,ad}A = \{a_1, ..., a_d\}A={a1,...,ad}。
- 过程:函数
TreeGenerate(D, A) - 生成结点
node。 - 递归返回情形 (1):如果 DDD中样本全属于同一类别CCC,则将
node标记为 CCC类叶结点,返回。 - 递归返回情形 (2):如果 A=∅A = \emptysetA=∅ 或 DDD 中样本在 AAA 上取值相同,则将
node标记为叶结点,类别设为 DDD中样本数最多的类,返回。 - 核心步骤:从 AAA中选择最优划分属性a∗a_*a∗(这是算法的灵魂)。
- 对于 a∗a_*a∗ 的每一个值 a∗va_*^va∗v:
- 为
node生成一个分支,包含 DDD 中在 a∗a_*a∗ 上取值为 a∗va_*^va∗v 的样本子集 DvD_vDv。 - 递归返回情形 (3):如果 DvD_vDv为空,则将分支结点标记为叶结点,类别为DDD中最多的类。
- 否则,以
TreeGenerate(Dv,A∖{a∗}D_v, A \setminus \{a_*\}Dv,A∖{a∗}) 为子结点。
- 为
第二部分:划分选择
决策树的关键在于第6步:如何选择最优划分属性a∗a_*a∗?我们的目标是随着划分进行,结点的“纯度”(Purity)越来越高。
2.1 信息增益 (Information Gain) - ID3算法
2.1.1 信息熵 (Information Entropy)
度量样本集合纯度最常用的一种指标。假定当前样本集合就是信息熵DDD 中第 kkk类样本所占的比例为pk(k=1,2,...,∣Y∣)p_k (k=1, 2, ..., |\mathcal{Y}|)pk(k=1,2,...,∣Y∣),则 DDD的信息熵定义为:
Ent(D)=−∑k=1∣Y∣pklog2pk(4.1) \text{Ent}(D) = - \sum_{k=1}^{|\mathcal{Y}|} p_k \log_2 p_k \tag{4.1}Ent(D)=−k=1∑∣Y∣pklog2pk(4.1)
数学扩展:为什么熵能衡量不确定性?(熵的极值证明)
我们要求证明:当所有类别的概率相等时(即pk=1∣Y∣p_k = \frac{1}{|\mathcal{Y}|}pk=∣Y∣1),熵最大(不确定性最大);当样本只属于某一类时,熵最小(为0)。
证明:
这是一个带约束的极值挑战。
目标函数:f(p1,...,pn)=−∑i=1npilnpif(p_1, ..., p_n) = - \sum_{i=1}^n p_i \ln p_if(p1,...,pn)=−∑i=1npilnpi(换底不影响单调性)
约束条件:∑i=1npi=1\sum_{i=1}^n p_i = 1∑i=1npi=1构建拉格朗日函数:
L(p1,...,pn,λ)=−∑i=1npilnpi+λ(∑i=1npi−1)L(p_1, ..., p_n, \lambda) = - \sum_{i=1}^n p_i \ln p_i + \lambda (\sum_{i=1}^n p_i - 1)L(p1,...,pn,λ)=−i=1∑npilnpi+λ(i=1∑npi−1)对 pip_ipi求偏导并令其为0:
∂L∂pi=−(lnpi+1)+λ=0\frac{\partial L}{\partial p_i} = - (\ln p_i + 1) + \lambda = 0∂pi∂L=−(lnpi+1)+λ=0
⇒lnpi=λ−1\Rightarrow \ln p_i = \lambda - 1⇒lnpi=λ−1
⇒pi=eλ−1\Rightarrow p_i = e^{\lambda - 1}⇒pi=eλ−1由于 λ\lambdaλ是常数,这意味着所有的pip_ipi都必须相等。
代入约束条件∑pi=1\sum p_i = 1∑pi=1,可得 pi=1np_i = \frac{1}{n}pi=n1。
此时海森矩阵为负定,熵达到最大值。这严格证明了熵在概率均匀分布时取最大值,纯度最低。
2.1.2 信息增益计算
假定离散属性aaa 有 VVV个可能的取值{a1,a2,...,aV}\{a^1, a^2, ..., a^V\}{a1,a2,...,aV},若使用 aaa 对 DDD进行划分,则会产生VVV个分支结点,其中第vvv个分支结点包含了DDD中所有在属性aaa 上取值为 ava^vav的样本,记为DvD^vDv。
我们可以计算出属性aaa 对样本集 DDD进行划分所获得的“信息增益”:
Gain(D,a)=Ent(D)−∑v=1V∣Dv∣∣D∣Ent(Dv)(4.2) \text{Gain}(D, a) = \text{Ent}(D) - \sum_{v=1}^{V} \frac{|D^v|}{|D|} \text{Ent}(D^v) \tag{4.2}Gain(D,a)=Ent(D)−v=1∑V∣D∣∣Dv∣Ent(Dv)(4.2)
物理意义:信息增益 = 划分前的信息熵 - 划分后加权平均的信息熵。增益越大,意味着使用属性aaa进行划分带来的“纯度提升”越大。ID3 算法便是采用信息增益作为划分准则。
2.2 增益率 (Gain Ratio) - C4.5算法
ID3算法存在一个偏好:它喜欢取值数目较多的属性(例如“编号”属性,每个样本一个编号,纯度极高,但毫无泛化能力)。
为了减少这种偏好,C4.5 算法引入了“增益率”。
Gain_ratio(D,a)=Gain(D,a)IV(a)(4.3) \text{Gain\_ratio}(D, a) = \frac{\text{Gain}(D, a)}{\text{IV}(a)} \tag{4.3}Gain_ratio(D,a)=IV(a)Gain(D,a)(4.3)
其中 IV(a)\text{IV}(a)IV(a) 是属性 aaa 的固有值 (Intrinsic Value):
IV(a)=−∑v=1V∣Dv∣∣D∣log2∣Dv∣∣D∣(4.4) \text{IV}(a) = - \sum_{v=1}^{V} \frac{|D^v|}{|D|} \log_2 \frac{|D^v|}{|D|} \tag{4.4}IV(a)=−v=1∑V∣D∣∣Dv∣log2∣D∣∣Dv∣(4.4)
注意:IV(a)\text{IV}(a)IV(a)其实就是属性aaa作为一个随机变量的熵。属性取值越多,IV(a)\text{IV}(a)IV(a) 通常越大。
C4.5 的启发式策略:先从候选属性中找出信息增益高于平均水平的属性,再从中选择增益率最高的。
2.3 基尼指数 (Gini Index) - CART算法
CART (Classification and Regression Tree) 使用基尼指数来度量纯度。这是一种运算速度更快的指标(避免了对数运算)。
Gini(D)=∑k=1∣Y∣∑k′≠kpkpk′=1−∑k=1∣Y∣pk2(4.5) \begin{aligned} \text{Gini}(D) &= \sum_{k=1}^{|\mathcal{Y}|} \sum_{k' \neq k} p_k p_{k'} \\ &= 1 - \sum_{k=1}^{|\mathcal{Y}|} p_k^2 \end{aligned} \tag{4.5}Gini(D)=k=1∑∣Y∣k′=k∑pkpk′=1−k=1∑∣Y∣pk2(4.5)
直观来说,Gini(D)\text{Gini}(D)Gini(D)反映了从数据集中随机抽取两个样本,其类别标记不一致的概率。
属性 aaa的基尼指数定义为:
Gini_index(D,a)=∑v=1V∣Dv∣∣D∣Gini(Dv)(4.6) \text{Gini\_index}(D, a) = \sum_{v=1}^{V} \frac{|D^v|}{|D|} \text{Gini}(D^v) \tag{4.6}Gini_index(D,a)=v=1∑V∣D∣∣Dv∣Gini(Dv)(4.6)
大家在候选属性集合AAA中,选择那个使得划分后基尼指数最小的属性作为最优划分属性。
数学扩展:Gini指数与熵的关系(泰勒展开)
我们能够证明 Gini 指数实际上是熵模型的一阶近似。
将 f(x)=−lnxf(x) = -\ln xf(x)=−lnx 在 x=1x=1x=1处进行一阶泰勒展开:
f(x)≈f(1)+f′(1)(x−1)=0+(−1)(x−1)=1−xf(x) \approx f(1) + f'(1)(x-1) = 0 + (-1)(x-1) = 1-xf(x)≈f(1)+f′(1)(x−1)=0+(−1)(x−1)=1−x因此,熵的公式可近似为:
Ent(D)=−∑pklnpk≈∑pk(1−pk)=∑pk−∑pk2=1−∑pk2=Gini(D) \text{Ent}(D) = - \sum p_k \ln p_k \approx \sum p_k (1 - p_k) = \sum p_k - \sum p_k^2 = 1 - \sum p_k^2 = \text{Gini}(D)Ent(D)=−∑pklnpk≈∑pk(1−pk)=∑pk−∑pk2=1−∑pk2=Gini(D)
这证明了基尼指数和熵在物理意义上的一致性。
第三部分:剪枝处理 (Pruning)
剪枝是决策树对付“过拟合”的主要手段。因为决策树极易将训练集的噪声也学成规律,导致模型过于繁琐。
3.1 预剪枝 (Pre-pruning)
定义:在决策树生成过程中,对每个结点在划分前先进行估计,若当前结点的划分不能带来决策树泛化性能提升,则停止划分并将当前结点标记为叶结点。
具体操作:
- 将素材集分为训练集和验证集。
- 在计算出最优划分属性a∗a_*a∗后,不立即划分。
- 计算不划分时的精度:该结点作为叶结点(类别取众数),在验证集上的精度是多少?(设为AccoldAcc_{old}Accold)
- 计算划分后的精度:若基于 a∗a_*a∗展开,子树在验证集上的精度是多少?(设为AccnewAcc_{new}Accnew)
- 判断:若 Accnew>AccoldAcc_{new} > Acc_{old}Accnew>Accold,则确认划分;否则,停止划分(剪枝)。
优缺点:
- 利:降低过拟合风险,显著减少训练时间和测试时间(贪心策略)。
- 弊:有些分支纵然当前不能提升泛化性能,但在其基础上进行的后续划分可能导致性能显著提高。预剪枝基于“贪心”本质,带来了欠拟合风险。
3.2 后剪枝 (Post-pruning)
定义:先从训练集生成一棵完整的决策树,然后自底向上地对非叶结点进行考察,若将该结点对应的子树替换为叶结点能带来泛化性能提升,则将该子树替换为叶结点。
具体操作:
- 完全生长一棵树。
- 找到最底层的非叶结点(通常是只有叶子作为孩子的结点)。
- 考察剪枝前:该子树在验证集上的精度。
- 考察剪枝后:将该结点变为叶结点(类别取众数),在验证集上的精度。
- 判断:若剪枝后精度没有下降(通常要求提升,或者相等也可以),则剪枝。
- 递归向上,直到根节点。
优缺点:
- 利:欠拟合风险很小,泛化性能通常优于预剪枝。
- 弊:训练时间开销大(需要先生成全树,再回溯)。
第四部分:连续值与缺失值处理
4.1 连续值处理 (Continuous Values)
现实数据中常会有“密度”、“含糖率”等连续属性。C4.5 采用二分法 (bi-partition) 处理。
给定样本集 DDD 和连续属性 aaa,假定 aaa 在 DDD 上出现了 nnn个不同的取值,将其从小到大排序为{a1,a2,...,an}\{a^1, a^2, ..., a^n\}{a1,a2,...,an}。
我们需要找到一个划分点ttt,将集合分为Dt−D_t^-Dt− (取值 ≤t\le t≤t) 和 Dt+D_t^+Dt+ (取值 >t> t>t)。
候选划分点集合TaT_aTa通常取相邻两个值的平均值:
Ta={ai+ai+12∣1≤i≤n−1}(4.7)
T_a = \{ \frac{a^i + a^{i+1}}{2} \mid 1 \le i \le n-1 \} \tag{4.7}Ta={2ai+ai+1∣1≤i≤n−1}(4.7)
继而,我们像离散属性一样考察这些划分点,选择最优的:
Gain(D,a)=maxt∈TaGain(D,a,t)=maxt∈Ta(Ent(D)−∑λ∈{−,+}∣Dtλ∣∣D∣Ent(Dtλ))(4.8)
\text{Gain}(D, a) = \max_{t \in T_a} \text{Gain}(D, a, t) = \max_{t \in T_a} (\text{Ent}(D) - \sum_{\lambda \in \{-, +\}} \frac{|D_t^\lambda|}{|D|} \text{Ent}(D_t^\lambda)) \tag{4.8}Gain(D,a)=t∈TamaxGain(D,a,t)=t∈Tamax(Ent(D)−λ∈{−,+}∑∣D∣∣Dtλ∣Ent(Dtλ))(4.8)
关键点:与离散属性不同,若当前结点划分启用了连续属性aaa,该属性在子结点中仍可再次作为划分属性(例如:先判断是否≤0.5\le 0.5≤0.5否就是,后续分支还可以判断≤0.3\le 0.3≤0.3)。
4.2 缺失值处理 (Missing Values)
当样本中存在缺失值时,我们要求处理两个问题:
- 如何选择划分属性?
- 给定划分属性,若样本在该属性上缺失,如何划分?
解决方案:
定义 ρ\rhoρ为无缺失值样本所占比例,p~k\tilde{p}_kp~k为无缺失值样本中第kkk 类的比例,r~v\tilde{r}_vr~v为无缺失样本中在属性aaa 上取值 ava^vav 的比例。
推广的信息增益计算公式:
Gain(D,a)=ρ×Gain(D~,a)=ρ×(Ent(D~)−∑v=1Vr~vEnt(D~v))(4.12)
\text{Gain}(D, a) = \rho \times \text{Gain}(\tilde{D}, a) = \rho \times (\text{Ent}(\tilde{D}) - \sum_{v=1}^V \tilde{r}_v \text{Ent}(\tilde{D}^v)) \tag{4.12}Gain(D,a)=ρ×Gain(D~,a)=ρ×(Ent(D~)−v=1∑Vr~vEnt(D~v))(4.12)
其中 D~\tilde{D}D~ 是 DDD 中在属性 aaa上没有缺失值的样本子集。这表示大家只根据有值的样本计算增益,然后乘以系数ρ\rhoρ进行“惩罚”。
样本划分逻辑(权重更新):
若样本 xxx 在划分属性 aaa 上已知取值 ava^vav,则划入对应分支,权重wxw_xwx 保持不变。
若样本 xxx 在划分属性 aaa 上缺失,则将 xxx同时划入所有子结点,但在不同子结点中,该样本的权重调整为r~v⋅wx\tilde{r}_v \cdot w_xr~v⋅wx。即:让同一个样本以不同的概率“分身”进入所有子节点。
第五部分: Python 代码完成
为了彻底理解上述理论,我们将编写一个不依赖 sklearn 的决策树。这个实现将包含:
- 熵与信息增益的计算(使用
numpy向量化加速)。 - 连续值处理(二分法)。
- 递归建树与预测。
import numpy as np
import pandas as pd
from collections import Counter
class DecisionTreeScratch:
def __init__(self, mode='ID3', min_samples_split=2):
"""
:param mode: 'ID3' (Info Gain) or 'C4.5' (Gain Ratio) or 'CART' (Gini)
"""
self.mode = mode
self.min_samples_split = min_samples_split
self.tree = None
def calc_entropy(self, y):
"""
计算信息熵 Formula 4.1
使用 numpy 进行向量化运算加速
"""
counts = np.bincount(y)
probabilities = counts / len(y)
# 避免 log(0) 的情况,只对概率大于0的项进行计算
probabilities = probabilities[probabilities > 0]
entropy = -np.sum(probabilities * np.log2(probabilities))
return entropy
def calc_gini(self, y):
"""
计算基尼指数 Formula 4.5
"""
counts = np.bincount(y)
probabilities = counts / len(y)
gini = 1 - np.sum(probabilities ** 2)
return gini
def split_dataset(self, X, y, feature_index, value, is_continuous=False):
"""
根据特征和值分割数据集
"""
if is_continuous:
# 连续值二分法 Formula 4.7
left_mask = X[:, feature_index] <= value
right_mask = X[:, feature_index] > value
else:
# 离散值匹配(此处作为通用工具,实际C4.5划分时是多路划分)
left_mask = X[:, feature_index] == value
right_mask = X[:, feature_index] != value
# 返回类似字典的结构,模拟多路或二路分割
splits = {}
if not is_continuous:
unique_values = np.unique(X[:, feature_index])
for val in unique_values:
mask = X[:, feature_index] == val
splits[val] = (X[mask], y[mask])
else:
splits['<='] = (X[left_mask], y[left_mask])
splits['>'] = (X[right_mask], y[right_mask])
return splits
def choose_best_feature(self, X, y, feature_types):
"""
选择最优划分属性 (核心算法: 遍历所有特征,计算增益)
涵盖 Formula 4.2 (Gain), 4.3 (Gain Ratio), 4.6 (Gini Index)
"""
num_features = X.shape[1]
base_entropy = self.calc_entropy(y)
best_metric = -1 # 增益或增益率
best_feature_idx = -1
best_split_value = None # 仅用于连续值
for i in range(num_features):
is_continuous = (feature_types[i] == 'continuous')
unique_values = np.unique(X[:, i])
new_entropy = 0
iv = 0 # Intrinsic Value for C4.5 Formula 4.4
current_split_value = None
if is_continuous:
# 连续值处理:排序取中点 (Formula 4.7)
sorted_vals = np.sort(unique_values)
mid_points = (sorted_vals[:-1] + sorted_vals[1:]) / 2
max_gain_for_feature = -1
best_threshold = None
# 遍历所有可能的划分点
for threshold in mid_points:
# 二分
mask_l = X[:, i] <= threshold
mask_r = X[:, i] > threshold
y_l, y_r = y[mask_l], y[mask_r]
if len(y_l) == 0 or len(y_r) == 0: continue
p_l = len(y_l) / len(y)
p_r = len(y_r) / len(y)
ent_split = p_l * self.calc_entropy(y_l) + p_r * self.calc_entropy(y_r)
gain = base_entropy - ent_split
if gain > max_gain_for_feature:
max_gain_for_feature = gain
best_threshold = threshold
# 选定该连续特征的最佳分割点后,作为该特征的Gain
info_gain = max_gain_for_feature
current_split_value = best_threshold
else:
# 离散值处理 (Formula 4.2)
for val in unique_values:
mask = X[:, i] == val
sub_y = y[mask]
if len(sub_y) == 0: continue
prob = len(sub_y) / len(y)
new_entropy += prob * self.calc_entropy(sub_y)
# 计算 IV (Formula 4.4)
iv -= prob * np.log2(prob + 1e-9)
info_gain = base_entropy - new_entropy
# 计算最终指标
final_metric = info_gain
# C4.5 增益率处理
if self.mode == 'C4.5':
if iv == 0: final_metric = 0
else: final_metric = info_gain / iv
# 选择最大增益/增益率
if final_metric > best_metric:
best_metric = final_metric
best_feature_idx = i
best_split_value = current_split_value
return best_feature_idx, best_split_value
def majority_cnt(self, y):
"""返回出现次数最多的类别"""
return Counter(y).most_common(1)[0][0]
def create_tree(self, X, y, feature_names, feature_types):
"""
递归生成决策树 (对应西瓜书图 4.2 基本算法)
"""
# 情形1: D中样本全属于同一类
if len(np.unique(y)) == 1:
return y[0]
# 情形2: 属性集为空 或 样本在属性上取值相同 (简化为特征用完)
if X.shape[1] == 0:
return self.majority_cnt(y)
# 选择最优特征
best_feat_idx, best_split_val = self.choose_best_feature(X, y, feature_types)
# 无法继续划分(增益太小或无特征)
if best_feat_idx == -1:
return self.majority_cnt(y)
best_feat_name = feature_names[best_feat_idx]
is_continuous = (feature_types[best_feat_idx] == 'continuous')
my_tree = {best_feat_name: {}}
# 划分数据集并递归
if is_continuous:
# 连续值:只有两个分支 <= 和 >val = best_split_valmy_tree[best_feat_name]['is_continuous'] = Truemy_tree[best_feat_name]['split_value'] = val# 获取分割后的数据splits = self.split_dataset(X, y, best_feat_idx, val, is_continuous=True)# 构建左子树l_X, l_y = splits['<=']if len(l_y) == 0: my_tree[best_feat_name]['<='] = self.majority_cnt(y)else: my_tree[best_feat_name]['<='] = self.create_tree(l_X, l_y, feature_names, feature_types)# 构建右子树r_X, r_y = splits['>']if len(r_y) == 0: my_tree[best_feat_name]['>'] = self.majority_cnt(y)else: my_tree[best_feat_name]['>'] = self.create_tree(r_X, r_y, feature_names, feature_types)else:# 离散值:每个取值一个分支my_tree[best_feat_name]['is_continuous'] = Falseunique_vals = np.unique(X[:, best_feat_idx])# 注意:按照西瓜书逻辑,离散属性使用后应从属性集中移除(除非可以重复使用,如某些变体)# 这里为了简化递归索引处理,我们逻辑上不再使用该属性,# 但物理上保留列(实际工程中通常使用 mask 数组来管理可用特征)for val in unique_vals:mask = X[:, best_feat_idx] == valsub_X = X[mask]sub_y = y[mask]# 递归生成子树my_tree[best_feat_name][val] = self.create_tree(sub_X, sub_y, feature_names, feature_types)return my_treedef fit(self, X, y, feature_names, feature_types):self.tree = self.create_tree(X, y, feature_names, feature_types)def predict_one(self, tree, sample, feature_names):"""递归遍历字典树进行预测"""if not isinstance(tree, dict):return treefeature_name = list(tree.keys())[0]feature_props = tree[feature_name]# 找到特征在样本中的索引try:feat_idx = feature_names.index(feature_name)except:return Noneval = sample[feat_idx]if feature_props.get('is_continuous'):split_val = feature_props['split_value']if val <= split_val:next_tree = feature_props['<=']else:next_tree = feature_props['>']else:next_tree = feature_props.get(val)if next_tree is None:# 遇到训练集中未见过的特征值,返回多数类或无法判断return "Unknown"return self.predict_one(next_tree, sample, feature_names)def predict(self, X, feature_names):return [self.predict_one(self.tree, sample, feature_names) for sample in X]# --- 测试代码 ---if __name__ == "__main__":# 创建西瓜数据集 2.0 (部分模拟)# 色泽(0), 根蒂(1), 敲声(2), 纹理(3), 脐部(4), 触感(5), 密度(6 continuous), 含糖(7 continuous)# 标签: 好瓜=1, 坏瓜=0data = [['青绿', '蜷缩', '浊响', '清晰', '凹陷', '硬滑', 0.697, 0.460, '是'],['乌黑', '蜷缩', '沉闷', '清晰', '凹陷', '硬滑', 0.774, 0.376, '是'],['乌黑', '蜷缩', '浊响', '清晰', '凹陷', '硬滑', 0.634, 0.264, '是'],['青绿', '蜷缩', '沉闷', '清晰', '凹陷', '硬滑', 0.608, 0.318, '是'],['浅白', '蜷缩', '浊响', '清晰', '凹陷', '硬滑', 0.556, 0.215, '是'],['青绿', '稍蜷', '浊响', '清晰', '稍凹', '软粘', 0.403, 0.237, '是'],['乌黑', '稍蜷', '浊响', '稍糊', '稍凹', '软粘', 0.481, 0.149, '是'],['乌黑', '稍蜷', '浊响', '清晰', '稍凹', '硬滑', 0.437, 0.211, '是'],['乌黑', '稍蜷', '沉闷', '稍糊', '稍凹', '硬滑', 0.666, 0.091, '否'],['青绿', '硬挺', '清脆', '清晰', '平坦', '软粘', 0.243, 0.267, '否'],['浅白', '硬挺', '清脆', '模糊', '平坦', '硬滑', 0.245, 0.057, '否'],['浅白', '蜷缩', '浊响', '模糊', '平坦', '软粘', 0.343, 0.099, '否'],['青绿', '稍蜷', '浊响', '稍糊', '凹陷', '硬滑', 0.639, 0.161, '否'],['浅白', '稍蜷', '沉闷', '稍糊', '凹陷', '硬滑', 0.657, 0.198, '否'],['乌黑', '稍蜷', '浊响', '清晰', '稍凹', '软粘', 0.360, 0.370, '否'],['浅白', '蜷缩', '浊响', '模糊', '平坦', '硬滑', 0.593, 0.042, '否'],['青绿', '蜷缩', '沉闷', '稍糊', '稍凹', '硬滑', 0.719, 0.103, '否']]df = pd.DataFrame(data, columns=['色泽', '根蒂', '敲声', '纹理', '脐部', '触感', '密度', '含糖率', '好瓜'])# 准备数据X = df.iloc[:, :-1].valuesy = df.iloc[:, -1].valuesy = np.array([1 if x == '是' else 0 for x in y]) # 转为 0/1feature_names = list(df.columns[:-1])# 定义特征类型:前6个是离散的,后2个是连续的feature_types = ['discrete'] * 6 + ['continuous'] * 2# 训练模型 (使用 C4.5 模式)print("开始训练 C4.5 决策树...")dt = DecisionTreeScratch(mode='C4.5')dt.fit(X, y, feature_names, feature_types)import pprintprint("\n生成的决策树结构:")pprint.pprint(dt.tree)# 预测preds = dt.predict(X, feature_names)acc = np.sum(preds == y) / len(y)print(f"\n训练集准确率: {acc * 100:.2f}%")