简介:面向数据挖掘与机器学习初学者的鸢尾花分类决策树实验代码,采用C++语言完整实现从数据读取、预处理到模型训练与预测的流程。实验基于经典的鸢尾花数据集,利用花瓣长度、花瓣宽度、萼片长度、萼片宽度四个特征判别Setosa、Versicolour、Virginica三种类别。压缩包共1个文件,为3KB的cpp源码,代码中划分了数据读取与预处理、决策树模型定义、最佳分裂标准计算、递归建树及预测等模块,便于逐步理解算法细节。目前已有3413人浏览学习。通过这份代码,读者可以掌握信息增益、基尼不纯度等分裂准则的C++实现方式,并体会决策树处理多分类问题的完整工程过程;相比单纯调库,手写算法更能加深对树结构、递归划分和停止条件的理解。代码注释与函数划分清晰,适合课程实验、课后巩固或毕业设计参考。
1. 为什么一个 10 分钟能跑完的实验,值得写一整篇
鸢尾花分类实验是机器学习里最接近“Hello World”的题目,但它和打印一行字符串完全不同:数据集只有 150 条样本、4 个特征,却同时考验你对数据清洗、特征理解、算法选型、过拟合控制和结果解释的把握。决策树作为这个实验里最直观的分类器,训练完还能把整棵树的判定逻辑画出来,告诉别人“模型为什么这么分”。这种可解释性在真实业务里价值很高,风控、医疗辅助诊断、设备故障定位这些场景至今还在用它做基线模型。
这篇文章要做的不是贴一段代码让你跑通,而是把背后的选择题讲清楚:为什么决策树适合做小样本多分类、特征怎么量化和切分、树深多少算合适、剪枝参数调哪个先哪个后。整个过程用 pandas 加载鸢尾花数据集,用 scikit-learn 的决策树实现,从数据处理一路做到树的可视化和边界分析。适合刚学机器学习想完整走一遍流程的人,也适合已经用过决策树但没仔细抠过参数的人。
2. 先认识鸢尾花数据集:三类样本和四个特征的分布逻辑
2.1 为什么是鸢尾花:小样本多分类的经典结构
鸢尾花数据集(Iris dataset)收集了三种鸢尾花各 50 条样本,特征是花萼长度、花萼宽度、花瓣长度、花瓣宽度,单位是厘米。三类分别是 setosa、versicolor、virginica。这个数据集的特殊之处在于它天然适合演示分类问题:有一类是线性可分的,另外两类存在重叠。这意味着一个模型如果只做简单划分,很容易在 versicolor 和 virginica 的边界上出错,正好用来观察决策树的切分行为。
150 条样本对现代计算机来说非常小,但小样本恰恰放大了过拟合的风险。决策树如果完全不限制生长,它能把训练集里每一条样本都包住,测试集上表现反而不稳定。用这个数据集做实验,核心不是“跑出多高的准确率”,而是理解树的生长逻辑和泛化能力之间的平衡。
2.2 用 pandas 加载并检查数据结构
动手第一步是用 pandas 读取数据,但这里有一个常见的坑:直接从 sklearn 载入的是 NumPy 数组,没有列名。这个阶段用 pandas 的read_csv或load_iris转 DataFrame 都可以,我一般先从sklearn.datasets加载原始数据,再转成 DataFrame 查看结构。这样做能保留特征名和标签名,后续画图、做特征重要性分析时不用对着下标猜。
import pandas as pd from sklearn.datasets import load_iris iris = load_iris() df = pd.DataFrame(data=iris.data, columns=iris.feature_names) df['target'] = iris.target df['species'] = df['target'].map({0: 'setosa', 1: 'versicolor', 2: 'virginica'}) print(df.head()) print(df.info()) print(df.groupby('species').describe())代码逻辑:iris.data是形状为 (150, 4) 的特征矩阵,iris.target是整数标签,iris.feature_names是对应的特征名。先构造成 DataFrame,再把整数标签映射为物种名称方便观察。df.info()检查有没有缺失值,groupby加describe能一次性看到每个类别在各特征上的均值、标准差、最小值、最大值。
2.2.1 四个特征的分布差异决定了决策树的切分顺序
对这三类鸢尾花做特征分布对比,能发现一个关键规律:花瓣长度和花瓣宽度在 setosa 和另外两类之间有明显间隔,而花萼宽度和花萼长度的重叠度较高。决策树在每一层选择特征时依据的是不纯度下降量,所以前几层大概率会在花瓣特征上做切分。这个现象后面可以通过打印树的 feature importance 来验证,也解释了为什么决策树不需要做特征标准化。
2.3 用一个散点图矩阵确认线性可分性
在训练任何模型之前,先做可视化是值得的。用 pandas 内置的scatter_matrix或者 seaborn 的pairplot画特征两两组合的散点图,能直观看到哪些特征组合能把三类分开。这一步不只为了好看,还直接影响你对模型上限的判断——如果两两特征组合都完全重叠,任何基于特征切分的树模型都不可能做到高准确率。
import matplotlib.pyplot as plt from pandas.plotting import scatter_matrix colors = {'setosa': '#2ca02c', 'versicolor': '#ff7f0e', 'virginica': '#1f77b4'} scatter_matrix(df[iris.feature_names], c=df['species'].map(colors), figsize=(12, 10), alpha=0.6) plt.show()这段代码把 4 个特征两两组合成 16 个子图,对角线是单特征分布直方图。跑完之后重点看花瓣长度和花瓣宽度组合的子图,setosa 会形成一团远离另外两类的点,而 versicolor 和 virginica 之间有部分交叠。这个交叠区域就是决策树后续需要反复切分的地方,也是过拟合最容易发生的区域。
3. 决策树的切分逻辑:信息熵、信息增益与基尼系数的选择
3.1 决策树的生长过程本质上是在回答“怎么切最划算”
决策树每次选择一个特征和一个阈值,把当前节点的样本分成左右两支,目标是让切分后的两个子节点内部尽可能“纯”——也就是同一类样本尽量聚在一起。衡量“纯”的指标常见有两个:信息熵(entropy)和基尼系数(gini)。scikit-learn 里DecisionTreeClassifier的criterion参数默认是'gini',也可以改成'entropy'。
信息熵的计算公式是:
H(D) = -Σ p_k * log2(p_k)其中p_k是第 k 类样本在当前节点中的占比。当所有样本都属于同一类时,熵为 0;当类别均匀分布时熵最大。信息增益就是父节点熵减去子节点熵的加权和。基尼系数的公式是:
Gini(D) = 1 - Σ p_k^2它表示从节点中随机抽两个样本,类别不一致的概率。数值越小纯度越高。
3.1.1 gini 和 entropy 实验差异很小,但计算成本不同
在实际实验里,gini 和 entropy 在鸢尾花数据上得到的树结构可能不同,但准确率差异通常非常小。gini 的计算不涉及对数运算,运行更快;entropy 对类别分布的变化更敏感,在某些数据集上生成的树会更“细致”。对鸢尾花这个规模的数据,二者差别可以忽略,但作为实验的一部分,把两个都跑一遍并对比输出来感受差异,比光看文档来得实在。
3.2 从 150 条样本到一棵树的完整过程
先看一个最小可运行的决策树代码,然后逐行解释树的生长过程:加载数据、切分训练集和测试集、定义模型、训练、评估。
from sklearn.model_selection import train_test_split from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import accuracy_score, classification_report X = df[iris.feature_names] y = df['target'] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) clf = DecisionTreeClassifier( criterion='gini', max_depth=4, min_samples_leaf=2, random_state=42 ) clf.fit(X_train, y_train) y_pred = clf.predict(X_test) print("accuracy:", accuracy_score(y_test, y_pred)) print(classification_report(y_test, y_pred, target_names=iris.target_names))参数说明:test_size=0.3表示 30% 的数据留作测试集;stratify=y保证切分后训练集和测试集中三类样本比例与原数据一致,在类别均衡的鸢尾花数据上作用不明显,但遇到不平衡数据时必须加;max_depth=4限制树的最大深度为 4 层,防止树无限生长;min_samples_leaf=2要求每个叶子节点至少包含 2 条样本,这是一个预剪枝策略。random_state=42固定随机种子,保证每次运行结果一致,方便复现。
3.3 为什么鸢尾花实验里要同时限制 max_depth 和 min_samples_leaf
很多人第一次做这个实验时只设max_depth,觉得树不要太深就够了。但实际操作中,max_depth控制的是树的整体高度,如果某个节点分裂后两个子节点里有一个只有一条样本,这个叶子仍然是不稳定的。min_samples_leaf直接限制了叶子节点的最小样本量,相当于在更细的粒度上做约束。两个参数一起调,树会更稳定。
另外还有一个容易被忽略的参数:min_impurity_decrease。它表示只有当分裂带来的不纯度下降量超过这个阈值时才允许分裂。这个参数常用于后剪枝场景,但在鸢尾花这样的小数据集上不推荐一开始就设,因为样本量少,不纯度下降量天然波动大,设了很容易导致整棵树都长不出来。
4. 在本地跑通鸢尾花决策树的最小实现:数据处理到树结构可视化
4.1 特征矩阵构造与标签编码的可复现做法
鸢尾花数据不需要处理缺失值,但真实项目中一定会遇到。这里给出一个标准处理流程:先用isnull().sum()检查缺失,再决定是删除还是填充。最常见的填充方式是类别变量用众数、数值变量用中位数。鸢尾花数据集没有这个问题,但实验里应该保留这一步,因为换数据集时流程可以直接复用。
df.isnull().sum() # 无缺失则继续 X = df[iris.feature_names].copy() # 只保留特征列 y = df['target'].copy() # 标签已经是整数,不需要额外编码这里X是 DataFrame,y是 Series,符合 scikit-learn 的输入要求。如果从 CSV 加载原始数据,标签可能是字符串,这时需要用sklearn.preprocessing.LabelEncoder或 pandas 的astype('category').cat.codes转为整数。注意不要在特征矩阵里混入原始标签列,否则模型会把“正确答案”当成特征学进去。
4.2 决策树结构可视化:把黑盒还原成 if-else 规则
训练完模型之后,最好把树画出来看一眼。scikit-learn 在较新版本里推荐用sklearn.tree.plot_tree,不需要额外安装 graphviz,直接基于 matplotlib 绘制,对中文环境也友好。
from sklearn.tree import plot_tree import matplotlib.pyplot as plt plt.figure(figsize=(20, 10)) plot_tree( clf, feature_names=iris.feature_names, class_names=iris.target_names, filled=True, rounded=True, fontsize=12 ) plt.show()filled=True让每个节点的背景色根据类别纯度渐变,颜色越纯说明该节点里某一类占比越高。rounded=True让节点边框圆角化,全局figsize加大防止文字重叠。画出图后重点看根节点的分裂条件,大概率是“花瓣宽度是否小于等于 0.8”,这正是数据分布分析时看到的强区分特征。
如果想把树结构导出成文本,可以用sklearn.tree.export_text:
from sklearn.tree import export_text tree_rules = export_text(clf, feature_names=iris.feature_names, max_depth=4) print(tree_rules)这样输出的是一段缩进格式的规则列表,适合放在代码仓库里做文档,也方便在终端里快速确认每一层分裂用的特征和阈值。
4.3 用决策边界图观察模型在哪类样本上容易出错
树结构告诉我们模型怎么分,但视觉上最直观的还是决策边界图。鸢尾花有 4 个特征,没法把所有维度画在一个平面里,常见做法是选两个特征组合成二维平面来画。比如选花瓣长度和花瓣宽度,这两个特征区分度最高,画出来的边界最清晰。
import numpy as np def plot_decision_boundary(X_data, y_data, model, feat_idx=(2, 3), ax=None): if ax is None: ax = plt.gca() x_min, x_max = X_data.iloc[:, feat_idx[0]].min() - 0.5, X_data.iloc[:, feat_idx[0]].max() + 0.5 y_min, y_max = X_data.iloc[:, feat_idx[1]].min() - 0.5, X_data.iloc[:, feat_idx[1]].max() + 0.5 xx, yy = np.meshgrid(np.linspace(x_min, x_max, 300), np.linspace(y_min, y_max, 300)) grid = np.c_[xx.ravel(), yy.ravel()] # 用完整特征矩阵的均值填充未参与作图的特征 full_grid = np.tile(X_data.mean().values, (grid.shape[0], 1)) full_grid[:, feat_idx[0]] = grid[:, 0] full_grid[:, feat_idx[1]] = grid[:, 1] Z = model.predict(full_grid).reshape(xx.shape) ax.contourf(xx, yy, Z, alpha=0.3, cmap='coolwarm') scatter = ax.scatter(X_data.iloc[:, feat_idx[0]], X_data.iloc[:, feat_idx[1]], c=y_data, cmap='coolwarm', edgecolor='k') ax.set_xlabel(iris.feature_names[feat_idx[0]]) ax.set_ylabel(iris.feature_names[feat_idx[1]]) return ax plot_decision_boundary(X_test, y_test, clf) plt.show()这个函数的核心逻辑是:在二维平面上生成密集网格点,每个点通过均值填充其他两个特征,构造出完整的 4 维向量交给模型预测,再用contourf画出分类区域。这种“填充均值”的做法在特征独立时近似成立,鸢尾花特征相关性中等,用来观察大致边界没问题。图上能清楚看到 setosa 区域被一刀切出来,versicolor 和 virginica 之间出现折线状边界,这就是决策树的轴平行切分特点。
5. 决策树的参数调优与剪枝:从过拟合到泛化
5.1 同一个数据上不同 max_depth 的表现差异
决策树不限制深度时,训练集准确率可以到 100%,但测试集准确率可能下降。做一个简单的对比实验:把max_depth从 1 调到 10,分别记录训练集和测试集准确率,画出曲线。
train_accs = [] test_accs = [] depths = range(1, 11) for depth in depths: model = DecisionTreeClassifier( criterion='gini', max_depth=depth, min_samples_leaf=1, random_state=42 ) model.fit(X_train, y_train) train_accs.append(accuracy_score(y_train, model.predict(X_train))) test_accs.append(accuracy_score(y_test, model.predict(X_test))) plt.plot(depths, train_accs, label='train accuracy', marker='o') plt.plot(depths, test_accs, label='test accuracy', marker='s') plt.xlabel('max_depth') plt.ylabel('accuracy') plt.legend() plt.show()这个曲线图通常会出现一个典型形态:训练准确度一路攀升,测试准确度在某个深度(可能是 3 或 4)之后开始波动或下降。这个转折点就是模型从欠拟合走向过拟合的分界。注意鸢尾花数据集小,测试集只有 45 条样本,准确率波动区间大,单次实验的曲线不会完全平滑,所以判断过拟合要结合多次随机切分的平均表现,不要因为某一次测试准确率降低就急着下结论。
5.2 预剪枝参数组合的推荐顺序
调参时不要一上来就网格搜索全参数。我是按这个顺序调的:
- 先固定
random_state,保证每次结果可比; - 调
max_depth,观察训练集和测试集准确率的差距; - 设定
min_samples_leaf为 2 或 3,看测试准确率是否提升; - 最后调
max_leaf_nodes,它是另一种控制树复杂度的手段。
max_leaf_nodes限制叶子节点的总数,比max_depth更直接。因为深度相同情况下,如果某个节点一直分裂,可能一棵树虽然深度不大但叶子很多。max_leaf_nodes=10表示整棵树最多 10 个叶子结点,每次分裂前先检查当前叶子数是否已达上限。
clf_pruned = DecisionTreeClassifier( criterion='gini', max_depth=4, min_samples_leaf=3, max_leaf_nodes=10, random_state=42 )以上组合的作用是:max_depth=4限制整体高度,min_samples_leaf=3让每个叶子至少覆盖 3 条样本,max_leaf_nodes=10从总数上封顶。三个条件同时满足,树结构会明显比默认配置简单,训练集准确率会降低,但测试集上的稳定性更好。
5.3 用网格搜索确认参数区间
调参数靠手试效率低,用GridSearchCV做小范围搜索,关键是把cv设置合理。鸢尾花数据 150 条,用默认的 5 折交叉验证即可,每折 30 条样本,分类器是稳定的。
from sklearn.model_selection import GridSearchCV param_grid = { 'max_depth': [2, 3, 4, 5], 'min_samples_leaf': [1, 2, 3, 4], 'max_leaf_nodes': [None, 5, 8, 10] } grid_search = GridSearchCV( DecisionTreeClassifier(random_state=42), param_grid, cv=5, scoring='accuracy' ) grid_search.fit(X_train, y_train) print("best params:", grid_search.best_params_) print("best cv score:", grid_search.best_score_)注意GridSearchCV虽然会返回best_params_,但不要直接信任它作为最终模型的参数。样本量小的时候,交叉验证分数本身有波动,跑几次网格搜索得到的“最优参数”可能不同。正确做法是:把网格搜索当成范围确认工具,然后用业务可解释性来校准。比如网格搜索给出max_depth=5,但树的图形看起来太碎,那就退到max_depth=3——在可解释性和准确率之间取平衡。
5.4 特征重要性的误读与正确解读方式
决策树的feature_importances_属性返回每个特征的重要性得分,总和为 1。这个得分是怎么算的?它是每个特征在所有节点上带来的不纯度下降量加权求和后归一化的结果。在鸢尾花数据上通常能看到花瓣宽度的重要性远高于花萼宽度。
for name, importance in zip(iris.feature_names, clf.feature_importances_): print(f"{name}: {importance:.3f}")这个数值容易产生一个误导:认为重要性低的特征就对分类没有贡献。实际上,如果两个特征高度相关,树可能只选其中一个来分裂,另一个的重要性就被压低了,但删除它并不一定影响模型表现。决策树只能告诉你“在当前树的生长过程中某个特征被用了多少”,不能下结论说某个特征没有信息量。要验证特征价值,应该做删除特征后的模型对比实验。
6. 对比实验:决策树与随机森林在鸢尾花数据上的稳定性
6.1 为什么要拿决策树和随机森林对比
决策树的一个问题是对数据的微小变化敏感:训练集里删掉一条样本,生成的树结构可能完全不同。随机森林通过有放回抽样和随机特征子集来降低方差,在鸢尾花数据上的默认表现通常不会比单棵决策树差太多,但在多次随机切分实验里,它的准确率波动更小。这个对比实验能帮助理解“集成方法为什么能提升稳定性”这个抽象概念。
from sklearn.ensemble import RandomForestClassifier rf = RandomForestClassifier( n_estimators=100, max_depth=4, min_samples_leaf=2, random_state=42 ) rf.fit(X_train, y_train) rf_pred = rf.predict(X_test) print("decision tree accuracy:", accuracy_score(y_test, y_pred)) print("random forest accuracy:", accuracy_score(rf_test, rf_pred))6.2 多轮随机切分下的准确率分布
单次对比没有统计意义。我写一个循环,每次用不同random_state切分数据,训练两类模型,记录 30 轮准确率,再比较均值与标准差。
dt_scores = [] rf_scores = [] for seed in range(30): X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.3, random_state=seed, stratify=y ) dt = DecisionTreeClassifier(max_depth=4, min_samples_leaf=2, random_state=42) dt.fit(X_tr, y_tr) dt_scores.append(accuracy_score(y_te, dt.predict(X_te))) rfc = RandomForestClassifier(n_estimators=100, max_depth=4, min_samples_leaf=2, random_state=42) rfc.fit(X_tr, y_tr) rf_scores.append(accuracy_score(y_te, rfc.predict(X_te))) import numpy as np dt_scores = np.array(dt_scores) rf_scores = np.array(rf_scores) print("决策树:", f"均值={dt_scores.mean():.4f}", f"标准差={dt_scores.std():.4f}") print("随机森林:", f"均值={rf_scores.mean():.4f}", f"标准差={rf_scores.std():.4f}")运行结果一般是随机森林的标准差更小。但注意,随机森林在特征只有 4 个的数据集上优势有限,因为每次分裂时可选择的特征子集只有 2 个(默认max_features='sqrt'),随机性带来的收益被限制住了。这个结论同样重要:集成方法不是万能的,特征维度太少时提升空间有限。
6.3 过拟合验证:训练集准确率 vs 测试集准确率的差值
判断模型是否过拟合,看训练集和测试集准确率的差值就够了。差值接近 0,说明模型泛化良好;差值很大,说明模型记住了训练集的噪声。把这个差值同时打印出来,和准确率放在一起判断,避免只看准确率一个数字。
dt_train_acc = accuracy_score(y_train, dt.predict(X_train)) dt_test_acc = accuracy_score(y_test, dt.predict(X_test)) print(f"决策树 train-test gap: {dt_train_acc - dt_test_acc:.4f}")对鸢尾花数据来说,max_depth=3到4之间通常能找到一个低差距又保持高准确率的区间。可以试试把max_depth设为 10,看看差距如何变大——这是理解过拟合最直接的体验:训练集表现很好,测试集表现骤降,树的结构图也变得非常庞大。
本文还有配套的精品资源,点击获取