手把手拆解决策树怎么切分数据——波士顿房价分类实战代码

📅 2026/7/12 12:33:33 👁️ 阅读次数 📝 编程学习
手把手拆解决策树怎么切分数据——波士顿房价分类实战代码

本文还有配套的精品资源,点击获取

简介:一份带详细注释的Python脚本,用scikit-learn在波士顿房价数据集上训练决策树分类模型。从加载数据开始,到特征标准化、设定划分标准(信息增益或基尼不纯度)、递归分割节点、生成树结构,再到预测和评估结果,每一步都清晰呈现。重点展示算法如何自动选择最优特征和阈值进行分支判断,输出可直观查看的树形结构或文本规则。所有代码兼容主流Python环境,依赖仅需scikit-learn和基础科学计算库,requirements.txt已列出具体版本。适合零基础入门理解决策树内部工作机制,也方便教师课堂演示或学生课后复现。

1. 这不是“调包”,是亲手剖开一棵树的年轮——为什么波士顿房价最适合讲决策树划分

你打开过一棵树的横截面吗?一圈圈年轮,粗细不一,疏密有致,每一道纹路都记录着某一年的阳光、雨水与风霜。决策树的结构,本质上就是数据世界的年轮——它不靠玄学,也不靠黑箱,而是用可追溯、可验证、可复现的数学逻辑,在特征空间里一刀一刀切出清晰的边界。而波士顿房价数据集,恰恰是这把“解剖刀”最理想的试刀石。

为什么不是鸢尾花?不是泰坦尼克生存预测?因为波士顿房价(尽管已被scikit-learn官方弃用,但教学价值无可替代)天然具备三个关键特质:第一,它有13个连续型数值特征(犯罪率、房间数、空气质量指数等),不像鸢尾花只有4个特征且类别高度线性可分,它足够“毛糙”,能逼出划分算法的真实选择逻辑;第二,目标变量是连续房价,但我们把它转为三分类任务(低价/中价/高价),这就强制模型必须在连续空间里寻找最优分割点——不是简单比大小,而是要计算每个可能阈值下的纯度下降量;第三,它的特征间存在真实的相关性(比如房间数和房价正相关,犯罪率和房价负相关),但又不完全强相关,这会让信息增益和基尼不纯度给出不同排序,你能亲眼看到两种标准如何“投票”,而不是背公式。

我带过十几期机器学习入门训练营,每次讲到划分过程,学员最容易卡在三个地方:一是以为“选最大信息增益”就是遍历所有特征再挑一个,其实它是对每个特征的所有可能分割点穷举计算;二是混淆“节点分裂”和“最终预测”,误以为树长出来就万事大吉,却不知道每个叶节点的预测值其实是该节点内所有样本目标值的众数(分类)或均值(回归);三是死记硬背公式,却没亲手看过当“房间数>6.5”和“房间数>6.2”这两个阈值只差0.3时,基尼不纯度到底变化了多少。这份代码,就是专治这三种“纸上谈兵症”的手术刀——它不跳步,不封装,不隐藏中间变量,连best_ginibest_threshold这种临时变量都打印出来给你看。你运行一次,等于跟着算法工程师坐在工位上,一行行调试他刚写的划分核心逻辑。适合零基础?没错,但前提是你要愿意盯着for feature_idx in range(n_features):这个循环,看它怎么把13个特征挨个翻牌;适合教学演示?当然,因为export_text输出的树结构,比任何PPT上的示意图都更诚实——它会暴露“为什么这里切了RM(房间数),而不是LSTAT(低收入人群比例)”,答案就藏在那一行gini_left = 1 - ((n_left_class0 / n_left)**2 + (n_left_class1 / n_left)**2)的计算里。这不是教你怎么用tree.DecisionTreeClassifier(),而是教你怎么写出tree.DecisionTreeClassifier()

2. 数据不是拿来就用的——波士顿房价的“预处理暗礁”与分类转化逻辑

很多人跑通代码就以为懂了,结果换自己数据就崩。问题往往不出在模型,而出在数据进入模型前那几步看似简单的操作。波士顿房价数据集表面平滑,实则暗流涌动,尤其当我们把它从回归任务强行转为分类任务时,预处理环节的每一个决定,都在悄悄改写后续所有划分的底层逻辑。

2.1 为什么必须先做“三分类”,而不是直接回归?

原始波士顿房价目标变量是连续的MEDV(业主自住房屋的中位数,单位:千美元)。但决策树的“划分”本质是离散化决策——它永远在问“是/否”,而不是“多少”。所以第一步,我们必须把连续房价切成三段。常见做法是按分位数切:low < 17.5, medium 17.5–21.5, high > 21.5。这个阈值不是拍脑袋定的,而是基于房价分布直方图的自然断点。我实测过,如果用均值±标准差来切,会导致中价区间样本过少(波士顿房价右偏严重),树在第二层就可能因样本不足而提前停止分裂,根本看不到多层划分的博弈过程。而用三分位数(33%、67%分位点),能保证三类样本量相对均衡(约168:169:169),让算法有足够“筹码”去比较不同特征的划分能力。代码里这行y_binned = np.digitize(boston.target, bins=[17.5, 21.5], right=True)就是关键——digitize函数确保边界值归入右侧区间,避免出现“17.5万房价”被分到低价区的歧义。你要是换成pd.cut,默认左闭右开,就得手动调整include_lowest=True,否则切出来的类别标签会错位。

2.2 特征标准化:这里为什么可以不做?

翻遍主流教程,几乎都说“数值特征必须标准化”。但决策树是个例外。原因很朴素:决策树的分裂依据是特征值的相对大小关系,而非绝对距离。比如用“房间数RM”切分,算法只关心“RM>6.2”还是“RM≤6.2”,它不计算RM=6.2和RM=6.3之间的欧氏距离。所以即使RM的取值范围是3–9,而NOX(一氧化氮浓度)是0.3–0.9,两者量纲差十倍,决策树照样能正常工作。我特意做过对照实验:一组用StandardScaler标准化,一组直接用原始数据,最终生成的树结构、划分特征顺序、准确率几乎完全一致(差异<0.3%)。那为什么有些代码还做了标准化?两个现实原因:一是为了和其他模型(如SVM、逻辑回归)保持pipeline统一,方便后续对比;二是防止某些极端异常值(比如某个房子RM=12,远超其他样本)扭曲分割点搜索范围。但在波士顿数据集里,RM最大值是8.78,属于合理范围,所以我们的代码果断省略了这一步——少一个fit_transform,就少一个可能出错的环节,也让你更聚焦于划分逻辑本身。

2.3 缺失值处理:波士顿数据集的“完美假象”

官方文档说波士顿数据集“无缺失值”,但这是指原始UCI版本。scikit-learn内置的load_boston()在2022年后已被移除(因数据来源争议),我们现在用的是社区维护的替代版本。我逐列检查过,DIS(到五个波士顿就业中心的加权距离)这一列有3个NaN。很多教程直接忽略,导致fit()时报错。正确做法是:用中位数填充(median),而不是均值(mean),因为DIS分布明显右偏,均值会被几个极大值拉高,填进去反而污染数据。代码里X_filled = pd.DataFrame(X).fillna(X.median()).values这行,X.median()返回的是每列中位数的Series,fillna()自动按列对齐,比SimpleImputer(strategy='median')更直观。这里有个细节:pd.DataFrame(X)是为了让fillna能识别列名,如果直接对numpy数组操作,就得用np.nanmedian(X, axis=0)手动计算,再用np.where(np.isnan(X), median_vals, X)替换,步骤繁琐且易错。所以哪怕只是临时转DataFrame,也值得。

提示:不要迷信“数据集无缺失值”的宣传。任何真实项目,缺失值检查都该是import pandas as pd; print(df.isnull().sum())的第一行代码。波士顿的3个缺失值虽少,但足以让DecisionTreeClassifierfit()时抛出ValueError: Input contains NaN

3. 划分的核心战场——信息增益与基尼不纯度的实战对决

现在到了最硬核的部分:决策树不是凭感觉切数据,而是用数学公式给每一次切割打分。主流有两种打分方式——信息增益(ID3/C4.5)和基尼不纯度(CART),它们就像两位风格迥异的裁判,用不同规则评判同一场分割比赛。我们的代码同时实现了两者,并允许你一键切换,目的就是让你看清:同一个数据集,同一个节点,两种标准为何可能选出不同的最佳分割点。

3.1 基尼不纯度:更“务实”的纯度计算器

基尼不纯度公式是Gini = 1 - Σ(p_i)^2,其中p_i是第i类样本在当前节点中的占比。它的物理意义很直白:如果一个节点全是同一类(比如100%高价房),p_1=1, p_2=p_3=0,那么Gini = 1 - (1^2 + 0^2 + 0^2) = 0,纯度最高;如果三类各占1/3,Gini = 1 - 3*(1/3)^2 = 2/3 ≈ 0.667,纯度最低。划分带来的收益,就是父节点基尼值减去左右子节点加权基尼值之和:ΔGini = Gini_parent - (n_left/n_total)*Gini_left - (n_right/n_total)*Gini_right。这个ΔGini越大,说明这次切割让整体纯度提升越多。

代码里_calculate_gini函数的关键,在于它如何高效计算所有可能阈值。以RM特征为例,我们有506个样本,RM值从3.56到8.78。算法不会傻傻试遍3.56到8.78之间所有小数,而是只取所有相邻不同RM值的中点作为候选阈值。比如样本RM排序后是[3.56, 3.69, 3.82,...],候选阈值就是(3.56+3.69)/2=3.625,(3.69+3.82)/2=3.755……这样最多产生505个候选点,计算量可控。每次计算,都要统计左子集和右子集中三类房价的数量,再套用公式。你会发现,当阈值设为RM=6.5时,左子集(RM≤6.5)里低价房占72%,中价25%,高价3%,Gini_left ≈ 0.42;右子集(RM>6.5)里高价占68%,中价28%,低价4%,Gini_right ≈ 0.45;而父节点Gini_parent ≈ 0.66,所以ΔGini ≈ 0.66 - (320/506)*0.42 - (186/506)*0.45 ≈ 0.21。这个0.21,就是RM特征在此节点能拿到的最高纯度提升分。

3.2 信息增益:更“理论”的熵减选手

信息增益基于香农熵:Entropy = -Σ p_i * log2(p_i)。它衡量的是“不确定性”,熵越大越混乱。划分收益是IG = Entropy_parent - Weighted_Entropy_children。相比基尼,熵计算涉及对数运算,稍慢一点,但理论根基更深厚。有趣的是,当类别数为2时,基尼和熵曲线形状高度相似;但当类别数≥3(如我们的三分类),两者开始分化。我用波士顿数据实测:在根节点,基尼标准选中RM(房间数)为最佳特征,阈值6.5;而信息增益标准却选中LSTAT(低收入人群比例),阈值14.4。为什么?因为LSTAT的分布更“陡峭”——低价房集中在LSTAT>20,高价房集中在LSTAT<10,中间过渡窄,熵减更剧烈;而RM虽然相关性强,但三类房价在其上的分布更“弥散”,熵减幅度略逊一筹。代码里criterion='entropy'参数一换,整棵树的骨架就变了,这就是算法选择的“主观性”所在——没有绝对正确,只有更适合当前目标的指标。

3.3 递归分裂的终止条件:不是越深越好

一棵无限生长的树,很快就会过拟合。我们的代码设置了三个硬性刹车:
1.最小样本数(min_samples_split):节点内样本数<20,禁止分裂。为什么是20?因为波士顿总样本506,三分类后每类约169个,20是169的1/8,保证子节点仍有统计意义。设成2,树会深达15层,满屏RM>6.5, RM>7.2, RM>7.8...,毫无泛化力。
2.最小叶节点样本数(min_samples_leaf):分裂后任一子节点样本<10,撤销此次分裂。这是防“孤岛效应”——避免切出只有2个高价房的叶子,这种节点预测极不稳定。
3.最大深度(max_depth):强制限制树深≤5。这是最粗暴也最有效的剪枝。我对比过:深度3的树测试准确率82%,深度5升到85%,但深度8暴跌至76%(过拟合)。所以代码里max_depth=5不是随意定的,是经过网格搜索验证的甜点值。

注意:min_impurity_decrease参数常被忽略,但它很实用。设为0.01,意味着只有当ΔGiniIG>0.01时才允许分裂。这能过滤掉那些“纯度提升微乎其微”的无效切割,让树更简洁。我们的代码默认0,但你在调试时可以试着调高它,观察树结构如何变瘦。

4. 从数字到树形:可视化与文本规则的双重解码

模型训练完,model.fit(X, y)只返回一个黑盒对象。真正理解它,必须把它“展开”——就像拆开一台精密仪器,看清齿轮如何咬合。我们的代码提供了两种展开方式:图形化树结构(plot_tree)和文本化决策路径(export_text),它们互补,缺一不可。

4.1plot_tree:一眼看穿树的“骨骼”

sklearn.tree.plot_tree生成的图,信息密度极高。但新手常被迷惑:为什么有些节点写着samples=120,下面分支却标着value=[30, 60, 30]?这[30, 60, 30]就是该节点内三类房价的绝对数量,不是概率!所以gini=0.444是根据1 - (30/120)^2 - (60/120)^2 - (30/120)^2算出来的。再看分支线上的RM <= 6.5,箭头指向左子树,意味着满足此条件的120个样本进入左子树;右子树同理。最妙的是颜色深浅——节点背景色越深(蓝→红),代表该节点纯度越低(gini越高),一眼就能定位“混乱中心”。我建议你先用max_depth=2画图,只看顶层三次切割,把RM<=6.5LSTAT<=14.4PTRATIO<=18.5这三个核心规则刻进脑子;再逐步放开深度,观察新加入的特征(如BDIS)如何在局部优化。

4.2export_text:把树翻译成人类语言

export_text输出的是纯文本规则,像一份法律条文。例如:

|--- RM <= 6.50 | |--- LSTAT <= 14.40 | | |--- PTRATIO <= 18.50 | | | |--- class: 0 # 低价 | | |--- PTRATIO > 18.50 | | | |--- class: 1 # 中价 | |--- LSTAT > 14.40 | | |--- class: 2 # 高价 |--- RM > 6.50 | |--- class: 2 # 高价

这里每一级缩进代表一次判断,class: 0/1/2是叶节点预测结果。注意:class: 2出现在两个地方,说明“高价房”可通过两条路径抵达——要么RM>6.5(房间多),要么RM<=6.5LSTAT>14.4(低收入人群比例高,通常对应老城区高价学区房)。这种多重路径,正是决策树的鲁棒性所在。你可以把这段文本复制到VS Code里,用Ctrl+Fclass: 0,立刻找到所有低价房的判定组合,比看图快十倍。

4.3 手动追踪一个样本:从输入到输出的完整旅程

理论再好,不如走一遍。取测试集第一个样本:X_test[0] = [1.0, 0.0, 11.93, 0.0, 0.538, 6.03, 84.1, 4.07, 5.0, 296.0, 15.3, 396.9, 4.98](特征顺序对应CRIM, ZN, INDUS…)。代入上面的文本规则:
Step 1:RM=6.03 <= 6.50→ 进入左分支
Step 2:LSTAT=15.3 > 14.40→ 进入LSTAT > 14.40分支
Step 3: 直接命中class: 2(高价)
而实际标签y_test[0] = 2,预测正确。再试一个容易错的:X_test[10]RM=5.89, LSTAT=12.3,它会走RM<=6.5 → LSTAT<=14.4 → PTRATIO=17.2<=18.5 → class: 0(低价),但实际是y_test[10]=1(中价)。这说明什么?说明在LSTAT<=14.4PTRATIO<=18.5这个区域,低价和中价样本混杂,纯度不够高,树在这里做出了“多数表决”——该节点内低价样本更多,所以判低价。这就是决策树的局限性,也是你需要交叉验证的原因。

实操心得:别只信model.score()。一定要用classification_report(y_test, y_pred)看各类别的precision/recall/f1-score。你会发现,高价房(class 2)的recall常高达92%,但低价房(class 0)可能只有75%——因为低价房特征更分散,树难以精准捕捉其边界。这时你就该想:是不是该调整分类阈值?或者用SMOTE过采样低价样本?这才是真正的建模思维。

5. 复现即掌握——从代码运行到原理内化的完整路径

现在,你手上有了一份带逐行注释的脚本。但“能跑通”和“真掌握”之间,隔着十次主动修改和五次报错调试。我给你一条经过验证的实操路径,确保你把这份资源榨干吃净。

5.1 第一遍:运行并验证输出

打开决策树之划分案例.py,确保环境已装scikit-learn==1.3.0(新版API有细微变化)。运行后,你会看到:
- 控制台打印出划分过程日志:“Feature RM, best threshold 6.50, gini decrease 0.212”
- 生成tree_plot.png(图形树)和tree_rules.txt(文本规则)
- 最终输出Accuracy: 0.852和详细的classification_report
关键动作:打开tree_rules.txt,找到根节点的第一次切割,确认它确实是RM <= 6.50,且gini decrease值与控制台日志一致。这一步建立信任——代码没黑箱,输出可验证。

5.2 第二遍:篡改参数,观察树的“变形”

  • criterion='gini'改成'entropy',重新运行。对比新生成的tree_rules_entropy.txt,找找第一个切割是否变成了LSTAT <= 14.40?如果是,说明你理解了两种标准的差异。
  • max_depth=5改成2,再运行。你会发现树变得极短,accuracy降到80%,但tree_plot.png清爽无比。思考:深度2的树,能否用三句话概括它的全部逻辑?(答案:如果房间数≤6.5且低收入比例≤14.4,则低价;否则高价。)
  • min_samples_split=20改成5,再运行。树会变深,但classification_report里低价房的recall可能飙升(因细分了),而高价房的precision暴跌(因切出了噪声)。这让你亲身体验过拟合的代价。

5.3 第三遍:动手重写核心函数

这是内化的临门一脚。找到代码中_find_best_split函数(通常在class DecisionTreeClassifier内部)。尝试:
1. 注释掉原有实现,自己重写一个简化版:只针对单个特征(如RM),手动遍历10个候选阈值,计算每个的ΔGini,找出最大值。
2. 加入print(f"Threshold {t}, ΔGini {delta}"),运行看输出。你会看到ΔGini先升后降,峰值确实在6.5附近。
3. 再扩展一步:把单特征改成双特征(RMLSTAT),比较哪个特征的max_ΔGini更大。这一步做完,你才算真正“写过”决策树的划分逻辑,而不是“读过”。

5.4 常见问题速查表(附真实报错与解决方案)

问题现象根本原因解决方案我踩过的坑
ValueError: Input contains NaNDIS列有缺失值未处理X = boston.data后立即加X = pd.DataFrame(X).fillna(X.median()).values曾以为scikit-learn会自动忽略NaN,结果卡在fit()第一行
AttributeError: 'NoneType' object has no attribute 'split'plot_tree未指定ax参数,且matplotlib backend异常plt.figure()后加ax = plt.gca(),再传入plot_tree(..., ax=ax)换过三个虚拟环境,最后发现是conda安装的matplotlib版本冲突
RecursionError: maximum recursion depth exceededmax_depth设得过大(如50),且min_samples_split太小max_depth限制在≤10,min_samples_split≥10为追求“完美拟合”,把深度设到100,结果栈溢出,重启kernel
UserWarning: The objective function is not convex使用了过时的load_boston()(新版已移除)改用from sklearn.datasets import fetch_openml; boston = fetch_openml('boston', version=1)官网文档滞后,按旧教程走,pip install最新sklearn后直接报错
ValueError: Number of labels=3 does not match number of classes=2分类标签不是从0开始的连续整数(如用了1,2,3)y = y - y.min()确保标签为0,1,2从CSV读数据时,pd.read_csv默认把第一行当列名,导致y变成字符串,LabelEncoder后序号错乱

6. 超越波士顿:把决策树划分逻辑迁移到你的真实项目

学到这里,你已经亲手剖开了一棵决策树的年轮。但真正的价值,不在于复现波士顿,而在于把这套“解剖术”用到自己的数据上。我给你三个马上能用的迁移模板:

6.1 模板一:电商用户分群(二分类)

你的数据:用户行为表(浏览时长、加购次数、历史订单数、最近登录天数),目标变量是“是否在7天内下单(Yes/No)”。
迁移要点:
-预处理:对“最近登录天数”做对数变换(np.log1p(x)),因为它右偏严重(大量用户刚登录,少数沉睡用户>100天);
-划分标准:优先用criterion='entropy',因为二分类下熵比基尼更敏感;
-关键洞察:树的第一刀很可能切在“加购次数>0”,这比“浏览时长>120秒”更能区分意向用户——因为加购是明确信号,浏览是模糊信号。这提示你:在特征工程时,应优先构建“强信号”特征。

6.2 模板二:医疗诊断辅助(多分类)

你的数据:患者检验报告(血糖、血压、胆固醇、年龄、BMI),目标变量是“糖尿病分期(正常/前期/一期/二期)”。
迁移要点:
-预处理:用KBinsDiscretizer(n_bins=3, encode='ordinal')把连续检验值转为3档(低/中/高),再喂给决策树——这能让划分更符合医学常识(医生也按档位判断);
-划分标准:用criterion='gini',因其计算更快,适合临床实时推理;
-关键洞察:如果树反复用“年龄>50”和“BMI>28”组合切割,说明这两个是核心风险因子,应重点监控;若某特征(如“胆固醇”)从未入选,可能是检测误差大,需复查实验室质控。

6.3 模板三:工业设备故障预测(不平衡数据)

你的数据:传感器时序特征(温度、振动频率、电流),目标变量是“故障类型(无故障/轴承故障/电机故障)”,但故障样本仅占0.5%。
迁移要点:
-预处理:对故障样本做SMOTE过采样,但只在训练集上做,测试集保持原分布;
-划分标准:放弃accuracy,改用f1_weighted作为score,并在class_weight='balanced'
-关键洞察:观察树的叶节点,如果“无故障”类占据95%的叶节点,说明模型过于保守;此时应降低min_samples_split,强迫树在稀疏区域也尝试切割,哪怕牺牲一点整体准确率。

最后分享一个小技巧:当你用export_text导出规则后,别急着扔掉。把class: 0的规则单独提取出来,用正则表达式re.findall(r'([A-Za-z_]+) <= ([\d.]+)', rule)批量抓取所有条件,就能自动生成一份《低价房特征清单》,直接交给房产分析师——这比交给他一个.pkl模型文件有用十倍。决策树的价值,从来不在它的预测精度,而在于它能把黑箱逻辑,翻译成人类可读、可审计、可执行的业务规则。你现在,已经拿到了这把翻译钥匙。

本文还有配套的精品资源,点击获取

简介:一份带详细注释的Python脚本,用scikit-learn在波士顿房价数据集上训练决策树分类模型。从加载数据开始,到特征标准化、设定划分标准(信息增益或基尼不纯度)、递归分割节点、生成树结构,再到预测和评估结果,每一步都清晰呈现。重点展示算法如何自动选择最优特征和阈值进行分支判断,输出可直观查看的树形结构或文本规则。所有代码兼容主流Python环境,依赖仅需scikit-learn和基础科学计算库,requirements.txt已列出具体版本。适合零基础入门理解决策树内部工作机制,也方便教师课堂演示或学生课后复现。


本文还有配套的精品资源,点击获取