大数跨境

彻底讲透一个强大算法模型,随机森林 !!

彻底讲透一个强大算法模型,随机森林 !! 机器学习和人工智能AI
2026-07-17
3

哈喽,大家好~

今儿和大家详细的聊聊随机森林~

咱们先举一个例子:

当你在一个重要问题上要做判断,你不希望只听一个人的意见,因为那个人可能有偏见或偶然错误;更稳妥的方法是问一群人,每个人独立做判断,然后“投票取众”。随机森林就是机器学习里把这个想法量化的算法:它让很多“决策树”分别在稍有不同的数据/视角下做决定,最后把这些树的结果汇总(分类时投票,回归时取平均),通常得到比单棵树更稳健、更不容易过拟合的结果。

核心要点:

  • 多个弱模型(决策树)合起来一个强模型。
  • 通过引入随机性让个体之间“差异化”,避免大家都犯同样的错误。
  • 可以量化“袋外误差”来估计泛化能力,不一定需要单独验证集。
  • 输出还可以给出“特征重要性”,便于解释。

随机森林核心逻辑

随机森林通常基于 CART(Classification and Regression Trees)决策树。

核心包含两层“随机”:

  1. 数据随机:每棵树用原始训练集有放回采样得到的子样本训练,大约 63.2% 的样本会出现在某棵树的训练集中,剩下的样本称为该树的袋外(OOB)样本。
  2. 特征随机:每次节点分裂时,不在所有特征上寻找最优分割,而是在随机选择的 $m$ 个特征子集中寻找最优分割($m$ 通常为 $\sqrt{p}$ 或 $p/3$,$p$ 为特征总数),从而降低树之间的相关度。

决策树择分用的不纯度指标(以分类为例)常用 Gini:

$$Gini(S) = 1 - \sum_{k=1}^{K} p_k^2 $$

其中 $p_k$ 是样本集 $S$ 中属于类别 $k$ 的比例。

某次按某特征与阈值划分为左右子集 $S_L, S_R$,不纯度减少量(基于 Gini)为:

$$\Delta Gini = Gini(S) - \frac{|S_L|}{|S|} Gini(S_L) - \frac{|S_R|}{|S|} Gini(S_R) $$

树的训练过程就是在允许的特征子集上选择能最大化 $\Delta Gini$ 的分裂,递归建树直至满足停止条件(最大深度、最小样本数等)。

最终预测(分类)用多数投票:

$$\hat{y} = \text{mode}\{ T_1(x), T_2(x), ..., T_M(x)\} $$

回归则取平均:

$$\hat{y} = \frac{1}{M}\sum_{m=1}^{M} T_m(x) $$

袋外误差(OOB)可以直接用于估计泛化误差,因为对于某个样本,被某棵树训练所用的概率是 $\approx 1 - (1 - 1/n)^n \approx 1 - e^{-1} \approx 0.632$,所以有一部分树对该样本是“未见过”的,可以利用它们的投票来评估。

完整案例

我们试着用 PyTorch 从头实现一个简化版的随机森林分类器~

数据方面,我们使用 sklearn 的 make_moons 与 make_blobs 叠加产生复杂非线性、带噪声的数据(二维),便于可视化决策边界。

用 PyTorch 张量加速分割与统计(不是用 sklearn 的 RandomForest)。

为简捷,决策树取 CART 的基本策略,分裂阈值取特征值的中点(剪枝采用最大深度与最小样本数)。

import torch
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
from sklearn.datasets import make_moons, make_blobs
from sklearn.metrics import confusion_matrix, roc_curve, auc
from sklearn.model_selection import train_test_split
import seaborn as sns
sns.set()

device = torch.device('cpu'

# 生成二维数据:moons + blobs 噪声叠加
np.random.seed(42)
X1, y1 = make_moons(n_samples=300, noise=0.15, random_state=0)
X2, y2 = make_blobs(n_samples=100, centers=[(2.50.0)], cluster_std=0.25, random_state=1)
# 将 blob 的标签设为 1(与 moon 中的一类可能重合),为了制造复杂的重叠
X = np.vstack([X1, X2])
y = np.hstack([y1, np.ones(len(y2))])

# 打乱并切分训练/测试
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
X_train = torch.tensor(X_train, dtype=torch.float32)
X_test  = torch.tensor(X_test,  dtype=torch.float32)
y_train = torch.tensor(y_train, dtype=torch.long)
y_test  = torch.tensor(y_test,  dtype=torch.long)

实现简化 CART 决策树:

class SimpleDecisionTree:
    def __init__(self, max_depth=6, min_samples_split=2, max_features=None):
        self.max_depth = max_depth
        self.min_samples_split = min_samples_split
        self.max_features = max_features  # number or None -> use all features
        self.tree = None

    def fit(self, X, y):
        X = X.clone()
        y = y.clone()
        n_features = X.shape[1]
        if self.max_features isNone:
            self.max_features = n_features
        self.n_classes = int(y.max().item() + 1)
        self.tree = self._build_tree(X, y, depth=0)

    def _gini(self, y):
        # y: 1D tensor of labels
        if y.numel() == 0:
            return0.0
        counts = torch.bincount(y, minlength=self.n_classes).float()
        p = counts / counts.sum()
        return1.0 - torch.sum(p * p)

    def _best_split(self, X, y):
        n_samples, n_features = X.shape
        best = {'feature'None'threshold'None'gain'0.0'left_idx'None'right_idx'None}
        base_gini = self._gini(y)
        # 随机选特征子集
        feat_idx = np.random.choice(n_features, self.max_features, replace=False)
        for feat in feat_idx:
            vals = X[:, feat]
            # 候选阈值:按特征值的中点
            sorted_vals, idx = torch.sort(vals)
            # 仅在不同值之间考虑阈值
            thresholds = (sorted_vals[:-1] + sorted_vals[1:]) / 2.0
            if thresholds.numel() == 0:
                continue
            # Vectorized check: for each threshold compute gini
            for thr in thresholds:
                left_mask = vals <= thr
                right_mask = ~left_mask
                if left_mask.sum() < 1or right_mask.sum() < 1:
                    continue
                g_left = self._gini(y[left_mask])
                g_right = self._gini(y[right_mask])
                w_left = left_mask.sum().item() / n_samples
                w_right = right_mask.sum().item() / n_samples
                gain = base_gini - (w_left * g_left + w_right * g_right)
                if gain > best['gain']:
                    best.update({'feature': int(feat), 'threshold': float(thr.item()), 'gain': float(gain),
                                 'left_idx': left_mask, 'right_idx': right_mask})
        return best

    def _build_tree(self, X, y, depth):
        node = {}
        n_samples = y.numel()
        num_labels = torch.bincount(y, minlength=self.n_classes)
        pred_label = torch.argmax(num_labels).item()
        node['is_leaf'] = False
        node['prediction'] = int(pred_label)
        # stopping criteria
        if depth >= self.max_depth or n_samples < self.min_samples_split or (num_labels > 0).sum() == 1:
            node['is_leaf'] = True
            return node
        best = self._best_split(X, y)
        if best['feature'isNoneor best['gain'] <= 1e-6:
            node['is_leaf'] = True
            return node
        # create children
        node['feature'] = best['feature']
        node['threshold'] = best['threshold']
        left_idx = best['left_idx']
        right_idx = best['right_idx']
        node['left'] = self._build_tree(X[left_idx], y[left_idx], depth + 1)
        node['right'] = self._build_tree(X[right_idx], y[right_idx], depth + 1)
        return node

    def _predict_one(self, x, node):
        if node['is_leaf']:
            return node['prediction']
        feat = node['feature']
        if x[feat] <= node['threshold']:
            return self._predict_one(x, node['left'])
        else:
            return self._predict_one(x, node['right'])

    def predict(self, X):
        X = X.clone()
        preds = []
        for i in range(X.shape[0]):
            preds.append(self._predict_one(X[i], self.tree))
        return torch.tensor(preds, dtype=torch.long)

实现随机森林:

class SimpleRandomForest:
    def __init__(self, n_estimators=20, max_depth=6, min_samples_split=2, max_features=None):
        self.n_estimators = n_estimators
        self.trees = []
        self.max_depth = max_depth
        self.min_samples_split = min_samples_split
        self.max_features = max_features
        self.oob_votes = None# for OOB evaluation
        self.feature_importances_ = None

    def fit(self, X, y):
        n_samples = X.shape[0]
        self.n_classes = int(y.max().item() + 1)
        self.trees = []
        # For OOB tracking: for each sample, accumulate votes from trees where sample was OOB
        oob_votes = [ [] for _ in range(n_samples) ]
        # feature importances: sum of gains per feature across trees
        feat_importances = torch.zeros(X.shape[1])
        for m in range(self.n_estimators):
            # bootstrap sample indices
            idxs = np.random.choice(n_samples, size=n_samples, replace=True)
            idxs = torch.tensor(idxs, dtype=torch.long)
            oob_mask = np.ones(n_samples, dtype=bool)
            oob_mask[idxs.numpy()] = False
            # train tree on bootstrap sample
            tree = SimpleDecisionTree(max_depth=self.max_depth, min_samples_split=self.min_samples_split,
                                      max_features=self.max_features)
            tree.fit(X[idxs], y[idxs])
            self.trees.append(tree)
            # accumulate OOB votes
            for i in range(n_samples):
                if oob_mask[i]:
                    pred = tree.predict(X[i:i+1])[0].item()
                    oob_votes[i].append(pred)
            # approximate feature importance by traversing tree and summing gains
            NOTE: here our SimpleDecisionTree didn't store gain per split; for demo we approximate by counting usage
            # A better impl would return per-split gain. We'll approximate: count appearance of feature in tree nodes
            def traverse(node):
                if node.get('is_leaf'False):
                    return []
                feat = node.get('feature'None)
                res = [feat] if feat isnotNoneelse []
                res += traverse(node.get('left', {}))
                res += traverse(node.get('right', {}))
                return res
            used_feats = traverse(tree.tree)
            for f in used_feats:
                if f isnotNone:
                    feat_importances[f] += 1.0
        # compute OOB error
        oob_preds = []
        oob_true = []
        for i in range(n_samples):
            votes = oob_votes[i]
            if len(votes) == 0:
                continue
            counts = np.bincount(votes, minlength=self.n_classes)
            pred = np.argmax(counts)
            oob_preds.append(pred)
            oob_true.append(int(y[i].item()))
        if len(oob_preds) > 0:
            oob_preds = np.array(oob_preds)
            oob_true = np.array(oob_true)
            oob_err = 1.0 - np.mean(oob_preds == oob_true)
        else:
            oob_err = None
        self.oob_error_ = oob_err
        # normalize feature importances
        self.feature_importances_ = (feat_importances / feat_importances.sum()).numpy()
        return self

    def predict(self, X):
        # majority voting
        all_preds = []
        for tree in self.trees:
            all_preds.append(tree.predict(X).numpy())
        all_preds = np.stack(all_preds, axis=1)  # (n_samples, n_trees)
        final = []
        for row in all_preds:
            counts = np.bincount(row, minlength=self.n_classes)
            final.append(np.argmax(counts))
        return np.array(final)

    def predict_proba(self, X):
        # class probability by averaging one-hot votes
        all_preds = []
        for tree in self.trees:
            all_preds.append(tree.predict(X).numpy())
        all_preds = np.stack(all_preds, axis=1)  # (n_samples, n_trees)
        probs = []
        for row in all_preds:
            counts = np.bincount(row, minlength=self.n_classes).astype(float)
            probs.append(counts / counts.sum())
        return np.array(probs)

训练随机森林并画图(决策边界 / 数据分布 / 特征重要性 / OOB):

# 参数设置
n_estimators = 40
rf = SimpleRandomForest(n_estimators=n_estimators, max_depth=6, min_samples_split=2, max_features=1)  # max_features=1 -> random

rf.fit(X_train, y_train)
y_pred = rf.predict(X_test)
acc = (y_pred == y_test.numpy()).mean()
print(f"Test accuracy: {acc:.3f}, OOB error: {rf.oob_error_}")

# 1) 数据散点图(训练 / 测试)
plt.figure(figsize=(6,5))
cmap_bright = ListedColormap(['#FF3B30''#34C759'])
plt.scatter(X_train[:,0], X_train[:,1], c=y_train, cmap=cmap_bright, edgecolor='k', alpha=0.7, label='train')
plt.scatter(X_test[:,0], X_test[:,1], c=y_test, cmap=cmap_bright, marker='^', edgecolor='k', alpha=1.0, label='test', s=80)
plt.title("Data distribution (bright colors)")
plt.legend()
plt.tight_layout()
plt.show()

# 2) 决策边界热力图
xx, yy = np.meshgrid(np.linspace(X[:,0].min()-0.5, X[:,0].max()+0.5300),
                     np.linspace(X[:,1].min()-0.8, X[:,1].max()+0.8300))
grid = np.c_[xx.ravel(), yy.ravel()]
grid_t = torch.tensor(grid, dtype=torch.float32)
Z = rf.predict(grid_t)
Z = Z.reshape(xx.shape)

plt.figure(figsize=(8,6))
cmap_bg = ListedColormap(['#FFEEEC''#E8FFF1'])
plt.contourf(xx, yy, Z, alpha=0.6, cmap=ListedColormap(['#FFCCCB','#C8F7D6']))
plt.scatter(X[:,0], X[:,1], c=y, cmap=cmap_bright, edgecolor='k', s=30)
plt.title("Random Forest Decision Boundary (vivid palette)")
plt.tight_layout()
plt.show()

# 3) 特征重要性条形图
plt.figure(figsize=(6,4))
feats = np.arange(X.shape[1])
imp = rf.feature_importances_
colors = ['#FF9500''#007AFF']
plt.bar(feats, imp, color=colors, edgecolor='k')
plt.xticks(feats, [f"f{int(i)}"for i in feats])
plt.title("Feature importances (approx)")
plt.ylabel("Relative importance")
plt.tight_layout()
plt.show()

# 4) OOB error 随树数变化(通过逐步训练近似)
# 为演示,我们按 1...n_estimators 分别训练多个 RF 并记录 OOB
oob_errs = []
for m in range(1, n_estimators+1):
    rf_m = SimpleRandomForest(n_estimators=m, max_depth=6, min_samples_split=2, max_features=1)
    rf_m.fit(X_train, y_train)
    oob_errs.append(rf_m.oob_error_)
plt.figure(figsize=(6,4))
plt.plot(range(1, n_estimators+1), oob_errs, '-o', color='#5856D6')
plt.xlabel("Number of trees")
plt.ylabel("OOB error")
plt.title("OOB error vs n_estimators")
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()

# 5) 混淆矩阵 & ROC(测试集)
cm = confusion_matrix(y_test.numpy(), y_pred)
plt.figure(figsize=(5,4))
sns.heatmap(cm, annot=True, fmt='d', cmap='YlGnBu', cbar=False)
plt.xlabel("Predicted")
plt.ylabel("True")
plt.title("Confusion Matrix (test)")
plt.tight_layout()
plt.show()

# ROC (二分类)
probs = rf.predict_proba(X_test)[:,1]
fpr, tpr, _ = roc_curve(y_test.numpy(), probs)
roc_auc = auc(fpr, tpr)
plt.figure(figsize=(5,4))
plt.plot(fpr, tpr, color='#FF2D55', lw=2, label=f'AUC = {roc_auc:.3f}')
plt.plot([0,1],[0,1], color='gray', linestyle='--')
plt.xlabel("FPR")
plt.ylabel("TPR")
plt.title("ROC Curve")
plt.legend(loc='lower right')
plt.tight_layout()
plt.show()

注意,我们在决策树实现中没有记录每次分裂的实际 gain 值(为保持代码简洁)。因此特征重要性这里采用“节点中被选用次数”作为近似量度(实际工程中应累计每次分裂带来的不纯度减少 $\Delta Gini$)。

训练时间与树的数量、数据量和 max_features 相关。我的实现是演示性质,适合教学与小数据测试;工业级随机森林建议使用 sklearn/LightGBM/XGBoost 等高效实现。

可视化分析

数据散点图:

直观观察数据在二维空间中的分布、标签类别及训练/测试划分。

通过颜色和标记可以看到数据的非线性结构(moons)与局部簇(blobs),说明线性模型难以胜任。

随机森林决策边界:

可视化模型在整个输入空间的分类决策。

由于随机森林由多棵非线性树组成,边界通常呈阶梯状、分段包围类区域。能看到模型如何分离两个类、在重叠区域的模糊边界,以及是否产生过度分割(过拟合)。热力与点的叠加方便对比真实数据。

特征重要性:

评估不同特征对模型决策的贡献。

在合成数据中两维可能贡献不一样(例如 blob 的位置可能高度依赖 x 轴)。条形图能告诉我们哪一维更能区分类别,便于后续特征筛选或工程解释。

OOB 误差随树数变化:

展示树的数量如何影响泛化误差,是否持续下降或趋于稳定。

通常随着树数增加,OOB 误差会下降然后趋于平稳(方差减少,但偏差稳定)。这个图能帮助我们选择合适的树数(在耗时与效果间折中)。

混淆矩阵与 ROC 曲线:

定量评估预测性能和错误类型(误报/漏报)。

混淆矩阵告诉你模型在哪个类上更容易犯错;ROC/AUC 提供 threshold 不变情况下模型的整体判别能力(越接近 1 越好)。这些都是模型选择与迭代的重要指标。

总结

随机森林,通常效果稳健,抗噪声能力强,不容易过拟合,相较于单棵完整树。

可以处理高维数据、混合类型特征(数值、类别)。

自带特征重要性评估,便于解释。OOB 误差可用于模型监控而不需要独立验证集(节省数据)。

缺点也很明显,在高维稀疏问题上(如文本)可能不如线性模型或基于树的梯度提升在速度与效果上优秀。

另外,模型体积较大,可解释性不如单棵树。

最后

最近准备了16大块的内容,124个算法问题的总结,完整的机器学习小册,免费领取~

领取:备注「算法小册」即可~
扫码如有问题,记得添加微信号:xiaobai_ml012

【声明】内容源于网络
0
0
机器学习和人工智能AI
让我们一起期待 AI 带给我们的每一场变革!推送最新行业内最新最前沿人工智能技术!
内容 355
粉丝 0
机器学习和人工智能AI 让我们一起期待 AI 带给我们的每一场变革!推送最新行业内最新最前沿人工智能技术!
总阅读3.1k
粉丝0
内容355