恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
层级分类实战:Branch CNN分支网络设计与工程落地要点
首页
资讯中心
/
层级分类实战:Branch CNN分支网络设计与工程落地要点
层级分类实战:Branch CNN分支网络设计与工程落地要点
发布时间:2026/10/14 5:53:14
这篇是层级分类系列的第二篇。上一篇里我用普通的多标签多分类方案硬解层级类目需求能跑通但有几个地方一直很别扭子类之间互相抢概率父类和子类的关系只能靠后处理规则硬补细分类别一多输出层就堆到几百个节点训练和部署都不舒服。后来在某个实际项目里换成 Branch CNN分支卷积网络重做了一遍才把这些问题理顺。这篇就把整个设计思路、实现要点、训练参数以及踩过的坑完整写一遍。如果你手头正好有“大类-小类”这种层级识别需求比如商品类目、车型识别、动植物细分类目这篇文章应该能帮你节省不少试错时间。1. 先想清楚层级分类为什么要换 Branch CNN1.1 之前方案的三个痛点先说清楚我在上一篇里用的“笨办法”是什么。最直接的做法是把所有层级的类别拍平比如一级类目有 12 个二级类目有 80 个那就直接训一个 92 类的多分类模型。这种平铺方案最省事但你实际上丢掉了一个关键信息二级类目一定是挂在某一级类目下面的。平铺之后模型要自己在所有类别之间学出隐含的层级关系等于把一棵结构清晰的树压成一条线再让网络从数据里把这个结构重新长出来这是白白增加学习难度。第二个方案是级联模型也就是先训一个一级分类器再对每个一级类目分别训一个二级分类器。听起来很合理但实际用过就知道有多痛苦一级分类器一旦分错二级分类器根本没有挽回机会而且要为每个一级类目单独维护模型12 个一级类目就是 13 个模型部署、更新、阈值调优全都要重复做一遍日常维护成本非常高。第三个方案是上一篇文章写的多标签二分类方案把每个二级类目当成一个独立的二分类目标用 sigmoid 输出。这个方案的问题是类别之间的相关性完全没利用上。模型会同时给“平板电脑”和“笔记本电脑”打高置信度因为它们的特征本来就接近但你的业务场景可能要求必须二选一。后来我发现与其在损失函数和后处理里反复打补丁不如直接在网络结构上把层级关系表达出来这就是 Branch CNN 的出发点。1.2 Branch CNN 的设计直觉Branch CNN 这个名字里容易有歧义很多人会先想到细粒度图像识别里那个 Bilinear CNN但这里说的是 Branch也就是分支结构。核心思想很简单一个共享的主干网络负责提取通用视觉特征然后在主干后面分出多个分类头每个分类头负责一个层级或者一类粗粒度分组。主干是公共底座分支各管一段互不干扰但共享底层信息。你可以把它想象成一个研究团队公共实验室、服务器和数据库是共享主干所有人都要用但每个课题组有自己的工位、有自己的资料架研究课题不同分工不同但不需要重新建一栋楼。放到图像分类里主干网络从像素里提取纹理、边缘、形状这些通用特征一级分类头基于这些特征判断“这是服装还是数码”二级分类头在“服装”这个约束下进一步判断“这是 T 恤还是卫衣”。二级分类头不需要重新学一遍什么是纹理和边缘所以参数效率高得多。这种结构比平铺方案更贴合问题的内在结构也比级联模型省参数。级联方案是“多个独立模型顺序调用”Branch CNN 是“一个模型内部并联多个分类头”主干部分只计算一次。我在实际项目里观察到一个非常明显的变化同样的骨干网络Branch CNN 的参数量多了大概 20% 到 30%主要来自分支头部的全连接层但效果比单独训 13 个模型稳定得多而且推理时主干只跑一次延迟几乎没增加。1.3 两种分支拓扑层级分支和分组分支分支怎么分其实有两种常见拓扑。第一种是严格按层级分主干后面接一个一级分类头再接一个二级分类头。这种结构把父类和子类作为两个明确的任务训练时父类分支和子类分支同时优化。第二种是按粗类分组主干后面接若干个“专家分支”每个分支负责一个一级类目下面的所有二级类目训练时每个样本只激活它所属的那个分支。我在项目里最终用的是两者的结合主干先接一个一级分类头用于输出粗粒度结果在二级分类这一层不做一个覆盖全部 80 类的巨型头而是给每个一级类目单独设一个分支头。比如一级类目“手机数码”对应一个分支这个分支只负责区分手机、平板、笔记本“服饰鞋包”对应另一个分支只负责区分 T 恤、卫衣、外套。好处是每个分支的决策面小类别之间的混淆难度降低而且分支之间天然形成了“职责分离”。为什么不用单一二级头因为 80 个二级类放在一个全连接层里等于重新退化成平铺分类只是多了个父类约束。而分组分支让每个头只需面对 5 到 15 个类别分类难度显著下降训练时每个分支内部的计算量也少。代价是实现时逻辑稍微复杂一点batch 里的样本要先按一级类目分组再喂给不同分支。这个复杂度在后端代码里可以管理换来的是训练速度和精度的双重收益。2. 标签体系设计层级分类的第一步2.1 层级标签怎么建模很多人一开始就把数据结构搞错拿一个平铺的 CSV 列直接喂给网络然后在模型代码里猜层级关系。这是错误的第一步。层级分类的第一步是把类目关系显式建立成树并且把每个样本标成“从根到叶的路径”。我在项目中用的格式是 JSON每个样本至少包含三个字段image_path、category_l1、category_l2。category_l1 是一级类目的索引category_l2 是二级类目的索引。如果你的业务有更深层次比如“服饰/男装/上衣/Polo 衫”那就把字段扩到 category_l1 到 category_l4原理是完全一样的。类目树本身要单独维护一份映射文件。比如{ categories: [ {id: 0, name: 数码产品, children: [0, 1, 2]}, {id: 1, name: 服饰鞋包, children: [3, 4, 5]} ], child_names: { 0: [智能手机, 平板电脑, 笔记本电脑], 1: [T恤, 卫衣, 外套] } }这里要特别注意二级类目的 id 在全数据集里必须唯一不能每个一级类目下都从 0 开始编号否则数据加载和损失计算时会出现严重的索引错位。我踩过这个坑某个二级类目在父类 0 下面叫 class 2在父类 1 下面也有一个 class 2结果训练时模型收到了两个含义完全不同的标签loss 震荡得非常厉害。训练时还有一个容易忽略的细节如果一级类目本身就是业务上需要输出的类别那么非叶节点也要有训练样本。什么意思就是有的图片确实只能判断到“数码产品”再往下判断“是手机还是平板”连人都看不准这时就需要一个“未知”或者“未细分”的二级标签而不是强行给样本标一个错误的具体类。否则模型会在根本没有标准答案的样本上反复摇摆子分支的精度会被拉低。2.2 损失函数怎么组合Branch CNN 的损失函数不是简单地把几个交叉熵加起来而是要把“父子层级关系”这个约束也编码进损失里。我用的组合是三项父级交叉熵、子级交叉熵、一致性约束项。父级交叉熵和子级交叉熵都是标准操作但这三者如果只是简单相加子分支依然可能学到“父类分错了但子类分对了”这种矛盾规律。比如父分支认为这是一件衣服子分支却判断出它是笔记本这种局面在单独训练两个头时经常发生。要解决它需要把父分支的信息注入到子分支的损失计算中。具体做法是根据父分支的预测置信度构造一个掩码在计算子分支交叉熵之前把不可能出现的子类 logits 压低。简化实现如下def hierarchical_loss(parent_logits, child_logits, parent_true, child_true, mask_matrix, ce_weight1.0, consistency_weight0.5): parent_loss F.cross_entropy(parent_logits, parent_true) # child_logits 的形状是 [B, num_child_candidates] child_loss F.cross_entropy(child_logits, child_true) # 一致性约束父分支预测概率和子分支概率对齐 parent_prob F.softmax(parent_logits, dim1) # 对每个样本取出父分支在真实父类上的概率作为子分支的置信度权重 confidence parent_prob.gather(1, parent_true.unsqueeze(1)).squeeze(1) consistency_loss F.cross_entropy(child_logits, child_true, reductionnone) * (1 - confidence.detach()) total_loss ce_weight * (parent_loss child_loss) consistency_weight * consistency_loss.mean() return total_loss这里的逻辑是如果父分支对自己的判断很确定那子分支应该专心优化如果父分支模棱两可我们就降低子分支对这个样本的惩罚力度不要勉强子分支在一个父类都分不清的样本上学出确定的子类。一致性损失的值不是越高越好它只是起到软约束的作用。我一般把 consistency_weight 设在 0.3 到 0.6 之间先跑一轮看整体 loss 的量级再根据父分支和子分支各自准确率的差距来调。如果父分支已经到 95% 但子分支只有 70%说明子分支是主要矛盾这时应该提高子分支交叉熵的权重或者增大子分支网络的容量。2.3 结果怎么约束训练时的一致性约束是软的推理时我建议做硬的约束因为它能直接杜绝最荒谬的错误。具体来说先由父分支输出一级类目然后子分支只在“父分支选中的那个一级类目下”的候选子类中做 softmax其余子类直接不参与比较。这个后处理逻辑看起来简单但收益很大。在某个商品类目项目里如果让子分支直接在全部 80 个子类上做 softmax即使有父分支帮助训练推理时依然会出现大概 3% 到 5% 的父-子矛盾样本。加上硬约束之后这个概率降到了 0因为模型根本没有机会输出另一个一级类目下的子类。另外一个建议是给父分支加置信度阈值。如果父分支对“数码产品”的置信度只有 0.4说明这个样本本身就很模糊这时候不要强行往子类细分直接输出“数码产品—未知”或者交给人工处理比硬着头皮猜一个子类靠谱得多。阈值怎么定需要结合业务容忍度如果你能接受错误细分但必须保证没有乱分可以把阈值降到 0.3如果不能接受误导性结果阈值调到 0.7 甚至更高。这个值没有标准答案只能拿一批真实数据测。3. 模型实现与训练实操3.1 网络结构代码我用 PyTorch 风格把整个模型结构写一遍方便你直接参考。完整实现会涉及骨干网络、分支头部和父子类别映射三块内容。import torch import torch.nn as nn class BranchHead(nn.Module): 每个分支的分类头输入共享特征输出该分支负责的类别数 def __init__(self, in_dim, hidden_dim, num_classes): super().__init__() self.fc nn.Sequential( nn.Linear(in_dim, hidden_dim), nn.BatchNorm1d(hidden_dim), nn.ReLU(inplaceTrue), nn.Linear(hidden_dim, num_classes) ) def forward(self, x): return self.fc(x) class BranchCNN(nn.Module): def __init__(self, backbone, num_parents, num_children_per_parent, hidden_dim256): super().__init__() self.backbone backbone self.parent_head BranchHead(backbone.out_dim, hidden_dim, num_parents) # 每个一级类目对应一个子分支头num_children_per_parent 是一个列表 self.child_heads nn.ModuleList([ BranchHead(backbone.out_dim, hidden_dim, n) for n in num_children_per_parent ]) def forward(self, x, parent_labelsNone): feats self.backbone(x) # [B, out_dim] parent_logits self.parent_head(feats) # 训练时必须传入 parent_labels用于按父类分组计算子分支 logits if parent_labels is not None: child_logits torch.zeros(x.size(0), self.num_max_children, devicex.device) for b in range(x.size(0)): pid parent_labels[b] head self.child_heads[pid] logit head(feats[b:b1]) # [1, num_children_of_pid] child_logits[b, :logit.size(1)] logit else: # 推理时先取父分支 top-1再走对应分支 parent_prob F.softmax(parent_logits, dim1) parent_pred parent_prob.argmax(dim1) child_logits torch.zeros(x.size(0), self.num_max_children, devicex.device) for b in range(x.size(0)): pid parent_pred[b] head self.child_heads[pid] logit head(feats[b:b1]) child_logits[b, :logit.size(1)] logit return parent_logits, child_logits代码里有几个地方需要说明。child_logits 在训练时是按传入的 parent_labels 来决定用哪个分支头因此训练时父标签一定不能传错否则模型会在错误的分支头上计算梯度。为了避免这个问题我在数据加载阶段就会做一次校验如果样本的 parent_labels 不在 0 到 num_parents-1 范围内直接抛出异常。num_max_children是所有一级类目下二级类目数量的最大值用于统一张量形状。如果某个父类下只有 5 个子类而另一个父类下有 15 个子类那么张量宽度取 15不足位置用 0 填充。计算损失时用 mask 把填充位置排除掉否则交叉熵会把 0 当成一个真实类别导致模型学到奇怪的东西。3.2 数据加载与批次构造数据加载最关键的一点batch 里的样本必须按父类分组喂给对应分支。如果不分组每个样本都要走不同的分支头实现起来就是上面代码里的 for 循环速度会受 batch 里样本顺序影响。更好的做法是自定义 DataLoader 的 collate_fn在构造 batch 时把同一父类的样本尽量凑在一起减少分支切换次数。def collate_fn(batch): images torch.stack([item[image] for item in batch]) parent_labels torch.tensor([item[parent_label] for item in batch]) child_labels torch.tensor([item[child_label] for item in batch]) return { image: images, parent_label: parent_labels, child_label: child_labels }我还会额外传一个 group_indices记录每个样本的父类 id方便在训练循环里按组计算。如果你的父类数量比较多比如 30 个每个 batch 里的样本可能只覆盖 8 个父类这时候更高效的做法是先统计当前 batch 中有哪些父类然后只把对应分支头加载到当前设备上其他分支头权重可以保留在 CPU 上。不过这属于性能优化的范畴项目初期不建议搞这么复杂。3.3 训练流程与关键参数训练流程上我推荐分两个阶段。第一阶段只训练分支头把主干冻结。做法是先初始化一个在大量通用图像数据上预训练好的骨干网络冻结它的权重只让 parent_head 和 child_heads 的权重更新。这个阶段跑 3 到 5 个 epoch目的是让随机初始化的分支头先适应特征空间的分布。如果一上来就全量微调分支头会往主干特征上强拉随机方向主干很容易被带偏。第二阶段解冻主干用较小的学习率整体微调。优化器我用 AdamW骨干网络学习率设 1e-5 到 3e-5分支头学习率设 1e-4 到 3e-4。为什么分支头学习率要更大因为分支头是随机初始化的需要更大幅度更新才能快速收敛而主干是预训练过的微调幅度太大会破坏掉已经学好的通用特征。训练循环里除了计算常规的 parent_loss 和 child_loss还要每 500 个 step 记录一下两个分支的准确率变化。我习惯把这个数值打印出来观察它们是否同步上升。如果 parent_acc 已经很高但 child_acc 还在低位徘徊大概率是分支头容量不足或样本数量不够需要调整结构而不是盲目加训练步数。关于 batch size项目里我用 64输入分辨率 224。如果显存不够可以降到 32但要同步调低学习率。学习率与 batch size 的关系大致是线性缩放batch size 从 64 降到 32学习率也减半否则步子太大训练不稳。3.4 评估指标怎么定层级分类的评估指标不能只看一个整体准确率一定要分层看。我项目里用三套指标父级准确率、子级准确率、父子联合准确率。父级准确率是只看一级类目分对的比例子级准确率是只看二级类目分对的比例注意这里不管父级是否分对只看二级标签有没有命中父子联合准确率要求两个层级全部正确。这三组指标之间有明显差值能告诉你瓶颈在哪里。如果父级 95%子级 92%但联合准确率只有 85%说明大部分错误来自父级分对后子级选错或者反过来。从我的经验看子级准确率通常比父级低 5 到 10 个百分点如果差距过大说明分支头学习不充分建议先检查子类样本数量是否真的够用。除了这三个指标我还会额外统计“父类正确但子类错误”和“父类错误但子类正确”这两个分组。前者占大头说明子分支有待优化后者如果很多往往说明父分支的信息没有在推理阶段充分利用这时候应该考虑把硬约束做得更彻底比如直接用父分支预测结果去裁剪子分支候选集而不是只做训练时的一致性。4. 实战踩坑记录4.1 分支之间的梯度干扰第一个坑是分支之间的梯度干扰。训练时父分支和子分支共用主干如果同时微调两个分支的梯度会叠加在主干上。父分支希望特征更利于区分“数码产品”和“服装”子分支希望特征更利于区分“手机”和“平板”这两个需求不是总一致的。我在项目里就遇到过父分支准确率稳步上升但子分支准确率在一个 epoch 内上下抖动loss 曲线像锯齿。解决方法是分阶段训练先冻结主干只训分支头等分支头稳定了再解冻主干解冻后还要对子分支的梯度做衰减我给子分支回传到主干的梯度乘了 0.5让父分支的任务对主干特征的影响占主导。这个系数不是拍脑袋定的我是先试了 0.1、0.5、1.0 三个值发现 0.5 时两个分支收敛最均衡。更激进的做法是让主干分别输出两套特征父分支和子分支各自接一套投影。这样两路特征在主干内部就已经分开了梯度干扰会小很多但代价是参数量增加。如果你的二级类目数量很大比如超过 100建议直接用这种方案。4.2 长尾与层级不均衡第二个坑是长尾问题。层级分类里一级类目之间的样本量可能差 10 倍二级类目之间可能差 100 倍。直接在原始分布上训练分支头会偏向样本多的类目少样本类目准确率很低。我之前某个类目只有 300 张图分类准确率只有 40% 多其他大类都能到 90%。先说父级不平衡的解法。在采样阶段做类目加权每个 epoch 里样本少的父类多采一些样本多的父类少采一些但总 epoch 数不变。这比直接上过采样平滑因为过采样容易让模型在少数类上记住重复样本。再说子级不平衡。我的经验是放弃普通的交叉熵改用 focal loss它能自动压低易分类样本的损失让模型更关注难分类的少数类。如果你不想换损失函数也可以给子分支的交叉熵加一个类别权重向量权重反比于样本量取对数。比如某个子类样本数 n权重设成 1.0 / log(1 n)这样极端样本量差异会被压缩。最后提醒一句长尾问题没有银弹。如果某个子类样本真的特别少加再多小花招都不如把数据补齐。我试过用数据增强生成变体但增强样本本质上还是同一张图的信息对提升真实分布下的泛化能力帮助有限只能算是权宜之计。4.3 父子预测矛盾第三个坑是父子和子类预测结果互相矛盾。即使训练损失里加了一致性约束推理时依然可能遇到父分支说这是“服饰”子分支却输出“智能手机”。原因有两个一是训练数据里确实存在少量误标样本二是子分支的 softmax 分布没有强制对齐父分支。训练侧我建议在数据预处理阶段就做一致性校验。如果某个样本的父标签是“服饰”但子标签是“智能手机”这条数据多半是标注错误直接过滤掉不要让它污染模型。推理侧我在前文说了要加硬约束但硬约束还有一个隐藏问题如果父分支分错了子分支在错误的候选集里怎么选都是错。缓解办法是让父分支的置信度参与子分支的最终打分比如最终得分 子分支得分 × 父分支置信度。这样父分支越不确定子分支的输出越被压低即使出现矛盾也能在业务层面暴露“这个样本分类置信度低”而不是直接给一个看似确定但很可能错的答案。4.4 部署优化与共享特征复用最后说部署。Branch CNN 的一个突出优势是主干共享它比级联模型少跑多个网络。但实际部署时还要注意内存和显存占用。如果每个分支头都是独立的全连接层当父类数量很多时分支头本身的参数量也不可小视。一个父类 30 个每个分支头 256 维输入、256 维隐藏层参数总量约 30 × (256×256 256×20)算下来大概 250 万不算大但也没小到可以忽略。优化手段有两个。第一个是剪枝训练完成后观察分支头里哪些神经元权重接近零可以裁剪掉。第二个是特征复用推理时主干输出特征只算一次之后父分支和子分支可以并行计算这个在 GPU 服务里几乎没有额外延迟但在 CPU 上反而会有劣势因为多个小矩阵乘法加起来不如一个大矩阵乘法快。我在某个实际部署中把父类数量压到了 8 个分支头参数量不大就直接全上。如果你的父类数量超过 50建议先做分支头权重共享比如所有分支头共用前面几层全连接只在最后一层分开。我试过这种参数共享方案精度损失不到 1 个百分点但参数量节省了大约一半。最后分享一个我实际用过最舒服的小技巧分支头不要都从同一个随机种子初始化。在训练初期让几个分支头用不同的随机初始化可以天然形成“分工”。有个分支头可能更擅长区分电子产品另一个分支头更擅长区分服饰最后它们会自动朝各自擅长方向收敛整体效果往往比所有分支头同质更新好。这个现象没有严格理论证明但我在多个数据集上观察到了值得一试。