简介Kaggle人类蛋白质图谱图像分类第一名解决方案完整技术文档面向有一定深度学习基础、希望了解多标签图像分类与医疗影像竞赛实战技巧的读者。文档基于DenseNet121搭建分类器采用AdaptiveConcatPool2d聚合池化、BatchNorm1d、Dropout、Linear等结构并引入FocalLossLovasz损失函数解决类别不平衡问题配合逐步衰减学习率调度和旋转/翻转/随机裁剪数据增强策略。同时覆盖哈希去除重复样本、traintest均值标准差归一化、基于度量学习检索最近邻修正测试集标签、以及高低配提交策略等后处理技术对提升稀有类识别与标签噪声场景下的模型鲁棒性很有借鉴意义。资源共1个docx文档压缩包大小117KB章节涵盖赛题概述、模型结构、训练参数、损失函数、预测策略与自省总结等便于按需查阅。已有116人学习下载适合竞赛选手和算法工程师系统复盘顶级方案、优化自身图像分类流程。1. 人类蛋白质图谱图像分类第一名方案一套围绕稀有类的系统工程人类蛋白质图谱图像分类HPA这个Kaggle竞赛拿第一名靠的不是花哨的网络结构而是一套围绕「稀有类」和「标签分布」做的系统工程DenseNet121做底座、FocalLossLovasz压住类别不平衡、最后用ArcFace度量学习在测试集上直接替换标签硬生生把分数再抬0.03以上。这份方案拆开看每一块单独拿出来都不算新鲜但串起来的完整度很高特别适合正在打多标签图像分类比赛、或者被长尾类别和阈值调参折磨的从业者。读完你至少能复现出DenseNet121Lovasz的训练管线并理解为什么「验证集F1好看」在HPA上并不等于公共榜分数高。2. 数据预处理哈希去重、统计量对齐与验证集划分2.1 哈希去重从v18外部数据中清掉约6000个测试集泄漏样本HPA这场比赛一个比较特殊的地方在于主办方提供的v18外部数据集中混进了大约6000张和测试集重复的图像。如果不处理模型训练时等于直接看过部分测试题公共榜分数会虚高私有榜却可能因为过拟合到这些重复样本上而翻车。第一名的做法很直接用哈希方法把重复样本找出来删掉。常见做法是先对每张图做尺寸归一化再计算感知哈希perceptual hash把图像内容压缩成一个固定的二进制串然后拿这个串去查重。我一般会用下面这种方式import hashlib from pathlib import Path from PIL import Image def dhash(img_path, hash_size16): # 转灰度并缩放到固定尺寸消除分辨率差异带来的干扰 img Image.open(img_path).convert(L).resize((hash_size 1, hash_size)) pixels list(img.getdata()) # 相邻像素亮度比较生成64位二进制串 diff [] for row in range(hash_size): for col in range(hash_size): left pixels[row * (hash_size 1) col] right pixels[row * (hash_size 1) col 1] diff.append(1 if left right else 0) return hex(int(.join(diff), 2)) seen {} dup_count 0 for p in Path(v18_external).rglob(*.png): h dhash(p) if h in seen: dup_count 1 p.unlink() # 删除重复样本 else: seen[h] p print(fremoved {dup_count} duplicates)这段代码的核心是把图像内容压缩成64位感知哈希然后靠字典判重。注意这里用相邻像素亮度比较而不是直接算均值哈希是因为HPA图像里细胞结构的纹理差异比整体亮度更关键dhash对亮度偏移不那么敏感。实际删了大约6000个样本和原方案一致。需要提醒的是感知哈希存在极小概率的误判两张内容不同但哈希碰撞的图像会被误删。所以跑完去重后建议把删除清单里属于同一抗体ID的样本数量做个统计如果某个ID的样本被成批删掉就人工抽查一下。比赛时这步做错了外部数据的价值就大打折扣。2.2 用traintest算mean/std归一化统计量覆盖真实分布图像分类的标准做法是用训练集计算RGB均值方差但HPA的测试集分布和训练集有明显差异。如果只用train算统计量测试图像过归一化层时像素分布会发生偏移尤其影响稀有类别那些边缘特征的响应。第一名的做法是把train和test拼在一起算mean/std。import numpy as np def compute_mean_std(loader): mean np.zeros(3) std np.zeros(3) n_samples 0 for images in loader: # images: (B, C, H, W)float32 原始像素 0-255 batch images.numpy().transpose(0, 2, 3, 1).reshape(-1, 3) mean batch.mean(axis0) * len(batch) std batch.std(axis0) * len(batch) n_samples len(batch) mean / n_samples std / n_samples return mean, std mean, std compute_mean_std(train_test_loader) # 保存为全局统计量供训练和推理共用 np.save(mean.npy, mean) np.save(std.npy, std)这里有一个容易被忽略的点HPA图像虽然是三通道RGB输出但实际比赛中很多选手用的是RGBY四通道输入——把HPA官方给的红色、绿色、蓝色、黄色四通道显微镜图全部喂给模型。如果走RGBY路线mean/std就是4维而不是3维上面代码里的np.zeros(3)要改成4dataloader的通道数也要对应调整。第一名的模型参考了RGBY预处理的内核所以这块别搞混。2.3 验证集划分按类别分布对齐训练集用Focal Loss当度量这场比赛里验证集怎么划分直接决定了你能不能看清模型的真实水平。第一名的做法是照着discussion 67819的拆分方式把样本按抗体ID组织确保同一个抗体ID的样本全部落在训练集或验证集同一侧避免数据泄漏。然后在此基础上做分层抽样让验证集里每个类别的出现比例和训练集保持一致。from sklearn.model_selection import train_test_split # df: 包含 id、antibody_id 和28个标签列 # 先用抗体ID聚合保证同ID不跨集合 df_antibody df.groupby(antibody_id)[label_cols].max() # 用「是否含稀有标签」做二值分层 df_antibody[has_rare] df_antibody[rare_cols].max(axis1) train_ab, val_ab train_test_split( df_antibody.index, test_size0.1, stratifydf_antibody[has_rare], random_state42 ) train_ids df[df[antibody_id].isin(train_ab)][id].tolist() val_ids df[df[antibody_id].isin(val_ab)][id].tolist()这里用「是否含稀有标签」做分层而不是直接多标签分层是因为HPA有28个标签多标签stratify在样本量小时会退化成一堆单例分组反而把分布弄乱。用是否有稀有类这个二值特征基本就能把稀有类的比例锁住。验证指标也要跟着换。第一名明确说F1不是好的验证度量因为F1对阈值太敏感而阈值又取决于train和val的分布差异。他在验证集上看的是整个val集的focal loss——这是个逐样本的损失不需要设阈值能从梯度层面反映模型对困难样本和稀有类的拟合程度。这也是为什么后面训练时损失函数用FocalLoss而不是直接的F1变体。3. 分类模型DenseNet121底座、分段学习率与FocalLossLovasz3.1 模型结构AdaptiveConcatPool2d把平均池化和最大池化的信息拼起来第一名的分类模型没有用很复杂的结构就是一个DenseNet121底座加一个自定义分类头。原文贴了完整的头部结构我按PyTorch习惯整理成下面的定义import torch import torch.nn as nn class HPAHead(nn.Module): DenseNet121 特征图 - 28类多标签输出 def __init__(self, in_channels1024, num_classes28): super().__init__() # 同时做全局平均池化和全局最大池化concat后通道翻倍 self.ap nn.AdaptiveAvgPool2d(1) self.mp nn.AdaptiveMaxPool2d(1) self.fc1 nn.Linear(in_channels * 2, 1024) self.bn1 nn.BatchNorm1d(in_channels * 2) self.bn2 nn.BatchNorm1d(1024) self.fc2 nn.Linear(1024, num_classes) self.drop nn.Dropout(0.5) def forward(self, x): # x: (B, 1024, 7, 7) DenseNet121 最后的特征图 x torch.cat([self.ap(x), self.mp(x)], dim1) # (B, 2048, 1, 1) x x.view(x.size(0), -1) x self.bn1(x) x self.drop(x) x nn.ReLU()(self.fc1(x)) x self.bn2(x) x self.drop(x) x self.fc2(x) return x这里的重点是AdaptiveConcatPool2d平均池化保留全局背景信息最大池化保留局部强响应。在多标签任务里像「棒和环」「脂质液滴」这类稀有标签在图像里往往只占一小块区域平均池化会把它们的信号稀释掉而最大池化能把这些强响应点保留下来。两个池化结果concat相当于让分类头同时看到「全局概况」和「局部峰值」。DenseNet121的输入层需要改一下如果走RGBY四通道第一层卷积的in_channels要改成4from torchvision.models import densenet121 model densenet121(pretrainedTrue) model.features.conv0 nn.Conv2d(4, 64, kernel_size7, stride2, padding3, biasFalse) model.classifier HPAHead(in_channels1024) # 替换默认分类器注意DenseNet121最后一层特征图通道是1024concat后变成2048所以全连接头第一层是Linear(2048, 1024)和原文结构一一对应。这里用BatchNorm而不是LayerNorm是因为在batch比较小且类别不平衡的场景下BN配合Dropout的收敛速度更稳。有人会问为什么不直接上transformer或者更新潮的网络结构原作者在比赛里试过各种多标签分类论文里的网络结果没有提升最后老老实实回到DenseNet121——放在这个数据规模下简单结构加对训练策略往往比换结构管用。3.2 训练参数与数据增强Adam加分段衰减旋转90度加随机裁剪训练超参数直接照抄原文就够用关键点我列在下面这张表里参数数值说明优化器Adam默认betas即可初始学习率30e-5第26个epoch开始衰减学习率策略分段常数衰减epoch25: 15e-530: 7.5e-535: 3e-540: 1e-5图像尺寸768x768原图随机裁剪512x512或1536原图裁1024数据增强旋转90度、水平翻转、垂直翻转不做过采样分段衰减的调度器写起来很直接def adjust_lr(optimizer, epoch): if epoch 40: lr 1e-5 elif epoch 35: lr 3e-5 elif epoch 30: lr 7.5e-5 elif epoch 25: lr 15e-5 else: lr 30e-5 for pg in optimizer.param_groups: pg[lr] lr这套调度比起cosine衰减好在哪分段常数衰减在epoch 25左右把学习率砍半相当于在训练中期强制收敛到当前最优区域然后每个阶段再小幅下调让稀有类的决策边界逐步稳定。HPA这种长尾分布的任务后期学习率太大很容易让低频率类别的权重被头部样本反复拉扯。数据增强这块旋转90度而不是随机小角度旋转是为了配合显微镜图像的网格结构——细胞图像旋转90度仍然有生物学意义。随机裁剪512x512从768x768原图里取等于让模型在不同视野下反复看同一个样本相当于免费的样本扩增。用albumentations实现的话是这样import albumentations as A train_transform A.Compose([ A.Rotate(limit90, p0.5), A.HorizontalFlip(p0.5), A.VerticalFlip(p0.5), A.RandomCrop(512, 512), ])推理时同样从768x768图里随机裁512x512用4个最佳focal loss epoch的模型各预测一次取最大值作为最终预测——注意是取max而不是取平均。多标签场景下取max能保留每个类别在所有crop中最强的信号取平均反而会把稀有类的弱信号稀释掉。3.3 损失函数FocalLoss压稀有类Lovasz平衡Recall和Precision损失函数是这套方案里最值得抄作业的部分。第一名明确说没有用宏F1软损失理由是batch太小而且有些类别样本太少宏F1的梯度在全局范围才稳定小batch下会剧烈抖动。他选的是FocalLoss Lovasz的组合。FocalLoss的PyTorch实现很简洁import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, gamma2.0): super().__init__() self.gamma gamma def forward(self, logits, targets): # logits: (B, 28) 未过sigmoid的原始输出 bce F.binary_cross_entropy_with_logits(logits, targets, reductionnone) pt torch.exp(-bce) focal (1 - pt) ** self.gamma * bce return focal.mean()gamma2.0是Focal Loss论文里的默认值。它的作用是当一个样本已经很好分类pt接近1时权重(1-pt)^gamma趋向0loss贡献变小难样本和稀有类样本的pt小权重接近1loss主导梯度。这样模型不会把注意力全放在好分类的头部类上。Lovasz loss则是针对IOU的代理损失。IOU本身是离散不可导的Lovasz通过次模分析构造了一个逐元素的surrogate。它在小batch下的行为是逐样本计算IOU梯度对罕见类天然友好——样本出现就学没出现就不影响梯度。第一名选它的理由很实在IOU和F1不完全一样但Lovasz能在一定程度上平衡Recall和Precision让模型不会为了刷recall而疯狂把每个类别都预测成正样本。这两个损失怎么组合常见做法是直接加权相加focal系数默认1lovasz系数也默认1。实际跑的时候可以把两个loss的数值各自归一化到同一量级再相加避免某一个loss主导。我在复现时习惯在每个epoch结束打印两个loss各自的均值如果focal loss的量级是lovasz的十倍以上就把focal的权重调小到0.5再试。4. 训练与验证避坑指南阈值、稀有类与过采样4.1 验证集F1很高公共榜分数却不涨现象训练过程中验证集F1一路走高看起来模型在变好但每次提交的公共榜LB分数几乎不动有一次甚至反向跌了。原因F1对阈值极其敏感而阈值取决于train和val的类别分布。HPA的稀有类样本数量极少val集里如果稀有类占比和测试集不一致一个微小的阈值波动就会让稀有类的precision和recall剧烈变化把真正的能力提升给掩盖掉。解决像第一名那样把验证指标从F1换成focal loss。focal loss不依赖阈值逐样本计算模型能力强不强直接反映在loss上。如果还是想看F1就把每个类的预测正样本比例强制对齐到训练集的比例再来算F1——这样阈值被固定住比较的才是模型能力而不是运气。4.2 公共榜上微调稀有类数量私有榜崩了现象在公共LB上手动把某两个稀有类的预测数量增减2到5个分数好像变好了一点最后一换到私有LB直接掉出奖牌圈。原因公共LB只是测试集的一部分稀有类在公共LB里的占比和私有LB不一定一致。为了公共LB去调整稀有类的阈值本质上是把模型预测往一小撮样本上拟合拟合过度就翻车。解决第一名只生成两个固定提交。第一个保持标签与公共测试集的比例——因为稀有类在测试集的真实比例未知直接设成训练集的比例第二个保持标签比例为训练集比例和公共测试集比例的均值。把这两个提交当作「可能的重组」的评估参考而不是当成微调目标。这样做的好处是如果两个提交分数接近说明模型对阈值不敏感可以放心如果差异大说明预测置信度本身有问题应该回到模型而不是去调阈值。4.3 小batch下用过采样或宏F1软损失loss直接不收敛现象把稀有类样本重复采样到和头部类一样多训练loss震荡得厉害val的focal loss反而变差换成宏F1 soft loss一个batch里某些类根本没出现loss直接跳到nan附近。原因HPA单卡训练batch size有限28个类里可能有七八个类在一个batch中完全不存在。宏F1的梯度是全局统计量小batch下的估计方差极大梯度方向基本是噪声。过采样的问题相反稀有类被重复采样后模型反复看到同一批图像很快过拟合到这些样本的特定噪声上泛化反而变差。解决别硬上这两种方法。FocalLoss逐样本计算对batch内的类别缺失不敏感天然适合小batchLovasz用IOU surrogate同样按batch内出现的样本计算梯度。数据增强已经够用了——随机裁剪512x512每次取不同的细胞视野等于给稀有类样本增加了不同上下文比复制粘贴同样的图要健康得多。5. 度量学习把抗体ID当成人脸ID用ArcFace给测试集补标签5.1 为什么CNN分类特征找近邻不够用比赛进入后期第一名想做一件事给定一张测试图从训练集里找到最相似的样本用它的标签做参考。最初用的CNN分类模型特征发现近邻质量并不好——分类特征是被softmax方向挤压过的同类样本在特征空间里虽然能分开但没有刻意把类内距离压小、类间距离拉大直接用欧氏距离找近邻经常找到语义上不相关的样本。后来他注意到一个关键线索具有相同抗体ID的样本几乎拥有相同的蛋白标签。这和人脸识别里「同一个人的不同照片」是一个逻辑。于是他把抗体ID当成face ID在HPA v18数据集上用度量学习训练一个resnet50用ArcFaceLoss做监督把特征拉成「同一抗体ID靠近、不同抗体ID远离」的分布。ArcFace的核心是加性角度间隔在归一化特征和权重之间给正确类别加上一个m角度的惩罚让模型被迫把每个类的特征压到一个更紧的簇里。训练完成后用这个模型提取测试图和训练图的embedding找最近邻的top1准确率能超过0.9这比CNN分类特征好用得多。5.2 ArcFaceLoss与ArcMarginProduct实现要点ArcFaceLoss和ArcMarginProduct的完整代码在原文里有我整理成了可直接复用的版本重点是参数s和m怎么理解。import math import torch import torch.nn as nn import torch.nn.functional as F from torch.nn import Parameter class ArcMarginProduct(nn.Module): 把embedding映射为归一化余弦相似度 def __init__(self, in_features, out_features): super().__init__() self.weight Parameter(torch.FloatTensor(out_features, in_features)) stdv 1. / math.sqrt(self.weight.size(1)) self.weight.data.uniform_(-stdv, stdv) def forward(self, features): # 特征向量和权重都做L2归一化点积即cosine相似度 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.classify_loss nn.CrossEntropyLoss() 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 def forward(self, logits, labels): cosine logits # 构造 cos(theta m) 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(cosine.size(), devicecosine.device) one_hot.scatter_(1, labels.view(-1, 1).long(), 1) output (one_hot * phi) ((1.0 - one_hot) * cosine) output * self.s loss1 self.classify_loss(output, labels) loss2 self.classify_loss(cosine, labels) gamma 1 loss (loss1 gamma * loss2) / (1 gamma) return loss这两个类的配合方式resnet50的全局池化后接一个BatchNorm1d(512)输出512维embeddingembedding输入ArcMarginProduct得到和类别数等长的cosine向量再把cosine向量和label丢给ArcFaceLoss。注意ArcFaceLoss的输入logits是cosine相似度而不是常规的logits所以logits的范围在[-1, 1]之间通过s30放大到[-30, 30]再送进CrossEntropyLoss。参数数值说明s30.0特征范数缩放因子放大cosine到合理温度区间m0.5角度间隔弧度约28.6度m越大类内越紧凑gamma1两个loss的平衡系数原方案默认1optimizerAdam学习率10e-5训练50个epoch推理时务必要把extract_feature开关打开提取bn3之后的特征向量做最近邻搜索。这里有个容易搞错的点ArcMarginProduct的权重也做了L2归一化所以训练完的权重行向量可以直接当成每个类的「中心向量」找最近邻时既可以用训练集的embedding也可以用这些中心向量。5.3 按抗体ID划分数据阈值替换标签度量学习模型的数据划分和分类模型不同训练时用V18数据的抗体ID来分割样本——把某些抗体ID的所有样本放进验证集同ID的其他样本放进训练集保证验证集里不会出现和训练集相同ID的样本。用top1准确率做验证指标验证集top10.9。这一步的意思是给定一张验证图模型在训练集里的最近邻有超过90%的概率和它属于同一个抗体ID因此标签也几乎一样。有了这个信心就可以对测试集做标签修正了。流程是提取测试集所有图像的embedding在V18外部数据加训练集里找最近邻如果最近邻的cosine距离超过某个阈值就用最近邻的标签替换测试图原来的预测标签。原文里有一个很重要的发现这个阈值不敏感。替换测试集中的1000个样本和替换1300个样本评分几乎相同。下面是替换逻辑的示意代码import numpy as np def replace_by_neighbor(preds, test_emb, db_emb, db_labels, threshold): # preds: (N, 28) 原分类模型的sigmoid输出 # test_emb: (N, 512) 度量学习模型提取的测试图特征 # db_emb: (M, 512) 训练集V18的特征 # db_labels: (M, 28) 对应的多标签 cos_sim test_emb db_emb.T # N x M nn_idx cos_sim.argmax(axis1) nn_score cos_sim.max(axis1) mask nn_score threshold preds[mask] db_labels[nn_idx[mask]] return preds阈值怎么取先在验证集上跑一遍画出「替换数量 vs top1准确率」的曲线找一个平台区间的值。验证集上top10.9意味着绝大多数替换都是对的阈值取在0.5到0.7之间通常都能落在平台区。这个操作为总分带来的提升超过0.03在多标签比赛里是非常可观的收益。6. 后处理与最终提交标签比例对齐和值得记住的两个习惯6.1 后处理技巧生成两个标签比例对齐的提交后处理是这套方案的最后一环也是最容易被忽视的加分项。第一名在比赛末期生成了两个提交第一个把测试集的稀有类比例设成训练集的比率第二个把稀有类比例设成训练集和公共测试集比率的平均值。他不建议在这个基础上继续微调——公共LB上调整2到5个稀有类样本可能涨分但那是在拟合一小撮公开数据换到私有LB风险极高。我用代码举个例子假设train_ratio是训练集28个类的正样本比例public_lb_ratio是公共测试集上统计的比例import numpy as np def adjust_to_ratio(preds, target_ratio): 把每列的概率按期望正样本比例取top-k置1 n, c preds.shape adjusted np.zeros_like(preds) for i in range(c): k int(round(n * target_ratio[i])) if k 0: continue idx np.argsort(preds[:, i])[-k:] adjusted[idx, i] 1 return adjusted # 提交A稀有类比例完全对齐训练集 sub_a adjust_to_ratio(raw_preds, train_ratio) # 提交B对齐训练集和公共测试集的平均比例 avg_ratio (train_ratio public_lb_ratio) / 2 sub_b adjust_to_ratio(raw_preds, avg_ratio)这两个提交的分数如果接近说明模型预测置信度分布是健康的如果差异大说明模型在稀有类上的置信度排序不可靠应该回头去查训练集和测试集的分布差异。6.2 验证与进阶阈值不敏感是度量学习有效的信号度量学习替换标签这个操作想稳定复现要抓住一个信号替换1000个样本和替换1300个样本评分几乎一样。这说明方法本身对阈值不敏感是真正有效的能力提升而不是碰运气。反过来如果你在跑通流程后发现替换几百个样本分数就剧烈波动那通常是embedding没有训练好先别急着上生产回到ArcFace的s和m参数上调。从那以后我每次打多标签比赛都会先去统计标签分布、检查训练集和测试集的正样本比例差异再决定阈值怎么定、要不要上度量学习修正标签。这套HPA方案的完整思路和ArcFace实现片段都在那份docx整理版文档里按章节顺序复现基本能还原出一个可用的高精度管线。希望帮到你。本文还有配套的精品资源点击获取