ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零训练CNN:古画朝代分类中的数据清洗与噪声标签处理实战

从零训练CNN:古画朝代分类中的数据清洗与噪声标签处理实战 简介这份文档面向具备一定机器学习与计算机视觉基础的学生和研究者围绕2024年秋季机器学习期末竞赛提供利用图像分类技术预测中国古代书法与绘画作品朝代的完整指导。赛题数据集包含3600张512×512图像其中900张为AI生成训练集标签存在噪声与类别不均衡问题需要参赛者先进行数据清洗再建模。资源包为1个docx文档压缩后约554KB内容涵盖任务定义、类别体系唐、宋、元、明、清及AI六类、评估指标与提交规范。评估采用加权方式总体准确率、非AI类别F1、AI类别F1各占30%另有10%的权益共享评分鼓励解决长尾难题。文档还明确了Kaggle提交规则、每日提交次数限制及分阶段时间节点帮助读者理解数据预处理、模型优化与竞赛排名的关键步骤适合希望提升实际项目操作能力并参与竞争排名的读者参考。目前已有67人学习。1. 从 3600 张古画里猜朝代这套赛题到底在考什么如果你手头正好有一份 3600 张、512×512 的中国古代书画扫描图数据集标签是唐、宋、元、明、清加一个 AI 生成类你会先干嘛多数人的第一反应是直接上 ResNet 跑一遍但这份 2024 秋季机器学习竞赛的赛题设计者显然不希望你这么干。它把 900 张 AI 生成图混进数据集其中只有一半被明确标成 AI 类另一半被随机塞进了五个真实朝代标签里——也就是说训练集里天然带着约 450 个「披着唐宋朝代皮的假画」。这不是一个纯粹的图像分类任务而是一个「先清洗、再分类」的复合任务。这份资源适合谁如果你正在做机器学习课程设计、想找一个有真实噪声和类别不均衡的图像分类实战或者你单纯想搞清楚「数据清洗到底能比换模型多拿多少分」这套赛题值得认真拆一遍。它的评估指标也很有意思总体准确率占 30%非 AI 类 F1 占 30%AI 类 F1 占 30%剩下 10% 给了一个叫 Stake Sharing 的长尾激励指标。换句话说你光把整体准确率刷高没用AI 类和真实朝代的 F1 必须同时兼顾否则权重一乘分数立刻塌下来。2. 数据清洗先行把 450 张假画从训练集里捞出来2.1 为什么必须先做数据清洗赛题原文写得很直白「handling the noisy and imbalanced data properly might be more helpful than using a stronger model」。这句话不是客套。你想想450 张 AI 图被随机打上了唐、宋、元、明、清的标签如果直接拿去训练一个六分类模型模型会学到什么它会学到「某些唐标签的图长得像 AI 图」然后在你预测测试集时把真正的唐画也往 AI 类偏。更麻烦的是AI 类 F1 单独占 30%你如果清洗不干净这个指标会直接崩。常见做法是分两阶段第一阶段训一个二分类器只判断「这张图是不是 AI 生成」第二阶段用清洗后的数据训六分类器。我一般会先跑一个轻量级的 CNN 或者甚至用 CLIP 的 zero-shot 能力做初步筛查但注意赛题规则明确禁止使用预训练模型权重所以 CLIP 这条路走不通。那就老老实实从零训一个小模型。2.2 二分类清洗模型的实现下面这段代码是一个最小可用的二分类清洗网络输入 512×512 的图输出二分类 logits。我把它写成可复现的 PyTorch 版本你可以直接抄。import torch import torch.nn as nn import torchvision.transforms as T from torch.utils.data import Dataset, DataLoader from PIL import Image import os class ArtworkDataset(Dataset): def __init__(self, img_dir, labels, transformNone): self.img_dir img_dir self.labels labels # list of (filename, label) self.transform transform def __len__(self): return len(self.labels) def __getitem__(self, idx): fname, label self.labels[idx] img Image.open(os.path.join(self.img_dir, fname)).convert(RGB) if self.transform: img self.transform(img) return img, label # 二分类清洗模型轻量 CNN从零训练 class CleanNet(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 32, 3, stride2, padding1), # 256 nn.BatchNorm2d(32), nn.ReLU(), nn.Conv2d(32, 64, 3, stride2, padding1), # 128 nn.BatchNorm2d(64), nn.ReLU(), nn.Conv2d(64, 128, 3, stride2, padding1),# 64 nn.BatchNorm2d(128), nn.ReLU(), nn.Conv2d(128, 256, 3, stride2, padding1),# 32 nn.BatchNorm2d(256), nn.ReLU(), nn.AdaptiveAvgPool2d(1) ) self.classifier nn.Linear(256, 2) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) return self.classifier(x) # 数据增强古画对颜色和纹理敏感别用太激进的裁剪 train_tf T.Compose([ T.Resize((256, 256)), # 降分辨率省显存512 太吃资源 T.RandomHorizontalFlip(), T.ColorJitter(brightness0.1, contrast0.1), T.ToTensor(), T.Normalize(mean[0.5]*3, std[0.5]*3) ])这段代码的关键参数说明stride2的四层卷积把 256×256 的输入一路降到 32×32再接全局平均池化参数量很小适合在单卡上快速迭代。T.Resize((256, 256))是我故意降的512 分辨率在清洗阶段没必要反而拖慢训练速度。ColorJitter的幅度压得很小因为古画的颜色本身就是朝代特征之一你把它抖太狠模型会分不清唐宋。训练循环里有一个细节因为只有一半 AI 图有明确标签你需要把「明确标为 AI」的图作为正样本「明确标为非 AI」的图作为负样本那些被随机打标的 AI 图暂时不参与二分类训练。等清洗模型训好后再对全量训练集做一次推理把预测为 AI 但标签不是 AI 的图挑出来人工确认或直接剔除。2.3 清洗后的数据再平衡清洗完你会发现一个更棘手的问题五个朝代的样本数可能本来就不均衡再加上你剔掉了一批被误标的 AI 图某些朝代的样本会更少。这时候别急着上 focal loss先看看混淆矩阵。赛题 TA 的建议很实在「Visualize the confusion matrix on the validation set」。我一般会先用sklearn.metrics.confusion_matrix画一张热力图看看模型到底把唐认成了宋还是明。如果是相邻朝代混淆那可能是特征不够细如果是某个朝代整体 F1 低那大概率是样本太少。针对长尾问题Stake Sharing 指标其实给了你一个信号那些只有少数人预测正确的困难样本如果你能预测对收益会放大。所以与其在头部朝代上刷准确率不如专门对尾部朝代做 oversampling 或者用 class-weighted loss。常见做法是给每个类别的损失乘一个权重权重和类别频率成反比但别乘太狠否则模型会对尾部过拟合。3. 六分类模型选型从零训练一个不靠预训练权重的 CNN3.1 为什么不能用预训练模型赛题规则写得很清楚「Using pre-trained model weights in any part of your code」直接算作弊全部零分。这意味着 ImageNet 上那些 ResNet、EfficientNet 的权重你都不能碰。你只能从随机初始化开始训。这对模型容量和训练策略提出了更高要求——没有预训练你就得靠数据增强、正则化和足够长的训练来弥补。我一般会选一个中等深度的 CNN比如类似 ResNet-18 的结构但把残差块改窄一点或者直接用 torchvision 里的resnet18(weightsNone)。注意weightsNone这个参数必须显式写否则默认会加载预训练权重那就翻车了。3.2 从零训练六分类模型的完整流程import torch import torch.nn as nn import torch.optim as optim from torchvision.models import resnet18 from sklearn.metrics import f1_score import numpy as np # 关键weightsNone绝不加载预训练权重 model resnet18(weightsNone, num_classes6) model model.cuda() # 类别权重根据清洗后的训练集频率计算 class_counts np.array([600, 580, 520, 610, 590, 450]) # 示例按实际数据改 class_weights 1.0 / class_counts class_weights class_weights / class_weights.sum() * 6 # 归一化到均值为1 criterion nn.CrossEntropyLoss(weighttorch.FloatTensor(class_weights).cuda()) optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) # 训练循环 for epoch in range(50): model.train() for imgs, labels in train_loader: imgs, labels imgs.cuda(), labels.cuda() optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, labels) loss.backward() optimizer.step() scheduler.step() # 每 5 个 epoch 在验证集上算一次 F1 if epoch % 5 0: model.eval() all_preds, all_labels [], [] with torch.no_grad(): for imgs, labels in val_loader: imgs imgs.cuda() preds model(imgs).argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) non_ai_f1 f1_score(all_labels, all_preds, labels[0,1,2,3,4], averagemacro) ai_f1 f1_score(all_labels, all_preds, labels[5], averagemacro) print(fEpoch {epoch}: Non-AI F1{non_ai_f1:.4f}, AI F1{ai_f1:.4f})这段代码里有几个参数值得展开说。class_weights的计算方式我用了倒数归一化目的是让每个类别的总损失贡献大致相等但又不至于让尾部类别的权重爆炸。AdamW的weight_decay1e-4是从零训练时防过拟合的关键因为没有预训练权重的模型更容易记住训练集。CosineAnnealingLR的T_max50和总 epoch 数对齐让学习率平滑降到接近零。验证集上我特意分开算 Non-AI F1 和 AI F1因为这两个指标在评估里各占 30%你必须同时盯着。如果 Non-AI F1 高但 AI F1 低说明清洗阶段漏掉了假画反过来如果 AI F1 高但 Non-AI F1 低说明模型可能把太多真实朝代图判成了 AI。3.3 数据增强策略的边界古画分类和自然图像分类有个本质区别你不能随便旋转、裁剪或翻转。一幅宋代山水画的构图本身就是朝代特征你把它上下翻转可能就变成了「不像任何朝代」的怪图。我一般只用三种增强小幅度随机水平翻转概率 0.3、轻微颜色抖动brightness 和 contrast 各 0.1、以及随机擦除小块区域RandomErasing概率 0.2。随机擦除是为了模拟古画上的破损和污渍这个在真实扫描图里很常见。千万别用 RandAugment 或 AutoAugment 那种激进策略它们是为自然图像设计的用在古画上会把朝代特征抹掉。血泪经验我曾经在一个类似任务上用了 RandAugment结果验证集准确率比不用还低 8 个百分点。4. 评估指标拆解Stake Sharing 到底怎么算4.1 四个指标的权重与计算逻辑赛题的评估指标是一个加权和我把它拆成表格更清楚指标权重计算方式优化方向Overall Accuracy30%所有类别所有样本的准确率整体分类正确Non-AI Class F130%五个真实朝代的 macro F1每个朝代都要好AI Class F130%AI 生成图的 F1清洗要干净Stake Sharing10%困难样本的动态分配长尾样本预测对Stake Sharing 的机制是这样的对于一张测试图如果只有 3 个人预测正确那么这 3 个人平分这 1 个 stake每人拿 1/3。最后把所有测试图上的 stake 累加再缩放到总分。注意超过 10% 的部分会被截断所以这个指标你最多拿 10 分但如果你在困难样本上表现好它能帮你拉开差距。4.2 如何针对 Stake Sharing 优化这个指标的本质是奖励「在别人都做不对的地方做对」。所以你不能只盯着整体准确率还要关注那些模型置信度低或者多个模型预测不一致的样本。我一般会做两件事第一用 ensemble 的预测分歧度来筛选困难样本分歧大的样本重点分析第二对尾部朝代做专门的数据增强比如对明、清这些样本可能较少的类别做 oversampling。但注意Stake Sharing 只占 10%而且有截断所以别为了它牺牲前三个指标。常见做法是先把前三个指标刷到 0.8 以上再回头调 Stake Sharing。5. 避坑与排查从提交格式到复现性5.1 提交 CSV 的格式坑赛题要求提交 CSV包含测试集的预测类别标签。demo 代码里给了导出格式但每年都有人在这里翻车。最常见的错误是CSV 里多了一列 index或者标签写成了字符串「Tang」而不是数字 0。Kaggle 的评分脚本只认数字标签你写「Tang」它直接报错。另一个坑是行数不对——测试集 400 张图你的 CSV 必须正好 400 行加表头多一行少一行都会失败。5.2 复现性检查清单赛题明确要求「Always make sure the results are reproducible」而且提交的代码和模型权重必须能在测试集上复现 Kaggle 的预测。我一般会在提交前跑一遍这个清单随机种子是否固定torch.manual_seed(42)、np.random.seed(42)、random.seed(42)三件套一个不能少。数据加载器的shuffle是否在验证和测试时关了训练时可以开但推理时必须关。模型权重是否保存了torch.save(model.state_dict(), model.pth)别忘了。日志是否保留了如果你提交的是 IPython notebook赛题要求保留日志输出。5.3 常见翻车记录现象验证集 F1 很高但 Kaggle 分数很低。原因验证集是从训练集里随机切的但训练集里的 AI 噪声标签分布和测试集不一样。你的模型在验证集上可能过拟合了那些噪声标签。 解决用清洗后的数据重新切验证集确保验证集里没有噪声标签。现象AI 类 F1 始终上不去。原因二分类清洗模型把太多真实朝代图误判成了 AI导致六分类模型看到的 AI 样本不纯。 解决降低清洗模型的分类阈值宁可漏掉一些 AI 图也别把真实图误删。我一般会把阈值从 0.5 降到 0.3。现象训练 loss 震荡严重。原因从零训练时学习率设太大了或者 batch size 太小。 解决把学习率降到 1e-4batch size 至少 32。如果显存不够用梯度累积。现象提交后 Kaggle 报「submission format error」。原因CSV 的列名不对或者标签列有缺失值。 解决用 pandas 读一遍你的 CSV检查df.isnull().sum()和df.shape。现象代码在本地能跑在 Brightspace 上复现不了。原因依赖库版本不一致或者用了本地路径。 解决把依赖写进requirements.txt路径全部用相对路径。6. 进阶技巧用伪标签和一致性正则再榨几个点如果你已经把基础流程跑通前三个指标都到了 0.85 左右想再往上走可以试试伪标签pseudo-labeling加一致性正则。思路很简单先用清洗后的数据训一个六分类模型然后在测试集上推理把高置信度的预测当作伪标签和训练集混在一起再训一轮。但注意赛题禁止「Labeling any split of the dataset manually」伪标签是模型自动生成的不违反规则。具体操作对测试集每张图如果模型预测的最大 softmax 概率大于 0.95就把这张图和预测标签加入训练集。第二轮训练时对同一张图做两次不同的数据增强要求两次预测一致这就是一致性正则。我一般会用KLDivLoss来约束两次预测的分布。# 伪标签 一致性正则的简化实现 model.eval() pseudo_data [] with torch.no_grad(): for imgs, _ in test_loader: imgs imgs.cuda() logits model(imgs) probs torch.softmax(logits, dim1) max_probs, preds probs.max(dim1) for i in range(imgs.size(0)): if max_probs[i] 0.95: pseudo_data.append((imgs[i].cpu(), preds[i].cpu())) # 第二轮训练时对伪标签样本加一致性损失 for imgs, labels in combined_loader: imgs, labels imgs.cuda(), labels.cuda() # 两次不同增强 imgs_aug1 augment(imgs) imgs_aug2 augment(imgs) logits1 model(imgs_aug1) logits2 model(imgs_aug2) ce_loss criterion(logits1, labels) # 一致性两次预测的 KL 散度 kl_loss nn.KLDivLoss(reductionbatchmean)( torch.log_softmax(logits1, dim1), torch.softmax(logits2, dim1) ) loss ce_loss 0.5 * kl_loss loss.backward() optimizer.step()这里的0.5是一致性损失的权重我一般从 0.1 开始试太大反而会拖累分类性能。伪标签的阈值 0.95 也不是固定的你可以根据测试集大小调整——测试集只有 400 张阈值可以设高一点保证伪标签质量。还有一个技巧是模型集成。从零训练多个不同初始化的模型推理时取平均 logits。集成能稳定提升 1-2 个点而且对 Stake Sharing 也有帮助因为集成后的预测在困难样本上更鲁棒。但注意别集成太多3 个就够了再多训练成本吃不消。从那以后我每次做带噪声标签的分类任务都会强制先跑一遍数据清洗的 baseline再对比直接训练的版本把两个版本的混淆矩阵并排看。这个习惯帮我省了很多「以为模型不行、其实是数据不行」的冤枉路。希望帮到你。本文还有配套的精品资源点击获取
返回列表