简介:一份面向2024年秋季机器学习竞赛的实用指导文档,适合具备一定机器学习或计算机视觉基础、希望参与图像分类与历史文物鉴定的学生和研究者。资源围绕“中国古代艺术品朝代预测”任务展开,基于3600张书法绘画扫描图像,其中900张为AI生成,训练集标签含噪声,因此专门强调了数据清洗、不均衡样本与长尾问题的处理思路。文档共1个docx文件,压缩包大小约554KB,内容精炼,便于快速查阅。目前已有67人关注学习。资料详细说明了竞赛目标、类别定义、评估指标(总体准确率、非AI类F1、AI类F1及权益共享得分)以及每日提交限制、截止节点和排行榜规则,有助于读者快速掌握赛制、制定建模策略,并针对性地优化模型鲁棒性;同时建议为数据清洗训练专用模型,以进一步处理噪声标签与AI图像干扰。
1. 2024秋季机器学习比赛指导:中国古代艺术品朝代预测,到底在考什么
如果你报名了这个方向的比赛,先别急着找模型,你得先意识到:这不是一个普通的图像分类题,它把计算机视觉里最折磨人的几个问题全凑齐了。基于图像识别的中国古代艺术品朝代预测,本质上是一个「细粒度图像分类 + 小样本学习 + 类别极度不平衡」的三重难题。你以为自己在做分类,其实是在跟数据较劲。
我见过太多参赛者上来就微调 ResNet,跑完第一个 epoch 就懵了:验证集准确率死活不超过 30%。原因很简单,陶瓷、书画、青铜器这些艺术品,不同朝代之间的差异有时候比同一朝代不同窑口的差异还小,而且存世数量差异悬殊——唐代的器物和清代的器物数量根本不在一个量级。这场比赛真正想考验的,不是你调参的手速,而是你处理真实脏数据、制定合理训练策略的能力。
这篇文章就围绕这个比赛,把数据准备、模型选型、训练配置、踩坑记录和提分技巧一条线讲透。适合正在打这场比赛、或者在做类似小样本图像分类项目的人,照着复现能少走两周弯路。
2. 数据清洗与标签体系构建:决定你是 70 分还是 30 分的分水岭
2.1 先搞清楚你要预测什么:朝代标签的三种粒度
比赛给的训练数据通常是「一张图 + 一个朝代标签」的形式。但这里有一个关键问题:朝代标签的粒度到底是什么?是「唐、宋、元、明、清」这种大朝代,还是「北宋、南宋、明早期、明中期」这种细分阶段?这直接决定了你的模型输出层神经元数量,也决定了任务难度。
我一般会先用脚本统计标签分布,看每个类别的样本量。这一步不是走流程,而是要回答两个问题:类别数有多少?最少的类有多少张图?如果最少的类只有几十张而最多的有几千张,那这就是一个典型的长尾分布,后续所有的策略都要围绕这个来设计。
import pandas as pd from collections import Counter # 假设训练集标注是 CSV 格式:image_name, label df = pd.read_csv('train_labels.csv') label_counts = Counter(df['label']) print(f"总类别数: {len(label_counts)}") print(f"样本总量: {sum(label_counts.values())}") print("各类别样本数:") for label, count in label_counts.most_common(): print(f" {label}: {count}") # 计算不平衡度 max_count = max(label_counts.values()) min_count = min(label_counts.values()) print(f"\n最大类/最小类比例: {max_count / min_count:.1f} 倍")这段代码的价值在于让你在动手训练之前,就对数据状况有数。如果最大类和最小类的比例超过 10 倍,就需要走类别重加权或者采样策略。如果最小类别只有几十张图,那就要考虑这个类在训练集上能不能学到有效特征——很多时候你会发现,表现最差的那一类,恰恰就是样本最少的那一类。
2.2 图像去重与坏图过滤:别让模型在垃圾上学习
艺术品数据集里最常见的坑是重复图和近似图。同一件器物可能被多个来源拍了不同角度的照片,或者同一个朝代的不同器物风格高度相似。如果不去重,模型会疯狂过拟合那些「高频出现的特定图像」,而不是学到真正的朝代特征。
去重有两个层次:精确去重用文件 MD5,近似去重用感知哈希。MD5 只能去完全相同的文件,但艺术品数据集里更多的是「同一件东西的不同照片」,这时候感知哈希更实用。
import hashlib import os from PIL import Image import imagehash def compute_md5(file_path): h = hashlib.md5() with open(file_path, 'rb') as f: for chunk in iter(lambda: f.read(4096), b''): h.update(chunk) return h.hexdigest() def compute_phash(file_path): img = Image.open(file_path).convert('RGB') return imagehash.phash(img, hash_size=16) # 示例:对一批图像计算 phash image_paths = [os.path.join('train_images', f) for f in os.listdir('train_images')] hash_dict = {} duplicates = [] for path in image_paths: phash = compute_phash(path) for existing_path, existing_hash in hash_dict.items(): # 汉明距离越小越相似,通常认为小于等于 5 是近似重复 if phash - existing_hash <= 5: duplicates.append((path, existing_path, phash - existing_hash)) break else: hash_dict[path] = phash print(f"检测到疑似近似重复图像对: {len(duplicates)} 组") for triplet in duplicates[:10]: print(f" {triplet}")这里有一个参数值得展开:phash - existing_hash计算的是汉明距离,阈值取 5 是经验值。对于艺术品这种背景复杂、拍摄角度多变的图,阈值太紧会漏掉真重复,太松会误杀有细微差别的有效样本。我一般会先跑一遍看输出,根据实际情况微调这个值。
除此之外,坏图也必须处理。有些图像文件本身是损坏的,PIL 打开会直接报错:零字节文件、截断的 JPEG、CMYK 色彩空间的文件。这些图如果不清理,训练到一半就会炸,或者在预处理阶段拖慢速度。我的习惯是写一个全量检查脚本,把所有打不开的、尺寸异常的图列出来,该删的删,该重新下载的重新下载。
import os from PIL import Image bad_images = [] for root, dirs, files in os.walk('train_images'): for f in files: path = os.path.join(root, f) try: img = Image.open(path) img.load() if img.size[0] < 64 or img.size[1] < 64: bad_images.append((path, 'size_too_small')) except Exception as e: bad_images.append((path, f'error: {e}')) print(f"发现坏图 {len(bad_images)} 张") with open('bad_images.txt', 'w') as f: for path, reason in bad_images: f.write(f"{path}\t{reason}\n")尺寸阈值 64 也有讲究。如果输入图本身比 64x64 还小,那放大到模型的输入尺寸(通常是 224x224 或 384x384)之后全是马赛克,这种图对朝代预测没有任何正面价值,留着只会制造噪声。
2.3 类别划分的艺术:合并和剔除的边界拿捏
真实比赛数据里,有些标注是有问题的。比如一个唐代的碗,标签写成了「唐代」,但它是唐三彩,和其他唐代青瓷差距很大。这时候你别无脑直接删,先看看是「类内差异大」还是「标注错误」。
如果同类内差异大到模型学不出共同特征,有几个处理方案:一是把差异大的子风格拆成独立类别,但这改变了比赛的评价逻辑,要谨慎;二是对这类用更强的数据增强,逼模型提取共性;三是统计这个类在验证集上的表现,如果准确率一直上不去,大概率是这个类自身的问题而不是模型的问题。
还有一类是「无意义类别」。比如有些数据集里会有「其他」「未知」这样的兜底标签,这种类别的图像内容五花八门,什么都有,模型永远学不出来。如果比赛允许处理,我的做法是:先训练一版把所有类都带上,看混淆矩阵,如果「其他」这个类大量被分到别的类,而且其他类也会被误分到「其他」,那说明这个类没有区分度,剔除掉、在推理阶段也禁止模型预测这个类,效果反而更好。
3. 预训练模型选型与迁移学习:别追新,追稳定性
3.1 为什么是迁移学习而不是从头训练
你对着一万张图从头训练一个 ResNet50,效果大概率不如用一个在 ImageNet 上预训练好的模型做微调。原因很简单:朝代之间的差异规律是「风格特征」,不是「像素特征」。预训练模型已经在海量自然图像上学到了边缘、纹理、形状这些底层表征,你需要的只是把高层特征从「识别猫狗」迁移到「识别釉色和纹饰」。
而在具体选型上,我一般不考虑最新的 ViT 或者大型多模态模型。比赛跑分要的是稳定和可控,ViT 在小数据集上容易欠拟合,调参空间大、翻车概率高。20 万张以下的数据量,ResNet50 或者 EfficientNet-B4 是性价比之王。这两个模型的共同特点是:训练技巧成熟、batchnorm 在迁移学习时表现稳定、有一大堆现成的复现经验可以参考。
3.2 用两个阶段缓解类别不平衡:先从大类别学起
类别不平衡在这个比赛里不是一个小问题,而是一个常态。面对这种问题,业界有一个常见做法叫「两阶段训练」:第一阶段只用样本量超过某个阈值的类别训练,得到一个「大类别分类器」;第二阶段把所有类别都加回来,但加载第一阶段的模型权重做初始化。
这样做的好处是:模型先学会了「朝代之间的通用区别」,然后才去学那些小类别的特殊细节,不会因为小类别样本太少,一开始就被大类别带偏。
import torch import torch.nn as nn import torchvision.models as models ckpt = torch.load('stage1_resnet50.pth', map_location='cpu') # 第一阶段模型,假设是 5 个大类 stage1_num_classes = 5 # 第二阶段模型,假设全部类别是 12 个 stage2_num_classes = 12 model = models.resnet50(weights=None) model.fc = nn.Linear(2048, stage1_num_classes) model.load_state_dict(ckpt['model_state_dict']) # 把分类头换掉,保留特征提取器权重 old_fc_weight = model.fc.weight.data.clone() model.fc = nn.Linear(2048, stage2_num_classes) with torch.no_grad(): # 新分类头的初始化:用旧分类头的均值向量补全 # 这样比随机初始化收敛快得多 for i in range(stage2_num_classes): if i < stage1_num_classes: model.fc.weight.data[i] = old_fc_weight[i] else: model.fc.weight.data[i] = old_fc_weight.mean(dim=0) print(f"从阶段1加载完成,分类头已扩展为 {stage2_num_classes} 类")这个初始化方式值得细说。直接把新分类头那几行随机初始化会打乱整个模型的初始状态吗?不会,因为分类头是最后一层,但如果不做任何处理,新类别的输出值会一开始就偏大或偏小,导致 loss 剧烈波动。用旧权重的均值向量来初始化新类别,相当于给新类别一个「中等偏保守」的起点,训练会更稳。
3.3 类别重加权损失函数:让模型多看两眼小样本
两阶段训练是从数据流角度做文章,损失函数层面还可以再叠一层。最常见的做法是在 CrossEntropyLoss 的权重参数里做手脚,权重按类别样本量的倒数来设,但不要直接用原始倒数——那会让样本量极小的类别权重爆表,导致模型疯狂过拟合那几个样本。
我常用的平滑方式是weight = (1 / sqrt(count))或者weight = (max_count / count) ** 0.5,这个 0.5 次方是一个比较中庸的选择。你可以在验证集上实际搜索这个幂次,0.3 到 0.8 之间通常能找到更好的值。
import numpy as np import torch.nn as nn def make_class_weight(label_counts, power=0.5): labels = sorted(label_counts.keys()) counts = np.array([label_counts[l] for l in labels], dtype=np.float32) max_count = counts.max() # 用 max_count 做归一化,权重范围被压在 [1, (max/min)^0.5] weight = (max_count / counts) ** power # 再做一次均值归一化,让有效学习率不至于整体漂移 weight = weight / weight.mean() return torch.from_numpy(weight).float() class_counts = {'唐': 1200, '宋': 800, '元': 300, '明': 1500, '清': 2000} class_weight = make_class_weight(class_counts, power=0.5) print(f"类别权重: {class_weight}") criterion = nn.CrossEntropyLoss(weight=class_weight, label_smoothing=0.1)注意我把label_smoothing=0.1也加上了。这个参数容易被忽略,但在小数据集上非常管用:它不让模型对训练标签过于自信,相当于一种正则化。尤其是当某个类的训练样本特别少时,没有 label smoothing 的模型会轻易把那个类的 logit 推到非常大,导致验证集上稍微有一点噪声就预测错。
4. 训练配置与验证策略:把每张图的用处都榨干
4.1 数据增强的剂量控制:艺术品不是普通照片
艺术品图像有一个特点:主体通常在画面中央,背景相对干净,但角度、光线、裁剪比例差异很大。这决定了数据增强的写法跟自然图像分类不一样。你不能用大尺度随机裁剪,因为有可能把器物的关键纹饰裁掉;也不能用力旋转,因为器物方向在图片里是有意义的(比如口沿纹饰的方向)。
我常用的增强组合是:Resize 到短边 256,中心或随机裁剪 224,水平随机翻转(但有条件,见下文),轻度色彩抖动,随机旋转只给 ±5 度。
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(p=0.3), transforms.RandomRotation(degrees=5), transforms.ColorJitter(brightness=0.2, contrast=0.15, saturation=0.1, hue=0.05), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) valid_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这里有一个「水平翻转概率设为 0.3 而不是 0.5」的细节。为什么?因为中国特色艺术品中有大量左右不对称的器物,比如带把手的壶、带流的长流瓶,如果水平翻转概率太高,模型会学到「左右无所谓」这个错觉。但这些器物在真实图像里是有固定的左和右的,过度的水平翻转反而制造错误样本。
你可能会想:验证集能不能也做增强?我建议别做,验证集用固定的中心裁剪就好。因为增强的随机性会掩盖模型真实水平。如果开了 TTA(测试时增强),那是另一个话题,后面进阶章节会讲。
4.2 训练超参数的保守起点:从稳定到极致
比赛训练常见的一个失误是学习率开得太大。迁移学习里,特征提取器已经有预训练权重了,它不需要那么大动静;你真正需要更新的是分类头和新学习的高层语义特征。所以我的起点是:骨干网络学习率 1e-4,分类头学习率 1e-3,用 AdamW 优化器。如果模型在验证集上 10 个 epoch 没动静,再把学习率往上微调;如果 loss 开始震荡,立即减半。
还有一个新手几乎必踩的坑:对骨干网络启动 grad cam 之类的梯度裁剪幅度没把握好,或者忘了给不同层设置不同的学习率。以下是一个简单的分组学习率实现:
def get_param_groups(model, lr_backbone=1e-4, lr_head=1e-3): backbone_params = [] head_params = [] for name, param in model.named_parameters(): if 'fc' in name or 'classifier' in name: head_params.append(param) else: backbone_params.append(param) return [ {'params': backbone_params, 'lr': lr_backbone}, {'params': head_params, 'lr': lr_head}, ] # 使用示例 model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) model.fc = nn.Linear(2048, 12) param_groups = get_param_groups(model, lr_backbone=1e-4, lr_head=1e-3) optimizer = torch.optim.AdamW(param_groups, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=30, eta_min=1e-6 )细心的读者会注意到weight_decay=1e-4。这个权重衰减是给整组参数用的,但这里有一个被很多实现忽略的问题:batchnorm 层的 gamma 和 beta 不应该加权重衰减,加了会干扰 BN 的归一化尺度。解决方法是额外做一个分组过滤,但为了代码简洁,许多比赛方案会直接用一个折中的权重衰减值。如果你发现训练不稳,优先检查 BN 层的梯度是否异常。
4.3 验证集划分:警惕「同源数据泄漏」
艺术品图像数据有一个很隐蔽的数据泄漏来源:同一件器物、同一个博物馆展柜、同一组公开图集,可能出现在训练集和验证集里。如果不去重就划分,验证集准确率会虚高 5% 到 15%,但这部分分数在测试集上根本拿不到。
我的做法是:先用感知哈希做一层近似去重,把相似度高的图像归到同一组,然后按「组」为单位划分训练集和验证集。这样至少能保证验证集里没有训练集的近似复制品。
import random from collections import defaultdict # 假设已经用 phash 算好了 group_id,存成 image_to_group.json import json with open('image_to_group.json', 'r') as f: image_to_group = json.load(f) group_to_images = defaultdict(list) for img, grp in image_to_group.items(): group_to_images[grp].append(img) all_groups = list(group_to_images.keys()) random.seed(42) random.shuffle(all_groups) valid_ratio = 0.15 valid_group = set(all_groups[:int(len(all_groups) * valid_ratio)]) valid_images = [] train_images = [] for img, grp in image_to_group.items(): if grp in valid_group: valid_images.append(img) else: train_images.append(img) print(f"训练集组数: {len(all_groups) - len(valid_group)}, 图像数: {len(train_images)}") print(f"验证集组数: {len(valid_group)}, 图像数: {len(valid_images)}")这里划验证集的本质不是「随机挑 15% 图像」,而是「随机挑 15% 的相似组」。如果你发现验证集和训练集之间存在大量近似重复,那你的验证策略就是自欺欺人。后续调参时,你会被一个虚高的分数误导,反复调出一个在公开测试集上完全失灵的模型。
5. 四线程训练中的避坑记录:从数据检查到玄学问题
5.1 数据加载线程炸了:不是你的 bug,是内存不够
- 现象:训练跑到第 2 个 epoch,DataLoader 报
RuntimeError: DataLoader worker (pid xxx) is killed,有时候直接整个进程退出。 - 原因:Dataloader 的
num_workers开太多,或者每张图的预处理太重(比如在 transform 里做大量 PIL 操作),瞬间内存/显存溢出。 - 解决:先把
num_workers降到 2 看是否稳定,再逐步上调到 CPU 核数的一半。图像解码统一走Image.open() -> RGB -> resize最短路径,不要在 transform 里做numpy -> PIL -> numpy的反复转换。
我见过最翻车的案例是有人把num_workers=16怼在 4 核的笔记本上,结果数据加载比模型训练还慢,而且内存直接爆了。这个参数不是越大越好,它受限于你的 CPU 内存带宽和图片解码速度。
5.2 验证集精度高、测试集却拉胯:典型的域偏移
- 现象:本地验证集准确率 92%,提交到比赛平台公开测试集只有 75%。
- 原因:验证集和测试集的图片来源不同。比赛方在构建测试集时,通常会用跟训练集完全不同来源的公开图库,这些图在光照、背景、清晰度上分布不一样。你的模型如果过拟合了训练集的「背景和色调」——比如训练集很多图是博物馆官网的白底图,测试集却是实拍展柜照片——那验证集分数就解释不了测试集分数。
- 解决:这正说明了先做同源去重的必要性。另外,对训练图做「风格随机化」增强,比如随机调整色温、加模拟玻璃反光、随机灰度化,能提高对域偏移的抵抗能力。如果比赛允许外部数据,还可以用少量测试集分布相近的图做辅助训练。
这种「验证集分数虚高」是比赛里最坑的,你调参调的再准,方向选错了都会打到棉花上。所以要在验证策略上下狠心:宁可验证集吃紧一些,也不能让它虚胖。
5.3 精度卡在某个值不动:loss 在降但指标不涨
- 现象:训练 loss 稳步下降,但验证集准确率在 80% 附近卡了 10 个 epoch,怎么调学习率都没反应。
- 原因:数据增强强度不够,或者类别数太多、类间距太小。模型已经记住了训练集的「最优解」,但在验证集上还差一口气。
- 解决:先检查是不是数据增强太轻——把 RandomCrop 改成 ScaleJitter,或者加 RandomErasing,让模型看到更多「残缺」的样本。第二个方法是把模型输入尺寸从 224 提到 320 或 384,这个操作在小数据集上往往能带来 2% 到 5% 的提升,代价是显存占用翻倍。
# 加大输入尺寸 + 加随机擦除 train_transform_v2 = transforms.Compose([ transforms.Resize((384, 384)), transforms.RandomCrop(320), transforms.RandomHorizontalFlip(p=0.3), transforms.RandomRotation(degrees=5), transforms.ColorJitter(brightness=0.2, contrast=0.15, saturation=0.1, hue=0.05), transforms.RandomErasing(p=0.25, scale=(0.02, 0.15), ratio=(0.3, 3.3)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])随机擦除RandomErasing的scale=(0.02, 0.15)意思是擦除区域占图像面积为 2% 到 15%,这个比例对艺术品细节非常重要。如果擦的太大,模型可能会把整个器物主体擦掉,等于制造了一个无法判别的样本。
5.4 朝代的严谨性要求被忽略:这是一个现实的问题
- 现象:模型把「明早期」的器物预测成了「明」或「元」,尽管单个 epoch 的准确率还行,但看混淆矩阵发现相邻朝代互相串。
- 原因:朝代之间的边界本来就不是一个像素级的清晰分界线。很多器物的朝代属性是考古学家的综合判断结果,图片本身的信息可能不足以支撑精确的细粒度分类。
- 解决:如果比赛允许,可以用「多标签软化」方案——不是硬生生的 12 类互斥分类,而是把标注转成一个朝代序列的概率分布,比如某器物 80% 属于明代、20% 属于元代。这样模型学到的不是「非此即彼」,而是「朝代渐变」。这需要在预处理时把 label 编成分布向量,而不是一个整数索引。
通常的做法是先统计标签共现矩阵,找出一批「在样本特征上容易混淆」的朝代对,然后手动训练一个次级分类器来区分这两个朝代。这个思路跟分层分类器很像:第一层判断大类(比如明清 vs 唐宋 vs 高古),第二层再细分具体朝代。
6. 测试时增强和模型融合:最后一公里的提分技巧
6.1 TTA:把一次预测变成 6 次投票
到了提交阶段,再好的模型也想在推理时再多榨一点提升。测试时增强(Test-Time Augmentation)的原理很简单:训练时你怎么增强,测试时也做几组差不多的扰动——不同的裁剪位置、翻转、小旋转——然后把多次预测取平均。
对艺术品这样对细节敏感的任务,TTA 的常见配方是:中心裁剪、左上裁剪、右下裁剪 + 水平翻转,一共 6 个变体。如果你的模型输入是 320,推理时先把图 Resize 到 360,再随机裁剪到 320,得到多视角预测。
import torch import torch.nn.functional as F from torchvision import transforms def predict_with_tta(model, img_tensor, device): """ img_tensor: 已经 normalize 后的 tensor, shape=[C, H, W] """ model.eval() tta_transforms = [ transforms.Compose([transforms.CenterCrop(224)]), transforms.Compose([transforms.CenterCrop(224), transforms.RandomHorizontalFlip(p=1.0)]), transforms.Compose([transforms.Resize(256), transforms.FiveCrop(224)]), ] all_preds = [] x = img_tensor.unsqueeze(0).to(device) with torch.no_grad(): # 方案1: 中心裁剪 pred = model(x) all_preds.append(F.softmax(pred, dim=1)) # 方案2: 水平翻转(不变物体方向的前提) x_flip = torch.flip(x, dims=[3]) pred = model(x_flip) all_preds.append(F.softmax(pred, dim=1)) # 方案3: 多尺度——resize成不同大小再center crop for scale in [0.8, 0.9, 1.1, 1.2]: scaled = F.interpolate( x, scale_factor=scale, mode='bilinear', align_corners=False ) # crop回原尺寸 scaled_final = F.interpolate( scaled, size=(224, 224), mode='bilinear', align_corners=False ) pred = model(scaled_final) all_preds.append(F.softmax(pred, dim=1)) avg_pred = torch.mean(torch.cat(all_preds, dim=0), dim=0, keepdim=True) return avg_pred请务必注意翻转的适用条件:如果模型在训练时用了低概率的水平翻转,那测试时也要用同样的概率约束,不能无脑上翻转。否则,前面说过的「带把手的壶」这类不对称器物会被翻转操作坑掉。
6.2 多模型融合的稳妥姿势:按分数加权而不是平均
如果你有多个候选模型——比如一个 ResNet50、一个 EfficientNet-B4、一个 ResNeXt——怎么融合是有讲究的。常见做法是直接把预测概率取平均,但这默认了每个模型实力相当。我更建议用「以验证集最优 epoch 为准」的分数加权。
import numpy as np # models_predictions: list of 概率矩阵, shape=[N_samples, num_classes] # valid_scores: 每个模型在验证集上的准确率 model_preds = np.array([pred1, pred2, pred3]) # (3, N, C) valid_scores = np.array([0.91, 0.88, 0.90]) # 归一化权重 weights = valid_scores / valid_scores.sum() # 加权融合 final_pred = np.tensordot(weights, model_preds, axes=(0, 0)) final_labels = np.argmax(final_pred, axis=1)这个融合方法是「验证分数驱动」的,但它有一个潜在问题:验证分数高的模型未必与测试分布对齐。如果你发现融合后的提交分数反而低于单模型最高分,那就不要融合。比赛不是做数学题,一切以实测为准。
6.3 提交格式检测和训练痕迹清理:比赛的最后一步
我见到有的参赛者模型分数不错,却因为提交文件里标签顺序错了、或者类别名大小写不一致而被判零分。这很可惜。后来我养成了一个习惯:写一个脚本,把提交文件和后端要求的 CSV 格式严格对齐,类别名做一遍精确字符串匹配,甚至会把valid_submission.csv的第一行打印出来人工核对。
import pandas as pd # 假设模型输出 image_name -> predicted_label 映射 submission = pd.DataFrame({ 'image_name': test_images, 'label': predicted_labels }) # 做一次基础校验 allowed_labels = ['唐', '宋', '元', '明', '清'] # 以比赛给定标签集合为准 assert set(submission['label']).issubset(allowed_labels), "存在非法的标签" # 检查是否有空值 assert submission['label'].notna().all(), "存在空标签" submission.to_csv('final_submission.csv', index=False) print(submission.head())说到训练痕迹,我在打比赛时总喜欢保留两份权重:一份是「验证集最优 epoch」的权重,一份是「最后一个 epoch」的权重。很多初学者只留最后一个 epoch,结果最后的训练过程已经过拟合了,验证集分数反而往下掉。正确做法是监控每个 epoch 的验证指标,保存最优的,并且顺手存一份稍微提前几轮、训练更稳定的版本——这两个模型往往能融合出更好的结果。
我自己的习惯是给每个实验跑一个完整的config.yaml存档,把数据增强版本、学习率、epoch、批大小一并写进去。因为调过几轮之后,你会发现原来某个看似不起眼的设置,其实是提升分数的关键。没有配置存档,你连后悔药都没得吃。希望这篇文章能帮你在跑这个比赛时少走几条弯路,把精力花在真正能提分的地方。
本文还有配套的精品资源,点击获取