☰
CART回归树原理与剪枝实战:从手写代码到sklearn调参
2026/10/2 3:05:17 网站建设 项目流程

决策树这个东西,很多入门教程讲分类讲得头头是道,一到回归就含糊带过。我当年学的时候也卡在这儿:明明ID3、C4.5都能用信息增益做分类,怎么到预测连续数值就不灵了?后来真正把CART回归树和剪枝流程走了一遍,才明白不是决策树不能做回归,而是你缺一个合适的误差度量方式,外加一套防止模型长得太野的刹车机制。

这篇文章适合正在学机器学习、刷西瓜书或者准备期末考的朋友,也适合那些用过sklearn的DecisionTreeRegressor但始终没搞懂里面参数含义的人。我会从CART回归树的拆分原理讲起,手推一遍构建过程,再把预剪枝和后剪枝讲透,最后用Python从零撸一个简化版回归树,再用sklearn做完整对比。整个过程不绕弯子,直接给你能复现的代码和能记住的判断标准。

1. 为什么回归问题要用CART回归树

1.1 分类树和回归树的本质差异

先说一个很多人忽略的问题:ID3和C4.5为什么不能直接做回归?因为它们依赖的信息增益、信息增益比,都是用熵或者基尼系数来衡量“类别纯度”。这两个指标的前提是目标变量是离散的、有明确的类别标签。你让模型预测房价是350万还是371万,熵这东西根本没法算,你总不能把每个价格当成一个分类吧,那样类别数比样本还多,树直接炸掉。

CART(Classification And Regression Tree)全称就是“分类与回归树”,它本身支持回归场景,关键是换了一套划分标准:不再看类别纯度,改看数值误差。分类时用的基尼系数我后面会提一下,但回归树的核心就一句话:找切分点,让切分后两边的平方误差之和最小。这个思路朴素得有点像在二维平面上画阶梯去逼近一条曲线,每个区间给一个常数预测值。

1.2 平方误差作为划分依据的逻辑

回归问题里,每个样本的真实值是 ( y_i ),预测值暂定为 ( \hat{y} ),误差可以用 ( (y_i - \hat{y})^2 ) 来衡量。CART回归树对某个候选切分点 ( s ) 做评估时,会把当前节点数据分成左区域 ( R_1 ) 和右区域 ( R_2 ),然后分别计算两个区域内部的目标值均值,作为该区域的预测值 ( c_1 )、( c_2 ),再算总的误差平方和 ( \sum_{x_i \in R_1} (y_i - c_1)^2 + \sum_{x_i \in R_2} (y_i - c_2)^2 )。

这里面有两个关键决策:第一,遍历所有特征、所有可能的切分值,找出误差最小的那一个;第二,左右区域的预测值为什么用均值而不是中位数或其他统计量?因为平方误差的最优解就是均值,这个用微积分就能验证:对一个固定的区域,使 ( \sum (y_i - c)^2 ) 最小的 ( c ) 就是均值。后面的实现里你就知道,这个均值直接决定了预测结果,也决定了叶子节点的输出。

2. 手推一遍CART回归树的构建过程

2.1 特征与切分点的暴力搜索

现在用一个特别小的例子走一遍流程,你就理解了。假设有5个样本,只有一个特征 ( x ) 和目标值 ( y ):

样本xy
113
225
338
4410
5514

第一步,确定候选切分点。对于连续特征,通常取相邻两个样本值的中间点做候选,比如 ( (1+2)/2=1.5 ),( (2+3)/2=2.5 ),这样依次得到 1.5、2.5、3.5、4.5 四个切分点。

第二步,对每个切分点计算划分后的误差平方和。切分点1.5表示左区域只有样本1,右区域是样本2到5。左区域预测值 ( c_1=3 ),误差为0;右区域预测值应该是 ( (5+8+10+14)/4=9.25 ),误差平方和是 ( (5-9.25)^2+(8-9.25)^2+(10-9.25)^2+(14-9.25)^2=42.75 )。总误差就是42.75。

再算切分点2.5:左区域是样本1、2,预测值 ( (3+5)/2=4 ),误差是2;右区域样本3、4、5,预测值 ( (8+10+14)/3=10.67 ),误差大约是 ( (8-10.67)^2+(10-10.67)^2+(14-10.67)^2=18.67 ),总误差20.67。继续算下去,切分点3.5对应左区域样本1-3,预测值 ( (3+5+8)/3=5.33 ),误差12.67;右区域样本4、5,预测值12,误差8,总误差20.67。切分点4.5的左区域样本1-4,平均值6.5,误差 ( (3-6.5)^2+(5-6.5)^2+(8-6.5)^2+(10-6.5)^2=29 ),右区域单点14,总误差29。

比较下来,切分点2.5和3.5的总误差都是最小的20.67。任选一个作为根节点的切分,你会发现这个操作本质上就是把相邻的两个y值差距大的地方切开。直观理解就是:树在找“突变点”,让每一段内部的数字尽量接近。数据量一旦变大,这个暴力搜索仍然可行,因为候选切分点最多就是 ( n-1 ) 个。

2.2 为什么叶子节点的预测值是均值

构建过程继续往深处走,比如切分点2.5把数据分成左右两块后,左右各自成为子节点。如果此时没有设任何停止条件,树会继续在左区域找切分点,比如把样本1和样本2再切开,左节点就只有样本1,右节点只有样本2。最后你会得到一棵把所有样本都单独放在叶子上的树,训练误差当然是0。

这听起来很完美对吧?但一测试马上露馅,因为这棵树已经死记硬背了每个样本,新数据进来它只能靠最近邻区间硬套。所谓回归树叶子节点的预测值取均值,就是它把一段区间内所有样本的 y 平均一下,当作这段区间最终的输出。这个输出对区间内的数据是“中庸”的,不会刻意贴合任何一个点,也因此保留了泛化能力。叶子节点覆盖的样本越多,预测越稳,但也越粗糙;越少,预测越精细,却越容易过拟合。这个矛盾是整篇文章后面所有剪枝操作的根源所在。

2.3 停止条件:没有刹车的树就是过拟合机器

实际使用中,CART回归树一般会设置几类停止条件,按优先级排序:

  • 最小样本数:节点样本数小于阈值时不再分裂,比如 ( min_samples_split=5 )。
  • 深度限制:树的最大深度到达设定值就停止,比如 ( max_depth=3 )。
  • 误差改善阈值:如果分裂前后的误差下降幅度小于某个阈值,就不值得继续切,例如 ( min_impurity_decrease )。
  • 叶子节点样本阈值:分裂后只要有任何一边的子节点样本数小于阈值,就不允许切,比如 ( min_samples_leaf=2 )。

这几种条件的本质不同值得说一下:( max_depth ) 是从“纵向”限制树的生长,( min_samples_split ) 是从“全局横向”限制节点再分裂的资格,( min_samples_leaf ) 则是从“分裂结果”上约束两边的样本量,确保不会出现某个叶子只有一个点的情况。这三个参数在实际调参时优先级不一样,很多人直接死磕深度,但如果在样本量较小的数据集上,( min_samples_leaf ) 往往更管用,因为它直接拦住了那些“叶子只装一个样本”的危险分裂。

3. 决策树剪枝:从预剪枝到后剪枝

3.1 过拟合的根源与剪枝的整体思路

踢开理论包装,树模型过拟合的根本原因就是一个:分裂次数太多,每个区间装得太满太细。训练样本里的噪声会被当成规律学习进去,比如某个样本因为偶然因素 ( y ) 特别大,树就会刻意在这个样本附近多切一刀,好让这个异常点“落地归位”。这种刀法对训练集很爽,对测试集就是灾难。

于是有了两条对抗路线。一条叫预剪枝,也就是构建过程中提前喊停,从头避免树长得过于复杂,简单说就是边建边刹车;另一条叫后剪枝,先让树不管不顾地长到完整形态,再自底向上把一些不能带来实际收益的分支剪掉。预剪枝的好处是省计算,缺点是策略性比较强,容易“一刀切”导致欠拟合;后剪枝更接近“先给自由再看表现”的路子,效果通常更好,但计算量更大。现实中两者应该搭配着来。

3.2 预剪枝的具体做法与两手准备

预剪枝本质上就是前面刚提过的那些停止条件。操作上,你可以在sklearn的 ( DecisionTreeRegressor ) 里直接设 ( max_depth=3 )、( min_samples_leaf=5 ) 这些参数,模型在构建过程中一旦触达条件就停止分裂。

但这里有个坑:预剪枝的阈值很难一次设准。设太严,比如 ( max_depth=2 ),模型可能连一个像样的特征交互都学不到;设太松,比如 ( max_depth=15 ),小数据集上还是照常过拟合。我的习惯是先用默认参数跑一遍,看看训练集和验证集的分数差距有多大,如果训练集 ( R^2 ) 已经0.98但验证集只有0.72,说明过拟合很严重,这时候先把 ( max_depth ) 从大到小扫一遍,画出验证集分数的曲线,选最高点附近的值。

预剪枝还有个容易忽略的好处:它能让模型更稳定。回归树对数据扰动本来就敏感,训练数据里加几个点就可能改变整体切分结构。提前限制深度后,树没那么容易把偶然波动纳入结构里,方差自然就降下来了。

3.3 后剪枝:CCP代价复杂度剪枝

后剪枝在sklearn里面最标准的方法是代价复杂度剪枝(Cost Complexity Pruning,简称CCP)。思路是给“树的复杂度”定义一个代价,然后让模型在误差和复杂度之间做权衡。

引入一个参数 ( \alpha ),对一个叶子数为 ( |T| ) 的子树 ( T ),定义它的损失函数:

( C_\alpha(T) = R(T) + \alpha \cdot |T| )

这里的 ( R(T) ) 是回归树的训练误差平方和,( |T| ) 是叶子节点数量。当 ( \alpha=0 ) 的时候,损失只看训练误差,完整树因为误差最小被选中;( \alpha ) 增大,惩罚项变大,模型就倾向于用更少的叶子节点去换稍微大一点的误差,树被剪得越来越干净。

具体剪枝过程要怎么看?对树中的某个内部节点 ( t ),把它当作叶子时,整棵子树的误差会变大,大出来的这部分记为 ( R(t)-R(T_t) ),同时叶子数从 ( |T_t| ) 降到1,减少的叶子数是 ( |T_t|-1 )。两者相除就得到一个临界值:

( \alpha_{eff} = \frac{R(t)-R(T_t)}{|T_t|-1} )

这个值的直观含义是:每少一片叶子,需要额外付出的误差增量。从根往叶子方向,每个内部节点都能算出一个临界值,CCP算法自底向上逐个剪掉临界值最小的那个节点,每剪一次就生成一棵新的子树,同时记录当前的 ( \alpha ) 值。整个过程会得到一棵剪枝路径,每一步对应一个 ( \alpha ) 和一棵子树。最后用交叉验证去选让验证集误差最小的那个 ( \alpha ),再对子树做最终预测。这个思路设计得很优雅,它把“树要多大”的问题变成一个连续超参 ( \alpha ) 的选择问题。

4. 用Python把回归树和剪枝跑一遍

4.1 从零实现一个最小版CART回归树

理解了原理之后,动手写一个简版实现会帮你把模糊的地方彻底压实。下面这段代码不做任何优化,只为展示核心逻辑:递归找切分点、切分后分别计算均值、设置最小样本量停止条件。

import numpy as np class SimpleCARTRegressor: def __init__(self, min_samples_split=5, max_depth=None): self.min_samples_split = min_samples_split self.max_depth = max_depth self.tree = None def _compute_error(self, y): if len(y) == 0: return 0.0 return np.sum((y - np.mean(y)) ** 2) def _best_split(self, X, y): best_feature, best_threshold, best_error = None, None, np.inf n_samples, n_features = X.shape for f in range(n_features): values = np.unique(X[:, f]) if len(values) < 2: continue thresholds = (values[:-1] + values[1:]) / 2 for t in thresholds: left_mask = X[:, f] <= t right_mask = X[:, f] > t if np.sum(left_mask) == 0 or np.sum(right_mask) == 0: continue error = self._compute_error(y[left_mask]) + self._compute_error(y[right_mask]) if error < best_error: best_error = error best_feature = f best_threshold = t return best_feature, best_threshold, best_error def _build(self, X, y, depth): n_samples = len(y) if n_samples < self.min_samples_split or (self.max_depth is not None and depth >= self.max_depth): return float(np.mean(y)) f, t, _ = self._best_split(X, y) if f is None: return float(np.mean(y)) left_mask = X[:, f] <= t right_mask = X[:, f] > t node = { "feature": f, "threshold": t, "left": self._build(X[left_mask], y[left_mask], depth + 1), "right": self._build(X[right_mask], y[right_mask], depth + 1) } return node def fit(self, X, y): X = np.asarray(X) y = np.asarray(y).reshape(-1) self.tree = self._build(X, y, depth=0) return self def _predict_one(self, x): node = self.tree while isinstance(node, dict): if x[node["feature"]] <= node["threshold"]: node = node["left"] else: node = node["right"] return node def predict(self, X): return np.array([self._predict_one(x) for x in np.asarray(X)])

注意几个设计取舍:这里的切分阈值取了相邻唯一值的中点,实际工程里可能会用更多干扰项处理,比如带权重的分位数切分;停止条件方面我只实现了 ( min_samples_split ) 和 ( max_depth ),没有实现 ( min_samples_leaf ),是因为这个最小版本想保持逻辑干净。每次分裂都要对所有特征的所有候选切分点做扫描,复杂度是 ( O(n \times m \times n_samples) ),在小数据集上完全可以接受。

4.2 用合成数据测试手写模型

造一条带噪声的曲线数据来检验:

import matplotlib.pyplot as plt from sklearn.model_selection import train_test_split rng = np.random.RandomState(42) X = np.linspace(0, 3, 200) y = np.sin(2 * X) + 0.3 * np.random.randn(200) X = X.reshape(-1, 1) X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) model = SimpleCARTRegressor(min_samples_split=10, max_depth=4) model.fit(X_train, y_train) pred = model.predict(X_test) r2 = 1 - np.sum((y_test - pred) ** 2) / np.sum((y_test - np.mean(y_test)) ** 2) print("手动实现R2:", round(r2, 4))

如果一切顺利,这个简化版模型大概能拿到0.6到0.7左右的 ( R^2 ),具体取决于随机种子和深度设置。它的每一步都对应前面讲过的原理:分裂时计算平方误差、叶子输出均值、深度控制让树不至于疯狂细化。把预测结果画出来,你会看到一条明显的阶梯状折线,每一段水平线就是一个叶子节点。

4.3 sklearn实战:预剪枝参数的对照实验

手写模型只是用来验证理解,真正干活还得用sklearn的 ( DecisionTreeRegressor )。先用默认参数建立一棵完整生长的树,再对比受限条件下的效果:

from sklearn.tree import DecisionTreeRegressor from sklearn.metrics import r2_score reg_default = DecisionTreeRegressor(random_state=42) reg_default.fit(X_train, y_train) print("默认树 训练R2:", round(reg_default.score(X_train, y_train), 4), "测试R2:", round(reg_default.score(X_test, y_test), 4)) reg_pruned = DecisionTreeRegressor( max_depth=3, min_samples_split=5, min_samples_leaf=4, random_state=42 ) reg_pruned.fit(X_train, y_train) print("预剪枝 训练R2:", round(reg_pruned.score(X_train, y_train), 4), "测试R2:", round(reg_pruned.score(X_test, y_test), 4))

这种小数据上,默认树几乎就是教科书级过拟合:训练 ( R^2 ) 接近1,测试 ( R^2 ) 稳定低于预剪枝版本。你多做几次不同比例的train_test_split就会发现,默认树的测试分数波动很大,而加了深度和叶子限制的树更平稳。这就是前面说的“方差下降”在数值上的体现。

4.4 sklearn实战:CCP后剪枝完整流程

后剪枝走的是另一条路线。先把树放开长,再通过cost_complexity_pruning_path拿到每个候选 ( \alpha ) 值对应的树信息,用交叉验证挑出最合适的 ( \alpha ):

reg_tree = DecisionTreeRegressor(random_state=42) reg_tree.fit(X_train, y_train) path = reg_tree.cost_complexity_pruning_path(X_train, y_train) ccp_alphas, impurities = path.ccp_alphas, path.impurities # 去掉最后一个纯叶子的alpha,避免树只剩一个节点 ccp_alphas = ccp_alphas[:-1] # 对每个alpha训练一棵对应剪枝程度的树 pruned_trees = [] for alpha in ccp_alphas: reg = DecisionTreeRegressor(random_state=42, ccp_alpha=alpha) reg.fit(X_train, y_train) pruned_trees.append(reg) # 简单用验证集选最优 scores = [reg.score(X_test, y_test) for reg in pruned_trees] best_idx = np.argmax(scores) best_alpha = ccp_alphas[best_idx] print("最优alpha:", round(best_alpha, 5), "测试R2:", round(scores[best_idx], 4))

这里有一个极易踩到的坑:ccp_alphas列表是单调递增的,每个 ( \alpha ) 训练出来的树叶子数逐步减少,但最优 ( \alpha ) 并不是越小越好。如果你拿测试集来选 ( \alpha ),相当于把测试集信息泄露进了模型选择过程,正确做法是把数据切成三份:训练集、验证集、测试集,用验证集选 ( \alpha ),最后再用测试集评估。数据量小的时候可以用交叉验证替代,sklearn里配合GridSearchCV扫ccp_alpha就非常方便。

后剪枝的另一个优势是:它保留了完整树可能挖掘出的复杂模式,只是在复杂度代价面前做了取舍,因此往往比纯预剪枝拿到的模型更“聪明”。实际业务里我更倾向于先用较大深度训练,再用CCP剪枝,配合验证集选参。

5. 常见问题与排查技巧实录

5.1 树太深导致的过拟合

最典型的现象就是训练集 ( R^2 ) 接近1,测试集分数低得离谱。解决逻辑分两步:先限制深度重新训练,看测试分数是否有提升;如果提升不明显,就上CCP后剪枝。注意不要一上来就把深度设为2,那种欠拟合是另一种极端。扫描法更靠谱:深度从1到10逐个跑,画出训练分数和测试分数的变化曲线,找交叉点附近的位置作为参考值。

5.2 离群点让叶子均值失真

回归树对异常值不算敏感,但也谈不上稳健。如果一个离群点单独占据一个叶子,预测时落在那个区域就会吃到一个不合理的极端值。排查方法很简单:打印树的叶子样本数和对应预测值,如果某个叶子的预测值比整体分布高出几个量级,就需要考虑在数据预处理时做截尾或者winsorize处理。另一个思路是提高min_samples_leaf,强迫离群点落在一个更大的区间里,让它被稀释掉,而不是单独成区。

5.3 预剪枝参数的选择:min_samples_split和min_samples_leaf有什么区别

这两个参数经常被混用,但实际上管的事情不一样。min_samples_split管的是“一个节点至少有多少样本才能继续分裂”,比如设为10,样本量小于10的节点直接变叶子;min_samples_leaf管的是“分裂之后每个子节点至少要保留多少样本”,比如设为5,哪怕当前节点有100个样本,只要某个候选切分点把一边切到只剩3个,这个切分就会被拒绝。

从效果看,min_samples_leaf对“叶子太细”的限制更直接,也更适合小样本数据集。我个人的默认配置偏向min_samples_leaf=5,再配合max_depth兜底,很少单独只调min_samples_split。

5.4 特征量纲与切分阈值:需要归一化吗

很多初学者会惯性给树模型做标准化,其实没必要。CART按特征值大小排序后取相邻中点作为切分候选,这个操作是单调的,特征放大缩小只影响阈值的绝对数值,不影响划分结果和预测值。真正影响树结构的是特征的取值范围和分布——取值范围大的特征候选切分点更多,更容易被选中,这只能说是一种选择偏差,不是量纲问题。如果你想避免某个连续特征因为取值种类太多而抢占分裂机会,可以对特征做分箱或者限制max_features,但不是必须的。树模型的天然优势就是能抓非线性关系,这也是它始终没被线性模型完全取代的重要原因。

5.5 分类树和回归树的剪枝差异

最后提一个容易混淆的知识点:CART分类树剪枝和回归树剪枝思路完全一致,都是代价复杂度剪枝,区别只在评估指标。分类树用基尼系数算节点不纯度,回归树用平方误差总和。基尼系数评估的是“这个节点里面类别有多杂”,越杂越需要继续分裂;平方误差评估的是“这个节点里面数值波动有多大”,波动越大越值得继续切。懂得了这个区别,看西瓜书里关于剪枝的公式就不会再懵了。

我个人在实际项目中已经习惯把决策树的调参流程固定成两段式:第一阶段用预剪枝参数粗暴限制树规模,快速确认特征有效性和基线精度;第二阶段把限制放宽,让树尽量长出来,再用CCP后剪枝加交叉验证精调。这样既避免了预剪枝的盲目性,也控制了后剪枝的计算开销。如果你正被决策树的超参数搞得焦头烂额,不妨照这个流程跑一轮,应该能少踩不少坑。

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

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

立即咨询