
做分类预测的同行应该都有这种体验模型上线的时候跑得好好的跑了大半年现场突然说“又发现一种新状态以前没见过”。我最早遇到这种情况时第一反应是拿新旧数据全量重训跑一次要小半天结果第二天又来一种新状态心态直接崩了。后来我把分类器换成了极端随机森林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; % 是否叶子节点 endcounts字段是整套增量机制的钥匙。训练阶段样本落到某个叶子时就在对应类别下标上加一预测阶段把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(end1: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(end1:newMaxClass) 0; end return; end node.left expandNode(node.left, newMaxClass); node.right expandNode(node.right, newMaxClass); end4.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 每到一个新类我分别做了什么每次新类到达我都跑一遍如下流程把森林所有叶子的counts扩展到当前最大类别编号用新类样本执行updateNodeIncremental让样本沉入每棵树记录每个叶子中该类样本数如果某叶子累计超过30条触发maybeSplitLeaf二次分裂同时额外新建两棵树只用当前已到达的所有类别的代表样本训练新类样本全部参与旧类各抽30条代表性样本在旧类抽样集上验证旧类准确率没有明显滑坡就继续接收下一批。这里额外建树的目的在第6章会详细解释。整体更新耗时从几十毫秒到几百毫秒不等和全量重训动辄几秒的耗时相比优势非常明显。5.3 逐步增量训练的准确率变化下表记录了各阶段测试集上的总准确率。测试集包含所有已到达类别的样本各类别数量均衡。阶段模型状态增量更新准确率全量重训基准增量更新耗时1类别1298.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准确率训练完类别1298.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加类别增量最大的价值不是单点精度最高而是它能让你在生产模型上做“微创手术”不用每次返工。如果你也在做状态监测、故障诊断或在线数据分类建议先拿这个框架跑通再根据你的数据分布调分裂阈值和新增树策略。最后提醒一句不管增量更新看起来多顺旧类数据的抽检集一定要留一份每轮更新后跑一遍。这是我能给你的最实在的一条建议。