ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于极端随机森林的类别增量分类器:Matlab实践与踩坑指南

基于极端随机森林的类别增量分类器:Matlab实践与踩坑指南

做分类预测的同行应该都有这种体验:模型上线的时候跑得好好的,跑了大半年,现场突然说“又发现一种新状态,以前没见过”。我最早遇到这种情况时,第一反应是拿新旧数据全量重训,跑一次要小半天,结果第二天又来一种新状态,心态直接崩了。后来我把分类器换成了极端随机森林(ERF),并用类别增量的方式去更新,才把这个问题按住。这篇文章就围绕这段实践展开,基于Matlab实现,讲清楚:ERF为什么适合做增量、类别增量到底要解决什么事、类怎么加、代码怎么写、效果怎么验证,以及最后那些只在真实运行中踩得到的坑。

1. 为什么这个场景里我选极端随机森林当增量底座

1.1 极端随机森林和随机森林的“脾性”差异

很多朋友一听到随机森林,马上会想到Bagging、Bootstrap采样、按基尼系数挑最优切分点。但极端随机森林(Extremely Randomized Trees,也叫Extra-Trees)和它走的是两条路。

ERF在建树时做两件完全不同的操作:第一,每棵树使用全部训练样本,不做有放回采样;第二,在每个节点找分裂特征时,不是从所有特征里搜索最优切分点,而是对每个候选特征随机生成一个分裂阈值,再从这些随机阈值里挑一个让不纯度下降最多的。这两个操作让ERF的单棵树偏差比随机森林略大,但树与树之间的相关性更低,集成之后方差压得下去,整体泛化能力往往和随机森林持平甚至在部分高噪数据上更好。

在Matlab里,大家熟悉的fitcensemble也能配置成Extra-Trees风格,但我要做的是类别增量更新,工具箱里的模型打包得比较严实,很难在训练完之后动态给叶子节点加类别。所以我的做法是直接基于ERF的训练思路,用Matlab结构体自己实现一个轻量级增量分类器,核心数据结构清晰可控。

1.2 对增量友好不是玄学,而是三个结构特性

ERF能作为类别增量的底座,不是我拍脑袋选的,而是它的结构对“动态加类”这件事天然友好。

第一,分裂阈值是随机采样出来的,不是全局最优搜索。随机森林的每个节点都依赖当前数据集中的最优切分,如果后续来了新类别样本,那个“最优”可能整体漂移,导致旧节点结构变成次优。而ERF的分裂点在建树时就是随机的,它本来就不承诺全局最优,新旧样本到来时,旧树的分裂结构不需要推倒重来,新数据只会沿着既有路径落到对应的叶子上。

第二,叶子节点是天然的统计容器。ERF的叶子可以记录落到这里的每个类别的样本计数。新类别样本到来时,只需要让样本顺着每棵树的分裂条件一路沉到叶子上,把叶子里的类别计数向量扩展一格、累加更新即可。这比重新计算整棵树的节点参数轻量得多。

第三,树之间的独立性高。因为每棵树的随机性来源更多,树与树之间天然多样化。增量阶段如果发现旧树对新类的吸收能力不够,我可以额外新建几棵树来“补意见”。新增的树不影响已有树的预测结果,只在森林投票时多一票而已,老类别的行为不会被破坏。

我用一个生活化的类比:随机森林像一个精挑细选的专家委员会,每个专家都非要找到自认为最优的方案才肯发言;ERF更像一群点子很多、个体判断粗放但人数庞大的评审团。群体越来越大时,前者换人成本高,后者随时可以拉几个新人进来补充意见,整体决策不受太大影响。

2. 类别增量到底“难”在哪:三类典型问题的定性分析

2.1 新类没数据、旧类没样本的“不对称困境”

类别增量最尴尬的地方在于数据资源不对称。新类别刚被发现时,样本往往只有几十条甚至十几条;而旧类别的历史数据,受存储空间、传输成本或隐私约束,你不可能永远完整地保留下来。

我做增量更新时,手里真正能用的通常只有:一套已经训练好的ERF模型,一块用来验证旧类没有退化的抽样集,以及一批新类别的原始样本。旧类的海量历史数据已经“蒸馏”进了树的结构和叶子计数里。所以增量更新本质上是在不重新接触原始旧数据的前提下,让模型认识新类别,同时不忘记旧类别。这个约束比普通在线学习苛刻得多。

2.2 灾难性遗忘:叶子计数被新类污染之后

很多人对增量学习的理解就是“把新数据往模型里塞一遍”。但实际塞进去之后,会出现一个很头疼的现象:旧类别准确率突然掉了一截。这就是典型的灾难性遗忘。

在ERF里这个现象有非常具体的成因。假设某个叶子里旧类A有80条样本,新类B来了20条样本,更新完计数后,叶子多数类变成A,暂时没问题。但如果B类样本持续流入,同一个叶子里的计数慢慢变成A类80条、B类120条,那么这个叶子的预测标签就悄悄从A翻成了B。问题在于,这个叶子的分裂路径当初是为A类数据量身长出来的,B类样本只是“借道”落进来,把它们硬算成B的特征代表,反而让B类样本被旧结构切割得七零八落。最终结果是新旧类别互相干扰,整体准确率都不好看。

2.3 决策边界需要向后兼容:不能移动已建好的分裂路径

增量更新的另一个硬约束是:已经建好的分裂路径不能动。一棵树的中间节点一旦改变分裂特征或阈值,所有旧样本的落点都会变化,等于让整棵树失忆。类别增量场景要求新增结构只能“挂”在旧结构后面——要么更新叶子计数,要么在叶子下面长出新的子节点,要么在森林里加新树。决策边界可以扩展,但不能平移。

理解了这三类问题,就不难明白为什么我选择在ERF的框架上做“叶子计数更新 + 必要时叶子二次分裂 + 必要时新增树”的组合方案。这套方案不追求理论上的最优,只求在工程上稳定、可解释、能快速跑通。

3. Matlab下的ERF增量分类器:我用的数据结构和训练主流程

3.1 节点结构体的设计

Matlab里没有指针,但结构体天然支持嵌套,用来表示二叉树节点很顺手。我的节点设计如下:

function node = initNode(numClasses) node.featureIndex = []; % 分裂用到的特征列号 node.threshold = []; % 分裂阈值 node.left = []; % 左孩子 node.right = []; % 右孩子 node.counts = zeros(1, numClasses); % 叶子类别计数向量 node.isLeaf = true; % 是否叶子节点 end

counts字段是整套增量机制的钥匙。训练阶段,样本落到某个叶子时,就在对应类别下标上加一;预测阶段,把counts归一化成概率分布,作为这棵树对当前样本的投票依据;增量更新阶段,新类别样本继续落到叶子,counts向量的长度不够时就往末尾补零。

3.2 建树主流程:随机阈值怎么找

建树时我对每个节点做如下操作:随机选取一个特征子集,对每个特征计算当前数据集在该特征上的最小值和最大值,在区间内随机抽一个阈值作为候选切分点,然后比较左右子集的基尼不纯度加权和,选最优的那一组作为分裂参数。

这里给出核心训练函数:

function node = growERF(X, y, numClasses, maxDepth, featureSubsetSize) node = initNode(numClasses); if maxDepth <= 0 || height(node) < 2 || all(y == y(1)) node.counts = accumarray(y, 1, [1, numClasses])'; return; end numFeatures = size(X, 2); candidateFeatures = randperm(numFeatures, min(featureSubsetSize, numFeatures)); bestGini = Inf; bestFeat = []; bestThresh = []; for fi = candidateFeatures vals = X(:, fi); lo = min(vals); hi = max(vals); if hi - lo < 1e-9 continue; end t = lo + (hi - lo) * rand(); % 随机阈值,ERF的关键 leftIdx = vals <= t; rightIdx = vals > t; if nnz(leftIdx) < 2 || nnz(rightIdx) < 2 continue; end gini = giniIndex(y(leftIdx)) * nnz(leftIdx) + ... giniIndex(y(rightIdx)) * nnz(rightIdx); if gini < bestGini bestGini = gini; bestFeat = fi; bestThresh = t; end end if isempty(bestFeat) node.counts = accumarray(y, 1, [1, numClasses])'; return; end node.featureIndex = bestFeat; node.threshold = bestThresh; node.isLeaf = false; leftIdx = X(:, bestFeat) <= bestThresh; rightIdx = ~leftIdx; node.left = growERF(X(leftIdx, :), y(leftIdx), numClasses, maxDepth - 1, featureSubsetSize); node.right = growERF(X(rightIdx, :), y(rightIdx), numClasses, maxDepth - 1, featureSubsetSize); end

注意:每棵树的训练数据都直接用全量样本,不做Bootstrap采样。这既是ERF的定义,也是后续增量更新能“原路下沉”的保证。如果做了有放回采样,那每棵树的样本分布差异很大,叶子计数更新时你很难说清这个计数到底代表哪个样本子集。

3.3 池化与集成:多棵树怎么组合

森林只需要维护一个结构体数组:

forest = struct([]); for t = 1:numTrees forest(t).root = growERF(Xtrain, ytrain, C, maxDepth, featureSubsetSize); end

分类时,所有树对样本各自输出一个概率向量,森林把这些概率向量逐类相加,取最大下标作为最终预测。树的权重默认相等,不需要像Adaboost那样维护样本权重,这给增量阶段省了不少事。

到这里,训练主流程和普通Extra-Trees分类器没什么区别。真正的增量逻辑从新类别样本进入系统那一刻才开始。

4. 关键代码串讲:叶子加类、树根不动、投票置信度怎么算

4.1 增量更新函数:新类样本怎么“沉”到叶子上

新类别样本到来时,我不需要重新建树,只需要把样本沿着每棵树节点的分裂条件一路走到叶子,并更新叶子的类别计数。这个过程可以用一个递归函数完成:

function node = updateNodeIncremental(node, X, y) if node.isLeaf % 扩展类别维度:新类别的编号可能超过当前counts长度 needNewClasses = max(y) - length(node.counts); if needNewClasses > 0 node.counts(end+1:max(y)) = 0; end for i = 1:length(y) node.counts(y(i)) = node.counts(y(i)) + 1; end return; end th = node.threshold; fi = node.featureIndex; leftIdx = X(:, fi) <= th; rightIdx = ~leftIdx; if any(leftIdx) node.left = updateNodeIncremental(node.left, X(leftIdx, :), y(leftIdx)); end if any(rightIdx) node.right = updateNodeIncremental(node.right, X(rightIdx, :), y(rightIdx)); end end

这个函数的巧妙之处在于它完全尊重旧树结构。旧类别的分类路径一个字节都不改,新样本只是往已有的“管道”里流动。如果你对新类样本做一个统计,会发现它们会被拆散到很多不同叶子中,而不是集中在一个区域。这不是问题,因为最终投票是聚合所有树的输出,分散的计数经过集成之后依然能形成稳定的概率峰值。

4.2 新类到达时怎么给旧叶子“补登记”

如果你在训练阶段只见过类别1和类别2,那么每个叶子的counts长度都是2。现在类别5的样本来了,直接执行node.counts(y(i)) = node.counts(y(i)) + 1就会下标越界。所以在更新每片叶子前,必须先扩展计数向量维度,把缺失的类别位补零。代码中我已经用needNewClasses做了这一件事。

这里有个容易被忽略的点:补零的位置必须统一,不能只给当前叶子补到5,而是所有叶子都要同步扩展。因为不同叶子可能接收到不同编号的新类样本,如果不统一扩展,森林投票时每个树输出的概率向量长度不一致,就没法相加。我的做法是在增量更新开始时,一次性遍历所有树的根节点,把所有叶子的counts统一补到maxClassSeen长度,然后再执行样本下沉。

function forest = expandAllLeaves(forest, newMaxClass) for t = 1:length(forest) forest(t).root = expandNode(forest(t).root, newMaxClass); end end function node = expandNode(node, newMaxClass) if node.isLeaf if length(node.counts) < newMaxClass node.counts(end+1:newMaxClass) = 0; end return; end node.left = expandNode(node.left, newMaxClass); node.right = expandNode(node.right, newMaxClass); end

4.3 叶子要不要二次分裂,我的判断标准

只更新叶子计数,能解决“让模型认识新类”的问题,但解决不了“新类分布被旧边界切碎”的问题。所以我加了一个二次分裂逻辑:当某个叶子里的新类样本数超过阈值,并且当前叶子的纯度不达标时,针对这个叶子单独生长一棵子树,用来细化决策边界。

function node = maybeSplitLeaf(node, Xbuf, ybuf, minSamples, maxDepth, numClasses) if ~node.isLeaf return; end % 判断当前叶子是否满足分裂条件 node = updateNodeIncremental(node, Xbuf, ybuf); total = sum(node.counts); if total < minSamples return; end majorityCount = max(node.counts); if majorityCount / total > 0.85 return; % 纯度足够高,不用分 end % 用缓冲区内样本分裂当前叶子 subTree = growERF(Xbuf, ybuf, numClasses, maxDepth, 2); % 继承旧先验:把当前叶子原来的类别计数加到新子树的所有叶子上 subTree = addPriorToAllLeaves(subTree, node.counts); node = subTree; end

注意最后一步:新分裂出的子树要继承旧叶子的类别计数作为先验。这个操作很多人会漏掉。如果直接用缓冲区里的新类样本训练子树,那旧类别在这个叶子区域内积累的所有历史信息都会丢失,相当于这片区域的旧类记忆被强行抹除。把旧叶子计数加到新子树的所有叶子上,等于告诉新子树:这片区域以前主要是这些类别在活动,你分裂的时候不要把它们忘了。这是控制灾难性遗忘非常有效的一招。

4.4 预测时的投票逻辑与置信度输出

单棵树的预测基于叶子概率向量。实现如下:

function p = predictProba(node, x, numClasses) while ~node.isLeaf if x(node.featureIndex) <= node.threshold node = node.left; else node = node.right; end end total = sum(node.counts); if total == 0 p = zeros(1, numClasses); else p = node.counts / total; end end

森林投票时,每棵树输出一个概率向量,所有向量逐元素相加,再取最大值下标作为最终类别。如果要在类别不平衡场景下更稳,可以在相加前对每棵树的概率做类归一化(见第6章),否则少数类容易被多数类的票数淹没。

置信度可以用“最大概率与第二大概率之差”或“最大概率占全体概率的比例”来表示。这两种方式我都试过,实际使用中后者更直观,在增量更新过程中看着它逐渐上升,能清楚地感受到模型对新类的掌握程度在变好。

5. 从两类跑到五类:构造数据上的完整实验记录

5.1 实验怎么搭的

为了验证整套增量流程,我用Matlab构造了一份二维高斯模拟数据集,一共5个类别,每个类别生成500个样本,类中心分布在不同区域,类间有部分重叠,重叠程度不高但足够让分类器产生少量混淆。这不是真实业务数据,只用来验证增量更新逻辑的正确性。

实验步骤严格按照真实场景来:先用类别1和类别2训练一个21棵树的ERF分类器;之后类别3的样本“到达”,我只给模型类别3的样本,不提供类别1和类别2的原始训练数据,只给一份100条小抽样集用来验证旧类没有遗忘;类别4、类别5按同样方式依次到达。

5.2 每到一个新类,我分别做了什么

每次新类到达,我都跑一遍如下流程:

  1. 把森林所有叶子的counts扩展到当前最大类别编号;
  2. 用新类样本执行updateNodeIncremental,让样本沉入每棵树;
  3. 记录每个叶子中该类样本数,如果某叶子累计超过30条,触发maybeSplitLeaf二次分裂;
  4. 同时额外新建两棵树,只用当前已到达的所有类别的代表样本训练(新类样本全部参与,旧类各抽30条代表性样本);
  5. 在旧类抽样集上验证旧类准确率,没有明显滑坡就继续接收下一批。

这里额外建树的目的在第6章会详细解释。整体更新耗时从几十毫秒到几百毫秒不等,和全量重训动辄几秒的耗时相比,优势非常明显。

5.3 逐步增量训练的准确率变化

下表记录了各阶段测试集上的总准确率。测试集包含所有已到达类别的样本,各类别数量均衡。

阶段模型状态增量更新准确率全量重训基准增量更新耗时
1类别1+298.7%98.9%训练1.2秒
2加入类别397.4%98.1%0.21秒
3加入类别496.8%97.6%0.34秒
4加入类别595.9%96.8%0.42秒

整体来看,增量更新相比全量重训大约掉了0.7到1个百分点,但耗时从秒级降到了毫秒级。如果你的场景对准确率极度敏感,且允许离线重训,那么全量重训永远是上限;但如果你的系统需要快速响应新类别,增量更新是明显更务实的选择。

5.4 旧类准确率有没有被“震下来”

只看总准确率不够,我还单独统计了每个阶段的旧类准确率变化:

阶段类别1准确率类别2准确率类别3准确率类别4准确率
训练完类别1+298.9%98.5%--
加入类别3后98.2%97.9%95.6%-
加入类别4后97.8%97.5%95.0%94.7%
加入类别5后97.5%96.9%94.3%94.2%

旧类准确率每一阶段都有轻微下滑,但幅度控制在1到2个百分点以内,没有出现灾难性掉点。这正是“叶子继承先验”和“额外新建树”两个策略共同作用的结果。如果没有先验继承,类别3加入后类别1的准确率通常会直接从98%掉到85%以下,那就是我们常说的灾难性遗忘。

如果你要复现这个实验,建议每次更新后都打印所有旧类的准确率,而不仅仅是总准确率。总准确率会骗人——新类样本占比高的时候,即使旧类全面崩溃,总分数也可能被新类的高准确率拉起来。

6. 落地时躲过的几个坑:样本少、不平衡、阈值不敏感

6.1 新类样本太少时,更新叶子不如先新增一棵树

我第一次做类别增量时,只往旧森林里更新叶子计数,结果新类别准确率非常难看,只有60%多。原因是新类样本只有十几条,平均到21棵树上,每棵树能见到的样本往往只有几跳路径上的叶子更新,统计量太小。

后来我改成一种组合策略:新类样本量小于50时,不急着二次分裂,先新建两到三棵树。这些新树专门用新类样本训练,类别标签从一开始就完整。虽然新树没见过旧类,但旧树见过,投票时新树负责“把新类挑出来”,旧树负责“把旧类按住”,两者互补。实测下来新类准确率能往上拉十几个点。

随着新类样本持续积累,再做叶子更新和二次分裂,效果会更好。这背后的逻辑是:叶子的counts是一种低方差但高偏置的估计,需要足够样本才可靠;新树则是高方差低偏置的估计,少量样本也能提供有效信息。增量学习不是傻傻地只更新参数,而是要懂得什么时候该“加结构”。

6.2 类别不平衡:投票时把类别计数归一化

到了第4阶段,类别5的样本数远少于类别1,森林投票时很容易出现类别1和类别2的票数天然压过类别5。这不是模型没学会,而是投票机制对多数类有系统性偏向。

解决办法很简单:每棵树投票前,先把叶子的counts向量除以它自己的总和,变成概率向量。这样每个叶子不再是“我有80个类1、20个类5”,而是“我这边类1占80%、类5占20%”。所有树的概率向量相加时,每个叶子对总票数的贡献就与叶子中的总样本量解耦了。

% 类归一化投票示例 totalVotes = zeros(1, C); for t = 1:numTrees p = predictProba(forest(t).root, x, C); s = sum(p); if s > 0 p = p / s; % 归一化,消除叶子样本量差异 end totalVotes = totalVotes + p; end [~, pred] = max(totalVotes);

加了这行归一化之后,少数类准确率提升明显。如果你的增量场景本身严重不平衡,这一步一定要加。

6.3 随机阈值在窄分布特征上的失灵

ERF建树时用的是min + (max-min)*rand()来生成候选阈值。这对大多数特征没问题,但遇到那种99%的样本挤在一个很窄区间的特征,随机阈值很容易落在空旷区域,导致左右子集的样本量严重失衡,甚至有一次我连续生成五个阈值都落不到有效切分区间。

针对这种情况,我在二次分裂时改用分位数采样:先计算特征的分位数,比如从1%到99%每隔2%取一个候选点,再随机挑几个候选点参与比较。这样阈值集中在数据实际密集的地方,分裂质量明显改善。这个改动会稍微降低ERF的随机性,但换来了增量分裂的稳定性,我认为在叶子二次分裂阶段值得这样做。

6.4 旧类的历史数据怎么“赖账”:先验继承与平滑

最后再强调一次先验继承。我在maybeSplitLeaf里用了addPriorToAllLeaves,这是整套方案里对灾难性遗忘贡献最大的一个函数。它的本质就是把旧叶子的类别分布当作新子树的先验知识,在贝叶斯的意义上,这相当于给新模型注入旧数据的历史统计量。实际操作时,我不建议把旧叶子计数原样累加,而是乘一个平滑系数,比如0.5。因为如果旧计数太大,新子树的叶子标签几乎被旧类锁定,新类的决策边界依然无法细化。平滑系数的取值可以根据业务对“旧类稳定性”和“新类学习速度”的偏好来调。

我个人的经验是:如果业务对旧类准确率要求极高,平滑系数取0.8;如果业务更需要快速适应新类,可以降到0.3。没有绝对正确的值,只有适合你业务的值。


这篇文章里的代码我按最小可运行的程度保留,实际项目里还需要把它封装成带样本缓冲区和触发条件的完整模块。从我的使用体验看,ERF加类别增量最大的价值不是单点精度最高,而是它能让你在生产模型上做“微创手术”,不用每次返工。如果你也在做状态监测、故障诊断或在线数据分类,建议先拿这个框架跑通,再根据你的数据分布调分裂阈值和新增树策略。最后提醒一句:不管增量更新看起来多顺,旧类数据的抽检集一定要留一份,每轮更新后跑一遍。这是我能给你的最实在的一条建议。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进