ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

域泛化数据集整理全流程:元数据、类别对齐与留一域划分

域泛化数据集整理全流程:元数据、类别对齐与留一域划分

上周帮一位做视觉方向的朋友清盘一个跨域图像分类的数据目录,他发来一句话:“压缩包都解开了,四个文件夹摆着,怎么跑结果都不对味。”我进去一看,四个域里有两个域的类名拼写是两套系统,同一张图在三个域里各出现过一次,测试域的图还被拿去算了归一化均值。这活儿跟模型一点关系都没有,纯粹是Domain Generalization 数据集整理的账没算清。

这件事听起来像搬箱子的体力活,实际是整个实验可信度的地基。域标错一层,后面所有关于“域不变特征”“域泛化性能”的结论都得推倒重来,而且错得非常安静——loss 曲线正常,准确率数字漂亮,只有把清单摊开来逐行核对才会发现根子上的问题。我这几年经手过图像分类、跨城市语义分割、跨摄像头行人重识别、多中心医疗影像几类跨域数据的整理工作,踩过的坑比写过的模型多得多。这篇就把整理这件事从头到尾掰开讲:域信息怎么落地、不同任务的基准长什么样、目录和清单怎么设计、划分协议怎么保证不泄漏、整理完该跑哪几组统计数字、以及那些只有亲手做过才会知道的坑。不管你是刚进这个方向要跑第一个基线,还是已经有一堆实验但结果总对不上号,下面这些内容应该都能直接拿去用。

1. 先把“域”这件事理清楚:DG数据集和普通数据集差在哪

1.1 域标注才是这类数据集的第一性元数据

普通分类数据集的核心元数据只有两样:图片路径和类别标签。DG 数据集多了一样东西,而且这一样是最关键的——每张图必须能回答“你来自哪个域”。域可以是采集设备、成像风格、场景类型、时间段、传感器型号、医院中心、语种,具体是什么取决于你的任务设定,但它必须是显式的、可查询的、和样本一一对应的字段。

我见过太多人拿到数据后第一反应是先跑一遍 baseline,等结果不理想了才回头补域信息,这时候通常已经晚了。因为很多公开基准的原始压缩包里,域信息藏在目录名、文件名前缀、甚至一张 README 表格里,解压的时候随手一改名字,这个映射关系就永久丢了。整理的第一步不是写代码,是把原始包的目录结构原样截图存档,写清楚“哪一层代表域、哪一层代表类”,再动手重组。

判断一份数据能不能当 DG 用,我给一个很粗暴的标准:能不能在不看图片内容的情况下,只根据元数据生成任意一个“留一域”的训练/测试划分。能,就是合格的 DG 数据;不能,那它最多只能当域适应(Domain Adaptation)用,或者干脆当普通分类数据用。这个标准听起来简单,实际能卡掉一大半“看起来像 DG”的数据集。

还有一个容易忽略的点:域的粒度问题。同一个采集来源内部可能存在子域,比如同一批相机拍的数据里分了白天和夜晚,那么“相机”和“时段”就是两个层级的域标签。整理时最好把域做成可组合的层级结构,而不是一个扁平的字符串,否则后面想做“域增量”或者“多因素偏移”分析时又要返工。

1.2 域偏移的四种成因,对应四种完全不同的基准形态

“域偏移”这三个字被用得太泛了,实际造成偏移的原因至少能分成四类,每一类对数据整理的要求都不一样。分清楚这个,你才知道自己整理出来的数据到底在测什么。

偏移成因典型基准形态模型面对的真实挑战整理时必须记录什么
外观/风格偏移同一物体在不同画风、渲染风格下的图像颜色、纹理统计分布剧烈变化,形状语义保持风格来源、渲染引擎、人工处理步骤
采集条件偏移不同设备、不同分辨率、不同压缩率、不同光照噪声模式、清晰度、曝光差异设备型号(脱敏后的代号)、原始分辨率、压缩参数
场景/背景偏移同一类目标出现在不同背景、不同城市、不同季节背景与类别的伪相关被打破场景类别、地理位置粗粒度信息、采集时段
标签分布偏移各域类别先验差异极大模型偏向头部类别,域内 macro 指标崩塌每域每类的样本数、类别先验分布

这四类经常是混在一起出现的。比如那个由艺术画作、卡通、照片、素描四个域组成的经典图像分类基准,它同时包含风格偏移和采集条件偏移——素描域的图是人工描线,清晰度和纹理和其他三个域完全不在一个量级。整理这类数据时,如果你只记了“domain=sketch”这一个字段,后面想分析“到底是风格差异导致掉点还是分辨率差异导致掉点”就无从下手了,因为原始分辨率这个信息在重组成统一尺寸的目录时已经被抹掉。

我的做法是元数据宁可多记,不能少记。整理阶段多写五个字段的成本是几分钟,实验阶段想追溯一个数字的来源可能要花两天。像原始宽高、文件大小、文件哈希、EXIF 里的相机信息(如果有)、原始目录路径,这些都应该进清单表。它们不参与训练,但参与排错。

1.3 动手前先立三张表:域表、类表、样本表

这是我最推荐的一个习惯,也是后面所有代码的基础。三张 CSV,职责分明,不要合并成一张大宽表。

域表(domains.csv):一行一个域,字段至少包括domain_id、display_name、source(来源说明)、n_samples、n_classes、note。这张表是为了让你一眼看清“我到底有几个域,规模差多少”。

类表(classes.csv):一行一个规范类名,字段包括class_id(整数)、canonical_name(统一后的英文短名)、aliases(分号分隔的别名列表)、parent(如果需要层级)。这张表是解决跨域类名不一致的唯一正确位置,后面会专门讲。

样本表(samples.csv):一行一个样本,字段包括sample_id、rel_path、domain_id、class_id、width、height、file_size、sha、split、err。这是整个数据集的主索引,所有划分、统计、加载都基于它。

三张表的关系很清晰:样本表通过domain_id关联域表、通过class_id关联类表。这种设计的最大好处是跨域类别对齐变成了一次表连接,而不是散落在几十个 if-else 里的字符串比较。当你处理的基准有七个域、六十五个类、两万多张图时,这个差别是决定性的。

2. 按任务选基准:图像分类之外的DG数据集版图

2.1 图像分类:成熟度最高,但也最容易选错

做 DG 的第一站基本都是图像分类,因为它的域标注最干净、评测协议最统一、公开基准最多。常见的几类形态:

多风格艺术图像类基准,通常是四个域(照片、艺术画、卡通、素描),七个类别左右,总量两万张上下。它的特点是域数量少但域间差异极大,素描域的准确率往往比其他域低百分之十几到二十,是观察“风格偏移”最直观的基准。缺点是类别太少,模型很容易在源域上过拟合到接近饱和,导致方法之间的差距被压缩到一两个点,统计显著性很难做出来。

多来源物品类基准,通常是六到七个域,每个域来自不同的数据采集方式(网图、产品图、素描图等),类别数在六十五个左右。它的域间差异比前者温和,但类别多、域内类别分布不均,适合做更细粒度的分析。整理这个基准最麻烦的地方是各域的类别子集不完全一致,有的域缺几个类,如果直接按目录名对齐,会出现某些类在某些域里样本数为零,训练时 batch 里一个正样本都没有。

多来源自然场景类基准,四个域分别来自不同的图像数据集来源,类别是“鸟、车、椅子”这类粗粒度概念。它的特点是域间重叠度高、标注噪声相对大,同一个类在不同来源里的定义边界不完全一样。整理时要特别小心标注口径问题,比如“椅子”在某个来源里包含沙发,在另一个来源里不包含。

跨域细粒度基准,比如不同来源的同一类动植物或商品,域数量多但每域样本量小。这类基准适合做少样本 DG,但整理成本高,因为需要大量人工核对类别映射。

选哪个,我给一个很实用的建议:第一次做 DG,选域数在四到七个、类别数在七到六十五之间、总样本量在两万到三万之间的基准。太小的方法差距出不来,太大的跑一次完整留一域要等太久,调参周期拉不上去。等你的流程跑顺了,再换大规模基准验证方法的可扩展性。

2.2 检测与分割:域标注从图片下沉到实例和像素

图像分类里域是整张图的属性,检测和分割就不一样了,域的概念要落到实例和像素级别,整理难度直接上一个台阶。

跨城市街景语义分割是比较典型的一类:每个“城市”或“城市+季节”组合算一个域,标注是逐像素的。整理时你会遇到几个硬问题。第一是标注格式不统一,有的来源用 COCO 风格的 JSON,有的用逐张 PNG 掩码,有的用自定义的标签文件,必须在整理阶段统一到一种中间格式。第二是类别体系不一致,A 城市把“护栏”和“围栏”算两类,B 城市合并成一类,这种必须建映射表并且逐条核对,不能靠自动字符串匹配。第三是忽略区域(ignore region)的处理,不同来源对“无法判断的像素”标注方式不同,有的是固定值 255,有的是单独一张 mask,如果统一时漏了,损失计算就会把无效像素当成某个真实类别。

跨域目标检测的整理重点在标注坐标的变换。不同来源的框格式有 xyxy、xywh、归一化坐标、左上右下等各种变体,还有一些来源的坐标原点在左下角。整理阶段一定要做一次可视化抽查——随机抽两百张图把框画上去看一眼,这一步能救掉后面无数次的“mAP 莫名很低”。

2.3 行人重识别与视频类:摄像头ID就是天然的域标签

跨域行人重识别是个很特殊的方向,因为它的“域”几乎是白送的——摄像头编号天然就是域标签,不同摄像头之间的光照、视角、背景差异构成了真实偏移。

但整理这类数据有几个别人不太会提醒你的坑。第一,tracklet(同一个人在同一摄像头下的一段连续轨迹)不能跨划分切开。同一个 tracklet 的多帧如果一部分进训练、一部分进测试,模型本质上是在做帧级记忆,指标会虚高得离谱。第二,身份 ID 在各域之间不重叠是常态,这和分类任务里“类别跨域共享”的假设完全不同,所以整理时样本表里的class_id字段实际存的是 identity,评测协议也要换成检索式的 mAP 和 Rank-k,不能套分类的 accuracy 脚本。第三,视频类数据的帧采样策略要写进清单,你抽了哪些帧、间隔多少、有没有去模糊帧,这些都会显著影响结果,必须记录。

2.4 非视觉领域:时间序列、医疗、语音、文本,整理逻辑共性大于差异

跳出图像之后,DG 的整理逻辑其实高度一致,只是具体字段换了名字。

时间序列类的域通常是“设备型号”“用户个体”“采集环境”。整理时最要命的是采样率不一致和时间对齐,不同设备采出来的序列长度、频率都不同,直接拼接会让模型学到采样率而不是真实模式。医疗影像类的域是“采集中心”“扫描仪型号”“成像协议”,这里有一条铁律:划分必须按患者级别切,不能按图像级别切。同一个患者的多张切片如果分到训练和测试两侧,那就是赤裸裸的泄漏,任何审稿人看到都会直接质疑结论。语音类的域是“语种”“说话人”“麦克风”,文本类的域是“领域来源”(评论、新闻、论坛帖子),整理重点都是保证同一实体不在两侧出现。

我整理过一份多中心医疗影像的清单,最后发现原始数据里同一个患者在不同中心各拍过一次,这种情况如果不做跨中心实体去重,留一域划分就彻底失效了。所以非视觉领域的整理,实体级去重(患者 ID、用户 ID、设备序列号)的优先级高于文件级去重。

3. 目录落盘与清单化:把散落的压缩包整理成能跑的结构

3.1 统一目录骨架与命名约定

目录结构我一般不追求花哨,够用、稳定、能一眼看懂就行。推荐这个骨架:

datasets/ <bench_name>/ raw/ # 原始压缩包和解压产物,只读,永不修改 images/ # 重组后的统一目录 <domain_id>/ <class_name>/ xxx.jpg meta/ domains.csv classes.csv samples.csv splits/ lodo_v1.json README.md # 记录来源、许可、整理日期、字段说明

几条硬性约定,后面能省很多事。raw/目录只读,任何清洗脚本的输出都写到新目录,绝不在原地改,这样出问题了随时能回滚。域目录名用小写短名,不带空格、不带中文、不带特殊符号,因为下游的数据加载器、shell 脚本、路径拼接都可能在这上面出问题。类目录名用规范名,和classes.csv里的canonical_name严格保持一致,不要让目录名和表里的名字出现第二种写法。

README.md这一项很多人懒得写,但我强烈建议写。至少要记录:数据来源说明、许可条款、整理日期、整理脚本的版本、每个域的样本数、已知的问题。三个月后你自己回来看,没有这个文件基本等于重新摸一遍。

3.2 用一段脚本把样本表生成出来

手工填表在两万张图的规模下是不可能的,必须脚本化。下面这段代码是我常用的模板,扫描目录同时把尺寸、模式、哈希、坏图信息一次性收齐:

import csv import hashlib from pathlib import Path from PIL import Image ROOT = Path("datasets/multidomain_cls/images") OUT = Path("datasets/multidomain_cls/meta/samples.csv") IMG_EXT = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} def file_hash(p: Path, chunk: int = 1 << 20) -> str: h = hashlib.blake2b(digest_size=16) with open(p, "rb") as f: while True: b = f.read(chunk) if not b: break h.update(b) return h.hexdigest() rows = [] for p in sorted(ROOT.rglob("*")): if p.suffix.lower() not in IMG_EXT: continue rel = p.relative_to(ROOT) parts = rel.parts domain_id = parts[0] if len(parts) >= 3 else "_unknown" class_name = parts[1] if len(parts) >= 3 else "_unknown" rec = { "sample_id": len(rows), "rel_path": str(rel).replace("\\", "/"), "domain_id": domain_id, "class_name": class_name, "width": "", "height": "", "mode": "", "file_size": p.stat().st_size, "sha": file_hash(p), "split": "", "err": "", } try: with Image.open(p) as im: w, h = im.size rec["width"], rec["height"], rec["mode"] = w, h, im.mode except Exception as e: rec["err"] = repr(e) rows.append(rec) with open(OUT, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=list(rows[0].keys())) writer.writeheader() writer.writerows(rows) bad = [r for r in rows if r["err"]] print(f"total={len(rows)} bad={len(bad)}") for r in bad[:10]: print(r["rel_path"], r["err"])

哈希用 blake2b 而不是 MD5,原因是速度差不多但碰撞概率低得多,而且digest_size=16已经足够做文件级去重。这段脚本里err字段是刻意保留的——任何打不开的图都不要静默跳过,先记录下来,等清理阶段统一决定是删除还是重新下载。我见过有人写脚本时直接try/except: pass,结果训练时 dataloader 报错,排查了半天才发现是几十张零字节文件。

3.3 跨域类别对齐:alias 表是唯一正确的位置

跨域数据里类名不一致几乎必然发生。同一个东西,A 域写aeroplane,B 域写airplane,C 域写plane;或者中文目录名和英文目录名混用;再或者大小写和下划线风格混乱。这类问题最危险的地方在于不报错——脚本照常跑,只是某些类永远匹配不到,在混淆矩阵里表现为某一列全零。

正确的处理方式分三步。第一步,把所有出现过的类名去重,导出一张清单。第二步,人工过一遍,建立规范名到别名的映射,写进classes.csv的aliases字段。第三步,用一个映射函数在生成样本表时把class_name换成class_id,而不是在训练时临场做字符串处理。

import pandas as pd cls = pd.read_csv("meta/classes.csv") alias2id = {} for _, r in cls.iterrows(): alias2id[str(r["canonical_name"]).strip().lower()] = int(r["class_id"]) if isinstance(r["aliases"], str) and r["aliases"]: for a in r["aliases"].split(";"): alias2id[a.strip().lower()] = int(r["class_id"]) df = pd.read_csv("meta/samples.csv") df["class_name_l"] = df["class_name"].str.strip().str.lower() df["class_id"] = df["class_name_l"].map(alias2id) unmapped = df[df["class_id"].isna()] print(f"unmapped={len(unmapped)}") print(unmapped["class_name"].value_counts().head(20))

跑完之后一定要看unmapped的数量。只要是零,说明映射表覆盖完整;只要不是零,就停下来查,不要拿fillna(-1)糊过去。这一步我一般会顺手做一次“类名 × 域”的频次交叉表,如果某个类在某个域里样本数为零,也要单独标记出来,因为它们会影响后续的类别均衡采样和损失函数设计。

3.4 完整性校验:坏图、重复图、异常尺寸三件套

数据清理阶段要跑三项检查,我一般放在同一个脚本里,输出一份问题清单而不是直接删文件。

import pandas as pd df = pd.read_csv("meta/samples.csv") # 1) 文件级重复 dup = df[df["sha"].duplicated(keep=False)] print(f"duplicate_groups={dup['sha'].nunique()} duplicate_rows={len(dup)}") cross_domain_dup = ( dup.groupby("sha")["domain_id"].nunique().loc[lambda s: s > 1] ) print(f"cross_domain_duplicates={len(cross_domain_dup)}") # 2) 异常尺寸 df["area"] = pd.to_numeric(df["width"], errors="coerce") * pd.to_numeric(df["height"], errors="coerce") print(df["area"].describe()) # 3) 坏图 print(df[df["err"].notna()][["rel_path", "err"]].head(20))

第一项最关键。跨域重复文件是 DG 实验里最隐蔽的泄漏来源,因为很多公开基准是从别的大型数据集里截取拼装的,不同域之间本来就有重叠。你训练的时候在一个域里见过这张图,测试时它在另一个域里又出现,模型的指标天然虚高。处理策略有三种:直接删掉重复项只保留一份、保留但标记一个dup_group字段、或者只保留在主域里。我一般选第二种,保留原始数据完整性,把决定权交给划分脚本,同时在README里写清楚有多少组重复。

第二项异常尺寸,主要是揪出那种几像素见方的垃圾图,以及一张顶一万张的超大图。前者通常是抓取失败留下的占位文件,后者会让 dataloader 在 resize 时巨慢。

第三项坏图,前面脚本已经记录了错误信息,这里只是汇总输出,人工过一眼决定去留。

4. 划分协议:leave-one-domain-out 怎么落地才真不泄漏

4.1 LODO 的三种实现层次,我为什么偏好清单层

留一域(Leave-One-Domain-Out)是 DG 最主流的评测协议,但“怎么落地”这件事上有三种实现方式,差别不小。

目录层实现:物理地把文件复制或软链接到train/和test/目录。优点是直观,任何加载器不用改就能用。缺点是文件系统开销大、多套协议要占多份空间、软链接在跨盘或打包分发时容易失效。

清单层实现:文件不动,在samples.csv里加split列,或者单独放一个splits/lodo_v1.json记录每个 fold 的域划分。加载时按清单过滤。优点是零拷贝、可版本化、可回溯、多套协议共存无压力。缺点是需要自己写一小段 Dataset 代码。

运行时实现:在训练脚本里根据传参动态过滤域。优点是灵活,缺点是划分逻辑散落在代码里,换个脚本就可能不一致。

我几乎只用清单层,理由很实在:协议需要版本化。同一个基准,第一版划分发现了重复文件问题,第二版删掉了重复,第三版又调整了验证集切法,如果没有独立的清单文件做快照,你根本说不清哪个数字对应哪版协议。清单文件小到几 MB,进 Git 毫无压力,出问题随时能 diff。

import json from itertools import combinations folds = [] domains = ["d_photo", "d_art", "d_cartoon", "d_sketch"] for tgt in domains: src = [d for d in domains if d != tgt] folds.append({ "fold_id": f"lodo_{tgt}", "train_domains": src, "test_domains": [tgt], "note": "single target domain, multi-source training", }) # 更严格的多源留一组合(当域数较多时可选) pairs = [list(c) for c in combinations(domains, 2)] print(f"num_folds={len(folds)} num_domain_pairs={len(pairs)}") with open("meta/splits/lodo_v1.json", "w", encoding="utf-8") as f: json.dump({"version": 1, "protocol": "LODO", "folds": folds}, f, indent=2)

域数只有四五个的时候,单目标留一的四个 fold 就够了。域数到七个以上,可以考虑加一组“留两个域”的 fold,检验方法在多目标场景下的稳健性。但这会成倍增加实验量,一般作为补充实验而不是主表。

4.2 域内验证集怎么切,以及一个绝对不能犯的错

留一域协议的训练侧有多个源域,这时候需要从源域里切出验证集做模型选择。切法有两种,各有适用场景。

源域内部切分:把每个源域按比例(比如八比二)切成训练和验证,测试域完全不参与。这是最常用的做法,优点是每个 fold 的训练数据量损失小,缺点是验证分布和测试分布差异大,选出来的超参对测试域未必最优。

留一个源域当验证:从多个源域里拿出一个作为验证域,剩下的作为训练域。优点是验证场景更接近真实泛化,缺点是训练数据进一步减少,而且 fold 数量会翻倍。

我一般先用第一种跑主实验,再用第二种做一轮补充,看看结论是否一致。两种都稳定,说明方法本身是稳的;只有一种能涨点,那就要怀疑是不是超参碰巧合适。

这里有一条底线,我必须说得非常直白:测试域的任何一个统计量都不能进入训练流程。包括但不限于:用全量数据算的均值方差做归一化、用全量数据拟合的 PCA、用全量数据的类别频率做重采样权重、用测试域的准确率挑 epoch。这些操作每一个都会让指标虚高,而且虚高的幅度往往正好是你“方法带来的提升”。我见过最典型的例子是归一化:写代码时图省事,在Dataset.__init__里对全部图片算了一遍均值和标准差,结果测试域的域偏移被这一步预处理抹平了一大截,最后的泛化性能虚高了好几个点。

正确的做法是只从训练域统计归一化参数,把它当作和权重一样的“要学的东西”,固化下来写进清单。如果要用到 ImageNet 级别的先验统计量,那也可以,但必须保证所有 fold、所有域都用同一套固定值,不能按 fold 重算。

4.3 域很少或者每域样本很少时,协议要换

标准 LODO 对域数和每域样本量都有要求。域只有两个的时候,留一域就退化成单向迁移,只能测 A→B 和 B→A 两个方向,这时候更接近域适应的设定,结论的普适性要打折。每域只有几十上百个样本的时候,域内验证集一切,训练集就没了,这时候要用少样本 DG 协议:每个源域给固定数量标注样本,测试域全量评测,随机采样多个 episode 取均值和方差。

还有一种进阶协议是域增量:训练时逐个域喂入,每个域只看一次,评测在所有见过的域和没见过的域上分别做。整理这类协议时,域的顺序本身就成了超参,必须写进清单并且固定,否则不同人跑出来的曲线没法比较。

4.4 划分结果要固化,包一层版本管理

清单文件里我建议至少记录这些字段:协议名、版本号、随机种子、每个 fold 的源域和目标域列表、每域的样本数、生成脚本的 Git commit(或者脚本内容哈希)、生成时间。

这些信息看着啰嗦,实际作用是让任何一个数字可复现。半年后你翻出一张表上的 78.3%,想确认它是不是用第二版划分跑的,只要对比清单文件的版本号就知道。没有这层记录,你只能重跑一遍,而重跑一遍的随机性又会引入新的不确定性。

5. 整理完必须跑的统计与画像:用数字确认数据到底是什么样

5.1 域-类交叉分布表,一张表看穿类别不平衡

清单生成之后,第一件事是跑交叉表。这张表能告诉你三件非常重要的事:哪些类在某些域里缺失、哪些域的类别分布严重偏斜、各域的总量差多少倍。

import pandas as pd df = pd.read_csv("meta/samples.csv") ct = pd.crosstab(df["domain_id"], df["class_id"]) print(ct) print("\n各域样本数:") print(ct.sum(axis=1).sort_values(ascending=False)) print("\n各类样本数:") print(ct.sum(axis=0).sort_values(ascending=False)) print("\n零样本的域-类组合数:", int((ct == 0).sum().sum()))

如果零组合数量很多,说明这个基准的类别体系不完整,你得考虑是只保留所有域都有的公共类,还是在损失函数里做 mask。我一般倾向于前者——取所有域的交集类,虽然会损失一些样本,但结论干净得多。后者虽然能多用一个域的私有类,但那部分类在每个 fold 里表现不稳定,反而干扰分析。

各域样本数差异超过五倍的时候,要注意评测指标的聚合方式,这个问题后面会专门讲。

5.2 量化域偏移:把“感觉差很多”变成可比较的数字

人眼判断两个域像不像非常不可靠。我做整理时一定会跑一组轻量统计,给每个域画一个“画像”,包括颜色均值方差、饱和度、清晰度、分辨率分布。这些数字不能直接当方法论,但能帮你解释结果。

import numpy as np from pathlib import Path from PIL import Image def image_stats(path: Path) -> dict: with Image.open(path).convert("RGB") as im: im.thumbnail((128, 128)) rgb = np.asarray(im, dtype=np.float32) / 255.0 flat = rgb.reshape(-1, 3) with Image.open(path).convert("L") as g: g.thumbnail((256, 256)) gray = np.asarray(g, dtype=np.float32) lap = np.abs( 4 * gray[1:-1, 1:-1] - gray[:-2, 1:-1] - gray[2:, 1:-1] - gray[1:-1, :-2] - gray[1:-1, 2:] ) return { "mean_r": round(float(flat[:, 0].mean()), 3), "mean_g": round(float(flat[:, 1].mean()), 3), "mean_b": round(float(flat[:, 2].mean()), 3), "std_gray": round(float(gray.std()), 2), "sat": round(float((rgb.max(-1) - rgb.min(-1)).mean()), 3), "sharp": round(float(lap.mean()), 2), }

把每个域的这些指标求均值列成表,你会立刻看出哪个域是“异常域”。比如素描域的饱和度接近零、清晰度指标偏低,这就是典型的人工绘制特征;某个摄影域的均值明显偏暗,说明采集时的曝光条件不同。这些信息在写实验分析时非常好用——当某个方法在素描域上掉点时,你可以有理有据地说“该域的纹理和对比度统计与其他域差异最大,符合风格偏移的预期”。

5.3 先用一个最朴素的基线把“难域”找出来

统计指标只能给线索,真正判断哪个域难,还是要跑一个朴素基线。做法很简单:每个 fold 用同一个最简单的训练流程(固定骨干、固定超参、不做任何域泛化技巧),把四个 fold 的测试准确率列出来。

fold测试域源域数量测试准确率相对源域均值掉点
lodo_photo照片3最高最小
lodo_art艺术画3中等中
lodo_cartoon卡通3中等偏下中偏大
lodo_sketch素描3最低最大

这张表的价值在于建立参照系。后面你上任何方法,涨点幅度都要放在这个参照系里看。如果一个方法整体涨了两个点但难域的掉点没有改善,那它可能只是吃到了简单域的红利,没有真正解决域偏移问题。我在整理阶段就会把这张基线表跑出来存档,后面每换一版整理方案,都用它对比一次,确保数据改动没有意外改变基准难度。

6. 我踩过的坑:从“看起来能跑”到“结果可信”之间隔着什么

6.1 文件级重复导致的跨域泄漏,排查链路完整记录

这个是代价最大的一个坑。当时跑完四个 fold,测试准确率普遍比公开报告高出三到五个点,我第一反应是“是不是我的实现有 bug”,查了两天代码没找到问题。真正的排查是从数据侧开始的,过程记录如下。

第一步,跑哈希去重,发现有大约百分之零点几的样本存在重复。数量不大,我一开始判断影响可以忽略。

第二步,把重复项按域分组看,发现其中有一部分是跨域重复,也就是同一张图在两个不同域里各出现一次。这时候问题就严重了,因为留一域划分下,同一张图可能一侧在训练域、一侧在测试域。

第三步,人工抽查这些重复图,确认是原始基准拼装时的重叠,不是我的整理脚本出错。

第四步,量化影响:把跨域重复的样本全部剔除后重跑,四个 fold 平均掉了两个多点,难域的掉点更明显。也就是说,之前报告的提升里有一大半来自泄漏。

处理方案我最后选的是:在清单里给每组重复打一个dup_group标记,划分时保证同一组的所有样本落在同一侧(同训练或同测试),而不是简单删除。这样既保住了数据量,又彻底切断了泄漏路径。代价是划分脚本要多一层约束逻辑,但非常值得。

这次之后我给自己定了个规矩:任何整理完的数据集,哈希去重必须跑,跨域重复必须单独统计,跨域重复的样本数必须写进 README。数字写出来,别人才能判断你的结论可不可信。

6.2 类名不一致引发的静默错标

前面提过类名对齐,这里说一个更隐蔽的变体:同一批数据内部,同一个类被两个不同拼写描述。比如某个域里同时存在motorbike和motorcycle两个目录,各自装了几百张图。如果映射表只覆盖了其中一个,另一个会被判为 unmapped,或者更糟——如果你用了某种模糊匹配,它可能被错配到另一个类上。

还有一种情况是大小写和连字符混用,traffic_light、traffic-light、TrafficLight三种写法共存。在 Linux 上这是三个不同目录,在 Windows 上大小写不敏感会导致覆盖,如果你在 Windows 上解压再拷到 Linux 服务器,可能已经丢了一批文件,而且完全不报错。

排查方法很简单:生成样本表后,把所有class_name去重打印出来人工过一遍。两万张图的基准类名通常在几十到几百个之间,人工核对是完全可行的,不要偷懒。核对完之后把规范名写死在classes.csv里,后面所有流程只认class_id,不再碰字符串。

6.3 预处理顺序错了,等于提前把测试域信息喂给模型

这个坑我在 6.1 之前就踩过一次,教训更深。当时的流程是:先把所有图 resize 到统一尺寸并做归一化,存成一份缓存,然后按清单划分训练测试。问题在于归一化用的均值方差是全量缓存算出来的,包含了测试域。

这个错误在指标上表现为:换一个方法,提升幅度忽大忽小,不稳定。因为预处理阶段已经吃掉了一部分域偏移,模型实际面对的分布和真实测试分布不一致,方法的真实效果被掩盖了。

修正之后的流程固定为三步:先划分,再统计训练域参数,最后才做预处理。缓存也按 fold 分目录存,不要复用同一个缓存目录。代价是存储空间变大、第一次跑每个 fold 要重新预处理一遍,但这是必须付出的成本。

顺带一提,如果你用了预训练骨干,那骨干在预训练数据上见过的东西本来就不知道,这部分不可控,但至少你自己这边不能主动引入泄漏。论文里该说明的就说明,不要假装没有。

6.4 聚合方式选错,macro 和 micro 能差出好几个点

留一域协议跑完四个 fold,怎么汇报总成绩?两种常见做法:把所有 fold 的测试样本拼在一起算总准确率(micro),或者先算每个 fold 的准确率再取平均(macro)。

域样本量悬殊的时候,这两种算法结果差得很多。假设一个 fold 的测试域有三万张图、准确率零点八,另一个 fold 的测试域只有两千张图、准确率零点五,micro 会被大域主导,总成绩接近零点七八,看起来还不错;macro 是零点六五,直接暴露了难域的问题。

DG 的核心诉求是在没见过的域上都表现好,所以主指标我一般用 macro,同时把每个 fold 的单独数字也列出来,micro 作为补充。如果你只报 micro,审稿人或同行很容易质疑你隐藏了难域的表现。整理阶段就该想清楚这件事,因为不同的聚合方式会影响你选择哪个 fold 做消融实验。

7. 一份可以直接照抄的整理清单

整个流程走完,我现在的固定动作是这样的:

第一,解压到raw/,只读,先截图记录原始目录结构,确认哪个层级代表域、哪个层级代表类。

第二,建立domains.csv和classes.csv,人工核对类名清单,建好 alias 映射。

第三,扫描生成samples.csv,字段至少包含样本 ID、路径、域 ID、类 ID、宽高、文件大小、哈希、坏图标记。

第四,跑完整性检查三件套:坏图、文件级重复、跨域重复,结果写进README。

第五,跑域-类交叉表,确认零样本组合,决定是取交集类还是保留差异。

第六,生成splits/lodo_v1.json,记录协议、种子、每 fold 的域列表、每域样本数。

第七,跑域画像统计,得到每个域的颜色、清晰度、尺寸画像。

第八,用最朴素的基线跑一遍所有 fold,存档参照表。

第九,确认预处理只在训练域上统计参数,缓存按 fold 隔离。

第十,把以上所有脚本、清单、README 一起进版本管理。

这套动作跑顺之后,整理一个四到七域、两三万张图的基准,大概需要半天到一天。听起来不少,但这半天能省掉的返工时间,按我的经验至少是它的五到十倍。整理这件事最反直觉的一点是:它不产出任何模型结果,却决定了你所有模型结果的含金量。我现在拿到任何一份跨域数据,第一件事不是看图片好不好看、类别难不难,而是先问一句“域标签在哪、划分清单在哪”。这两个问题答不上来,后面的工作做得再漂亮,也只是在沙滩上盖楼。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进