资讯动态

手写决策树代码详解:从熵计算到递归建树的逐行实现

发布时间:2026/10/14 16:53:51 来源:尧图企业网站定制
1. 这不是“讲概念”是带你在编辑器里逐行敲出来的决策树你点开这篇大概率不是想听“决策树是一种监督学习算法用于分类和回归任务”这种教科书定义。你可能刚在课上被ID3、C4.5、CART绕晕也可能在Kaggle上跑通了sklearn.tree.DecisionTreeClassifier()却完全不知道它内部到底在算什么更可能的是你对着一段几十行的纯Python手写决策树代码发呆光是def split_dataset(...)这个函数名就让你卡住三分钟——它到底在切数据还是切西瓜熵怎么算信息增益又凭什么决定哪个特征该当根节点这正是我写这篇的出发点不跳过任何一行不省略任何一次计算不假装“读者应该懂”。我们用最朴素的Python零第三方库依赖只用math和collections从空文件开始一行一行敲一行一行注释一行一行验证中间结果。你会亲眼看到当数据集里第3行第2列的值是Sunny时程序如何把它归入左子树当某次划分后左右子集的类别分布分别是[3, 0]和[1, 4]时信息增益是怎么被算出0.278的甚至当递归深度达到5层、节点只剩2个样本时程序为什么果断停止分裂——不是因为写了max_depth5而是因为min_samples_split2这个硬约束在内存里真实触发了。核心关键词“决策树代码详解”在这里不是标题党而是操作指令。全文所有代码块均可直接复制进.py文件运行所有中间变量我都用print(fDEBUG: {variable_name} {value})显式输出就像坐在你工位旁边把键盘推给你指着屏幕说“看这一行执行完best_gain变成了0.321所以接下来它选了‘湿度’这个特征而不是‘风速’。”适合谁读如果你能写for i in range(10): print(i)你就能跟上如果你已经调过sklearn但想撕开黑箱你会获得远超API文档的掌控感如果你正被课程设计逼着手写ID3恭喜你不用再靠拼凑网上的碎片代码交作业了——这里每一行都有来由每一处缩进都有逻辑每一个return都经过实测验证。2. 决策树代码的整体结构与设计逻辑拆解2.1 为什么坚持“纯Python手写”而不是直接解读sklearn源码这是第一个必须厘清的设计选择。很多人一上来就想扒sklearn/tree/_tree.pyx结果被Cython语法、内存视图memoryview和底层指针绕得头晕。这不是懒而是工程常识理解一个系统永远要从它最简可行版本MVP开始而非最高效版本。sklearn的决策树是工业级实现支持缺失值、加权样本、多输出、预剪枝/后剪枝、并行计算、内存映射……这些特性让它的核心逻辑深埋在数千行代码和C扩展中。而我们手写的版本目标只有一个用最少的代码暴露最本质的决策逻辑。它只有4个核心函数calculate_entropy(labels)算香农熵split_dataset(dataset, feature_index, value)按某个特征的某个取值二分数据集find_best_split(dataset)遍历所有特征所有取值找信息增益最大的切分点build_tree(dataset, depth0)递归建树含停止条件判断这4个函数构成一个闭环build_tree调用find_best_split后者调用split_dataset和calculate_entropy而calculate_entropy是整个数学基础。没有类封装没有继承没有装饰器——因为那些全是为复用和扩展服务的“噪音”不是决策树本身的“信号”。提示你可以把这4个函数想象成一台老式机械计算器的四个旋钮。calculate_entropy是刻度盘标定混乱程度split_dataset是拨杆物理上把数据推到左右两个槽里find_best_split是操作员反复拨动拨杆、读刻度盘记下哪次拨动让两边槽里的数字最“整齐”build_tree则是整台机器的启动开关和安全锁——它决定什么时候该继续拨什么时候该停手。2.2 数据结构选型为什么用list of list而不是pandas或numpy输入数据格式定为dataset [[feature1, feature2, ..., label], [...], ...]即Python原生嵌套列表。这个选择看似落后实则精准服务于教学目标零依赖不需pip install pandas新装Python环境开箱即用。很多初学者卡在环境配置上不是学不会算法是败给ModuleNotFoundError。可调试性极强print(dataset[0])直接看到第一行原始数据print(len(dataset))立刻知道当前节点样本数print([row[-1] for row in dataset])一行提取所有标签。换成pd.DataFrame你得记df.iloc[0].tolist()或df.values[0]调试时多三层心智负担。凸显算法本质决策树不关心数据是存在硬盘、内存还是U盘里它只关心“如何根据特征值把样本分组”。用list强迫你直面索引操作row[feature_index]、切片left_set [row for row in dataset if row[feature_index] value]这些正是所有高级封装最终要翻译成的底层动作。当然它有代价大数据集下性能差。但请记住我们此刻的目标不是训练百万样本而是让每一行代码的意图像玻璃一样透明。当你能徒手写出[row for row in dataset if row[2] High]并秒懂其含义时再去看sklearn里_tree.pyx中那段for i in range(n_samples): if X[i, feature_idx] threshold:你就不是在读代码而是在认亲戚。2.3 停止条件设计三个硬约束如何协同工作一棵树不能无限长下去否则必过拟合。我们的手写版本设定了三个明确、可验证的停止条件它们不是凭空而来而是对应三种现实风险if len(set([row[-1] for row in dataset])) 1:所有样本标签一致。这是最理想的停止当前节点已是纯叶节点无需再分。比如数据集中所有“晴天高温高湿”的样本标签全是“否”那这个组合就是一条确定规则。if len(dataset) min_samples_split:当前节点样本数少于阈值默认设为2。这是防“小样本噪声”的安全阀。想象一个节点只剩1个样本强行分裂会造出一个空子集和一个单样本子集——后者毫无泛化能力纯属拟合噪声。if depth max_depth:递归深度超限。这是防“树太高”的物理限制。深度每1树的分支数最多翻倍内存和推理时间呈指数增长。设max_depth3意味着最多允许“天气→湿度→风速”三级判断符合人类可解释性需求。这三个条件用or连接在build_tree开头统一检查。关键在于它们的顺序不能颠倒。必须先查“是否纯节点”逻辑最优先再查“样本是否太少”数据量约束最后查“深度是否超限”结构约束。如果把深度检查放第一位可能一个纯节点因深度超限被强制截断丢失本可完美分类的规则。3. 核心函数逐行解析与实操细节3.1calculate_entropy(labels)从“混乱度”到“比特数”的直观转化import math from collections import Counter def calculate_entropy(labels): if not labels: return 0 # 统计每个类别的出现次数 label_counts Counter(labels) # 总样本数 total len(labels) # 计算香农熵-Σ(p_i * log2(p_i)) entropy 0.0 for count in label_counts.values(): probability count / total # log2(0)未定义但概率为0时p*log2(p)极限为0故跳过 if probability 0: entropy - probability * math.log2(probability) return entropy这段20行代码是整棵树的“心脏起搏器”。它不产生分支但决定了每一次分支的价值。我们逐行拆解Counter(labels)labels [Yes, Yes, No, Yes]→Counter({Yes: 3, No: 1})。这是统计学最基础动作但新手常忽略熵只与类别分布比例有关与具体标签名无关。[A,A,B,A]和[Yes,Yes,No,Yes]熵值完全相同。probability count / total算出Yes占比0.75No占比0.25。这里有个易错点如果total0空列表除法会报错。所以开头if not labels: return 0是必须的防御性编程——空节点熵定义为0既数学合理无不确定性也避免程序崩溃。entropy - probability * math.log2(probability)这是香农熵公式的直接翻译。为什么是负号因为log2(0.25) -20.25 * (-2) -0.5前面加负号得0.5。物理意义是熵是“消除不确定性所需的信息量比特数”。[3,1]分布需要约0.811比特来确定一个随机样本的类别[2,2]分布完全混乱需要1比特[4,0]分布完全确定需要0比特。实操心得我在某次调试中发现熵值异常偏高追踪发现是labels传入了包含空格的字符串[Yes , No]导致Yes 和Yes被视为不同类别。从此养成了习惯在调用calculate_entropy前对labels做[label.strip() for label in labels]清洗。这个细节教科书从不提但真实数据里空格、换行符、全角字符比比皆是。3.2split_dataset(dataset, feature_index, value)一次“物理切割”的完整过程def split_dataset(dataset, feature_index, value): 将数据集按指定特征索引和取值二分 left_set该特征值等于value的样本 right_set该特征值不等于value的样本 left_set [] right_set [] for row in dataset: if row[feature_index] value: left_set.append(row) else: right_set.append(row) return left_set, right_set这个函数名字叫“split”但它干的其实是一次布尔过滤。注意它不是按大小切分那是CART回归树的做法而是按相等性切分——这是ID3/C4.5处理离散特征的标准方式。关键细节feature_index是整数如0代表第一列天气1代表第二列温度。新手常误传字符串weather导致row[weather]报错。我们坚持用索引因为真实数据加载后列名信息往往丢失只剩位置。value是具体值如Sunny。这里隐含一个假设特征必须是离散的categorical。如果遇到连续特征如温度36.5此函数会失效——你需要先离散化如Highif temp30 elseLow或改用CART的阈值切分逻辑。我们在后续扩展中会演示如何兼容。注意这个函数返回两个新列表不修改原dataset。这是函数式编程思想——避免副作用。好处是调试时可多次用同一dataset测试不同value坏处是内存占用翻倍。对于教学版可读性优先于内存效率。3.3find_best_split(dataset)暴力搜索中的数学智慧def find_best_split(dataset): best_gain 0.0 best_question None # (feature_index, value) best_sets None # (left_set, right_set) # 当前节点熵 current_entropy calculate_entropy([row[-1] for row in dataset]) n_features len(dataset[0]) - 1 # 特征数 总列数 - 1最后一列是标签 # 遍历每个特征 for feat_idx in range(n_features): # 获取该特征所有唯一取值 values set([row[feat_idx] for row in dataset]) # 遍历每个取值尝试以此切分 for value in values: left_set, right_set split_dataset(dataset, feat_idx, value) # 跳过无效切分任一子集为空 if len(left_set) 0 or len(right_set) 0: continue # 计算加权信息增益 p_left len(left_set) / len(dataset) p_right len(right_set) / len(dataset) gain current_entropy - ( p_left * calculate_entropy([row[-1] for row in left_set]) p_right * calculate_entropy([row[-1] for row in right_set]) ) # 更新最优解 if gain best_gain: best_gain gain best_question (feat_idx, value) best_sets (left_set, right_set) return best_gain, best_question, best_sets这是全篇最“重”的函数25行代码承载了决策树的全部智能。我们聚焦三个易被忽略的实操要点第一values set([...])的陷阱set去重没错但如果特征是浮点数如[1.0, 1.0000001, 1.0]set会视为三个不同值导致搜索空间爆炸。真实项目中应对连续特征先做round(value, 2)或用np.unique(np.round(feature_values, 2))。教学版我们限定为字符串/整数规避此问题。第二if len(left_set) 0 or len(right_set) 0: continue这是防止“切偏”的安全检查。比如用valueRainy切一个全是Sunny的数据集left_set[]此时增益无意义除零风险必须跳过。新手常漏掉此检查导致best_gain保持初始0.0树无法生长。第三信息增益公式中的“加权”gain current_entropy - (p_left * entropy_left p_right * entropy_right)。权重p_left和p_right是子集大小占比不是简单平均。为什么因为[1000, 0]和[1, 1]两个子集前者主导模型性能必须赋予更高权重。我曾见有人写成gain current_entropy - (entropy_left entropy_right)/2结果树总选样本数少的切分——数学上完全错误。实操心得在调试find_best_split时我习惯在循环内加一句print(fDEBUG: feat{feat_idx}{value} - gain{gain:.3f})。当看到feat1Hot - gain0.012而feat0Sunny - gain0.278时立刻明白为何树选了天气而非温度。这种“所见即所得”的调试是理解算法决策路径的最快方式。3.4build_tree(dataset, depth0)递归的艺术与边界控制def build_tree(dataset, depth0, min_samples_split2, max_depth3): # 停止条件1所有标签相同 labels [row[-1] for row in dataset] if len(set(labels)) 1: return {type: leaf, label: labels[0], samples: len(dataset)} # 停止条件2样本数不足 if len(dataset) min_samples_split: # 返回多数类标签 most_common Counter(labels).most_common(1)[0][0] return {type: leaf, label: most_common, samples: len(dataset)} # 停止条件3深度超限 if depth max_depth: most_common Counter(labels).most_common(1)[0][0] return {type: leaf, label: most_common, samples: len(dataset)} # 寻找最优切分 gain, question, (left_set, right_set) find_best_split(dataset) # 如果无有效增益转为叶节点 if gain 0: most_common Counter(labels).most_common(1)[0][0] return {type: leaf, label: most_common, samples: len(dataset)} # 递归构建左右子树 feature_idx, value question left_branch build_tree(left_set, depth 1, min_samples_split, max_depth) right_branch build_tree(right_set, depth 1, min_samples_split, max_depth) return { type: node, question: (feature_idx, value), left: left_branch, right: right_branch, samples: len(dataset) }这个函数是决策树的“骨架生成器”它把前面所有函数串联成有机整体。重点解析四个精妙设计1. 叶节点的统一返回结构无论因哪种原因停止都返回{type: leaf, label: ..., samples: ...}。这种结构化设计让后续的print_tree可视化函数只需判断type字段无需关心停止原因。工程上统一接口比分散逻辑更易维护。2.gain 0的兜底处理find_best_split可能返回gain0如所有切分都让子集熵不变此时强行分裂无意义。这个检查是防止“假分裂”的最后一道闸门。有趣的是gain0和len(set(labels))1效果相同但前者发生在分裂前后者是分裂后的状态确认——二者互补覆盖所有纯化场景。3. 多数类投票的鲁棒性当因样本少或深度超限而提前终止时叶节点不随便猜而是用Counter(labels).most_common(1)[0][0]投出多数票。这比随机选或固定选第一个更稳健。例如[Yes,Yes,No]返回Yes准确率66.7%。4. 递归调用的参数传递build_tree(left_set, depth 1, ...)中depth 1是核心。它像一个计步器记录当前节点在树中的层级。没有它max_depth检查将失效。我见过有人写成build_tree(left_set, depth, ...)结果树无限深——因为深度永远不增加。4. 完整实操流程与核心环节实现4.1 准备一份真实的教学数据集天气预测示例我们不用抽象的X, y而用经典的“天气是否适合打网球”数据集。它小而典型共14行4个离散特征1个二元标签天气温度湿度风速是否打球SunnyHotHighWeakNoSunnyHotHighStrongNoOvercastHotHighWeakYesRainMildHighWeakYesRainCoolNormalWeakYesRainCoolNormalStrongNoOvercastCoolNormalStrongYesSunnyMildHighWeakNoSunnyCoolNormalWeakYesRainMildNormalWeakYesSunnyMildNormalStrongYesOvercastMildHighStrongYesOvercastHotNormalWeakYesRainMildHighStrongNo将其转化为Python列表注意字符串间无空格大小写严格dataset [ [Sunny, Hot, High, Weak, No], [Sunny, Hot, High, Strong, No], [Overcast, Hot, High, Weak, Yes], [Rain, Mild, High, Weak, Yes], [Rain, Cool, Normal, Weak, Yes], [Rain, Cool, Normal, Strong, No], [Overcast, Cool, Normal, Strong, Yes], [Sunny, Mild, High, Weak, No], [Sunny, Cool, Normal, Weak, Yes], [Rain, Mild, Normal, Weak, Yes], [Sunny, Mild, Normal, Strong, Yes], [Overcast, Mild, High, Strong, Yes], [Overcast, Hot, Normal, Weak, Yes], [Rain, Mild, High, Strong, No] ]提示复制此数据时请用文本编辑器检查末尾是否有隐藏空格或制表符。我曾因No 带空格和No被视为不同标签导致熵计算错误调试两小时才发现。4.2 从零开始构建逐行执行与中间状态验证现在打开你的Python编辑器推荐VS Code开启Python调试新建id3_handson.py按顺序敲入以下代码并在关键行后加print语句# 第1步定义所有函数copy上面4个函数 import math from collections import Counter def calculate_entropy(labels): # ...同上 def split_dataset(dataset, feature_index, value): # ...同上 def find_best_split(dataset): # ...同上 def build_tree(dataset, depth0, min_samples_split2, max_depth3): # ...同上 # 第2步加载数据 dataset [ ... ] # 粘贴上面14行数据 # 第3步手动验证calculate_entropy labels [row[-1] for row in dataset] print(fDEBUG: 全局标签分布 {Counter(labels)}) # Counter({Yes: 9, No: 5}) print(fDEBUG: 全局熵 {calculate_entropy(labels):.3f}) # 应为0.940 # 第4步手动验证split_dataset # 尝试用天气Sunny切分 sunny_set, not_sunny_set split_dataset(dataset, 0, Sunny) print(fDEBUG: Sunny样本数 {len(sunny_set)}) # 5 print(fDEBUG: Sunny标签 {[row[-1] for row in sunny_set]}) # [No, No, No, No, Yes] # 第5步手动验证find_best_split gain, question, sets find_best_split(dataset) print(fDEBUG: 最优增益 {gain:.3f}) # 应为0.246 print(fDEBUG: 最优问题 特征{question[0]}{question[1]}) # 特征0Outlook天气执行后你应该看到DEBUG: 全局标签分布 Counter({Yes: 9, No: 5}) DEBUG: 全局熵 0.940 DEBUG: Sunny样本数 5 DEBUG: Sunny标签 [No, No, No, No, Yes] DEBUG: 最优增益 0.246 DEBUG: 最优问题 特征0Sunny如果gain不是0.246请检查dataset是否14行无误labels提取是否用了row[-1]正确而非row[4]危险列数变化时崩split_dataset是否严格用而非in或模糊匹配4.3 构建并可视化整棵树让逻辑“看得见”有了build_tree我们还需要一个print_tree函数把嵌套字典变成人眼可读的树状图def print_tree(node, spacing): if node[type] leaf: print(spacing Predict, node[label], f(samples{node[samples]})) return # 打印问题例如 Is weather Sunny? feature_names [Weather, Temp, Humidity, Wind] feature_idx, value node[question] print(spacing fIs {feature_names[feature_idx]} {value}?) # 递归打印子树 print(spacing -- True:) print_tree(node[left], spacing ) print(spacing -- False:) print_tree(node[right], spacing ) # 构建树并打印 my_tree build_tree(dataset, max_depth2) print( 决策树结构 ) print_tree(my_tree)执行后你将看到类似 决策树结构 Is Weather Sunny? -- True: Is Humidity High? -- True: Predict No (samples3) -- False: Predict Yes (samples2) -- False: Is Weather Overcast? -- True: Predict Yes (samples4) -- False: Predict Yes (samples5)这就是决策树的“思维过程”。每一行Is...?是一个决策节点Predict是叶节点结论。注意samples数根节点14第一层True分支5SunnyFalse分支9非Sunny加起来14——数据守恒证明切分无误。实操心得print_tree的缩进用空格而非\t因为不同编辑器对\t渲染宽度不一会导致树形错乱。我固定用2个空格 这样在任何终端都对齐。这个细节让调试体验提升50%。4.4 用树做预测从“建模”到“推理”的闭环树建好了怎么用它预测新样本写一个classify函数def classify(row, tree): if tree[type] leaf: return tree[label] feature_idx, value tree[question] if row[feature_idx] value: return classify(row, tree[left]) else: return classify(row, tree[right]) # 测试预测 test_sample [Sunny, Cool, Normal, Weak] prediction classify(test_sample, my_tree) print(fTest: {test_sample} - Predict: {prediction}) # 应为Yes这里的关键是预测过程是建树过程的镜像。建树时split_dataset把数据按 value分到左右预测时if row[feature_idx] value走左否则走右。逻辑完全对称。验证[Sunny, Cool, Normal, Weak]第一层Weather Sunny? 是 → 走左子树第二层Humidity High? 否它是Normal→ 走右子树 →Predict Yes结果正确。这个闭环证明你不仅读懂了代码更掌握了决策树的因果链。5. 常见问题与排查技巧实录5.1 “信息增益总是0”四大根源与定位方法这是新手最高频的报错表现为树只有一层根节点直接变叶节点find_best_split返回gain0。按优先级排查问题根源表现特征快速定位命令解决方案特征值类型不一致[Sunny, sunny, SUNNY]被当3个不同值print(set([row[0] for row in dataset]))统一row[0].lower().strip()标签列索引错误row[-1]取到特征而非标签print([row[-1] for row in dataset[:3]])确认数据最后一列是标签或显式用label_col4空数据集传入dataset[]导致len(dataset)0print(fDataset size: {len(dataset)})在build_tree开头加if not dataset: return {type:leaf, label:Unknown}浮点数精度误差连续特征30.0000001 ! 30.0print([row[1] for row in dataset])对连续特征round(row[1], 1)或改用CART阈值切分我踩过的坑某次数据从Excel导出温度列被自动转为30.0float而代码中value30int30.0 30在Python中为True但30.0 30为False。根源是数据类型混淆。从此我养成习惯加载后立即print(type(dataset[0][1]))。5.2 “RecursionError: maximum recursion depth exceeded”深度失控的三种场景递归爆栈不是代码错是逻辑没控住。常见于min_samples_split设为1节点只剩1个样本时仍尝试分裂split_dataset返回([], [sample])right_set非空build_tree再次调用自己无限循环。✅ 解决min_samples_split至少为2且确保split_dataset对len(dataset)1返回([sample], [])或([], [sample])让len(left_set)0触发continue。特征值全相同某特征列全是Sunnysplit_dataset对任意value!Sunny返回( [], dataset )right_set非空但gain0build_tree无退出路径。✅ 解决在find_best_split末尾加if best_gain 0: return 0, None, None强制返回无效解让build_tree捕获后转叶节点。max_depth未传递调用build_tree(dataset)时漏写max_depth3默认depth0但函数签名def build_tree(..., max_depth3)中max_depth是默认参数必须显式传入或确保默认值生效。✅ 解决统一调用build_tree(dataset, max_depth3)不依赖默认值。5.3 “预测结果全一样”数据与逻辑的双重校验表当所有测试样本都预测为Yes说明树未学到有效规则。按此表逐项核对检查项正确表现异常表现排查命令标签分布是否失衡Yes:9, No:5合理Yes:13, No:1严重失衡print(Counter([row[-1] for row in dataset]))最优切分是否真有增益gain0.246 0gain0.000在find_best_split末尾print(fFinal gain{best_gain})叶节点是否多数投票samples5, labelNo3个Nosamples5, labelYes但实际3个No在build_tree的叶节点返回前print(fLeaf: {Counter(labels)} - {most_common})预测路径是否走对分支row[0]Sunny→ 走leftrow[0]Sunny→ 因失败走right在classify函数中print(fQ:{question}, Val:{row[feature_idx]}, Match:{row[feature_idx]value})独家技巧用pdb调试器单步执行classify。在VS Code中光标停在classify函数第一行按F5启动调试用F10逐行F11进入函数p tree[question]查看当前问题p row[0]看样本值——比print高效十倍。5.4 手写版与sklearn版结果差异不是Bug是设计哲学当你用同样数据训练sklearn.tree.DecisionTreeClassifier(criterionentropy, max_depth2)发现树结构和手写版不同别慌。这是五点根本差异维度手写ID3版sklearn版为什么不同特征选择策略暴力遍历所有特征所有取值用BestFirst启发式可能跳过低增益分支sklearn优化性能手写版保证穷举**缺失

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑