资讯详情

随机森林Python实现:从三行代码到可交付模型

📅 2026/11/6 10:26:18 | 华诺云谱 👁 阅读
随机森林Python实现:从三行代码到可交付模型
简介面向希望动手理解随机森林算法原理与 Python 实现的学习者这份资料以可运行代码加小型数据集的方式帮助读者从零完成分类实验并观察模型训练与预测流程。压缩包共 4 个文件包含 2 个 py 脚本、1 个 csv 数据集和 1 个 txt 阅读说明整体约 34KB体积轻便适合课堂演示、自学复现与代码对照。已有 15746 人学习下载说明其在入门实践场景中具有一定参考价值。资料内提供两份实现代码一份来自网络一份为作者整理编写均配有详尽中文注释且已调试通过结合 sonar 数据集可完成数据加载、特征处理、模型构建与结果评估也便于比较不同写法、理解参数调整对准确率的影响。编程环境为 Python2.7调参空间保留较多适合继续改进。1. 随机森林 Python 实现三行代码和一份能交付的实现差在哪很多人第一次写随机森林 Python 实现套路就是三行RandomForestClassifier()、fit、predict跑完看到准确率 0.95 就以为收工。真把这套代码搬到自己的表格数据上第一版结果往往很难看——不是报错就是指标虚高、换个随机种子就大幅波动。这份代码包解决的正是这段落差它把数据加载、训练切分、参数配置、评估和特征重要性拆成独立脚本每个文件只干一件事改一处不牵连别处。适合两类人刚学完集成学习、想找一份能跑通的骨架的以及手上有一份结构化 CSV、想快速拿到一个可解释基线模型的。下面按「文件怎么摆、参数怎么定、指标怎么看、坑在哪」的顺序拆开。2. 拆包看结构数据、特征、训练脚本怎么摆拿到一份带数据集的随机森林代码第一件事不是运行main.py而是先确认它把「数据」和「模型」的边界划在哪里。边界模糊的代码包通常用两周就会变成一团面——想换一份数据要先改训练脚本想调参又要回头动加载逻辑。我一般会花十分钟先把目录和数据字段过一遍再决定是直接用还是先重构。2.1 目录与字段动手前先确认这四件事一份结构清晰的实现通常是这种形态data/放原始 CSVsrc/放加载与训练脚本outputs/放模型文件和指标报告根目录一个run.py串流程。这个划分不是形式主义它的实际价值在于数据换了只动data/参数换了只动配置产物永远落在outputs/不会出现「上次那个模型文件到底存哪了」这种问题。动代码之前我固定会确认四件事缺一件后面就要返工目标列是哪一个它的取值是 0/1 二分类、多分类还是连续值连续值要走RandomForestRegressor别拿分类器硬套特征列里有几列是 ID、时间戳、姓名这类「高基数但无信息」的字段这些必须在入模前剔除类别型特征有多少个取值基数大不大基数超过几十的类别列直接 one-hot 会把特征维度撑爆缺失值是NaN、空字符串还是-1、999这种占位符第三点最容易出问题。随机森林对特征尺度不敏感但对特征维度和噪声很敏感一列有 500 个取值的城市字段做 one-hot等于往 500 个几乎全零的维度里塞噪声重要性排序会被彻底搅乱。基数值超过 20 的类别列我一般直接丢掉或者手工合并成十几组而不是硬编码。2.2 加载与切分一行 stratify 省掉后面一堆麻烦加载脚本看着简单但它是后面所有指标的地基。下面这份是我在多个项目里反复用的写法改动量小、容错够用# src/data_loader.py import pandas as pd from sklearn.model_selection import train_test_split FEATURE_COLS [age, income, tenure, n_tickets, avg_session] TARGET_COL churn def load_dataset(path: str): df pd.read_csv(path) # 列名统一小写去空格避免 Age 和 age 同时存在的低级错误 df.columns [c.strip().lower() for c in df.columns] # 目标列如果是字符串标签统一映射成 0/1sklearn 对字符串标签支持有限 if df[TARGET_COL].dtype object: df[TARGET_COL] df[TARGET_COL].map({yes: 1, no: 0}) X df[FEATURE_COLS].copy() y df[TARGET_COL].astype(int) return X, y if __name__ __main__: X, y load_dataset(data/customer_sample.csv) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, # 留 20% 做最终评估全程不参与调参 random_state42, # 固定切分保证每次跑出来的测试集是同一批 stratifyy # 按标签比例分层正样本少的时候必加 ) print(X_train.shape, X_test.shape, y_train.mean(), y_test.mean())几个参数值得单独说。stratifyy是防翻车的第一道闸如果正样本只占 3%随机切分有可能测试集里只剩个位数正样本评估结果的方差大到没有参考价值分层之后训练集和测试集的正样本比例被强制对齐。random_state不是「为了好看」而是让你在两周后能复现同一批数据划分——模型指标对不上时第一个要排除的就是切分变了。test_size0.2是常规比例样本量低于 2000 行时我会改成 0.25 甚至 0.3宁可训练数据少一点也要保证测试集里每类都有足够的样本支撑指标计算。最后那行print里的y_train.mean()是我个人的习惯训练集和测试集的正样本率应该几乎相等差超过 0.01 就说明分层没生效或者标签里有脏值。2.3 特征预处理不用标准化但这三件事必须做随机森林基于特征阈值切分对量纲不敏感所以标准化、归一化这一步可以省掉——这也是它在表格数据上比逻辑回归、SVM 省事的地方。但省掉标准化不等于省掉预处理下面三件事不做模型要么报错要么悄悄给你一个虚高的分数。第一件是缺失值。RandomForestClassifier从早版本起就不接受NaN直接喂进去会抛ValueError: Input contains NaN。常见做法是数值列填中位数、类别列填众数或者干脆加一列缺失标记再填补让模型自己学「缺失」这件事有没有信息量。我一般先看缺失率低于 5% 的列直接填中位数高于 40% 的列先问一句这列还值不值得留。第二件是类别编码。随机森林不能直接吃字符串LabelEncoder把类别映射成 0、1、2 会让树误以为这是有序的Sexfemale变成 1、male变成 0这个顺序是人造的。基数小10 以内的类别列用 one-hot 最稳基数大的列用目标编码更容易保住信息但目标编码必须在训练集内部做不然就是标签泄漏。第三件是常量列和近常量列。某一列 99.8% 都是同一个值它对划分几乎没有贡献但会占一个特征重要性名额稀释真正的强特征。我通常在加载后加一句df df.loc[:, df.nunique() 1]把常量列清掉近常量的列则看业务判断。# src/preprocess.py import pandas as pd from sklearn.impute import SimpleImputer def basic_clean(df: pd.DataFrame, num_cols, cat_cols): # 1) 常量列直接删 df df.loc[:, df.nunique() 1].copy() # 2) 数值列中位数填补 num_imp SimpleImputer(strategymedian) df[num_cols] num_imp.fit_transform(df[num_cols]) # 3) 类别列众数填补 one-hotdrop_first 去掉共线的那一列 for c in cat_cols: df[c] df[c].fillna(df[c].mode()[0]) df pd.get_dummies(df, columnscat_cols, drop_firstTrue) return df这段代码里SimpleImputer必须fit在训练集上、transform到测试集上直接对全量数据fit_transform是最典型的标签泄漏。drop_firstTrue的作用是去掉 one-hot 产生的冗余列对随机森林来说不是必需树模型不怕共线但能少几个特征维度训练快一点。3. 训练主链路从单棵树到森林的参数怎么定预处理做完就进入真正决定效果的一段。随机森林的参数不算多但每一个都有明确的适用区间拍脑袋填默认值跑出来的模型通常既不是最好也不是最差只是「看起来能用」。这一章按我实际的调参顺序写先立对照组再定森林规模最后看输出。3.1 先跑一棵决策树当对照组跳过单棵树直接上森林是我见过最常见的方法论缺失。随机森林的本质是「很多棵带随机性的树投票」如果单棵树的效果和森林差不多说明数据本身的信号就那么多继续加树只是浪费算力如果森林比单棵树好一大截才说明 bagging 和特征随机性起了作用这时候调max_features才有意义。# src/baseline_tree.py from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import f1_score tree DecisionTreeClassifier( max_depth5, # 先限深不加限制的树一定过拟合 min_samples_leaf20, # 叶子最少 20 个样本防止切出只含几个点的碎片叶子 random_state42 ) tree.fit(X_train, y_train) pred_tree tree.predict(X_test) print(单树 F1:, round(f1_score(y_test, pred_tree), 4))max_depth5和min_samples_leaf20这两个值不是随便写的限深控制模型复杂度叶子最小样本数控制切分粒度两者一起用能把单棵树压到一个「能看出趋势但不过拟合」的状态。这一步的产出不是最终模型而是一个基准分数——后面森林跑出来如果连这个数都超不过问题多半在数据或特征不在模型。3.2 n_estimators、max_depth、max_features 的取值区间森林这一层的参数我按影响从大到小排n_estimators决定树的数量max_depth决定单棵树的复杂度max_features决定每次分裂时候选特征的多少min_samples_leaf和min_samples_split控制叶子粒度class_weight处理不平衡。参数作用常用区间调大的后果n_estimators树的数量100 起常用 300~500指标上升后趋平训练时间和内存线性增长max_depth单棵树最大深度5~20或不限太深必过拟合太浅欠拟合max_features每次分裂的候选特征数分类 sqrt(n)回归 n/3越大单树越强、树间相关性越高min_samples_leaf叶子最小样本数1~50样本少时调大越大模型越平滑偏差上升class_weight类别权重None 或 balancedbalanced 提升少数类召回牺牲部分精度n_estimators是最不需要纠结的一个从 100 加到 300指标通常有明显提升300 加到 500 提升已经很微弱再往上基本是拿电费换小数点后第三位。我的习惯是先固定 300 跑通全流程等参数和特征都稳定了再单独扫一次 100/300/500/800画一条曲线确认拐点在哪。max_features是随机森林之所以「随机」的关键。分类任务默认sqrt(n_features)回归默认1.0也就是用全部特征这一点常被误会。它直接控制树与树之间的相关性取值越小单棵树越弱但彼此越独立集成后的方差越低取值越大单棵树越强但树之间越像退化成一个普通的 bagging。特征数在 20 到 200 之间时我一般在sqrt(n)附近做一个小范围扫描而不是死守默认值。# src/train_rf.py from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report rf RandomForestClassifier( n_estimators300, # 树的数量先给一个够用的值 max_depth12, # 限制深度控制单树复杂度 max_featuressqrt, # 分类任务默认特征维度高时可显式写死数值 min_samples_leaf3, # 叶子最小样本缓解过拟合 class_weightbalanced, # 正样本少时开启代价是精度可能略降 oob_scoreTrue, # 打开袋外评估白拿一个验证分数 n_jobs-1, # 用满所有核注意内存 random_state42 ) rf.fit(X_train, y_train) pred rf.predict(X_test) print(OOB:, round(rf.oob_score_, 4)) print(classification_report(y_test, pred, digits4))oob_scoreTrue是随机森林相对其他模型的一个白拿的福利每个样本在每棵树里约有三分之一概率没被抽到这些「袋外样本」可以当验证集用不需要额外切分。n_jobs-1会占满所有 CPU 核小数据上感觉不到数据量上去之后内存峰值会翻几倍这一点后面避坑章会单独说。3.3 训练结束先看这三组输出模型fit完很多人直接看accuracy就结束了。我一般固定看三组OOB 分数和测试集分数的差距、分类报告里各类的 precision/recall、以及正负样本预测概率的分布。OOB 和测试集分数如果差得很多比如 OOB 0.88、测试集 0.72说明模型在袋外样本上表现好但在真正的新数据上掉下来通常是训练集和测试集的分布不一致或者测试集太小导致指标抖动。两者接近才说明这个分数是可以信的。分类报告里二分类任务重点看少数类的 recall。准确率在类别不平衡时是彻底的误导指标正样本占 5% 时全预测成负类也有 95% 的准确率。class_weightbalanced会把少数类的权重按比例放大代价是整体准确率可能降一两个点但少数类的召回通常能提十几个点——哪个更重要取决于业务不是取决于数字好看。第三组是预测概率的分布。把rf.predict_proba(X_test)[:, 1]拉出来看直方图如果大量样本挤在 0.45 到 0.55 之间说明模型对这批样本几乎没有区分度只是被 0.5 的阈值硬切成了两类。这种情况要么加特征要么改用predict_proba输出概率交给下游做分层决策而不是死守 0.5。4. 评估与可解释性OOB、混淆矩阵和特征重要性随机森林最大的卖点是「效果稳 能解释」但这两点都得靠正确的评估方式兜住。评估做错模型再准也是自欺解释做错特征重要性排序会把业务方带进沟里。这一章讲三件在交付前必须做的事。4.1 OOB 分数不用验证集也能估泛化袋外评估的原理不复杂自助采样bootstrap时每个样本被抽中的概率约 63.2%剩下约 36.8% 没进训练集这些样本就是这棵树的「袋外样本」。森林里每棵树都对属于自己袋外的样本做一次预测把所有树的袋外预测聚合起来就得到一个不依赖额外切分的泛化估计。它的价值在于省数据。样本量只有几千行的时候再切一个验证集出来会很心疼OOB 让你在只用训练集的情况下就能判断模型有没有过拟合。但 OOB 有两个边界要知道一是它随n_estimators增大而趋于稳定树太少比如 50 棵以下时 OOB 波动很大二是它不能替代测试集因为 OOB 用的仍然是训练集的数据分布对分布漂移毫无察觉。# src/evaluate.py import numpy as np import pandas as pd from sklearn.metrics import confusion_matrix, roc_auc_score proba rf.predict_proba(X_test)[:, 1] pred (proba 0.5).astype(int) cm confusion_matrix(y_test, pred) print(pd.DataFrame(cm, index[真实-负, 真实-正], columns[预测-负, 预测-正])) print(AUC:, round(roc_auc_score(y_test, proba), 4)) # 概率分布分桶看模型是不是在 0.5 附近瞎猜 bins pd.cut(proba, bins[0, 0.2, 0.4, 0.5, 0.6, 0.8, 1.0]) print(bins.value_counts().sort_index())roc_auc_score用的是概率而不是硬标签它衡量的是「随机取一个正样本和一个负样本正样本得分更高的概率」对阈值不敏感是比准确率更可靠的排序质量指标。后面那段分桶统计是我自己加的纯粹为了肉眼判断模型有没有区分度跑一次几秒钟能省掉很多无效的调参。4.2 混淆矩阵与分类报告准确率骗人的时候混淆矩阵要读的是四个格子背后的业务含义不是四个数字。假阳性预测为正、实际为负和假阴性预测为负、实际为正的代价在绝大多数场景里并不对称风控场景漏掉一个坏用户的代价远高于误拦一个好人医疗筛查漏诊的代价远高于误报。这种情况下阈值应该往少数类那边偏而不是停在 0.5。一个实操细节调整阈值之后分类报告要重新算不能拿默认阈值的结果去汇报。我一般的做法是扫一遍 0.1 到 0.9 的阈值把每个阈值下的 precision、recall、F1 列成一张小表交给业务方选一个他们能接受的工作点而不是我替他们拍板。classification_report里的macro avg和weighted avg也要分清macro是各类指标的直接平均少数类的表现会被同等权重计入weighted按样本数加权少数类的影响被稀释。类别不平衡时看macro看整体表现时看weighted两个数差很多就说明模型在偏科。4.3 特征重要性impurity 和 permutation 要对着看rf.feature_importances_用的是不纯度下降mean decrease in impurity计算快但有一个公认的偏差它偏爱取值多的连续特征和高基数类别特征因为这类特征有更多的切分点可以选择天然容易带来更大的不纯度下降。直接用这个排序去汇报很容易得出「用户 ID 最重要」这种荒谬结论。我的固定做法是两种重要性对着看先看 impurity 重要性拿到一个初筛再用置换重要性permutation importance在测试集上交叉验证。置换重要性的逻辑更朴素也更可信把某一列的值随机打乱看模型指标掉多少掉得多说明这列重要几乎不掉说明这列可有可无。它慢一些但结论更接近真实。# src/importance.py import numpy as np import pandas as pd from sklearn.inspection import permutation_importance imp pd.Series(rf.feature_importances_, indexX_train.columns) perm permutation_importance( rf, X_test, y_test, n_repeats10, # 每列重复打乱 10 次取均值降方差 random_state42, scoringf1 # 用和业务一致的指标别默认 accuracy ) perm_s pd.Series(perm.importances_mean, indexX_test.columns) compare pd.DataFrame({impurity: imp, permutation: perm_s}) compare[rank_gap] compare[impurity].rank(ascendingFalse) - \ compare[permutation].rank(ascendingFalse) print(compare.sort_values(permutation, ascendingFalse).head(15))n_repeats10是精度和耗时的折中重复次数越多均值的方差越小10 次在中等规模数据上通常够用。scoring一定要显式指定成和业务指标一致的那个默认用 accuracy 在不平衡数据上会得出完全错误的排序。最后那列rank_gap是我自己加的两种重要性排名差距大的特征通常就是高基数或强噪声的嫌疑对象值得单独看一眼原始分布。5. 避坑与排查五条实打实踩过的记录下面这五条是我在把随机森林从「能跑」推到「敢交付」的过程中反复撞上的。每条按现象、原因、解决写不展开理论。5.1 训练集 0.99、测试集 0.62现象训练完打印训练集准确率 0.99测试集只有 0.62两个数放在一起像两个模型。原因树的深度完全没限制min_samples_leaf默认是 1每棵树都能一路切到每个叶子只剩一个样本训练集被完整记住。随机森林虽然有 bagging 平均但单树过拟合到极致时平均也救不回来。解决先给max_depth设一个 10 到 15 的上限同时把min_samples_leaf提到 3 到 20 之间然后固定n_estimators300重跑。如果训练测试差距还是很大再回头查特征里有没有泄漏列比如和目标列强相关的衍生字段。5.2 特征重要性第一名是一列 ID现象feature_importances_排出来第一位是一个看着就应该是标识符的列业务方当场质疑整个模型。原因这一列的基数极高几乎每行一个取值不纯度重要性对这类特征有天生的偏爱树可以用它切出大量碎片化的分支每次切分都能带来一点不纯度下降累积起来就排到了第一。解决入模前把所有 ID、时间戳、流水号类字段全部剔除如果业务上确实需要保留就改用置换重要性做最终排序。判断标准很简单——这列乱序打乱之后指标几乎不变那它本来就不该在模型里。5.3 n_jobs-1 之后内存占用立刻升高现象小数据上跑得好好的脚本换到几万行、上百个特征的数据上进程被杀日志里只留下一句 MemoryError。原因n_jobs-1会为每个 CPU 核复制一份训练数据核数乘以数据体积就是内存峰值。数据本身在内存里占 2G16 核跑起来峰值能到 20G 以上普通机器和容器环境都扛不住。解决把n_jobs显式设成一个具体值4 或 8用top或容器监控看一眼内存曲线再定。另外可以在训练前把浮点特征降成 float32内存直接减半精度损失在随机森林这种阈值切分模型上基本看不出来。5.4 类别特征直接丢进去报错现象ValueError: could not convert string to float报错行指向fit。原因数据里还有字符串列没编码pandas读进来是object类型sklearn 不接受。解决入模前跑一句X_train.dtypes[X_train.dtypes object]把字符串列揪出来逐列决定是 one-hot 还是丢弃。这一步最好固化到加载脚本里而不是每次训练前手动查一遍。另外要确认训练集和测试集的列完全一致——one-hot 之后测试集少一列是高频翻车点用X_test X_test.reindex(columnsX_train.columns, fill_value0)兜一句。5.5 random_state 不写两次结果对不上现象同一个人、同一份数据、同一份代码跑两次指标差两个点争论到底是数据变了还是模型不行。原因RandomForestClassifier不设random_state时每次的 bootstrap 采样和特征子集选择都不同结果自然有波动。数据量越小波动越大。解决random_state在切分、模型、置换重要性三个地方全部显式写死。同时要建立一个认知——固定种子是为了「可复现地调试」不是为了「掩盖方差」。真正判断模型稳不稳得跑 5 折交叉验证看均值和标准差单次结果没有任何说服力。6. 进阶把小网格搜索和交叉验证串成一条能复现的流程参数和评估都跑通之后最后一步是把「调参」这件事变成一条可复现的流水线。我见过太多人调参是手动改数字、跑一次、记一下、再改跑了二十次之后完全说不清哪个组合对应哪个分数血泪经验。正确做法是把搜索空间、交叉验证和结果写入文件一次性写完让机器去跑。6.1 网格范围怎么框才不浪费机时网格搜索的耗时是各参数取值个数的乘积乘以折数再乘以单次训练时间参数一多就爆炸。我的做法是先做单参数扫描确认每个参数的拐点在哪再在拐点附近做小网格。# src/tune.py import json import pandas as pd from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import GridSearchCV, StratifiedKFold param_grid { n_estimators: [200, 300, 500], max_depth: [8, 12, 16], min_samples_leaf: [1, 3, 8], max_features: [sqrt, 0.3] } cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) gs GridSearchCV( estimatorRandomForestClassifier(class_weightbalanced, n_jobs4, random_state42), param_gridparam_grid, scoringf1, # 和业务指标对齐别用默认 accuracy cvcv, n_jobs1, # 外层不再并行避免嵌套并行把内存打满 verbose1, return_train_scoreTrue # 保留训练分数用来看过拟合程度 ) gs.fit(X_train, y_train) print(best:, gs.best_params_, round(gs.best_score_, 4)) cvdf pd.DataFrame(gs.cv_results_) cvdf.to_csv(outputs/grid_results.csv, indexFalse) with open(outputs/best_params.json, w) as f: json.dump(gs.best_params_, f, indent2)这个网格一共 3×3×3×254 组5 折就是 270 次训练300 棵树的量级下在普通笔记本上大概几十分钟属于可接受的区间。n_jobs1这个设置容易引起争议内层模型已经用 4 个核并行建树外层搜索再开并行会形成嵌套内存峰值成倍上涨还不如老老实实串行跑。return_train_scoreTrue的用处是拿训练分数和验证分数对比如果某组参数训练 0.99、验证 0.7那组参数直接淘汰不用等它排到第一才发现过拟合。选参数时不要只盯best_score_。我的习惯是看cv_results_里前五名的均值和标准差选那个「均值高、标准差小」的组合而不是均值最高但标准差大的。差 0.005 的均值换 0.03 的标准差下降在真实数据上通常是划算的。6.2 把最优参数、模型和评估报告一起保存到文件调参调完就结束的流程两周后必然返工。交付前的最后一步是把最优参数、训练好的模型、测试集指标、特征重要性一起落盘形成一份能对得上的记录。# src/finalize.py import joblib from sklearn.metrics import classification_report best gs.best_estimator_ # 已经是全量训练集上 refit 过的模型 joblib.dump(best, outputs/rf_model.joblib) # 保存整模型下游直接 load 即可 report classification_report(y_test, best.predict(X_test), output_dictTrue) pd.DataFrame(report).T.to_csv(outputs/test_report.csv) fi pd.Series(best.feature_importances_, indexX_train.columns).sort_values(ascendingFalse) fi.to_csv(outputs/feature_importance.csv, header[importance])GridSearchCV在找到最优参数后默认会用全量训练数据重新fit一次best_estimator_拿到的就是这个 refit 后的模型不需要再手动训一遍。joblib比pickle更适合保存带大量 numpy 数组的 sklearn 模型读写更快。三份产出放在一起任何人拿到这个目录都能回答「用了哪些参数、测试集多少分、哪些特征在起作用」这三个问题。我现在的固定习惯是任何一次要拿去汇报的模型必须先跑完这条流水线参数、分数、重要性三份文件缺一不可缺了就不许进汇报材料。吃过一次亏——半年前的一个基线模型特征重要性截图还在参数和随机种子找不到了只能推倒重跑白丢两天。所以别嫌这一步麻烦它是唯一能让你在两个月后还说得清模型是怎么来的东西。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑