简介:CIFAR10.zip 是一份面向深度学习入门者与图像分类实战学习者的配套资料包,围绕卷积神经网络在 CIFAR10 数据集上的训练与测试展开,适合正在学习 TensorFlow、想跑通第一个图像分类项目、或需要对照完整实现来理解 CNN 结构的学生与开发者。包内共 6 个文件,以 2 份 Python 训练脚本为主体,配合已训练好的 h5 权重文件、记录损失与准确率变化的曲线图、测试效果截图以及一份说明文档,压缩包大小约 2.82MB,体量轻巧但环节完整。目前已有 4780 人学习下载。读者可直接加载权重做推理验证,也可以基于脚本修改网络层数、超参数重新训练,用曲线图比对收敛情况、用测试截图核对分类效果;两份脚本还可相互参照,帮助理解不同写法下的模型搭建与训练流程,减少从零调试的成本。
1. 别再从 MNIST 起步了:CIFAR10.zip 里那套 32×32 图像分类工程
带过几个刚入门的同学,几乎每个人都在 MNIST 上刷到过 99%,然后卡住,不知道下一步该写什么。MNIST 太干净了,干净到把真实项目里的麻烦全掩盖了:三通道彩色图、要做数据增强、验证集得单独切、模型导出后前后处理对不上。CIFAR10.zip 填的正是这个断层,它是一套基于 TensorFlow 的卷积神经网络图像分类完整工程,把数据加载、增强、训练、评估、导出串成一条能跑的线,10 类 32×32 彩色小图,50000 张训练、10000 张测试,笔记本就能跑,复杂度又足够把问题暴露出来。适合刚学完深度学习基础想跑通一个可改可调项目的人,也适合手上正好有 32×32 级别小图分类需求、想找个干净骨架往里塞数据的开发者。
2. 工程骨架:六个文件撑起数据、模型、训练、导出四层
压缩包解开之后你会发现它没有用任何脚手架,没有 Flask 后端、没有前端页面,就是一个纯训练工程。这种"素"反而是好事——每一层都摊在明面上,改哪一行会影响哪一步,读一遍就有数。我一般拿到这类项目先不急着跑,先花十分钟把目录和配置读一遍,比盲目python train.py然后对着报错猜要快得多。
2.1 目录骨架:每个文件只负责一段链路
合理的工程目录不是把代码按"多长"切,而是按"职责"切。这个项目的划分方式是这样的:
cifar10/ ├── config.py # 超参与路径,所有脚本的唯一入口 ├── data.py # 下载、切分、增强、构建 tf.data 管道 ├── model.py # 卷积主干 + 分类头,纯函数式构建 ├── train.py # 训练入口,callbacks 与断点续训 ├── evaluate.py # 测试集评估、混淆矩阵、按类召回 ├── export.py # SavedModel / TFLite 导出 ├── requirements.txt └── checkpoints/ # best.keras、train_log.csvconfig.py是全局唯一的参数来源,data.py只吐tf.data.Dataset,model.py只吐tf.keras.Model,train.py把它们拼起来。这种拆法的好处是评估脚本和导出脚本可以直接复用同一份数据和同一份模型定义,不会出现"训练时归一化了、推理时忘了"这类血泪问题。我在别的项目里见过把模型定义抄两遍的写法,训练脚本一遍、推理脚本一遍,改了个激活函数忘了同步,线上精度直接掉十几个点。
2.2 配置入口:一个字典管住所有超参
新手最容易犯的错是把batch_size=128这种数字直接写死在五个地方,改一次要全文件搜索替换。这个项目用了一个普通 dict 收口:
# config.py —— 所有可调项集中在这里,脚本里不再出现魔法数字 CONFIG = { "data_dir": "./data", # 数据缓存位置,首次运行会自动下载 "num_classes": 10, # 类别数,换数据集时必须改 "image_size": 32, # 输入边长,换数据集时必须改 "batch_size": 128, # 显存不够就往下调,64/32 都行 "epochs": 60, "base_filters": 32, # 第一个 stage 的通道数,后面每 stage 翻倍 "dropout": 0.3, "lr": 1e-3, # AdamW 的初始学习率 "weight_decay": 5e-4, "ckpt_dir": "./checkpoints", "export_dir": "./exported_model", }字典本身没什么技术含量,价值在于约束:任何脚本from config import CONFIG之后就拿得到全部参数,实验记录也只需要存这一份 dict。我做对比实验时习惯在train.py开头把CONFIG连同git rev-parse HEAD一起 dump 到checkpoints/run_meta.json,过两周回头看"那次 86.3% 是怎么跑出来的",不用靠记忆。
base_filters和dropout这两个参数后面调参章节会反复动,先记住它们分别控制模型宽度和正则强度。
2.3 环境与依赖:TensorFlow 版本对齐比装包本身更麻烦
深度学习项目跑不起来的头号原因从来不是代码错,是版本不对。这个项目的依赖很少,requirements.txt里核心就三行:
# 建议 Python 3.10 或 3.11,太新的版本部分预编译轮子还没跟上 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install "tensorflow==2.16.1" numpy scikit-learn matplotlibTensorFlow 2.16 之后默认走 Keras 3,模型保存后缀从.h5变成了.keras,model.save()的行为也有细微差别。如果你的环境里还留着 2.13 之类的老版本,ModelCheckpoint写出来的文件新版可能读不了——这不是玄学,就是 Keras 2 和 Keras 3 的格式不兼容。装完先做一次自检,把版本和 GPU 情况打出来:
import tensorflow as tf print("TF:", tf.__version__) gpus = tf.config.list_physical_devices("GPU") print("GPU:", gpus) # 按需申请显存,避免一上来就把整张卡占满导致别的进程起不来 for g in gpus: tf.config.experimental.set_memory_growth(g, True)set_memory_growth(True)这行在共享机器上尤其重要。不开的话 TensorFlow 默认一次性吃掉几乎全部显存,同事的进程排队都排不进来。如果你看到GPU: []也别慌,CIFAR-10 这个规模纯 CPU 跑 60 个 epoch 大概一两个小时,能跑通就行,调参阶段再想办法上卡。
提示:Windows 上原生 TensorFlow 2.11 之后就停止支持 GPU 了。要么走 WSL2,要么老老实实 CPU 跑,别在这上面耗一晚上。
3. 核心链路拆解:一个 batch 从磁盘到 logits 的完整路径
跑通训练只是开始,真正要改东西得知道数据在每一步变成了什么形状。这一章按数据流的顺序拆:load_data拿出来的是uint8的(50000, 32, 32, 3),中间经历切片、打乱、增强、批处理,进模型时是(128, 32, 32, 3)的float32,出来是(128, 10)的 logits。任何一步形状对不上,报错信息都会指回这里。
3.1 数据层:tf.data 管道的算子顺序决定增强有没有生效
先把原始数据读进来。CIFAR-10 官方给的测试集是 10000 张,很多教程直接拿它当验证集调参,调完再报测试精度——这是典型的自己骗自己。这个项目从训练集里切 5000 张做验证,测试集留到最后只用一次:
# data.py import tensorflow as tf from config import CONFIG def load_splits(cfg): (x_tr, y_tr), (x_te, y_te) = tf.keras.datasets.cifar10.load_data() # 标签从 (N, 1) 压成 (N,),否则 sparse 损失会报 rank 不匹配 y_tr, y_te = y_tr.reshape(-1), y_te.reshape(-1) # 从训练集尾部切 5000 张做验证,测试集原封不动留到最后 x_val, y_val = x_tr[-5000:], y_tr[-5000:] x_tr, y_tr = x_tr[:-5000], y_tr[:-5000] # 保持 0~255 的 uint8 值域,转 float32 只是为了让增强算子好算 cast = lambda a: a.astype("float32") return (cast(x_tr), y_tr), (cast(x_val), y_val), (cast(x_te), y_te)这里刻意没有除以 255。归一化被放进了模型的第一层(下一节会看到),这样导出的模型自带预处理,端上部署时不用再实现一遍除法和通道顺序。这是我在一个移动端项目上踩过坑之后养成的习惯:训练脚本里的预处理逻辑,只要没写进计算图,早晚会在某个客户端上漏掉。
增强函数负责把 32×32 随机平移加翻转,注意先 pad 到 40 再裁回 32,否则random_crop在原尺寸上等于什么都没做:
def augment(image, label): # 先补边到 40×40,再随机裁回 32×32,等价于 ±4 像素的平移 image = tf.image.resize_with_crop_or_pad(image, 40, 40) image = tf.image.random_crop(image, size=[32, 32, 3]) image = tf.image.random_flip_left_right(image) return tf.clip_by_value(image, 0.0, 255.0), label管道组装是全文最容易翻车的地方,map必须在batch之前:
def build_dataset(x, y, cfg, training=False): ds = tf.data.Dataset.from_tensor_slices((x, y)) if training: ds = ds.shuffle(10_000, seed=42) # 缓冲 1 万,够打散了 ds = ds.map(augment, num_parallel_calls=4) # 增强作用在单张图上 ds = ds.batch(cfg["batch_size"]) ds = ds.prefetch(tf.data.AUTOTUNE) # 训练与取数重叠 return ds如果map(augment)写在batch之后,augment收到的就是(128, 32, 32, 3)的整批张量,random_crop会对整批做同一次裁剪——128 张图裁的位置一模一样,等于只做了一个全局平移,增强效果基本为零。这个错误不报错、不警告,只体现在验证集精度比预期低三四个点上,属于典型的黑匣子问题。
数据常驻内存这件事也值得算一笔:50000×32×32×3 的 float32 大约 586 MiB,加上验证集和测试集总共不到 750 MiB,普通笔记本完全吃得下。所以这里不需要from_tensor_slices之外的花活,也别去开什么多进程读盘,纯属给自己找麻烦。
3.2 模型层:三层 stage 的 Conv-BN-ReLU 堆叠
模型结构是标准的 VGG 风格小网络,两个卷积一组、池化一次、通道翻倍,一共三个 stage。先把卷积块抽出来,避免重复代码:
# model.py from tensorflow.keras import layers, Model def conv_bn(x, filters): x = layers.Conv2D(filters, 3, padding="same", use_bias=False)(x) x = layers.BatchNormalization()(x) # 紧跟卷积,卷积层就可以关掉 bias return layers.Activation("relu")(x) def build_model(num_classes=10, base=32, dropout=0.3): inp = layers.Input(shape=(32, 32, 3), name="image") # 归一化写进模型:导出后外部只需要喂 0~255 的原始像素 x = layers.Rescaling(1.0 / 255)(inp) x = conv_bn(x, base) # 32×32×32 x = conv_bn(x, base) x = layers.MaxPooling2D(2)(x) # 16×16×32 x = conv_bn(x, base * 2) # 16×16×64 x = conv_bn(x, base * 2) x = layers.MaxPooling2D(2)(x) # 8×8×64 x = conv_bn(x, base * 4) # 8×8×128 x = layers.GlobalAveragePooling2D()(x) # 8×8×128 -> 128,比 Flatten 少 8 倍参数 x = layers.Dropout(dropout)(x) out = layers.Dense(num_classes, name="logits")(x) # 不加 softmax return Model(inp, out, name="cifar_cnn")几个刻意的选择值得说一下。use_bias=False是因为 BN 的 beta 参数已经把偏置吸收了,再留一个纯属浪费;GlobalAveragePooling2D代替Flatten,把 8192 维压到 128 维,参数量从 80 万降到 8 万,小数据集上泛化明显更稳;最后一层不接 softmax,输出 logits,配合损失函数里的from_logits=True,数值上更稳定,因为交叉熵内部会做 log-softmax,不需要先算一遍指数再取对数。
Rescaling(1/255)放在最前面同样是部署导向的。模型文件里带着这一步,后面导出 TFLite、写 Android 端推理代码时,Bitmap读出来的0~255字节数组直接往输入张量里塞就行,不用在 Java 侧再写一遍归一化——少写一处,就少一处和训练不一致的可能。
3.3 训练层:fit 与自定义 train_step 两条路
常规训练用compile+fit就够,这个项目默认走这条路:
# train.py import tensorflow as tf from config import CONFIG from data import load_splits, build_dataset from model import build_model cfg = CONFIG (xtr, ytr), (xval, yval), (xte, yte) = load_splits(cfg) train_ds = build_dataset(xtr, ytr, cfg, training=True) val_ds = build_dataset(xval, yval, cfg, training=False) model = build_model(cfg["num_classes"], cfg["base_filters"], cfg["dropout"]) model.compile( optimizer=tf.keras.optimizers.AdamW( learning_rate=cfg["lr"], weight_decay=cfg["weight_decay"]), # 标签是整数索引,用 Sparse 版本;如果标签是 one-hot 就换成 Categorical loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True), metrics=[tf.keras.metrics.SparseCategoricalAccuracy(name="acc")], ) callbacks = [ tf.keras.callbacks.ModelCheckpoint( f"{cfg['ckpt_dir']}/best.keras", monitor="val_acc", save_best_only=True, mode="max"), tf.keras.callbacks.ReduceLROnPlateau( monitor="val_loss", factor=0.5, patience=3, min_lr=1e-5), tf.keras.callbacks.EarlyStopping( monitor="val_loss", patience=8, restore_best_weights=True), tf.keras.callbacks.CSVLogger(f"{cfg['ckpt_dir']}/train_log.csv"), ] model.fit(train_ds, validation_data=val_ds, epochs=cfg["epochs"], callbacks=callbacks)三个 callback 各管一件事:ModelCheckpoint只留验证精度最高的那一版权重,ReduceLROnPlateau在验证损失停滞时把学习率砍半,EarlyStopping兜底防止白跑。注意restore_best_weights=True,训练结束后模型会自动回滚到最优 epoch 的状态,不然你拿到的是最后一个 epoch 的权重,很可能已经过拟合了。
SparseCategoricalCrossentropy配整数标签,CategoricalCrossentropy配 one-hot 标签,这两者混用是新手最常见的静默失败——损失能算出来,梯度也在动,但数值完全不对,精度永远卡在 10%。
如果你要做梯度裁剪、多任务损失加权、或者混合精度下需要手动LossScaleOptimizer,就得下沉到自定义train_step:
class CifarModel(tf.keras.Model): def train_step(self, data): x, y = data with tf.GradientTape() as tape: logits = self(x, training=True) loss = self.compiled_loss(y, logits) grads = tape.gradient(loss, self.trainable_variables) # 全局范数裁剪,防止个别 batch 把权重带偏 grads, _ = tf.clip_by_global_norm(grads, 5.0) self.optimizer.apply_gradients(zip(grads, self.trainable_variables)) self.compiled_metrics.update_state(y, logits) return {m.name: m.result() for m in self.metrics}train_step的返回值必须是一个{指标名: 标量}的字典,fit的进度条和 CSVLogger 全靠它。忘了update_state的话进度条会一直显示 0,但损失在正常下降——又是一个不报错的坑。
3.4 评估与导出:混淆矩阵比单一准确率有信息量
evaluate.py里做的事情比model.evaluate多一步,它把预测结果拆成按类的召回率:
import numpy as np from sklearn.metrics import classification_report, confusion_matrix logits = model.predict(build_dataset(xte, yte, cfg), verbose=0) pred = np.argmax(logits, axis=1) # logits -> 类别索引 print(classification_report(yte, pred, target_names=CLASS_NAMES, digits=4)) cm = confusion_matrix(yte, pred) print(cm.diagonal() / cm.sum(axis=1)) # 每一类的召回率CLASS_NAMES就是 CIFAR-10 那十类,顺序固定为飞机、汽车、鸟、猫、鹿、狗、蛙、马、船、卡车。看混淆矩阵比看总的 85% 有用得多——猫和狗互相混淆、鹿和马互相混淆是这套数据的经典现象,两者加起来能占全部错误的四成。如果你的业务场景对某一类特别敏感,就该针对性地去做这一类别的数据增强或者类别加权,而不是笼统地加 epoch。
导出用 Keras 3 的export接口拿 SavedModel 格式:
model.export(cfg["export_dir"]) # 生成带 signatures 的 SavedModel 目录导出之后立刻做一次自检,拿一张原图跑一遍确认输出维度对得上:
img = tf.io.decode_jpeg(tf.io.read_file("sample.jpg")) img = tf.image.resize(img, [32, 32]) img = tf.expand_dims(tf.cast(img, tf.float32), 0) # (1, 32, 32, 3),值域 0~255 prob = tf.nn.softmax(model(img, training=False))[0] print(CLASS_NAMES[int(tf.argmax(prob))], float(tf.reduce_max(prob)))training=False不能省,它决定了 BN 层走移动均值还是走当前 batch 统计量。推理时忘了传这个参数,同一张图两次预测结果不一样,排查起来能让人怀疑人生。
4. 跑通与调参:从首次运行到 85% 验证准确率的实操顺序
代码读完了就该跑。这一章给的是我自己在这个工程上的实际推进顺序,包括每一步的预期数字——有了预期,看到异常才知道是代码问题还是正常波动。
4.1 首次运行:命令、日志与合理预期
第一次跑先砍掉一半 epoch,确认链路是通的:
python train.py --epochs 20 --batch-size 128 # 另开一个终端盯日志 tail -f checkpoints/train_log.csv单卡 GPU 上每个 epoch 大概 8 到 15 秒,20 个 epoch 两三分钟就能出结果。CPU 上慢十倍左右,第一次验证建议先用--epochs 3确认能跑通再放开。
预期数字这样看:没做增强的裸网络大概在 70% 到 75% 验证准确率,加上数据增强和余弦衰减能到 85% 上下,把base_filters提到 64 再堆一个卷积能摸到 88%。如果跑了 20 个 epoch 验证精度还在 10% 附近,别怀疑人生,先去看损失函数的标签格式对不对;如果在 30% 到 50% 之间徘徊,八成是增强写错了位置或者学习率大了十倍。
train_log.csv里会记下每个 epoch 的loss、acc、val_loss、val_acc、lr,把它拖进表格软件画两条曲线,比盯着进度条刷屏有用。
4.2 调参顺序:先学习率,后增强,最后才动模型宽度
新手常犯的错是同时改五个参数,然后不知道是哪个起的作用。我一般按下面这个顺序推进,一次只动一个变量:
| 步骤 | 动什么 | 看哪个指标 | 大致效果 |
|---|---|---|---|
| 1 | 学习率 1e-4 到 3e-3 之间扫三档 | 前 5 个 epoch 的 val_loss 下降速度 | 70% 到 78% |
| 2 | 打开随机裁剪加翻转 | train 与 val 的精度差 | 到 83% 左右 |
| 3 | base_filters 32 提到 64 | 单 epoch 耗时涨了多少 | 到 86% |
| 4 | dropout 与 weight_decay 微调 | val_loss 尾部是否上翘 | 稳住不掉点 |
学习率放第一位是因为它影响最大、试错成本最低,跑三个 epoch 就能看出趋势。增强放第二位是因为它直接决定过拟合程度,而 CIFAR-10 这个数据量,不加增强的模型几乎必然在 20 个 epoch 之后开始背训练集。模型宽度放最后,因为它会让训练时间线性甚至超线性增长,先确认前两步都到位了再花这个钱。
ReduceLROnPlateau的patience=3配factor=0.5是个保守组合,如果你观察到验证损失在某个值附近抖动但整体还在降,可以把 patience 调到 5 让它再等等;如果曲线已经明显上翘,就让它早点砍。
4.3 训练曲线怎么读:三种形态对应三种病
看曲线是调参的基本功,我把常见形态归成三类。
第一种是两条线一起高高挂着下不来,train_acc 和 val_acc 都在 60% 附近。这是欠拟合,模型容量不够或者学习率太小,先加base_filters,不行就把epochs拉长,别急着加正则。
第二种是 train_acc 一路冲到 99%,val_acc 在 80% 附近打转,两条线的缝越拉越宽。这是过拟合,增强不够或者正则太弱。优先检查 3.1 节那个map的位置,确认增强真的生效了,再考虑提高dropout到 0.4、weight_decay到 1e-3。
第三种是 val_loss 在某个 epoch 突然跳高一大截然后慢慢降回来。这通常是学习率过大撞上了某个梯度大的 batch,ReduceLROnPlateau会自动处理;如果跳得太频繁,把初始学习率降一半,或者上clip_by_global_norm。
还有一个容易被忽略的细节:BN 层的移动均值需要几百个 batch 才能稳下来,所以前两三个 epoch 的 val_acc 偏低是正常现象,别在第一个 epoch 结束就急着下结论说模型不行。
5. 避坑与排查:五个把我卡了半天的现场记录
这一节里每一条都是真发生过的,不是从文档里抄的常见问题列表。共同特点是不报错——报错的坑好查,静默失败的坑才要命。
5.1 笔记本里反复建模型,第二次训练报显存不足
现象:Jupyter 里先跑了一遍小实验,改完超参重新执行训练单元,第 1 个 epoch 就抛ResourceExhaustedError,但nvidia-smi看着显存还有余量。
原因:Keras 的模型对象被全局变量和旧的计算图引用着,Python 的垃圾回收不会立刻释放显存,第二次建模型是在旧的基础上再占一份。Jupyter 里的全局变量生命周期跟脚本完全不一样,脚本跑完进程退出,显存自然回收,所以同样的代码在命令行里没问题。
解决:训练单元开头固定加三行清场,再配合set_memory_growth。
import gc, tensorflow as tf tf.keras.backend.clear_session() # 清掉默认计算图和层命名状态 gc.collect() # 触发一次回收,让旧模型真正被释放清完之后显存占用立刻回落到基线。真要在同一个进程里连着跑多组实验,就把每组实验包成一个函数,函数返回时局部变量自动失效,比在顶层堆一堆model1、model2干净得多。
5.2 训练 20 个 epoch,精度死死卡在 10%
现象:损失在 2.3 附近小幅震荡,准确率始终 0.1000 左右,跟随机猜十类一样。没有报错,没有警告。
原因:标签形状和损失函数不匹配。load_data()返回的y_train是(50000, 1),如果直接喂给SparseCategoricalCrossentropy,TF 会把它当成一个长度为 1 的序列标签去处理,算出来的损失值对但标签全被当成第 0 类附近的东西,模型学到的是一个恒等输出。另一种变体是标签做了 one-hot 但损失用了 Sparse 版本,表现一样。
解决:在数据加载处统一把标签压成一维,并在训练前打一次形状断点。
y_tr = y_tr.reshape(-1) # (N, 1) -> (N,) assert y_tr.ndim == 1 and y_tr.max() < cfg["num_classes"]养成习惯,凡是标签进损失函数之前,都把shape和min/max打一次。代价是一行 print,收益是省下两小时。
5.3 增强加了,验证精度反而比不加低
现象:加完随机裁剪和翻转,训练集精度涨得更慢了,验证精度比不加增强时还低两三个点,两条曲线的缝隙也没收窄。
原因:map(augment)写在batch()之后,augment收到的是整批(128, 32, 32, 3),random_crop的size参数作用在批维度上退化了,整批图被裁在同一个位置、同一个翻转状态。等价于对每个 epoch 做了一个全局的、所有样本一致的变换,既没增加样本多样性,又引入了额外的分布偏移。
解决:把map挪到batch之前(见 3.1 节的build_dataset),并在augment里加一句打印验证它拿到的形状:
def augment(image, label): tf.debugging.assert_rank(image, 3) # 必须是单张 (H, W, C),批进来就会炸 ...assert_rank在生产代码里可以去掉,但写数据增强的时候留一行,能省掉大量"为什么增强没效果"的困惑。
5.4 断点续训之后验证精度突然掉了五个点
现象:训练跑到第 30 个 epoch 手动中断,用model.load_weights("best.keras")恢复继续跑,第 31 个 epoch 的验证精度比中断前那次低了一大截,学习率也回到了初始值。
原因:只存了权重,没存优化器状态。AdamW 内部维护两阶动量,恢复时这两个缓冲区是空的,前几个 step 的更新方向基本靠当前梯度硬拽,相当于把优化器重启了一遍。同时ReduceLROnPlateau的衰减历史也没了,学习率回到1e-3,模型被推离了原来的收敛点。
解决:要么存完整的.keras文件(含优化器状态),要么用tf.train.Checkpoint把模型、优化器、epoch 计数一起管起来。
ckpt = tf.train.Checkpoint(model=model, optimizer=model.optimizer, epoch=tf.Variable(0)) manager = tf.train.CheckpointManager(ckpt, "./checkpoints/resumable", max_to_keep=3) # 训练循环里每个 epoch 末尾:manager.save() # 恢复时:ckpt.restore(manager.latest_checkpoint).expect_partial()expect_partial()是必须的,不然加载旧 checkpoint 时缺字段会直接抛异常。恢复之后把optimizer.learning_rate手动设回中断时的值,再继续跑。
5.5 训练好的模型在别人机器上预测全是同一类
现象:本地evaluate.py跑出来 86%,把模型和一张图发给同事,他那边推理出来十次有九次是 "cat",概率分布还特别平。
原因:预处理不一致。同事的推理脚本直接把0~255的数组喂进了一个期望0~1的模型,或者通道顺序按 BGR 读的图。BN 层对输入尺度极其敏感,输入整体放大 255 倍,激活值直接饱和,输出退化成常数。
解决:把归一化写进模型(3.2 节那个Rescaling层),导出时它会被固化进计算图;同时在导出脚本里加一份preprocess.md,写清楚输入的形状、值域、通道顺序,跟模型文件放一起。后来我干脆在 SavedModel 的 signature 里带上input_name="image_0_255_rgb",名字本身就是文档。
6. 二次开发:换成自己的数据集并压到端侧模型
这个工程真正的价值不在 CIFAR-10 本身,而在它是个可以往里塞数据的壳子。换成自己的 32×32 或者 64×64 分类任务,改动面比想象中小很多。
6.1 换数据集:只需要改四个地方
第一处是config.py里的num_classes和image_size。第二处是数据加载函数,把load_splits换掉,最省事的是走目录式读取:
train_ds = tf.keras.utils.image_dataset_from_directory( "my_data/train", image_size=(cfg["image_size"], cfg["image_size"]), batch_size=cfg["batch_size"], label_mode="int", # int 对应 sparse 损失,categorical 对应 one-hot )第三处是augment里的尺寸常量,40 和 32 这两个数字要跟着image_size一起变,裁剪的 padding 一般取边长的八分之一左右。第四处是model.py的池化次数——输入 64×64 就多加一次MaxPooling2D,输入 32×32 保持两次,输入 128×128 加到四次,保证进GlobalAveragePooling之前的特征图还有 4×4 以上。
改完先别急着训练,跑一遍"一个 batch 的过拟合"验证:拿 32 张图,关掉增强和 dropout,训练 200 步,看能不能把损失压到接近 0。压不下去说明模型结构或者标签映射有问题,压下去了才说明链路是通的。这个检查我每次都做。
6.2 端侧导出:TFLite 转换与前后处理对齐
要在移动端或者边缘设备上跑,转成 TFLite:
converter = tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations = [tf.lite.Optimize.DEFAULT] # 动态范围量化,体积压到 1/4 tflite_model = converter.convert() open("cifar_cnn.tflite", "wb").write(tflite_model)量化之后精度通常会掉 0.5 到 1.5 个点,可接受。如果掉得多,就准备两百张训练图做代表性数据集,走Optimize.DEFAULT加converter.representative_dataset的全整数量化,精度损失能收回一部分。
转换完必须做一次数值对齐验证:同一张图分别用 Keras 模型和 TFLite 解释器跑,比较两个 logits 向量的余弦相似度,低于 0.99 就说明转换过程出了问题,常见原因是模型里有 TFLite 不支持的自定义算子被替换了。
interp = tf.lite.Interpreter(model_path="cifar_cnn.tflite") interp.allocate_tensors() inp = interp.get_input_details()[0] interp.set_tensor(inp["index"], batch_0_255) # 注意 dtype,量化模型是 int8 interp.invoke() tflite_logits = interp.get_tensor(interp.get_output_details()[0]["index"])量化模型的输入是int8,需要在喂之前按scale和zero_point做转换,别直接把 float 塞进去,解释器不报错但结果全错。
6.3 改完之后照着这张表验一遍
| 检查项 | 怎么验 | 不通过说明什么 |
|---|---|---|
| 形状链路 | 打印每个 batch 的x.shape、y.shape | 数据管道或批次维度出错 |
| 标签映射 | 抽样 20 张图人眼核对类别名 | class_names顺序和训练时不一致 |
| 过拟合小样本 | 32 张图训练 200 步,损失应接近 0 | 模型结构或损失配置有问题 |
| 增强生效 | 同一 batch 内可视化 8 张增强后的图 | map位置错了或增强幅度为零 |
| 导出一致性 | Keras 与 TFLite 输出余弦相似度大于 0.99 | 转换丢算子或预处理没固化 |
| 端到端 | 从原始文件读图一路到类别名 | 前后处理在某处漏了一步 |
最后一行表格是我吃过亏才加上的:曾经有一次模型精度没问题、TFLite 转换也没问题,卡在客户端读图的通道顺序上,BGR 当 RGB 喂进去,整批预测都偏成了"卡车"。查了两天才发现是拍照库的默认格式。
从那以后我每次换数据集,都强制走一遍完整验证,尤其是"从原始文件读一张图,一路走到打印类别名"这一条——不看配置文件、不看中间变量,就看最终输出对不对。模型这行当里,中间任何一步的自我感觉良好都不作数,只有端到端跑通的那一次才算数。希望帮到你。
本文还有配套的精品资源,点击获取