简介:面向深度学习初学者与图像分类实践者的 TinyImageNet 预训练模型微调示例工程,基于 Python 与 PyTorch 实现,用规模适中的 64×64 像素、约 200 类图像数据走通从加载预训练权重到微调的完整流程,适合显存与算力有限、希望快速上手迁移学习的读者对照练习。压缩包共 34 个文件、约 58KB,以 8 个 py 源码为骨架,配合 20 个 pyc 缓存、3 个 sh 运行与数据准备脚本,另附 LICENSE、README 与 .gitignore;目录涵盖 train.py 训练入口、data.py 数据加载、model 与 quant 模块、resnet18_imagenet_quant.py 量化网络定义及 utils 工具函数,结构紧凑便于按模块阅读。已有 480 人学习。读者可从中获得数据加载与预处理、模型加载、冻结底层只训练分类层、损失函数与优化器选择、训练验证与测试评估的完整代码参照,理解量化与微调结合的实现思路,并据此迁移到自己的小样本分类任务。
1. 预训练权重在 64×64 的小图上到底能生效几成
ResNet-18 的 ImageNet 预训练权重搬到 TinyImageNet 上,冻结骨干只训分类头,跑十几个 epoch 之后验证集准确率卡在 12% 上下——比 200 类随机猜的 0.5% 好看,但离能交付的 60% 还差着一段距离。这套 TinyImageNetTorch 微调预训练代码包,做的事情就是把这段容易出问题的过程整理清楚:用 Python 和 PyTorch,把 64×64 的小图、藏在标注文件里的验证集标签、以及 ImageNet 预训练骨干,组合成一条能跑通、能复现、参数能改的微调流程。
它面向的是已经会用 nn.Module、能看懂 DataLoader、但一换数据集就被输入尺寸、归一化统计值、冻结策略限制住的人。你拿到的是一份结构清晰的预训练微调代码,并非一份需要你从头实现的空洞教程;能否用出效果,取决于你有没有把标签空间、stem 结构和学习率分组这三件事理顺。
2. 数据集底座:把 wnids.txt 读成唯一的标签空间
TinyImageNet 最容易出错的地方不在模型,而在标签。它给出了一个 wnids.txt,列出了 200 个类别 id 及其顺序;训练集按train/<wnid>/images/分层存放,验证集却把 10000 张图平铺在同一个目录里,标签另放在一份用制表符分隔的文本里。如果你一边用 ImageFolder 的字典序、一边用 wnids.txt 的顺序,训练和验证的分类头索引就对不上,模型其实在学习,但评估结果是噪声。
2.1 目录结构与解压层级
先确认文件布局和目录层级,因为从不同渠道拿到的压缩包会解出tiny-imagenet-200/tiny-imagenet-200/这样的双层结构,脚本一启动就报路径不存在。
| 路径 | 内容 | 数量 |
|---|---|---|
train/<wnid>/images/*.JPEG | 训练图,按类分目录 | 100000 |
train/<wnid>/<wnid>_boxes.txt | 检测框标注,分类任务用不到 | 200 份 |
val/images/*.JPEG | 验证图,全部平铺 | 10000 |
val/annotations/val_annotations.txt | 验证集标签,制表符分隔 | 10000 行 |
test/images/*.JPEG | 测试图,无标签 | 10000 |
wnids.txt | 200 个类别 id | 200 行 |
words.txt | id 到英文名的映射 | 200+ 行 |
动手前先跑一遍自检,比训练到一半才发现少了一半数据省事得多:
from pathlib import Path root = Path("data/tiny-imagenet-200") if (root / "tiny-imagenet-200").is_dir(): # 兼容双层解压 root = root / "tiny-imagenet-200" wnids = (root / "wnids.txt").read_text(encoding="utf-8").split() print("类别数:", len(wnids)) # 期望 200 print("训练图:", len(list((root / "train").glob("*/*/images/*.JPEG")))) # 期望 100000 print("验证图:", len(list((root / "val/images").glob("*.JPEG")))) # 期望 10000三个数字对不上就别往下走。训练图数量少于 100000,多半是解压中断;验证图数量和标注行数不一致,说明标注文件被某个编辑器改过换行符。
2.2 一个 Dataset 同时覆盖 train 和 val
下面这个类把两个 split 的差异全部封装在__init__里,训练和验证共用同一套cls_to_idx,从源头上避免标签错位。
from pathlib import Path from PIL import Image from torch.utils.data import Dataset class TinyImageNet(Dataset): """train / val 共用一份标签映射,索引顺序固定取自 wnids.txt""" def __init__(self, root, split="train", transform=None): self.root = Path(root) self.transform = transform self.wnids = (self.root / "wnids.txt").read_text(encoding="utf-8-sig").split() self.cls_to_idx = {w: i for i, w in enumerate(self.wnids)} self.samples = [] if split == "train": for wnid in self.wnids: # 只遍历 wnids.txt 里列出的类 for p in sorted((self.root / "train" / wnid / "images").glob("*.JPEG")): self.samples.append((p, self.cls_to_idx[wnid])) elif split == "val": ann = self.root / "val" / "annotations" / "val_annotations.txt" with ann.open(encoding="utf-8-sig") as f: for line in f: parts = line.rstrip("\n").split("\t") name, wnid = parts[0], parts[1] # 后面 4 列是检测框,丢弃 self.samples.append( (self.root / "val" / "images" / name, self.cls_to_idx[wnid]) ) else: raise ValueError(f"未知 split: {split}") assert len(self.samples) > 0, "样本为空,先检查解压层级" def __len__(self): return len(self.samples) def __getitem__(self, index): path, label = self.samples[index] img = Image.open(path).convert("RGB") # 统一三通道,防止灰度图混入 if self.transform is not None: img = self.transform(img) return img, label几个参数值得单独说明。encoding="utf-8-sig"是为了去掉部分文本文件开头的 BOM,带 BOM 时第一个 wnid 会读成\ufeffn01443537,字典里查不到,直接抛异常。split("\t")必须用制表符而不是空格,val_annotations.txt里文件名和 id 之间是\t,用split()无参调用在某些行上会切出多余片段。训练集那段for wnid in self.wnids是有意的:有些分发包里train/下会多出无关目录,只认 wnids.txt 能把这些目录过滤掉。
构造完之后建议打一行日志确认:
train_set = TinyImageNet(root, "train") val_set = TinyImageNet(root, "val") print(len(train_set), len(val_set)) # 100000 10000 print(train_set.cls_to_idx[train_set.wnids[0]]) # 必须是 02.3 增强强度与归一化统计值
64×64 的图能承受的增强很有限。裁剪幅度一大,主体就被切没了;色彩抖动过头,纹理信息全毁。我一般用 RandomResizedCrop 配 0.6~1.0 的 scale,再加一个水平翻转就收手,剩下的交给模型容量。
from torchvision import transforms as T # 社区常用的 TinyImageNet 统计值;换成 ImageNet 统计值也能跑,但训练验证必须一致 MEAN = (0.4802, 0.4481, 0.3975) STD = (0.2770, 0.2691, 0.2821) train_tf = T.Compose([ T.RandomResizedCrop(64, scale=(0.6, 1.0), ratio=(0.8, 1.25)), T.RandomHorizontalFlip(p=0.5), T.ToTensor(), T.Normalize(MEAN, STD), ]) val_tf = T.Compose([ T.Resize(64), # 原图就是 64,写上是防止混入其他尺寸 T.CenterCrop(64), T.ToTensor(), T.Normalize(MEAN, STD), ])如果你更相信自己的数据,可以现场算一遍统计值,前提是 Dataset 的 transform 只保留 ToTensor:
import torch from torch.utils.data import DataLoader def mean_std(dataset, batch_size=512, num_workers=8): loader = DataLoader(dataset, batch_size=batch_size, num_workers=num_workers) n, s, s2 = 0, torch.zeros(3), torch.zeros(3) for x, _ in loader: n += x.size(0) s += x.sum(dim=(0, 2, 3)) s2 += (x ** 2).sum(dim=(0, 2, 3)) mean = s / (n * 64 * 64) std = (s2 / (n * 64 * 64) - mean ** 2).sqrt() return mean, std这里用的是像素数 64×64 而不是 batch 数,s和s2是按通道累加的,所以除数必须是样本数乘像素数。算出来和上面的经验值差不了多少,但如果你做过筛选或重采样,重算一次更稳妥。
提示:验证集 transform 里绝对不能出现 RandomResizedCrop、ColorJitter 这类随机算子。我在一个项目里手滑复制了训练配置,验证准确率在 epoch 之间跳了 8 个点,排查了半天以为是模型不稳定。
3. 预训练骨干与分类头:64×64 输入下需要改哪几行
ImageNet 预训练的骨干是按 224×224 设计的,第一层普遍是 7×7 步长 2 的卷积,紧跟一个 3×3 步长 2 的最大池化。这个组合在 224 上把分辨率降到 56,很合理;在 64 上连降两次只剩 16,再到 layer4 就只剩 2×2,空间信息被压得所剩无几。不改结构直接塞 64×64,微调后的准确率会比改过结构的低一截,而且这个损失很隐蔽——训练 loss 正常下降,只是上限低。
3.1 骨干选型与 stem 改造
下面这张表是我在 200 类、10 万训练图上跑出来的大致区间,单卡、batch 128、训练 30 epoch、余弦退火,不同随机种子会有 1~2 个点的波动,仅作选型参考:
| 骨干 | 参数量 | 64×64 改造方式 | top-1 大致区间 | 单 epoch 耗时 |
|---|---|---|---|---|
| resnet18 | 11M | conv1 换 3×3/s1,去掉 maxpool | 62%~66% | 基准 1.0× |
| resnet34 | 21M | 同上 | 65%~69% | 约 1.8× |
| mobilenet_v3_small | 2.5M | 保持结构,仅调整宽度 | 55%~60% | 约 0.5× |
| efficientnet_b0 | 5M | 保持结构 | 60%~64% | 约 0.9× |
| vit_b_16 | 86M | patch 16 在 64 上只有 16 个 token,需上采样到 224 或改 patch 尺寸 | 不建议直用 | 3× 以上 |
结论比较明确:想快速拿到可用结果就 resnet18 加 stem 改造;显存和延迟紧张就 mobilenet_v3_small;ViT 系列在 64×64 上并不划算,token 太少,位置编码也没法复用预训练权重,除非你把图放大到 224。
import torch.nn as nn from torchvision import models def build_model(name="resnet18", num_classes=200, pretrained=True, small_input=True): weights = "IMAGENET1K_V1" if pretrained else None model = getattr(models, name)(weights=weights) if small_input and name.startswith("resnet"): # 7x7/s2 + maxpool 对 64x64 太狠:换成 3x3/s1 并去掉 maxpool model.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) model.maxpool = nn.Identity() in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) # 200 类新头,随机初始化 return model model = build_model("resnet18", num_classes=200)weights="IMAGENET1K_V1"需要 torchvision 0.13 以上版本,低于这个版本要用pretrained=True。替换conv1之后,这一层的权重是随机的,预训练学到的低频滤波器用不上了,但后面四个 stage 的权重完好,微调依然比从零训练快很多——这正是值得做的地方。如果显存够、时间也不紧张,把输入上采样到 112 甚至 224 再配合原版 stem,往往能再多两三个点,代价是训练时间翻倍。
3.2 分类头替换与冻结策略
新换的fc是随机初始化的,一开始梯度很大,如果同一时刻骨干也在大学习率下更新,预训练特征几个 step 就被冲掉了。稳妥的顺序是两段式:先冻骨干只训头,等 loss 稳住了再解冻全量微调。
def set_backbone_frozen(model, frozen=True): for name, p in model.named_parameters(): p.requires_grad = not frozen if not name.startswith("fc.") else True def set_bn_eval(model): """冻结骨干时,BN 的 running stats 也不该被小 batch 带偏""" for m in model.modules(): if isinstance(m, nn.BatchNorm2d): m.eval()第二个函数是很多实现漏掉的一步。model.train()会把所有 BN 置为训练模式,此时即使requires_grad=False,running_mean 和 running_var 仍然会被当前 batch 的统计量更新。TinyImageNet 的 batch 通常只有 128,单卡还可能更小,用一个 batch 的均值方差去覆盖 ImageNet 上学到的统计量,验证集准确率会莫名其妙地掉。正确做法是在每个 epoch 开始model.train()之后,立刻调用set_bn_eval(model)把 BN 拉回 eval。
解冻时机没有标准答案,我一般看验证集:冻结阶段验证准确率连续 2 个 epoch 涨幅小于 0.3 个点,就解冻全量,同时把骨干学习率降到原来的十分之一。
3.3 差分学习率与参数分组
全量微调阶段,骨干和新头必须用不同学习率,这个比例大概在 1:10。除此之外还有一组容易被忽略:BN 的 weight 和所有 bias 不应该做 weight decay,否则相当于持续把它们往 0 拉。
def make_param_groups(model, head_lr=1e-3, backbone_lr=1e-4, weight_decay=5e-4): head, decay, no_decay = [], [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue if name.startswith("fc."): head.append(p) elif p.ndim <= 1: # BN 权重、所有 bias no_decay.append(p) else: decay.append(p) return [ {"params": decay, "lr": backbone_lr, "weight_decay": weight_decay}, {"params": no_decay, "lr": backbone_lr, "weight_decay": 0.0}, {"params": head, "lr": head_lr, "weight_decay": weight_decay}, ] groups = make_param_groups(model) optimizer = torch.optim.AdamW(groups) print([g["lr"] for g in groups]) # [0.0001, 0.0001, 0.001]p.ndim <= 1这个判断覆盖了 BN 的 weight、bias 以及全连接层的 bias,简单但够用。参数组顺序会影响某些 scheduler 的行为,验证阶段打印一下每组的学习率,确认没有写反。AdamW 的 weight_decay 是解耦的,5e-4 是个比较安全的起点;换成 SGD momentum 0.9 的话,weight_decay 通常要提到 1e-4 到 5e-4 之间并配合更长训练轮次。
4. 训练循环:AMP、余弦调度与可续训的 checkpoint
训练循环本身不复杂,真正决定这套代码能不能长期使用的是三件事:混合精度有没有正确处理 scaler、学习率调度是按 epoch 还是按 step 走、以及中断之后能不能原样接上。第三点在实验室机器上尤其重要,抢占式调度随时可能把你的进程清掉。
4.1 单个 epoch 的训练函数
import torch def train_one_epoch(model, loader, criterion, optimizer, scheduler, scaler, device, frozen_backbone=False, amp=True, clip=1.0): model.train() if frozen_backbone: set_bn_eval(model) # 关键:冻结时 BN 不许更新统计量 total_loss, correct, seen = 0.0, 0, 0 for images, targets in loader: images = images.to(device, non_blocking=True) targets = targets.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) with torch.amp.autocast("cuda", enabled=amp): logits = model(images) loss = criterion(logits, targets) scaler.scale(loss).backward() if clip > 0: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), clip) scaler.step(optimizer) scaler.update() scheduler.step() # 余弦调度按 step 走,粒度更细 total_loss += loss.item() * images.size(0) correct += (logits.argmax(1) == targets).sum().item() seen += images.size(0) return total_loss / seen, correct / seenoptimizer.zero_grad(set_to_none=True)比默认的置零省一次显存写,长训练下来有感觉。clip=1.0是给解冻初期的保险,梯度突然爆掉时能兜一下;如果你的 loss 一直很稳,可以关掉,省一点算力。scaler.unscale_(optimizer)必须在clip_grad_norm_之前调用,否则裁剪的是被放大过的梯度,阈值失去意义——这是一个经典写法错误,症状是裁剪看起来生效了但训练照样发散。
scheduler.step()放在每个 iteration 后面,意味着余弦周期是按总 step 数算的,初始化时要写成T_cos = epochs * len(loader)。如果你习惯按 epoch 调度,就把它挪到 epoch 末尾,两者不能混。
4.2 验证与指标记录
验证集只有 10000 张,一次前向几十秒,值得算细一点:top-1、top-5、以及每个类的准确率。后者能告诉你模型是不是在少数类上崩了。
@torch.no_grad() def evaluate(model, loader, device, amp=True): model.eval() top1 = top5 = total = 0 per_class_correct = torch.zeros(200) per_class_total = torch.zeros(200) for images, targets in loader: images, targets = images.to(device), targets.to(device) with torch.amp.autocast("cuda", enabled=amp): logits = model(images) pred = logits.argmax(1) top1 += (pred == targets).sum().item() k = min(5, logits.size(1)) top5 += logits.topk(k, dim=1).indices.eq(targets[:, None]).any(1).sum().item() total += targets.size(0) for t, p in zip(targets.cpu(), pred.cpu()): per_class_total[t] += 1 per_class_correct[t] += int(t == p) acc_per_class = per_class_correct / per_class_total.clamp(min=1) return top1 / total, top5 / total, acc_per_classper_class_total.clamp(min=1)是为了防止某个类在采样子集里没出现导致除零。拿到acc_per_class之后,按升序排一下,通常能看到最低的几个类掉到 20% 以下,这些类往往外观相近或者样本质量差,后续补数据、加 cutout 就该优先照顾它们。
4.3 断点续训与随机种子
一个完整的 checkpoint 至少要包含模型、优化器、调度器、scaler、epoch 和最佳指标六项。少存 scaler 是最常见的疏漏,恢复后 AMP 的 loss scale 从初始值重来,前几个 step 会白跑。
def save_ckpt(path, model, optimizer, scheduler, scaler, epoch, best_acc): torch.save({ "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "scaler": scaler.state_dict(), "epoch": epoch, "best_acc": best_acc, }, path) def load_ckpt(path, model, optimizer=None, scheduler=None, scaler=None): ckpt = torch.load(path, map_location="cpu", weights_only=False) model.load_state_dict(ckpt["model"]) if optimizer is not None: optimizer.load_state_dict(ckpt["optimizer"]) if scheduler is not None: scheduler.load_state_dict(ckpt["scheduler"]) if scaler is not None: scaler.load_state_dict(ckpt["scaler"]) return ckpt["epoch"], ckpt["best_acc"]weights_only=False是给 PyTorch 2.6 之后的默认行为留的口子,老版本没有这个参数,按你的版本删掉即可。恢复训练时最容易被忽略的是 DataLoader 的随机状态——epoch 从 5 恢复,但 shuffle 的顺序和中断前不一样,等于换了一份数据顺序,指标会抖一下。要严格复现就在 checkpoint 里顺手存torch.get_rng_state(),并在创建 DataLoader 时传入固定的generator。
种子这块,torch.backends.cudnn.deterministic = True会让部分卷积走慢路径,能换来可复现;如果只是要稳定不要逐位一致,把benchmark设成 True、只固定 Python 和 NumPy 的种子就够了。
import random, numpy as np, torch def seed_everything(seed=42, deterministic=False): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = deterministic torch.backends.cudnn.benchmark = not deterministic5. 避坑排查:五个最容易让结果失真的细节
这一章记录我在这套流程上真实踩过的五个坑,每个都按现象、原因、解决排列。它们有个共同特点:训练过程一切正常,loss 曲线也很平滑,只是结果不对。
5.1 数据和输入侧的三个坏味道
坑一:验证准确率一直在 0.5% 左右,等于随机猜。
现象:训练集准确率能到 60%,验证集死活不动,混淆矩阵看起来完全均匀。
原因:验证集标签和图片没对上。val_annotations.txt里的文件名形如val_0.JPEG,但某些分发包里图片被重命名过,或者你在解压时用了会改变大小写的工具,val_0.JPEG变成val_0.jpeg,Linux 上大小写敏感,路径拼接后找不到文件——如果代码里用了try/except兜底,就会返回零张量而不报错。
解决:在 Dataset 的__init__里断言len(self.samples) == 10000,并在__getitem__里先path.exists()检查,缺文件立刻抛异常而不是静默跳过。
坑二:换了个模型准确率掉了 10 个点,结构没改。
现象:resnet18 换成 mobilenet_v3_small,其他都没动,top-1 从 64% 掉到 54%。
原因:归一化统计值和输入尺寸不匹配。mobilenet 的预训练配置期望 224 输入的 ImageNet 统计值,你给的是 TinyImageNet 统计值加 64 输入,两个分布同时偏,微调要花更多 epoch 才能拉回来。
解决:要么全程用一致的统计值并接受稍慢的收敛,要么在上采样到 224 时切回 ImageNet 统计值。改完之后训练集和验证集都要重新生成 transform,不能只改一边。
坑三:GPU 利用率只有 20%,一个 epoch 要跑十几分钟。
现象:nvidia-smi显示显存占满但算力利用率长期在 20% 上下,日志里每几十个 step 卡一下。
原因:JPEG 解码在 CPU 上单线程做,64×64 的图解码开销占比极高,num_workers默认是 0。
解决:num_workers=8、persistent_workers=True、pin_memory=True三件套一起上。如果还是慢,把整个训练集预先解码成uint8的.npy内存映射文件,或者打包成 WebDataset 分片,解码压力就从每个 step 挪到了预处理阶段,我一般会预留 30% 的磁盘然后一次性转换。
5.2 训练侧的三个隐形陷阱
坑四:解冻骨干之后,验证准确率突然掉 15 个点再慢慢爬。
现象:冻结阶段稳定在 55%,一解冻直接掉到 40%,之后要 10 个 epoch 才追回来。
原因:一是骨干学习率设成了和头一样的 1e-3,预训练特征被冲垮;二是model.train()把 BN 打开了,小 batch 的统计量污染了 running stats。
解决:学习率按 1:10 分组,解冻初期加 2 个 epoch 的线性 warmup;每个 epoch 在model.train()之后显式调用set_bn_eval,如果你的解冻是全量微调、batch 又大于 256,可以放开 BN 训练,否则老老实实锁住。
坑五:AMP 开启后 loss 变成 NaN,或者长时间不下降。
现象:第一个 epoch 正常,第二个 epoch 开始 loss 卡在某个值不动,日志里能看到 scaler 的 scale 值一路往下掉。
原因:梯度频繁溢出,scaler 不断缩小 scale 直到跳过太多 step。常见诱因是学习率过大、或者某层出现了除零。
解决:先把amp=False跑 50 个 step 确认数值路径没问题;再用clip=1.0裁剪梯度;如果仍然溢出,把 head 的学习率降到 5e-4 试试。另外记得在scaler.step之前判断scaler.get_scale()的走势,它比 loss 更早暴露问题。
6. 进阶技巧:分层学习率与 TTA 把最后几个点补出来
全量微调跑到收敛之后,想再往上提一两个点,最省事的是分层学习率衰减(llrd)。思路很简单:越靠近输入的层,特征越通用,越不该动;越靠近输出的层,越贴合当前任务,可以给更大的步长。给每个 stage 乘一个 0.75 的衰减系数,写起来就十几行。
def make_llrd_groups(model, base_lr=1e-4, head_lr=1e-3, decay=0.75, weight_decay=5e-4): """按 stage 从后往前递减:layer4 拿到 base_lr,layer1 拿到 base_lr * decay^3""" stages = ["layer4", "layer3", "layer2", "layer1"] groups = [] for i, stage in enumerate(stages): lr = base_lr * (decay ** i) params = [p for n, p in model.named_parameters() if n.startswith(stage) and p.requires_grad and p.ndim > 1] if params: groups.append({"params": params, "lr": lr, "weight_decay": weight_decay}) groups.append({"params": [p for n, p in model.named_parameters() if n.startswith("fc.")], "lr": head_lr, "weight_decay": weight_decay}) return groups optimizer = torch.optim.AdamW(make_llrd_groups(model))这套分组配余弦退火,在 resnet18 上通常比单组学习率稳定,尤其在训练轮次拉长到 60 epoch 之后,过拟合出现得更晚。要注意的是 stage 名字依赖 torchvision 的命名,换成别的骨干得先打印一遍named_parameters()对齐,命名对不上会静默生成空组,优化器不会报错但那些层就再也不更新了,这个错误极难发现。
另一个技巧是 TTA(测试时增强)。验证集只有 10000 张,跑两遍前向不心疼:原图一遍、水平翻转一遍,两个 softmax 取平均再 argmax。实测能稳定带来 0.8~1.5 个点,代价是验证时间翻倍。写的时候别忘了翻转后要把 logits 翻回来对齐,这个顺序错了结果会比不做 TTA 还差。判断 TTA 值不值得开,看混淆矩阵里那些对称性强的类——左右翻转后本来就应该判成同一类,这些类占比越高,收益越明显。
从那以后我每次改完数据管道,都会先只跑 200 个 step,把训练集和验证集的准确率同时打出来确认两者同步上升,再放开跑完整轮次。这个习惯帮我省下的 GPU 小时,比任何调参技巧都多。希望帮到你。
本文还有配套的精品资源,点击获取