随机森林
随机森林(Random Forest, RF)是集成学习(Ensemble Learning)的代表算法之一:它用 Bootstrap 抽样和特征随机选择"制造"出几百棵互不相同的决策树,再让这些树"投票表决"出最终结果。随机森林在表格数据的分类与回归任务上素有"开箱即用的强基线"之称——几乎不用精心调参就能拿到相当不错的成绩,还能顺带给出特征重要性排序,因此是数学建模竞赛中分类预测、风险评估、特征筛选类题目的高频武器。本文从原理、适用场景、评价指标、可视化图表到可运行代码(含手写实现与 sklearn 双对照),完整梳理随机森林的竞赛实战用法,并全程与单棵决策树逐项对比,讲清"森林为何比一棵树好"。
一、算法含义
1.1 通俗理解
一棵决策树就像一位"专家":它根据经验规则("年龄是否大于 30?收入是否低于 5 万?")逐层判断,最终给出结论。单棵决策树的问题是太容易"钻牛角尖"——它把训练数据记得太死(低偏差、高方差),换个样本就出错。随机森林的思路是**"三个臭皮匠,顶个诸葛亮"**:
- 训练 棵(通常 100~500 棵)互不相同的决策树——每棵树的训练数据不同(Bootstrap 抽样)、每次分裂时能看的特征也不同(特征随机选择),所以每棵树犯的错各不相同;
- 预测时让所有树投票(分类取多数票,回归取平均值),少数树的错误被多数树的正确"淹没";
- 每棵树"独立"地从不同角度犯错,平均之后误差相互抵消——方差被大幅压低,模型从"过拟合的锯齿边界"变成"平滑稳健的边界"。
一句话:随机森林 = Bootstrap 抽样 + 随机特征选择 + 决策树 + 多数投票(平均)。
1.2 集成学习与 Bagging
集成学习(Ensemble Learning):把多个"弱而不同"的基学习器组合起来,获得比任何单个学习器都更好的性能。要让集成有效,两个条件缺一不可:
- 准确性:每个基学习器至少比随机猜测强一点(弱学习器);
- 多样性:基学习器之间犯的错尽量不重叠——错误不相关时才能互相抵消。
Bagging(Bootstrap Aggregating,装袋法) 是实现"多样性"的经典手段:对同一训练集做 次 Bootstrap 抽样,训练 个基学习器,再聚合(分类投票、回归平均)。
Bootstrap 抽样:从 个样本中有放回地随机抽 个,构成一份自助样本 。每个样本被抽中的概率为
即每棵树大约只"见过" 63.2% 的不同样本;剩下的约 36.8% 样本没被抽中,称为袋外(Out-of-Bag, OOB)样本。这一现象在第六节代码中有实测验证( 时一次抽样抽中不同样本的比例为 0.602,接近理论值 0.632)。
聚合(Aggregation):
- 分类(多数投票):,即统计 棵树各自预测的类别,票数最多的类别胜出;也可以对各类别概率取平均后取最大(软投票,通常更细腻);
- 回归(简单平均):。
1.3 两个"随机":随机森林区别于普通 Bagging 的关键
普通 Bagging 只对样本做随机(Bootstrap),树与树之间仍可能高度相似(尤其当存在少数极强特征时,每棵树都会先选同样的根分裂)。随机森林在此基础上加入了第二重随机——特征随机:
- 样本随机(行随机):每棵树用一份 Bootstrap 样本 训练;
- 特征随机(列随机):树的每一次分裂,只从 个特征中随机抽取 个候选特征,从中选最优分裂(基尼指数或信息增益最大)。默认取值:
特征随机降低了树与树之间的相关性 (见 1.5 节),让"多样化投票"真正发挥作用,同时大幅减少了每棵树对少数强特征的依赖,使弱特征也有机会参与分裂。
1.4 袋外样本与 OOB 误差——"免费的交叉验证"
第 棵树只见过 中的样本,于是对任意训练样本 ,都存在一批没训练过它的树:
用 中的树对样本 投票,得到袋外预测 :
OOB 误差 = 全体样本上袋外预测的误判率:
为什么说它是"免费的交叉验证"?因为对每个样本,投票的树都从未见过它,等价于留一验证——但不用重新训练任何模型,训练过程中顺带就得到了。理论(Breiman, 1996)与大量实验表明,OOB 误差与同规模交叉验证的估计非常接近,因此在竞赛中可以不划分验证集就用 OOB 误差评估泛化能力(sklearn 中设 oob_score=True 即可)。第六节实例中 OOB 误差 0.069 与测试误差 0.073 只差 0.004,正是这一点的直接证据。
1.5 为什么能降低方差
以平方损失为例,模型的期望泛化误差可分解为:
单棵完全生长的决策树偏差低、方差高(对训练数据的微小扰动非常敏感,极易过拟合)。设每棵树的方差为 、树间两两相关系数为 ,则 棵树的平均的方差为
由公式立刻读出三点:
- (树完全相同):方差不下降——集成白做;
- (树完全独立):方差降为 ,随树数线性下降;
- :方差有下界 ,决定森林方差下限的是树间相关性。
随机森林正是靠特征随机压低 、靠Bootstrap + 大 摊薄余下的方差,从而在几乎不增加偏差的前提下大幅压低方差——这是它"抗过拟合"的数学根源。
1.6 算法流程
- 输入:训练集 ,树数量 ,分裂候选特征数 ;
- 对 :
- 对训练集做 Bootstrap 抽样,得到 (有放回抽 个),记 OOB 样本集 ;
- 在 上训练一棵决策树:每次分裂先从 个特征中随机选 个,再在其中选最优特征与切分点(分类用基尼指数/信息增益,回归用平方误差),通常完全生长、不剪枝();
- 输出: 棵树 ;预测时多数投票(分类)或取平均(回归);同时由 计算 OOB 误差。
1.7 优缺点
优点:
- 精度高、抗过拟合:方差被集成摊薄,很少需要剪枝或正则,默认参数即可打天下;
- 无需特征预处理:不要求标准化/归一化(树按阈值分裂,对单调变换不敏感),连续与离散特征可混合使用,对缺失值有一定容忍度;
- 自带两个"副产品":OOB 误差(无需验证集的泛化评估)与特征重要性(筛选特征、解释变量的相对作用);
- 可并行训练: 棵树互不依赖,
n_jobs=-1全核并行,速度与核数近似成正比; - 稳健:对离群点、噪声特征、共线性不敏感(噪声特征的重要性自然趋近 0);
- 支持分类、回归、多分类,且通过
class_weight="balanced"可应对类别不平衡。
缺点:
- 不可解释:只能给出特征重要性排序,给不出"如果…那么…"的业务规则(需要规则解释时应改用单棵决策树);
- 模型体积大: 棵完全生长的树很占内存,预测速度与 成正比,不适合低时延/嵌入式场景;
- 概率输出未经校准:由投票比例得到的"概率"常呈 S 形失真(过度自信/不自信),需要精确概率时应做校准或改用 Logistic 回归;
- 不能外推:预测值被限制在训练数据取值范围内,时间序列趋势外推、超出取值范围的回归预测都会失败;
- 高维稀疏数据不占优:如 TF-IDF 上百万维的文本数据,线性模型或梯度提升树通常更好;
- 训练时间虽可并行,但总计算量仍明显大于单棵决策树或线性模型。
二、何时使用(适用场景与条件)
2.1 适用场景
- 表格数据分类/回归的强基线:拿到一份"样本 × 特征"的表格数据(几十到几万行),第一件事就是把随机森林跑一遍当作基线——它在 Kaggle 等比赛中长期扮演"首个提交版本"的角色,后续任何改进都要先超过它;
- 特征重要性排序与特征筛选:竞赛中常用随机森林的 MDI/置换重要性为特征排序,筛选出关键变量,再交给其他模型或构建指标体系(重要性归一化后可当权重用,与熵权法、AHP 形成对照);
- 多分类与类别不平衡:多分类天然支持;不平衡数据配合
class_weight="balanced"或class_weight="balanced_subsample"使用; - 特征质量不明、含有噪声特征:噪声特征几乎不影响森林精度(其重要性自动接近 0),适合"不知道哪些变量有用"的探索阶段;
- 混合型特征:连续变量 + 离散变量(含高基数类别变量)直接喂进去即可,不需要哑变量编码(编码反而可能变差);
- 需要快速给出一个"说得过去的答案":竞赛时间紧、调参资源有限时,随机森林是性价比最高的选择。
2.2 竞赛典型题目
- 信用违约预测:用借款人的年龄、收入、负债率、历史逾期等特征预测是否违约,输出违约概率与特征重要性(重要性大的特征即"关键风险因子");
- 疾病诊断/风险预警:体检指标 → 患病风险分类;工业传感器数据 → 设备故障预警(二分类);
- 客户流失预测:运营商/电商的用户行为特征 → 是否流失,再用重要性找出流失驱动因素;
- 指标体系构建:先用 RF 计算各指标重要性并归一化为权重,再与熵权法/AHP 的权重对比,作为论文的"主客观权重结合"段落;
- 基线对照:论文中"我们提出的 X 模型"必须与随机森林、Logistic 等基线对比,RF 是最好的基线之一。
2.3 使用前提
- 数据形态:结构化表格数据(样本 × 特征),特征是有信息的连续或离散变量;图像、文本等原始信号需要先手工提取特征才能用;
- 有监督:必须有标签(分类的类别或回归的数值);
- 样本量:几百到几万行最合适。 太小(如 < 100)时 Bootstrap 抽样波动大、每棵树都不可靠; 太大时训练时间线性上升(可并行缓解);
- 特征规模: 与 的比例不宜极端。 时仍能跑,但应适当调大 (如 )保证树间多样性;
- 取值范围内预测:只做"内插"式预测,不做外推。
2.4 不适用情形
- 需要向业务方解释规则:"为什么拒绝这笔贷款"需要"如果收入 < 3000 且负债 > 50% 则拒绝"式的规则 → 用单棵决策树(CART,深度 3~5)并打印规则;
- 需要精确的预测概率:RF 的投票比例不等于真实概率 → 改用 Logistic 回归,或对 RF 概率做 Platt/isotonic 校准;
- 需要外推:预测明年销量(超出历史范围)、外插温度曲线 → 用回归模型,不用 RF;
- 原始图像/语音/文本:直接用 CNN、Transformer 等深度模型,RF 在原始信号上无能为力;
- 低时延在线推理、嵌入式部署:内存与时延受限 → 单棵树、逻辑回归或小型梯度提升模型;
- 超高维稀疏数据:如百万维 TF-IDF → 线性模型(朴素贝叶斯、线性 SVM、Logistic)更合适;
- 强调单变量统计推断("收入每增加 1 元,违约概率增加多少",需要系数、置信区间、p 值)→ 用 Logistic/线性回归。
2.5 与决策树 / GBDT / SVM / Logistic 的对比选择
| 方法 | 优势 | 劣势 | 何时选它 |
|---|---|---|---|
| 决策树 | 可解释(规则路径)、无需预处理、速度快 | 高方差、极易过拟合、精度低 | 需要规则解释;做随机森林/GBDT 的基学习器 |
| 随机森林 | 强基线、抗过拟合、特征重要性、OOB 验证、可并行 | 黑箱、概率未校准、体积大、不能外推 | 表格数据分类/回归的默认首选之一,特征筛选 |
| GBDT(XGBoost/LightGBM 等) | 精度天花板更高、可排序/点击率等细粒度任务表现好 | 调参敏感、易过拟合、训练串行较慢 | 追求排名与极限精度;样本量大(≥ 10 万) |
| SVM | 小样本高维强、核技巧处理非线性、理论完备 | 大样本训练慢()、概率输出麻烦 | 小样本高维(如文本、基因表达)分类 |
| Logistic 回归 | 系数可解释 + 概率天然校准 + 统计推断(OR、p 值) | 线性假设、需特征工程 | 需要系数解释、概率输出、做规则化评分卡 |
竞赛口诀:"先跑随机森林当基线,再上 XGBoost 冲精度,解释交给单树与 Logistic。"
三、算法指标
以下指标以二分类为例(多分类只需推广混淆矩阵)。符号建立在混淆矩阵上,约定"正类"是竞赛中关心的那一类(如"违约""患病")。
3.1 混淆矩阵(基础)
| 预测为 0 | 预测为 1 | |
|---|---|---|
| 真实为 0 | TN(真负例) | FP(假正例,误报) |
| 真实为 1 | FN(假负例,漏报) | TP(真正例) |
3.2 准确率 Accuracy
含义与解读:所有样本中被分对的比例,最直观的指标。注意:类别严重不平衡时会失真(如 99:1 的数据,"全预测多数类"就有 0.99 的准确率),此时应看 F1、ROC-AUC 或混淆矩阵本身。本例两类 1:1,准确率可信。
3.3 精确率 Precision
含义与解读:预测为"正"的样本中真正为正的比例,衡量误报的严重程度。查准率高 = "抓到的都是真的"。竞赛中用于"宁可少报、不可错报"的场景(如起诉、骚扰式营销)。
3.4 召回率 Recall
含义与解读:真实为正的样本中被找出的比例,衡量漏报的严重程度。查全率高 = "该抓的都抓到了"。竞赛中用于"宁可错报、不可漏报"的场景(如疾病筛查、欺诈拦截)。
3.5 F1 分数
含义与解读:精确率与召回率的调和平均,两者均衡时才高(一个低,F1 就被拉低)。类别不平衡、且正类更重要时的首选单值指标。推广形式 可通过 给召回率更大权重。
3.6 ROC-AUC
- 真阳性率 (即召回率);假阳性率 ;
- ROC 曲线:把分类器输出的得分(RF 中为投票概率)从高到低当作阈值扫描,每取一个阈值得一对 ,连成曲线;
- AUC = ROC 曲线下面积,。
含义与解读:随机抽一对正负样本,正样本得分更高的概率。AUC 与阈值无关,只衡量"排序能力",是竞赛中最常用的分类总评指标:0.5 = 随机猜测;0.70.8 一般;0.80.9 较好;> 0.9 优秀。类别不平衡时 AUC 比准确率可靠得多。
3.7 OOB 误差——无需测试集的内部验证
含义与解读:每个训练样本只用"没训练过它的树"(约 36.8% 的树)投票,统计误判率。它是随机森林独有的指标(其他模型没有"免费的袋外样本"),与 折交叉验证的估计精度相当,但零额外开销。用途:① 不划分验证集即可评估泛化误差(竞赛数据稀缺时尤其珍贵);② 观察误差随树数量下降的过程,确定 取多大;③ 作调参依据(如网格搜索 max_features、min_samples_leaf)。注意:树太少(如 1~2 棵)时大量样本没有袋外树,OOB 误差会严重失真(见 7.3 节)。
3.8 特征重要性(MDI 与置换重要性)
MDI(Mean Decrease in Impurity,基尼下降法):记录每棵树中每个特征分裂带来的不纯度下降量,按到达节点的样本占比加权,再对全部树取平均并归一化。节点 的基尼指数为
特征 的 MDI 重要性:
sklearn 中即 rf.feature_importances_,默认已归一化使和为 1。
置换重要性(Permutation Importance):在测试集上,把某列特征随机打乱,看模型得分下降多少:
含义与解读:数值越大特征越重要;纯噪声特征的重要性应接近 0(本例两个噪声特征的置换重要性只有 0.012 与 0.005)。注意:MDI 是训练集内部统计量,偏好高基数/连续特征且受共线性干扰;置换重要性基于测试集、模型无关,论文中两者一起报告更有说服力。重要性是相对值,只能比大小,不能解读为"因果效应"。
3.9 树数量 n_estimators 的影响
含义与解读: 越大,投票越稳、方差越小,但训练/预测时间线性增加。经验规律:误差在 较小时(< 50)随 快速下降,之后趋于平稳;一般 100~500 棵足够,更多只增加成本不增加精度。竞赛中可通过 OOB 误差曲线选 (取曲线走平的位置)。第六节实例的实测数据见 7.1 节。
3.10 指标汇总表
| 指标 | 中文名 | 公式 | 含义 | 解读 |
|---|---|---|---|---|
| 准确率 | 分对的比例 | 类别均衡时可信;不平衡时失真 | ||
| 精确率 | 预测为正者中真为正的比例 | 高 = 误报少;"少报勿错报"场景 | ||
| 召回率 | 真为正者中被找出的比例 | 高 = 漏报少;"宁错勿漏"场景 | ||
| F1 分数 | 精确率与召回率的调和平均 | 两者均衡才高;不平衡首选 | ||
| ROC 曲线下面积 | 阈值无关的排序能力 | 0.5 随机,>0.8 较好,>0.9 优秀 | ||
| OOB 误差 | 见 3.7 式 | 袋外样本投票的误判率 | 免测试集的泛化估计,≈ CV 精度 | |
| 特征重要性 | 见 3.8 式 | 特征对预测的贡献(相对值) | 噪声特征 ≈ 0;只比大小不比因果 | |
| 树数量 | — | 基学习器个数 | 100~500 足够;看 OOB 曲线定 |
四、可视化图表
下表汇总本节 4 张图(图名与第六节代码保存的文件名一一对应,均存于 figures/ 目录,前缀 rf_)。
| 图名(文件) | 用途 | 关键解读点 |
|---|---|---|
① 树数量与 OOB 误差/测试准确率曲线(rf_oob_curve.png) | 观察误差随树数量的收敛过程,确定 n_estimators 取值 | 左轴 OOB 误差(红实线)从 1 棵时的 0.35 快速跌到 50 棵附近走平;右轴测试准确率(蓝虚线)同步上升趋稳;两曲线趋势一致且数值接近 → OOB 可替代验证集;对数横轴看清 1~10 棵的剧变 |
② 特征重要性条形图(rf_feature_importance.png) | 特征筛选与变量解释,竞赛论文标配图 | 两个真实特征(横纵坐标)重要性占绝对主导(0.38/0.50);两个纯噪声特征接近 0(0.06/0.05)→ 森林自动"无视"噪声;数值只比较大小,不代表因果 |
③ 决策边界对比:单树 vs 森林(rf_decision_boundary.png) | 直观展示集成对边界的"平滑"作用 | 左图单树边界锯齿状、毛刺多(把噪声也记住了,过拟合);右图森林边界平滑圆润、更接近真实的"月亮"形状;两色混叠区是数据固有噪声,任何模型都无法消除 |
④ ROC 曲线:森林 vs 单树(rf_roc.png) | 阈值无关的判别能力总览 | 森林 AUC(约 0.97)明显高于单树(约 0.89);森林曲线更贴近左上角;手写森林与 sklearn 森林两条曲线几乎重合(实现正确性的旁证);对角虚线 = 随机猜测 |
好图的特征:①曲线先陡降后走平(走平点即推荐 );②噪声特征重要性远低于真实特征(差一个数量级);③森林边界比单树明显平滑;④ROC 明显凸向左上角、AUC > 0.9。
异常图的特征(出现即警惕):①OOB 误差随树数量不降反升或剧烈震荡 → 特征无信息或标签噪声严重;②所有特征重要性几乎相等且都不接近 0 → 特征间共线性严重(改用置换重要性);③森林边界与单树一样崎岖 → 太小或 max_features 设置错误;④ROC 贴着对角线(AUC≈0.5)→ 模型没学到东西,检查特征与标签对齐、数据泄露。
五、符号说明
| 符号 | 含义 | 示例/单位 |
|---|---|---|
| 样本量 | 本例 (训练 450 / 测试 150) | |
| 特征维数 | 本例 (2 个真实 + 2 个噪声) | |
| 第 个样本的特征向量与标签 | (二分类) | |
树的数量(n_estimators) | 本例 | |
| 第 棵树的 Bootstrap 样本集 | 有放回抽 个,覆盖约 63.2% 样本 | |
| / | 第 棵树 / 样本 的袋外集合 | ,约 棵树 |
| 每次分裂随机候选的特征数 | 分类 ,回归 | |
| 第 棵决策树的预测 | 类别或类别概率 | |
| 集成预测(多数投票/平均) | ||
| 样本 的袋外预测 | 仅用 中的树投票 | |
| OOB 误差 | 无量纲,,越接近测试误差越好 | |
| 真正/真负/假正/假负例数 | 个(本例见 7.1 节混淆矩阵) | |
| 准确率/精确率/召回率/F1 | 无量纲,,越大越好 | |
| 真/假阳性率 | , | |
| ROC 曲线下面积 | 无量纲, | |
| 基尼指数(节点不纯度) | , | |
| 特征 的 MDI 重要性 | 归一化后 | |
| 置换重要性(打乱特征后得分下降) | 得分单位(如准确率),越大越重要 | |
| 树间两两相关系数 | ,越小集成收益越大 | |
| 单棵树的预测方差 | 方差分解中的方差项 |
六、可运行程序(完整代码)
环境要求:Python 3.12,依赖 numpy、scikit-learn、matplotlib、pandas(
pip install numpy scikit-learn matplotlib pandas)。以下代码块按顺序拼接保存为rf_demo.py,在本文档所在目录运行即可:控制台打印全部指标,并在figures/子目录生成 4 张图(文件名以rf_开头)。数据为程序内合成的"双月牙 + 2 个噪声特征"二分类数据(,4 个特征,np.random.seed(42)保证可复现),无外部文件依赖。程序依次完成:合成数据 → 手写随机森林(Bootstrap 抽样 + sklearn 决策树组装 + 手算 OOB 误差)→ sklearnRandomForestClassifier与单棵决策树对照 → 打印第三节全部指标 → 特征重要性(MDI 手写/官方 + 置换重要性)→ 树数量影响实验 → 绘制第四节全部 4 张图并plt.show()。运行输出的典型数值解读见第七节。
# -*- coding: utf-8 -*-
# ========== 0. 导入库与全局设置 ==========
# 运行环境: Python 3.12; 依赖库: numpy / scikit-learn / matplotlib / pandas(scipy 本例未用到)
import os
import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
f1_score, roc_auc_score, roc_curve, confusion_matrix)
# 中文显示设置(macOS 用 PingFang SC,Windows 用 SimHei,Linux 可用 Arial Unicode MS)
plt.rcParams["font.sans-serif"] = ["PingFang SC", "Arial Unicode MS", "SimHei"]
plt.rcParams["axes.unicode_minus"] = False
# ---- 无图形界面环境下 plt.show() 会提示 "non-interactive",过滤掉以保持输出干净 ----
warnings.filterwarnings("ignore", message=".*non-interactive.*")
os.makedirs("figures", exist_ok=True) # 图片输出目录
# ========== 1. 生成合成分类数据(自包含,无外部文件依赖) ==========
np.random.seed(42) # 固定随机种子,保证结果可复现
# 月亮形数据:两个"月牙"靠得很近,线性模型无法分开,单棵决策树边界会很曲折
X_moon, y = make_moons(n_samples=600, noise=0.25, random_state=42)
# 手工拼接 2 列与 y 完全无关的标准正态噪声,用于演示"噪声特征重要性≈0"
X = np.column_stack([
X_moon,
np.random.RandomState(7).randn(600), # 噪声特征 2
np.random.RandomState(8).randn(600), # 噪声特征 3
])
# 共 4 个特征:特征 0、1 为月亮的横纵坐标(真正有用),特征 2、3 为纯噪声
feature_names = ["特征0(横坐标)", "特征1(纵坐标)", "特征2(噪声)", "特征3(噪声)"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y)
print("训练集形状:", X_train.shape, " 测试集形状:", X_test.shape)
# 顺便验证 63.2% 现象:一次 bootstrap 抽样中,约有多少比例的不同样本被抽中
idx0 = np.random.RandomState(42).randint(0, len(X_train), len(X_train))
unique_ratio = len(np.unique(idx0)) / len(X_train)
print(f"一次 bootstrap 抽样抽中的不同样本比例: {unique_ratio:.3f}(理论值约 0.632)")
# ========== 2. 手写随机森林(bootstrap 抽样 + 决策树基学习器 + 多数投票 + OOB 误差) ==========
class HandRandomForest:
"""手写随机森林:
1. 每棵树用一份 bootstrap 样本(有放回抽 n 个)训练;
2. 每棵树分裂时随机选 sqrt(p) 个特征(交给 DecisionTreeClassifier 的 max_features);
3. 预测用所有树的多数投票;
4. OOB 误差:对每个样本只用"没抽到它"的那些树投票,等价于免费交叉验证。
"""
def __init__(self, n_trees=100, max_features="sqrt", max_depth=None, random_state=42):
self.n_trees = n_trees # 树的数量
self.max_features = max_features # 每次分裂随机考虑的特征数
self.max_depth = max_depth # 树深度限制(None = 完全生长)
self.random_state = random_state
def fit(self, X, y):
"""训练 T 棵树"""
n = len(X)
self.classes_ = np.unique(y)
self.X_train_ = np.asarray(X)
self.y_train_ = np.asarray(y)
rng = np.random.RandomState(self.random_state) # 独立随机流,不影响全局种子
self.trees_ = []
self.oob_indices_ = [] # 每棵树对应的"袋外样本"索引
for t in range(self.n_trees):
# ---- 随机性之一:样本随机(bootstrap 有放回抽样) ----
idx = rng.randint(0, n, size=n) # 有放回抽 n 个 → 约 63.2% 不同样本被抽中
self.oob_indices_.append(np.setdiff1d(np.arange(n), idx)) # 没被抽中的 = 袋外样本
# ---- 随机性之二:特征随机(由 max_features="sqrt" 实现) ----
tree = DecisionTreeClassifier(
max_features=self.max_features, # 每次分裂随机选 √p 个特征
max_depth=self.max_depth,
random_state=self.random_state + t, # 每棵树种子不同 → 每棵树都不同
)
tree.fit(X[idx], y[idx])
self.trees_.append(tree)
return self
def predict(self, X):
"""多数投票:统计每棵树投给各类别的票数,取票多者"""
votes = np.array([tree.predict(X) for tree in self.trees_]) # 形状 (n_trees, n)
y_hat = np.array([np.bincount(votes[:, i]).argmax() for i in range(votes.shape[1])])
return y_hat
def predict_proba(self, X):
"""平均概率:每棵树输出类别概率,取平均(比硬投票更细腻)"""
proba = np.array([tree.predict_proba(X) for tree in self.trees_]).mean(axis=0)
return proba
def oob_predictions_(self):
"""OOB 预测:对训练集每个样本,只用'训练时没抽到它'的树投票"""
n = len(self.X_train_)
votes = [[] for _ in range(n)]
for t, tree in enumerate(self.trees_):
oob_idx = self.oob_indices_[t]
pred = tree.predict(self.X_train_[oob_idx])
for i, p in zip(oob_idx, pred):
votes[i].append(p)
y_oob = np.full(n, -1)
for i in range(n):
if len(votes[i]) > 0: # 该样本至少有一棵袋外树
y_oob[i] = np.bincount(votes[i]).argmax()
return y_oob
def oob_score(self):
"""OOB 准确率(1 - OOB 误差)"""
y_oob = self.oob_predictions_()
mask = y_oob >= 0 # 排除没有任何袋外树的样本(概率极低,几乎不会发生)
return (y_oob[mask] == self.y_train_[mask]).mean()
# ========== 3. 训练三个模型:手写森林、sklearn 森林、单棵决策树(对照) ==========
rf_hand = HandRandomForest(n_trees=200, max_features="sqrt", random_state=42)
rf_hand.fit(X_train, y_train)
rf_sk = RandomForestClassifier(
n_estimators=200, # 树的数量(经验:100~500 基本足够)
max_features="sqrt", # 每次分裂随机选 √p 个特征(分类任务默认)
oob_score=True, # 打开袋外估计:无需单独验证集即可评估泛化误差
random_state=42,
n_jobs=-1, # 并行训练所有核
)
rf_sk.fit(X_train, y_train)
tree_single = DecisionTreeClassifier(random_state=42) # 单棵决策树作对照
tree_single.fit(X_train, y_train)
# ========== 4. 打印第三节要求的全部指标(分类指标 + OOB 误差) ==========
def print_report(name, y_true, y_pred, y_proba, oob_err=None):
print(f"\n================ {name} ================")
print("混淆矩阵 (行=真实类别, 列=预测类别):")
print(confusion_matrix(y_true, y_pred))
print(f"准确率 Accuracy : {accuracy_score(y_true, y_pred):.4f}")
print(f"精确率 Precision : {precision_score(y_true, y_pred):.4f}")
print(f"召回率 Recall : {recall_score(y_true, y_pred):.4f}")
print(f"F1 分数 : {f1_score(y_true, y_pred):.4f}")
print(f"ROC-AUC : {roc_auc_score(y_true, y_proba):.4f}")
if oob_err is not None:
print(f"OOB 误差(袋外) : {oob_err:.4f} <-- 训练集内部验证,无需测试集")
y_pred_hand = rf_hand.predict(X_test)
y_pred_sk = rf_sk.predict(X_test)
y_pred_tree = tree_single.predict(X_test)
proba_hand = rf_hand.predict_proba(X_test)[:, 1]
proba_sk = rf_sk.predict_proba(X_test)[:, 1]
proba_tree = tree_single.predict_proba(X_test)[:, 1]
print_report("手写随机森林 (200棵树)", y_test, y_pred_hand, proba_hand, oob_err=1 - rf_hand.oob_score())
print_report("sklearn 随机森林 (200棵树)", y_test, y_pred_sk, proba_sk, oob_err=1 - rf_sk.oob_score_)
print_report("单棵决策树 (对照)", y_test, y_pred_tree, proba_tree)
# ========== 5. 特征重要性:MDI(基尼下降)与置换重要性,含手写/官方对比 ==========
imp_mdi = rf_sk.feature_importances_ # 官方 MDI 重要性
imp_hand = np.mean([t.feature_importances_ for t in rf_hand.trees_], axis=0) # 手写森林 MDI(各树平均)
perm = permutation_importance(rf_sk, X_test, y_test, n_repeats=10, random_state=42)
imp_perm = perm.importances_mean # 置换重要性
imp_df = pd.DataFrame({
"特征": feature_names,
"手写森林MDI": imp_hand,
"sklearn森林MDI": imp_mdi,
"置换重要性": imp_perm,
})
print("\n================ 特征重要性对比 ================")
print("(特征2、特征3 是纯噪声特征,它们的重要性应明显偏低、置换重要性接近 0)")
print(imp_df.to_string(index=False, float_format=lambda v: f"{v:.4f}"))
# ========== 6. 树数量 n_estimators 的影响:OOB 误差 vs 测试误差 ==========
n_list = [1, 2, 5, 10, 20, 50, 100, 200, 400]
oob_errs, test_errs = [], []
for n_est in n_list:
rf_tmp = RandomForestClassifier(n_estimators=n_est, oob_score=True,
random_state=42, n_jobs=-1)
with warnings.catch_warnings():
# 树太少(如 1~2 棵)时部分样本没有袋外树,sklearn 会发警告,这里忽略
warnings.simplefilter("ignore")
rf_tmp.fit(X_train, y_train)
oob_errs.append(1 - rf_tmp.oob_score_) # OOB 误差:训练集内部验证
test_errs.append(1 - rf_tmp.score(X_test, y_test)) # 测试误差 = 1 - 测试准确率
df_ntree = pd.DataFrame({"树数量": n_list, "OOB误差": oob_errs, "测试误差": test_errs})
print("\n================ 树数量 n_estimators 的影响 ================")
print(df_ntree.to_string(index=False, float_format=lambda v: f"{v:.4f}"))
print("解读:树从 1 棵增到 50~100 棵时误差快速下降并趋于平稳;OOB 误差与测试误差非常接近。")
# ========== 7. 图1:树数量与 OOB 误差 / 测试准确率曲线(展示"树越多越稳") ==========
fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(n_list, oob_errs, "o-", lw=2, color="#C44E52", label="OOB 误差(左轴)")
ax.set_xscale("log") # 树数量取对数坐标,便于观察 1~10 棵时的剧烈变化
ax.set_xlabel("树数量 n_estimators")
ax.set_ylabel("OOB 误差")
ax.set_ylim(0.0, 0.45)
ax.grid(alpha=0.3)
ax2 = ax.twinx()
ax2.plot(n_list, [1 - e for e in test_errs], "s--", lw=2, color="#4C72B0", label="测试准确率(右轴)")
ax2.set_ylabel("测试准确率")
ax2.set_ylim(0.75, 1.0)
h1, l1 = ax.get_legend_handles_labels()
h2, l2 = ax2.get_legend_handles_labels()
ax.legend(h1 + h2, l1 + l2, loc="center right")
ax.set_title("随机森林:树数量对 OOB 误差与测试准确率的影响(树越多越稳)")
plt.tight_layout()
plt.savefig("figures/rf_oob_curve.png", dpi=200)
plt.show()
# ========== 8. 图2:特征重要性条形图(噪声特征应接近 0) ==========
order = np.argsort(imp_mdi) # 升序排列,画水平条形图时从下往上由小到大
fig, ax = plt.subplots(figsize=(8, 5))
bars = ax.barh(np.array(feature_names)[order], imp_mdi[order], color="#4C72B0")
for b, v in zip(bars, imp_mdi[order]):
ax.text(b.get_width() + 0.002, b.get_y() + b.get_height() / 2,
f"{v:.3f}", va="center")
ax.set_xlabel("MDI 特征重要性(基尼下降量)")
ax.set_title("随机森林特征重要性:噪声特征的重要性应接近 0")
plt.tight_layout()
plt.savefig("figures/rf_feature_importance.png", dpi=200)
plt.show()
# ========== 9. 图3:决策边界对比(单棵决策树 vs 随机森林,两个子图) ==========
# 只用前 2 个特征重新训练,便于在二维平面上画出决策边界
X2_train, X2_test = X_train[:, :2], X_test[:, :2]
dt2 = DecisionTreeClassifier(max_depth=None, random_state=42).fit(X2_train, y_train)
rf2 = RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1).fit(X2_train, y_train)
x_min, x_max = X2_train[:, 0].min() - 0.5, X2_train[:, 0].max() + 0.5
y_min, y_max = X2_train[:, 1].min() - 0.5, X2_train[:, 1].max() + 0.5
xx, yy = np.meshgrid(np.linspace(x_min, x_max, 300),
np.linspace(y_min, y_max, 300))
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
for ax, model, title in zip(
axes,
[dt2, rf2],
["单棵决策树:边界曲折、毛刺多(过拟合)", "随机森林:边界平滑、更稳健"]):
Z = model.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
ax.contourf(xx, yy, Z, alpha=0.35, cmap="RdBu")
ax.scatter(X2_train[:, 0][y_train == 0], X2_train[:, 1][y_train == 0],
c="#C44E52", s=15, label="类别 0", edgecolors="k", linewidths=0.3)
ax.scatter(X2_train[:, 0][y_train == 1], X2_train[:, 1][y_train == 1],
c="#4C72B0", s=15, label="类别 1", edgecolors="k", linewidths=0.3)
ax.set_xlabel("特征 0")
ax.set_ylabel("特征 1")
ax.set_title(title)
ax.legend(loc="upper right")
plt.tight_layout()
plt.savefig("figures/rf_decision_boundary.png", dpi=200)
plt.show()
# ========== 10. 图4:ROC 曲线(随机森林 vs 单棵决策树) ==========
fig, ax = plt.subplots(figsize=(7, 6))
for proba, name, ls in [(proba_tree, "单棵决策树", "-"),
(proba_hand, "手写随机森林(200棵)", "-"),
(proba_sk, "sklearn随机森林(200棵)", "--")]:
fpr, tpr, _ = roc_curve(y_test, proba)
auc = roc_auc_score(y_test, proba)
ax.plot(fpr, tpr, lw=2, ls=ls, label=f"{name} (AUC={auc:.4f})")
ax.plot([0, 1], [0, 1], "k--", lw=1, label="随机猜测 (AUC=0.5000)")
ax.set_xlabel("假阳性率 FPR")
ax.set_ylabel("真阳性率 TPR")
ax.set_title("ROC 曲线:随机森林 vs 单棵决策树")
ax.legend(loc="lower right")
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig("figures/rf_roc.png", dpi=200)
plt.show()
print("\n程序运行完毕!4 张图已保存至 figures/ 目录:")
print(" figures/rf_oob_curve.png, figures/rf_feature_importance.png")
print(" figures/rf_decision_boundary.png, figures/rf_roc.png")
七、结果解读与注意事项
7.1 本次示例的运行结果与解读
上述程序(随机种子固定为 42)的输出完全可复现,任何机器上运行结果相同。关键输出如下:
训练集形状: (450, 4) 测试集形状: (150, 4)
一次 bootstrap 抽样抽中的不同样本比例: 0.602(理论值约 0.632)
================ 手写随机森林 (200棵树) ================
混淆矩阵 (行=真实类别, 列=预测类别):
[[69 6]
[ 5 70]]
准确率 Accuracy : 0.9267
精确率 Precision : 0.9211
召回率 Recall : 0.9333
F1 分数 : 0.9272
ROC-AUC : 0.9711
OOB 误差(袋外) : 0.0711 <-- 训练集内部验证,无需测试集
================ sklearn 随机森林 (200棵树) ================
混淆矩阵 (行=真实类别, 列=预测类别):
[[69 6]
[ 5 70]]
准确率 Accuracy : 0.9267
精确率 Precision : 0.9211
召回率 Recall : 0.9333
F1 分数 : 0.9272
ROC-AUC : 0.9705
OOB 误差(袋外) : 0.0689 <-- 训练集内部验证,无需测试集
================ 单棵决策树 (对照) ================
混淆矩阵 (行=真实类别, 列=预测类别):
[[65 10]
[ 6 69]]
准确率 Accuracy : 0.8933
精确率 Precision : 0.8734
召回率 Recall : 0.9200
F1 分数 : 0.8961
ROC-AUC : 0.8933
================ 特征重要性对比 ================
(特征2、特征3 是纯噪声特征,它们的重要性应明显偏低、置换重要性接近 0)
特征 手写森林MDI sklearn森林MDI 置换重要性
特征0(横坐标) 0.3809 0.3791 0.2213
特征1(纵坐标) 0.5007 0.5034 0.2533
特征2(噪声) 0.0624 0.0631 0.0120
特征3(噪声) 0.0560 0.0543 0.0047
================ 树数量 n_estimators 的影响 ================
树数量 OOB误差 测试误差
1 0.3489 0.1400
2 0.2800 0.1000
5 0.1578 0.0867
10 0.0978 0.0867
20 0.0711 0.0800
50 0.0711 0.0867
100 0.0711 0.0800
200 0.0689 0.0733
400 0.0667 0.0733
解读:树从 1 棵增到 50~100 棵时误差快速下降并趋于平稳;OOB 误差与测试误差非常接近。
逐项解读:
- Bootstrap 比例实测 0.602 ≈ 理论 0.632:一次有放回抽样()抽中的不同样本比例为 0.602,与理论极限 吻合(单次抽样的标准差约 0.02),直接验证了 1.2 节的 63.2% 现象——每棵树平均有约 37% 的样本没"见过",这就是 OOB 验证成立的基础。
- 森林全面碾压单树:准确率 0.9267 对 0.8933(提升 3.3 个百分点),F1 0.9272 对 0.8961,ROC-AUC 0.9711 对 0.8933(提升近 0.08)。注意本例类别 1:1 且"提升空间"本身不大(单树已经很接近 0.9),0.08 的 AUC 提升已经相当可观;在更嘈杂的真实数据上,森林相对单树的优势通常更明显。
- OOB 误差 ≈ 测试误差:手写森林 OOB 误差 0.0711、sklearn 森林 OOB 误差 0.0689,而真实测试误差为 ——差距仅 0.004 左右。这就是"OOB 可以替代验证集"的实证:在竞赛中数据稀缺时,可以只用训练集、靠 OOB 误差报告泛化能力。
- 手写实现与 sklearn 完全对得上:混淆矩阵逐格相同(TN=69、FP=6、FN=5、TP=70),五个指标中只有 ROC-AUC 相差 0.0006(手写用"概率平均"投票、sklearn 内部实现细节略有差异所致),MDI 重要性各特征相差不超过 0.003。证明第六节手写的 bootstrap + 组装 + OOB 流程与 sklearn 是同一套算法,论文中可直接用此作为"手写对照验证"段。
- 噪声特征重要性接近 0:特征 2、3 是与 完全无关的标准正态噪声,它们的 MDI 重要性仅 0.063/0.054(真实特征为 0.379/0.503,差一个数量级),置换重要性更夸张:0.012/0.005(打乱它们几乎不影响预测)。这是"随机森林自动忽略噪声特征"的直接证据,也是用置换重要性筛选特征时的典型形态。
- 树数量的收敛过程: 时 OOB 误差 0.3489(注意此时只有约 63% 的样本有袋外树,sklearn 对其余样本用多数类代替,误差被高估,不可当真实泛化误差用)、测试误差 0.14; 时已分别降至 0.0711/0.0867; 后基本走平(0.0689/0.0733), 只再降 0.002。结论:100~200 棵树对本题绰绰有余。表中测试误差在 20~100 棵间有 0.0867→0.0800 的小幅波动,这是随机抽样的正常表现,不表示"树越多越差"。
7.2 四张图的解读(本例)
- 图①(
rf_oob_curve.png):左轴红实线 OOB 误差从 1 棵时的 0.35 陡降到 50 棵附近的 0.07 并走平;右轴蓝虚线测试准确率同步从 0.86 升到 0.93 趋稳。两轴曲线在 后几乎水平——"再加树"的边际收益趋近于 0,这就是选 的依据。OOB 与测试两条曲线的数值始终接近,说明 OOB 是可靠的免费验证。 - 图②(
rf_feature_importance.png):水平条形图从下到上按重要性升序排列,两个噪声特征(0.063、0.054)与两个真实特征(0.379、0.503)形成鲜明落差,一眼即可完成特征筛选:只保留前两个特征即可,模型精度几乎不受影响。 - 图③(
rf_decision_boundary.png):左图单棵树的决策边界呈锯齿状、处处毛刺——它把训练集里的噪声样本逐个"记住"了;右图森林边界平滑圆润,勾勒出两弯月牙的真实形状。两图并排放置,是"方差被平均掉"最直观的视觉证据,也是论文里"集成 vs 单模型"章节的标配图。 - 图④(
rf_roc.png):森林曲线(AUC≈0.97)明显更贴近左上角,单树(AUC≈0.89)次之,随机猜测为对角线;手写森林与 sklearn 森林两条曲线几乎完全重合——既展示集成优势,又旁证手写实现的正确性。
7.3 常见坑与应对
- 树太少: 时模型退化成一棵决策树,方差大、精度低(本例测试误差 0.14,比 的 0.073 差近一倍)。应对:默认 100 起步,用 OOB 误差曲线确认走平;500 棵以上边际收益可忽略,只白白增加时间与内存。
- max_features 设置不当:设成 1(每次只看 1 个特征)树太弱、偏差上升;设成 (每次看全部特征)退化成 Bagging,树间相关性 大、方差降不下来。应对:分类默认
sqrt、回归默认 通常就是好选择;高噪声数据可适当增大 。 - random_state 不固定:Bootstrap 与特征随机都依赖随机数,不固定种子时每次运行结果(指标、重要性、图)都会变,论文无法复现。应对:
random_state=42固定主森林;调参实验也要固定种子,否则"改进"可能是随机波动造成的假象。 - 类别不平衡:正类只占 5% 时,森林倾向全部预测多数类,准确率虚高但召回率接近 0。应对:设
class_weight="balanced"(或"balanced_subsample");报告指标改用 F1、ROC-AUC、PR 曲线;必要时用 SMOTE 类方法过采样后再训练。 - 特征重要性的两个陷阱:① MDI 偏好连续特征与高基数类别特征(分裂机会多),且受共线性影响(两个强相关特征会把重要性"瓜分");② 特征重要性是相关性的反映,不是因果。应对:重要结论用置换重要性(基于测试集、无上述偏好)复核;论文措辞写"与目标关联最强"而非"影响最大"。
- 把 OOB 当测试集反复使用:用 OOB 误差做网格调参没问题,但调完后再报告 OOB 误差就有"数据泄漏"嫌疑(OOB 参与了选择)。应对:调参用 OOB 或交叉验证,最终成绩只报告真正的留出测试集;无测试集时明确写"OOB 误差为袋外估计"。
- 用 RF 做外推:回归任务中,测试点超出训练数据取值范围时,RF 的预测被"夹"在见过的目标值之间,外推必然失败。应对:外推任务改用回归模型(见 2.4 节),RF 只做内插预测。
- 样本量太小: 时 Bootstrap 扰动太大,每棵树都不可靠,OOB 误差也不稳定。应对:小样本优先考虑 Logistic、SVM、朴素贝叶斯,或使用留一交叉验证。
- 树深不加限制导致模型臃肿:完全生长的树在数据大时单棵可达数万个节点, 棵内存爆炸。应对:必要时设
max_depth、min_samples_leaf(如 5~10)压缩树,代价通常是轻微掉点,换来体积与速度。 - RF 的概率直接当风险概率用:投票比例不是校准过的概率(本例 AUC 0.97 但概率绝对值可能系统偏高/偏低)。应对:评分卡、风险定价等需要精确概率的场景,对 RF 输出做 Platt/isotonic 校准,或直接改用 Logistic 回归。
7.4 竞赛论文写作建议(话术模板)
建模段(本文数据实例版,数值与 7.1 节运行输出一致):
本文采用随机森林构建分类模型:以 Bootstrap 抽样(单棵树覆盖约 63.2% 的训练样本)与特征随机选择(每次分裂随机考察 个特征)训练 200 棵决策树,以多数投票聚合预测。在仿真数据上,森林测试准确率 0.9267、ROC-AUC 0.9711,较单棵决策树(0.8933 / 0.8933)分别提升 3.3 个百分点与 0.078;袋外(OOB)误差 0.0689 与测试误差 0.0733 仅差 0.004,表明 OOB 估计可靠,模型未过拟合。两个真实特征的重要性(0.503、0.379)远高于两个注入的噪声特征(0.063、0.054),置换重要性噪声特征仅为 0.012 与 0.005,验证了模型对无关特征稳健、特征重要性排序可信。
对比论证段(评审加分点):
手写实现(Bootstrap + 决策树组装 + 手工袋外投票)与 sklearn
RandomForestClassifier的混淆矩阵完全一致(TN=69、FP=6、FN=5、TP=70),ROC-AUC 相差仅 0.0006,证明手写流程与标准算法等价。进一步考察树数量影响:树数从 1 增至 50 时 OOB 误差由 0.3489 快速降至 0.0711,此后趋于平稳(400 棵时为 0.0667),故取 在精度与计算成本间取得平衡。
指标段(通用模板,⟨ ⟩ 内替换为自己的数值):
采用随机森林( 棵,,
random_state=42)对数据进行分类,测试集准确率 ⟨0.927⟩、F1 分数 ⟨0.927⟩、ROC-AUC ⟨0.971⟩;袋外误差 ⟨0.069⟩ 与测试误差接近,表明泛化性能稳定。特征重要性显示 ⟨特征1、特征2⟩ 为关键变量(置换重要性 ⟨0.25、0.22⟩),其余特征置换重要性均低于 ⟨0.05⟩,予以剔除后模型性能无显著变化。
八、延伸阅读
- GBDT(梯度提升树):与随机森林的"并行 + 投票"不同,GBDT 是串行的:每棵新树拟合前面所有树的残差(负梯度),逐步逼近真实值。偏差更低、精度天花板更高,但更易过拟合、调参更多。竞赛中常"RF 打底、GBDT 冲刺"。
- XGBoost:GBDT 的工程化巅峰,加入二阶梯度、正则化、稀疏感知、早停(early stopping)等特性,长期占据表格数据比赛(Kaggle)的榜首位置。sklearn 接口
xgb.XGBClassifier与 RF 用法几乎一样,可直接替换。 - LightGBM:微软开源的 GBDT 实现,基于直方图算法与 Leaf-wise 生长策略,训练速度比 XGBoost 快数倍、内存占用更低,是大样本(百万级)竞赛的首选,但不适合小样本(易过拟合)。
- ExtraTrees(极端随机树):随机森林的"激进版"——分裂阈值也完全随机选取(不再是"最优切分"),树间相关性更低、训练更快,性能通常与 RF 不相上下,是调参空间最小的集成模型。
- 孤立森林(Isolation Forest):随机森林的无监督兄弟,专做异常检测——利用"异常点更容易被随机切分孤立出来"(平均路径短)的原理打分。竞赛中的离群点筛查、欺诈检测常用
sklearn.ensemble.IsolationForest。 - 推荐资源:Breiman (2001) Random Forests 原始论文(了解理论根基);sklearn 官方用户指南 Ensemble methods 一章(含 OOB、重要性、ExtraTrees 的 API 说明);《统计学习导论》(ISLR)第 8 章基于树的方法(决策树 → Bagging → 随机森林 → Boosting 的完整脉络);周志华《机器学习》第 8 章集成学习(方差降低的数学推导)。