上个月有个做生鲜称重设备的同行找我吐槽:他拿 Fruits-360 图像数据集练了个水果分类模型,测试集上跑出 99.2% 的准确率,兴冲冲接到自家摄像头上一试,识别率直接掉到六成左右,苹果和桃子分不清,青椒和黄瓜互相认错。这个落差几乎是所有第一次用这个数据集的人都会经历的。问题不在模型,也不在 Fruits-360 本身的质量——它是目前公开的水果蔬菜图像数据集里做得最规整的一个,图像干净、类别多、还有蔬菜,拿来入门图像分类几乎是最优解。问题在于,它是一个为"研究受控条件下的分类"而设计的数据集,很多人把它当成了"真实场景数据"来用,中间的鸿沟没人提醒。
我前后用 Fruits-360 做过三四个项目,从超市自助称重台的品类辅助识别,到冰箱食材盘点的小程序,踩的坑基本覆盖了这个数据集的全部边界。下面把我摸清楚的版本差异、目录玄机、加载写法、过拟合陷阱和改造思路完整写一遍,不管你是在 Kaggle 上跑 notebook,还是准备把它塞进自己的训练流水线,这些内容都能直接抄。
1. 先搞清楚 Fruits-360 的出身:它是"拍"出来的,不是"爬"出来的
1.1 转盘加定焦相机,数据是一帧一帧攒出来的
理解采集方式,才能理解它为什么"太干净"。Fruits-360 出自一篇 2018 年的论文(Horea Mureșan 和 Mihai Oltean),采集流程按论文描述大致是这样:把水果或蔬菜摆在匀速旋转的转盘上,摄像头固定在正前方,录一段完整旋转一周的视频,然后从视频里逐帧抽帧,再用颜色阈值加连通域分析把背景去掉,把主体抠出来、居中、缩放到统一尺寸。最终你看到的每张图,主体都居中、背景都是纯白、没有遮挡、没有杂乱光影。
这就解释了一个很多人第一次打开数据集时的疑惑:为什么同一个苹果会有几十张几乎一模一样的图?因为它们本来就是同一个苹果在转盘上转的过程中被抽出来的连续帧。转盘转一圈,苹果的每一个角度都被拍到了,所以数据集天然覆盖了 360 度视角——这一点非常宝贵,别的地方很难拿到。
反过来说,这个采集方式也决定了它的短板:没有真实背景、没有遮挡、光照高度统一、拍摄距离固定。模型在这个分布上学到的东西,和真实场景的分布差了十万八千里。这不是数据集的毛病,是使用者要自己补的课。
提示:如果你只是要验证一个分类网络结构、跑通一次训练流水线、或者教学演示,Fruits-360 的"干净"是优点,能让你快速拿到正反馈。但只要涉及落地,就必须额外准备真实场景数据做微调或至少做验证。
1.2 从 60 类到 141 类:版本迭代带来的第一个坑
Fruits-360 不是一个冻结的数据集,它一直在扩。最早发布时大概 60 个类别,后面逐年加,到近几年常见版本已经到 141 个类别,覆盖的水果蔬菜种类越来越多,还出现了按中文习惯不太容易直译的命名。
这件事带来的直接后果是:你在网上搜到的教程,代码可能没问题,但打印出来的类别数和你本地跑出来的对不上;更麻烦的是,某些类别的文件夹在不同版本里改过名字,老代码里硬编码的类别列表会直接报错或者静默错位。我自己就吃过一次亏,一篇博客里的类别数是 103,我本地是 131,我以为是数据下载不全,重新下了两次才发现纯粹是版本不同。
所以第一件事不是写模型,是先确认版本:
# 统计实际类别数和图片总数,别信任何博客里的数字 DATA=/path/to/fruits-360 echo "Training 类别数: $(find $DATA/Training -mindepth 1 -maxdepth 1 -type d | wc -l)" echo "Test 类别数: $(find $DATA/Test -mindepth 1 -maxdepth 1 -type d | wc -l)" echo "Training 图片数: $(find $DATA/Training -type f \( -name '*.jpg' -o -name '*.png' \) | wc -l)" echo "Test 图片数: $(find $DATA/Test -type f \( -name '*.jpg' -o -name '*.png' \) | wc -l)"跑完这四行,你对自己手里的数据规模就有底了。以一个常见的 100x100 版本为例,Training 目录下大约六万七千多张,Test 大约两万两千多张,但不同版本差异明显,以你自己跑出来的为准。
1.3 三套尺寸和官方的目录划分,选错了后面全白干
目前流传比较广的有三个变体,用途完全不同,我列个表对比一下:
| 变体 | 单张尺寸 | 大致体量 | 适合场景 | 注意点 |
|---|---|---|---|---|
| 100x100 版 | 100x100 | 单张约 30 KB,Training 约 2 GB | 入门、教学、快速实验、小模型 | 分辨率低,细节纹理丢失较多 |
| 224x224 版 | 224x224 | 单张约 147 KB,Training 约 9.5 GB | 迁移学习、对比主流骨干网络 | 内存吃紧,别想着全量读进内存 |
| Original size 版 | 原始分辨率 | 体量最大 | 需要自己决定预处理方式、做自定义裁剪 | 必须先统一尺寸,否则 batch 组不起来 |
我的建议很直接:第一次上手用 100x100 版,跑通全流程、把准确率刷到 95% 以上,你就能确认代码链路没问题;之后如果要做迁移学习或者对比不同骨干网络,再换 224x224。至于原始分辨率版本,除非你要研究预处理策略本身,否则性价比不高——Fruits-360 的主体已经居中且白底,放大到原始分辨率并不会给你带来额外的判别信息。
另外要留意目录划分:绝大多数版本提供Training和Test两个顶层目录,部分版本额外带一个Validation。如果你的项目需要独立验证集,别指望官方给,得自己划——而"怎么划"恰恰是这个数据集最大的坑,第 4 节会重点讲。
2. 目录结构和命名规则里,藏着三个必须先读懂的信号
2.1 带空格的类名和两层结构,会绊倒一批脚本
Fruits-360 的目录结构是标准的两层:顶层是Training/Test,第二层是类别文件夹,比如Apple Red 1、Banana 1、Cucumber 1。注意,类名里带空格,这在命令行和脚本里是个不大不小的麻烦。
我用 Bash 批量处理时,第一次就翻车了:
# 错误写法:遇到 "Apple Red 1" 会被拆成三个参数 for d in $(ls Training); do echo $d; done # 正确写法:用通配加引用 for d in Training/*/; do printf "%s\t%s\n" "$(ls "$d" | wc -l)" "$(basename "$d")" done | sort -n | head -20Python 里相对安全,因为os.listdir返回的是完整字符串,不会拆词。但如果你在写 shell 打包脚本、或者在 Dockerfile 里拼路径,空格一定会给你找麻烦。养成随手加引号的习惯。
还有一个细节:不同版本里,Training和Test的类目集合不一定完全一致。有用户反馈过 Test 下存在 Training 里没有的类别文件夹,我自己的经验是不同版本情况不一样。这种事一旦发生,用ImageFolder或者flow_from_directory各自推断类别时,两边的索引顺序就会错位,你算出来的准确率是假的。所以务必在加载完数据后立刻做一次集合对比:
import os DATA = "/path/to/fruits-360" train_cls = set(os.listdir(f"{DATA}/Training")) test_cls = set(os.listdir(f"{DATA}/Test")) print("Training 类数:", len(train_cls)) print("Test 类数:", len(test_cls)) print("只在 Test 出现:", sorted(test_cls - train_cls)) print("只在 Training 出现:", sorted(train_cls - test_cls))如果两个集合相等,你可以放心用框架自动推断的类别顺序;只要不相等,就必须显式传classes=参数,把类别列表固定成 Training 那一份,然后手工过滤掉 Test 里多出来的目录。这一步不做,后面所有的指标都不可信。
2.2 "Apple Red 1" 后面的数字,代表的是不同的果实个体
很多人以为后缀数字是版本号或者尺寸编号,其实不是。按论文的说法,同一类目下的不同编号对应的是不同的果实个体或不同的拍摄批次。比如Apple Red 1和Apple Red 2是两个不同的苹果各自转一圈拍的,3可能是更换了光照条件或者换了另一批果子。
这个信息非常有用,因为它给你提供了一条做"干净划分"的线索:同一个编号下的所有图片,理论上来自同一颗果子的同一次旋转拍摄,帧与帧之间高度相关。如果你要做训练/验证划分,按编号整体划分比随机划分靠谱得多——同一编号要么全进训练,要么全进验证。不过要注意,编号粒度可能还是太粗(一个编号下就有几百张),所以更细的做法是看文件名。
Fruits-360 的文件名通常是"数字_数字.jpg"这种形式,前缀数字大致对应旋转过程中的角度或帧序号,具体命名规则不同版本略有差异,你ls一眼就能看出来。理解了这个结构,第 4 节讲的划分策略才落得了地。
2.3 先跑一遍类别分布统计,别等训练完才发现长尾
这个数据集的类别不均衡是客观存在的。多数类目每类在 150 到 250 张之间,但个别热门类目能到 490 张以上。Test 侧更明显,有些类目的测试样本只有二十来张,单类准确率抖动会非常大,你看到"某个类准确率只有 60%"时先别急着改模型,先看看这个类总共多少张测试图。
跑一下分布统计,一条命令搞定:
find Training -mindepth 1 -maxdepth 1 -type d -print0 \ | while IFS= read -r -d '' d; do n=$(find "$d" -type f | wc -l) printf "%5d %s\n" "$n" "$(basename "$d")" done | sort -n > class_dist.txt head -10 class_dist.txt # 最少样本的 10 个类 tail -10 class_dist.txt # 最多样本的 10 个类拿这个结果做两件事:一是判断需不需要做重采样或类权重;二是给后续评估做准备——评估时除了看整体 accuracy,一定要看 macro-F1 和混淆矩阵,否则大类会把小类的错误完全掩盖掉。
3. 三条加载路径:从最省事到最可控
3.1 Keras / TensorFlow:五行走通,但要把类别顺序钉死
用 TensorFlow 的话,image_dataset_from_directory是最省事的入口:
import tensorflow as tf DATA = "/path/to/fruits-360" IMG_SIZE = (100, 100) BATCH = 64 train_ds = tf.keras.utils.image_dataset_from_directory( f"{DATA}/Training", image_size=IMG_SIZE, batch_size=BATCH, label_mode="int", shuffle=True, seed=42, ) val_ds = tf.keras.utils.image_dataset_from_directory( f"{DATA}/Test", image_size=IMG_SIZE, batch_size=BATCH, label_mode="int", shuffle=False, class_names=train_ds.class_names, # 关键:强制沿用训练集的类别顺序 ) print("类别数:", len(train_ds.class_names))那几个参数里,class_names是最容易被漏掉、后果又最严重的一个。它的作用是把验证集的类别索引强行对齐到训练集。如果不传,框架会自己按目录名排序重新推断一遍,一旦两边目录集合有差异,索引就会错位,然后你得到一个看起来很合理、实际完全错误的准确率。shuffle=False也是同理,方便后面把预测结果和标签一一对应起来做混淆矩阵。
label_mode选int还是categorical取决于你的损失函数:sparse_categorical_crossentropy配int,categorical_crossentropy配categorical。我一般用int,省一次独热编码,显存也省一点。
至于性能,image_dataset_from_directory默认的读盘吞吐在机械盘上可能跟不上 GPU,训练时如果nvidia-smi看到 GPU 利用率上蹿下跳,加这两行就能缓解:
train_ds = train_ds.cache().prefetch(tf.data.AUTOTUNE) val_ds = val_ds.cache().prefetch(tf.data.AUTOTUNE)100x100 版本全量 cache 到内存大概 2 GB 左右,一般机器扛得住;224x224 版本就别 cache 了,会直接把内存吃光。
3.2 PyTorch:ImageFolder 好用,但那行校验千万别省
PyTorch 这边用的是ImageFolder,代码同样短:
import os from torchvision import datasets, transforms from torch.utils.data import DataLoader DATA = "/path/to/fruits-360" train_tf = transforms.Compose([ transforms.Resize((128, 128)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) eval_tf = transforms.Compose([ transforms.Resize((128, 128)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) train_set = datasets.ImageFolder(f"{DATA}/Training", transform=train_tf) test_set = datasets.ImageFolder(f"{DATA}/Test", transform=eval_tf) # 这行是保命的,务必打印出来看 assert train_set.classes == test_set.classes, "训练集与测试集类别不一致,先处理目录再做映射" train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=4, pin_memory=True) test_loader = DataLoader(test_set, batch_size=64, shuffle=False, num_workers=4, pin_memory=True) print("样本数:", len(train_set), len(test_set), "类别数:", len(train_set.classes))ImageFolder的类别顺序是按目录名排序的,所以只要两个目录的类别集合一致,索引天然对齐。那行assert就是用来兜住"集合不一致"这种意外的。真实项目里我还会把train_set.classes存成一个 json 文件,推理时直接读它做标签映射,避免训练和部署两套代码各推一遍类别顺序。
num_workers的设置有个经验值:设成物理核心数的一半到全部之间,配合pin_memory=True一般就够。如果你在容器里跑,注意共享内存默认只有 64 MB,num_workers一大就容易报Bus error或者卡死,这时候要么把num_workers降到 2,要么给容器加--shm-size=2g。这个坑我卡了整整一个下午。
3.3 缓存成 npy:先算清内存账再动手
如果你要反复做实验,每次都重新解码几万张 JPEG 实在太浪费,把整个数据集转成一个.npy数组是最省事的大招:
import os import numpy as np from PIL import Image from tqdm import tqdm def pack(split_dir, out_path, size=(100, 100)): classes = sorted([d for d in os.listdir(split_dir) if os.path.isdir(os.path.join(split_dir, d))]) xs, ys = [], [] for ci, cname in enumerate(tqdm(classes)): cdir = os.path.join(split_dir, cname) for fn in os.listdir(cdir): p = os.path.join(cdir, fn) try: im = Image.open(p).convert("RGB").resize(size) except Exception: continue xs.append(np.asarray(im, dtype=np.uint8)) ys.append(ci) np.save(out_path + "_x.npy", np.stack(xs)) np.save(out_path + "_y.npy", np.array(ys, dtype=np.int64)) np.save(out_path + "_classes.npy", np.array(classes)) print("打包完成:", len(ys), "张") pack("/path/to/fruits-360/Training", "fruits_train")动手之前先算账,这是我强烈建议养成的一个习惯。以 100x100 的 RGB 图为例,单张uint8占用 100×100×3 = 30000 字节,也就是约 30 KB。六万七千张大约 1.94 GB,用uint8存下来完全吃得消。但如果你贪方便直接转成float32,内存瞬间变成约 7.8 GB,普通笔记本会直接开始交换分区,训练速度反而比边读边解码还慢。
换成 224x224 就完全是另一回事了:单张 224×224×3 = 150528 字节,约 147 KB,六万七千张就是约 9.5 GB,uint8都快撑不住,转float32就是 38 GB。所以 224x224 版本的正确姿势是老实做流式读取,只在训练时按 batch 解码和归一化,缓存这条路不要走。
提示:无论是 npy 缓存还是直接读盘,归一化都放在 GPU 上做更划算。把
uint8数据传上去再用一行x = x.float().div_(255),比在 CPU 上折腾省不少时间。
4. 真正该警惕的:这个数据集"太干净",导致准确率虚高
4.1 白底、居中、无遮挡:模型大概率在偷看你没给它的信息
先说一个不太舒服的事实:在一个背景永远是纯白、主体永远居中、光照永远一致的数据集上,一个简单的卷积网络能轻松拿到很高的分数,但这里面有多少来自"识别水果形状",有多少来自"识别白色背景里那块有颜色的区域",很难说清。
最直接的验证方法是做一次背景扰动实验:把测试图的主体保留、背景换成浅灰或随机纹理,看准确率掉多少。我实测过一次,掉十几个点是常态。如果掉得特别厉害,说明模型强依赖背景与主体的边界对比,这种模型换到真实照片上必然不行。
还有一个更隐蔽的依赖:颜色。这个数据集里颜色和类别的相关性极高,几乎一一对应,模型完全可以只靠平均色做决策,而不去看纹理和形状。做一次灰度化推理实验就能验证——把图片转成灰度再喂进去,如果准确率崩掉一大半,就说明颜色占的比重过高。这在实验室里没问题,但实际场景里同一品类不同成熟度的果子颜色差异巨大,纯靠颜色会非常脆弱。
应对思路不是放弃这个数据集,而是把它当"预训练素材"而不是"最终训练素材":先在 Fruits-360 上把网络训到收敛,让卷积核学会一些通用的边缘、纹理、色块特征,然后再用少量真实场景数据做微调。这比从零开始在几百张真实图上训,效果要好得多。
4.2 相邻帧近乎重复:随机切验证集等于自己骗自己
这是我认为 Fruits-360 上最容易犯、后果也最严重的错误。
前面说过,数据来自连续视频抽帧。这意味着同一个类目下,第 100 帧和第 101 帧几乎是同一张图,可能只差转盘转了一度。如果你按常规做法把数据随机打乱后切出 20% 当验证集,那么同一个果子的邻近帧很可能一张进了训练集、一张进了验证集。验证集和训练集高度重叠,你看到的验证准确率会显著高于模型真实的泛化能力,典型的"看起来收敛得很好,一上生产就拉胯"。
那怎么切才干净?靠文件名里的帧序号。具体做法是:对每个类目,按文件名里的数字前缀排序,然后按块切分而不是随机抽,比如每 10 张里取第 0 张进验证集、其余进训练集,这样验证集里的每一张,它左右相邻的帧都在训练集里——等等,这不还是泄漏吗?
对,所以更严格的做法是按"段"切:把连续的帧序列切成若干段,整段整段地分配给训练或验证,段与段之间留一段间隔不用。举个具体例子,某个类目有 480 帧,你可以按每 40 帧为一段,切成 12 段,然后让第 1、4、7、10 段进验证集,其余进训练集,段与段之间的边界帧直接丢弃。这样验证集里的图和训练集里的图在时间上至少隔了几十帧,相关性大幅下降。
代价是训练数据变少了,但换来的评估可信度值得。我在冰箱食材盘点那个项目里就是这么做的,验证准确率从虚高的 99% 掉到了 93% 左右——数字不好看,但这个 93% 后来在真实数据上微调后确实能稳住,而之前那个 99% 对应的真实场景表现是灾难性的。
4.3 官方 Test 集也不是随机抽的,别把它当交叉验证用
还有一个细节值得说清楚:Training和Test这两份数据不是从同一批图片里随机对半分的,从目录组织和采集批次看,Test 更接近"另一次拍摄"的独立集合。这对你其实是好事,意味着官方 Test 的指标比你自己乱切的验证集更可信。
但要避免一个错误做法:把 Test 当成验证集来调超参数,反复在它上面试学习率、试网络结构、试增强策略。这样调上几十轮之后,Test 事实上就变成了你的验证集,你报出来的指标会带上明显的选择偏差。正确做法是从 Training 里按 4.2 的方法切出一份自己的验证集用于调参,Test 只在最后跑一次,那一次的结果才值得写进报告。
我给自己定的规矩是:Training 切出的验证集用来做所有决策,官方 Test 只允许在"准备发版"的时候跑,跑完就冻结,不再回头改任何东西。这条规矩救过我很多次。
5. 把 Fruits-360 用在真实场景里的几处改造
5.1 推理前加一道"同款抠图",效果比换网络明显
既然训练数据是抠好背景、居中的,那就在推理端也做一次同样的预处理。这个思路听起来有点"作弊",但工程上极其有效,而且成本很低。
具体做法是用经典的阈值法把前景抠出来:转到 HSV 空间,用饱和度或与背景的色差做阈值得到掩码,取最大连通域,算出外接矩形,裁出来,缩放到 100x100 或 128x128,贴到纯白底上。不需要任何深度学习模型,OpenCV 二三十行就够。
我在一个自助称重台的原型上做过对比:同一套模型权重,直接喂原始摄像头截图,准确率大概六成多;加上这道抠图预处理之后,能到八成五左右。换网络结构、调超参折腾一整天的收益,远远比不上这二三十行预处理代码。当然,真实场景有阴影、有相邻物体、有反光,抠图不会永远成功,所以工程上还要加一层兜底:抠图失败或前景面积占比异常时,走"整图直接推理"的降级路径。
5.2 增强策略怎么选:旋转是白送的,颜色抖动才是刚需
Fruits-360 的增强策略有个反直觉的地方:随机旋转几乎不会带来收益,因为这个数据集本身就覆盖了 360 度视角,每个类别在任意角度上都有样本。你再随机转,只是重复它已有的信息。同理,水平翻转对水果这种近似对称的物体也基本是白送的,不亏但也不赚。
真正有用的是这几类:
- 颜色抖动:亮度、对比度、饱和度、色调小幅扰动。这是刚需,因为真实摄像头白平衡千差万别。但幅度要控制,色调(hue)扰得太狠会让青苹果变成红苹果,直接把标签搞错,我一般把 hue 限制在 ±0.05 以内。
- 随机裁剪加缩放:模拟主体在画面中大小位置的变化。这个很有用,因为真实照片里果子不会永远居中、永远占满画面。裁剪比例我常用 0.85 到 1.0。
- 随机亮度与高斯噪声:模拟不同光照条件和摄像头噪声,幅度不用大。
- 随机擦除:模拟遮挡。这个对提升鲁棒性帮助明显,但比例别超过 0.2,否则把主体擦掉一半,标签就不可信了。
不要用垂直翻转。水果的上下朝向在真实场景里是有意义的,垂直翻转出来的样本在物理上不成立,长期来看会污染模型对形状的认知。
5.3 小模型加迁移学习:参数量和准确率之间的取舍
Fruits-360 的 100x100 输入配合一个五层左右的普通卷积网络,就能到这个数据集的高分区间,这是它的友好之处。但我要提醒的是,别被这个容易程度误导,以为模型越简单越好。
如果你最终要落地到手机或者嵌入式设备,路线应该是:先在 Fruits-360 上训练一个小骨干(比如宽度缩到 0.5 的轻量网络,或者直接蒸馏自一个较大的模型),得到一个"能泛化到水果形状"的起点,再用真实场景数据微调最后两三个阶段。冻结策略上,我一般先冻结全部骨干只训分类头几轮,让分类头找到方向,然后解冻后段用很小的学习率(比如主学习率的十分之一)继续训。一次性全解冻配大学习率,很容易把这套干净数据上学到的通用特征冲掉。
至于模型大小,我的经验是:在 Fruits-360 上,参数量从几百万降到几十万,指标下降往往只有一个百分点左右,但推理速度能快好几倍。落地场景里,这个交换几乎永远是划算的,所以别一上来就上大骨干,先测小模型,不够再往上加。
6. 我实际踩过的几个坑和排查过程
6.1 类别映射错位:那个假的 92%
有一次我复用了一份别人写的推理脚本,把训练好的模型接上去,评估出来 92% 的准确率,看着挺正常。但混淆矩阵特别奇怪,好几个视觉上毫无相似度的类别互相混淆,比如"梨"和"柠檬"。
排查链路是这样的:先怀疑模型权重,换成之前验证过的权重,结果一样;再怀疑数据预处理,打印了几张验证集图片和标签,图片没问题;然后我去看推理脚本里的标签映射,发现它是直接sorted(os.listdir(TEST_DIR))得到的类别列表,而训练时用的是sorted(os.listdir(TRAIN_DIR))——两个目录的类别集合有细微差异,导致从某一位开始整体错位一格。
修复方式很简单:训练结束时把train_set.classes序列化存成 json,推理时读这个 json 做映射,任何时刻都不再依赖目录的实时排序。从那以后我在所有项目里都强制做这一步,并且加了一条断言:模型输出维度必须等于标签映射表的长度。这个断言后来还帮我抓到过一次"换了数据集版本但忘了重训"的低级错误。
6.2 DataLoader 卡死和内存被吃光,往往是两个完全不同的原因
训练过程中突然卡住不动,有两种表现特别像但根因完全不同:
第一种是训练一开始就卡死,日志停在第一个 batch。这基本是num_workers和共享内存的问题,尤其在容器里。排查办法是把num_workers直接设成 0 跑一遍,如果能跑通,问题就定位了。解决方式是降 worker 数或者给容器加共享内存。
第二种是训练跑了几十个 batch 之后越来越慢,最后被杀掉。这是内存泄漏或者缓存无限增长,常见于在训练循环里用列表不断收集预测结果、又不做截断的写法。我踩过一次,每个 batch 都把全部预测概率存进一个 list,几个 epoch 下来吃了十几个 G。解决方式很简单:评估循环里只累积必要的标量或者做周期性归约,不要把完整张量攒在内存里。
排查这类问题时,我习惯在训练循环里每 N 个 batch 打一行psutil.Process().memory_info().rss / 1024 ** 2,看内存曲线是不是稳步上升。稳步上升就是泄漏,忽高忽低是正常的缓存行为。
6.3 文件名排序和隐藏文件的那些小事
最后说几个看起来很小、但每次都有人中招的点。
一是排序不一致。Python 的sorted()对字符串是字典序,Apple Red 10会排在Apple Red 2前面。如果你自己写脚本按文件名遍历并假设顺序等价于编号顺序,这个假设在编号超过 9 之后就不成立了。做帧序切分时必须把文件名里的数字提取出来转成整数再排序。
二是隐藏文件。macOS 解压出来的目录里会混进.DS_Store,Windows 上偶尔有Thumbs.db。ImageFolder对目录里的非图片文件处理得比较宽容,但如果你自己写遍历逻辑,没做扩展名过滤,一个.DS_Store就能让你的脚本在打开文件时崩掉。稳妥写法是显式判断后缀:
import os IMG_EXT = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} def list_images(d): return [os.path.join(d, f) for f in os.listdir(d) if os.path.splitext(f)[1].lower() in IMG_EXT]三是解压方式。Fruits-360 在不少平台上是打包成 zip 分发的,用命令行解压时如果忘了处理中文路径或者符号链接参数,可能出现部分图片解压失败但程序不报错的情况——文件存在但大小为 0。跑一遍"文件大小小于 1 KB 就报警"的检查,可以提前发现这类静默损坏:
find /path/to/fruits-360 -type f -size -1k -print | head这个检查我在每次下载完新数据集后都会跑一遍,花几秒钟,能省掉几个小时的困惑。
用到现在,我对 Fruits-360 的定位已经很清楚了:它是一个出色的起点数据集,用来验证代码链路、预训练特征提取器、做教学演示都非常合适,但它的天花板也很明确——它教不会模型如何应付真实世界的杂乱。真正决定项目成败的,是你为真实场景补了多少课:按帧序切出干净的验证集、在推理端补上一致性的预处理、用少量真实数据做微调、把评估口径从"测试集准确率"换成"真实场景混淆矩阵"。这几件事里,第一件和最后一件最容易被跳过,而它们恰恰是最省时间也最省心的部分。