深度学习从入门到精通 - 迁移学习实战:用预训练模型解决小样本难题

发布于:2025-09-06 ⋅ 阅读:(15) ⋅ 点赞:(0)

深度学习从入门到精通 - 迁移学习实战:用预训练模型解决小样本难题

各位,是不是经常遇到这样的困境:手头只有一个可怜巴巴的小数据集,几百张图片,甚至几十条文本记录,却梦想着搭建一个能打的深度学习模型?看着那些在ImageNet上横扫千军的巨型模型,再看看自己那点寒酸的标注数据,是不是觉得希望渺茫?别急,这就是迁移学习(Transfer Learning) 大显身手的时候了!今天这篇长文,咱们就扎扎实实地聊透:如何利用别人训练好的、强大的预训练模型,来解决你的小样本学习难题。我会带你们从理论到代码,从选择模型到调优技巧,把整个流程掰开了揉碎了讲清楚,更重要的是——分享那些我亲自踩过的坑,帮你们省下宝贵的头发

为什么非得是迁移学习?

先说个最直观的痛点:训练数据太少。深度神经网络,特别是处理图像、文本、语音这些复杂数据的模型,动辄需要数百万甚至上亿的标注样本才能训练出好效果。想想ImageNet那1400多万张图片!咱们普通人、小团队、初创公司,哪有这种级别的资源?从头开始训练一个像样的模型,在小数据上,结局几乎注定是灾难性的过拟合(Overfitting) ——模型把训练数据里的噪声和无关特征都记住了,在没见过的数据上表现一塌糊涂。

那预训练模型的价值在哪?想象一下,一个在ImageNet上训练好的ResNet模型,它已经学会了识别成千上万种物体的基本视觉元素:边缘、角点、纹理、基本形状、甚至一些复杂的物体部件。这些低层到中层的视觉特征,对于绝大多数视觉任务(比如识别不同的动物、医疗影像分析、卫星图像识别)其实都是通用的! 为什么要从零开始重新发明轮子呢?

巨大的通用数据集
训练复杂的深度模型
预训练模型 - 学习通用特征
你的小数据集
微调特定任务
高性能专属模型

这张图道出了迁移学习的核心思想:知识迁移。我们把在大数据集(源域)上学到的知识(模型参数),迁移到我们关心的、数据量小的目标任务(目标域)上。迁移学习通常有两种主要策略:

  1. 特征提取(Feature Extraction):把预训练模型当作一个强大的、固定的特征提取器。移除其最后的分类层(通常是全连接层),然后让我们的小数据集通过这个“管道”,提取出丰富的特征向量。接着,我们在这些提取好的特征之上,训练一个新的、简单的分类器(比如一个线性SVM或者一个小型全连接网络)。
  2. 微调(Fine-Tuning):在特征提取的基础上更进一步。我们不仅替换掉最后几层,还会解冻预训练模型的一部分(通常是靠近顶部的层),让它们也能在我们的小数据集上进行轻微更新。这样,模型不仅能利用通用的底层特征,还能针对当前任务的特殊性,调整那些更抽象的、高层特征的表征。这个技巧在小样本场景下极其有效,是我强烈推荐的主力方案。

迁移学习的数学直觉:特征表示的可迁移性

为什么底层的卷积核学到的特征可以迁移?形式化地理解一下。假设预训练模型 fsf_sfs 在源域数据 DsD_sDs 上训练,学习到的参数为 θs\theta_sθs。其目标是优化源任务损失函数 LsL_sLs

θs∗=arg⁡min⁡θLs(fs(x;θ),ys)for(x,ys)∈Ds \theta_s^* = \arg \min_{\theta} L_s(f_s(x; \theta), y_s) \quad \text{for} \quad (x, y_s) \in D_s θs=argθminLs(fs(x;θ),ys)for(x,ys)Ds

迁移学习的目标是将 fsf_sfs 的知识迁移到目标任务上,目标任务有数据集 DtD_tDt,目标任务损失函数 LtL_tLt。我们初始化目标模型 ftf_tft 的参数 θt\theta_tθtθs∗\theta_s^*θs (或部分参数)。

关键假设:源域 DsD_sDs 和目标域 DtD_tDt 共享某种底层的数据生成分布 P(x)P(x)P(x) 的某些结构,即使它们的条件分布 P(y∣x)P(y|x)P(yx) 可能不同(任务不同)。深度模型的层级结构使得:

  • 低层参数 θlow\theta^{low}θlow:捕捉图像/数据的通用低级特征(如边缘、纹理),这些特征对很多任务都是普适的(即 Plow(x)P^{low}(x)Plow(x) 相似)。迁移时,这些层通常冻结(freeze),即 θtlow=θslow∗\theta_t^{low} = \theta_s^{low*}θtlow=θslow 保持不变。
  • 高层参数 θhigh\theta^{high}θhigh:学习更抽象、任务相关的特征(如物体部件、类别概念)。Phigh(y∣x)P^{high}(y|x)Phigh(yx) 可能不同。在微调时,我们允许 θthigh\theta_t^{high}θthigh 在目标数据 DtD_tDt 上继续更新:

θthigh∗=arg⁡min⁡θhighLt(ft(x;θtlow∗,θhigh),yt)for(x,yt)∈Dt \theta_t^{high*} = \arg \min_{\theta^{high}} L_t(f_t(x; \theta_t^{low*}, \theta^{high}), y_t) \quad \text{for} \quad (x, y_t) \in D_t θthigh=argθhighminLt(ft(x;θtlow,θhigh),yt)for(x,yt)Dt

  • 新任务层 θnew\theta^{new}θnew:完全重新训练的层,适应目标任务的具体输出(如新的类别数)。

我们期望 θslow∗\theta_s^{low*}θslow 已经是一个非常好的通用特征提取器起点,大大降低了在 DtD_tDt 上学习 θthigh∗\theta_t^{high*}θthighθnew∗\theta^{new*}θnew 所需的样本复杂度和优化难度。

实战开始:用PyTorch玩转迁移学习(以图像分类为例)

假设我们要构建一个花卉分类器,但手头只有每个类别几十张图片(典型的 Caltech-101 风格的小样本任务)。我们选用在 ImageNet 上预训练好的 ResNet50 作为基础模型。

第一步:获取并准备预训练模型

import torch
import torchvision.models as models
import torch.nn as nn

# 加载预训练的ResNet50模型,并下载权重
pretrained_model = models.resnet50(weights='IMAGENET1K_V2')  # 使用torchvision较新的weights API

# 查看模型结构 (可选)
print(pretrained_model)

关键点解释: weights='IMAGENET1K_V2' 告诉 PyTorch 加载在 ImageNet 数据集上训练好的权重。ResNet50 的最后一层是一个有 1000 个神经元的全连接层(对应 ImageNet 的 1000 个类)。这个层我们肯定要换掉!

第二步:冻结卷积层(特征提取阶段的关键)

这是避免小数据破坏预训练特征的关键操作!切记!

# 冻结模型的所有参数(默认情况下,新加载的模型参数是 requires_grad=True 的)
for param in pretrained_model.parameters():
    param.requires_grad = False  # 在反向传播时,这些参数不再计算梯度,也就不会被更新

踩坑记录: 我见过不少朋友兴奋地加载了预训练模型,却忘了冻结卷积层,结果在小数据集上训练了几个 epoch 后,模型效果反而比随机初始化还差!为什么呢?因为小数据集提供的梯度信号太弱、噪声太大,不足以引导整个庞大的卷积基进行有意义的更新,反而把原本学好的通用特征给“污染”或“遗忘”了。这个小细节真的能决定成败。

第三步:替换分类头(任务适配)

# 计算我们新数据集的类别数 (假设 num_classes 已知, 比如 5 类花卉)
num_classes = 5

# 获取ResNet50最后一个全连接层的输入特征数 (fc层的in_features)
num_ftrs = pretrained_model.fc.in_features

# 创建一个新的全连接层 (nn.Linear), 输入维度是num_ftrs, 输出维度是我们的类别数num_classes
pretrained_model.fc = nn.Linear(num_ftrs, num_classes)

# 现在,只有新添加的 fc 层的参数 requires_grad=True (默认就是True)

关键点解释: 我们只保留了 ResNet50 的卷积基(特征提取器),把最后的分类器(fc层)替换成了适合我们任务的新分类器。此时,可训练的参数只有新 fc 层的权重和偏置项。

第四步:准备数据(小样本更要精心处理)

数据少,数据增强(Data Augmentation) 就是救命稻草!它能人为增加数据的多样性和数量,有效防止过拟合。

from torchvision import transforms, datasets
from torch.utils.data import DataLoader

# 定义训练和验证的数据增强及归一化
# 注意:验证/测试集通常只做归一化,不做随机增强!
train_transforms = transforms.Compose([
    transforms.RandomResizedCrop(224),          # 随机裁剪并缩放到224x224
    transforms.RandomHorizontalFlip(),          # 随机水平翻转
    transforms.RandomRotation(15),              # 随机旋转±15度
    transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), # 随机调整亮度/对比度/饱和度
    transforms.ToTensor(),                      # 转为Tensor [0,1]
    transforms.Normalize(mean=[0.485, 0.456, 0.406],  # ImageNet的均值归一化
                         std=[0.229, 0.224, 0.225])   # ImageNet的标准差归一化
])

val_transforms = transforms.Compose([
    transforms.Resize(256),                     # 缩放到256x256
    transforms.CenterCrop(224),                 # 中心裁剪到224x224
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

# 假设你的数据文件夹结构是:
# dataset_root/
#     train/
#         class1/
#         class2/
#         ...
#     val/
#         class1/
#         class2/
#         ...
train_dataset = datasets.ImageFolder('path/to/dataset_root/train', transform=train_transforms)
val_dataset = datasets.ImageFolder('path/to/dataset_root/val', transform=val_transforms)

# 创建数据加载器 (DataLoader)
# 小样本情况下,batch_size不宜过大,避免几个batch就遍历完整个epoch,导致梯度更新不稳定。
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4)
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4)

踩坑记录: 归一化参数 (mean, std) 直接用了 ImageNet 的。这个好不好?大多数情况下没问题,因为预训练模型是在ImageNet归一化的数据上训练的,输入保持一致很重要。 除非你的数据分布(比如医学X光片)和自然图像(ImageNet) 极其 不同,才需要考虑计算自己数据的均值和标准差。另一个坑是增强强度:增强太弱,效果有限;增强太强(比如大角度旋转对某些类别不合理),反而会扭曲样本,引入噪声。需要根据具体数据尝试调整。

第五步:定义损失函数和优化器(只优化新层)

import torch.optim as optim

# 将模型移到GPU (如果可用)
device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')
pretrained_model = pretrained_model.to(device)

# 定义损失函数 (交叉熵损失非常适合分类任务)
criterion = nn.CrossEntropyLoss()

# 定义优化器 (只优化那些 requires_grad=True 的参数, 也就是我们新添加的fc层)
# 学习率 (lr) 是关键参数!对于新层,我们可以用相对较大的学习率 (比如0.001, 0.01)
optimizer = optim.Adam(pretrained_model.fc.parameters(), lr=0.001)  # 这里我偏爱Adam

为什么学习率可以设大点? 因为新 fc 层的权重是随机初始化的,没有经过预训练,需要用较大的步长快速学习适应我们任务的特征。同时,卷积基被冻结了,不用担心大学习率破坏它们。

第六步:训练与评估(简洁版)

num_epochs = 25  # 小样本通常收敛较快,但也要足够轮次让新分类器学好

for epoch in range(num_epochs):
    # 训练阶段
    pretrained_model.train()  # 设置模型为训练模式 (启用 BatchNorm/Dropout)
    running_loss = 0.0
    running_corrects = 0

    for inputs, labels in train_loader:
        inputs = inputs.to(device)
        labels = labels.to(device)

        # 前向传播
        outputs = pretrained_model(inputs)
        loss = criterion(outputs, labels)

        # 反向传播 + 优化 (只更新fc层!)
        optimizer.zero_grad()  # 清除历史梯度
        loss.backward()        # 计算梯度
        optimizer.step()       # 更新参数

        # 统计
        _, preds = torch.max(outputs, 1)
        running_loss += loss.item() * inputs.size(0)
        running_corrects += torch.sum(preds == labels.data)

    epoch_loss = running_loss / len(train_dataset)
    epoch_acc = running_corrects.double() / len(train_dataset)
    print(f'Epoch {epoch+1}/{num_epochs} Train Loss: {epoch_loss:.4f} Acc: {epoch_acc:.4f}')

    # 验证阶段 (在每个epoch结束时评估)
    pretrained_model.eval()  # 设置模型为评估模式 (禁用 BatchNorm/Dropout)
    val_running_loss = 0.0
    val_running_corrects = 0
    with torch.no_grad():    # 在验证时不计算梯度,节省内存和计算
        for val_inputs, val_labels in val_loader:
            val_inputs = val_inputs.to(device)
            val_labels = val_labels.to(device)
            val_outputs = pretrained_model(val_inputs)
            val_loss = criterion(val_outputs, val_labels)
            _, val_preds = torch.max(val_outputs, 1)
            val_running_loss += val_loss.item() * val_inputs.size(0)
            val_running_corrects += torch.sum(val_preds == val_labels.data)

    val_epoch_loss = val_running_loss / len(val_dataset)
    val_epoch_acc = val_running_corrects.double() / len(val_dataset)
    print(f'Epoch {epoch+1}/{num_epochs} Val Loss: {val_epoch_loss:.4f} Acc: {val_epoch_acc:.4f}')

print('Training complete!')

特征提取模式总结: 这种方法通常能快速得到一个还不错的基线模型,计算开销小(因为大部分网络不需要计算梯度),特别适合计算资源有限或者数据量非常非常小(<100/类)的情况。但缺点也很明显:性能天花板可能不够高,因为它无法利用我们特定的数据去优化那些更抽象的特征表示。

进阶秘籍:微调(Fine-Tuning)解锁更高性能

当你的小样本数据量稍微大一点(比如每类有几百张),或者特征提取模式的效果不够满意时,该祭出微调大法了!微调的精髓在于:解冻预训练模型靠近顶部的部分卷积层,让它们也能在我们的数据集上进行小幅更新。

为什么只解冻顶部层?

  • 底层的卷积核学习的是非常通用、基础的特征(边缘、纹理),这些特征对大多数视觉任务都很有用,基本不需要改变。解冻它们风险大(容易破坏),收益不确定。
  • 高层的卷积核学习的是更抽象、更特定于原始任务(ImageNet分类)的特征(如“轮子”、“动物头部”)。这些特征可能需要根据我们的新任务(比如花卉分类)进行调整,使其更关注花朵的结构、花瓣纹理等。解冻这些层,让它们“忘记”一点原始的1000类,学会聚焦于我们的5类花卉。

如何选择解冻哪些层? 这个没有绝对标准,需要实验!常见策略:

  1. 解冻最后1-2个Block:以ResNet50为例,它由4个主要阶段(layer1, layer2, layer3, layer4),每个阶段包含多个残差块。通常从解冻最后一个或两个阶段(如 layer4, layer3)开始尝试。
  2. 解冻所有卷积层,但使用极小的学习率:更激进,但风险也更大。需要更谨慎的学习率调度。
  3. 逐层解冻(Gradual Unfreezing):开始时只训练新分类层;训练稳定后,解冻最高层并训练;最后再解冻更多层。这比较稳健。

修改代码进行微调(续接特征提取的代码):

# 在替换完 fc 层之后,进行解冻操作 (假设我们决定解冻 ResNet50 的 layer4)
# 1. 首先,确保之前的冻结操作已经做了 (即大部分层是冻结的)
# 2. 找到我们要解冻的层 (这里是 layer4)
for name, param in pretrained_model.named_parameters():
    # 默认所有param.requires_grad=False (之前冻结了)
    if 'layer4' in name:  # 或者更具体的名字如 'layer4.0.conv1.weight'
        param.requires_grad = True  # 解冻 layer4 的所有参数

# 检查一下解冻情况 (可选)
for name, param in pretrained_model.named_parameters():
    if param.requires_grad:
        print(f"Will train: {name}")

# 重新定义优化器,这次要包含那些新解冻的、需要梯度的参数!
# 学习率策略非常重要!!!
optimizer = optim.Adam(
    [
        {'params': pretrained_model.fc.parameters(), 'lr': 0.001},  # 新分类层,较大的学习率
        {'params': pretrained_model.layer4.parameters(), 'lr': 0.0001}  # 解冻的卷积层,非常小的学习率 (通常是新层lr的1/10或1/100)
    ],
    lr=0.001  # 基础学习率 (通常被上面覆盖)
)

# 或者更简洁地,收集所有需要优化的参数
trainable_params = filter(lambda p: p.requires_grad, pretrained_model.parameters())
optimizer = optim.Adam(trainable_params, lr=0.001)  # 但这样所有可训练参数用一个lr,不够精细

# 强烈推荐使用分层的、差异化的学习率!上面第一个optimizer定义是推荐做法。

微调的核心技巧:差异化学习率(Differential Learning Rates)

  • 新添加的全连接层(fc:随机初始化,需要较大学习率(如 0.001, 0.01)快速学习。
  • 刚刚解冻的、靠近顶部的卷积层(如 layer4:已经包含有用的知识,但我们希望它们做微小的调整以适应新任务。使用非常小的学习率(如 0.0001, 0.00001),通常是新层学习率的 1/10 到 1/100。
  • (如果解冻更多层,如 layer3:可以给一个比 layer4 更小的学习率(如 layer4 的 1/2 或 1/10)。越底层的参数,学习率应该设得越小!

踩坑记录(血泪教训!):

  1. 学习率太大/没分层:这是最常见的翻车点!直接对解冻层使用和分类层一样大的学习率(如0.001),几个epoch就能把预训练模型学好的宝贵特征“洗掉”,模型性能会暴跌!微调的本质是微小的调整(fine-tuning),不是大刀阔斧的改动(re-training)!用很小的学习率!
  2. 解冻层数太多/太早:在小样本场景下,解冻太多层(尤其是底层)等同于让模型可动参数剧增,极大增加了过拟合风险。从少量高层解冻开始是最稳妥的。
  3. 数据增强不足:微调时模型能力更强,如果数据不够多样,过拟合会来得更快更猛。微调必须搭配更强的数据增强!
  4. 训练轮次过多(过拟合):小样本训练务必密切监控验证集指标(Accuracy/Loss)。一旦验证集loss开始上升或accuracy停滞/下降,立刻停止训练(Early Stopping)!保存验证集效果最好的模型。千万别硬着头皮继续训! 使用 torch.save(model.state_dict(), 'best_model.pth') 保存最佳状态。

微调后的效果: 相比于纯特征提取,微调通常能带来显著提升(提升几个甚至十几个百分点都很常见)。它允许模型更深入地适应目标任务的特点。

模型选择:不只是ResNet!

ResNet是经典,但预训练模型的世界很宽广:

  • Vision Transformers (ViT, DeiT, Swin Transformer):Transformer架构在CV领域的应用,在许多任务上超越了传统CNN。Hugging Face transformers 库是获取这类预训练模型(如 google/vit-base-patch16-224-in21k)的好地方。它们迁移学习的潜力巨大!
  • EfficientNet (B0-B7):在精度和计算效率之间取得了极佳的平衡。torchvision.models.efficientnet_bX
  • MobileNetV2/V3:专为移动/嵌入式设备设计的轻量级模型。torchvision.models.mobilenet_v2/v3
  • NLP领域的BERT, GPT, RoBERTa等:迁移学习在NLP小样本任务(文本分类、情感分析、NER)上同样是标配!原理相通:使用预训练模型提取文本特征或微调。这些模型在 transformers 库中也唾手可得。

选哪个?基本原则:

  • 精度优先:ViT-Large, ResNet152, EfficientNet-B7。计算资源充足时考虑。
  • 速度/效率优先:MobileNetV3, EfficientNet-B0/B1。部署在手机、边缘设备。
  • 平衡点:ResNet50, EfficientNet-B4, ViT-Base。最常用的主力选择。

更多锦囊妙计(提升小样本迁移效果)

  1. 学习率预热(Learning Rate Warmup):训练初期使用非常小的学习率(如0.00001),逐渐线性增加到设定的初始学习率(如0.001),持续几个epoch。这有助于稳定训练初期的梯度更新,特别是微调时。
  2. 学习率衰减(Learning Rate Scheduler):随着训练的进行,逐步降低学习率。常用的有 StepLR(固定步长衰减)、ReduceLROnPlateau(验证指标停滞时衰减)、CosineAnnealingLR(余弦退火)。对于微调至关重要,让模型后期能更精细地收敛。
  3. 特征向量缓存:如果你在做纯特征提取(冻结卷积基),并且特征提取器本身很大(如ResNet152),计算特征向量会很耗时。一个技巧是:预先计算并保存整个训练集和验证集的特征向量(features = pretrained_conv_base(inputs),然后训练过程就变成了在这些固定特征向量上训练小型分类器(如SVM、小MLP、XGBoost),速度飞快!非常适合快速实验不同分类器。
  4. 集成学习(Ensemble):训练多个使用不同预训练模型(如ResNet50, EfficientNetB4, ViT-Base)或不同初始化或不同数据采样的模型,然后融合它们的预测结果(平均、投票)。这能进一步提升小样本下的模型鲁棒性和准确性,当然代价是更多的计算资源。
  5. 度量学习(Metric Learning)/ 小样本学习算法(Few-Shot Learning):如果样本量真的极小(如每类只有1-5个样本),可能需要更专门的算法,如Siamese Networks, Prototypical Networks, Matching Networks。它们的目标是学习一个“比较相似性”的嵌入空间(Embedding Space),而非直接分类。预训练模型同样可以作为这些算法的强大特征提取器起点。

总结与展望

迁移学习,特别是基于预训练模型的微调,绝对是解决小样本深度学习任务的必备神器。它让我们站在巨人的肩膀上,用有限的标注数据撬动强大的模型性能。核心路径就是两步:

  1. 特征提取:冻结预训练模型,仅训练新分类头。快速、省资源、基线模型首选。
  2. 微调:解冻部分高层网络层,配合差异化小学习率进行训练。性能提升显著的关键操作。

写到这里,不得不感慨一下—— 深度学习的门槛,确实被迁移学习大大拉低了。各位,掌握好这个工具,即使只有几百张标注图片,你也完全有能力训练出实用、甚至惊艳的模型! 别再让“数据少”成为你入门或实践的阻碍了。

最后敲个黑板:

  • 数据增强!数据增强!数据增强! 小样本的命根子。
  • 冻结卷积基! 特征提取的基础,防止小数据破坏。
  • 微调要用小学习率! (分层设定更优)否则前功尽弃。
  • 盯紧验证集,早停防过拟合!
  • 动手实验! 不同模型、解冻层数、学习率、增强策略,效果差异可能很大。实践出真知。

网站公告

今日签到

点亮在社区的每一天
去签到