
简介面向机器学习初学者、课程实验与算法复现场景这份随机森林实践材料围绕 Python 实现与小数据集验证展开适合希望读懂分类流程并动手跑通代码的读者。压缩包共 4 个文件包含 2 份 Python 源码、1 份 CSV 数据集和 1 份 TXT 阅读说明整体约 34KB体积轻巧便于本地解压后直接查看与运行。两份源码均配有详尽中文注释且已调试通过一份来自网络、一份为整理后重写可对照比较建树、投票与预测等环节的实现差异帮助梳理随机森林的关键函数与编码思路。附带的小型数据集可用于分类实验读者能观察特征输入、模型训练与结果评估的完整链路并在此基础上调整参数、记录正确率变化积累调参与排错经验。目前已有 15746 人学习适合作为入门练习与代码阅读样本因数据规模较小且调参未深入结果更偏向流程演示若追求更高精度还需自行优化。1. 解压之后先别急着跑这份随机森林 Python 代码包到底能给什么同事扔过来一个压缩包说“随机森林 Python 的代码和数据都在里面你跑一下看看准不准”。解压出来是一堆 csv、两套实现外加一个空的 outputs 目录没有 README。这种资源包最容易卡住人的地方不是算法难而是你根本不知道该先跑哪个文件、跑完该看什么、结果不对该怀疑谁。它的价值恰好也在这儿手写版和 sklearn 版跑的是同一份数据能互相对照哪一步抄错了立刻能暴露。适合两类人一类是刚学完决策树、想看清 Bagging 到底怎么落地的新手另一类是要把现成模型塞进业务、但不想只当一个fit/predict调包侠的开发者。往下从目录结构一路拆到你自己改分裂准则。2. 目录结构与技术选型手写版和 sklearn 版为什么都要留2.1 目录结构先认清哪些是入口哪些是被 import 的库拿到这类资源包我会先花两分钟把目录画出来标清楚“能直接 run 的”和“只能被 import 的”。代码包大体是这个形状random-forest-demo/ ├── data/ # 原始数据只读任何清洗都写到 outputs 里 │ ├── iris.csv │ ├── winequality_red.csv │ ├── titanic_train.csv │ └── housing_small.csv ├── rf_scratch/ # 纯 numpy 手写实现 │ ├── criteria.py # 基尼 / 熵 / 方差 │ ├── tree.py # 单棵 CART 树 │ ├── forest.py # Bootstrap 特征子采样 集成 │ └── metrics.py # 准确率、混淆矩阵 ├── rf_sklearn/ # sklearn 对照、调参、解释 │ ├── train.py # 基线训练程序入口 │ ├── tune.py # 网格搜索 │ └── explain.py # 特征重要性、置换重要性 ├── notebooks/ │ └── 01_eda.ipynb # 先看数据长什么样 ├── outputs/ # 图、模型、日志全部落这里 └── requirements.txt路径职责什么时候需要动它notebooks/01_eda.ipynb分布、缺失、类别不平衡一眼看清换新数据集时第一个改rf_sklearn/train.py跑基线、出指标、存模型想要一个能交差的结果时rf_sklearn/tune.py网格 / 随机搜索基线不够用时rf_scratch/tree.py单棵树的分裂与递归想改准则、加剪枝时rf_scratch/forest.py采样、集成、OOB想改并行、改聚合方式时data/*.csv原始数据只读不要就地覆盖这里有个容易忽略的约定rf_scratch/下面没有if __name__ __main__它是一组库直接python tree.py什么都不会发生。真正的入口只有两个rf_sklearn/train.py和 notebook。新手最常干的事是挨个文件双击运行然后以为代码坏了。2.2 技术选型numpy 打底sklearn 只当标尺手写版刻意只依赖 numpy连 sklearn 的DecisionTreeClassifier都不 import。原因很实际一旦允许调 sklearn你就会忍不住用它的树去对照然后跳过最该看的那段递归。所有矩阵运算用 ndarray连续特征的分裂点搜索靠“排序 累积类别计数”向量化纯 Python 的双层 for 循环在几千行数据上要跑好几分钟这是血泪经验。pandas 的角色被限制在“读数据”这一步fit之前统一.values转成 ndarray。原因有两个手写树里大量用布尔索引X[mask]DataFrame 上做这个会退化成iloc慢一个量级另外 sklearn 内部也是 ndarray两边用同一种数据表示结果对齐时才不会掺进 dtype 和列顺序的干扰。sklearn 版存在的意义是标尺不是替代品。它的作用是三件事给一个可信的准确率上界、给一组调好的参数、给置换重要性这类手写版没实现的诊断工具。常见做法是先把rf_sklearn/train.py跑通拿到基线数字再拿手写版去逼近它差得离谱就说明某一环写错了。2.3 数据集四份 csv 各管一件事别拿错数据集不是随便凑的每份负责验证一条链路。拿错数据集去调参会得到一堆看起来合理但没法解释的结论。数据集量级任务在这份代码里负责验证什么iris.csv150 行 4 特征 3 类多分类冒烟测试、手写版与 sklearn 逐棵对齐winequality_red.csv千余行 11 特征回归 / 二分类阈值切分、类别不平衡下的评估titanic_train.csv数百行混合类型二分类缺失值、类别编码的预处理链路housing_small.csv数百行连续特征回归手写版把“投票”换成“均值”的改造点iris 用来做对齐是有讲究的特征少、树浅、类别均衡两套实现只要随机种子一致准确率应该一模一样差 0.01 都能立刻定位。titanic 则相反它脏得很有代表性——Age有缺失、Embarked有缺失、Sex是字符串预处理顺序写反就会静默产生 NaN模型照样能训只是结果莫名其妙地低。winequality 那种连续标签直接当分类做会切出七八个类别通常的做法是先定一个阈值二值化再谈准确率。3. 环境搭建与第一次跑通从建虚拟环境到出第一张特征重要性图3.1 环境三条命令别往全局环境里装python -m venv .venv source .venv/bin/activate # Windows 下换成 .venv\Scripts\activate pip install numpy pandas scikit-learn matplotlib jupyter pip freeze requirements.txt # 锁版本别让自己三天后复现不出来核心依赖就四个不需要额外的东西。不建议直接在全局或某个大而全的发行版环境里跑sklearn 跨版本有参数改名和默认值变更报出来的TypeError往往和你的代码逻辑毫无关系排查方向直接跑偏。提示pip freeze出来的文件建议提交到版本库。调参调了两天结果复现不出来八成是这一步偷懒了。激活成功的标志是命令行前面出现(.venv)。接下来验证一下环境是否完整python -c import numpy, pandas, sklearn, matplotlib; print(ok)打印ok再往下走。如果卡在sklearn上通常是编译依赖的问题换一个干净的虚拟环境重装比在原环境里修快得多。3.2 数据加载把 csv 变成模型能吃的 X / y先写一个只服务 iris 的 loader把约定固定下来最后一列是标签列名统一小写去空格。import pandas as pd from sklearn.model_selection import train_test_split def load_iris(pathdata/iris.csv, seed42): df pd.read_csv(path) # 列名统一有些导出工具会带空格或大写后面按名字取列会翻车 df.columns [c.strip().lower().replace( , _) for c in df.columns] y df.iloc[:, -1].values # 约定最后一列是标签 X df.iloc[:, :-1].values # 其余全是特征且都已是数值 # 小数据集必须分层否则某一类可能整体落进测试集 return train_test_split(X, y, test_size0.2, random_stateseed, stratifyy)iloc[:, -1]依赖列顺序不是列名所以读进来第一件事是确认列顺序没被 Excel 打乱。stratifyy在 150 行这种量级上是必须的不分层的话3 类里数量最少的那一类有可能测试集里一个样本都没有classification_report直接给你一片nan。X.values转 ndarray 是为了后面手写树里做布尔索引。titanic 那份要复杂一些缺失值和编码的顺序不能反def load_titanic(pathdata/titanic_train.csv, seed42): df pd.read_csv(path) cols [Pclass, Sex, Age, Fare, Embarked, Survived] df df[cols].copy() # 1. 先填缺失再编码。顺序反了 fillna 会把 NaN 变成字符串 nan df[Age] df[Age].fillna(df[Age].median()) # 年龄长尾中位数比均值稳 df[Embarked] df[Embarked].fillna(S) # 登船港是类别填众数 # 2. 编码成整数随机森林不需要 one-hot但需要数值 df[Sex] df[Sex].map({male: 0, female: 1}) df[Embarked] df[Embarked].map({S: 0, C: 1, Q: 2}) y df.pop(Survived).values return train_test_split(df.values, y, test_size0.2, random_stateseed, stratifyy)Age用中位数而不是均值是因为船上年龄分布右偏均值会被少数高龄乘客拉高。Embarked必须先fillna再map反过来写的话map遇到NaN会原样返回NaNfillna(S)再去填就已经晚了整列变成 object 类型sklearn 会在fit时报“无法把 string 转成 float”。这个坑没有报错提示指向它只能靠看 dtype 发现。注意编码完之后跑一句df.dtypes检查只要有一列是 object就别急着训练。3.3 第一次训练参数先给一组能跑的默认值from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report X_train, X_test, y_train, y_test load_iris() clf RandomForestClassifier( n_estimators300, # 树的数量边际收益递减300 在这个量级足够 max_depthNone, # 先不限深让树自己长 min_samples_leaf2, # 叶子最少 2 个样本抑制单样本叶子带来的过拟合 max_featuressqrt, # 每次分裂随机看 sqrt(p) 个特征分类默认值 oob_scoreTrue, # 用袋外样本做一次免费验证 n_jobs-1, # 用满所有核 random_state42, # 固定随机性否则两次结果对不上 ) clf.fit(X_train, y_train) print(OOB 分数:, round(clf.oob_score_, 4)) print(classification_report(y_test, clf.predict(X_test)))n_estimators从 100 加到 300准确率通常涨一两个点再往上加基本是浪费电。min_samples_leaf2是为了避免树长到每个叶子只剩一个样本那种树在训练集上完美、在测试集上抖得厉害。oob_scoreTrue是随机森林白送的一份验证每棵树训练时有约 37% 的样本没被抽到用这些样本给这棵树打分再汇总等于不切验证集也能估一个泛化误差。OOB 和测试集准确率差三个百分点以内算正常差十几个点就要怀疑数据泄漏或者切分方式有问题。拿到基线后接着出图这张图比准确率更能说服人import numpy as np import matplotlib.pyplot as plt imp clf.feature_importances_ order np.argsort(imp) plt.barh(np.array([f0, f1, f2, f3])[order], imp[order]) plt.xlabel(importance) plt.tight_layout() plt.savefig(outputs/feature_importance.png, dpi150)feature_importances_是归一化的不纯度下降量加起来等于 1。它快但对高基数特征有偏好只能当参考真正的结论我会用rf_sklearn/explain.py里的置换重要性再核一遍。4. 核心模块拆解手写随机森林的三个关键实现4.1 分裂准则基尼、熵、方差三件套准则函数是整棵树唯一需要“可插拔”的部分。分类用基尼或熵回归用方差签名统一成“输入一维标签数组返回一个标量”。import numpy as np def gini(y): 基尼不纯度1 - sum(p_k^2)越小越纯 if y.size 0: return 0.0 _, counts np.unique(y, return_countsTrue) p counts / y.size return 1.0 - np.sum(p ** 2) def entropy(y): 信息熵-sum(p_k * log2 p_k)比基尼慢在 log 上 if y.size 0: return 0.0 _, counts np.unique(y, return_countsTrue) p counts / y.size return float(-np.sum(p * np.log2(p))) def variance(y): 回归树的 MSE 版本叶子值取均值 return float(np.var(y)) if y.size else 0.0这三个函数只在“评估某个节点有多纯”时调用不能直接拿去做分裂搜索——每个候选切点都调一次np.unique的话时间复杂度会从 O(n log n) 涨到 O(n² log n)。所以真正的实现里基尼是用累积类别计数向量化算出来的见下一节。熵和基尼在分类效果上的差距通常很小选哪个更像玄学但熵的可解释性好一点报告里好写。4.2 递归建树一次分裂搜索的完整链路先解决搜索。核心思路是把特征排一次序然后扫一遍所有合法切点用前缀和算出左右两侧的类别分布。def best_split(X, y, features, min_samples_leaf): 在所有候选特征上找加权不纯度下降最大的切点 n X.shape[0] parent gini(y) best {gain: 0.0, feature: None, threshold: None} for f in features: col X[:, f] order np.argsort(col, kindmergesort) # 稳定排序重复值不被打乱 xs, ys col[order], y[order] # 只在相邻不同值之间切相同值中间切没有意义 cand np.where(xs[1:] ! xs[:-1])[0] if cand.size 0: continue # 叶子样本数约束两侧都得满足 left_n cand 1 right_n n - left_n ok (left_n min_samples_leaf) (right_n min_samples_leaf) cand, left_n, right_n cand[ok], left_n[ok], right_n[ok] if cand.size 0: continue # 累积类别计数cum[k] 表示前 k1 个样本的类别分布 classes, yi np.unique(ys, return_inverseTrue) onehot np.zeros((n, classes.size)) onehot[np.arange(n), yi] 1 cum np.cumsum(onehot, axis0) left cum[cand] # (m, C) right cum[-1] - left p_left left / left_n.reshape(-1, 1) p_right right / right_n.reshape(-1, 1) imp_left 1.0 - np.sum(p_left ** 2, axis1) imp_right 1.0 - np.sum(p_right ** 2, axis1) # 加权不纯度下降 gain parent - (left_n / n) * imp_left - (right_n / n) * imp_right i int(np.argmax(gain)) if gain[i] best[gain]: best {gain: float(gain[i]), feature: f, threshold: float((xs[cand[i]] xs[cand[i] 1]) / 2)} return best几个参数值得说清楚。min_samples_leaf同时约束左右两侧只约束一边会切出只有一个样本的叶子。阈值取相邻两个不同值的中间点这样x threshold和x threshold能把训练集里的样本严格分开。kindmergesort是稳定排序特征里有大量重复值时非稳定排序会让同一份数据两次训练得到不同的树固定种子也救不回来。然后是递归本身。停止条件必须写三重少一个就会出现无限递归或者树深到爆栈class Node: __slots__ (feature, threshold, left, right, value) def __init__(self, valueNone): self.feature self.threshold None self.left self.right None self.value value # 叶子节点的预测值 def majority(y): vals, counts np.unique(y, return_countsTrue) return vals[np.argmax(counts)] def build_tree(X, y, depth, max_depth, min_samples_split, min_samples_leaf, n_features, rng): node Node(valuemajority(y)) # 三个停止条件到深度上限 / 样本太少 / 已经纯净 if depth max_depth or y.size min_samples_split or np.unique(y).size 1: return node # 每个节点重新抽一批特征这是随机森林和普通 Bagging 的关键区别 feats rng.choice(X.shape[1], sizen_features, replaceFalse) split best_split(X, y, feats, min_samples_leaf) if split[feature] is None: # 找不到合法切点退化成叶子 return node mask X[:, split[feature]] split[threshold] node.feature, node.threshold split[feature], split[threshold] node.left build_tree(X[mask], y[mask], depth 1, max_depth, min_samples_split, min_samples_leaf, n_features, rng) node.right build_tree(X[~mask], y[~mask], depth 1, max_depth, min_samples_split, min_samples_leaf, n_features, rng) return noden_features在每次递归时重新抽跟 sklearn 的行为一致。有些教材把它写成“每棵树抽一次然后固定”两种都能跑但结果对不上做对齐验证时这是第一个要统一的点。__slots__是顺手优化上完棵树之后节点数量能到几十万省下的内存很可观。4.3 Bootstrap 与特征子采样随机性加在哪一层森林层只做三件事有放回抽样、并发地建树、收集袋外样本。class RandomForest: def __init__(self, n_estimators100, max_depthNone, min_samples_split2, min_samples_leaf1, max_featuressqrt, oob_scoreTrue, random_stateNone): self.n_estimators n_estimators self.max_depth max_depth if max_depth is not None else 10 ** 9 self.min_samples_split min_samples_split self.min_samples_leaf min_samples_leaf self.max_features max_features self.oob_score oob_score self.random_state random_state def fit(self, X, y): rng np.random.default_rng(self.random_state) # 独立 Generator别用全局种子 n, p X.shape self.classes_ np.unique(y) k int(np.sqrt(p)) if self.max_features sqrt else p self.trees_, self.y_train_ [], y # 袋外投票累积不用硬投票计数累积概率再取 argmax 更稳 self.oob_proba_ np.zeros((n, self.classes_.size)) self.oob_count_ np.zeros(n) for _ in range(self.n_estimators): idx rng.integers(0, n, n) # 有放回约 36.8% 的样本一次都没被抽到 oob np.ones(n, dtypebool) oob[np.unique(idx)] False tree build_tree(X[idx], y[idx], 0, self.max_depth, self.min_samples_split, self.min_samples_leaf, k, rng) self.trees_.append(tree) if self.oob_score and oob.any(): self.oob_proba_[oob] predict_proba_tree(tree, X[oob], self.classes_) self.oob_count_[oob] 1 return selfrng.integers(0, n, n)比rng.choice(n, n, replaceTrue)快两者等价。1 - 1/e ≈ 0.632 这个数字就是袋外样本的比例来源样本量越大越接近它。OOB 这里用概率累加而不是多数投票是因为同一棵树的概率分布可以直接相加而投票计数在类别不均衡时会偏向多数类。4.4 预测与 OOB 打分把一棵树的结果变成森林的结果def predict_proba_tree(node, X, classes_): 把样本推到叶子返回 one-hot 形式的概率单棵树是硬输出 out np.zeros((X.shape[0], classes_.size)) for i in range(X.shape[0]): cur node while cur.feature is not None: cur cur.left if X[i, cur.feature] cur.threshold else cur.right out[i, np.searchsorted(classes_, cur.value)] 1.0 return out def predict_proba(self, X): proba np.zeros((X.shape[0], self.classes_.size)) for tree in self.trees_: proba predict_proba_tree(tree, X, self.classes_) return proba / len(self.trees_) def predict(self, X): return self.classes_[np.argmax(self.predict_proba(X), axis1)] def oob_score(self): has self.oob_count_ 0 pred self.classes_[np.argmax(self.oob_proba_[has] / self.oob_count_[has, None], axis1)] return float(np.mean(pred self.y_train_[has]))单棵树输出的是 one-hot森林把它们平均成软概率再取最大值这叫软投票。硬投票每棵树各投一票看谁票多在类别均衡时两者结果差不多不均衡时软投票通常更好一点。回归任务把predict_proba换成每棵树叶子值的均值就行聚合逻辑只改一行。OOB 打分里has这个掩码不能省没被任何一棵树漏掉的样本分母是 0。5. 避坑与排查跑这套代码最常见的 5 个翻车现场5.1 训练十分钟不出结果或者直接 RecursionError现象python rf_sklearn/train.py跑了很久没动静换成手写版直接抛RecursionError: maximum recursion depth exceeded。原因max_depthNone加min_samples_leaf1连续特征上每次都能找到让不纯度下降的切点树会一直长到每个叶子只剩一个样本深度接近样本量。手写版是递归实现Python 默认递归上限一千层直接撞墙。解决给min_samples_leaf一个下限分类 2 到 5回归 3 到 10同时给max_depth一个兜底值。手写版里我把None显式转成10 ** 9是故意的——让它靠样本约束停下来而不是靠深度。5.2 手写版和 sklearn 版准确率差一大截现象同一份 iris手写版 0.90sklearn 0.97怎么调都对不上。原因三个地方最容易出偏差。一是分裂阈值搜索粒度不同手写版扫全部相邻值sklearn 在特征基数很大时会做近似分箱二是max_features的抽样时机我写在每个节点有些实现写成每棵树三是随机种子不在同一条链上sklearn 的random_state和 numpy 的 Generator 是两套独立的状态。解决做对齐验证时把所有随机性关掉——max_featuresp用全部特征、bootstrapFalse、random_state固定先让单棵树和DecisionTreeClassifier(criteriongini)完全一致再逐层放开。5.3 特征重要性全是 0或者和业务直觉反着来现象明明是最相关的那个特征feature_importances_打出来是 0。原因feature_importances_是训练集上的不纯度下降累加如果某个特征在树的上层从没被抽中过它一次都不会贡献。特征之间高度共线时更明显两个强相关特征会互相“抢”重要性一个拿满另一个归零。另外特征方差接近 0 时它也永远切不出有效分裂。解决换sklearn.inspection.permutation_importance在测试集上打乱单列看指标跌多少结论更贴近业务。看到 0 先查这一列的标准差再查它和其他特征的相关系数。5.4 Windows 上n_jobs-1卡死或者进程无限增殖现象脚本刚跑起来任务管理器里冒出一堆 python 进程内存爆掉或者干脆卡住不动。原因Windows 用 spawn 方式启动子进程子进程会重新 import 主模块。顶层代码没有if __name__ __main__:保护时子进程一 import 就又去建森林无限递归。解决把训练代码全部塞进main()并用if __name__ __main__:守住调试阶段先把n_jobs设成 1确认逻辑没问题再开多核。5.5 明明固定了random_state结果还是每次都不一样现象random_state42写死了两次运行准确率差一个点。原因只固定了 sklearn 的种子手写版里还在用全局的np.random.seed而其他地方比如train_test_split、你自己写的采样会消耗全局状态顺序一变结果就变。另外一种情况是train_test_split在设种子之前就被调用了。解决全部改用np.random.default_rng(seed)生成独立 Generator把 rng 一路传进build_tree禁止任何模块碰全局np.random。这条我踩过不止一次改完之后两次运行的输出能逐字节一致。6. 二次开发换数据、加准则、导出模型的改造路径与验证方法这份代码包真正能被用起来的地方是它把“改哪儿”标得很清楚。换自己的数据基本只动 loaderrf_sklearn/train.py里的load_iris()换成你的函数即可只要保证返回四个 ndarray 且标签是数值。改完先跑 100 棵树看 OOB 稳不稳OOB 抖动超过两个点说明数据预处理有问题别急着调参。改造目标改哪个文件验证方式接入自有数据loader 函数OOB 与测试集差距在 3 个点内增加分裂准则rf_scratch/criteria.py单棵树与 sklearn 对应 criterion 完全一致导出模型rf_scratch/tree.py加序列化导出再加载后预测结果逐元素相同加速分裂搜索best_split里的 onehot 缓冲单次fit耗时下降且预测不变改成回归森林聚合处换均值、准则换方差与RandomForestRegressor的 R² 接近加一种准则是最小改动注册进去就行CRITERIA {gini: gini, entropy: entropy} def make_criterion(name): if name not in CRITERIA: raise ValueError(funknown criterion: {name}) return CRITERIA[name]但要注意best_split里现在硬编码了基尼的向量化公式加准则时得同时把这段改成按名字分支否则你只是换了个评估函数搜索逻辑还在算基尼结果看起来“没变化”。这就是那种不报错、但结论完全错的黑匣子只能靠对齐测试抓出来。对齐测试我一般写成这样跑通它再做任何改动from sklearn.tree import DecisionTreeClassifier # 关掉所有随机性单棵树必须和 sklearn 一模一样 tree DecisionTreeClassifier(criteriongini, max_depth4, random_state0) tree.fit(X_train, y_train) mine build_tree(X_train, y_train, 0, 4, 2, 1, X_train.shape[1], np.random.default_rng(0)) assert np.array_equal(tree.predict(X_test), predict_tree(mine, X_test))改完之后我还会跑一条学习曲线把n_estimators从 10 扫到 500看准确率在哪一段变平。这份代码包的默认值是 300换到你的数据上不一定够尤其样本上万之后曲线的拐点会往右移。从那以后我每次动这类“算法 数据”的资源包都会先把单棵树对齐跑通再谈森林和调参——跳过这一步后面所有数字都是玄学调出来的东西也没法复现。希望帮到你。本文还有配套的精品资源点击获取