这篇是层级分类系列的第二篇。上一篇里我用普通的多标签多分类方案硬解层级类目需求,能跑通但有几个地方一直很别扭:子类之间互相抢概率,父类和子类的关系只能靠后处理规则硬补,细分类别一多,输出层就堆到几百个节点,训练和部署都不舒服。后来在某个实际项目里换成 Branch CNN(分支卷积网络)重做了一遍,才把这些问题理顺。这篇就把整个设计思路、实现要点、训练参数以及踩过的坑完整写一遍。如果你手头正好有“大类-小类”这种层级识别需求,比如商品类目、车型识别、动植物细分类目,这篇文章应该能帮你节省不少试错时间。
1. 先想清楚:层级分类为什么要换 Branch CNN
1.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_weight=1.0, consistency_weight=0.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, dim=1) # 对每个样本,取出父分支在真实父类上的概率,作为子分支的置信度权重 confidence = parent_prob.gather(1, parent_true.unsqueeze(1)).squeeze(1) consistency_loss = F.cross_entropy(child_logits, child_true, reduction="none") * (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(inplace=True), 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_dim=256): 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_labels=None): 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, device=x.device) for b in range(x.size(0)): pid = parent_labels[b] head = self.child_heads[pid] logit = head(feats[b:b+1]) # [1, num_children_of_pid] child_logits[b, :logit.size(1)] = logit else: # 推理时先取父分支 top-1,再走对应分支 parent_prob = F.softmax(parent_logits, dim=1) parent_pred = parent_prob.argmax(dim=1) child_logits = torch.zeros(x.size(0), self.num_max_children, device=x.device) for b in range(x.size(0)): pid = parent_pred[b] head = self.child_heads[pid] logit = head(feats[b:b+1]) 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 个百分点,但参数量节省了大约一半。
最后分享一个我实际用过最舒服的小技巧:分支头不要都从同一个随机种子初始化。在训练初期,让几个分支头用不同的随机初始化,可以天然形成“分工”。有个分支头可能更擅长区分电子产品,另一个分支头更擅长区分服饰,最后它们会自动朝各自擅长方向收敛,整体效果往往比所有分支头同质更新好。这个现象没有严格理论证明,但我在多个数据集上观察到了,值得一试。