基于PyTorch的CNN食物识别模型:从原理到实战
你打开外卖App对着餐盒拍一张照片系统自动识别出“宫保鸡丁”和“米饭”然后完成结算。食堂的结算台前摄像头一扫餐盘里的菜品被逐项识别计价。这些场景背后核心的技术单元就是卷积神经网络CNN。我在接触深度学习之后第一个真正跑通并反复调优的项目就是用PyTorch实现一个基于CNN的食物识别模型。这个项目不大但麻雀虽小五脏俱全数据加载、模型设计、训练调参、评估优化一个常规图像分类任务的完整链条全都走了一遍。这篇文章就把CNN的基本原理和PyTorch实现过程整理出来既有理论简述也有完整可复现的代码最后还会聊聊我实际踩过的一些坑适合有Python基础、想通过具体项目入门深度学习的开发者。1. CNN简述卷积神经网络为什么能“看懂”食物图片1.1 卷积层像探照灯一样扫描食物图像的纹理和边缘卷积层是CNN的核心它做的事情用一个生活化的类比来解释就是一张食物图片被切分成很多小区域卷积核像探照灯一样每次只照亮一个小方块区域然后提取这个小区域的特征接着平移一个步长去照亮下一个区域最后把每次提取到的特征按照空间位置拼成一张新的特征图。这个过程就是卷积运算。用食物识别来理解会更直观。一张披萨的图片包含大量信息饼底的焦黄色泽、芝士拉丝的纹理、香肠片圆润的边缘轮廓、番茄酱的红色区域。这些信息在计算机眼里就是一个巨大的像素矩阵单独看每一个像素其实毫无意义但如果看一个局部区域——比如芝士的纹理就会发现相似的像素排列在水平或垂直方向上形成了明显的变化规律卷积核恰恰擅长捕捉这种规律。一个3x3的卷积核如果设计成检测水平边缘它会在披萨饼底边缘的位置产生强烈的响应换成另一个检测圆形的卷积核它又会对香肠片的轮廓产生激活。卷积层把这种方式推广到整个网络。浅层卷积核捕捉的通常是线条、颜色块、角点等基础特征深层卷积核则在浅层特征的基础上组合出更复杂的语义——比如“芝士拉丝”“蔬菜丁”“肉片纹理”。这也是CNN与传统全连接网络最本质的区别传统网络把整张图片展平成向量彻底破坏了空间结构卷积操作则天然保留了图片的局部空间关系所以理解图像内容时特别擅长。单个卷积层里通常会有多个卷积核每个卷积核负责一种特征模式。假设输入是一张224x224x3的彩色图片第一层设置32个3x3卷积核输出就是32张224x224的特征图每张特征图代表一种被激活的模式。如果设置padding为1步长为1输出尺寸可以保持和输入一致这样信息不会有空间压缩。这层的参数量大概是3x3x3x32864只加上偏置的话是896个参数——非常小这也为后面的训练稳定性打下了基础。1.2 池化层压缩特征图留下“最重要的特征”卷积层之后通常会接一个池化层。池化层做的事情也很简单在特征图的每个小窗口里取一个代表值。最常用的是最大池化比如2x2的窗口步长为2那么每个窗口里选出数值最大的那个保留下来。特征图的宽高各缩小一倍但通道数不变。池化的意义可以用一个具体场景来说明一盘红烧肉在照片里可能摆在盘子的正中央也可能稍微偏左一点甚至盘子倾斜角度略有不同。我们希望模型无论肉块出现在哪个位置都能识别出它是红烧肉。最大池化天然带有这种平移不变性——只要某个特征在窗口内出现它就会被保留下来至于具体出现在窗口的左上角还是右下角池化层并不关心。这个特性让CNN对食物的摆放位置、拍摄角度有很强的容忍度。还有个很实际的好处是计算量下降。特征图从224x224变成112x112数据量直接减少到四分之一后续层的计算负担大幅降低同时也在一定程度上减少了过拟合。传统全连接网络参数量动辄上千万很大程度是因为图片太大、层与层之间全部连接而CNN通过池化源源不断地压缩空间尺寸最终送到全连接层的数据量已经很小整体参数量自然可控。1.3 激活函数与全连接层把特征变成“这盘菜是什么”卷积和池化做的都是特征提取真正做“决策”的是网络的最后几层。在卷积层之间通常会插入激活函数最常见的还是ReLU公式是f(x)max(0,x)。它的作用是引入非线性能力。如果不用激活函数多层卷积和一层卷积在数学上其实是等价的因为线性变换的叠加仍然是线性变换网络就永远学不会复杂模式。ReLU计算极快梯度传导也比早年的Sigmoid顺畅得多能够有效缓解梯度消失的问题。最后几个全连接层的作用是把前面提取到的抽象特征进行加权整合。例如在最后一层之前特征图已经抽象成类似“是否有波浪形芝士纹理”“是否有圆形肉片”“底层底色是否偏黄”等高级信息全连接层就是把所有这些信息汇总起来依据每个类别的得分做最终判断。输出的类别得分会通过Softmax函数转换成概率分布。Softmax做的事情非常直白先把每个得分取指数保证非负再除以所有指数的总和让所有类别的概率加起来等于1。比如输出层是5个类别某个样本的输出得分是[2.0, 1.0, 0.1, 0.3, 0.5]经过Softmax后概率分布可能变成[0.42, 0.16, 0.06, 0.08, 0.09]概率最大的就是模型预测的结果。训练过程中我们会用交叉熵损失函数来计算真实标签和预测概率之间的差距然后通过反向传播更新网络权重。1.4 为什么食物识别偏偏适合CNN食物识别这个任务有一个特点类内差异大、类间差异小。同样是“炒饭”不同餐厅做出来的颜色、配料、摆盘差异极大而“宫保鸡丁”和“鱼香肉丝”在很多图片里都是红棕色调带一点辣椒段和花生米/笋丝光看整体颜色直方图很难区分。这要求模型能够关注到局部细节特征——鸡肉丁的形状、酱汁的浓稠度、配菜的分布CNN的局部感受野机制正好应对这种需求。另一个原因是食物图片的背景通常非常杂乱可能是餐桌纹理、别人的手、餐具的反光也可能是打包盒的Logo。全连接网络看待整张图片的每个像素权重分配到背景上造成干扰CNN通过滑动卷积核提取局部特征即使背景变化很大只要目标食物的特征存在卷积层依然能提取出关键信号。加上池化层的平移不变性同一个菜品换个盘子、换个角度模型依然能认出来。此外CNN的权值共享特性也带来了参数量的优势。一个3x3卷积核在整张图片上滑动无论图片多大它自身的参数量都只有那些不像全连接层每加一个像素节点都会引入大量新权重。这使得CNN在训练数据有限的食物识别任务中更不容易过拟合。2. 数据准备构建食物识别数据集与预处理流程2.1 数据集的选取与目录结构设计食物识别常用的公开数据集有Food-101、Recipe1M等其中Food-101包含101个类别的食物图片每一类大约1000张非常适合入门。如果你打算针对食堂的特定菜品做识别自建数据集也是一个方案但需要花较长时间去采集。这里我以分类数为5的自制小数据集为例演示完整的实现因为数量小、训练快更适合理解整个流程。如果你的目标是复现Food-101级别的模型把类别数改成101、数据路径改成对应的目录即可。推荐的数据目录结构如下PyTorch的ImageFolder能直接识别这种结构非常方便data/ train/ pizza/ pizza_001.jpg pizza_002.jpg sushi/ sushi_001.jpg burger/ ramen/ salad/ val/ pizza/ sushi/ burger/ ramen/ salad/每个子文件夹的名称就是类别标签ImageFolder会按照文件夹名字自动生成从0开始的类别索引。这样就不用手动维护一个CSV标签文件了省去很多麻烦。2.2 数据增强与transforms配置食物图片的光照、角度、摆盘千差万别为了让模型有更好的泛化能力一定要做数据增强。训练集和验证集的transforms通常分开配置from torchvision import transforms train_transforms transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.8, 1.0)), transforms.RandomRotation(15), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transforms transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])这里需要解释几个关键选择RandomResizedCrop会随机裁剪图片的一部分再缩放到224x224模拟食物在画面中大小不一的情况同时强迫模型看到食物的局部特征而不是只依赖整图构图。RandomRotation(15)只做正负15度的微小旋转因为食物照片通常不会倒过来旋转角度过大会让模型学到错误的不变性假设。ColorJitter调节亮度、对比度、饱和度模拟不同光线条件下的拍摄效果。Normalize的mean和std用的是ImageNet统计值这是torchvision预训练模型的通用约定。即使你不加载预训练权重使用这个归一化数值也能让数据分布更稳定。有个细节需要注意验证集不能做随机增强否则每次评估结果都会波动无法真实反映模型性能。所以验证集只做Resize和CenterCrop保证评估的确定性。2.3 使用ImageFolder与DataLoader加载数据from torchvision import datasets from torch.utils.data import DataLoader train_dataset datasets.ImageFolder(rootdata/train, transformtrain_transforms) val_dataset datasets.ImageFolder(rootdata/val, transformval_transforms) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue) print(训练集类别数:, len(train_dataset.classes)) print(训练集样本数:, len(train_dataset)) print(类别映射:, train_dataset.class_to_idx)ImageFolder会自动扫描目录下的子文件夹建立类别映射。DataLoader里面的几个参数是我反复调整过的shuffleTrue对训练集是必须的每个epoch都要重新打乱数据顺序防止模型学习到样本顺序的伪规律。num_workers设置数据加载的子进程数。如果你的机器有8个CPU核心设成4通常是一个不错的起点设太大会在数据量小的时候反而增加进程切换开销。pin_memory在GPU训练时可以加速CPU到GPU的数据传输特别是batch_size较大时效果明显。如果你的数据不是标准的文件夹结构而是一个大文件夹里有些图片配一个CSV标注文件可以自己写一个Dataset子类。重写__init__、__len__、__getitem__三个方法即可核心是在__getitem__中返回(image_tensor, label)的元组和ImageFolder的返回格式保持一致后续训练代码完全不用改。3. 模型搭建用PyTorch从零定义一个CNN3.1 网络结构设计思路自定义一个小型CNN输入是3x224x224的RGB食物图片输出是5个类别的得分。网络结构包含三个卷积块每个卷积块由卷积层、ReLU激活、最大池化组成最后接两个全连接层进行分类。import torch import torch.nn as nn import torch.nn.functional as F class FoodCNN(nn.Module): def __init__(self, num_classes5): super(FoodCNN, self).__init__() self.conv1 nn.Sequential( nn.Conv2d(3, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2) ) self.conv2 nn.Sequential( nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2) ) self.conv3 nn.Sequential( nn.Conv2d(64, 128, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2) ) self.fc1 nn.Sequential( nn.Linear(128 * 28 * 28, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5) ) self.fc2 nn.Linear(256, num_classes) def forward(self, x): x self.conv1(x) x self.conv2(x) x self.conv3(x) x x.view(x.size(0), -1) x self.fc1(x) x self.fc2(x) return x这张网络各层的尺寸变化需要手动算清楚。输入224x224经过第一个池化变成112x112第二个池化成56x56第三个池化成28x28通道数则从3变成32、64、128。展平之后是128x28x28100352个特征送到第一个全连接层它有100352x256个权重——大概是2560万个参数在小型CNN里已经不算少了。所以我加了Dropout(0.5)来降低过拟合风险。如果你觉得参数量太大可以在第三个卷积块不再做池化或者把第一个全连接层的输出降到128。我后来在实际调优中发现对于1000张左右的训练数据256维的全连接层配合Dropout效果最稳太大容易过拟合太小分类能力不足。3.2 损失函数与优化器选择分类任务最常用的损失函数是交叉熵损失PyTorch里封装为nn.CrossEntropyLoss()它会自动把网络输出的原始得分转换成概率并计算损失。这个函数有两个隐含的坑我第一次用的时候踩过一是不要在最后一个全连接层加Softmax因为CrossEntropyLoss内部已经做了Softmax再加就是双重计算影响数值稳定性二是它的target需要是整数类型的类别索引而不是one-hot向量。优化器选择上Adam是入门阶段最省心的选择它对学习率的敏感性比SGD低在图像分类任务上收敛也快。不过如果希望精度冲得更高可以先用Adam快速跑通再切换到SGD配合动量进行精调。我的经验是Adam设置lr0.001SGD设置lr0.01、momentum0.9、weight_decay1e-4两种方案在这个数据集上都表现不错。import torch.optim as optim model FoodCNN(num_classes5) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001)3.3 训练循环与模型保存训练循环的写法在PyTorch项目里几乎像一个固定的模板一个外层epoch循环内层先遍历train_loader完成前向和反向传播再遍历val_loader做评估。我习惯把训练和评估拆成两个函数逻辑更清晰。def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() running_loss 0.0 correct 0 total 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() running_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() epoch_loss running_loss / total epoch_acc correct / total return epoch_loss, epoch_acc def validate(model, val_loader, criterion, device): model.eval() running_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) running_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() epoch_loss running_loss / total epoch_acc correct / total return epoch_loss, epoch_acc训练时有两个重要的习惯第一model.train()和model.eval()必须成对出现。train()模式下Dropout生效BatchNorm会更新均值和方差eval()模式下Dropout关闭BatchNorm使用累积的统计量。如果不在验证前切换到eval()会得到非常不稳定的评估结果。第二每次反向传播之前要调用optimizer.zero_grad()。PyTorch的梯度是累积的如果不清零多个batch的梯度会叠加到一起导致权重更新方向完全错误。这是我一开始经常忘记的细节也是最容易排查的Bug之一。主循环如下device torch.device(cuda if torch.cuda.is_available() else cpu) print(当前设备:, device) model model.to(device) num_epochs 30 best_acc 0.0 for epoch in range(num_epochs): train_loss, train_acc train_one_epoch( model, train_loader, criterion, optimizer, device ) val_loss, val_acc validate(model, val_loader, criterion, device) print(fEpoch {epoch1:03d} | fTrain Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | fVal Loss: {val_loss:.4f} | Val Acc: {val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth) print(f 模型已保存验证准确率: {best_acc:.4f})保存模型时需要注意model.state_dict()保存的是参数占地面积小加载时还需要先实例化一个结构完全相同的模型再调用load_state_dict。torch.save(model)把整个模型对象保存下来虽然在加载时更方便但兼容性差当代码更新后旧模型可能加载失败。我强烈建议保存state_dict这是实际项目里的通用做法。3.4 单张图片预测与输出解释训练完成后做单张图片预测是很有成就感的环节但代码里有个容易犯的错误单个图片张量缺少batch这一维。PyTorch的模型默认输入是[N, C, H, W]而单张图片读出来是[C, H, W]直接用model(img)会报维度错误。解决办法是用unsqueeze(0)在最前面增加一维。from PIL import Image def predict_image(image_path, model, class_names, device): model.eval() image Image.open(image_path).convert(RGB) image val_transforms(image) image image.unsqueeze(0).to(device) with torch.no_grad(): outputs model(image) probabilities torch.softmax(outputs, dim1) confidence, predicted torch.max(probabilities, 1) class_idx predicted.item() class_name class_names[class_idx] confidence confidence.item() return class_name, confidence调用时传入类别名称列表class_names train_dataset.classes name, conf predict_image(test_pizza.jpg, model, class_names, device) print(f预测结果: {name}置信度: {conf:.4f})需要强调的是class_names的顺序要和训练时ImageFolder生成的class_to_idx顺序一致。如果你换了数据集重新生成映射之后再传入列表否则预测的标签和实际含义会错位。4. 训练过程中的常见问题与调优经验4.1 过拟合准确率上不去、验证集波动大的元凶在我第一次训练这个食物识别模型时30个epoch跑下来训练集准确率逼近100%验证集准确率却只在60%左右徘徊。这就是典型的过拟合。CNN的参数量非常大如果训练数据不够多模型会“记住”训练集里每张图的细节比如某张披萨照片的特定桌布纹理。解决过拟合我按照优先级尝试了几种方法第一优先是增强数据。把ColorJitter的幅度加大一点增加RandomAffine做轻微的平移和缩放。第二是更激进的Dropout。把第一个全连接层的Dropout从0.3调到0.5效果立竿见影。第三是早停。在验证集准确率连续几个epoch不再提升时停止训练并加载历史最佳模型。我通常会监控验证损失连续5个epoch不降就停。4.2 学习率设置从0.001开始保持简单学习率是最直接影响收敛效果的超参数之一。Adam优化器默认的0.001在大多数情况下都能工作但这个值并不意味着一定适合你的任务。如果loss出现剧烈震荡调低到0.0003或0.0001如果loss下降非常缓慢可以小幅调高到0.003。我实际对比过两组实验一组固定lr0.001一组采用余弦退火调度从0.001降到0.0001。后者的最终验证准确率比前者高约2个百分点。在小型CNN上采用CosineAnnealingLR是一个低成本的提升手段from torch.optim.lr_scheduler import CosineAnnealingLR scheduler CosineAnnealingLR(optimizer, T_max30)每个epoch结束后调用scheduler.step()更新学习率即可。训练初期学习率大快速找到一个较优的区域训练后期学习率变小在局部精细调整。这种方式对食物识别这种噪声较多的数据特别有效。4.3 类别不均衡某些菜类样本多导致模型偏科自建数据集最容易出现类别不均衡的问题。比如食堂数据里“米饭”的图片特别多“沙拉”的图片非常少模型会倾向于把所有样本都预测成“米饭”因为这样训练损失已经很低。避免这个问题有两个方案方案一是为损失函数添加权重让少数类样本的损失贡献更大class_counts torch.bincount(torch.tensor(train_dataset.targets)) total_samples class_counts.sum() class_weights total_samples / (len(class_counts) * class_counts.float()) criterion nn.CrossEntropyLoss(weightclass_weights.to(device))方案二是数据重采样在DataLoader中设置WeightedRandomSampler让每个类别每个epoch出现的次数大致均衡。我个人的经验是如果类别数量差异在5倍以内用损失权重就够了差异更大时两种方法可以结合使用。4.4 训练速度慢从数据加载和GPU利用率找原因训练速度慢的问题通常不在模型本身而在数据管线。最常见的瓶颈是数据加载耗时过长GPU大部分时间处于等待状态。几个立竿见影的优化手段把num_workers从默认的0改为4或8。确保pin_memoryTrue。如果显存有限不要一次性把整个数据集读进显存。尝试混合精度训练。PyTorch原生支持torch.cuda.amp代码改动很小from torch.cuda.amp import autocast, GradScaler scaler GradScaler() with autocast(): outputs model(images) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() optimizer.zero_grad()混合精度能让训练速度提升约30%到50%特别是在GPU支持Tensor Core的硬件上效果更加明显。4.5 训练状态速查表现象可能原因解决方向训练loss不下降学习率太大或太小调整lr至0.0001-0.01区间训练准确率低但验证准确率高数据划分有问题或验证集太简单重检查数据分布和划分逻辑训练准确率超高、验证准确率低过拟合增强数据增强、加大Dropout、提前停止验证准确率大幅波动验证集未使用eval模式检查model.eval()loss出现NaN学习率过大降低学习率GPU显存不足batch size过大减小batch size每个epoch时间越来越长数据加载成为瓶颈增加num_workers开启pin_memory5. 从入门到实用进一步优化食物识别模型的思路5.1 迁移学习几乎是必选项自定义的小型CNN在Food-101这种101类大规模数据集上很难达到高精度在这个数据量级上直接从零训练很容易过拟合。迁移学习把在ImageNet上预训练好的模型拿过来替换最后一层分类器只对这一层做微调或者对整个网络做低学习率的微调。这种方式能大幅降低训练时间和数据需求。PyTorch里用torchvision的模型非常简单from torchvision import models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) num_ftrs model.fc.in_features model.fc nn.Linear(num_ftrs, num_classes)需要注意几点迁移学习之后输入图像的归一化参数必须与预训练时保持一致也就是上面用的ImageNet统计值预训练模型前几层学到的是通用边角纹理特征后几层则更针对ImageNet的类别。如果你想快速得到一个不错的基线先冻结backbone只训练新的分类头等分类头收敛后再解冻所有层做微调这是一个非常稳的方案。我自己在食物识别项目中的体会是哪怕只有几千张训练图片用ResNet18做迁移学习也能轻松达到85%以上的验证准确率而自定义CNN可能还在75%左右挣扎。5.2 可视化特征图与Grad-CAM训练完模型后你可能会好奇模型到底看到了什么。有两个常用的可视化手段一是直接可视化某个卷积层输出的特征图但浅层特征相对抽象对理解模型帮助有限二是用Grad-CAM生成类别激活热力图标记出图片中哪些区域对分类贡献最大。Grad-CAM的原理是取目标类别对最后一个卷积层特征图的梯度做全局平均池化得到每个通道的权重再加权组合特征图得到一张定位热力图。在食物识别里应用尤其有意思如果模型把一张图片识别成披萨热力图的高亮区域通常会集中在芝士和饼底部分而不是餐盘边缘。如果热力图高亮区域非常分散说明模型学到了一些背景噪声需要在数据增强上做改进。PyTorch实现Grad-CAM并不复杂网上也有封装好的库但我建议自己实现一遍这样对梯度流动的理解会深很多。5.3 部署与模型导出模型训练完成后实际应用场景往往是Android端或Web端。PyTorch模型直接部署相对困难常规做法是导出为ONNX格式再借助不同平台的推理引擎。导出代码很短model.eval() dummy_input torch.randn(1, 3, 224, 224).to(device) torch.onnx.export(model, dummy_input, food_cnn.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch_size}, output: {0: batch_size}})可以在桌面端先实践一遍然后用ONNX Runtime的Python接口加载做推理。更重要的是能感受到模型从训练框架到推理框架的流程切换这在以后做任何实际项目都会用到。有一点需要注意ONNX导出时尽量把模型切到eval()模式并固定输入尺寸动态尺寸的导出会带来不必要的复杂度。6. 写在最后的个人经验在多次完整走完这个食物识别项目之后我最大的体会是一个简单的CNN模型真正的学习曲线不一定在模型搭建上而是在数据流和训练流程的每个小细节里。比如输入归一化数值写错导致训练不收敛忘记调用model.train()导致验证结果忽高忽低单张图片预测时忘记加batch维度导致维度报错……这些看似很小的坑却是构建深度学习应用最有价值的那部分经验。还有一个小技巧值得分享不管你的最终目标是大模型还是小模型先编写一套清晰的训练、评估、预测代码框架很重要。我后来换到其他图像任务比如垃圾分类识别、手写字符识别基本只需要改数据集路径和模型类别数其他代码几乎不用动。这种经验积累比单纯追求一个更高的精度要划算得多。如果你想从零开始动手做一个完整的图像分类项目不妨就拿食物识别作为切入点跑通这个闭环之后就可以向更复杂的模型和数据发起了。