news 2026/9/8 13:44:51

基于TinyImageNet的PyTorch预训练模型微调实战与对比

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于TinyImageNet的PyTorch预训练模型微调实战与对比

简介:面向深度学习初学者与PyTorch开发者,资源以TinyImageNet数据集为载体,演示如何利用预训练模型进行图像分类微调。TinyImageNet有约200类64×64彩色图像,数据量适中,非常适合在有限算力下练习迁移学习。资源中的Python脚本覆盖数据加载与预处理、预训练模型读取、冻结底层参数、替换分类层、损失函数与优化器设置、训练验证及测试评估等关键环节,并额外提供量化相关脚本,可帮助理解模型压缩思路。压缩包共34个文件,以Python源码与pyc编译缓存为主,辅以shell运行脚本、README说明和许可证文件,整体仅58KB,目录简洁、易于按需修改。已有477人学习下载,适合希望快速上手PyTorch迁移学习、在小型数据集上验证微调效果的开发者参考。 去年年底我接到一个图像分类的小任务,数据集不大、显卡也不算富裕,但类别数不少,要求还堆得挺满。第一反应是拿个现成的预训练模型直接微调,毕竟这年头从零训练一个CNN已经不划算了。于是我把TinyImageNetTorch这个项目翻了出来,在TinyImageNet数据集上用PyTorch把预训练模型微调的整体流程完整跑了一遍,顺手把全量微调、freeze微调和lora微调都对比了一轮。这篇文章就是把那段时间的实操记录、踩坑经历和代码细节整理出来,给想快速上手微调、又不想只看理论的朋友做个参考。

这项目适合谁呢?你如果已经会PyTorch基本操作,接触过分分类任务,但没认真折腾过“微调”这个环节,那这篇应该能让你少走不少弯路。就算你之后要去做大模型微调,这里面的核心思路——什么时候该冻层、学习率怎么设、数据增强做到什么程度——也是一脉相承的。

1. 项目起点:为什么选TinyImageNet做微调实验

1.1 TinyImageNet是什么,和ImageNet差在哪

TinyImageNet是ImageNet的一个压缩版子集,保留200个类别,每类500张训练图、50张验证图和50张测试图,图片统一缩放到64x64分辨率。这里要注意,它不是CIFAR-10那种32x32的小图,64x64虽然也不大,但好歹能看出物体轮廓,比CIFAR多了一点真实世界的纹理和结构信息。

它和完整ImageNet最大的区别其实不在类别数,而在分辨率和数据量。ImageNet原始图片是224x224甚至更大,TinyImageNet直接缩到64x64,这意味着预训练模型如果原本是在ImageNet上训练的,它的默认输入尺寸通常是224x224,直接拿64x64的图喂进去,要么先插值放大,要么就把模型第一个卷积层的感受野重新考虑一下。实际项目中,绝大多数人都是直接resize到224x224再喂给预训练模型,代价是多一点计算量,但能省掉很多适配上的麻烦。

从实验角度讲,TinyImageNet比CIFAR更接近真实分布,又比完整ImageNet好跑得多,单卡就能玩,迭代一轮也就几分钟的事。用来验证微调策略、对比不同微调方式,非常合适。

1.2 为什么是微调,而不是从头训

这个问题我每次跟新人聊都要强调一遍。一个ResNet18在ImageNet上预训练,实际上已经把“怎么看一张图”这个事学得差不多了——边缘、纹理、形状、部分语义信息,都在卷积核里存着。你拿到一个新任务,不管是分200类还是分2类,这些底层特征都是通用的,差别主要在高层的语义组合方式。

从头训练意味着你要用有限的数据把这一整套特征重新学一遍。数据少、算力不够、训练不稳定,三个坑随便踩一个就够你受的。而微调等于“站在巨人肩膀上”,你只需要让模型适应目标任务的特点,收敛速度快得多,最终精度也通常更高。

我在这个项目里用的就是ResNet18预训练模型,torchvision里直接加载,权重是在ImageNet上训好的。这样我能把精力放在研究“怎么微调更好”上,而不是从头折腾一个网络架构。

1.3 项目目标与技术选型

这个项目的核心目标很简单:在TinyImageNet数据集上,通过微调预训练ResNet18模型,达到尽可能高的分类准确率,同时对比不同微调策略的差异。

技术选型上:

  • 框架:PyTorch,生态成熟,torchvision里直接有预训练模型,加载权重一行代码。
  • 模型:ResNet18为主,轻量、收敛快,足够应付64x64输入。也试了ResNet50,精度略高但训练时间明显变长,平衡下来ResNet18更适合快速迭代。
  • 微调方式:全量微调、freeze微调、lora微调,三种都各跑一轮,做对比。

这里提前说一个结论:对于TinyImageNet这种中等规模数据,freeze微调的效果往往比全量微调更稳。原因后面展开讲。

2. 微调前的准备:环境、数据与模型

2.1 环境搭建与依赖安装

我用的环境是Python 3.10 + PyTorch 2.1.0 + CUDA 11.8,显卡是一张Tesla T4,16GB显存,跑ResNet18的微调绰绰有余。

conda create -n tinyimg python=3.10 conda activate tinyimg pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install matplotlib numpy tqdm pillow

这里有个细节:torchvision的版本一定要跟torch匹配,否则加载预训练权重很容易报错。我一开始用的torch 2.0 + torchvision 0.15,倒是没出问题;但如果你用torch 2.1系列,torchvision最好也是0.16,不然models.resnet18(weights=ResNet18_Weights.DEFAULT)这行可能直接挂掉。

2.2 数据集下载与预处理

TinyImageNet的原始数据可以从CS231N课程页面下载,也可以到Kaggle上找现成的。下载完解压后,目录结构大致是:

tiny-imagenet-200/ ├── train/ │ ├── n01443537/ │ │ ├── images/ │ │ │ ├── n01443537_0.JPEG │ │ │ └── ... │ │ └── n01443537_boxes.txt │ └── ... ├── val/ │ ├── images/ │ ├── val_annotations.txt │ └── ... └── wnids.txt

train目录下是200个类别的子文件夹,每个文件夹一个类,直接用torchvision的ImageFolder就能读。麻烦的是val目录,它的图片是平铺在images/下的,需要根据val_annotations.txt里的映射关系重新组织成按类别的子目录结构,否则ImageFolder没法直接用。

我写了一个小脚本处理这个事:

import os import shutil val_dir = 'tiny-imagenet-200/val' val_images = os.path.join(val_dir, 'images') anno_file = os.path.join(val_dir, 'val_annotations.txt') with open(anno_file) as f: for line in f.readlines(): parts = line.strip().split('\t') img_name, class_id = parts[0], parts[1] class_dir = os.path.join(val_dir, class_id) os.makedirs(class_dir, exist_ok=True) shutil.copy( os.path.join(val_images, img_name), os.path.join(class_dir, img_name) )

跑完之后,val目录下会出现200个分类子文件夹,ImageFolder就能正常读用了。

2.3 预训练模型的选择:ResNet系列怎么挑

torchvision里预训练模型很多,但做微调实验,我建议从ResNet18或ResNet34入手,原因很实际:

  • ResNet18参数量约1100万,T4上单卡跑batch size 128毫无压力,迭代也快。
  • ResNet50参数量约2500万,精度略高,但训练时间大概多一倍,如果只是想验证微调策略,没必要一开始就上它。
  • 更重的模型(比如ViT)在TinyImageNet这种数据规模下反而容易过拟合,而且调参难度高。

加载方式:

import torchvision.models as models model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) num_ftrs = model.fc.in_features model.fc = torch.nn.Linear(num_ftrs, 200)

把全连接层的输出从1000改成200即可。这里有个关键点:model.fc替换之后,新初始化的这一层的学习率最好比主干网络大一些,因为它是随机初始化的,需要更快地学习;而主干已经预训练过了,学习率要放低,避免破坏原有特征。

3. 三种主流微调方式,到底怎么选

3.1 全量微调:最直接但最吃资源

全量微调就是让整个网络的参数都参与梯度更新,包括预训练好的卷积层和最后新加的全连接层。这么做的好处是模型完全自由地去适应新数据集,理论上限最高;坏处是需要的训练数据量、显存和时间都多不少。

在做TinyImageNet实验时,全量微调我用的是SGD优化器加动量,学习率设0.001,batch size 128。效果上,训练集准确率能很快冲到95%以上,但验证集准确率增长相对缓慢,最后稳定在56%左右。中间还出现过训练集和验证集差距越拉越大的情况,明显是过拟合的趋势。

我的建议是,如果你的数据集和预训练任务的分布差异比较大(比如ImageNet预训练后做医学影像),全量微调会更合适,因为它允许模型彻底调整特征提取方式。但如果数据集本身就比较小,全量微调风险较高,容易过拟合。

3.2 freeze微调:冻结特征提取层,只训练分类头

freeze微调也叫冻结微调,做法是冻结backbone(主干网络)的所有参数,只训练最后几层或全连接层。在PyTorch里操作很简单:

# 冻结除fc外的所有层 for param in model.parameters(): param.requires_grad = False # 解冻最后一层(fc已经被替换成200类输出了) for param in model.fc.parameters(): param.requires_grad = True

你还可以选择“部分解冻”,比如解冻最后两个block,让高层特征也能学一点新任务的东西。我在实验里做了两种设置:

  • 只训练fc层:验证集准确率约53%
  • 解冻最后一个残差块 + fc层:验证集准确率约58%,比全量微调的56%还高一点

这个结果很说明问题:TinyImageNet的64x64输入分辨率跟ImageNet的224x224差距大,模型底层特征基本还够用,但高层的语义特征需要跟着目标类别适当调整。只解冻最后几个block,既控制了训练复杂度,又给模型留了适应空间。

冻结微调最大的优势是显存占用小、训练快,因为不需要反向传播更新前面层的梯度,中间特征图也不用一直保留。如果显存紧张,或者需要快速迭代验证想法,这个方法很实用。

3.3 LoRA微调:参数高效的另一种思路

LoRA(Low-Rank Adaptation)最初是大模型微调的宠儿,思路是冻结原模型全部参数,在权重矩阵旁边加一个低秩分解的旁路分支,训练时只更新旁路参数。在PyTorch里,借助peft库,哪怕用在ResNet这种CNN上也照样能跑。

from peft import LoraConfig, get_peft_model lora_config = LoraConfig( r=8, lora_alpha=16, lora_dropout=0.1, target_modules=["conv1", "conv2", "conv3"] # 按实际层名调整 ) model_lora = get_peft_model(model, lora_config)

需要注意,ResNet的卷积层有很多重复的block,层名管理比Transformer复杂,target_modules得写对。实践下来,对CNN做LoRA微调的效果不如在Transformer架构上那么亮眼,但优势依然有用:只训练少量参数(大约只有全量参数的0.5%-2%),显存占用极低,而且能保持接近全量微调的精度。

在我的实验里,LoRA微调(r=8)最终验证集准确率约55%,略低于“解冻最后一个block+fc”的58%,但训练速度最快、显存占用最小。如果你的场景里,每个任务都要出一个单独的模型,LoRA这种能快速切换任务而不用为每个任务存一个大模型的方式,就非常有价值。

3.4 三种方式的对比速查表

微调方式可训练参数量显存占用训练速度TinyImageNet验证集Top-1适用场景
全量微调全部56%数据量大、任务分布差异大
freeze(只训fc)仅fc层极低极快53%数据少、任务接近预训练
freeze(解冻最后block+fc)少量58%中等数据、迁移调试通用
LoRA微调约1%-2%极低55%多任务并行、参数高效场景

从这张表能看出,并不是“微调方式越复杂效果越好”,更多是问题和资源的匹配。TinyImageNet上表现最好的是“解冻最后block+fc”这种中间态策略,既给了模型适应空间,又没让它自由到过拟合。

4. 实操过程:TinyImageNetTorch核心代码拆解

4.1 数据加载与增强的关键细节

数据增强对微调结果的影响,有时比模型结构本身还大。我的做法是:

from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize(224), # 或 256,然后 RandomCrop(224) transforms.RandomHorizontalFlip(), transforms.RandomAffine(degrees=10, translate=(0.05, 0.05)), transforms.ColorJitter(brightness=0.2, contrast=0.2), 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]) ])

这里有几个细节:

  • 图像从64x64直接Resize到224x224,虽然会模糊,但预训练模型是在224x224上训的,输入分布更匹配,精度反而比直接喂64x64好。
  • 归一化的mean和std用的是ImageNet的标准值,因为预训练模型就是在ImageNet上学的特征,输入分布保持一致很重要。
  • 验证集不要用RandomCrop,只用CenterCrop,保证评估结果稳定、可复现。

4.2 训练循环与超参数设置

基于TinyImageNetTorch这个项目的核心代码思想,我整理了一份简洁的训练循环。优化器上,微调阶段我更推荐SGD+momentum,而不是Adam。原因很简单:SGD在微调场景下往往能收敛到更好的局部最优,尤其当你的学习率调得足够保守时。Adam虽然前期收敛快,但后期精度容易卡住。

import torch import torch.optim as optim from tqdm import tqdm def train_epoch(model, dataloader, criterion, optimizer, device): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in tqdm(dataloader): images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() return running_loss / total, 100.0 * correct / total

超参数设置我的经验值:

  • 优化器:SGD,momentum=0.9,weight_decay=1e-4
  • 初始学习率:全量微调用0.001,freeze微调用0.01(因为要训练的参数少,学习率可以稍大)
  • 学习率调度:CosineAnnealingLR,T_mult=2,注意配合warmup
  • Epoch:小数据150个epoch,大数据20~50个epoch
  • Batch size:64或128,取决于显存

这里要特别强调学习率。微调预训练模型最忌讳的学习率就是设太大,0.01对全量微调来说就是灾难,loss直接震荡到怀疑人生。我强烈建议加一个warmup,前5个epoch从0线性升到设定值,让模型特别是新替换的fc层先适应一下随机初始化的状态,再进入正式训练节奏。

optimizer = optim.SGD(model.parameters(), lr=0.001, momentum=0.9, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)

4.3 评估与结果可视化

训练完成后,除了验证集准确率,我还喜欢画一下混淆矩阵和loss曲线。混淆矩阵能直观看出哪些类别容易被混淆,比如“猫”和“狗”这种视觉近邻类目。除此之外,我还做了个有意思的检查——把每个类别的样本数统计一下,看数据是否均衡。TinyImageNet本身类别均衡,但如果你换了自定义数据集,这一步一定不要省。

保存模型的时候,我习惯把完整checkpoint存下来,包含模型的状态字典、优化器状态、epoch数和最佳准确率:

torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'best_acc': best_acc, }, 'best_checkpoint.pth')

Load的时候也要整套加载,这样即使后面训练中断,也能从断点恢复,不用从头再来。

5. 常见问题与排查技巧实录

5.1 显存不够用怎么办

微调预训练模型对显存的需求主要来自三块:模型参数、激活值、优化器状态。全量微调时三层显存都要占,freeze微调时背部的激活值不需要保留,可以省下一大块。

如果还是不够,我的经验是:

  • 先降batch size,最直接。
  • 开启torch.cuda.amp.autocast()GradScaler做混合精度训练,能把显存占用降低一半左右,且精度损失几乎可以忽略。
  • 用梯度累积,比如batch size=16跑4步再更新一次权重,效果等价于batch size=64。唯一注意的就是批归一化层的行为,它会在每步累积时看到的样本更少,影响均值方差的估计。
scaler = torch.cuda.amp.GradScaler() for i, (images, labels) in enumerate(dataloader): images, labels = images.to(device), labels.to(device) with torch.cuda.amp.autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() if (i + 1) % accumulation_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()

5.2 过拟合还是欠拟合,怎么判断和调优

做微调最常见的两个状况:

第一,训练集loss一直降,验证集loss不降反升,这就纯纯过拟合。解决办法按顺序尝试:加大数据增强(RandomResizedCrop、Cutout、Mixup)、加dropout(在fc层前加Dropout(0.2))、调大weight_decay、降低fc层学习率。通常前两个就能解决大部分问题。

第二,训练集和验证集loss都迟迟降不下去,这是欠拟合。先检查学习率是不是太小,再看是不是冻结了太多层导致模型表达能力不够。如果已经解冻到最后两个block还没效果,就得考虑换更大的模型,或者减少冻结范围。

5.3 数据加载成瓶颈的优化手段

64x64的图片本来加载很快,但如果我把它resize到224x224,数据加载的CPU预处理就成了一个大瓶颈。尤其是你机器上CPU核数少的话,GPU经常会空着等数据。这时候有两个优化方向:

  • DataLoader里把num_workers调高,我实测8线程比2线程快一倍不止。
  • 把预处理完的图片以tensor形式缓存到内存里(如果内存够的话),或者在首次加载时把resize后的图片存成LMDB/ImageFolder的cache,之后每次直接读缓存。

另外,如果数据集路径在机械硬盘上,IO会非常慢,尽量把数据放到SSD上,或者一次性加载进内存。

TinyImageNet全套图片加起来不大(几百MB),单纯一次性读入内存的做法完全可以接受:

from torch.utils.data import TensorDataset # 先一次性读取并resize到224x224,转成tensor存内存 images, labels = [], [] for path, label in tqdm(dataset.samples): img = Image.open(path).convert('RGB') img = val_transform(img) # 预处理的transform images.append(img) labels.append(label) X = torch.stack(images) y = torch.tensor(labels) memory_dataset = TensorDataset(X, y) memory_loader = DataLoader(memory_dataset, batch_size=128, shuffle=True)

这样跑起来,GPU利用率能稳定保持在95%以上,整个训练过程从“等数据”变成了“纯算”。

5.4 类别不匹配与加载预训练权重的坑

很多新手在微调时遇到的第一个报错就是:加载预训练权重时最后一层维度不匹配。原因很简单——替换fc层的输出类别后,新旧权重矩阵形状不一致,直接load_state_dict必报错。解决办法就是先替换fc层,再加载预训练权重,或者用load_state_dict(weights, strict=False)跳过不匹配的层。

我个人的习惯是:先创建模型、替换fc层、再加载预训练权重。这样strict=True,能保证预训练权重的完整性,避免因为参数名错位导致某些层随机初始化而不自知。

最后的几点私货

最初跑TinyImageNetTorch这个项目,本意只是练手,没想到最后把这些微调方式、数据流优化、训练技巧逐个摸了一遍,反而对迁移学习有了更立体的理解。现在回想起来,有几个经验特别想留在这里:

  • 微调前先花几分钟分析数据分布和预训练任务之间的关系,它决定了你该选哪种微调策略,而不是上来就无脑全量微调。
  • 学习率永远是第一优先级的超参数。微调时宁可小一点,也不要因为心急开大学习率导致前期训练直接崩掉。
  • 如果显卡是T4这种入门级,混合精度训练和freeze微调是好朋友,能帮你轻松省下一半显存。
  • 下次再有小规模图像分类项目,我大概率还是会先跑一遍“解冻最后block+fc”的freeze微调作为baseline,再考虑要不要换更强的模型或更复杂的策略。

如果你也想动手试试,这个项目的代码量不大,完全可以在一晚上跑出初步结果。把这套流程吃透之后,你再去看大模型微调的LoRA也好、全量微调也好,会发现核心思想早就通了。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 13:44:12

Selenium测试框架云上集成指南:环境搭建、Grid集群与自动化实践

1. 从零到一:为什么要在云上集成Selenium测试框架做Web自动化测试的朋友应该都有类似的经历:本地脚本跑得好好的,一换环境就崩;领导要看报告,你得半夜爬起来截日志;项目组想搞持续集成,可测试机…

作者头像 李华
网站建设 2026/9/8 13:44:05

芯片UID实战:从字符串陷阱到防抄板与MQTT一机一密

量产发货后的第一个深夜,群里突然开始刷屏:一批设备连不上MQTT服务器,报认证失败,而且失败的批次很集中。我当时第一反应是服务器配置出问题了,结果查了一圈EMQX日志,发现是设备上报的ClientID在同一批里几…

作者头像 李华
网站建设 2026/9/8 13:43:09

hermes-agent:构建稳定可控的AI智能体架构的工程实践

很多人都觉得,只要把大模型 API 一接,再丢给它几个工具函数,一个 AI 智能体就算做完了。可真到了实际项目里,你会发现 prompt 写得再花哨,只要工具一多、任务一长,agent 就开始“胡言乱语”,要么…

作者头像 李华
网站建设 2026/9/8 13:43:08

【单片机毕业设计】基于 STM32 的多传感器数据采集消防控制系统设计 基于 STM32 的本地阈值配置安防环境监控系统设计与实现(012607)

博主介绍:✌️码农一枚 ,专注于大学生项目实战开发、讲解和毕业🚢文撰写修改等。全栈领域优质创作者,博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于嵌入式单片机,Java、小程序技术领域和毕业项目实战 ✌️…

作者头像 李华
网站建设 2026/9/8 13:43:03

上位机开发必踩的坑:大小端与字节序完整解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 13:42:47

AI虚拟试穿:从技术原理到电商落地全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华