简介:面向机器学习初学者、课程实验与算法复现场景,这份随机森林实践材料围绕 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.csv | 150 行 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(path="data/iris.csv", seed=42): 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_size=0.2, random_state=seed, stratify=y)iloc[:, -1]依赖列顺序,不是列名,所以读进来第一件事是确认列顺序没被 Excel 打乱。stratify=y在 150 行这种量级上是必须的:不分层的话,3 类里数量最少的那一类有可能测试集里一个样本都没有,classification_report直接给你一片nan。X.values转 ndarray 是为了后面手写树里做布尔索引。
titanic 那份要复杂一些,缺失值和编码的顺序不能反:
def load_titanic(path="data/titanic_train.csv", seed=42): 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_size=0.2, random_state=seed, stratify=y)Age用中位数而不是均值,是因为船上年龄分布右偏,均值会被少数高龄乘客拉高。Embarked必须先fillna再map,反过来写的话map遇到NaN会原样返回NaN,fillna("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_estimators=300, # 树的数量,边际收益递减,300 在这个量级足够 max_depth=None, # 先不限深,让树自己长 min_samples_leaf=2, # 叶子最少 2 个样本,抑制单样本叶子带来的过拟合 max_features="sqrt", # 每次分裂随机看 sqrt(p) 个特征,分类默认值 oob_score=True, # 用袋外样本做一次免费验证 n_jobs=-1, # 用满所有核 random_state=42, # 固定随机性,否则两次结果对不上 ) 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_leaf=2是为了避免树长到每个叶子只剩一个样本,那种树在训练集上完美、在测试集上抖得厉害。oob_score=True是随机森林白送的一份验证:每棵树训练时有约 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", dpi=150)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_counts=True) 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_counts=True) 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, kind="mergesort") # 稳定排序,重复值不被打乱 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] 表示前 k+1 个样本的类别分布 classes, yi = np.unique(ys, return_inverse=True) onehot = np.zeros((n, classes.size)) onehot[np.arange(n), yi] = 1 cum = np.cumsum(onehot, axis=0) 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, axis=1) imp_right = 1.0 - np.sum(p_right ** 2, axis=1) # 加权不纯度下降 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能把训练集里的样本严格分开。kind="mergesort"是稳定排序,特征里有大量重复值时,非稳定排序会让同一份数据两次训练得到不同的树,固定种子也救不回来。
然后是递归本身。停止条件必须写三重,少一个就会出现无限递归或者树深到爆栈:
class Node: __slots__ = ("feature", "threshold", "left", "right", "value") def __init__(self, value=None): self.feature = self.threshold = None self.left = self.right = None self.value = value # 叶子节点的预测值 def majority(y): vals, counts = np.unique(y, return_counts=True) return vals[np.argmax(counts)] def build_tree(X, y, depth, max_depth, min_samples_split, min_samples_leaf, n_features, rng): node = Node(value=majority(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], size=n_features, replace=False) 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_estimators=100, max_depth=None, min_samples_split=2, min_samples_leaf=1, max_features="sqrt", oob_score=True, random_state=None): 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, dtype=bool) 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, replace=True)快,两者等价。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), axis=1)] def oob_score(self): has = self.oob_count_ > 0 pred = self.classes_[np.argmax(self.oob_proba_[has] / self.oob_count_[has, None], axis=1)] 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_depth=None加min_samples_leaf=1,连续特征上每次都能找到让不纯度下降的切点,树会一直长到每个叶子只剩一个样本,深度接近样本量。手写版是递归实现,Python 默认递归上限一千层,直接撞墙。
解决:给min_samples_leaf一个下限(分类 2 到 5,回归 3 到 10),同时给max_depth一个兜底值。手写版里我把None显式转成10 ** 9,是故意的——让它靠样本约束停下来,而不是靠深度。
5.2 手写版和 sklearn 版准确率差一大截
现象:同一份 iris,手写版 0.90,sklearn 0.97,怎么调都对不上。
原因:三个地方最容易出偏差。一是分裂阈值搜索粒度不同,手写版扫全部相邻值,sklearn 在特征基数很大时会做近似分箱;二是max_features的抽样时机,我写在每个节点,有些实现写成每棵树;三是随机种子不在同一条链上,sklearn 的random_state和 numpy 的 Generator 是两套独立的状态。
解决:做对齐验证时把所有随机性关掉——max_features=p(用全部特征)、bootstrap=False、random_state固定,先让单棵树和DecisionTreeClassifier(criterion="gini")完全一致,再逐层放开。
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_state=42写死了,两次运行准确率差一个点。
原因:只固定了 sklearn 的种子,手写版里还在用全局的np.random.seed,而其他地方(比如train_test_split、你自己写的采样)会消耗全局状态,顺序一变结果就变。另外一种情况是train_test_split在设种子之前就被调用了。
解决:全部改用np.random.default_rng(seed)生成独立 Generator,把 rng 一路传进build_tree,禁止任何模块碰全局np.random。这条我踩过不止一次,改完之后两次运行的输出能逐字节一致。
6. 二次开发:换数据、加准则、导出模型的改造路径与验证方法
这份代码包真正能被用起来的地方,是它把“改哪儿”标得很清楚。换自己的数据基本只动 loader,rf_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(f"unknown criterion: {name}") return CRITERIA[name]但要注意best_split里现在硬编码了基尼的向量化公式,加准则时得同时把这段改成按名字分支,否则你只是换了个评估函数,搜索逻辑还在算基尼,结果看起来“没变化”。这就是那种不报错、但结论完全错的黑匣子,只能靠对齐测试抓出来。
对齐测试我一般写成这样,跑通它再做任何改动:
from sklearn.tree import DecisionTreeClassifier # 关掉所有随机性,单棵树必须和 sklearn 一模一样 tree = DecisionTreeClassifier(criterion="gini", max_depth=4, random_state=0) 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,换到你的数据上不一定够,尤其样本上万之后,曲线的拐点会往右移。
从那以后,我每次动这类“算法 + 数据”的资源包,都会先把单棵树对齐跑通,再谈森林和调参——跳过这一步,后面所有数字都是玄学,调出来的东西也没法复现。希望帮到你。
本文还有配套的精品资源,点击获取