
简介这份文档面向具备机器学习与计算机视觉基础的学生和研究者围绕2024年秋季机器学习期末竞赛讲解如何用图像分类技术预测中国古代书法与绘画作品的朝代。赛题数据集含3600张512×512图像覆盖唐、宋、元、明、清及AI生成六类其中900张为AI生成图且训练集标签存在噪声与类别不均衡文档据此强调数据清洗与预处理的关键作用。资源包共1个docx文件约554KB内容涵盖任务定义、评估指标、提交规则与时间节点等完整赛题说明。评估采用总体准确率、非AI类别F1、AI类别F1与权益共享评分的加权组合并鼓励解决长尾难题。读者可借此掌握文物鉴定场景下的建模流程、噪声标签处理思路与竞赛排名策略提升实际项目操作能力。目前已有67人学习。1. 从一堆瓶瓶罐罐里认出朝代图像识别做古代艺术品断代到底能不能落地你手里有一批博物馆公开的陶瓷、青铜、书画照片标签是朝代——唐、宋、元、明、清。现在要训练一个模型给它一张没见过的器物图让它输出朝代。这件事听起来像典型的图像分类但真正动手你会发现它和猫狗分类完全不是一个难度类间差异可能只是釉色深浅、器型弧度、纹饰密度而类内差异却大到离谱——同样是宋代汝窑和钧窑的颜色分布几乎不在一个色域。更麻烦的是数据量。公开可用的、带可靠朝代标签的古代艺术品图像清洗完之后往往只有几千张分到五个朝代每类几百张这对深度网络来说属于小样本场景。所以这篇文章不聊虚的就围绕「基于图像识别的中国古代艺术品朝代预测」这条线把数据怎么整、模型怎么选、参数怎么调、坑在哪一步步拆开讲清楚。适合有 Python 基础、做过简单图像分类、想把这个方向当课程设计或比赛题目的读者。2. 数据先行古代艺术品图像的采集、清洗与标签体系2.1 为什么这个任务的数据比模型更决定上限图像识别项目里数据质量决定天花板模型只是逼近这个天花板。朝代预测尤其如此因为标签本身带有模糊性。一件器物可能横跨两个朝代比如五代十国到北宋早期窑口延续、风格渐变你让标注者硬选一个不同人给出的答案可能不一致。常见做法是优先选博物馆官方断代明确的藏品官方写「北宋」就标北宋写「宋」就归到宋不要自己二次推断。另一个现实问题是类别不平衡。唐代瓷器存世量相对少清代离得近、存世量大直接按原始分布训练模型会偏向预测清代。我一般会在划分数据集之前先统计每类数量如果最大类与最小类比例超过 3:1就考虑对少数类做定向增强或者用加权采样。这里要提醒一句不要用随机过采样复制图片那只会让模型记住同一张图验证集指标虚高测试集直接翻车。2.2 从原始图片到可训练数据集目录结构与划分脚本假设你已经从公开渠道收集了一批图片按朝代放在不同文件夹里。下一步是统一尺寸、去除损坏文件、按类别分层划分训练集和验证集。下面这段脚本做三件事遍历每个朝代文件夹、校验图片可读性、按 8:2 分层切分并复制到目标目录。import os import shutil import random from PIL import Image # 原始数据目录每个子文件夹是一个朝代 RAW_DIR raw_data # 输出目录 OUT_DIR dataset TRAIN_RATIO 0.8 random.seed(42) dynasties [d for d in os.listdir(RAW_DIR) if os.path.isdir(os.path.join(RAW_DIR, d))] for dynasty in dynasties: src_dir os.path.join(RAW_DIR, dynasty) images [] for fname in os.listdir(src_dir): fpath os.path.join(src_dir, fname) try: # 校验图片完整性损坏文件直接跳过 with Image.open(fpath) as img: img.verify() images.append(fname) except Exception: print(f损坏文件跳过: {fpath}) random.shuffle(images) split int(len(images) * TRAIN_RATIO) train_imgs images[:split] val_imgs images[split:] for subset, files in [(train, train_imgs), (val, val_imgs)]: dst_dir os.path.join(OUT_DIR, subset, dynasty) os.makedirs(dst_dir, exist_okTrue) for fname in files: shutil.copy(os.path.join(src_dir, fname), os.path.join(dst_dir, fname)) print(f{dynasty}: 训练 {len(train_imgs)} 张, 验证 {len(val_imgs)} 张)这段脚本的关键点有三个。第一Image.verify()能提前筛掉下载不完整或格式损坏的图片避免训练时 DataLoader 直接报错中断。第二random.seed(42)保证每次划分结果一致方便复现实验。第三按类别分别划分而不是全量混洗能保证每个朝代在训练集和验证集中的比例一致这对小样本分类很重要。参数方面TRAIN_RATIO设为 0.8 是常规起点如果某类总数少于 200 张建议改成 0.7给验证集留出足够样本否则验证指标波动会很大。2.3 标签体系与类别映射别让「宋」和「南宋」打架朝代标签的粒度需要提前定死。常见做法是粗分到「唐、宋、元、明、清」五类把南宋北宋合并为宋把五代十国归入唐或宋的过渡期具体归哪边取决于你的数据构成。如果你收集到的数据里南宋北宋都有且数量不少也可以分成六类或七类但类别越多每类样本越少模型越难学。我一般会先做一个类别分布表看看每个粒度的样本量是否支撑训练。映射关系建议写成一个 JSON 文件训练和推理共用同一份避免训练时用一套标签、推理时用另一套导致输出对不上。import json label_map { tang: 0, song: 1, yuan: 2, ming: 3, qing: 4 } with open(label_map.json, w, encodingutf-8) as f: json.dump(label_map, f, ensure_asciiFalse, indent2) # 推理时加载 with open(label_map.json, r, encodingutf-8) as f: loaded json.load(f) idx_to_label {v: k for k, v in loaded.items()} print(idx_to_label)这份映射文件看起来简单但它是训练和部署之间的契约。曾经有一次我在训练脚本里按文件夹名称字母序生成标签推理脚本里按手写字典映射结果「明」和「清」的顺序反了离线指标正常上线全错。血泪经验就是标签映射只维护一份所有环节都读它。3. 模型选型与训练从 ResNet 到 EfficientNet 的朝代分类实战3.1 小样本图像分类为什么我优先选预训练模型微调从零训练一个卷积网络需要大量数据而古代艺术品朝代预测的数据量通常撑不起来。预训练模型在 ImageNet 上已经学到了边缘、纹理、颜色分布等底层特征这些特征对器物图像同样有效。常见做法是加载在 ImageNet 上预训练的 ResNet50 或 EfficientNet-B0替换最后的全连接层输出改为你的朝代类别数然后分阶段微调。第一阶段冻结主干只训练分类头学习率设 1e-3第二阶段解冻全部层用更小的学习率 1e-4 到 1e-5 做全局微调。这样做的原因是随机初始化的分类头一开始梯度很大如果直接全局微调会把预训练学到的特征打乱。先让分类头收敛再小学习率调整主干是更稳的路径。EfficientNet-B0 参数量比 ResNet50 小很多在数据量几千张的场景下过拟合风险更低推理速度也更快适合课程设计或比赛提交。3.2 用 PyTorch 搭一个可复现的训练流程下面这段代码用 torchvision 加载预训练 EfficientNet-B0替换分类头定义数据增强和训练循环。数据增强只对训练集做验证集只做 resize 和归一化。import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms, models # 数据增强训练集用随机裁剪和翻转验证集只做确定性变换 train_tf transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_ds datasets.ImageFolder(dataset/train, transformtrain_tf) val_ds datasets.ImageFolder(dataset/val, transformval_tf) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_ds, batch_size32, shuffleFalse, num_workers4) num_classes len(train_ds.classes) model models.efficientnet_b0(weightsmodels.EfficientNet_B0_Weights.IMAGENET1K_V1) # 替换分类头 model.classifier[1] nn.Linear(model.classifier[1].in_features, num_classes) model model.cuda() criterion nn.CrossEntropyLoss() # 第一阶段只训练分类头 for param in model.features.parameters(): param.requires_grad False optimizer torch.optim.Adam(model.classifier.parameters(), lr1e-3) for epoch in range(5): model.train() for imgs, labels in train_loader: imgs, labels imgs.cuda(), labels.cuda() optimizer.zero_grad() loss criterion(model(imgs), labels) loss.backward() optimizer.step() print(f阶段一 epoch {epoch1} 完成) # 第二阶段全局微调 for param in model.parameters(): param.requires_grad True optimizer torch.optim.Adam(model.parameters(), lr1e-4) for epoch in range(20): model.train() for imgs, labels in train_loader: imgs, labels imgs.cuda(), labels.cuda() optimizer.zero_grad() loss criterion(model(imgs), labels) loss.backward() optimizer.step() # 验证 model.eval() correct, total 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels imgs.cuda(), labels.cuda() preds model(imgs).argmax(dim1) correct (preds labels).sum().item() total labels.size(0) print(f阶段二 epoch {epoch1}, 验证准确率 {correct/total:.4f})逻辑说明第一阶段冻结model.features只更新model.classifier让随机初始化的分类头先找到合理方向。第二阶段解冻全部参数学习率降到 1e-4避免破坏预训练特征。RandomResizedCrop的scale(0.7, 1.0)表示随机裁剪原图 70% 到 100% 的区域再缩放到 224这个范围对器物图像比较合适裁太狠会丢掉器型整体信息。ColorJitter的强度设 0.2 是保守值因为朝代判断有时依赖釉色颜色扰动太强会引入噪声。batch_size32在显存 8GB 以上基本够用如果显存不足降到 16同时把学习率按比例降到 5e-5。验证准确率每轮打印如果连续 5 轮不升可以考虑提前停止。3.3 学习率、批大小与早停三个最容易被忽视的参数学习率是训练中最玄学的参数。阶段一用 1e-3 是因为只训练分类头参数量少大一点收敛快。阶段二用 1e-4 是全局微调的安全值如果你发现验证 loss 震荡剧烈降到 5e-5。批大小影响梯度估计的稳定性小批量噪声大但泛化可能更好大批量训练快但容易陷入尖锐极小值。我一般从 32 开始试如果验证指标波动超过 3 个百分点降到 16。早停策略建议监控验证集准确率连续 7 轮不提升就停同时保存验证集最优的模型权重而不是最后一轮的权重。很多人忽略这一点训练完直接用最后一个 epoch 的模型结果比最优轮次差好几个点。另外类别不平衡时可以在CrossEntropyLoss里传weight参数按类别样本数的倒数加权让少数类样本的损失贡献更大。4. 避坑与排查朝代预测项目里最容易翻车的五个地方4.1 验证集准确率很高测试集一塌糊涂现象训练时验证集准确率到 85%提交测试集只有 50% 多。原因通常有两个一是数据泄漏同一件器物的多角度照片被分到了训练集和验证集模型记住了这件器物而不是朝代特征二是验证集和测试集分布不一致比如验证集里清代样本多测试集里唐代样本多。解决方法是按器物 ID 划分数据集同一件器物的所有照片只出现在一个子集里。如果原始数据没有器物 ID可以用图片感知哈希做去重把相似度极高的图片归为一组再划分。4.2 模型把所有样本都预测成数量最多的那个朝代现象混淆矩阵显示清代召回率接近 1其他朝代接近 0。原因就是类别不平衡模型发现全猜清代也能拿到不低的准确率。解决方法是加权损失或重采样。加权损失更简单在CrossEntropyLoss里传入weighttorch.tensor([1.0, 1.0, 1.5, 1.2, 0.8])这样的权重具体数值按类别频率的倒数调整。重采样可以用WeightedRandomSampler让每个 batch 里各类别比例接近均衡。注意不要对验证集做重采样验证集要保持真实分布才能反映实际性能。4.3 图片背景干扰导致模型学偏现象模型在博物馆展柜照片上表现好在去掉背景的器物图上表现差。原因是训练数据里很多图片背景是展柜、标签、灯光模型把背景特征当成了朝代线索。解决方法是做前景分割或背景替换。简单做法是用 GrabCut 或基于颜色的分割把器物区域抠出来贴到纯色背景上再训练。更彻底的做法是收集同一器物在不同背景下的照片让模型无法依赖背景。如果时间有限至少要在数据增强里加入随机背景替换强迫模型关注器物本身。4.4 训练 loss 不下降或者变成 NaN现象训练几个 batch 后 loss 变成 NaN。常见原因是学习率太大、数据归一化参数不对、或者输入图片有损坏导致异常值。先检查归一化ImageNet 的 mean 和 std 是固定值如果你自己算了一组但算错了输入分布偏移会导致梯度爆炸。再检查学习率阶段二如果误用了 1e-3很容易 NaN。解决方法是加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)同时把学习率降一个数量级。另外num_workers设太大在某些系统上会导致数据读取异常可以先设 0 排查是否是数据加载问题。4.5 推理时预处理和训练时不一致现象离线验证准确率 80%部署后推理结果乱七八糟。原因往往是推理时的 resize、归一化、通道顺序和训练时不一致。比如训练用了 RGB推理时 OpenCV 默认读进来是 BGR训练用了Resize((224,224))推理时用了Resize(256)再中心裁剪。解决方法是把预处理写成一个函数训练和推理共用。下面是一个推理预处理的示例确保和训练验证集变换完全一致。from PIL import Image import torch from torchvision import transforms infer_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def predict(image_path, model, idx_to_label): img Image.open(image_path).convert(RGB) # 强制 RGB避免 BGR 问题 tensor infer_tf(img).unsqueeze(0).cuda() model.eval() with torch.no_grad(): logits model(tensor) pred logits.argmax(dim1).item() return idx_to_label[pred]这段代码里convert(RGB)是关键不管原图是什么模式统一转成 RGB和训练时 ImageFolder 读进来的格式对齐。unsqueeze(0)增加 batch 维度。推理时不要做随机增强只用确定性的 resize 和归一化。5. 把准确率再往上推一推两个我常用的进阶技巧第一个技巧是测试时增强。训练完之后对同一张测试图片做多次不同变换——比如原始尺寸、水平翻转、轻微裁剪——分别推理然后把各类别概率平均取最大概率对应的朝代。这个方法几乎不增加训练成本但在我做过的几个器物分类任务里稳定带来 2 到 4 个百分点的提升。代码实现很简单定义一个变换列表循环推理累加 softmax 输出。import torch.nn.functional as F tta_transforms [ transforms.Compose([transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225])]), transforms.Compose([transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p1.0), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225])]), transforms.Compose([transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225])]), ] def predict_tta(image_path, model, idx_to_label): img Image.open(image_path).convert(RGB) probs torch.zeros(1, len(idx_to_label)).cuda() model.eval() with torch.no_grad(): for tf in tta_transforms: tensor tf(img).unsqueeze(0).cuda() probs F.softmax(model(tensor), dim1) pred probs.argmax(dim1).item() return idx_to_label[pred]第二个技巧是混淆类别定向补数据。训练完第一版模型后看混淆矩阵找出最容易混的两个朝代比如宋和元。然后针对这两个类别定向收集更多区分性样本或者请标注者重新检查这两类的标签是否有误。很多时候不是模型不行而是宋元之间的标签本身就有争议。我一般会把这个步骤放在第一版模型跑完之后用数据去补模型的短板而不是盲目换更大的网络。换网络带来的提升往往不如补几百张高质量样本。最后说一个我自己的习惯每次实验都记录数据版本、模型权重、学习率、批大小和验证集指标写在一个 CSV 里。朝代预测这类任务随机种子、数据划分、增强强度都会影响结果没有记录就无法复现也无法判断到底是模型改进了还是运气好。希望帮到你。本文还有配套的精品资源点击获取