迁移学习把在大规模数据集(如 ImageNet)上预训练好的模型权重搬过来,只针对目标任务重新训练部分或全部层。它解决的是三个现实问题:目标数据集样本少、从零训练成本高、小数据上从零训练容易过拟合。MindSpore 提供了完整的权重加载与参数冻结接口,配合昇腾 NPU 可以在很短时间内完成一次微调。本文基于图像分类任务,讲解特征提取与微调两种做法的选择依据、预训练权重的正确加载方式、分层学习率配置,并给出可直接套用的完整训练代码。

1. 迁移学习为什么有效

卷积网络的前几层学到的是边缘、纹理、颜色块这类通用底层特征,这些特征与具体任务无关——无论你最后要分类的是猫狗、花卉还是工业缺陷图,底层特征都能复用。越靠近输出的层才越与具体类别相关。

所以迁移学习的做法是:把预训练模型的"特征提取能力"整体搬过来,只重新学习与目标任务相关的那部分。

用不用迁移学习,判断依据主要是两条:

条件

建议

目标数据集小(几千张以内)

必须用迁移学习,从零训练几乎必然过拟合

目标数据与预训练数据分布接近(如自然图像)

直接微调,收益最大

目标数据与预训练数据分布差异大(如医学影像、遥感)

仍可用,但建议解冻更多层,或配合更大的学习率

目标数据集很大(十万级以上)

从零训练也是选项,但迁移学习收敛更快

2. 两种做法:特征提取还是微调

特征提取(冻结 backbone)。 把预训练模型当作固定的特征提取器,只训练新替换的分类头。

  • 优点:训练快、几乎不会破坏预训练特征、小数据上表现稳定

  • 缺点:上层特征无法针对新任务调整,上限较低

  • 适用:目标数据极少(几百到几千张)、与预训练数据相似

微调(解冻部分或全部层)。 在替换分类头的基础上,让 backbone 的一部分或全部层也参与训练。

  • 优点:特征能适配新任务,精度上限更高

  • 缺点:需要更多数据,学习率调大会破坏预训练特征

  • 适用:目标数据有几千张以上、对精度有要求

实践里的常规路径是两段式:先冻结 backbone 只训分类头(让新头先收敛到合理范围),再解冻 backbone 用更小的学习率整体微调。直接解冻全部层配大学习率,很容易把预训练特征冲掉,结果反而不如冻结。

3. 预训练权重的正确加载方式

这是最容易翻车的一步。MindSpore 的加载接口是 load_checkpoint + load_param_into_net:

from mindspore.train import load_checkpoint, load_param_into_net
​
param_dict = load_checkpoint('./resnet50_pretrain.ckpt') # 读权重文件
not_loaded = load_param_into_net(net, param_dict) # 灌进网络

问题在于分类头的形状对不上。预训练模型通常是 1000 类(ImageNet),你的任务可能是 10 类,两者 fc.weight 的形状不同。此时 load_param_into_net 会直接抛异常:

RuntimeError: For 'load_param_into_net', fc.weight in the argument 'net' should
have the same shape as fc.weight in the argument 'parameter_dict'.
But got its shape (10, 64) in the argument 'net' and shape (1000, 64) in the
argument 'parameter_dict'.

一个常见误解是加 strict_load=False 就能跳过。实测不能:

load_param_into_net(net, param_dict, strict_load=False) # 仍然抛同样的异常

strict_load 只控制"参数缺失"是否被容忍,不管形状不匹配。形状对不上时无论这个参数是什么都会报错。

正确做法是先按名字和形状过滤一遍,只保留能对上的:

from mindspore.train import load_checkpoint, load_param_into_net
​
def load_pretrained(net, ckpt_path, skip_keywords=('fc',)):
"""加载预训练权重,跳过名字命中 skip_keywords 或形状不匹配的参数"""
param_dict = load_checkpoint(ckpt_path)
net_params = {p.name: p for p in net.get_parameters()}
​
filtered = {}
skipped = []
for name, value in param_dict.items():
if name not in net_params:
skipped.append((name, '网络中没有该参数'))
continue
if net_params[name].shape != value.shape:
skipped.append((name, f'形状不匹配 {value.shape} vs {net_params[name].shape}'))
continue
if any(k in name for k in skip_keywords):
skipped.append((name, '命中跳过关键字'))
continue
filtered[name] = value
​
not_loaded = load_param_into_net(net, filtered)
print(f'成功加载 {len(filtered)} 个参数')
for name, reason in skipped:
print(f' 跳过 {name}: {reason}')
return not_loaded

实测输出(用一个简化网络跑的,参数数量随模型规模变化,这里看的是跳过逻辑):

成功加载 5 个参数
跳过 fc.weight: 形状不匹配 (1000, 64) vs (10, 64)
跳过 fc.bias: 形状不匹配 (1000,) vs (10,)

分类头被正确跳过,backbone 全部加载。这个函数可以直接复用,改 skip_keywords 就能适配不同的头( classifier、head 等)。

4. 应用一:冻结 backbone,只训练分类头

适合目标数据少的场景。核心是两件事:替换分类头、冻结其余层。

import mindspore as ms
from mindspore import nn
from mindspore.train import Model, LossMonitor
​
ms.set_device('Ascend')
ms.set_context(mode=ms.GRAPH_MODE)
​
# 0. 网络结构:本例取自 MindCV 视觉模型库(pip install mindcv)
# from mindcv.models import resnet50
# 1. 载入预训练模型,替换分类头(假设原模型是 1000 类)
num_classes = 10
net = resnet50(pretrained=False) # 从 mindcv 或 modelzoo 取结构
net.fc = nn.Dense(net.fc.in_channels, num_classes, weight_init='XavierUniform')
​
# 2. 加载 backbone 权重(分类头按上一节的方式跳过)
load_pretrained(net, './resnet50_pretrain.ckpt', skip_keywords=('fc',))
​
# 3. 冻结 backbone:只让分类头参与训练
for param in net.get_parameters():
if 'fc' not in param.name:
param.requires_grad = False
​
print('参与训练的参数:', [p.name for p in net.trainable_params()])
# 输出: ['fc.weight', 'fc.bias']
​
# 4. 优化器只接收可训练参数
optimizer = nn.Momentum(
net.trainable_params(),
learning_rate=1e-3, # 新头可以用较大的学习率
momentum=0.9,
weight_decay=1e-4,
)
​
loss_fn = nn.SoftmaxCrossEntropyWithLogits(sparse=True, reduction='mean')
model = Model(net, loss_fn=loss_fn, optimizer=optimizer, metrics={'acc'})
model.train(epoch=5, train_dataset=train_dataset, callbacks=[LossMonitor()])

两个细节:

  • 冻结必须在构造优化器之前完成。optimizer 接收的是参数列表,如果先建优化器再冻结,优化器里仍然持有那些参数,会照常更新。

  • 只把 trainable_params() 传给优化器。传 get_parameters() 会把冻结参数也塞进去,虽然 requires_grad=False 不产生梯度,但部分优化器仍会维护它们的状态,白白占显存。

5. 应用二:解冻微调 + 分层学习率

分类头收敛之后,解冻 backbone 做整体微调。此时要用分层学习率:backbone 用很小的学习率(避免破坏已有特征),新头用较大的学习率(它还需要学)。

# 1. 解冻全部层
for param in net.get_parameters():
param.requires_grad = True
​
# 2. 按层分组
backbone_params = [p for p in net.trainable_params() if 'fc' not in p.name]
head_params = [p for p in net.trainable_params() if 'fc' in p.name]
​
# 3. 分层学习率:backbone 小、head 大
optimizer = nn.Momentum(
[
{'params': backbone_params, 'lr': 1e-4, 'weight_decay': 1e-4},
{'params': head_params, 'lr': 1e-3, 'weight_decay': 1e-4},
],
learning_rate=1e-4, # 默认学习率,未指定 lr 的组用它
momentum=0.9,
)
​
loss_fn = nn.SoftmaxCrossEntropyWithLogits(sparse=True, reduction='mean')
train_net = ms.amp.build_train_network(net, optimizer, loss_fn, level='O2')

分层学习率的取值经验:

参数组

学习率

理由

backbone(预训练层)

1e-5 ~ 1e-4

太大破坏预训练特征

新分类头

1e-3 ~ 1e-2

从零初始化,需要更快收敛

如果要更精细,可以按网络深度分段(浅层更小、深层稍大),但通常分两组就够了。

关于 BatchNorm 层。 微调时一般让 BN 的统计量继续更新,不要冻结;但如果 batch size 很小(比如小于 8),BN 的统计量估计不准,此时应冻结 BN 的 moving_mean / moving_variance,避免被小批量带偏。

6. 数据准备与增强

MindSpore 用 ImageFolderDataset 按目录读取图像数据,目录结构是"每个类别一个子目录":

dataset/
├── train/
│ ├── class_0/ *.jpg
│ ├── class_1/ *.jpg
│ └── ...
└── val/
├── class_0/ *.jpg
└── ...

import mindspore.dataset as ds
import mindspore.dataset.vision as vision
import mindspore.dataset.transforms as transforms
from mindspore.dataset.vision import Inter
​
def build_dataset(data_dir, training=True, batch_size=32, image_size=224):
dataset = ds.ImageFolderDataset(
dataset_dir=data_dir,
shuffle=True,
num_parallel_workers=4,
)
​
# 解码 + 增强
if training:
trans = [
vision.Decode(),
vision.RandomResizedCrop(image_size, scale=(0.8, 1.0)),
vision.RandomHorizontalFlip(prob=0.5),
vision.RandomColorAdjust(brightness=0.2, contrast=0.2),
vision.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]),
vision.HWC2CHW(),
]
else:
trans = [
vision.Decode(),
vision.Resize(image_size + 32, interpolation=Inter.BILINEAR),
vision.CenterCrop(image_size),
vision.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]),
vision.HWC2CHW(),
]
​
dataset = dataset.map(operations=trans, input_columns='image',
num_parallel_workers=4)
dataset = dataset.map(operations=transforms.TypeCast(ms.int32),
input_columns='label')
dataset = dataset.batch(batch_size, drop_remainder=training)
return dataset
​
​
train_dataset = build_dataset('./dataset/train', training=True)
val_dataset = build_dataset('./dataset/val', training=False)

两个注意点:

  • 训练集和验证集的归一化参数必须一致,且要用预训练模型训练时的那组(ImageNet 的 mean/std),否则预训练特征的分布对不上,等于白迁移。

  • 验证集不做随机增强,只做 resize + center crop,否则评估结果不可复现。

7. 完整训练与评估

把前面的部分串起来,加学习率调度和模型保存:

import mindspore as ms
from mindspore import nn
from mindspore.train import Model, LossMonitor, CheckpointConfig, ModelCheckpoint
​
ms.set_device('Ascend')
ms.set_context(mode=ms.GRAPH_MODE)
​
# 1. 网络 + 预训练权重
num_classes = 10
net = resnet50(pretrained=False)
net.fc = nn.Dense(net.fc.in_channels, num_classes, weight_init='XavierUniform')
load_pretrained(net, './resnet50_pretrain.ckpt', skip_keywords=('fc',))
​
# 2. 两段式:先冻结训头
for p in net.get_parameters():
if 'fc' not in p.name:
p.requires_grad = False
​
optimizer = nn.Momentum(net.trainable_params(), learning_rate=1e-3, momentum=0.9)
model = Model(net,
loss_fn=nn.SoftmaxCrossEntropyWithLogits(sparse=True, reduction='mean'),
optimizer=optimizer,
metrics={'acc'})
​
print('--- 阶段一:只训练分类头 ---')
model.train(epoch=5, train_dataset=train_dataset, callbacks=[LossMonitor()])
acc = model.eval(val_dataset)
print(f'阶段一验证精度: {acc}')
​
# 3. 解冻微调
for p in net.get_parameters():
p.requires_grad = True
​
backbone_params = [p for p in net.trainable_params() if 'fc' not in p.name]
head_params = [p for p in net.trainable_params() if 'fc' in p.name]
optimizer = nn.Momentum(
[{'params': backbone_params, 'lr': 1e-4},
{'params': head_params, 'lr': 1e-3}],
learning_rate=1e-4, momentum=0.9)
​
model = Model(net,
loss_fn=nn.SoftmaxCrossEntropyWithLogits(sparse=True, reduction='mean'),
optimizer=optimizer,
metrics={'acc'},
amp_level='O2') # 混合精度,见混合精度篇
​
# 4. 保存验证精度最高的权重
ckpt_config = CheckpointConfig(saved_network=net, keep_checkpoint_max=3)
ckpt_cb = ModelCheckpoint(prefix='finetune', directory='./ckpt', config=ckpt_config)
​
print('--- 阶段二:整体微调 ---')
model.train(epoch=20, train_dataset=train_dataset,
callbacks=[LossMonitor(), ckpt_cb])
print(f'最终验证精度: {model.eval(val_dataset)}')

8. 常见问题

  1. load_param_into_net 报形状不匹配。 strict_load=False 解决不了,必须按形状过滤参数。最常见的原因是分类头类别数不同。

  2. 冻结了但参数还在更新。 冻结要在构造优化器之前做,且优化器只接收 trainable_params()。

  3. 微调后精度不如冻结。 学习率太大破坏了预训练特征。backbone 的学习率降到 1e-5 试试,或只解冻最后两个阶段。

  4. 换了分类头后 loss 一直不降。 检查新头是否用了合适的初始化(XavierUniform 或 HeUniform);也确认一下优化器里确实包含了新头的参数。

  5. 验证集精度远低于训练集。 小数据上正常过拟合。加大数据增强、加 weight decay、或者退回冻结模式。

  6. 用了错误的归一化参数。 必须和预训练时一致(ImageNet 的 mean/std),否则特征分布对不上。

  7. batch size 太小导致 BN 不稳定。 小于 8 时冻结 BN 的统计量,或者改用 GroupNorm。

  8. 加载权重后精度极差。 确认权重确实加载进去了——打印 not_loaded 列表看有没有漏掉关键层。

  9. 数据集类别顺序变了。 ImageFolderDataset 按目录名字典序映射类别 id,训练集和验证集的目录名必须一致。

  10. 想在 CPU 上先验证流程。 可以,网络构建、权重加载、冻结、分层学习率这些都能跑;但混合精度和实际训练速度要看 Ascend。

9. 小结

迁移学习的关键决策只有两个:冻结还是解冻,以及学习率给多大。

小数据集、与预训练数据接近 → 冻结 backbone 只训分类头,稳。数据够多 → 两段式,先冻结让头收敛,再解冻用 1e-4 量级的学习率整体微调。加载权重时记得分类头要按形状过滤,这是最容易卡住的一步——strict_load=False 帮不了你,得自己过滤。

配套的两件事别忽略:归一化参数必须和预训练一致;验证集不做随机增强。

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐