桑叶品种图像分类实战:基于ResNet50与ViT的迁移学习与验证

发布时间:2026/10/25 12:38:39
桑叶品种图像分类实战:基于ResNet50与ViT的迁移学习与验证

简介:面向桑叶品种识别与图像分类任务,这份已标注数据集包含约5000张图片,覆盖红王、白王、Chiang Mai 60、Buriram 60等10个常见品种。训练集与测试集已提前划分,同品种图片集中存放,便于直接训练与评估;随包还提供Python可视化脚本,可快速查看各类别样本分布,json文件则记录了完整类别映射与划分信息。整个7z压缩包共2000个文件,核心为1998张JPG图片,另附1个Py脚本与1个json配置文件,压缩后约730MB,适合用作物联网/智慧农业背景下的图像分类教学、算法对比或毕业论文实验。该资源目前已吸引76人学习下载,配套技术专栏还给出了图像分类网络改进思路与计算机视觉完整项目参考,能帮助初学者快速走通数据加载、模型训练与评估全流程。

1. 桑叶品种图像分类:一份5000张已标注数据能做什么

桑树品种识别在蚕桑产区、代用茶加工和农技推广里是一道反复出现的重复劳动:靠肉眼把采回来的叶片按品种分拣,不同品种的叶形、叶色、叶脉和锯齿差异确实存在,但看久了眼睛容易花,新人上手还要培训一段时间。这个场景非常适合用图像分类算法来替代人工初筛。

“桑叶品种数据集图像分类数据集”这类已标注数据,正好把最费人工的两个环节(采集、打标)直接省掉,约5000张图对单品种多分类是一个够用的起点。对象是两类人:一是想做农业视觉落地的算法工程师,二是拿公开流程练手的入门者。落地主线很清楚:按规范划分数据、用预训练模型做迁移学习、用混淆矩阵和Grad-CAM验证结果。下文按这条线展开,每一步的参数和坑都列出来。

2. 拿到数据集先别训练:目录规划、标注检查与数据划分

很多人拿到一份已标注数据集,第一反应是直接开训。但图像分类任务里,数据准备流程决定了模型可靠性的上限,它比模型选型更值得花时间。这一章处理三件事:判断数据规模够不够用、把数据按规范拆成三份、把标注质量检查一遍。

2.1 5000张已标注数据意味着什么:先估算“够不够用”

数据够不够用,取决于类别数和类间差异。具体有多少个品种,以你拿到的实际标注为准;假设有5个品种,每类大约1000张,这是常规迁移学习配置;假设有15个品种,每类300多张,就已经进入小样本训练区间。桑叶品种之间的差异非常细,叶形、叶色、叶脉密度、锯齿深浅都可能成为判据,和森林图像分类里的树种识别类似,属于典型的细粒度特征。每类只有几百张时,只能靠预训练权重、数据增强和正则化来补。

已标注是这份数据最大的红利。图像分类落地最贵的成本不是训练,而是“采集+清洗+打标”。标注阶段省掉之后,精力可以集中在目录检查、拆分策略和训练参数上。但要注意:标注做过不代表标注全对,后面第2.3节会给一套检查脚本,千万别跳过。

2.2 设计目录与划分策略:train/val/test怎么分

常见做法是改成ImageFolder风格的目录结构,torchvision的datasets.ImageFolder可以直接读取,省掉自定义Dataset。如果你的原始数据是“一个CSV标注文件加一堆图片”,第一步要先转成目录结构,否则后面每次加载都要写额外映射逻辑。

sangye_splits/ ├── train/ │ ├── cv1/ │ ├── cv2/ │ └── ... ├── val/ │ ├── cv1/ │ ├── cv2/ │ └── ... └── test/ ├── cv1/ ├── cv2/ └── ...

三个目录的作用要提前定好:

目录用途是否参与训练
train模型训练,权重更新是
val每轮结束后评估,用于保存历史最佳模型否,但间接影响模型选择
test全部调参完成后的最终评估,只跑一次否

下面这段脚本把原始目录拆成70 / 15 / 15的比例。我这里用的是“复制”而不是“移动”,原始素材保留一份,后面发现划分有问题还有后悔药。

import os import random import shutil from collections import defaultdict random.seed(42) # 假设原始目录结构: raw/<品种名>/<图像文件> src_root = "raw" dst_root = "sangye_splits" ratio = (0.7, 0.15, 0.15) for cls_name in os.listdir(src_root): cls_dir = os.path.join(src_root, cls_name) if not os.path.isdir(cls_dir): continue files = [f for f in os.listdir(cls_dir) if f.lower().endswith((".jpg", ".jpeg", ".png"))] random.shuffle(files) n_total = len(files) n_train = int(n_total * ratio[0]) n_val = int(n_total * ratio[1]) splits = { "train": files[:n_train], "val": files[n_train:n_train + n_val], "test": files[n_train + n_val:], } for split_name, split_files in splits.items(): for fname in split_files: src = os.path.join(cls_dir, fname) dst = os.path.join(dst_root, split_name, cls_name, fname) os.makedirs(os.path.dirname(dst), exist_ok=True) shutil.copyfile(src, dst)

逻辑说明:外层循环遍历每个品种目录,内层把图像文件名随机打乱后切三段。random.seed(42)固定随机源,保证之后重跑脚本得到相同划分。复制而非移动是为了保留原始素材,便于后续追查问题。

注意:如果原始文件名包含拍摄批次、植株编号等信息,或者你手头有“同一棵树多张连拍”的现场记录,不能直接shuffle。要按批次分组后再留出,保证同一来源的图像只进train、val或test其中一个。这是后面避坑章里的高频翻车点。

2.3 标注质量检查:读图、统计类别、找损坏样本

已标注不等于没问题。常见标注质量问题有三类:类别目录里混入其他物种、图片损坏、类名拼写不统一。下面这段脚本做两项基础检查:统计每个类别的图片数量,并找出打不开的损坏文件。

from PIL import Image import os counts = {} broken = [] for cls_name in os.listdir(src_root): cls_dir = os.path.join(src_root, cls_name) if not os.path.isdir(cls_dir): continue counts[cls_name] = 0 for fname in os.listdir(cls_dir): full_path = os.path.join(cls_dir, fname) try: with Image.open(full_path) as im: im.verify() counts[cls_name] += 1 except Exception: broken.append(full_path) print("类别统计:", counts) print("疑似损坏:", broken)

逻辑说明:im.verify()会读取文件结构但不完整解码所有像素,遇到截断或格式伪装的文件会抛异常,正好用来筛选损坏样本。类别统计结果出来之后,数量明显偏少的类要单独标记,后面训练时给更高权重或更多增强。损坏文件不要直接删除,复制到一个broken/目录里,检查清楚是什么原因再处理,避免误删。

除此之外,还要检查“标对了没有”。细粒度数据里最容易混的是两个外观很接近的品种,我一般会从每类随机抽50张拼成一张九宫格,肉眼过一遍。如果发现只有一两个类别大量互混,说明原始标注在采集时就错了,靠训练是救不回来的,只能重新清洗或剔除。

2.4 类别不均衡的应对:先统计再决定加权还是采样

统计结果出来后,通常会看到类别数量不均衡。两个常见处理思路:给损失函数加权,或者用采样器让每个batch尽量均衡。

max_count = max(counts.values()) class_weights = {cls: max_count / cnt for cls, cnt in counts.items()} class_names = list(counts.keys()) weight_vector = torch.tensor([class_weights[c] for c in class_names]) criterion = torch.nn.CrossEntropyLoss(weight=weight_vector)

逻辑说明:反频权重的含义是“样本越少的类,单样本梯度贡献越大”,适合中等程度不均衡,比如3倍以内的差距。如果少数类只有几十张,加权容易让模型反复看同一批图,导致对少数类过拟合,此时优先考虑数据增强或更强的预训练特征。重采样的话,在DataLoader里挂WeightedRandomSampler,让每个epoch抽样时多抽少数类,效果和损失加权接近,但batch内的数据多样性更好。

3. 用预训练ResNet50在本地跑通桑叶分类:训练代码与参数调法

数据准备好之后,训练阶段就是很多人搜的“用ResNet50小样本图像分类的整个过程详细步骤”的主干。最容易出问题的不是模型定义,而是预处理、学习率和增强策略。用ImageNet预训练的ResNet50做迁移学习,是这个数据量级下最稳的起点。

3.1 为什么迁移学习是首选:预训练权重、模型容量与数据量匹配

5000张已标注数据按5个品种算,每类约1000张,从头训练CNN理论上可行,但桑叶的纹理特征很细,随机初始化很难学到叶脉、锯齿这种高频局部结构。ImageNet预训练权重提供了大量纹理基元,在叶片这类自然物体上迁移效果通常不错。ResNet50的定位是“容量适中”:比ResNet18表达能力强,比ResNet152更容易过拟合,显存占用和训练时长都比较合理。EfficientNet系列也可作为备选,适合愿意多调增强参数的读者。一句话选型结论:先跑ResNet50做baseline,再换ViT验证上限,这也是第4章要做的事。

3.2 从零实现一个最小训练流程:数据加载、数据增强、微调

import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import transforms, datasets from torchvision.models import resnet50, ResNet50_Weights device = "cuda" if torch.cuda.is_available() else "cpu" train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.3), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_transform = 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]), ]) train_ds = datasets.ImageFolder("sangye_splits/train", transform=train_transform) val_ds = datasets.ImageFolder("sangye_splits/val", transform=val_transform) class_names = train_ds.classes train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4, pin_memory=True) model = resnet50(weights=ResNet50_Weights.IMAGENET1K_V2) model.fc = nn.Linear(model.fc.in_features, len(class_names)) model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=40) best_acc = 0.0 for epoch in range(40): model.train() for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) logits = model(imgs) loss = criterion(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() model.eval() correct = 0 total = 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.to(device), labels.to(device) preds = model(imgs).argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) val_acc = correct / total if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "best_sangye_resnet50.pth") print(f"epoch {epoch}: val_acc {val_acc:.4f} *")

逻辑说明:RandomResizedCrop的scale=(0.6, 1.0)表示随机裁剪面积占原图面积的60%到100%,对桑叶这种非正方形物体来说,比固定resize更能保留叶片实体;如果图像背景占比高,可以把下限降到0.5。val_transform先Resize(256)再CenterCrop(224),保持评估时的平移不变性,不做随机增强。model.fc替换成品种数输出,这是迁移学习的标准动作,因为ImageNet自带的1000类分类头在桑叶数据集上没有意义。

优化器用AdamW配合weight_decay=1e-4,比SGD少操心Momentum调参,当前数据量下微调很顺手。学习率1e-4是全量微调的常见起点,比从头训练低一个数量级。CosineAnnealingLR的T_max=40与总epoch数一致,让学习率从1e-4平滑退火到接近0。如果显存不够把batch_size降到16,学习率同步降到5e-5。

提示:scheduler.step()在每轮末尾调用。如果换用ReduceLROnPlateau这类需要“看指标”的调度器,要在验证集跑完后带着val_acc调用,两种写法的位置完全不同。

3.3 关键参数说明:batch size、学习率、epoch怎么定

参数推荐起点说明调整信号
batch size325000张数据下足够支撑BN统计显存OOM就减半
学习率1e-4微调阶段比从头训练低一档验证loss震荡就降到5e-5
epoch30~50对应一个完整余弦周期验证指标连续不涨就早停
weight_decay1e-4抑制过拟合训练/验证差距大就升到1e-3
增强强度scale=(0.6,1.0)农业图像背景杂乱需要更强增广过拟合信号明显就再加强

如果手里只有一张消费级显卡,batch size被迫调到8,要注意BN统计会变抖。两个办法:一是开启梯度累积,每4个step更新一次参数,等效于batch_size=32;二是先换ResNet18把流程跑通,再回来调大模型。另外,在脚本开头固定随机种子:

def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True

这个不写不报错,但不写的结果是“每次跑出来的指标都不一样”,在对比实验阶段会浪费时间。

3.4 训练曲线怎么看:损失下降但验证不动时怎么办

训练loss一路掉到0.1、验证准确率却停在72%不再动,这是过拟合的典型画面。先看训练损失和验证损失的差,差距越大过拟合越重。处理顺序建议:先加强增强,再加正则,最后才考虑换模型。增强看第4章的mixup和随机擦除;正则就是调大weight_decay;换模型是把ResNet50换成ResNet18验证是不是容量过剩。

反过来,如果训练loss和验证loss都高,说明欠拟合,此时加增强反而更坏。优先提高学习率或训练轮数。每次实验把train_loss、val_loss、val_acc按epoch存成CSV,画出两条曲线,曲线比任何trick都直观。我自己会把训练和验证损失之差当作训练是否健康的硬指标,绝不只看准确率。

4. 从CNN换到ViT:小样本图像分类的选型对比与数据增强

最新的图像分类模型榜单上,ViT相关结构的出镜率已经超过CNN,但它不一定适合所有数据规模。这一章重点讲清楚:ViT在5000张级别数据上处于什么位置,分类头到底要怎么调整,以及哪些增强手段真正有用。

4.1 ViT在5000张级别数据上的真实定位:数据效率与特征容量

ViT从头训练时数据效率低,因为它没有CNN那种内置的局部性和平移等变性,空间结构都得靠数据去学。用预训练ViT做迁移学习,情况会改观,但微调时仍然比CNN更容易过拟合。对照关系大致如下:

模型参数量(近似)在小规模数据上的表现倾向适合场景
ResNet5025M数据效率高,不容易过拟合优先跑出的baseline
EfficientNet-S约5~8M需要精细调增强,收益有限轻量化部署
ViT-B/1686M特征容量大但吃数据,靠预训练兜底想验证模型上限时做对照

结论是:在5000张已标注数据上,不要一上来就押注ViT。先跑CNN基线,拿到一组可靠指标,再用ViT做对照实验。如果ViT有强预训练权重(比如在更大量级数据上预训练过的版本),它有可能超过ResNet50,但这个“有可能”必须在自家验证集上确认,不能拿公开榜单的结论硬套。

4.2 用torchvision加载预训练ViT,并处理分类头:迁移时分类头用调整吗

from torchvision.models import vit_b_16, ViT_B_16_Weights vit = vit_b_16(weights=ViT_B_16_Weights.IMAGENET1K_V1) vit.heads.head = torch.nn.Linear(vit.heads.head.in_features, len(class_names))

逻辑说明:torchvision的ViT把分类头部放在heads.head字段,替换成桑叶品种数即可。in_features通常等于768,这是ViT-B/16的token embedding维度。

直接回答高频问题:用ViT评估时分类头肯定要调整。预训练权重自带的是ImageNet的1000类输出,不改会直接报维度不匹配。更隐蔽的是第二个问题:新分类头是随机初始化的。如果一上来就全量微调,随机头的梯度会连带污染预训练主体。常见做法是先冻结主体,只训新分类头几个epoch,再解冻后半段做全量微调。这个“先线性探测、再微调”的顺序,在ViT上比ResNet50更关键。

ViT输入默认是224×224。torchvision也支持更高分辨率版本,但会明显增加显存和训练时间。在第3章CNN基线跑完之前,不建议直接上高分辨率;当真正常用的细纹理区分不开时,再考虑ViT加更大输入尺寸的组合。

4.3 数据增强加码:mixup、CutMix 与随机擦除的落地写法

小样本图像分类能起死回生的地方,数据增强排在第一位。mixup把两张图按比例线性插值,标签也跟着混合,能有效阻止模型死记单张图。下面是完整的mixup实现,可以直接挂进第3章的训练循环。

import numpy as np import torch def mixup(x, y, alpha=0.2): if alpha > 0: lam = np.random.beta(alpha, alpha) else: lam = 1.0 batch_size = x.size(0) idx = torch.randperm(batch_size).to(x.device) x_mixed = lam * x + (1 - lam) * x[idx] y_mixed = lam * y + (1 - lam) * y[idx] return x_mixed, y_mixed # 在训练循环里使用: # for imgs, labels in train_loader: # imgs, labels = mixup(imgs, labels, alpha=0.2) # logits = model(imgs)

逻辑说明:lam服从Beta(α, α),α越小混合程度越轻;α=0.2是比较保守的起点。y_mixed不再是整数标签,而是两个类别之间的软标签,CrossEntropyLoss能够直接处理这种分布,不影响反向传播。注意只能对训练集做mixup,验证集和测试集必须保持原始数据。CutMix则把一块矩形区域替换成另一张图的切片,标签按面积比例混合,对“叶片重叠、部分遮挡”的场景更贴近,但实现稍复杂。优先级建议:先给train_transform加RandomErasing(p=0.25, scale=(0.02, 0.1)),跑一组对比,再上mixup,最后试CutMix,每次只改一项,避免多个因素混在一起看不出谁起作用。

4.4 如果数据只有几十张:1-shot/5-shot的处理思路

后续如果要扩新的桑叶品种,新类别可能只给几张参考图,这时普通微调很难起作用,已经进入小样本图像分类1-shot/5-shot的范畴。常见做法有两个方向:一是用预训练模型倒数第二层输出的特征向量作为图像表示,把参考图特征存成原型,新样本通过余弦相似度或最近邻来分类;二是对原型做简单线性分类头。这两类方法都不需要大量训练,但桑叶品种间差异过细,容易混淆,特征提取器的质量比分类方法更重要。

如果整个数据集只有几十张,另一个思路是用自监督预训练模型或强监督预训练模型抽特征,然后冻结特征做线性探测,这比端到端微调更稳。5000张已标注数据足够走端到端微调,但理解这条链路能帮你判断“到底该补数据还是该换方法”。

5. 桑叶品种分类的5个踩坑记录:从过拟合到标注错位

下面这些坑是农业细分类任务里的共性问题,换到桑叶品种数据集上大概率也能遇到。每条按“现象 → 原因 → 解决”的顺序列出来,训练出问题时直接对照排查。

5.1 训练损失一路下降,验证准确率原地踏步

现象:训练loss降到0.1以下,验证准确率停在70%上下不动,模型看起来“学得很好”,但验证集就是不给面子。

原因:数据增强太弱,模型把训练集的背景细节背下来了。桑叶数据集如果拍摄背景单一,模型很容易去记桌面木纹、标签笔迹这类背景线索,而不是叶片本身。

解决:把RandomResizedCrop的scale下限降到0.5,加入RandomErasing(p=0.25),同时把weight_decay从1e-4提到1e-3。如果还压不住,换ResNet18跑一组对比,低成本判断到底是容量问题还是增强不够。

5.2 验证集95%,换环境立刻崩

现象:验证集准确率能到95%,换到另一个场地、另一批拍摄条件下直接掉到60%以下。

原因:典型的“兄弟图”泄漏。同一棵植株的连拍图、同一批拍摄的连续帧被随机shuffle到了train和val两个集合,模型在验证集上见过太多相似图,指标虚高,一到独立数据就露馅。

解决:划分数据前先按拍摄批次、植株编号或目录名分组,保证同一来源的图片只进入train/val/test中的一个集合。代码层面可以先把样本按组聚合,再用sklearn.model_selection.GroupShuffleSplit按组切分,比逐文件随机shuffle可靠得多。

5.3 少数品种几乎不识别

现象:混淆矩阵里多数类acc能做到90%,小众类recall接近0,所有叶片都被预测成大类。

原因:类别不均衡,普通CrossEntropyLoss被高频类别主导。小类别在总损失里占比太低,梯度更新方向基本只看多数类。

解决:先跑一遍类别统计,最大类与最小类差距超过3倍,就考虑给CrossEntropyLoss的weight参数传反频权重,或改用WeightedRandomSampler。如果少数类本身只有几十张,先靠增强和预训练特征撑,不要一上来就猛加权,否则模型会对这几张图过拟合。

5.4 直接缩到224把纹理细节压没了

现象:所有图片直接用Resize(224, 224)塞进模型,准确率怎么调都上不去,换更强的模型也没用。

原因:桑叶品种差异集中在叶缘锯齿、叶脉密度这类高频纹理,强行把整张图等比压成224×224,这些细节被插值算法抹掉了。

解决:改用Resize(256)再CenterCrop(224),保留中心区域的更多细节;或者用RandomResizedCrop并把scale下限设在0.6以上。显存允许时把输入尺寸提到320再做对比,细粒度任务里分辨率提升带来的收益经常比换模型更明显。

5.5 模型学的是背景而不是叶片

现象:准确率看着不错,把Grad-CAM热区画出来一看就翻车:高亮全在纸面、桌沿和花盆边缘,叶片区域反而是冷的。

原因:数据集采集环境太统一,模型学会了“背景捷径”。只要换背景或摆放姿态,模型立刻失效。

解决:先剔除背景占比过高、叶柄或标签入画的样本;增强阶段增加随机平移、旋转和亮度扰动,打破背景一致性。这类问题靠acc发现不了,必须结合第6章的可视化验证手段来揪。

6. 用混淆矩阵与Grad-CAM验证模型:落地前的最后一关

这一章不写训练,只做验证。桑叶品种分类模型交到农技站或上线之前,至少跑两件事:混淆矩阵和Grad-CAM。前者回答“哪两个品种最容易互相认错”,后者回答“模型到底在看叶子的哪个部分”。

混淆矩阵的生成很简单,拿第3章训练好的模型对val集做一次推理:

from sklearn.metrics import confusion_matrix, classification_report import torch all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for imgs, labels in val_loader: imgs = imgs.to(device) preds = model(imgs).argmax(dim=1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) print(classification_report(all_labels, all_preds, target_names=class_names)) print(confusion_matrix(all_labels, all_preds))

一眼扫对角线之外的最大混淆对。桑叶品种里最常见的是“叶缘锯齿深的A品种被错认成B品种”,说明全局RGB特征不够,接下来可以加局部裁剪图作为第二路输入,或者提高输入分辨率。分类报告里的macro avg比整体acc更能反映小类别表现。

Grad-CAM用最简实现就够看热区,不需要额外库。选ResNet50的最后一个卷积层,钩住它的输出和梯度:

import torch.nn.functional as F grads, feats = [], [] model.layer4[-1].register_forward_hook( lambda m, i, o: feats.append(o)) model.layer4[-1].register_full_backward_hook( lambda m, gi, go: grads.append(go[0])) model.eval() img = val_transform(example_image).to(device) out = model(img.unsqueeze(0)) target = out.argmax(dim=1) model.zero_grad() out[0, target].backward() weights = grads[0].mean(dim=(2, 3), keepdim=True) cam = (weights * feats[0]).sum(dim=1, keepdim=True).relu() cam = F.interpolate(cam, size=(224, 224), mode="bilinear", align_corners=False) cam = cam.squeeze().detach().cpu().numpy()

逻辑说明:最后一个卷积层的输出代表模型学到的空间特征,梯度全局平均得到每个通道的重要性,加权求和后经过ReLU去掉负响应,再上采样到输入分辨率。这就是Grad-CAM的核心思路。看热区位置:落在叶脉和锯齿上是好信号;落在桌面、标签、花盆上就是第5.5节的背景捷径。如果背景热区明显,说明数据采集条件太干净,需要补一批不同背景的样本。

我的习惯是每次训练结束后先跑这两个图,再决定下一步是调参、补数据还是直接换模型。这套验证流程成本极低,但能把“凭感觉相信acc”变成“知道模型哪里强哪里弱”。第一次做时觉得麻烦,多做两次就会发现,它省下的试错时间远多于这点付出,希望帮到你。

本文还有配套的精品资源,点击获取