从数据到部署:用Keras迁移学习实现食物图像分类
先聊点实际的。上个月有个朋友找我说手里攒了一万张外卖评论图想按菜品自动归类手工打标签打到怀疑人生。我帮他跑完这套流程顺手把代码整理了一下——这件事让我又一次确认图像分类没有那么玄乎把它拆成“数据准备、模型选型、训练调参、结果评估”四步每个环节都有章可循。这篇文章就把我这次做食物识别器的完整过程讲一遍从OpenCV图像预处理开始到Keras迁移学习训练模型再到最后的评估排查全部基于我实际跑通的代码。如果你正好想做一个自己的图像分类器或者刚看完本系列前面几篇关于图像处理基础的内容想进阶到分类实战这篇文章可以直接拿来当参考。1. 项目整体思路拆解从一个真实需求说起1.1 这个分类器到底要解决什么问题先明确任务给一张食物照片判断它属于哪一类。我这次选择了五个最常见的类别——披萨、寿司、汉堡、沙拉、拉面。这个选类不是随便拍的它覆盖了西餐、日料、轻食和中餐面食形态差异足够大模型容易学到区分性特征。如果你的项目里类别之间长得很像比如“意面”和“炒面”那预处理的细节就要更讲究后面我会专门讲这个问题。项目的输入输出也很清楚输入是一张RGB图片尺寸不限输出是一个五分类的概率分布。整套流程包含三块图像预处理负责把任意尺寸的图片变成模型可接受的固定尺寸张量特征提取与分类我用的是迁移学习直接拿ImageNet上预训练好的模型当特征提取器训练与评估在自有数据集上微调然后用混淆矩阵确认每个类别的真实表现。1.2 为什么选“迁移学习”而不是从零训练很多人一上手就想自己搭一个CNN从零训练听我一句劝如果你的数据集没有几十万张这条路非常容易夭折。从零训练的卷积神经网络需要同时学习“边缘、纹理、形状”这些底层特征和“披萨上的芝士拉丝”这种高层语义特征数据量不够的时候模型会把背景噪声当成重要特征典型表现就是训练集准确率接近100%验证集卡在60%上不去。迁移学习的逻辑完全不同预训练模型已经在ImageNet上见过一千多类物体它的前几层卷积核学到的是通用的边缘、角点、纹理检测器。我们做的事情说通俗点就是“请一个已经训练有素的实习生来干活只需要告诉他你们公司的具体规矩”。这个“规矩”就是后面加上的分类层以及在新数据集上的微调。1.3 技术栈选型与版本踩坑提醒我用的是Python 3.8 OpenCV 4.5 TensorFlow 2.6 Keras 2.6环境很经典网上资料也最多。有一点必须提醒你TensorFlow 2.5之前和之后的API有些细微差别尤其是keras.applications的导入路径如果你用的是TF 1.x时代的旧教程很可能会卡在from keras.applications import ResNet50这种导入错误上。新版本的写法是from tensorflow.keras.applications import ResNet50我们统一用这个。另外OpenCV在这里的主要职责是图像读取和缩放不要指望它去做特征工程——特征提取这件事已经完全交给深度模型了。我前几篇系列文章里反复强调过OpenCV的图像金字塔和缩放技巧这次正好用上。真实场景的图片尺寸五花八门相机拍出来的是4032×3024网图可能是500×500直接塞进神经网络肯定行不通。缩放这一步做得是否规范直接影响最终准确率所以下一节先把数据这条命脉讲透。2. 数据准备与预处理决定成败的第一关2.1 数据集从哪来怎么构建我这次用的数据集是自己攒的每类300张训练图、80张验证图、40张测试图总共五类合计2100张。很多人问公开数据集不是有现成的Food-101吗为什么不用Food-101有101个类别、每类1000张很权威但它类别太多跑一次完整的训练非常耗时而且很多类别的图片包含大量桌布、手部、餐具等干扰信息。自己做一个小而干净的数据集反而能更快验证流程的正确性。收集数据有几个硬性要求第一图片必须包含明确的主体不能是一桌子菜那种大场景第二同一类的图片要尽可能多样化披萨要有圆盘装的、厚底的、薄底的不能全是同一个角度同一家店的图第三样本类别之间数量要均衡我见过有人五类数据分别是800、600、300、200、50模型最后直接把最少那类全部预测错原因就是训练样本太少根本学不到该类别的稳定特征。2.2 预处理流水线的三个关键步骤我的预处理函数一共有三步每一步都有它的实际作用不是走过场。第一步缩放。无论原图多大统一缩放到224×224像素。这个尺寸不是随便定的ResNet50的默认输入尺寸就是224×224用其他尺寸要么报错要么需要额外配置。缩放的实现我建议用OpenCV的cv2.resize插值方法在缩小图片时用INTER_AREA可以避免出现明显的摩尔纹和锯齿放大图片时用INTER_LINEAR速度和质量比较均衡。第二步归一化。像素值从0到255的范围压缩到0到1之间用astype(float) / 255.0实现。这一步看起来简单但它对梯度下降的收敛速度影响非常大。你想想如果输入特征的范围是0到255而模型权重初始值通常在-1到1之间两者相乘后的数值范围很发散梯度更新会非常不稳定。归一化之后所有输入都落在一个温和的区间里优化器才能顺利干活。第三步数据增强。这一步是我反复调试之后确认有明显收益的环节也是本系列前面文章没细讲过的地方。我用Keras的ImageDataGenerator做随机旋转、平移、翻转、缩放、错切在训练时每次迭代都会对输入图片做随机的几何变换相当于同一张图被衍生出无数个“新样本”。为什么这能提升泛化能力因为模型不再死记硬背某一张图的像素排列而是学习类别不变的本质特征。餐桌照片里同一个汉堡可能是横着拍的、斜着拍的、俯拍的增强之后的模型对拍摄角度的变化就不那么敏感。from tensorflow.keras.preprocessing.image import ImageDataGenerator train_datagen ImageDataGenerator( rescale1.0 / 255.0, rotation_range20, width_shift_range0.2, height_shift_range0.2, shear_range0.2, zoom_range0.2, horizontal_flipTrue, fill_modenearest )这里有个细节验证集和测试集绝对不能做增强只能做归一化。原因很简单验证集是用来模拟“真实世界里来了一张新照片”的场景真实照片不会自动翻转和旋转。如果你给验证集也做了随机变换那评估出来的指标就是带水分的不能真实反映模型在新数据上的表现。我见过有人嫌麻烦一个ImageDataGenerator通吃所有数据集最后验证集准确率虚高部署之后实际效果打对折。2.3 数据平衡与标签编码的实操细节标签不能直接用字符串喂给模型要做one-hot编码也就是把“披萨”变成[1,0,0,0,0]这种形式。用Keras的to_categorical可以轻松实现。与之配套的输出层的激活函数必须是softmax它会把五个神经元的输出变成一组和为1的概率值。很多刚入门的同学容易在这里配错输出层用了sigmoid那是二分类或者多标签分类的写法用在五分类上会导致每个类别的输出概率不是互斥的训练出来的结果没法解释。数据预处理完成后下一步是选模型。这一步我要讲透一点因为选型直接决定了你的训练时间、显存占用和最终效果的上限。3. 模型选型解析为什么ResNet50是个稳妥的默认选项3.1 主流预训练模型怎么选Keras的applications模块里提供了好几个现成的预训练模型最常用的有VGG16、ResNet50、InceptionV3、EfficientNet。它们的核心差别在于网络深度、参数量和设计思路。VGG16是2014年的经典结构思路极其朴素就是堆卷积层参数量高达1.38亿跑一次训练非常吃显存而且它没有解决深层网络的梯度消失问题。ResNet50引入了残差连接简单说就是让每一层除了学习新的特征变换之外还保留一条“直达通道”把前面的信息原封不动地传过来这样即使网络深到50层梯度也能顺畅地回传。InceptionV3走的是另一条路用不同尺寸的卷积核并行处理再拼接对算力的利用效率更高。EfficientNet则是近几年的新贵通过复合缩放达到性能与参数量之间的平衡。我个人给的建议是第一次做迁移学习优先选ResNet50。它的结构足够深特征表达能力强在ImageNet上的性能有保障而且算力要求适中。我这次用的是普通GPU4G显存ResNet50配合batch_size32训练得很流畅。VGG16我再也不碰了训练一轮的时间差不多是ResNet50的3倍准确率还没什么优势。3.2 冻结与微调哪些层该冻哪些层该解冻迁移学习有一个关键操作把预训练模型前面的卷积基冻结住只训练后面新增的全连接层。冻结的意思是设置layer.trainable False这些层的权重在训练过程中不更新。为什么要冻结因为ImageNet预训练权重已经是千锤百炼的通用特征提取器如果一开始就允许微调它而我们手里的数据只有一千多张模型很容易发生灾难性遗忘——把学好的通用特征弄坏去拟合我们的小数据集。实际操作中我的策略是分两阶段训练。第一阶段冻结全部卷积基只训练新增的全连接层。这个阶段跑完验证准确率通常已经能到85%左右说明新分类层已经学会了如何利用预训练特征做决策。第二阶段解冻ResNet50最后几层卷积层具体是resnet.trainable True之后再手动保留前143层的冻结状态让后几层参与微调。这个阶段用更小的学习率比如1e-5让高层特征向我们的食物类别方向做精细调整。这个流程跑完准确率能再提升5个点左右。3.3 自定义分类头的设计要点预训练模型输出的特征向量维度很高ResNet50的全局平均池化输出是2048维。后面接的分类头我设计了两层全连接第一层128个神经元用ReLU激活加Dropout0.5第二层就是5个神经元的输出层用softmax。Dropout在训练时随机丢弃一半的神经元连接强制网络不要把赌注押在某几个神经元上从而降低过拟合。这个设计也被大量实际项目验证过属于性价比最高的分类头结构。from tensorflow.keras.applications import ResNet50 from tensorflow.keras.models import Model from tensorflow.keras.layers import Dense, Dropout, GlobalAveragePooling2D base_model ResNet50(weightsimagenet, include_topFalse, input_shape(224, 224, 3)) x base_model.output x GlobalAveragePooling2D()(x) x Dense(128, activationrelu)(x) x Dropout(0.5)(x) predictions Dense(5, activationsoftmax)(x) model Model(inputsbase_model.input, outputspredictions)include_topFalse这个参数要重点解释一下它表示不加载ImageNet的1000类输出层只保留卷积基。因为我们要分类的类别是5类跟ImageNet的1000类完全不同那个输出层对我们没有意义。如果不设这个参数模型直接带着1000类的输出层后面还得手动去掉白白增加工作量。选型定下来之后最激动的部分来了训练。我先把话放这儿训练环节80%的坑都出在超参数上所以这一节我会把每个参数为什么这么设置讲清楚。4. 训练过程实录超参数、代码与调参心得4.1 损失函数和优化器的选择逻辑多分类任务的标准损失函数是categorical_crossentropy它衡量的是预测概率分布与真实标签分布之间的差距。我们期望的极端情况是真实类别概率为1其余为0。交叉熵在这个目标下会给出非常陡峭的梯度信号让模型快速学会“自信地”做判断。优化器我选Adam初始学习率1e-4。Adam相当于给每个参数配了自适应学习率前期收敛快后期不容易震荡。为什么不直接用SGDSGD的手动调参要求高学习率、动量、衰减都要精细设置对于第一次做项目的朋友来说太劝退。等整个流程跑通之后如果你的数据量很大可以再尝试把Adam换成SGD加余弦退火通常还能有一两个点的提升但那是进阶玩法。4.2 完整训练代码与训练过程观察from tensorflow.keras.optimizers import Adam from tensorflow.keras.utils import to_categorical import numpy as np model.compile( losscategorical_crossentropy, optimizerAdam(learning_rate1e-4), metrics[accuracy] ) # 假设 trainX, trainY, valX, valY 已经通过预处理得到 trainY_cat to_categorical(trainY, num_classes5) valY_cat to_categorical(valY, num_classes5) history model.fit( train_datagen.flow(trainX, trainY_cat, batch_size32), steps_per_epochlen(trainX) // 32, validation_data(valX, valY_cat), epochs25, verbose1 )训练过程我建议盯住两个指标训练loss和验证loss。一个健康的训练曲线应该是这样前几个epoch两个loss都快速下降中段训练loss继续下降验证loss开始波动如果训练loss降得很低但验证loss反而升高那就是过拟合了需要增加Dropout或者提前停止。我用EarlyStopping设置patience3验证loss连续3个epoch不下降就自动停止这套机制能省下大量时间。我在第一轮跑的时候epoch设了50但实际跑到第21个epoch就触发了提前停止。最终验证准确率92.7%测试准确率91.8%。这里有个经验如果验证准确率比训练准确率低得太多比如差了15个百分点以上那就是过拟合如果两者都低那就是欠拟合需要增加模型容量或者放宽冻结层。4.3 学习率调整策略什么时候该动它训练过程中如果loss曲线在某个阶段呈“台阶状”长期不下降那多半是陷入局部最优了。这时候手动降低学习率是常见做法我习惯用ReduceLROnPlateau监测验证loss如果连续3个epoch没有改善就把学习率乘以0.5。这里要说一个细节一个周期内不要降太多次最多降两三次就够了。我自己试过把学习率狂降到底结果是模型在训练集上近乎“背诵”了所有样本验证集上反而崩盘。from tensorflow.keras.callbacks import ReduceLROnPlateau reduce_lr ReduceLROnPlateau( monitorval_loss, factor0.5, patience3, min_lr1e-6, verbose1 )第二轮训练时因为第一轮已经接近收敛我会解冻部分卷积层再用1e-5的学习率去微调。这个阶段的loss一开始可能会反弹一点别慌这是正常现象。解冻的层在适应新任务时旧知识的权重会产生波动大概两三个epoch之后loss就会重新下降而且会降到比之前更低的位置。训练完模型很多人直接看一个准确率就收工了。这是最典型的偷懒行为。一个总体准确率90%的模型可能其中某一类准确率只有60%这种隐患部署上线之后才会暴露。所以评估环节不能省。5. 模型评估与常见问题排查实测数据与速查表5.1 混淆矩阵怎么读混淆矩阵比准确率诚实得多。我这次测试集一共200张图每类40张输出一个5×5矩阵行是真实类别列是预测类别。我直接贴一下我跑出来的矩阵预测披萨预测寿司预测汉堡预测沙拉预测拉面实际披萨380110实际寿司037201实际汉堡213610实际沙拉100390实际拉面010039从矩阵里能看到几个信息。第一拉面和沙拉表现最好40张只错了1张因为它们的视觉形态非常独特拉面的碗和面条纹理、沙拉的绿色菜叶都很难跟其他类别混淆。第二汉堡和寿司之间有一定混淆仔细看被错分的样本大多是俯拍的寿司拼盘米粒和面包胚的颜色相近而且都有肉类纹理。第三披萨被预测成汉堡2张共同点是图片上都是俯拍、主体占画面比例偏小模型能看到的细节不够。如果没有混淆矩阵我只能看到一个绿色的92%数字看不到这些结构性的错误更没办法针对性地补强数据。5.2 分类报告与阈值调整分类报告里每个类别的精确率、召回率、F1分数都要看。精确率是“模型说这是披萨实际真是披萨的比例”召回率是“实际是披萨的图模型找出多少”。如果某一类的召回率低说明这个类别经常被漏掉解决办法是增加该类别的训练样本或者做针对性增强。如果某一类的精确率低说明它经常被误报需要让模型看到更多“容易混淆的负样本”。另外一个小技巧不要默认取概率最大的那个类别作为最终输出。在部署阶段我会给模型设一个置信度阈值比如概率低于0.6时返回“不确定”让用户确认而不是硬猜。这个操作在真实业务场景里非常重要因为餐饮评论里的图五花八门有拍收据的、有拍环境装修的模型对这类非食物图片应该有“拒绝回答”的能力而不是强行分类。5.3 常见问题速查表我把训练过程中最容易踩的坑整理成了一张表都是我实际遇到并解决的问题现象可能原因解决方案训练loss不下降一直徘徊在1.6左右学习率过大或过小尝试1e-3到1e-5区间观察前5个epoch的loss趋势训练准确率很高验证准确率很低过拟合增加Dropout比例、增强数据增强强度、提前停止验证loss在epoch中途突然暴涨学习率过高导致梯度爆炸降低学习率检查是否有数据预处理异常某个类别准确率明显低于其他类该类样本不足或场景单一增加该类样本的收集做针对性增强旋转、色彩抖动模型对非目标图片给出高置信度预测训练数据里缺少负样本添加“其他类”专门收集场景图、噪声图作为第6类解冻微调后loss先升后降正常现象继续训练3到5个epoch观察趋势不要慌预测结果全偏向多数类数据不平衡采用类别权重class_weight或重新平衡数据集这里面的“添加负样本”这一条尤其值得说道。我最初只设了五个类别测试的时候故意扔进一张拍咖啡的照片模型居然以极高的置信度预测成了“沙拉”。原因不复杂训练时它从没见过咖啡而咖啡杯里的拉花纹理跟沙拉的绿叶纹理在低分辨率下确实有几分相似。后来我在训练集里加了一个“其他”类别收集了各种非食物的图片模型学会了在这些样本上输出低置信度或者归入其他类这个问题就缓解了。很多公开的分类模型demo都会有这个毛病恰恰是新手最容易忽略的。5.4 模型体积与推理速度的实际测试训练完的模型文件大小是98MB这个体积部署到服务器没问题但放到移动端就偏大了。我测了一下推理速度在CPU上用ResNet50跑单张224×224图片平均耗时180毫秒在GPU上大概30毫秒。如果你的场景对延迟敏感有两个优化方向一是换轻量级模型比如MobileNetV3体积只有20MB左右准确率会低两三个点二是用TensorFlow Lite做量化把模型转成INT8格式体积能压缩到四分之一推理速度提升明显。对这个食物识别项目来说180毫秒的CPU延迟完全够用毕竟用户上传一张图然后等待分类结果200毫秒以内的响应很难察觉。我只做了批量推理接口的优化一次接受多张图片请求而不是逐张开进程。6. 部署到真实场景的几点补充6.1 模型导出的标准姿势训练结束后我习惯把整个模型连同权重一起保存为.h5文件。有同学问搞这么麻烦干嘛直接把训练脚本留着不就行了你想想部署环境里通常没有训练代码、没有Keras的后端细节一个独立的模型文件最干净。model.save(food_classifier.h5)部署端加载的时候只需要一行from tensorflow.keras.models import load_model model load_model(food_classifier.h5)预处理函数要跟训练时完全一致这个坑我踩过训练时做了归一化部署时忘了除255预测结果直接乱套。建议把预处理逻辑封装成一个函数训练和部署共用同一份代码。输出层的处理也要注意。Keras预测出来的是一个五维概率向量比如[0.92, 0.03, 0.02, 0.02, 0.01]要取最大值对应的索引再映射回类别名。我建议把这个映射关系单独放在一个JSON文件里而不是写死在代码中方便以后扩展类别。6.2 从一个分类器到一个实用系统单个分类器只能回答“这是什么菜”但真实需求往往更复杂。比如那位朋友的实际场景是一张评论图里可能同时有披萨和沙拉这就不是单标签分类能解决的需要换成多标签分类输出层改成sigmoid每个类别独立判断有或没有。还有一类需求是定位加分类比如检测出图里所有食物区域再对每个区域分类。这个需要引入目标检测模型常见的方案是Faster R-CNN或者YOLO加分类头。从单标签分类升级到目标检测数据标注的工作量会大一个量级因为每张图要画边界框。我的建议是先确认业务真的需要知道食物的位置如果只是给整张图打个标签就不要贸然上检测模型。部署形态上也有一点经验值得分享。我做了一个非常简单的Flask接口接收上传图片返回类别和置信度然后在本地用Postman测试了二三十张图片才正式接入业务系统。先跑通单机接口再考虑容器化部署这个顺序能少踩很多坑。容器化的时候关注一下模型文件是否被打包进去我有一个同事就是因为Docker镜像里漏了模型文件白白排查了一个下午。6.3 关于模型效果的持续维护图像分类模型上线运行之后需要持续收集它的错误预测样本定期补充到训练集中做增量训练。食物图片的风格会随时间变化比如最近流行的那种暗色调探店摄影跟两年前的照片风格完全不同模型如果不更新准确率会逐渐下滑。我那位朋友的做法是每周导出一次预测失败的样本人工确认后按类别归档每个月做一次微调效果一直保持得不错。我做这个项目的过程中最深的一点体会是图像分类项目里数据和调参的重要性远高于模型结构本身。这个食物分类器能跑到92%的准确率靠的不是什么惊世骇俗的网络结构而是一千多张认真清洗的图片、规范的数据增强、以及分阶段的冻结微调策略。把这些基础步骤老老实实做好大多数项目都能得到满意的结果。最后再分享一个小技巧也是我每次做分类项目都会做的收尾动作把测试集里预测失败的样本挨个打印出来配上真实标签和预测标签肉眼扫一遍。这个动作花不了几分钟但你能直观看到模型哪类特征没学到是数据问题还是模型问题一眼就能判断。很多时候比盯着loss曲线瞎猜高效得多。