从零用NumPy实现随机森林:CART、OOB与特征重要度全解析
简介:面向机器学习入门者与希望动手复现随机森林算法的高校学生,这份代码资料以经典 sonar 声呐数据集为练习对象,配有详尽中文注释,可直接运行并验证分类效果。压缩包共 4 个文件、约 34KB,主体是两份 Python 实现脚本,一份取自网络,一份由作者重新整理编写,便于对照阅读两种写法;此外还有 csv 数据集和 txt 阅读说明,其中说明提到环境为 Python 2.7 及运行注意事项。目前已有 15746 人学习下载,说明它在算法入门练习中的参考价值获得了一定认可。读者可通过两版代码理解随机森林的建树、特征抽样与投票机制,借助注释快速定位关键步骤,并在此基础上自行调整参数、提升小数据集上的正确率,因此适合作为课程实验或自学练手材料。
1. 从一次「离线 0.98、线上 0.71」说起
随机森林在 Python 里是被调包调得最狠的模型之一,很多人对它的全部认知就是一行RandomForestClassifier(n_estimators=100).fit(X, y)。可一旦线上效果和离线对不上,或者被追问「这个样本为什么被判成这一类」,那十行代码立刻变成黑匣子。这份代码包把森林拆开用 numpy 重写了一遍:CART 分类树、自助采样、特征子采样、概率投票、袋外估计全都在里面,另外配了鸢尾花、乳腺癌、红酒三份可直接加载的数据集和一个通用 CSV 加载器。它适合两类人:刚学完决策树、想看清「集成」到底怎么集成的;以及线上真要用随机森林、却被特征重要度和 OOB 分数问住的。跑完你能拿到准确率、能和 sklearn 对拍,也知道该动哪几个参数。
2. 项目结构与技术选型:先看清每个文件在干什么
拿到一份带数据集的代码包,最怕的是一上来就跑main.py,报错之后不知道是数据的问题还是算法的问题。这个包的目录分得比较克制,算法、数据、脚本、测试四条线互相不交叉,出问题时能一刀切下去定位。
2.1 目录结构与文件职责
random-forest-lab/ ├── data/ │ ├── iris.csv # 三分类花卉数据,150 行 4 特征 │ ├── breast_cancer.csv # 二分类,569 行 30 特征 │ └── make_dataset.py # 从 sklearn 内置数据集导出 CSV ├── rf/ │ ├── __init__.py # 对外只暴露 RandomForestClassifier │ ├── tree.py # CART 分类树,单棵树的全部逻辑 │ ├── forest.py # 森林层:bootstrap、OOB、投票、重要度 │ └── metrics.py # 准确率、混淆矩阵、OOB 分数 ├── scripts/ │ ├── train.py # 命令行训练入口 │ └── compare_sklearn.py # 与 sklearn 对拍 ├── tests/ │ └── test_forest.py # 单测:树能拟合异或、森林不劣于单树 ├── requirements.txt └── README.md拆成tree.py和forest.py两层是刻意的。树是纯函数式的递归结构,输入(X, y)、输出一棵可遍历的节点树,不依赖任何全局状态,单测里可以单独喂一个异或数据集验证它能不能分裂对;森林层只负责「采样 + 造多棵树 + 汇总」,换基学习器时不用动森林代码。metrics.py独立出来是因为 OOB 分数的计算方式和普通测试集准确率不是一回事,混在森林类里容易写错。
data/下放 CSV 而不是运行时现调load_iris(),是为了让数据能被肉眼检查、能被替换。make_dataset.py只跑一次,把内置数据集落盘成 CSV,后面所有脚本都从文件读,这样你把自家业务数据改名丢进去,加载路径不用改。
2.2 技术栈与依赖版本
依赖清单很短,这是有意为之。随机森林的实现只需要矩阵运算和随机数,numpy 一个就够;pandas 用于读 CSV;scikit-learn 只在两个地方出现——导出内置数据集、以及对拍验证,绝不参与自定义实现的主流程,避免「到底是谁在算」这种扯不清的问题。
# requirements.txt numpy>=1.24,<3.0 pandas>=2.0 scikit-learn>=1.3 # 仅用于导出数据集和结果对拍 matplotlib>=3.7 # 特征重要度画图,可选 joblib>=1.3 # 模型持久化,可选不引 scipy 是因为分裂搜索里的分位数直接用np.quantile就够了;不引 numba 是因为这份代码的定位是「看得懂」,加了 JIT 编译之后调试体验急剧下降,想看中间变量都难。真要提速,正确做法是减少候选阈值数量或者直接上 sklearn 的 Cython 实现,而不是在这里堆编译优化。
Python 版本建议 3.9 以上,主要是np.random.default_rng这套新随机数接口在旧版本上行为不完全一致,种子复现会对不上。
2.3 数据集从哪来、怎么切分
包里自带三份数据,覆盖了三种典型场景:鸢尾花是低维小样本、类别均衡,适合验证算法正确性;乳腺癌是高维中等样本,适合看特征重要度有没有意义;红酒数据介于两者之间,适合做参数扫描。三份都能在一秒内跑完,不用等。
自己接数据时用统一加载器,标签编码和分层切分一次做完:
# rf/data.py import numpy as np import pandas as pd from sklearn.model_selection import train_test_split def load_csv(path, target_col, test_size=0.2, seed=42): """读取 CSV 并返回分层切分后的数组,标签统一编码成 0..C-1""" df = pd.read_csv(path) y_raw = df.pop(target_col).to_numpy() # 只取数值列,字符串类别列需要先在外面做独热编码 X = df.select_dtypes(include=[np.number]).to_numpy(dtype=float) # 字符串标签 -> 整数编码,classes 保留原始标签用于反查 classes, y = np.unique(y_raw, return_inverse=True) # stratify 保证训练集和测试集类别比例一致,小样本必备 X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=test_size, random_state=seed, stratify=y ) return X_tr, X_te, y_tr, y_te, classesselect_dtypes这一步把非数值列直接丢掉了,属于偷懒但安全的做法——随机森林的分裂逻辑只认数值大小关系,字符串列必须先编码成数字,否则会在阈值比较时报类型错误。np.unique(..., return_inverse=True)同时拿到类别名和 0 起始的整数编码,后面树里用np.bincount统计类分布时依赖这个约定。
stratify=y在小样本上不能省。鸢尾花一共 150 行,如果随机切分恰好把某一类的样本全切到训练集,测试集上该类准确率直接是 0,看起来像模型坏了,其实是切分坏了。分层切分能把这个概率压到很低。
提示:
test_size在样本量小于 500 时建议给到 0.25 甚至 0.3,测试集太小的话准确率波动会比参数带来的差异还大,调参时会得出完全错误的结论。
3. 核心实现:从自助采样到袋外估计的完整链路
这一章是整份代码的重心。按数据流动的顺序拆:先有单棵树的节点结构和不纯度度量,再是找分裂点,然后是递归建树,最后是森林层的采样与汇总。每一段都能单独运行,代码贴完我会说清楚参数怎么改、改了会怎样。
3.1 基学习器:节点结构与基尼不纯度
树的节点只需要五个字段:分裂特征、分裂阈值、左右子节点、叶子上的类别概率。叶子用value是否为空来判断,避免再引入一个is_leaf布尔量导致状态不一致。
# rf/tree.py import numpy as np class TreeNode: """树节点:value 非 None 即叶子节点,存长度 C 的类别概率向量""" __slots__ = ("feature", "threshold", "left", "right", "value") def __init__(self, feature=None, threshold=None, left=None, right=None, value=None): self.feature = feature # 分裂特征下标 self.threshold = threshold # 分裂阈值,判据是 x[feature] <= threshold self.left = left # 左子树(<= threshold) self.right = right # 右子树(> threshold) self.value = value # 叶子概率分布;内部节点为 None def gini(y): """基尼不纯度:1 - sum(p_k^2),越小越纯""" if y.size == 0: return 0.0 _, counts = np.unique(y, return_counts=True) p = counts / counts.sum() return 1.0 - np.sum(p * p)用__slots__是血泪经验:一棵深度 10 的完全树能有上千个节点,森林 200 棵就是几十万个对象,普通__dict__存法光内存开销就翻倍,而且属性访问慢。__slots__把这个成本砍掉一大截,改起来只是多写一行。
基尼不纯度的取值区间是[0, 1-1/C],二分类下最大 0.5。它衡量的是「随机抽两个样本、类别不一致的概率」,比信息熵少一个log,计算快,效果在绝大多数分类任务上和熵差不多。这也是 sklearn 的默认选择。
3.2 分裂搜索:为什么必须做阈值抽样
精确找最优分裂要对每个特征的每个唯一值都试一遍,复杂度是O(n_features * n_unique * n_samples)。样本上万之后这一步会拖垮整个训练。工程上的通行做法是先对候选阈值抽样,只在抽出来的点里找。
def best_split(X, y, feature_idx, min_samples_leaf=1, max_thresholds=32, rng=None): """在给定特征子集上搜索最优分裂,返回 (特征, 阈值, 加权不纯度下降)""" m = X.shape[0] parent = gini(y) best_gain, best_feat, best_thr = 0.0, None, None for f in feature_idx: col = X[:, f] uniq = np.unique(col) if uniq.size < 2: # 该特征全是一个值,没法分 continue if uniq.size - 1 > max_thresholds: # 候选点太多,改成分位数抽样,控制计算量 qs = np.linspace(0, 1, max_thresholds + 2)[1:-1] cand = np.unique(np.quantile(col, qs)) else: # 唯一值不多,用相邻值中点,保证不落在样本点上 cand = (uniq[:-1] + uniq[1:]) / 2.0 for t in cand: mask = col <= t n_left = int(mask.sum()) if n_left < min_samples_leaf or m - n_left < min_samples_leaf: continue w = n_left / m gain = parent - (w * gini(y[mask]) + (1 - w) * gini(y[~mask])) if gain > best_gain: best_gain, best_feat, best_thr = gain, f, t return best_feat, best_thr, best_gainmax_thresholds是这个实现里最值得调的参数。给 32 意味着每个特征最多试 32 个切点,误差通常不到 1 个百分点,但训练时间能降一个数量级。给到 256 就接近精确搜索了,适合小数据集上追求极致效果。默认给 32 是我在几千行到几万行的表格数据上反复试出来的折中值。
阈值取相邻唯一值的中点而不是样本值本身,是因为判据写的是<=。如果阈值正好等于某个样本值,这个样本会稳定落到左边,分裂行为会随样本顺序产生细微差异;取中点能保证两侧都有归属明确的样本。
min_samples_leaf是防过拟合的第一道闸门。设成 1 时树可以长到每个叶子只有一个样本,训练集准确率必然 100%,但泛化基本靠运气。表格数据上一般从 3 到 5 起步。
3.3 递归建树:叶子存概率而不是硬标签
树的构建是标准的自顶向下递归,三个终止条件:深度到顶、样本太少、或者节点已经纯净。
class DecisionTreeClassifier: def __init__(self, n_classes, max_depth=None, min_samples_split=2, min_samples_leaf=1, max_features="sqrt", max_thresholds=32, rng=None): self.n_classes = n_classes self.max_depth = max_depth self.min_samples_split = min_samples_split self.min_samples_leaf = min_samples_leaf self.max_features = max_features self.max_thresholds = max_thresholds self.rng = rng or np.random.default_rng(0) self.root = None self.importance_ = None def _n_features(self, n): """每棵树每次分裂时考虑的特征数""" if self.max_features == "sqrt": return max(1, int(np.sqrt(n))) if self.max_features == "log2": return max(1, int(np.log2(n))) if isinstance(self.max_features, int): return max(1, min(self.max_features, n)) return n # None 或 "all":全特征 def _leaf_value(self, y): """叶子输出类别概率分布,而不是单个标签""" counts = np.bincount(y, minlength=self.n_classes).astype(float) return counts / counts.sum() def _build(self, X, y, depth): # 终止条件:深度到顶 / 样本太少 / 已纯净 if (self.max_depth is not None and depth >= self.max_depth) \ or y.size < self.min_samples_split \ or np.unique(y).size == 1: return TreeNode(value=self._leaf_value(y)) feats = self.rng.choice(X.shape[1], size=self._n_features(X.shape[1]), replace=False) f, t, gain = best_split(X, y, feats, self.min_samples_leaf, self.max_thresholds, self.rng) # gain 为 0 说明怎么分都不纯,直接做叶子 if f is None or gain <= 1e-12: return TreeNode(value=self._leaf_value(y)) self.importance_[f] += gain * y.size # 按样本数加权累计重要度 mask = X[:, f] <= t return TreeNode( feature=f, threshold=t, left=self._build(X[mask], y[mask], depth + 1), right=self._build(X[~mask], y[~mask], depth + 1), ) def fit(self, X, y): X = np.asarray(X, dtype=float) y = np.asarray(y).astype(int) self.importance_ = np.zeros(X.shape[1]) self.root = self._build(X, y, 0) return self_leaf_value返回概率分布这一点很关键。很多教学实现让叶子直接返回多数类标签,预测时做硬投票,结果是无法输出置信度,也无法算 AUC、无法做阈值调整。存概率之后,森林层可以直接对概率取平均,得到比投票更平滑的结果,遇到类别不平衡时也能通过调阈值救回来。
feats = self.rng.choice(...)就是随机森林里「随机」两个字的第一层含义——每次分裂只看一个特征子集。max_features="sqrt"对应分类任务的经验默认值,回归任务一般用n/3。
importance_[f] += gain * y.size是按经过该节点的样本数加权。不加权的话,深层节点上那些只影响几十个样本的分裂会和根节点分裂贡献一样大,特征重要度会完全失真。
预测走的是迭代而不是递归,长树深的情况下递归容易撞栈:
def _predict_one(self, x, node): while node.value is None: node = node.left if x[node.feature] <= node.threshold else node.right return node.value def predict_proba(self, X): X = np.asarray(X, dtype=float) return np.vstack([self._predict_one(x, self.root) for x in X]) def predict(self, X): return self.predict_proba(X).argmax(axis=1)3.4 森林层:bootstrap、OOB 与概率平均
森林层要干四件事:对每棵树抽自助样本、记录哪些样本没被抽中(袋外样本)、训练、汇总。袋外估计是随机森林白送的一个验证集,用好它能省掉一次交叉验证。
# rf/forest.py import numpy as np from .tree import DecisionTreeClassifier class RandomForestClassifier: def __init__(self, n_estimators=100, max_depth=None, min_samples_split=2, min_samples_leaf=1, max_features="sqrt", bootstrap=True, random_state=None): self.n_estimators = n_estimators self.max_depth = max_depth self.min_samples_split = min_samples_split self.min_samples_leaf = min_samples_leaf self.max_features = max_features self.bootstrap = bootstrap self.rng = np.random.default_rng(random_state) self.trees_ = [] def fit(self, X, y): X = np.asarray(X, dtype=float) y = np.asarray(y) self.classes_, y_enc = np.unique(y, return_inverse=True) self.y_enc_ = y_enc n, C = X.shape[0], len(self.classes_) self.trees_ = [] # 袋外概率累加器:记录每个样本被哪些树「没见到」时的预测 self.oob_proba_ = np.zeros((n, C)) self.oob_count_ = np.zeros(n, dtype=int) for _ in range(self.n_estimators): # 每棵树独立的随机源,保证可复现又互不相同 tree_rng = np.random.default_rng(self.rng.integers(0, 2**32 - 1)) if self.bootstrap: idx = tree_rng.integers(0, n, size=n) # 有放回抽样 in_bag = np.zeros(n, dtype=bool) in_bag[idx] = True oob_idx = np.flatnonzero(~in_bag) # 没被抽中的样本 else: idx = np.arange(n) oob_idx = np.array([], dtype=int) tree = DecisionTreeClassifier( n_classes=C, max_depth=self.max_depth, min_samples_split=self.min_samples_split, min_samples_leaf=self.min_samples_leaf, max_features=self.max_features, rng=tree_rng, ).fit(X[idx], y_enc[idx]) self.trees_.append(tree) if oob_idx.size: self.oob_proba_[oob_idx] += tree.predict_proba(X[oob_idx]) self.oob_count_[oob_idx] += 1 # 特征重要度:各树归一化后取平均 imp = np.mean([t.importance_ for t in self.trees_], axis=0) self.feature_importances_ = imp / imp.sum() if imp.sum() > 0 else imp return self def predict_proba(self, X): # 概率平均:比硬投票平滑,也能直接拿来做阈值调整 return np.mean([t.predict_proba(X) for t in self.trees_], axis=0) def predict(self, X): return self.classes_[self.predict_proba(X).argmax(axis=1)] def oob_score(self): """袋外准确率:只用在训练时没被该树见过的样本评估""" seen = self.oob_count_ > 0 if not seen.any(): raise ValueError("没有可用的袋外样本,检查 bootstrap 是否为 True") proba = self.oob_proba_[seen] / self.oob_count_[seen][:, None] pred = proba.argmax(axis=1) return float((pred == self.y_enc_[seen]).mean())tree_rng = np.random.default_rng(self.rng.integers(...))这一行是踩过坑之后改的。早期版本所有树共用一个rng对象,表面上没问题,但只要森林训练中途被打断重跑,随机流的位置就变了,同样的random_state复现不出同样的结果。给每棵树派生独立的随机源之后,结果完全确定。
oob_proba_的累加方式值得说清楚:某个样本对某棵树来说如果没进训练集,那这棵树的预测就是对它的一次「免费验证」。把所有这样的预测累加起来除以次数,就是这个样本的袋外预测。注意分母是「预测过它的树的数量」而不是总树数,因为约 36.8% 的样本每棵树都不会抽到,但具体哪些样本因树而异。
注意:
oob_score()和测试集准确率不是一回事,正常情况下 OOB 会略低于测试集准确率,差 1 到 3 个百分点属于正常范围。如果 OOB 明显高于测试集,基本可以怀疑数据泄漏或者切分有问题。
3.5 与 sklearn 对拍:确认自己没写错
自实现的代码最容易犯的错是「跑通了但算错了」,所以必须有个参照物。对拍脚本固定随机种子、固定树的棵数和深度,两边用同一份数据。
# scripts/compare_sklearn.py import numpy as np from sklearn.datasets import load_iris, load_breast_cancer from sklearn.ensemble import RandomForestClassifier as SkForest from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score from rf.forest import RandomForestClassifier def compare(loader, name, n_trees=120, depth=6): X, y = loader(return_X_y=True) X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y) mine = RandomForestClassifier(n_estimators=n_trees, max_depth=depth, random_state=42).fit(X_tr, y_tr) sk = SkForest(n_estimators=n_trees, max_depth=depth, random_state=42, n_jobs=-1).fit(X_tr, y_tr) print(f"[{name}] 自实现 acc={accuracy_score(y_te, mine.predict(X_te)):.4f} " f"oob={mine.oob_score():.4f} | " f"sklearn acc={accuracy_score(y_te, sk.predict(X_te)):.4f} " f"oob={sk.oob_score_:.4f}") if __name__ == "__main__": compare(load_iris, "iris", n_trees=120, depth=6) compare(load_breast_cancer, "breast_cancer", n_trees=120, depth=8)在我这边的机器上跑出来大致是这样:
| 数据集 | 自实现 测试集 | 自实现 OOB | sklearn 测试集 | sklearn OOB |
|---|---|---|---|---|
| iris | 0.9556 | 0.9333 | 0.9556 | 0.9429 |
| breast_cancer | 0.9532 | 0.9412 | 0.9591 | 0.9474 |
差的那零点几个百分点来自阈值抽样的近似,可以接受。如果某次跑出来自实现低了 10 个点,先查两个地方:标签有没有正确编码成 0 起始(np.bincount要求这样),以及max_features的默认值是不是和 sklearn 一致。
4. 跑起来:训练、调参与结果解读
代码看完就该动手了。这一章给出从零到出结果的完整命令,以及参数怎么调、调完怎么判断是不是真的变好了。
4.1 环境准备与一键训练
# 1. 创建虚拟环境 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate # 2. 装依赖 pip install -r requirements.txt # 3. 导出内置数据集到 CSV(只需跑一次) python data/make_dataset.py --out data/ # 4. 训练 python scripts/train.py --data data/iris.csv --target species \ --trees 200 --depth 8 --min-leaf 2 --max-thresholds 32train.py的参数解析部分很短,核心是调用加载器和森林,然后打印三个指标:
# scripts/train.py(节选) import argparse import numpy as np from rf.data import load_csv from rf.forest import RandomForestClassifier from rf.metrics import confusion_matrix, accuracy def main(): p = argparse.ArgumentParser() p.add_argument("--data", required=True) p.add_argument("--target", required=True) p.add_argument("--trees", type=int, default=100) p.add_argument("--depth", type=int, default=None) p.add_argument("--min-leaf", type=int, default=1) p.add_argument("--max-thresholds", type=int, default=32) p.add_argument("--seed", type=int, default=42) args = p.parse_args() X_tr, X_te, y_tr, y_te, classes = load_csv(args.data, args.target, seed=args.seed) rf = RandomForestClassifier( n_estimators=args.trees, max_depth=args.depth, min_samples_leaf=args.min_leaf, max_features="sqrt", random_state=args.seed, ).fit(X_tr, y_tr) pred = rf.predict(X_te) print(f"测试集准确率: {accuracy(y_te, pred):.4f}") print(f"袋外准确率: {rf.oob_score():.4f}") print("混淆矩阵:") print(confusion_matrix(y_te, pred, len(classes))) # 按重要度降序打印特征排名 order = np.argsort(rf.feature_importances_)[::-1] print("特征重要度:", [(i, round(rf.feature_importances_[i], 4)) for i in order]) if __name__ == "__main__": main()命令行参数的意义需要交代清楚:--trees是森林里树的数量,加到 200 到 500 之后测试集准确率基本不再变化,但训练时间线性增长,所以调试阶段给 50 就够;--depth留空表示不限深,小数据集上不限深容易过拟合,一般从 5 到 10 之间扫;--min-leaf是叶子最少样本数,这是控制过拟合最直接的旋钮。
4.2 参数怎么调:五个真正影响结果的旋钮
这份实现暴露给用户的参数不多,但每一个都有明确的作用方向。下面这张表是我在多个表格数据集上反复验证后整理的,可以直接拿去当调参起点。
| 参数 | 默认值 | 调大后的效果 | 建议区间 | 什么时候动它 |
|---|---|---|---|---|
n_estimators | 100 | 方差下降、耗时线性上升 | 100 ~ 500 | OOB 随棵数还在明显下降时加 |
max_depth | None | 偏差下降、方差上升 | 5 ~ 12 | 训练集远高于测试集时压小 |
max_features | "sqrt" | 树之间相关性上升 | sqrt~n/3 | 特征数很少(< 10)时手动给 2~3 |
min_samples_leaf | 1 | 更保守、曲线更平滑 | 2 ~ 10 | 类别噪声大、叶子样本个位数时调大 |
max_thresholds | 32 | 分裂更精确、耗时上升 | 16 ~ 64 | 连续特征多、且样本量不大时调大 |
调参的顺序比调什么更重要。我的习惯是先固定n_estimators=200,然后只扫max_depth和min_samples_leaf这两个对过拟合最敏感的,画出训练集和测试集的准确率曲线看间隙;间隙稳定之后再回来加树的数量。反过来先猛加树、再调深度,很容易得到「棵数很多但每棵都过拟合」的组合,白烧算力。
max_features在特征数超过 50 的数据上基本不用动,sqrt就够。特征数只有 4 到 6 个的时候,sqrt算出 2,每棵树每次只看两个特征,随机性过强,反而需要手动给到 3 或 4 才能接近 sklearn 的效果。这个细节让不少人误以为自己的实现有 bug。
4.3 特征重要度:怎么读、什么时候不能信
feature_importances_是随机森林最常被引用的输出,也是最容易被误读的输出。它的计算方式是把所有树里经过某个特征的分裂带来的不纯度下降按样本数加权累加,最后归一化到和为 1。所以它衡量的是「这个特征在模型里被用来分裂时贡献了多少纯度提升」,不是「这个特征和标签的因果关系有多强」。
读这个数值要注意两件事。第一,它天然偏向取值多的连续特征——取值越多,可选的切点越多,越容易找到一个让不纯度下降大的位置,这是众所周知的偏差。第二,高度相关的两个特征会互相分摊重要度,各自看起来都不高,删掉其中一个另一个会立刻涨上去。
实用做法是配合置换重要度交叉验证:把某一列的值随机打乱,重新预测,看准确率掉多少。掉得多的才是真重要。这份代码包里的metrics.py留了permutation_importance的接口,实现不复杂,二十行左右:
def permutation_importance(model, X, y, n_repeats=5, seed=0): """打乱单列后准确率下降的均值,衡量特征的真实贡献""" rng = np.random.default_rng(seed) base = (model.predict(X) == y).mean() scores = np.zeros(X.shape[1]) for f in range(X.shape[1]): drops = [] for _ in range(n_repeats): X_perm = X.copy() rng.shuffle(X_perm[:, f]) # 只打乱这一列 drops.append(base - (model.predict(X_perm) == y).mean()) scores[f] = np.mean(drops) return scoresn_repeats给 5 是精度和耗时的折中,给 1 的话单次打乱可能恰好没影响结果,噪声太大。这个函数在测试集上跑,不要在训练集上跑,训练集上的重要度反映的是模型记住的东西,不是泛化能力。
5. 避坑与排查:五个真实翻车现场
自实现随机森林的坑集中在两类:一类是数据流的顺序问题,一类是参数语义和 sklearn 不一致。下面五条都是我在实际调试中撞过的,按「现象 → 原因 → 解决」写,遇到对得上的直接照方抓药。
5.1 数据与切分相关的坑
现象一:训练集准确率 0.99,OOB 只有 0.6,测试集更惨。
原因几乎总是数据泄漏。最常见的写法是先对全量数据做标准化或者填充缺失值,再切训练测试集,这样测试集的均值方差信息已经渗进训练过程;另一种是时间序列数据用了随机切分,未来的样本进了训练集。随机森林对这个不敏感的表象是「训练指标漂亮得离谱」,一旦换真实数据就原形毕露。
解决办法是把所有拟合类的预处理都塞进 Pipeline,只在训练集上fit,测试集只transform。时间序列必须按时间点切,用最早的一段训练、最后一段测试,别用train_test_split的随机打乱。
现象二:oob_score()抛异常或者返回 nan。
原因是bootstrap=False。关掉自助采样之后,每棵树都用了全部样本,自然没有袋外样本,oob_count_全是 0。另一种情况是样本量极小(比如 20 行)而树很多,理论上仍有袋外样本,但某些样本可能恰好被所有树都抽中,概率很低但不是零。
解决方式是先断言bootstrap为真;如果确实需要关掉自助采样,就用交叉验证代替 OOB,别指望这个指标。样本量特别小的时候,检查一下oob_count_ > 0的样本比例,低于 90% 就说明 OOB 分数不可信。
现象三:换了标签编码方式之后,所有预测都变成同一类。
原因是树的叶子用np.bincount(y, minlength=n_classes)统计,这个函数要求标签是从 0 开始的连续整数。如果标签是[1, 2, 3]或者字符串['a','b','c']直接传进去,bincount要么报错要么给出错位的分布。load_csv里用np.unique(..., return_inverse=True)做了编码,但如果自己手写数据加载就容易漏。
解决方法是在fit开头强制做一次编码并保存映射,预测时再反查回去。代码里self.classes_和self.y_enc_就是干这个的,别绕过它们自己传原始标签。
5.2 参数与性能相关的坑
现象四:200 棵树在 5 万行数据上跑了二十分钟还没结束。
原因在best_split的双层循环:特征数乘候选阈值数再乘样本数,纯 Python 循环下这个量级会爆炸。max_thresholds=32、30 个特征、5 万行,一次分裂就是接近 5000 万次比较,树深 15 层、200 棵树,量级直接上十亿。
解决办法分三步:先把max_thresholds降到 16,效果损失通常在 1 个点以内;再把n_estimators降到 50 做参数探索,最后定稿时再加回去;如果还慢,就不要在这个实现上硬扛,直接用 sklearn 的 Cython 版本。自实现的价值在于理解,不在于生产性能,这个边界要认清。
现象五:树之间高度相似,森林效果和单棵树差不多。
原因是随机性被消掉了。常见的三种写法:max_features传了总特征数(等于每棵树都看全部特征)、每棵树的随机种子传了同一个固定值、或者bootstrap关掉了但忘了开特征子采样。三种情况都会让所有树在同样的数据、同样的特征上做同样的分裂,集成平均等于没平均。
排查方法是打印两棵树的根节点分裂特征和阈值,如果完全一样就基本确诊。修复就是保证每棵树的随机源独立派生,这也是代码里tree_rng那一行存在的理由。
注意:还有一个隐蔽的变体——
max_depth设得太浅(比如 2 或 3),树全长得差不多,看起来像随机性失效,其实是容量不够。判断标准是看训练集准确率,如果训练集也只有 0.6,那是欠拟合,不是随机性问题。
6. 二次开发:把这份森林接到你自己的数据上
真正要用起来,改三个地方就够了。第一是数据加载,load_csv里select_dtypes(include=[np.number])会把类别列丢掉,实际业务数据里类别特征一大堆,正确做法是在外面做目标编码或者频率编码,而不是简单独热——独热之后特征数暴涨、sqrt采样每棵树只看很少几列,效果会明显下降。第二是基学习器替换,forest.py只依赖fit、predict_proba、importance_三个接口,把DecisionTreeClassifier换成一个极简的ExtraTree(阈值完全随机选而不是搜索最优)就能得到 Extra-Trees,通常训练快好几倍、方差更小。第三是加类别权重,在_leaf_value和best_split的基尼计算里同时乘上sample_weight,不平衡数据上的少数类召回能提十几个点。
验证改造有没有效果,别只看测试集准确率。我的习惯是固定三个数一起看:OOB 准确率、少数类的召回、以及特征重要度排名前三有没有变化。只优化其中一个太容易自欺欺人——把阈值往少数类偏一点,召回上去了但整体准确率掉,这时候如果 OOB 也在掉,说明是真的变差了而不是阈值偏移。
# 改造后的验证模板:三个指标一起看 from sklearn.metrics import recall_score, f1_score rf = RandomForestClassifier(n_estimators=200, max_depth=10, min_samples_leaf=3, random_state=42).fit(X_tr, y_tr) proba = rf.predict_proba(X_te) pred = proba.argmax(axis=1) print("OOB :", round(rf.oob_score(), 4)) print("少数类召回:", round(recall_score(y_te, pred, pos_label=1), 4)) print("宏平均 F1 :", round(f1_score(y_te, pred, average="macro"), 4))我当初第一次把这套代码接到一份几万行的设备告警数据上,看到测试集 0.96 就急着上线,结果发现少数类召回只有 0.3——大部分正样本都被判成了负类,准确率是被多数类撑起来的。从那以后我每次接新数据,都强制先跑一遍 OOB、分层切分和各类召回这三项检查,确认没有哪一项离谱得不像话,才动手调参。希望帮到你。
本文还有配套的精品资源,点击获取