☰
用Python实现随机森林分位数回归:从点预测到区间预测
2026/10/11 20:12:25 网站建设 项目流程

简介:面向具备Python与机器学习基础的开发者、数据科学从业者,这份资源针对多输入单输出回归任务中仅有点预测、缺乏不确定性评估的痛点,系统讲解如何用QRFR(随机森林分位数回归)实现区间预测。文档从分位数回归与随机森林的理论基础入手,结合项目背景、目标、挑战及创新点,覆盖金融分析、气候预测、医疗健康等典型应用场景,并给出完整的模型描述与可运行的示例代码。内容同时涉及数据预处理、超参数调节、过拟合控制等实践难点,以及QRFR在复杂异构数据下的泛化优势,帮助读者理解从原理到落地的完整链路。压缩包共1个docx文档,大小仅33KB,内容以模型理论讲解与实战代码为主,目录结构清晰,可快速定位到模型架构、背景介绍与示例代码章节。已有1116人学习下载,适合希望提升预测可靠性、掌握区间估计方法的读者参考实践。

1. QRFR 不只是随机森林加个分位数:它把“预测一个数”换成“预测一段区间”

你手头有一组多维特征,要预测一个连续目标,业务方却追问:“给个区间吧,别只报一个数。”这种诉求在量化风控、设备寿命预测、电力负荷预测里非常常见。普通随机森林只能出点预测,而 QRFR(Quantile Regression Forest,随机森林分位数回归)的做法,是在每棵树的叶节点上保留训练样本的目标值,再按分位数把预测不确定性量化成区间。本文用 Python 从零实现一个多输入单输出的 QRFR,覆盖模型原理、评估指标和落地避坑清单,示例代码可以直接换成自己的数据跑通。

2. 从点预测到区间预测:QRFR 的原理与选型理由

2.1 为什么点预测不够:区间预测到底在回答什么问题

随机森林回归输出的往往是一个均值,比如预测某台设备剩余寿命是 120 天。实际使用中这个数字意义有限:如果业务上要在 100 天时安排检修,你需要知道的是“有多少把握落在 100 天到 140 天之间”。点预测隐藏了两类信息:一是数据本身的噪声,二是模型对这个特定样本的把握程度。

更关键的是,预测不确定性通常不是均匀的。特征取值在训练数据密集区时,模型很有把握;落在稀疏区或者超出训练范围时,误差会放大。普通随机森林对这些毫无区分,只会给一个不痛不痒的均值。QRFR 能回答“输入 x 时,目标值 y 的分布大约在哪个区间”,而且这个区间会随着输入位置自动变宽变窄,这恰好是很多工程场景真正需要的。

所以区间预测不是在点预测之上加一个固定误差带。它要回答的问题是:给定输入,目标值的条件分位数在哪里。条件分位数是随特征变化的,QRFR 就是用来估计这个条件分位数的。

2.2 QRFR 是怎么算出分位数的:叶节点样本分布

普通随机森林在训练时,每棵决策树不断分裂,最终每个叶节点里保留了一批训练样本。预测时,新样本落到某棵树的某个叶节点,模型取该叶节点训练样本目标值的平均作为输出。QRFR 的核心变化在于:不再丢弃分布信息,而是把这些样本的目标值全部保留下来。

具体实现上,训练阶段和普通随机森林几乎一样,只是额外记录每个叶节点对应的训练样本索引。预测阶段,对于一个新样本 x,先让它穿过每一棵树,收集它在每棵树上落入的叶节点里的所有目标值,然后把所有树收集到的目标值拼成一个混合样本集合。对这个集合直接取分位数,比如 2.5%、50%、97.5%,就得到了下界、中位数和上界。

用公式表达会更清楚。条件分位数的定义为:

Q_q(x) = min{ y : P(Y ≤ y | X = x) ≥ q }

QRFR 用训练样本的经验分布去近似这个条件分布:

P(Y ≤ y | X = x) ≈ (1 / T) · Σ_t (1 / n_t) · Σ_{i ∈ L_t(x)} 1(y_i ≤ y)

其中 T 是树的数量,L_t(x) 是第 t 棵树中样本 x 落入的叶节点,n_t 是该叶节点中的训练样本数。可以看到,每棵树对分布的贡献被等权平均,叶节点内每个样本也等权。这就是 Meinshausen 在 2006 年提出的分位数回归森林的核心思想。

这里要特别说明:sklearn 的 RandomForestRegressor.predict 返回的是叶节点均值的平均,它把分布信息丢掉了。QRFR 正好是在这个基础上多保留了一步,这也是为什么很多随机森林的框架里没有直接提供分位数接口,需要自己做一层封装。

2.3 常见区间预测方案对比:为什么我选 QRFR

工程里做区间预测的方案不止一种,各有利弊。我这些年在实际项目里对比过几个常用路线,给出一张表供参考。

方案实现难度区间宽度是否随输入变化可解释性适合场景
固定误差带(均值 ± kσ)最低否高误差分布近似同方差时
分位数回归 + GBDT中是中需要更平滑的分位数曲线
QRFR低-中是高表格数据、特征维度 5-200 的回归
NGBoost中高是中想要完整概率分布输出
贝叶斯神经网络 / MC Dropout高是低图像、序列等非表格数据

QRFR 最大的优势是训练逻辑与普通随机森林一致,不需要自定义损失函数,也不用调神经网络的超参。它保留了随机森林的特性:对特征尺度不敏感、能自动处理特征交互、不需要归一化。这在真实业务数据上非常省心,尤其当你面对的是几十个量纲各异的业务特征,又没有时间做精细清洗的时候。

另一个现实理由是代码可靠。quantile-forest 这个库提供了完整的 RandomForestQuantileRegressor,但完全依赖第三方实现会有版本兼容顾虑。我常用的做法是直接用 sklearn 的 RandomForestRegressor 封装一层,代码量不大,还能完全控制叶节点样本的收集逻辑,出了问题自己就能查。

3. 多输入单输出 QRFR 的 Python 实现:模型封装、示例代码与参数说明

3.1 环境依赖与合成数据:多输入单输出的训练集怎么构造

实现 QRFR 只需要三个基础库:numpy、scikit-learn、matplotlib。不需要额外安装专用包,我用的是 Python 3.9 以上版本,sklearn 1.2 以上,如果你用的是更早的版本,apply 接口和 RandomForestRegressor 的行为基本一致,代码可以兼容。

为了演示多输入单输出,我不会去加载某个固定的公开数据集,而是直接构造一个带异方差噪声的合成回归问题。异方差的意思是噪声幅度随特征变化,这正好能看出 QRFR 区间预测的价值:普通固定误差带做不到这一点。

import numpy as np import matplotlib.pyplot as plt from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import train_test_split rng = np.random.default_rng(42) n = 1000 # 5 个输入特征,多输入单输出 X = rng.uniform(-3, 3, size=(n, 5)) # 目标由前三个特征的非线性组合决定,噪声幅度随 x0 增大而增大 y = ( np.sin(X[:, 0]) + 0.5 * X[:, 1] ** 2 - 0.3 * X[:, 2] + rng.normal(0, 0.2 + 0.1 * np.abs(X[:, 0]), size=n) ) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=7 )

这段代码生成了 5 维输入和一个连续目标。y 与特征的函数关系是非线性的,而且噪声标准差随第一个特征绝对值增大而增大。这样设置有两个目的:第一,测试集能真实评估区间预测能力;第二,后面你会看到 QRFR 的区间宽度在 x0 绝对值大的样本上自动变宽,而均值加固定误差带做不到。

train_test_split 的 random_state 固定为 7,保证后面每次跑实验切分一致。这里特别提醒:切分要在任何模型训练之前完成,否则会在后面第 5 章踩到数据泄露的坑。

3.2 用 sklearn 的 RandomForestRegressor 封装 QRFR 类

核心封装类如下。思路是:训练时保存每个叶节点覆盖的训练样本索引,预测时收集对应目标值并计算分位数。

class QuantileRegressionForest: """极简 QRFR:在随机森林叶节点上收集样本,再算分位数。""" def __init__(self, n_estimators=200, min_samples_leaf=10, max_depth=None, max_features="sqrt", random_state=42): self.rf = RandomForestRegressor( n_estimators=n_estimators, min_samples_leaf=min_samples_leaf, max_depth=max_depth, max_features=max_features, random_state=random_state, ) self.quantiles = None def fit(self, X, y, quantiles=(0.025, 0.5, 0.975)): self.rf.fit(X, y) self.quantiles = list(quantiles) self.y_train_ = np.asarray(y) # apply 返回每个训练样本在每棵树中落在哪个叶节点 leaf_ids = self.rf.apply(X) # 按树缓存每个叶节点覆盖的训练样本索引 self._node_samples = [] for tid in range(self.rf.n_estimators): node_map = {} for i, leaf in enumerate(leaf_ids[:, tid]): node_map.setdefault(leaf, []).append(i) self._node_samples.append(node_map) return self def predict_interval(self, X, quantiles=None): quantiles = quantiles if quantiles is not None else self.quantiles X = np.asarray(X) leaf_ids = self.rf.apply(X) out = np.zeros((X.shape[0], len(quantiles))) for i in range(X.shape[0]): vals = [] for tid in range(self.rf.n_estimators): leaf = leaf_ids[i, tid] idx = self._node_samples[tid].get(leaf) if idx is not None: vals.append(self.y_train_[idx]) if vals: all_vals = np.concatenate(vals) out[i] = np.quantile(all_vals, quantiles) else: out[i] = self.rf.predict(X[i:i + 1]) return out

这段代码里有几个关键设计。

fit 阶段调用 rf.apply(X),得到形状为 (n_samples, n_estimators) 的叶节点编号矩阵。随后遍历每棵树,把每个叶节点对应的训练样本索引存进 node_map 字典。predict_interval 阶段,对每个测试样本,查出它在每棵树上的叶节点,再从字典里拿到训练样本索引,把索引对应的 y 值全部收集起来,最后用 np.quantile 一次性计算多个分位数。

np.quantile 默认采用 linear 插值方式,这意味着叶节点样本少时分位数会出现明显的跳变。这一点在第 5 章会展开讲。如果你的数据量特别大、预测样本也有几十万个,这种逐样本循环会偏慢,可以改用矩阵化方式:先批量拿到所有样本的叶节点矩阵,再对每个叶节点预先聚合样本索引。但考虑到多数场景单次预测样本在几千量级,这个实现够用且直观。

3.3 跑通完整流程:训练、预测区间与覆盖率核对

模型封装好后,主流程非常短。

qrf = QuantileRegressionForest( n_estimators=300, min_samples_leaf=30, max_features="sqrt", random_state=42, ) qrf.fit(X_train, y_train, quantiles=(0.025, 0.5, 0.975)) pred = qrf.predict_interval(X_test) lower, mid, upper = pred[:, 0], pred[:, 1], pred[:, 2] # 95% 预测区间覆盖率 coverage = np.mean((y_test >= lower) & (y_test <= upper)) print(f"PICP: {coverage:.3f}")

PICP 是 Prediction Interval Coverage Probability,即真实目标值落入预测区间的比例。95% 名义水平下的区间,PICP 通常在 0.9 到 0.97 之间属于正常。如果明显低于 0.9,说明区间太窄,需要调整参数;如果长期高于 0.98,说明区间过宽,预测区间对决策帮助有限。

再看一眼区间宽度是否随输入变化:

width = upper - lower # 按第一个特征把测试样本分成低区和高区,比较平均宽度 mask_low = X_test[:, 0] < 0 mask_high = X_test[:, 0] >= 0 print(f"x0<0 平均宽度: {width[mask_low].mean():.3f}") print(f"x0>=0 平均宽度: {width[mask_high].mean():.3f}")

由于数据构造时噪声幅度随 x0 绝对值增大,这里你应该能看到明显的宽度差异。这验证了 QRFR 不是给一个固定误差带,而是真的在按输入位置调整区间。如果宽度的落差很小,通常是因为 min_samples_leaf 设得过大,把不同区域的样本混在一起,把异方差信息磨平了。

4. 区间预测效果怎么评估与调参:覆盖率、区间宽度与三个必调参数

4.1 PICP、PINAW 和区间得分:三个指标一起看

工程上评估区间预测,只看覆盖率一个指标远远不够。把区间拉得无限宽,覆盖率 100%,但毫无决策价值。反过来区间极窄,覆盖率高不了。所以实际落地我至少同时看三个指标。

PICP 就是覆盖率,公式很简单:

PICP = (1 / N) · Σ 1(L(x_i) ≤ y_i ≤ U(x_i))

PINAW 是归一化平均区间宽度。直接把宽度除以目标值的极差:

PINAW = (1 / (N · R)) · Σ (U(x_i) - L(x_i))

其中 R 是目标值在测试集上的取值范围。PINAW 越小说明区间越紧凑。两个指标结合看,能得到“窄且准”的区间。

还有一个更严格的综合指标叫区间得分(Interval Score),它同时惩罚过宽和漏掉真实值:

S = (1 / N) · Σ [ (U - L) + (2 / α) · (L - y_i) · 1(y_i < L) + (2 / α) · (y_i - U) · 1(y_i > U) ]

公式里 α 是显著性水平,比如 95% 区间对应 α = 0.05。区间越宽,第一项越大;真实值漏在区间外,第二或第三项会带来沉重惩罚。这个得分越低越好。我一般把这三个指标一起打印:

def evaluate_interval(y_true, lower, upper, alpha=0.05): y_true = np.asarray(y_true) lower = np.asarray(lower) upper = np.asarray(upper) # PICP picp = np.mean((y_true >= lower) & (y_true <= upper)) # PINAW r = np.ptp(y_true) pinaw = np.mean(upper - lower) / r # Interval Score penalty = np.zeros_like(y_true) penalty += 2 / alpha * (lower - y_true) * (y_true < lower) penalty += 2 / alpha * (y_true - upper) * (y_true > upper) score = np.mean((upper - lower) + penalty) return {"PICP": picp, "PINAW": pinaw, "IntervalScore": score} print(evaluate_interval(y_test, lower, upper))

输出结果里 Interval Score 是相对值,没有绝对好坏标准,但可以在参数调优时作为单一目标。实际业务中我也会关注漏检的位置:如果漏掉的样本总是集中在某个特征区间,说明模型在那个区域表达能力不够,或者训练数据稀疏。

4.2 三个必调参数:min_samples_leaf、n_estimators、max_features

QRFR 的参数和随机森林大同小异,但影响方向有差异。

min_samples_leaf 是影响区间质量的第一参数。它直接决定每个叶节点里至少有多少训练样本。设得太小,比如默认的 1,叶节点样本极少,分位数估计方差大,预测区间会呈现严重锯齿状且覆盖率不稳;设得太大,叶节点混入大量分布不同的样本,区间被平均化,宽度整体变大,异方差信息丢失。我的经验是:先从样本总量的 1% 起步,比如 1000 条训练数据设 10,5000 条设 30-50,再用验证集微调。

n_estimators 在点预测里往往 100 棵就够,但对 QRFR 来说,区间稳定性的提升是持续的。因为分位数估计依赖每个叶节点的样本集合,树越多,收集到的混合样本越丰富,分位数曲线越平滑。我在实践中一般设 300 到 500,低于 100 时候区间抖动非常明显。

max_features 默认的 "sqrt" 对大多数表格数据都合适。如果你的输入特征之间有强相关性,可以试 "log2";如果特征数很少(比如 3 到 5 个),可以把它加大到 1.0,让每棵树看到全部特征。特征数多、样本量不足时,"sqrt" 能有效减少过拟合,避免某些特征主导分裂导致叶节点样本偏向。

这三个参数的调优顺序,我习惯是先固定 n_estimators=300,粗调 min_samples_leaf 找到覆盖率合理的区间,然后把 max_features 在 ["sqrt", "log2", 0.8] 里试一遍,最后加大 n_estimators 看稳定性。

4.3 参数敏感性实验怎么跑:别只调参不看稳定性

调参最忌讳只看一次随机切分的结果。同样的数据和参数,换一个 random_state,PICP 波动超过 0.03 都很正常。我一般会做一个小实验:固定参数,用不同的 random_state 跑 10 次切分,记录每次 PICP、PINAW 和 Interval Score,看均值和标准差。

标准差大说明模型对这个超参组合很敏感,这本身就是一种不稳健,部署上线后很容易翻车。反之,如果参数让平均值变好但标准差翻倍,我会宁愿退一步选更平滑的组合。QRFR 的随机性来源有两个:随机森林自身的 bootstrap 抽样和特征抽样,以及训练测试切分。这两个都要在实验中一起抖动,才能看出模型真实水平。

5. QRFR 落地避坑:五个常见翻车点与排查方法

5.1 现象:预测区间画出来是锯齿状阶梯

区间曲线不平滑,一步一级台阶,像阶梯函数。原因是叶节点样本少,np.quantile 能取到的值就那么几个离散档位,分位数估计跳变。

解决的办法按优先级排序:先调大 min_samples_leaf,让每个叶节点至少有 20 到 50 个样本;再把 n_estimators 提高到 300 以上,增加重叠样本量;如果还不行,检查特征是否有大量唯一值太少的哑变量,它们会制造大量叶子节点,把样本打散。还有一种更治本的方式是直接用 quantile-forest 库,它内部对分位数做了平滑处理,但代价是失去对这些细节的控制力。

5.2 现象:95% 区间实际覆盖率只有 70% 到 80%

这是最常见也最危险的翻车。先检查训练集内部覆盖率,用训练数据预测并计算 PICP。如果训练集覆盖率正常而测试集崩了,说明模型分布外泛化能力差,典型的过拟合。如果训练集覆盖率本身就低,多半是 min_samples_leaf 太小,叶节点分位数估计偏差太大。

还有一个容易忽视的原因:测试集分布和训练集不一致。比如训练数据是去年一整年的,测试数据是最近两个月的,业务环境已经变了。这不是调参能解决的,要考虑滚动训练或做特征漂移检测。最直接的验证方式是把测试集按时间排序,逐段看覆盖率,如果后段普遍低,就是分布漂移,不是模型自身问题。

5.3 现象:先 fit 再切分,覆盖率魔幻般高达 0.99

这是个典型的思路错误,而且代码里非常隐蔽。有人把全量数据直接丢进模型的 fit,然后在训练集上随机抽样一部分去做“预测验证”,得到的覆盖率当然高得离谱,因为模型见过这些样本了。

正确顺序一定是先切分、再训练、再预测。更隐蔽的变体是:用 GridSearchCV 做参数调优后,直接用同一份全量数据的最优参数模型去预测全量数据来评估。这同样是数据泄露,因为交叉验证中验证集的信息已经通过参数选择流入了模型。评估时,我永远保留一个从未参与任何训练和调参的 hold-out 测试集。

5.4 现象:特征维度高、样本量少,区间宽度发散

特征 100 个、训练样本 800 条,QRFR 的预测区间忽宽忽窄,完全没有规律。原因在于随机森林在高维稀疏空间中分裂时,每棵树的叶节点覆盖的邻域非常不均匀,某些测试样本落进极小的叶节点,收集到的样本少且分散,分位数自然不稳定。

解决思路有三条:一是用特征筛选把维度压到 20 以内再训练;二是把 max_features 设成 "log2" 降低每棵树对高维空间的依赖;三是提高 n_estimators 到 500,让每棵树能互补。如果这些都不够,考虑先做 PCA 或树模型的特征重要性筛选,再进 QRFR。

5.5 现象:异常值把尾部拉飞,区间宽度被个别点放大

训练集里有一两个极端大的 y 值,它们落进某个叶节点后,所有经过这个区域的测试样本上界会突然飙高。分位数对尾部天然敏感,尤其是 97.5% 这种高分位数,一个异常值就能把区间拉宽。

我一般在训练前先对 y 做一次简单的截断处理,比如用分位数把上下 1% 的极值压缩(Winsorize)。这不会对点预测模型伤筋动骨,但能让区间宽度稳定很多。如果业务场景不允许修改原始目标值,那就只能调大 min_samples_leaf,让异常值被更多正常样本稀释,代价是整个区间会变宽。

6. 一个进阶习惯:用滚动验证与分段覆盖率检验 QRFR 是否真的可靠

当你决定把 QRFR 用到真实业务里,我建议养成两个习惯:滚动验证和分段覆盖率检验。前者解决数据分布漂移问题,后者解决区间是否在全局均匀有效的问题。

滚动验证的做法是:把训练数据按时间排序,用前 k 个窗口训练,预测下一个窗口,逐步向后滑动。这和时序预测里的 walk-forward 验证一样,能模拟上线后的真实使用方式。如果业务数据没有时间属性,也可以用 KFold 或者拟随机切分,但千万别用默认的 StratifiedKFold 直接套在回归任务上,要知道回归的切分需要保持分布完整,shuffle 随机切分通常就够了。

分段覆盖率检验更直观:把测试集按预测中位数排序,分成若干段,分别统计每段覆盖率。如果某一段覆盖率明显偏低,说明模型在预测值偏大或偏小的区域上估计不准。一个实用的实现片段:

order = np.argsort(mid) n_seg = 5 seg_size = len(y_test) // n_seg for k in range(n_seg): idx = order[k * seg_size:(k + 1) * seg_size] seg_cov = ((y_test[idx] >= lower[idx]) & (y_test[idx] <= upper[idx])).mean() print(f"segment {k + 1}: coverage = {seg_cov:.3f}")

如果前几段覆盖率 0.97、后几段 0.72,说明模型对高预测值区间的把握明显不足,可能是训练数据在这些区间样本少,也可能是异方差结构没被充分学习。这时候我会回头检查是不是 min_samples_leaf 设太大,把尾部特征磨平了。

我还习惯在模型上线前做一次保守性检查:故意挑一批训练集中很少出现的特征组合,跑一遍预测,看区间宽度是否明显加宽。如果宽度和常规样本差不多,那这个区间在稀疏区域就是不诚实的,需要靠调参或者补充数据来修正。有一段时间我做完区间预测直接看整体 PICP 就交付了,后来某次换新品数据后整个模型翻车,才发现没做分段校验。从那以后,只要是 QRFR 的多输入单输出区间预测,我一定会同时出整体覆盖率、分段覆盖率和宽度三张图交叉确认。希望这个习惯也帮到你。

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

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

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

立即咨询