MindSpore迁移学习_预训练模型微调实战
迁移学习把在大规模数据集(如 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. 常见问题
-
load_param_into_net报形状不匹配。strict_load=False解决不了,必须按形状过滤参数。最常见的原因是分类头类别数不同。 -
冻结了但参数还在更新。 冻结要在构造优化器之前做,且优化器只接收
trainable_params()。 -
微调后精度不如冻结。 学习率太大破坏了预训练特征。backbone 的学习率降到 1e-5 试试,或只解冻最后两个阶段。
-
换了分类头后 loss 一直不降。 检查新头是否用了合适的初始化(
XavierUniform或HeUniform);也确认一下优化器里确实包含了新头的参数。 -
验证集精度远低于训练集。 小数据上正常过拟合。加大数据增强、加 weight decay、或者退回冻结模式。
-
用了错误的归一化参数。 必须和预训练时一致(ImageNet 的 mean/std),否则特征分布对不上。
-
batch size 太小导致 BN 不稳定。 小于 8 时冻结 BN 的统计量,或者改用 GroupNorm。
-
加载权重后精度极差。 确认权重确实加载进去了——打印
not_loaded列表看有没有漏掉关键层。 -
数据集类别顺序变了。
ImageFolderDataset按目录名字典序映射类别 id,训练集和验证集的目录名必须一致。 -
想在 CPU 上先验证流程。 可以,网络构建、权重加载、冻结、分层学习率这些都能跑;但混合精度和实际训练速度要看 Ascend。
9. 小结
迁移学习的关键决策只有两个:冻结还是解冻,以及学习率给多大。
小数据集、与预训练数据接近 → 冻结 backbone 只训分类头,稳。数据够多 → 两段式,先冻结让头收敛,再解冻用 1e-4 量级的学习率整体微调。加载权重时记得分类头要按形状过滤,这是最容易卡住的一步——strict_load=False 帮不了你,得自己过滤。
配套的两件事别忽略:归一化参数必须和预训练一致;验证集不做随机增强。
鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。
更多推荐

所有评论(0)