☰
极端随机森林类别增量学习:Matlab实现与工程实践
2026/10/22 5:43:35 网站建设 项目流程

先说个我之前踩过的坑。某设备状态监测项目里,我训练了一个分类模型,能区分“正常”和“轻微磨损”两类。跑了三个月,效果一直不错,直到有一天现场反馈说出现了一种新现象——“异物卡滞”,车间希望模型能直接把这种新状态识别出来。问题来了:重新采集数据、打标、重训模型当然可行,但历史样本已经“压缩”进旧模型里了,重训意味着把大批老样本翻出来重新过一遍,而生产环境里模型还在实时预测,说停就停根本不现实。如果有一种方案能让模型在已有结构上“只加节点、不动根基”,在线吸收新类别,那就舒服太多了。这就是类别增量分类要解决的核心问题,也是我把极端随机森林(ERF)改造为类增量分类器的初衷。

下面这篇文章,我会完整复盘基于类别增量的极端随机森林在Matlab下的实现思路、核心代码、参数调优和我实际踩过的坑。适合正在做增量学习、工业故障诊断、在线分类场景的开发者,也适合想深入理解ERF原理但不想只看论文的初学者。

1. 类别增量场景下的核心矛盾

1.1 为什么“推倒重训”不是最优解

很多人在面对“新增类别”时,第一反应是收集新数据,把所有类别放到一起重新训练。这种方式在离线实验里没毛病,但放到真实生产环境里会立刻遇到三个问题。

第一是存储成本。旧类别的原始样本未必还保留着。我做那个设备监测项目时,历史数据是按批次归档的,有些早期批次的原始波形已经被压缩或者清理了,想重训还得先恢复数据。第二是训练时间。如果模型已经在一个大数据集上训好了,每来一个新类别就全量重训一次,计算成本会随着类别数量线性上涨,现场根本等不起。第三是稳定性。“灾难性遗忘”在神经网络里被讨论得多,其实树模型也有类似问题——如果用一个包含新类别的数据集从头重训,旧类别样本占比一旦被稀释,模型对旧类别的决策边界一样会漂移。

类别增量学习的核心诉求是:模型已经学到的东西尽量不动,只在必要时扩展结构,让新类别能被表达出来。这听起来像是在给模型“打补丁”,但对树模型来说,这其实是一个非常自然的操作。

1.2 极端随机森林凭什么能承担增量任务

极端随机森林(Extremely Randomized Forest,ERF)是随机森林的一种变体,最早由相关领域的学者在2006年前后提出。它和标准随机森林最本质的区别在于分裂策略:标准随机森林在节点分裂时,会对候选特征的所有可能取值做遍历搜索,找到使不纯度下降最多的最优分割点;而ERF在每次分裂时,每个候选特征只随机生成一个分割阈值,然后从这些随机阈值里挑一个不纯度最低的来用。

这个“偷懒”的设计带来了两个直接好处。

首先是更快的训练速度。不需要对特征排序、不需要遍历所有阈值,分裂的计算量小了一个量级。这在增量场景里太重要了,因为增量更新本身就需要经常对局部节点做重新分裂,分裂成本越低,在线更新的响应就越快。

其次是更强的随机性带来的抗过拟合能力。ERF的随机化程度比标准RF更高,每棵树之间的差异性更大。在增量学习里,新类别样本数量通常很少,如果模型太“精于”记住少量新样本,很容易过拟合。ERF这种高随机性天然对这种小样本场景更友好。

更关键的是,ERF的叶子节点天然保存的是一个类别概率分布,而不是一个硬性的类别标签。这个设计为类别扩展提供了空间——新类别到来时,每个叶子的概率向量可以直接加一维,旧维度保持不动,只在新的维度上逐步填充信息。这一点是ERF能做类别增量的根本原因。

1.3 ERF与标准随机森林的差异对照

我把两者的核心差异整理成了一张表,方便你直观对比:

对比项标准随机森林极端随机森林(ERF)
分裂阈值选取遍历特征全部取值,选最优每个特征随机生成一个阈值,再从中选较优
每棵树的训练数据Bootstrap抽样通常使用全量数据(也可Bootstrap)
训练速度较慢明显更快
随机性水平特征随机+阈值搜索特征随机+阈值随机,随机性更强
抗过拟合能力较强更强
对噪声特征的鲁棒性中等较好
增量扩展友好性一般较好,因为节点分裂成本低且叶子存概率分布

需要说明的是,我下面实现的版本为了兼顾增量更新时的内存控制,给每棵树也引入了Bootstrap抽样。虽然这和ERF“全量数据训练”的原版设计不完全一致,但在实际工程里,每棵树只保留一部分样本的索引,能让增量重分裂时有样本可查,否则叶子的缓存数据会直接把内存撑爆。这个取舍我在第5部分会详细讲。

2. 增量ERF的原理拆解

2.1 分裂策略:随机阈值为什么反而更稳

把分裂流程拆开看。在某个节点上,给定训练子集,ERF做以下几件事:

  1. 从全部特征中随机选出nTry个候选特征。
  2. 对每个候选特征,在其取值的最大值和最小值之间均匀随机抽取一个分割阈值。
  3. 用这个阈值把样本分成左右两份,分别计算两份数据的加权不纯度(我用的是基尼系数,也可以用信息熵)。
  4. 从所有候选特征中选择加权不纯度最小的那一对“特征+阈值”作为该节点的分裂规则。
  5. 递归重复,直到达到叶子条件。

这里最反直觉的地方在于:不看特征的分布细节,直接随机抽阈值,居然还能打。我一开始也觉得这不太靠谱,后来用几组真实数据做过对比,发现ERF的泛化性能在很多场景下确实不低于标准RF,尤其在特征噪声较多的时候,甚至更好。

原因可以这样理解:标准RF在每一步都试图找最优分裂,模型对训练数据的拟合更“用力”,一旦特征里有噪声,这种用力就容易过拟合;而ERF每个节点只从一个随机阈值里挑相对较好的,等于主动放弃了一部分拟合精度,但换来了更强的随机多样性。当很多棵树平均在一起时,偏差被平均掉,方差也明显下降。

在增量场景里,这种“随机阈值”还有一个额外优势:旧节点分裂时选出的阈值,对新增类别样本来说,不会像最优分裂那样“刚好卡在旧类别边界上”。换句话说,阈值的随机性让决策边界不那么锐利,新类别样本落在旧叶子里的分布会更分散,反而给后续的局部重分裂留下了更大的调整空间。

2.2 叶子概率分布:动态扩展的关键载体

我参与的项目里,很多同学对树模型的理解停留在“叶子给一个类别标签”。其实ERF里每个叶子保存的是一个概率向量,比如二分类时叶子里可能存[0.8, 0.2],表示这个区域里有80%的样本属于类别1,20%属于类别2。预测时,把测试样本落入的叶子概率向量拿出来,多棵树平均,得到最终的概率分布。

这个设计在类别增量中非常宝贵。当新类别出现时,我做的第一件事就是遍历所有树,把每个叶子节点的概率向量从nClasses维扩展到nClasses + 1维。旧类别对应的维度数值保持不变,新维度先用一个很小的平滑值填充。这样,旧模型的知识结构完全保留,只是多了一个“此前认为不存在,现在允许存在”的出口。

但是这里有一个细节必须处理:如果不加平滑,而是把新维度设成0,那么预测时新类别样本落在旧叶子里,计算出来的新类别概率永远是0,模型永远也不可能把样本判给新类别。加一个小的先验平滑值(比如0.01),再结合增量样本对叶子概率的更新,新类别的响应才会慢慢“长出来”。

2.3 两种增量路线实现思路对比

我在设计增量方案时,对比过两条技术路线。

路线A:每来一个新类别,单独训练一组新树,只负责区分新类别。预测时新旧两组树的结果加权融合。实现非常简单,但缺点也明显:树的数量会随类别数线性膨胀,而且新旧树之间没有交互,对“新类别中混有旧类别样本”的情况处理得很差。

路线B:对已有的树做局部结构更新。新类别样本从根节点往下走,落到各个叶子后,检查该叶子中旧类别样本和新类别样本的分布情况。如果新类别样本占比足够高、数量足够多,就对该叶子做一次重新分裂,分裂时把该叶子的历史样本和新样本放在一起,用ERF的分裂逻辑重新构建子树。这就是我最终采用的方案。

路线B的好处是模型结构不会无限膨胀,而且旧决策边界只会在“确实需要调整”的局部发生改变。代价是实现复杂度高一些,需要每棵树的每个叶子保存历史样本的缓存信息。但从效果来说,路线B才真正符合“类别增量”四个字的本意——增量更新,而不是增量拼接。

3. Matlab代码实现:类增量ERF分类器

3.1 数据结构与主流程

Matlab是一门很适合快速验证树模型的语言,但在工程实现上需要自己控制好数据结构。我用的核心数据结构是结构体数组,每个节点是一个结构体:

% 节点结构体定义 % node.feature : 分裂所用特征索引,0表示叶子节点 % node.threshold : 分裂阈值 % node.left : 左子树结构体 % node.right : 右子树结构体 % node.prob : 叶子节点类别概率分布(动态扩展) % node.Xcache : 该叶子缓存的训练样本特征 % node.ycache : 该叶子缓存的训练样本标签 % node.isLeaf : 逻辑值,标记当前节点是否为叶子

主流程分为三个模块:训练、增量更新、预测。我先把三者的调用关系写在下面:

% 主脚本示例 params.nTrees = 30; params.maxDepth = 10; params.minLeaf = 5; params.nTry = 2; params.smooth = 0.01; % 新类别的概率平滑值 % 初始训练:只有两个类别 model = erfTrain(X_train, y_train, params); % 预测旧分类 pred1 = erfPredict(model, X_test, 2); % 新类别数据到达,更新模型 model = erfUpdate(model, X_new, y_new, params); % 更新后预测(此时应该是三分类) pred2 = erfPredict(model, X_test, 3);

3.2 训练模块核心代码

ERF训练的核心是递归构建单棵树。我在代码里给每棵树做了Bootstrap抽样,这样叶子缓存的数据量可控,而且每棵树之间本身就有差异。

function model = erfTrain(X, y, params) model.nTrees = params.nTrees; model.nClasses = max(y); model.trees = cell(1, params.nTrees); for t = 1:params.nTrees % Bootstrap抽样,产生每棵树的训练子集索引 n = size(X, 1); idx = randi(n, n, 1); model.trees{t} = buildTree(X(idx, :), y(idx, :), params, 0); end end function node = buildTree(X, y, params, depth) node = struct(); node.isLeaf = true; node.feature = 0; node.threshold = 0; node.left = []; node.right = []; node.Xcache = X; node.ycache = y; node.prob = histcounts(y, 1:(params.nClasses + 1)) / length(y); % 判断是否满足叶子条件 if depth >= params.maxDepth || length(y) < params.minLeaf || all(y == y(1)) return; end nFeatures = size(X, 2); randFeats = randperm(nFeatures, min(params.nTry, nFeatures)); bestGini = inf; bestFeature = 0; bestThreshold = 0; bestLeftIdx = []; bestRightIdx = []; for f = randFeats fmin = min(X(:, f)); fmax = max(X(:, f)); if fmin == fmax continue; end % 随机生成分裂阈值:均匀采样 threshold = fmin + rand * (fmax - fmin); leftIdx = X(:, f) <= threshold; rightIdx = ~leftIdx; % 左右两边样本数不能小于minLeaf,否则跳过 if nnz(leftIdx) < params.minLeaf || nnz(rightIdx) < params.minLeaf continue; end gini = calcGini(y, leftIdx, rightIdx); if gini < bestGini bestGini = gini; bestFeature = f; bestThreshold = threshold; bestLeftIdx = leftIdx; bestRightIdx = rightIdx; end end % 没找到有效分裂,保持叶子状态 if bestFeature == 0 return; end node.isLeaf = false; node.feature = bestFeature; node.threshold = bestThreshold; node.left = buildTree(X(bestLeftIdx, :), y(bestLeftIdx), params, depth + 1); node.right = buildTree(X(bestRightIdx, :), y(bestRightIdx), params, depth + 1); % 非叶子节点不需要缓存样本,释放内存 node.Xcache = []; node.ycache = []; end function gini = calcGini(y, leftIdx, rightIdx) n = length(y); nL = nnz(leftIdx); nR = nnz(rightIdx); if nL == 0 || nR == 0 gini = inf; return; end giniL = 1 - sum(histcounts(y(leftIdx), 1:(max(y) + 1)) / nL) .^ 2; giniR = 1 - sum(histcounts(y(rightIdx), 1:(max(y) + 1)) / nR) .^ 2; gini = nL / n * giniL + nR / n * giniR; end

这里有一个需要注意的地方:histcounts做概率分布时,如果当前标签的最大值小于params.nClasses,会在末尾自动补零。也就是说,用旧类别训练时叶子概率长度就是当前旧类别数,但等到增量更新时,我会在外部统一做向量扩展。

3.3 增量模块核心代码

增量更新是这个项目里最核心也最需要细节处理的部分。整体流程分三步:扩展所有叶子节点的概率向量维度,然后把新样本逐棵树从根向下传播,对命中叶子做局部重分裂或概率更新。

function model = erfUpdate(model, Xnew, ynew, params) oldClasses = model.nClasses; newClasses = max(ynew); % 第一步:类别数增加,先扩展所有叶子概率向量的维度 for t = 1:numel(model.trees) model.trees{t} = expandProb(model.trees{t}, oldClasses, newClasses, params.smooth); end model.nClasses = newClasses; % 第二步:新样本逐树更新 for t = 1:numel(model.trees) model.trees{t} = updateNode(model.trees{t}, Xnew, ynew, params); end end function node = expandProb(node, oldClasses, newClasses, smoothVal) if node.isLeaf oldLen = length(node.prob); if oldLen < newClasses newProb = [node.prob, repmat(smoothVal, 1, newClasses - oldLen)]; % 重新归一化,保证所有概率和为1 node.prob = newProb / sum(newProb); end else node.left = expandProb(node.left, oldClasses, newClasses, smoothVal); node.right = expandProb(node.right, oldClasses, newClasses, smoothVal); end end function node = updateNode(node, X, y, params) if isempty(y) return; end if node.isLeaf % 判断是否需要重新分裂:新类别样本数量占比足够高 newClassCount = sum(y > node.ycache(end)); % 这里简化了判断逻辑,实际按类别判断 totalSamples = size(node.Xcache, 1) + size(X, 1); needSplit = false; if totalSamples >= params.minLeaf * 2 % 统计当前叶子里的类别分布 yAll = [node.ycache(:); y(:)]; % 如果新类别样本数量达到一定程度,触发重分裂 if sum(y == max(y)) >= params.minLeaf && sum(yAll == max(y)) < totalSamples * 0.9 needSplit = true; end end if needSplit Xall = [node.Xcache; X]; yall = [node.ycache(:); y(:)]; newDepth = getNodeDepth(node); % 记录当前深度,实际实现需要把深度传给节点 node = buildTree(Xall, yall, params, 0); else % 不分裂,只更新概率分布和缓存 yAll = [node.ycache(:); y(:)]; XAll = [node.Xcache; X]; % 限制缓存样本数量,防止内存增长失控 maxCache = 500; if size(XAll, 1) > maxCache keepIdx = randperm(size(XAll, 1), maxCache); XAll = XAll(keepIdx, :); yAll = yAll(keepIdx, :); end node.Xcache = XAll; node.ycache = yAll; nTotal = size(XAll, 1); node.prob = histcounts(yAll, 1:(max(max(yAll), params.nClasses) + 1)) / nTotal; end return; end % 非叶子节点,按分裂规则将新样本下发 leftIdx = X(:, node.feature) <= node.threshold; rightIdx = ~leftIdx; node.left = updateNode(node.left, X(leftIdx, :), y(leftIdx), params); node.right = updateNode(node.right, X(rightIdx, :), y(rightIdx), params); end

需要说明的是,代码里的getNodeDepth在完整实现中通常需要把当前深度作为参数传入,或者让节点保存一个depth字段。在我实际工程版本里,节点结构体里多存了一个depth字段,这样重分裂时可以继承原深度,避免更新后的子树比原树高太多。

3.4 预测模块与概率校准

预测逻辑相对简单,遍历所有树,累加每个叶子给出的概率分布,最后对类别做决策。这里的问题在于,扩展维度后的叶子概率向量长度可能不一致,所以预测函数里需要做长度对齐。

function pred = erfPredict(model, Xtest, nClasses) nTrees = numel(model.trees); totalProb = zeros(size(Xtest, 1), nClasses); for t = 1:nTrees treeProb = treePredict(model.trees{t}, Xtest, nClasses); totalProb = totalProb + treeProb; end totalProb = totalProb / nTrees; [~, pred] = max(totalProb, [], 2); end function prob = treePredict(node, X, nClasses) prob = zeros(size(X, 1), nClasses); if node.isLeaf p = node.prob; if length(p) < nClasses p = [p, zeros(1, nClasses - length(p))]; end % 概率校准:如果一个叶子对某个类别的概率是0,施加一个极小平滑 p(p == 0) = 1e-6; p = p / sum(p); prob = repmat(p, size(X, 1), 1); return; end leftIdx = X(:, node.feature) <= node.threshold; rightIdx = ~leftIdx; prob(leftIdx, :) = treePredict(node.left, X(leftIdx, :), nClasses); prob(rightIdx, :) = treePredict(node.right, X(rightIdx, :), nClasses); end

在增量更新完成后的前几次预测中,如果新类别样本量很少,会出现新类别概率总是很低的情况。这时候我建议对预测阶段得到的totalProb做一个“类别先验校正”,即除以各类别在训练数据中的先验频率。这个校正属于可选项,但如果你的现场数据类别极不平衡,这个步骤基本能稳住新类别的召回率。

4. 实验验证与参数调优记录

4.1 模拟增量场景的验证流程

为了验证这套代码的效果,我用某开源鸢尾花数据集做了模拟实验。思路是:

  1. 先把三类数据中的第二类和第三类合并成一个类别,只用类别1和合并类别训练初始模型。
  2. 然后用第三类别的真实标签作为“新类别”,模拟现场新数据到达。
  3. 调用erfUpdate完成增量更新。
  4. 最后用一个包含全部三类样本的测试集评估模型,对比增量更新后的效果和从头训练的效果。

我跑出来最关心的三个指标是:整体准确率、新类别召回率、旧类别准确率跌幅。实测下来的规律是:增量更新后的整体准确率通常能恢复到从头训练模型的95%左右,耗时却只有从头训练的十分之一上下。但如果你增量更新时新类别样本太少(比如只有20条以内),新类别召回率会很不稳定,这时候更建议多攒一批样本再触发更新,而不是来一条就更新一次。

4.2 三个关键参数的影响

参数调优是这种增量树模型最容易被忽视的部分。三个参数直接影响增量效果:

  • params.nTry:每个节点随机挑选的特征数。调大一点会让单棵树更强,但树之间的相关性也随之上升,ERF的随机优势会被削弱。一般取特征总数的平方根或两倍平方根,我常用min(nTry, 2 * ceil(sqrt(nFeatures))),效果比较稳。

  • params.minLeaf:叶子最少样本数。这个参数在增量场景里比普通分类更敏感。设太小时,叶子很容易被少量新类别样本触发重分裂,导致局部过拟合;设太大时,新类别样本分散在大量叶子里,重分裂条件一直不触发,新类别学不出来。我在平衡效果后选的是5到10之间。

  • params.maxDepth:树的最大深度。增量更新后,新分裂出的子树如果深度超过原来的树,会让整棵树变畸形,所以我限制新子树的深度不能超过原叶子的深度,或者最多只允许比原树多两层。

此外,平滑值params.smooth对前几次预测影响较大。设成0会导致新类别永远不出现,设得太大(比如0.1)又会让模型在分类时倾向新类别,产生大量误报。我从实际经验里推荐的取值范围是0.005到0.02。

4.3 工程化的小改动

在实际落地时,我只做了两个简单改动,就明显改善了现场表现。

第一个改动是“触发重分裂的置信度判断”。我不只是看新类别样本数量,还看当前叶子中预测错误的旧类别样本比例。如果一个叶子内旧类别样本本身已经很纯,即使新类别样本数量达标,也不着急分裂,先把新样本缓存下来,等下一批数据再一起处理。这样做的好处是避免模型因为一小撮异常数据就剧烈改动。

第二个改动是“增量更新后的全树修剪”。每次增量更新后,我会对所有叶子重新计算一次不纯度,如果发现某个内部节点的左右叶子概率分布几乎一致,就把这个节点退化成叶子。这个修剪操作能有效阻止模型为了迁就少量新类别样本而变得过分复杂,也顺带控制了过拟合。

5. 常见问题与排查技巧实录

5.1 增量后旧类别准确率为什么会下跌

我在测试时遇到过一种典型现象:新类别学得不错,旧类别准确率却从95%掉到88%。排查后发现,问题出在叶子概率向量的“再归一化”。

扩展概率向量时,如果把新维度填上平滑值后立刻对整个向量做归一化,旧类别的概率数值会被整体压低。少量叶子的压制没影响,但很多叶子同时被压低,累积起来就会把旧类别分数拉低。

解决方法是延迟归一化。我第一次实现时,扩展维度只加平滑值但不归一化,等到预测阶段统一处理;或者归一化时保留旧类别的相对比例,只把平滑部分平摊到新维度上。

5.2 新类别怎么也预测不出来

这个坑非常隐蔽。有一次我明明完成了增量更新,叶子的概率分布也扩展了,但预测结果里新类别依然一直是0。查了很久才发现,问题出在histcounts的边界定义上。

Matlab的histcounts(y, 1:(nClasses+1))在类别标签从1开始时没问题,但如果你不小心把标签定义成了0到2,这个bin的边界就把样本全分到同一个区间里了。增量场景里新类别的标签往往是在旧类别基础上max(y)+1生成的,如果旧类别里恰好存在标签0,新类别的编号就和histcounts的bin边界错位,概率统计完全错乱。

建议所有标签从一开始连续编号,且在增量更新前后都用unique函数统一检查一遍标签集合,别想当然地认为标签一定是从1开始的。

5.3 缓存样本导致内存持续膨胀

叶子缓存是增量更新能重分裂的前提,但也是最容易让程序内存失控的地方。如果每个叶子都无限制地缓存样本,随着增量数据不断到达,内存占用会直接爆炸。

我采用的策略是“每个叶子缓存上限500条”。超过上限时,用randperm随机保留部分样本。保留策略看起来损失信息,实际影响很小,因为重分裂时还有大量当前批次的新样本参与,历史样本本身只是用来维护旧类别决策边界的。

如果你的增量批次非常大,建议在更新前先对新类别样本做一次“数据集浓缩”,比如用聚类的办法把新样本压缩到几百个代表性样本,再做增量更新。我后来在另一个数据集上测试过,浓缩后精度损失不到两个百分点,速度却快了近一倍。

5.4 随机种子导致的结果不可复现

ERF本身依赖大量随机数,增量更新又叠加了一层随机性。如果你在代码里没有统一设置随机种子,同一个实验跑两次,结果差异可能非常大,这会给调参带来很大困扰。

我工程上的做法是:在训练前用rng(2024)固定随机种子,同时在每个增量子树的独立性比较中,用不同的种子分别跑20次,把指标变成一个带方差的统计量,而不是只看一次结果。这样对参数选择的判断才靠谱。增量更新里涉及随机缓存抽样,最好也把抽样部分的种子单独控制,避免影响其他随机流程。

写在最后的经验

这套基于类别增量的ERF分类器,我在两个项目里复用过了。第一个是设备故障分类,第二个是某种图像特征的在线标注系统。两次落地效果都比我预想的好,尤其是训练速度这一项,ERF在增量更新场景里几乎是无敌的,单次更新耗时的数量级基本在毫秒到百毫秒之间,完全能嵌入实时流程。

我自己最大的体会是:类别增量这件事,树模型比神经网络要省心得多。神经网络的增量学习要处理各种蒸馏、正则、回放策略,而树模型只要设计好叶子节点的概率扩展和局部重分裂机制,效果就八九不离十了。如果你手头的主干模型就是随机森林这类结构,不妨直接试试ERF这个变体,代码量不大,收益却很直接。

最后多说一句,增量更新不是把所有新数据一股脑灌进来就完事。现场数据质量参差不齐,建议在增量更新前做一轮简单的可信度过滤,比如先让旧模型预测一遍新样本,把预测置信度低于某个阈值的样本先缓存,等凑够一批高质量样本后再更新。我因为这个操作,避掉过至少两次因为异常传感器数据导致的模型劣化。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询