从预训练到微调:ResNet18在小数据集上的性能提升实战

如果你正在处理一个规模有限的图像数据集,比如只有几百或几千张图片,并且对模型的最终精度抱有期待,那么你很可能已经体会过那种无力感。直接训练一个模型,效果往往不尽如人意;而直接使用一个在ImageNet上预训练好的模型,又总觉得它“不懂”你的特定任务。这种时候,微调 就成了连接通用知识与特定领域的一座关键桥梁。

这篇文章,我想和你深入聊聊,如何将一个强大的预训练模型——比如经典的 ResNet18——通过微调,让它在一个小规模、特定类别的数据集上(比如我们以11类犬种识别为例)发挥出远超预训练模型的性能。这不仅仅是替换最后一层全连接层那么简单,其中涉及数据准备、策略选择、训练技巧和效果评估的完整闭环。我会结合具体的代码和实战经验,带你走一遍从“拿来主义”到“量身定制”的全过程,看看准确率究竟能提升多少,以及为什么会有这样的提升。

1. 理解微调:为何它能成为小数据集的“救星”

在深度学习领域,尤其是在计算机视觉任务中,数据被喻为模型的“燃料”。然而,高质量、大规模的数据集往往可遇不可求。当我们面对一个仅有千余张图片的犬类识别任务时,从头开始训练一个深度神经网络,比如ResNet18,几乎注定会失败。模型参数太多,数据太少,极易导致严重的过拟合——模型记住了训练集中的所有噪声和细节,却丧失了泛化到新样本的能力。

这时,预训练模型的价值就凸显出来了。以ResNet18为例,它已经在包含1000个类别、超过百万张图片的ImageNet数据集上经过了充分的训练。在这个过程中,模型的前几层学习到了非常通用且强大的特征提取能力,比如识别边缘、纹理、形状和初级语义信息。这些特征对于绝大多数视觉任务都是有益的。

注意:微调与特征提取是两个不同的概念。特征提取是冻结预训练模型的所有层,只将其作为一个固定的特征提取器,然后在其后训练一个新的分类器。而微调则允许我们解冻部分或全部预训练层,用我们自己的数据继续训练,从而让模型的特征表示朝着我们的特定任务方向进行“微调”和优化。

对于小数据集,微调策略的优势在于:

  • 低数据需求:它极大地降低了对数据量的要求,因为模型不需要从零开始学习通用特征。
  • 高起点:训练从一个非常优秀的初始点开始,收敛速度更快,最终性能的上限也更高。
  • 防止过拟合:通过冻结部分底层网络(这些层学习的是通用特征),我们实际上减少了需要训练的参数数量,从而降低了过拟合的风险。

那么,一个关键问题是:在微调时,我们应该调整哪些层?一个常见的实践是:

  1. 完全替换分类头:移除预训练模型原有的全连接层(对应ImageNet的1000类输出),根据我们的类别数(如11类)新建一个全新的分类层。
  2. 选择性解冻:通常,我们会冻结网络的前面大部分层(尤其是卷积层),只训练最后几层和新的分类头。随着训练的进行,如果验证集性能达到平台期,可以尝试解冻更多层进行精细调整。

下表对比了三种不同策略在小数据集上的典型表现和适用场景:

策略训练参数训练速度对数据量的需求预期性能(小数据集)主要风险
从头训练全部参数极高差,严重过拟合无法收敛,泛化能力极差
特征提取仅新分类头极快较好,有基础保障性能上限受限于预训练特征
微调部分或全部参数中等通常最优学习率设置不当可能导致灾难性遗忘

从表中可以看出,微调在性能与风险之间取得了最佳平衡,是小数据集任务的首选方案。接下来,我们就进入实战环节,看看如何具体操作。

2. 实战准备:构建你的专属犬类数据集

任何机器学习项目的基石都是数据。我们的目标是构建一个用于11类犬种识别的数据集。假设你已经通过各种渠道收集了大约1089张图片,每类犬种大约100张。数据质量参差不齐,有各种角度、光照和背景。

第一步,也是至关重要的一步,是数据整理与划分。我们需要一个清晰的目录结构。我推荐如下方式:

pet_dog/
├── train/
│   ├── beagle/
│   ├── poodle/
│   ├── husky/
│   └── ... (其他9个类别)
└── val/
    ├── beagle/
    ├── poodle/
    ├── husky/
    └── ... (其他9个类别)

trainval目录下的子文件夹名称(即类别名)必须严格一致。通常按照8:2的比例随机划分训练集和验证集。这里我分享一个自己常用的、更加健壮的划分脚本,它避免了原脚本中可能因路径问题导致的错误,并增加了进度提示:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
import shutil
import random
from tqdm import tqdm  # 用于显示进度条

def split_dataset(source_root, target_root, train_ratio=0.8, seed=42):
    """
    将按类别组织的源数据集,按比例划分到train和val目录。
    
    参数:
        source_root: 源数据根目录,其下每个子文件夹是一个类别。
        target_root: 目标根目录,将在此创建train和val文件夹。
        train_ratio: 训练集比例。
        seed: 随机种子,确保结果可复现。
    """
    random.seed(seed)
    os.makedirs(os.path.join(target_root, 'train'), exist_ok=True)
    os.makedirs(os.path.join(target_root, 'val'), exist_ok=True)
    
    # 获取所有类别
    classes = [d for d in os.listdir(source_root) 
               if os.path.isdir(os.path.join(source_root, d))]
    print(f"发现 {len(classes)} 个类别: {classes}")
    
    for cls in classes:
        cls_path = os.path.join(source_root, cls)
        images = [f for f in os.listdir(cls_path) 
                  if f.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif'))]
        random.shuffle(images)
        
        split_idx = int(len(images) * train_ratio)
        train_imgs = images[:split_idx]
        val_imgs = images[split_idx:]
        
        # 创建类别子目录
        os.makedirs(os.path.join(target_root, 'train', cls), exist_ok=True)
        os.makedirs(os.path.join(target_root, 'val', cls), exist_ok=True)
        
        # 复制训练集图片
        for img in tqdm(train_imgs, desc=f"复制 {cls} -> train"):
            src = os.path.join(cls_path, img)
            dst = os.path.join(target_root, 'train', cls, img)
            shutil.copy2(src, dst)
        
        # 复制验证集图片
        for img in tqdm(val_imgs, desc=f"复制 {cls} -> val"):
            src = os.path.join(cls_path, img)
            dst = os.path.join(target_root, 'val', cls, img)
            shutil.copy2(src, dst)
    
    print("数据集划分完成!")

if __name__ == '__main__':
    # 请修改为你的实际路径
    SOURCE_DIR = "/path/to/your/raw_dog_images"  # 原始数据,按类别分文件夹存放
    TARGET_DIR = "/path/to/your/pet_dog_dataset" # 划分后的数据集总目录
    split_dataset(SOURCE_DIR, TARGET_DIR, train_ratio=0.8)

运行这个脚本后,你就得到了一个结构清晰、划分好的数据集,为后续的模型训练打好了基础。

3. 模型微调的核心:策略与代码实现

有了数据,我们就可以开始动手改造ResNet18了。使用PyTorch,这个过程变得非常直观。核心思想是:加载预训练权重,修改网络输出层,并制定一个合理的训练策略。

首先,我们来看模型初始化的关键代码。这里我采用了一种更灵活的方式,允许你通过参数控制冻结哪些层:

import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import models

def create_model(num_classes, pretrained=True, freeze_backbone=True):
    """
    创建并配置用于微调的ResNet18模型。
    
    参数:
        num_classes: 目标任务的类别数(本例为11)。
        pretrained: 是否加载ImageNet预训练权重。
        freeze_backbone: 是否冻结卷积层(特征提取部分)。
    """
    # 加载预训练模型
    model = models.resnet18(pretrained=pretrained)
    
    # 冻结所有参数的梯度
    if freeze_backbone:
        for param in model.parameters():
            param.requires_grad = False
    
    # 替换最后的全连接层
    num_ftrs = model.fc.in_features
    model.fc = nn.Linear(num_ftrs, num_classes)  # 新的分类头
    
    # 确保新添加的层的参数是需要梯度的(可训练)
    for param in model.fc.parameters():
        param.requires_grad = True
        
    # 如果你不想冻结所有层,也可以选择性地解冻最后几个阶段(stage)
    # 例如,解冻layer4(ResNet的最后一个卷积块)
    # if not freeze_backbone:
    #     for param in model.layer4.parameters():
    #         param.requires_grad = True
    
    return model

# 使用示例
model = create_model(num_classes=11, pretrained=True, freeze_backbone=True)
print(model)

接下来是训练循环。一个健壮的训练循环需要包含损失计算、精度评估、学习率调度以及模型保存。我习惯将训练和验证过程封装成一个函数,并记录每个epoch的指标,便于后续分析。

def train_one_epoch(model, dataloader, criterion, optimizer, device, scheduler=None):
    """训练一个epoch"""
    model.train()
    running_loss = 0.0
    running_corrects = 0
    
    for inputs, labels in dataloader:
        inputs, labels = inputs.to(device), labels.to(device)
        
        optimizer.zero_grad()
        with torch.set_grad_enabled(True):
            outputs = model(inputs)
            loss = criterion(outputs, labels)
            _, preds = torch.max(outputs, 1)
            loss.backward()
            optimizer.step()
        
        running_loss += loss.item() * inputs.size(0)
        running_corrects += torch.sum(preds == labels.data)
    
    if scheduler is not None:
        scheduler.step()
    
    epoch_loss = running_loss / len(dataloader.dataset)
    epoch_acc = running_corrects.double() / len(dataloader.dataset)
    return epoch_loss, epoch_acc

def validate(model, dataloader, criterion, device):
    """验证模型"""
    model.eval()
    running_loss = 0.0
    running_corrects = 0
    
    with torch.no_grad():
        for inputs, labels in dataloader:
            inputs, labels = inputs.to(device), labels.to(device)
            outputs = model(inputs)
            loss = criterion(outputs, labels)
            _, preds = torch.max(outputs, 1)
            
            running_loss += loss.item() * inputs.size(0)
            running_corrects += torch.sum(preds == labels.data)
    
    epoch_loss = running_loss / len(dataloader.dataset)
    epoch_acc = running_corrects.double() / len(dataloader.dataset)
    return epoch_loss, epoch_acc

在主函数中,我们需要配置数据加载(使用torchvisionImageFolderDataLoader)、定义优化器、损失函数和学习率调度器。这里有一个关键技巧:对于微调,我们通常希望分类头(新加的层)以较大的学习率快速学习,而预训练层以较小的学习率缓慢调整。这可以通过为模型的不同部分设置不同的学习率来实现。

# 假设model是上面创建的模型
trainable_params = []
trainable_params.append({'params': model.fc.parameters(), 'lr': 1e-3})  # 分类头,学习率稍大

# 如果解冻了部分卷积层,也为它们设置参数组
# trainable_params.append({'params': model.layer4.parameters(), 'lr': 1e-4})

optimizer = optim.SGD(trainable_params, momentum=0.9, weight_decay=1e-4)
# 或者使用Adam
# optimizer = optim.Adam(trainable_params)

criterion = nn.CrossEntropyLoss()
# 使用余弦退火学习率调度,效果通常比StepLR更平滑
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_epochs)

完成训练后,别忘了保存模型的最佳权重(通常是在验证集上准确率最高的那个),而不仅仅是最后一个epoch的权重。

4. 效果对比与分析:数字背后的故事

训练完成后,最激动人心的环节就是看结果了。我们分别用纯预训练模型(仅替换分类头,不微调)微调后的模型在同一个验证集上进行测试。

为了公平对比,对于纯预训练模型,我们冻结所有卷积层,只训练新添加的分类头(即特征提取模式)。训练5个epoch后,其在验证集上的准确率大约在 65%-72% 之间波动。这个结果不算差,说明ResNet18提取的通用特征确实有很强的区分能力。

然而,当我们采用微调策略(例如,先冻结卷积层训练几轮分类头,然后解冻最后两个卷积块继续训练)后,情况发生了显著变化。在同样的训练周期内,验证集准确率可以稳定提升到 85%-92% 的区间。

这个超过20个百分点的提升从何而来?我们可以从几个维度来分析:

  1. 特征适应性:预训练模型在ImageNet上学到的特征偏向于通用物体(猫、狗、汽车、杯子等)。而我们的犬类数据集,虽然都是狗,但不同品种在毛发纹理、耳朵形状、体型比例上存在细微差别。微调过程让模型的后几层卷积核能够调整,使其对这些类内差异更加敏感。
  2. 决策边界优化:仅仅使用固定的通用特征,分类器学习到的决策边界可能不是最优的。微调允许特征提取器和分类器协同优化,找到最适合当前数据分布的“特征-分类”组合。
  3. 过拟合的控制:由于我们采用了冻结大部分层、数据增强(随机裁剪、水平翻转等)、以及可能的正则化手段,微调过程并没有因为数据量小而陷入严重的过拟合,反而实现了泛化性能的提升。

为了更直观地感受这种差异,我们可以做一个简单的推理测试。用同一张“哈士奇”的图片,分别输入到两个模型中,观察其Top-3的预测结果。

  • 预训练模型(特征提取)输出:可能会将哈士奇识别为外形相似的“阿拉斯加雪橇犬”或“狼”,甚至可能因为背景或姿势给出完全不相关的类别,比如“吉娃娃”(如果预训练特征对大小比例不敏感的话)。
  • 微调模型输出:Top-1预测为“哈士奇”的概率会极高,Top-2和Top-3也大概率是其他雪橇犬或外形相似的犬种,表现出对犬种细分领域更强的鉴别力。

这种对比清晰地表明,微调不仅仅是让模型“认识”了新的类别,更是让它深入理解了这些类别之间的细微差别。在实际部署中,这种精度的提升直接关系到用户体验和系统可靠性。

5. 超越基础:高级技巧与避坑指南

掌握了基本的微调流程后,我们可以进一步探索一些高级技巧来榨干小数据集的每一分潜力,同时避开常见的陷阱。

数据增强的威力:对于小数据集,数据增强是必须的,而不仅仅是可选的。它通过在训练过程中对图像进行随机变换(如旋转、缩放、色彩抖动、CutMix等),来人工增加数据的多样性和数量,从而提升模型的鲁棒性。PyTorch的torchvision.transforms提供了丰富的工具。

from torchvision import transforms

# 一个更强力的训练数据增强组合
train_transform = transforms.Compose([
    transforms.RandomResizedCrop(224, scale=(0.8, 1.0)),
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomRotation(10),
    transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

学习率策略与优化器选择

  • 分层学习率:如前所述,对网络不同部分使用不同的学习率至关重要。
  • 学习率预热:训练初期使用一个很小的学习率,逐步增加到设定值,有助于稳定训练。
  • 优化器SGD with momentum 在微调中往往比 Adam 表现更好,泛化能力更强,尽管Adam收敛更快。

灾难性遗忘的应对:微调时,如果学习率设置过大,可能会“冲刷”掉预训练模型中有用的通用知识,导致模型在新任务上表现还不如特征提取。对策是使用较小的学习率(例如1e-4到1e-5对于解冻的卷积层),并配合学习率监控。

模型选择与更深层的网络:ResNet18是一个很好的起点。如果你的计算资源允许,可以尝试ResNet34、ResNet50甚至EfficientNet。更深或更先进的模型通常有更强的特征提取能力,但需要更谨慎的微调策略(如冻结更多层、使用更小的学习率),以避免过拟合。

评估与早停:始终在独立的验证集上监控模型性能。当验证集损失连续多个epoch不再下降时,就应该停止训练,保存当前最佳模型。这是防止过拟合最简单有效的方法。

最后,我想分享一个自己踩过的坑:类别不平衡问题。如果你的11类犬种图片数量差异很大(比如哈士奇有200张,而某种稀有犬只有50张),直接训练会导致模型偏向于样本多的类别。解决方法包括对少数类进行过采样、在损失函数中使用类别权重(CrossEntropyLossweight参数),或者使用更高级的采样策略。

微调是一门实践的艺术,没有放之四海而皆准的最优参数。最好的方法就是动手实验,根据你的验证集指标不断调整策略。从预训练模型出发,用你的数据为其注入“灵魂”,看着它在你的专属任务上表现越来越出色,这个过程本身就充满了乐趣和成就感。希望这篇详细的实战指南能帮你少走弯路,更快地获得理想的模型。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐