简介:这是一份面向迁移学习研究者和入门者的 MATLAB 实现代码包,围绕 TCA(传递成分分析)等经典域适配方法展开,同时整理了 JDA、GFK、TKL、DAN、JAN 等流行方法的参考实现与论文清单,便于系统梳理迁移学习脉络。压缩包共 23 个文件,以 8 个 .m 源码文件为主体,另有 3 个 Python 脚本、4 个 Markdown 文档、4 张示意图,以及少量配置与数据文件,整体仅 921KB,轻量而完整。源码涵盖 MyTCA、MyJDA、MyGFK、MyTJM、MyJGSA、MyCORAL 等多种代表性实现,便于横向对比不同方法的建模思路,并配有数据说明与文档,可帮助理解各类域适配方法的核心思路、数学推导与代码细节;文档中还整理了知识蒸馏、深度网络特征可转移性等经典论文,方便延伸阅读。已有 668 人学习下载,适合作为课程作业、论文复现或项目起步的参考资料,无论初学者还是进阶者,都能从中找到可借鉴的代码和思路。
1. 别把迁移学习当成黑匣子:这个示例到底解决了什么问题
一个示例工程敢在标题里放“惊人”两个字,通常是因为它解决了一个非常具体的痛点:你手上只有几千张打标图片,没有大集群,就用一台带普通独显的工作站,几十分钟内就能把图像分类准确率从随机的 20% 拉到 85% 以上。这个痛点对应的是深度学习里的冷启动问题,迁移学习(transfer learning)就是针对它设计的。标题里的 AmazingTransferLearning 是一个跑在 MATLAB 环境里的迁移学习示例工程,核心思路是加载预训练好的卷积网络,保留它已经学会的底层特征,只替换最后分类层并做微调。这篇文章会带你把这条链路完整复现一遍:选网络、准备数据、替换分类头、设置训练选项、评估,再讲清楚调参边界和常见坑。适合两类人:有标注数据但算力有限的研究者,以及需要快速验证图像分类方案可行性的工程人员。
2. 迁移学习为什么能省训练时间:预训练网络、特征复用与选型依据
迁移学习不是玄学,它的依据是卷积网络特征的层次性。浅层卷积核学到的是边缘、纹理、颜色渐变这类基础模式,这些模式在医学影像、工业缺陷、卫星图、自然图像之间是通用的;越往深层,卷积核越依赖具体数据集的语义结构。所以把在 ImageNet 上训好的网络拿过来用,前中部特征不用重新学,只需要在尾部接一个新的分类头去适配你的任务类别。于是同样用几千张图片训练,从头初始化要训几百轮,预训练模型往往十几轮就收敛,训练时间通常能省一个数量级。
这也解释了为什么示例工程叫“惊人”却不神奇:它没有改变训练的数学原理,只是换了一个好得多的起点。你真正要做的,是理解这个预训练起点在哪里结束、新任务从哪里开始,然后把两者接得够稳。下面先解决选型问题,再给一个最小可跑的流程。
2.1 预训练网络选哪种:ResNet-50、GoogLeNet 与轻量网络的取舍
在 MATLAB 深度学习工具箱里可以直接加载使用的预训练网络有好几个,先记住这四个就够用了。
| 网络 | 层数 | 参数量近似 | 输入尺寸 | 典型适用场景 |
|---|---|---|---|---|
| ResNet-50 | 50 | 25.6M | 224×224 | 数据量中等、显存充足,精度与速度均衡 |
| GoogLeNet | 22 | 7M | 224×224 | 数据量不大或机器性能一般 |
| MobileNet-v2 | 53 | 3.4M | 224×224 | 移动端部署、CPU 训练 |
| SqueezeNet | 18 | 1.2M | 227×227 | 显存很紧时用来跑通流程 |
我一般按这样的逻辑选择:数据量在 5000 张以下,直接上 ResNet-50 或 GoogLeNet,前者精度潜力更大,后者更容易收敛;如果只有一张老显卡或根本没有独显,就把 SqueezeNet 当作首跑目标,流程通了、确认数据没问题,再换回 ResNet-50。这里有个容易写错的细节:各网络输入尺寸不一样,SqueezeNet 是 227×227,不是 224,后面构造 augmentedImageDatastore 时尺寸填错会直接报输入不匹配。加载方式统一是net = resnet50这种,第一次运行会联网下载预训练权重,耗时长短取决于你的网络环境,这是正常现象。
% 加载预训练好的 ResNet-50,第一次运行会下载权重 net = resnet50; % 转成 layerGraph 才能灵活替换层 lgraph = layerGraph(net); % 打开可视化窗口,检查每层输出尺寸与层名 analyzeNetwork(lgraph);analyzeNetwork弹出的窗口会列出每一层的名称、输出尺寸、参数量,这个窗口对定位“分类层在哪”非常关键。ResNet-50 最后三层通常是全局池化、全连接层 fc1000、以及一个分类输出层,后续替换操作必须使用准确的层名字符串,所以先运行它比直接翻文档更可靠。
2.2 最小流程:加载网络、确认输入尺寸与分类层位置
在替换分类层之前,先确认两件事:输入尺寸是多少、最后一层叫什么。以 ResNet-50 为例,输入尺寸是 224×224,分类层叫ClassificationLayer_fc1000,它前面紧挨着fc1000全连接层。换到 GoogLeNet,这两个名字就变成了loss3-classifier和output,完全对不上,所以记网络的层名比记网络结构更实用。
% 查看最后三层的信息,层名必须从这里复制 disp(lgraph.Layers(end-2:end)); % 查看第一层输入尺寸 inputSize = net.Layers(1).InputSize; disp(inputSize);disp输出的结构里,Name字段就是要用的层名。复制粘贴而不是手敲,可以避免很多拼写错误。确认输入尺寸后,后续所有图片都要统一缩放到这个尺寸。请不要尝试去改网络第一层的输入大小,预训练权重的卷积统计是基于原始输入尺寸学习的,硬改第一层会让底层特征的感受野失配,精度会明显下降。
3. 完整复现 AmazingTransferLearning:从数据目录到训练脚本
跑通整个流程只需要四个环节:数据按目录组织、按比例划分、替换分类层、训练与评估。这一章给的是能直接抄的脚本,每一段后面会解释为什么这么写、参数怎么改。
3.1 数据集的目录组织与按比例划分
做迁移学习项目,我第一步不是写训练脚本,而是先把数据目录理顺。推荐的结构是平铺的类别文件夹:每个类别一个子目录,子目录名就是类别标签。这样的好处是imageDatastore可以直接用foldernames自动生成标签,不需要写 CSV 映射文件,也不会在代码里硬编码路径与标签的对应关系。
% data/all 下是类别子文件夹,比如 data/all/cat、data/all/dog imdsAll = imageDatastore('data/all', ... 'IncludeSubfolders', true, ... 'LabelSource', 'foldernames'); % 按每个类别内部取 80% 作为训练集,20% 作为验证集 [imdsTrain, imdsVal] = splitEachLabel(imdsAll, 0.8, 'randomized');splitEachLabel的第一个参数是按类别内部比例划分,所以哪怕某个类别只有 50 张图片,也会保证 40 张进训练集、10 张进验证集,不会出现某个类别整体跑进一个集合的情况。参数randomized必须保留,否则datastore按内部顺序取连续段,同一个场景连续拍摄的高相似图片会被分到同一侧,验证集就失去了参考价值。划分之后可以再跑一下countEachLabel(imdsTrain)看看各类数量是否均衡,如果不均衡,训练阶段要考虑加权重或数据增强,这是后话。
3.2 替换分类层:先查层名,再重新接线
替换分类层是整个流程里最容易翻车的一步。翻车原因基本只有一个:层名写错。不同预训练网络的分类层叫法完全不同,ResNet-50 最后是fc1000和ClassificationLayer_fc1000,GoogLeNet 是loss3-classifier和output,硬套模板必报错,报错内容通常是“找不到名为 xx 的层”。
% 用训练集的标签类别数确定新分类头尺寸 numClasses = numel(categories(imdsTrain.Labels)); % 构造新的全连接层与分类层 newFC = fullyConnectedLayer(numClasses, 'Name', 'new_fc'); newCL = classificationLayer('Name', 'new_classoutput'); % 替换 ResNet-50 的旧全连接层和旧分类层,层名必须与上面 disp 的一致 lgraph = replaceLayer(lgraph, 'fc1000', newFC); lgraph = replaceLayer(lgraph, 'ClassificationLayer_fc1000', newCL);replaceLayer会检查输入输出连接是否匹配,层名写错会当场报错,不会静默跳过。fullyConnectedLayer里也可以顺手设置WeightLearnRateFactor,这个参数留到第 5 章讲。还有一种情况:如果你是从深网设计器导出的网络,结构里可能带有辅助分支或额外输出,替换前需要先把辅助分支裁掉,否则训练选项里还要配置OutputNetwork之类的东西,流程会复杂很多。
3.3 一个能跑通的训练脚本与各参数含义
训练集和验证集都必须经过augmentedImageDatastore统一缩放,这一层的名字里带 “augmented”,但它可以只做缩放不做任何增强。验证集在这里保持干净,数据增强等跑通基线之后再加。
% 训练集与验证集统一缩放到 224x224 augimdsTrain = augmentedImageDatastore([224 224], imdsTrain, ... 'OutputSizeMode', 'resize'); augimdsVal = augmentedImageDatastore([224 224], imdsVal, ... 'OutputSizeMode', 'resize'); % 微调场景下学习率是命门,见下方参数说明 options = trainingOptions('sgdm', ... 'InitialLearnRate', 1e-4, ... 'MiniBatchSize', 16, ... 'MaxEpochs', 10, ... 'Shuffle', 'every-epoch', ... 'ValidationData', augimdsVal, ... 'ValidationFrequency', 20, ... 'Plots', 'training-progress', ... 'Verbose', true); netTransfer = trainNetwork(augimdsTrain, lgraph, options);InitialLearnRate设为 1e-4 是微调的安全起点,不是 1e-2,更不是 0.1。从头训练用大学习率没问题,但迁移学习的预训练权重已经接近一个良好的局部最优,学习率太大会发生灾难性遗忘,损失可能在第一个 epoch 短暂下降后立刻发散。MiniBatchSize设为 16 是折中,显存不足就降到 8,迁移学习里小 batch 常常泛化更好,不用太担心收敛变慢。ValidationFrequency是每多少次迭代做一次验证,20 意味着一个 epoch 内能看到一两次验证曲线,太频繁会拖慢训练。Shuffle保持every-epoch,防止模型记住 batch 顺序。
3.4 训练后的评估:准确率、混淆矩阵、单张预测
训练完先看验证集整体表现,再看哪些类别互相混淆,最后抽单张图片确认置信度。这三步能排除绝大多数的“假成功”——比如准确率看着高,但某一个类别完全没学会。
% 对整个验证集做预测 YPred = classify(netTransfer, augimdsVal); YReal = imdsVal.Labels; accuracy = mean(YPred == YReal); fprintf('验证准确率: %.2f%%\n', accuracy * 100); % 混淆矩阵,查看类别间互相混淆的情况 figure; confusionchart(confusionmat(YReal, YPred)); % 单张图片预测 img = imread('data/val/cat/021.jpg'); img = imresize(img, [224 224]); lab = predict(netTransfer, img); [~, idx] = max(lab); disp(categories(imdsVal.Labels(idx)));classify可以直接接受 datastore 或四维数组,但如果只给一个H×W×3的单张图片,必须先imresize,不然会报维度错误。predict输出的是每个类别的概率向量,向量顺序与categories(imdsTrain.Labels)的顺序一致,所以取max索引后要用categories再映射回类别名,直接输出数字索引是没有意义的。
4. MATLAB 迁移学习避坑指南:五类高频问题的定位与解决
跑通基线容易,跑出可用模型难。这一章不是泛泛的注意事项,而是我复现这类示例时实际遇到的五个问题,按“现象 → 原因 → 解决”拆开写。这些问题原理都不深,但 MATLAB 的报错信息和实际原因之间往往隔了好几层,第一次遇到会卡很久。
4.1 验证准确率一直不涨:先怀疑学习率,再怀疑验证集增强
现象:训练 loss 正常下降,但验证准确率始终在随机水平附近,比如五类任务一直卡在 20% 上下。
原因:最大嫌疑是InitialLearnRate太大,预训练权重在一次大步更新里被推离原来的良好区域,损失发散;其次是验证集用了带增强的 datastore,验证分布被污染,曲线一路虚高或者完全不涨;还有一个小概率原因,是replaceLayer之后训练用的却还是旧网络,模型输出维度对应原来的 1000 类。
解决:先把学习率降到 1e-5 重跑一遍,这是排错成本最低的一步。如果还不涨,打开验证集datastore构造语句,检查有没有RandXTranslation这类增强参数,有就去掉。最后训练完打印netTransfer.Layers(end).Name,如果还是旧分类层的名字,检查一下是不是lgraph被重新赋回了原网络。这三个检查做完,基本能覆盖全部常见原因。
4.2 输入尺寸不匹配:改数据,不要改网络
现象:trainNetwork报Input image size错误,或者提示期望输入是[224 224 3]。
原因:imageDatastore读出的图片是原始尺寸,而预训练网络第一层的输入尺寸是写死的。ResNet-50 要求 224×224,任何其他尺寸都会报错。
解决:用augmentedImageDatastore统一缩放,而不是去改网络第一层。不少资料会教人重写网络输入层,这在迁移学习里是下策:输入尺寸变了,预训练卷积核的感受野覆盖范围与统计分布就错位了,特征提取能力会明显退化。另外,如果数据是灰度图,读进来是H×W单通道,还需要在readFcn里通过repmat复制成三通道,否则会在输入层维度检查时报错。
4.3 显存溢出与 CPU 训练慢:先降 batch,再去查 datastore
现象:GPU 训练中途报Out of memory;CPU 训练一个 epoch 跑几十分钟。
原因:显存溢出主要是MiniBatchSize太大;CPU 慢则有两层原因,一是网络本身太大,二是原始图片分辨率过高,augmentedImageDatastore在 CPU 侧做 resize 的开销全部变成了瓶颈,GPU 只能空等。
解决:先把MiniBatchSize降到 8,再看有没有救。如果原始图是 4000×3000 级别,不要直接在 datastore 里缩放,先写个脚本把所有图片imresize到 512×512 存盘,再建 datastore,这一步能把 CPU 预处理时间降一个量级。最后还是不行,就换 SqueezeNet 跑通验证,确认数据与流程没问题再换回大网络。关掉Plots的training-progress也能省一点 CPU,训练多轮时绘图开销并不小。
4.4 训练 loss 变 NaN:三个排查方向
现象:第一个 epoch 正常,第二个 epoch 开始 loss 变成NaN,准确率直接归零。
原因:最常见是学习率过大导致权重迭代越界;其次输入图片里本身有NaN或Inf像素,读进来一算就崩;还有可能是分类层输出维度与标签数不一致,numClasses写错,比如取标签时没有用categories而导致维度对不上。
解决:三个方向并行排查。先检查数据:
img = readimage(imdsTrain, 1); any(isnan(img(:))) % 再检查标签是否与你预期的类别数一致 disp(unique(imdsTrain.Labels));如果数据和标签都没问题,把优化器换成'adam',学习率降到 1e-5 再跑。adam的默认动量对学习率不敏感,比sgdm更适合用来救急,等模型能稳定收敛了再换回sgdm精调。
4.5 冻结层之后训练时间没缩短:正确理解 LearnRateFactor 的边界
现象:把骨干层的WeightLearnRateFactor设为 0 后,训练时间几乎没有变化。
原因:LearnRateFactor=0只是让优化器跳过该层权重的更新,自动微分在前向和反向传播阶段仍然会把梯度算完。很多人以为冻结等于省了前向计算,实际省的主要是优化器状态更新那一步,计算量大头还在。
解决:想缩短训练时间,优先减MaxEpochs、增大ValidationFrequency、减小输入尺寸或换轻量网络。冻结的真正价值不是提速,而是防止随机初始化的新分类头在反向传播中把预训练权重冲乱。如果你的目标只是省时间,不要去纠结冻结,直接换小网络。
5. 把“能跑”调成“能用”:分层学习率、两阶段微调与数据增强参数
基线准确率只能证明流程通了。真实项目里,准确率往往会卡在 85% 附近再往上不去了。这时候不需要堆 epoch,而是要从学习率结构和数据分布上下功夫。这一章的三个手段,是迁移学习里最常用的提精度组合拳。
5.1 分层学习率:骨架层小步走,分类头大步跑
把整个网络用同一个学习率微调,表面省事,实际会陷入尴尬:学习率小了新分类头学得慢,学习率大了底层预训练特征被扰动。分层学习率的思路很简单,靠近输入的特征通用性最强,让它们小步更新甚至不动;靠近输出的高层语义与新任务最相关,让它们大步学习。
% 拿到当前所有层对象 layers = lgraph.Layers; for i = 1:numel(layers) thisLayer = layers(i); % 只处理卷积层,全连接层保持默认学习率因子 1 if isa(thisLayer, 'nnet.cnn.layer.Convolution2DLayer') % 越靠近输入的层,这个因子可以设得越小,比如 0.01 thisLayer.WeightLearnRateFactor = 0.1; thisLayer.BiasLearnRateFactor = 0.1; lgraph = replaceLayer(lgraph, thisLayer.Name, thisLayer); end end这里的关键是将修改后的层对象通过replaceLayer写回lgraph,直接改lgraph.Layers的返回值不会生效,因为layerGraph内部是独立存储层对象的。如果只想精细控制某一层,比如把res5之前设成 0.01、res5设成 0.1、新 FC 设成 1,那就把循环里的判断条件换成按Name前缀匹配,常见做法是先跑一版全 0.1 的,根据验证曲线再决定要不要细分。
5.2 两阶段微调:先训新分类头,再解冻最后一个残差块
两阶段微调是我在数据量特别少时固定使用的一套流程:第一阶段冻结骨干,只让随机初始化的新分类头收敛;第二阶段解冻靠近输出的最后几个残差块,用很小的学习率精调。顺序不能反过来,因为新分类头随机初始化产生的梯度幅度很大,如果一开始就带着骨干一起更新,预训练权重会被冲乱,后面再怎么精调都很难回来。
% 阶段一:冻结骨干,只训练新分类头 layers = lgraph.Layers; for i = 1:numel(layers) thisLayer = layers(i); if ~strcmp(thisLayer.Name, 'new_fc') && ~strcmp(thisLayer.Name, 'new_classoutput') % 有学习率因子属性的层全部冻结 if isprop(thisLayer, 'WeightLearnRateFactor') thisLayer.WeightLearnRateFactor = 0; thisLayer.BiasLearnRateFactor = 0; lgraph = replaceLayer(lgraph, thisLayer.Name, thisLayer); end end end options1 = trainingOptions('sgdm', ... 'InitialLearnRate', 3e-4, ... % 新分类头是随机初始化的,可以稍大 'MiniBatchSize', 16, ... 'MaxEpochs', 15, ... 'ValidationData', augimdsVal, ... 'ValidationFrequency', 30); net1 = trainNetwork(augimdsTrain, lgraph, options1);第二阶段只在最后一段卷积上精调:
% 阶段二:解冻 res5 之后的层,用极小学习率精调 layers = net1.Layers; for i = 1:numel(layers) thisLayer = layers(i); if isprop(thisLayer, 'WeightLearnRateFactor') % 以 ResNet-50 为例,ps输出前两个字符判断是否属于末尾段 if startsWith(thisLayer.Name, 'res5') || startsWith(thisLayer.Name, 'bn5') thisLayer.WeightLearnRateFactor = 1; thisLayer.BiasLearnRateFactor = 1; lgraph = replaceLayer(lgraph, thisLayer.Name, thisLayer); end end end options2 = trainingOptions('sgdm', ... 'InitialLearnRate', 1e-5, ... 'MiniBatchSize', 16, ... 'MaxEpochs', 10, ... 'ValidationData', augimdsVal); netTransfer = trainNetwork(augimdsTrain, lgraph, options2);两个阶段的参数差别可以对照看:
| 阶段 | 冻结范围 | InitialLearnRate | MaxEpochs | 目的 |
|---|---|---|---|---|
| 阶段一 | 除 new_fc 外全部冻结 | 3e-4 | 15 | 让随机分类头收敛 |
| 阶段二 | 仅解冻 res5 之后 | 1e-5 | 10 | 精调高层语义特征 |
注意startsWith(thisLayer.Name, 'res5')是 ResNet-50 的命名规则,换网络必须重新查层名,GoogLeNet 没有res5,所以要对着disp(lgraph.Layers)找尾部卷积层的真实前缀。
5.3 数据增强参数:四个旋钮怎么定才不破坏语义
数据增强不是开得越猛越好,它有很强的任务相关性。我通常从最小的一档开始,跑一个 epoch 看验证集与训练集准确率差距,再决定要不要加码。
% 训练集启用轻度增强 augimdsTrain = augmentedImageDatastore([224 224], imdsTrain, ... 'RandXTranslation', [-10 10], ... 'RandYTranslation', [-10 10], ... 'RandRotation', [-15 15], ... 'RandXScale', [0.9 1.1], ... 'RandXReflection', true); % 验证集永远不增强 augimdsVal = augmentedImageDatastore([224 224], imdsVal, ... 'OutputSizeMode', 'resize');参数边界这里直接给结论:RandRotation15 度对日常物体分类足够,但如果你做的是车牌识别、字符识别,旋转超过 10 度会改变字符的语义,要关掉或收紧到 5 度以内。RandXReflection水平翻转不是所有任务都适用,区分左右手、识别朝向的任务开了翻转等于把标签改错。RandXScale0.9 到 1.1 是安全范围,缩放太猛会把小目标变得过小。验证集与训练集准确率差距超过 5 个百分点,说明增强不够或数据量太少;如果训练集被增强拖到涨不上去,那就是增强过头了。
提示:验证集必须使用不带任何增强的 datastore,否则验证曲线会“虚高”,调参时你会误以为模型已经很好,实际一到现场就露馅。
6. 用类激活热力图给迁移学习做一次体检
训练结束只是完成了一半。我习惯在每次迁移学习训练后做一次“体检”:用类激活热力图看看模型到底在看哪里。MATLAB 深度学习工具箱提供了gradCAM功能,它计算输入图像各个位置对最终分类决策的贡献强度,把贡献度叠加显示在原图上,能直观暴露模型是不是在偷懒。
% 挑一张验证集图片,用真实标签作为目标 img = readimage(imdsVal, 5); trueLabel = imdsVal.Labels(5); % 在最后一个卷积块的输出上计算热力图 map = gradCAM(netTransfer, img, trueLabel, 'Layer', 'res5c_relu'); % 原图与热力图半透明叠加 figure; imshow(img); hold on; imagesc(map, 'AlphaData', 0.5); colormap(jet); colorbar;Layer参数指定为最后一个卷积块的输出,效果比直接接在分类层上更稳定,因为分类层之后是全局平均池化,空间信息已经被压缩,热力图分辨率会很低。gradCAM返回的热力图尺寸与输入图片一致,可以直接叠加显示。期望的形态是热力图集中在这个类别的典型部位上,而不是贴在背景或某个固定角落。
热力图的分布还能反过来指导数据清洗。如果热力图一直聚焦在背景、边缘或画面的固定位置,通常说明数据集里存在系统性偏差,比如训练样本里的目标总出现在同一位置、背景高度相似。有次我做一个类似的缺陷分类任务,验证准确率到了 96%,投入实际场景却持续漏检,后来把每张图的热力图翻出来一看,模型关注的全都集中在样本右下角的固定反光区,根本原因是训练时标注框把缺陷全部裁到了画面正中心。之后我把裁剪范围扩大、加入随机平移,问题才真正解决。这件事之后我给自己立了一个规矩:准确率达标不算完,先看热力图再谈上线。希望帮到你。
本文还有配套的精品资源,点击获取