☰
ImageNet devkit标签映射与Top-1/Top-5评测口径
2026/10/26 4:33:03 网站建设 项目流程

简介:这份ILSVRC2012_devkit_t12开发工具包面向计算机视觉与深度学习研究者、ImageNet挑战赛参赛团队,以及希望复现2012年图像分类评测流程的学习者。它围绕ImageNet 2012的类别体系、数据划分和官方评价标准,提供理解赛题与本地评估所需的基础设施,适合具备一定模型训练经验、需要核对提交结果或搭建验证流程的中高级读者。压缩包共15个文件,以9个m脚本为主,辅以4个txt说明、1个mat元数据文件和1个copying许可文件,整体约2.45MB,体积轻巧但结构完整。资源内含类别与验证集真值信息、Top-1/Top-5错误率计算逻辑、预测结果示例、XML解析与哈希校验等工具,可用于本地复现官方评测、检查预测文件格式、分析模型误判并排查提交环节的问题。目前已有3054人学习,适合作为ImageNet2012分类实验的评测基准与赛题入口资料。

1. 这个 20MB 的压缩包不含一张图:它凭什么决定你的 ImageNet 评测可信

ILSVRC2012_devkit_t12.tar.gz解压出来只有十几兆,里面没有一张 JPEG,全是.mat、.txt和一堆 MATLAB 脚本。可偏偏就是这包东西,决定了你报出来的 ImageNet Top-1 到底算不算数。它管三件事:1000 个类的官方定义(ILSVRC_ID、WNID、可读类名)、50000 张验证集的正确答案,以及官方那套 Top-1/Top-5 的计数口径。换句话说,图像数据本身可以来自任何镜像,但“这张图到底是第几类”的解释权,只在 devkit 里。

刚接触 ImageNet2012 devkit 的人最容易犯的错,是把训练集文件夹名当成标签直接排序用,然后拿验证集跑出一个漂亮数字,却完全不知道自己和别人的 76% 是不是同一个 76%。如果你正在复现分类模型、做迁移学习、或者要给自家模型报一个能被人相信的指标,这份压缩包是必须落地的一环。下面按我实际拆包、转换、跑评测的顺序写,含代码和踩过的坑。

2. 拆包与三件套盘点:meta.mat、ground truth 和 evaluation 目录各管什么

2.1 解包后的目录结构与各文件职责

先把包解开,看看它到底给了什么。不同时间段下载的包内部文件数量可能有细微差别,但主干结构是稳定的,data/和evaluation/两个目录一定在。

ILSVRC2012_devkit_t12/ ├── readme.txt # 任务说明与提交格式 ├── COPYING ├── data/ │ ├── meta.mat # 1000 类的结构体数组,一切映射的源头 │ ├── ILSVRC2012_validation_ground_truth.txt # 50000 行整数答案 │ └── ILSVRC2012_validation_bbox.txt # 部分版本附带,分类任务用不上 └── evaluation/ ├── ILSVRC2012_validation_ground_truth.txt # 评测脚本读的那份副本 └── V1/ V2/ ... # 官方参考实现

各文件的分工值得单独说清楚,因为新手最常见的误解就是“以为 devkit 里有图片”:

文件内容落地时的用途
data/meta.mat1×1000 的 MATLAB struct 数组生成类名表、WNID 映射、每类训练图数量
data/ILSVRC2012_validation_ground_truth.txt50000 行,每行 1 个 1~1000 的整数验证集标签,行序等于文件名序
evaluation/官方评测参考实现,早期版本以 MATLAB 为主定义 Top-1/Top-5 的统计口径、提交文件格式

meta.mat是整个包的核心。它里面的synsets每一项带这些字段:ILSVRC_ID(1~1000)、WNID(形如n01440764的 WordNet 编号)、words(逗号分隔的同义词串)、gloss(英文释义)、num_children、children、wordnet_height、num_train_images。做分类只需要前四个加最后一个,做层次化分析或层级损失时才用得上children和wordnet_height。

evaluation/里那套代码我很少直接跑——MATLAB 运行时太重,而且它主要价值是定义规则而不是替你算。它做的事情很朴素:把提交文件解析成“图片 ID → 5 个类别 ID(按置信度降序)”,再和 ground truth 逐行比,统计 Top-1 和 Top-5 错误率。规则吃透了,用 Python 三十行就能复刻,还方便塞进训练循环里做在线验证。

提示:data/和evaluation/下各有一份 ground truth,内容应当一致。如果你只保留一份,保留data/里的,评测代码里显式指向它,别让脚本自己去猜路径。

2.2 用 scipy 打开 meta.mat:把 MATLAB 结构体摊平成字典

.mat是这一整套流程里第一个真正的门槛。scipy.io.loadmat默认会把每个字段再包一层(1,1)的 ndarray,取出来的WNID不是字符串,而是array([array(['n01440764'], dtype='<U9')], dtype=object)这种东西。拿它去和文件夹名做==比较,结果永远是False,而且不报错——这就是最典型的静默翻车。

我的做法是写一个递归剥壳函数,把任意深度的单元素数组压成标量或普通 list:

import json from pathlib import Path import numpy as np import scipy.io as sio DEVKIT = Path("ILSVRC2012_devkit_t12") META = DEVKIT / "data" / "meta.mat" def scalar(x): """把 scipy 读 MATLAB struct 产生的嵌套数组逐层剥成 Python 原生类型""" if isinstance(x, np.ndarray): flat = x.reshape(-1) if flat.size == 1: return scalar(flat[0]) # 单元素:继续往里剥 return [scalar(i) for i in flat] # 多元素(如 children):转 list if isinstance(x, np.str_): return str(x).strip() if isinstance(x, (np.integer, np.floating)): return x.item() return x # struct_as_record=False 让每个结构体变成带属性的对象,比按字段名索引好写 meta = sio.loadmat(META, struct_as_record=False, squeeze_me=False) synsets = meta["synsets"].reshape(-1) print(len(synsets)) # 1000 print(scalar(synsets[0].WNID)) # 'n01440764' print(scalar(synsets[0].ILSVRC_ID)) # 1 print(scalar(synsets[0].words)) # 'tench, Tinca tinca'

struct_as_record=False这个参数是关键:设成True(默认)时你得写meta['synsets'][0][i]['WNID'],索引层级多、还容易写错;设成False后可以直接点属性。squeeze_me建议保持False,让形状稳定,剥壳交给scalar统一处理,避免“有时候是标量有时候是数组”的玄学。

验证这一步是否成功,只看一个信号:scalar(synsets[0].WNID)打印出来应该是干净的n01440764,前后没有array(、没有中括号、没有多余空格。如果打出来还带壳,后面所有映射都会错,别急着往下走。

2.3 三行断言先做完整性自检

在写任何转换逻辑之前,先花两分钟把数据本身验一遍。血泪经验是:下载中断、解压截断、编辑器把 txt 末尾换行吃掉,这三种情况都会让 ground truth 变成 49999 行或者多出一行空行,而np.loadtxt有时会静默跳过,最后算出来的准确率差个零点几个百分点,你查一整天。

gt_path = DEVKIT / "data" / "ILSVRC2012_validation_ground_truth.txt" gt = np.loadtxt(gt_path, dtype=np.int64) assert gt.shape == (50000,), f"ground truth 行数不对: {gt.shape}" assert gt.min() >= 1 and gt.max() <= 1000, "存在越界标签,索引基准可能搞错了" assert len(np.unique(gt)) > 990, "类别覆盖异常,文件可能被截断"

三条断言分别锁死:样本数、标签取值范围、类别覆盖度。第三条用> 990而不是== 1000,因为 50000 张验证集按类均匀分布是每类 50 张,理论上 1000 类全覆盖,但不同版本的划分可能有极小偏差,留一点余量比硬卡更实用。

同时把meta.mat里num_train_images求和也顺手算一下,正常应在 128 万量级。这个数和 ground truth 行数一起,构成了“这份 devkit 是完整的”最小证据链。

3. 三套编号的换算:ILSVRC_ID、WNID 和 val 文件名怎么对上

3.1 ILSVRC_ID 排序与 WNID 字典序,什么时候会打架

同一张图在生态里有三套编号,混淆是准确率出问题的头号来源:

  • val 文件名:ILSVRC2012_val_00000001.JPEG到ILSVRC2012_val_00050000.JPEG,序号从 1 开始,逐张对应 ground truth 的第 1 行到第 50000 行。
  • ground truth 值:1~1000 的整数,指的是ILSVRC_ID,不是 0-based 索引,也不是 WNID。
  • 训练集文件夹名:直接就是 WNID,n01440764、n01443537这样。

三段编号要串起来,中间必须有一张显式映射表。这里有个流传很广的“简化”:直接sorted(os.listdir(train_dir))拿排序后的下标当标签。在 ILSVRC2012 这一版里,1000 个类恰好是按 WNID 字典序排列、ILSVRC_ID也是 1~1000 顺排,所以这么干“碰巧”对得上。但这是巧合,不是契约——历史版本的ILSVRC_ID和 WNID 顺序并不一致。更现实的风险是:os.listdir会把.DS_Store、__MACOSX、你自己加的readme.md一起排进去,下标整体偏移一位,然后你的模型在验证集上表现得像随机猜。

我的习惯是永远显式建映射,不靠隐式排序:

records = [] for i, s in enumerate(synsets): words = scalar(s.words) # "tench, Tinca tinca" records.append({ "index": i, # 0-based 下标,仅作内部使用 "ilsvrc_id": int(scalar(s.ILSVRC_ID)),# 1..1000,ground truth 用的就是它 "wnid": scalar(s.WNID), # 训练集文件夹名 "name": words.split(",")[0].strip(), # 展示用主类名 "words": words, "num_train_images": int(scalar(s.num_train_images)), }) records.sort(key=lambda r: r["ilsvrc_id"]) assert [r["ilsvrc_id"] for r in records] == list(range(1, 1001))

最后那行断言是后悔药:如果哪天换了 devkit 版本、或者ILSVRC_ID不是紧凑的 1~1000,这里会立刻炸出来,而不是等到训练完才发现标签错位。

3.2 生成 meta.json 和 wnid_to_index 两张表

排序完就可以落地成资产了。我一般存两份东西:一份给人看的meta.json(带类名和释义,方便写报告、做可视化),一份给代码用的字典(内存里现建,不落盘,避免和 json 不同步)。

out = DEVKIT / "meta.json" out.write_text( json.dumps(records, ensure_ascii=False, indent=1), encoding="utf-8", ) # 训练集:WNID -> 0-based 标签下标(DataLoader 要的格式) wnid_to_index = {r["wnid"]: r["index"] for r in records} # 反向:0-based 下标 -> 可读类名(打印预测结果用) index_to_name = {r["index"]: r["name"] for r in records} print(len(wnid_to_index), wnid_to_index["n01440764"]) # 1000 0

这里有个细节值得强调:index我取的是enumerate(synsets)的原始顺序,而records又按ilsvrc_id排了序。在 2012 这一版两者一致,所以index == ilsvrc_id - 1成立。但我仍然分开存两个字段,因为一旦某天处理的是别的年份的 devkit,两个值就会分叉,到时候改一行排序逻辑就行,不用动下游所有代码。

另外,wnid_to_index里必须只包含真正的类别目录。如果你直接对训练集根目录os.listdir,记得过滤掉非n开头的项:

train_root = Path("/data/imagenet/train") wnids = [d.name for d in train_root.iterdir() if d.is_dir() and d.name.startswith("n") and d.name in wnid_to_index] assert len(wnids) == 1000, f"训练集只找到 {len(wnids)} 个类目录"

3.3 把 50000 个 val 文件名对回 ground truth

验证集这一侧同样不能靠猜。ILSVRC2012_val_00000001.JPEG的序号就是行号,这是官方约定的顺序,但前提是你的文件名和官方一致、并且按数值而不是字符串排序。字符串排序在00000001到00050000这种等宽补零的命名下不会出错,但如果有人重命名过,就会在最难察觉的地方翻车。

import re val_root = Path("/data/imagenet/val") pat = re.compile(r"ILSVRC2012_val_(\d{8})\.JPEG$") pairs = [] for p in val_root.iterdir(): m = pat.match(p.name) if m is None: continue # 跳过非官方命名的杂项文件 row = int(m.group(1)) - 1 # 1-based 文件名 -> 0-based 行号 pairs.append((row, p, int(gt[row]) - 1)) # 第三个是 0-based 标签 pairs.sort(key=lambda x: x[0]) assert len(pairs) == 50000, f"val 只匹配到 {len(pairs)} 张" assert [x[0] for x in pairs] == list(range(50000)), "行号不连续,文件缺失"

注意int(gt[row]) - 1这一步,它把 1-based 的 ILSVRC_ID 转成了和wnid_to_index对齐的 0-based 下标。整个流程里只有这两处做转换(这里,以及构建训练集标签时),集中在一处比散落在训练脚本、评测脚本、可视化脚本里各转一遍安全得多。

注意:正则里的.JPEG是大写。ImageNet 验证集文件名的扩展名确实是大写JPEG,写成小写会导致匹配到 0 张、后面报ZeroDivisionError,而这个报错信息完全不会提示你“是扩展名问题”。

4. 跑一次 Top-1/Top-5:从 logits 到官方口径的错误率

4.1 读 ground truth 并确认索引基准

标签基准确认过一次就够了,但要在代码里留一道运行时检查。我见过不止一个项目,训练侧用 0-based 下标、验证侧忘了减 1,结果是模型正常训练、验证准确率恒定在 0.1% 左右,看起来像“模型没学到东西”,实际是标签整体错了一位。

评测函数的入参约定必须写死在函数签名旁边:targets是 0-based 类别下标,取值 0~999,与index_to_name的键一致。别在函数内部再做任何转换,转换只发生在数据加载阶段。这个约定写进注释比写进文档有用,因为改代码的人一定会看到注释。

4.2 评测循环与 top-k 计算

下面这段可以直接抄。核心只有两行:topk拿前 k 个类别下标,eq广播比较后按行取any。

import torch @torch.no_grad() def evaluate(model, loader, device="cuda", k=5): model.eval() correct1 = correctk = total = 0 for images, targets in loader: images = images.to(device, non_blocking=True) targets = targets.to(device, non_blocking=True) logits = model(images.float()) # (B, 1000) kk = min(k, logits.size(1)) idx = logits.topk(kk, dim=1).indices # (B, k),已按置信度降序 hit = idx.eq(targets.view(-1, 1)) # (B, k) 的布尔矩阵 correct1 += hit[:, 0].sum().item() # Top-1:第一个位置命中 correctk += hit.any(dim=1).sum().item() # Top-5:任意位置命中 total += targets.numel() return correct1 / total, correctk / total

几个参数值得调:non_blocking=True配合DataLoader(pin_memory=True)能把 H2D 拷贝藏进计算里;logits.float()是给混合精度推理留的余量,半精度下topk的排序值可能出现并列,float 化之后结果更稳;k=5让函数可复用于 Top-1/Top-5 双指标报告。

如果只有 numpy,np.argpartition(-logits, k-1, axis=1)[:, :k]也能拿到前 k,但注意argpartition不保证有序,只看any的话没问题,要想拿“第一个命中位置”就得再sort一次。用 torch 的topk少这一层心智负担。

4.3 口径对齐:center crop、10-crop 与 batch size 的影响

同一个模型,同一份 devkit,报出来的 Top-1 能差出好几个点,绝大部分差异不在代码而在预处理。要和人对比,先对齐三件事:

维度常见两种做法差异来源
resize + crop短边缩到 256,中心裁 224缩到 232 再裁、或直接 resize 到 224,结果都不同
crop 数单中心裁 vs 10-crop 平均10-crop 通常略高,但推理成本翻十倍
归一化ImageNet mean/std vs 0.5/0.5用错均值整条曲线平移
验证频率每 epoch 全量 vs 每 N 步抽样抽样评估方差大,容易误判收敛点

我的建议是:先用单中心裁把流程跑通,确认数字落在合理区间(现代 backbone 在 50000 张 val 上 Top-1 应该远高于 50%,若在 0.1% 附近基本可断定是标签问题),再决定要不要上 10-crop。别一上来就 10-crop,那样即使标签错了,数字也会被“平均”得没那么难看,反而掩盖问题。

还有个容易忽略的点:官方报的通常是错误率(error rate),不是准确率。15.3% 的 Top-5 error 对应 84.7% 的 Top-5 accuracy。写报告时把单位标清楚,top5_error = 1 - top5_acc,别两套数字混着放同一张表里。

5. 避坑与排查:五个让准确率凭空空降的细节

5.1 全错位:ground truth 的 1-based 索引被当成 0-based

现象:训练 loss 正常下降,验证 Top-1 稳定在 0.1% 上下,Top-5 约 0.5%,看起来像“模型完全没学到”。

原因:gt里的值是 1~1000,直接拿去和 0~999 的预测下标比较,除了极少数巧合,全部错位。更隐蔽的版本是:训练集标签用wnid_to_index得到了 0-based,验证集却直接用了原始gt,两套基准混在同一个脚本里。

解决:在所有读 ground truth 的地方统一减 1,并在评测函数入口加一条assert targets.max() < 1000 and targets.min() >= 0。另外在流程早期做一次“作弊测试”:把 ground truth 直接当预测结果喂进评测函数,如果 Top-1 不是 100%,说明映射链上一定有问题,先修这个再看模型。

5.2 全丢失:glob 匹配不到 .JPEG,评测集变成 0 张

现象:np.mean([])返回nan,或者除零报ZeroDivisionError;有时DataLoader长度是 0,循环体一次都不进,函数返回0/0。

原因:验证集扩展名是大写.JPEG,glob("*.jpg")或glob("*.jpeg")一张都匹配不到。也有人写成*.JPEG但目录层级多了一层,同样是空集合。

解决:用正则精确匹配并assert数量等于 50000。空集合必须在加载阶段就炸,不能让它一路漂到指标计算处——那时错误信息已经离现场很远了。同理,ImageFolder遇到空目录不报错,是自己实现Dataset时容易踩的坑。

5.3 全字符串化:scipy 读出的 WNID 带数组壳,比较永远为 False

现象:wnid_to_index建出来长度是 0,或者key in dict永远返回False,但print出来看着“就是那个字符串”。

原因:scalar没剥干净,拿到的是array(['n01440764'], dtype='<U9'),repr里能看出中括号,但打印时和字符串长得几乎一样。用它做字典键,得到的是不可哈希报错;用它做比较,得到的是静默的False。

解决:所有从.mat出来的字段一律过一遍剥壳函数,并且在建表后加断言assert len(wnid_to_index) == 1000和assert "n01440764" in wnid_to_index。这两条能拦住 99% 的类型问题。

5.4 虚高:val 参与训练或调参,报出来的数字自己都不信

现象:模型在自己的验证集上比公开结果高出一两个点,换一批数据就掉回去。

原因:拿 50000 张 val 做了微调、或者反复在 val 上挑超参再报 val 指标。这份 devkit 只有 val 标签,没有 test 标签(test 走官方服务器),所以 val 一旦被污染,就没有干净的留出集了。

解决:从训练集里切一部分做内部验证,val 只在最后跑一次。如果必须多次看 val,就明确标注“该数字为验证集上调参后的结果”,别和单次评估的数字放一起比。这个习惯比多刷零点几个点重要得多。

5.5 口径漂移:换了 crop 方式,和历史结果没法比

现象:换了台机器、换了份 dataloader,同一个权重跑出来的 Top-1 差了 1~2 个点。

原因:resize 短边长度、插值方式(bilinear / bicubic / antialias)、归一化均值方差、甚至ToTensor的先后顺序,任一环节变化都会移动结果。这类差异不是 bug,但没有记录就无法归因。

解决:把预处理参数写进配置文件并随结果一起落盘,评测报告里附一行“resize 256 / center crop 224 / bilinear / ImageNet mean-std”。看起来啰嗦,但半年后回看时,这一行能省下重新实验的一整天。

6. 把 devkit 固化成资产:一份 meta.json、一个自检脚本、一次回归

这套东西我现在的处理方式固定下来了:meta.mat只读一次,转成meta.json存进仓库;所有下游代码只依赖meta.json,不再碰.mat。理由很实在——.mat的解析依赖 scipy 版本,而meta.json用标准库就能读,CI 里少一个依赖,就少一类环境问题。

转换和落地之后,我会再写一个verify_devkit.py,把关键不变量固化成可以反复执行的检查。它跑起来不到一秒,但能挡住绝大多数“换机器就崩”的场景:

import json, sys from pathlib import Path import numpy as np DEVKIT = Path("ILSVRC2012_devkit_t12") meta = json.loads((DEVKIT / "meta.json").read_text(encoding="utf-8")) gt = np.loadtxt(DEVKIT / "data" / "ILSVRC2012_validation_ground_truth.txt", dtype=np.int64) checks = [ ("类别数为 1000", len(meta) == 1000), ("ILSVRC_ID 紧凑", [r["ilsvrc_id"] for r in meta] == list(range(1, 1001))), ("首类锚点", meta[0]["wnid"] == "n01440764" and "tench" in meta[0]["name"]), ("WNID 无重复", len({r["wnid"] for r in meta}) == 1000), ("val 数量", gt.shape == (50000,)), ("标签范围", int(gt.min()) >= 1 and int(gt.max()) <= 1000), ] bad = [name for name, ok in checks if not ok] print("\n".join(f"[{'OK' if ok else 'FAIL'}] {n}" for n, ok in checks)) if bad: sys.exit(f"devkit 自检失败: {bad}")

meta[0]["wnid"] == "n01440764"这条是“金标准锚点”。ILSVRC2012 的第一类就是n01440764(tench,丁鲷),这个对应关系十几年没变过。把它写成断言,等于给整条映射链上了一把锁:排序错了、剥壳没剥干净、.mat版本不对,这一条都会挂。这比在文档里写十行“请注意顺序”有效得多。

回归验证也别省。挑一个权重已知、公开数字明确的模型(比如某个经典 backbone 的官方权重),用同一份预处理跑一遍 val,把结果和公开值对比,容忍度设 0.3~0.5 个点。这个动作本质上是给你的整条数据管线做端到端校验——只要它稳,后面换模型、换优化器、换分布式策略,你都能确信指标变化来自模型本身,而不是某个-1忘了减。

还有一个小技巧:把union检查和top5_error = 1 - top5_acc的换算写进评测函数内部,而不是散在报告脚本里。数字口径统一在一个地方产生,表格就不会出现“这张表是错误率、那张是准确率”的尴尬。

从那以后我每次拿到新的图像数据集,第一件事都不是写 dataloader,而是先把标签文件和元数据摊平、建映射、写断言、跑金标准锚点,四步走完才碰模型。这套流程在最开始看着慢,但它把“指标为什么不对”这个问题,从训练结束后的通宵排查,提前到了开工前的五分钟。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询