HPA多标签分类冠军方案:数据清洗、FocalLoss与度量学习
简介来自Kaggle人类蛋白质图谱图像分类竞赛的第一名方案完整记录基于CNN分类器与度量学习模型的技术路线适合正在攻克多标签图像分类、类别不均衡及竞赛调参难题的深度学习开发者阅读。文档围绕DenseNet121网络结构展开给出AdaptiveConcatPool2d、BatchNorm1d等模型层细节并讲解v18外部数据哈希去重、traintest均值标准差预处理、Adam优化器分段学习率及FocalLossLovasz组合损失函数。后处理部分说明如何保持标签比例生成两个提交以及利用度量学习检索最近邻替换测试集标签、提升公共分数0.03的思路。配套资源为1个docx文档共117KB内容聚焦方案要点与实验决策已有116人学习下载。对希望复现顶级Kaggle方案、优化自身分类管线的读者有直接参考价值。1. 从人类蛋白质图谱到多标签分类一条反直觉的冠军路径人类蛋白质图谱图像分类HPA这个比赛表面看是个常规的多标签分类问题28个标签、单细胞显微镜图像、Kaggle平台。但认真拆完第一名方案后会意识到真正拉开差距的并不是网络结构有多深而是数据清洗、验证集设计、损失函数选取和度量学习组合起来的一条完整链路。冠军方案自己用的只是DenseNet121单模型、单个折配合FocalLoss加Lovasz损失最后用ArcFace度量学习找最近邻替换测试集标签在公共榜上直接提升了0.03以上。这个思路对现在做图像分类模型、多标签任务或者准备参加Kaggle竞赛的人都有参考价值尤其是当你发现调参已经改不动分数、而数据分布和标签质量才是瓶颈的时候。2. 数据清洗与验证集设计哈希去重和类别比例对齐2.1 外部数据与测试集的哈希去重HPA比赛除了官方训练集之外还提供了v18外部数据。冠军方案里提到外部数据和测试集之间存在大约6000个重复样本。这里的关键问题是如果这些重复样本被直接放进训练集模型就等于见过测试集的一部分答案线下验证分数会虚高公共榜和私有榜的gap也会被误导。常规的做法是计算感知哈希pHash在图像层面做近似去重。import imagehash from PIL import Image from pathlib import Path def dedup_by_phash(root_dir, hash_size8): hash_map {} dup_ids set() for img_path in Path(root_dir).rglob(*.png): img Image.open(img_path).convert(L).resize((256, 256)) h str(imagehash.phash(img, hash_sizehash_size)) if h in hash_map: dup_ids.add(img_path.stem) else: hash_map[h] img_path.stem return dup_ids逻辑说明imagehash.phash基于离散余弦变换提取图像低频信息低频分量对缩放、轻微平移和噪声不敏感正好适合判断显微镜图像是否来自同一原始样本。hash_size8表示生成64位哈希位数越高区分度越高但也更容易把同一图像的不同压缩版本判成不同样本。这里先统一转灰度并缩放到256x256再计算哈希避免同图不同尺寸导致哈希不一致。实际执行时建议对训练集、测试集、外部数据三份数据两两比对而不是只扫一个目录。2.2 验证集划分让类别分布对齐训练集冠军在讨论区参考了trentb的切分方式并且明确说明了一个容易被忽略的问题F1对阈值敏感而阈值又完全取决于训练集和验证集的类别分布。如果验证集的类别比例和训练集不一致那么验证F1的变化就无法反映模型真实能力的提升。所以他改用FocalLoss作为验证指标同时在切分时保持验证集各类别比例与训练集一致。验证指标优点实际问题F1直观、接近线上评测对阈值高度敏感阈值受标签分布影响FocalLoss天然处理类别不平衡数值不直观需要看相对变化趋势一种常见的实现方式是先统计训练集中每个标签的出现频率然后对验证集样本按标签频率加权抽样让验证集整体的标签分布贴近训练集。from sklearn.utils import resample def balance_val_by_freq(val_df, train_freq): weights val_df[Label].apply( lambda labels: sum(train_freq.get(l, 0) for l in labels) ) sampled resample( val_df, replaceFalse, n_samplesint(len(val_df) * 0.8), random_state42 ) return sampled注意这里不是做类别平衡采样而是构造一个分布对齐的验证集。train_freq是每个标签在训练集中的出现频率字典权重只用来辅助判断实际抽样时仍按样本整体分布进行。这样验证Loss才能反映模型在真实比赛分布上的能力后续调阈值、选epoch才有意义。2.3 标准化统计量train加test一起算原文提到使用train加test计算均值和标准差在图像输入模型之前做标准化。显微镜图像在不同批次采集时亮度和对比度会有系统差异只用训练集统计量做标准化测试集图像的数值分布会偏移。把测试集图像统计量一起纳入计算相当于让模型看到尽量一致的数据分布范围。2.4 训练前先看类别分布在跑模型之前至少要做一次标签分布统计HPA这种比赛里稀有类别可能只有几十个样本。类别极度不平衡是后面选择FocalLoss的直接原因也是度量学习能带来巨大提升的原因如果28个类别样本量均衡最近邻替换的效果也不会这么明显。3. CNN分类器DenseNet121、FocalLoss与Lovasz的配合3.1 为什么是DenseNet121而不是更大的网络冠军在原文里说得很直白他试了多标签分类论文里的各种网络结构结果没有明显改进。DenseNet121的优势在于ImageNet预训练权重成熟、参数量适中、特征复用机制对密集小目标更友好。在512x512输入下单卡可以跑较大的batch模型效率与准确度之间更好平衡。实际竞赛中大规模集成确实能涨点但单模型的稳定性和可复现性更值得优先保证。3.2 模型头部AdaptiveConcatPool2d加多层BN冠军方案的模型头部与公共内核基本一致核心模块是AdaptiveConcatPool2d和带BatchNorm的多层全连接。import torch import torch.nn as nn class AdaptiveConcatPool2d(nn.Module): def __init__(self, output_size1): super().__init__() self.ap nn.AdaptiveAvgPool2d(output_size) self.mp nn.AdaptiveMaxPool2d(output_size) def forward(self, x): return torch.cat([self.ap(x), self.mp(x)], dim1) class DenseNet121Head(nn.Module): def __init__(self, in_channels1024, num_classes28): super().__init__() self.concat_pool AdaptiveConcatPool2d(1) self.head nn.Sequential( nn.BatchNorm1d(in_channels * 2), nn.Dropout(0.5), nn.Linear(in_channels * 2, 1024), nn.ReLU(inplaceTrue), nn.BatchNorm1d(1024), nn.Dropout(0.5), nn.Linear(1024, num_classes) ) def forward(self, x): x self.concat_pool(x).view(x.size(0), -1) return self.head(x)逻辑说明AdaptiveConcatPool2d把平均池化和最大池化输出拼接在一起平均池化保留整体响应最大池化保留强激活区域对于蛋白质图谱这种目标分散、信号稀疏的图像两者互补效果好于单独使用任意一种。注意DenseNet121最后一个卷积块输出1024通道拼接后是2048维所以第一层BatchNorm1d(2048)再经过1024维中间层降到28维输出。两个Dropout(0.5)分别放在两次全连接前用于抑制过拟合多标签分类中这个结构比单层全连接要稳得多。3.3 训练配置Adam加分段学习率衰减冠军使用的学习率调度是分段常数衰减初始学习率30e-5按epoch逐步降低。我一般也会在训练中后期手动分段比Cosine衰减更容易控制训练节奏。epoch区间学习率0-2530e-526-3015e-531-357.5e-536-403e-5411e-5对应PyTorch的LambdaLR写法如下def lr_lambda(epoch): if epoch 40: return 1e-5 / 30e-5 if epoch 35: return 3e-5 / 30e-5 if epoch 30: return 7.5e-5 / 30e-5 if epoch 25: return 15e-5 / 30e-5 return 1.0lr_lambda返回的是相对于初始学习率的缩放系数初始学习率设置为30e-5配合LambdaLR使用。每个阶段学习率减半或减为三分之一让模型在大步搜索后逐渐收敛到更优区域。3.4 FocalLoss处理极度不平衡的标签冠军没有使用宏F1软损失原因是batch size较小且某些类别样本太少软F1的梯度会被多数类别主导。FocalLoss的核心思想是降低易分类样本对loss的贡献把训练重心推向稀有类别。import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, gamma2.0, alpha0.25): super().__init__() self.gamma gamma self.alpha alpha def forward(self, logits, targets): ce_loss F.binary_cross_entropy_with_logits( logits, targets, reductionnone ) p torch.sigmoid(logits) p_t p * targets (1 - p) * (1 - targets) focal_weight (1 - p_t) ** self.gamma if self.alpha is not None: focal_weight focal_weight * self.alpha return (focal_weight * ce_loss).mean()逻辑说明gamma2.0是常用的默认值它控制困难样本的权重放大程度gamma越大对易分类样本的压制越强但gamma过大会导致训练不稳定。alpha是类别平衡系数这里是一个常数HPA这种多标签场景也可以为每个类别单独设定alpha但需要预先统计类别频率避免人为引入偏见。由于HPA是极度不平衡的多标签任务binary_cross_entropy_with_logits配合sigmoid输出每个类别独立计算损失比softmax更适合多标签预测。3.5 Lovasz损失间接优化F1度量Lovasz损失通过次模函数光滑地逼近IoU损失Jaccard index的优化与F1高度相关。冠军使用Lovasz的原因是它能在一定程度上平衡Recall和Precision虽然IOU和F1并不等价但优化方向是近似的。训练时FocalLoss和Lovasz按比例相加常见做法是各占0.5FocalLoss负责稳定训练Lovasz负责让模型预测的集合更贴近真实标签。4. 度量学习把抗体ID当人脸ID用ArcFace找最近邻4.1 为什么要引入度量学习当冠军用CNN分类器的特征做最近邻检索时发现效果并不好。原因是分类任务训练出的特征只追求线性可分并不保证语义相近的样本在特征空间中彼此靠近。于是他把度量学习引入了HPA观察到相同抗体ID的样本具有几乎相同的标记可以把抗体ID类比为人脸识别中的身份ID。这样每个抗体ID成了一个类别任务从多标签分类转换成检索任务。4.2 数据划分按抗体ID分折度量学习的数据划分与分类任务不同必须按抗体ID划分同一ID的所有样本不能同时出现在训练集和验证集否则模型只需要记住ID就能得到很高的检索精度。冠军使用的验证指标是top1 accuracy也就是对验证集中每个样本在训练集中找最近邻判断最近邻的标签是否一致最终top1精度超过0.9。4.3 ArcFaceLoss带角度边界的度量损失ArcFace把人脸识别中的角度边界引入softmax损失核心思路是在特征与权重向量的夹角上加一个margin。import math import torch import torch.nn as nn import torch.nn.functional as F from torch.nn import Parameter class ArcMarginProduct(nn.Module): def __init__(self, in_features, out_features): super().__init__() self.weight Parameter(torch.FloatTensor(out_features, in_features)) nn.init.xavier_uniform_(self.weight) def forward(self, features): cosine F.linear(F.normalize(features), F.normalize(self.weight)) return cosine class ArcFaceLoss(nn.Module): def __init__(self, s30.0, m0.5): super().__init__() self.s s self.cos_m math.cos(m) self.sin_m math.sin(m) self.th math.cos(math.pi - m) self.mm math.sin(math.pi - m) * m self.ce nn.CrossEntropyLoss() def forward(self, cosine, labels): sine torch.sqrt(1.0 - torch.pow(cosine, 2)) phi cosine * self.cos_m - sine * self.sin_m phi torch.where(cosine self.th, phi, cosine - self.mm) one_hot torch.zeros_like(cosine) one_hot.scatter_(1, labels.view(-1, 1).long(), 1) output one_hot * phi (1.0 - one_hot) * cosine output * self.s loss1 self.ce(output, labels) loss2 self.ce(cosine, labels) return (loss1 loss2) / 2逻辑说明网络最后一层输出特征经L2归一化后与归一化的权重做内积得到cosine相似度。s30是特征范数的缩放因子控制softmax的尖锐程度s越大分类边界越严格m0.5是弧度制的角度间隔m越大不同抗体ID之间需要的角度差距越大但m过大会导致难收敛。这里先对特征和权重做归一化内积等价于余弦相似度。easy_marginFalse对应原文的逻辑即当余弦值小于阈值cos(pi - m)时用cosine - mm作为替代避免角度超过pi导致数值不稳定。4.4 网络结构ResNet50加投影头度量学习模型使用ResNet50作为backbone在全局平均池化后接了一个投影头BN、全连接、ReLU、全连接最后输出512维特征。中间还插入了dropout概率与分类头不同。训练时使用旋转90度和随机翻转做数据增强。投影头的目的是把backbone输出的原始特征映射到一个更适合度量学习的嵌入空间比直接在分类特征上计算余弦距离更有效。4.5 最近邻替换测试集标签度量学习模型在验证集上top1精度超过0.9后冠军对测试集每个样本在训练集和v18外部数据中找最近邻并设置一个距离阈值低于阈值则用最近邻的标签替换模型预测的标签。from sklearn.neighbors import NearestNeighbors import numpy as np def replace_with_neighbor(feats_test, feats_train, labels_train, threshold0.3): nn_model NearestNeighbors(n_neighbors1, metriccosine) nn_model.fit(feats_train) dists, idxs nn_model.kneighbors(feats_test) dists dists[:, 0] neighbor_labels labels_train[idxs[:, 0]] return np.where(dists threshold, neighbor_labels, pred_labels)因为特征已经做了L2归一化余弦距离和欧氏距离是等价的可以直接用NearestNeighbors的cosine度量。threshold需要结合验证集上同类样本和异类样本的距离分布来确定一个少用做法是画出距离直方图在两类分布的交叉点附近取值。原文提到替换1000个样本和替换1300个样本得分几乎相同说明结果对阈值不敏感这也意味着测试集中确实存在大量与训练集特征高度一致的样本。4.6 阈值不敏感但必须检验替换样本的数量由阈值控制阈值放宽会替换更多标签收紧则替换更少。虽然结果对阈值不敏感但仍需注意测试集与v18数据的分布差异有些测试样本在训练集和v18中找不到近邻这种情况下不应该替换保留原预测即可。5. 标签比例后处理与提交策略0.03分的来源5.1 两种标签比例对齐方式比赛最后阶段需要确定如何调整预测标签冠军生成了两个提交。第一种是保持标签与公共测试集的比例但由于稀有类别的真实比例未知将稀有类别的比例设置为与训练集相同第二种是保持标签比例与训练集和公共测试集的平均比率。这两种做法的核心都是让预测集合的标签分布不偏离已知分布避免因为预测过拟合而出现大量稀有类别误报。提示通过每次增减2到5个稀有类别样本观察公共LB变化可以评估当前提交对标签分布的敏感度但这种方式不能直接用来调参因为公共测试集只占整个测试集的一小部分极其容易过拟合。5.2 多尺度TTA随机裁剪加最大值聚合冠军在预测阶段使用了4个最优epoch的模型权重从768x768图像中随机裁剪512x512的patch对多个patch的预测取最大值。取最大值而不是平均值是因为多标签分类中每个类别独立判断取最大值保留了该类别在所有patch中出现过的强信号避免被某个低置信度patch拉低分数。TTA会增加预测时间具体倍数取决于裁剪patch数量。5.3 复现时的检查项复现这套方案时有几个容易出错的位置。第一输入尺寸必须保持768x768裁剪512x512或1536x1536裁剪1024x1024缩放尺寸不一致会直接影响FocalLoss的效果。第二验证集划分后要检查每个类别的样本数是否与训练集比例对齐。第三度量学习模型训练时验证集数据不能与训练集共用任何抗体ID。第四标签替换后要重新统计测试集预测的类别比例确认没有出现极端偏差。一个监控技巧是每训练完一个epoch就统计当前模型在验证集上预测标签的比例分布如果某个类别预测比例显著低于训练集比例大概率是模型对该类别的召回率不足这时候优先调整FocalLoss的alpha参数而不是直接改阈值。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →