深入浅出迁移学习:从理论到实践
1. 引言:为什么需要迁移学习?
在深度学习爆发的这十年里,我们见证了模型性能的飞速提升 ——ResNet 在图像分类上突破人类视觉极限,BERT 在 NLP 任务上刷新基准,GPT 系列更是开启了大语言模型时代。但这些亮眼成果的背后,隐藏着两个核心前提:大规模标注数据和充足的计算资源。
现实场景中,这两个前提往往难以满足:
- 医学影像任务中,一张肺结节标注图需要放射科医生花费数小时审核,数据集规模通常只有数千张;
- 工业缺陷检测任务中,某类罕见缺陷的样本可能只有几十甚至十几张,无法支撑深度学习模型的训练;
- 小语种 NLP 任务(如维吾尔语情感分析),标注数据稀缺,从零训练模型性能极差;
- 边缘设备部署场景中,计算资源有限,无法承担从零训练一个百万参数模型的成本。
传统深度学习的本质是 “孤立学习”—— 每个任务都需要独立的数据集和训练过程,模型无法复用已学知识。而人类则擅长 “举一反三”:学会识别猫之后,再学识别狗会更容易;掌握英语语法后,学习法语会更快。迁移学习(Transfer Learning, TL) 正是模仿人类这种学习方式的技术,它将从 “源域任务” 中学到的知识,迁移到 “目标域任务” 中,从而解决目标域数据稀缺、计算资源不足的问题。
本文将从理论到实践,系统讲解迁移学习的核心概念、分类体系、常用方法,并通过两个完整的 PyTorch 代码实例(计算机视觉 + 自然语言处理)帮助读者落地,最后探讨迁移学习的挑战与未来方向。无论你是深度学习初学者,还是需要解决实际问题的算法工程师,都能从本文中获得启发。
2. 迁移学习核心概念解析
在深入迁移学习的方法前,我们必须先理清几个核心概念 —— 这些概念是理解所有迁移学习技术的基础,也是避免混淆的关键。
2.1 什么是迁移学习?
迁移学习的官方定义可概括为: 利用源域(Source Domain)和源任务(Source Task)的知识,来提升目标域(Target Domain)和目标任务(Target Task)的学习性能。
简单来说,就是 “借鸡生蛋”:用已有的、数据充足的任务(如 ImageNet 图像分类)的训练成果,帮助数据稀缺的新任务(如宠物狗品种分类)提升效果。
需要注意的是,迁移学习的核心前提是源域与目标域 / 任务存在 “相关性”。如果源任务是 “识别汽车”,目标任务是 “识别诗歌情感”,两者毫无关联,迁移不仅无效,还可能产生负面影响(即 “负迁移”)。
2.2 迁移学习 vs 传统机器学习:关键差异
为了更清晰地理解迁移学习,我们对比传统机器学习与迁移学习的核心差异:
对比维度 | 传统机器学习 | 迁移学习 |
---|---|---|
数据假设 | 训练数据(源域)与测试数据(目标域)同分布 | 源域与目标域可不同分布 |
任务独立性 | 每个任务独立训练,无知识复用 | 复用源任务知识,辅助目标任务训练 |
数据依赖 | 依赖大规模标注数据 | 可在目标域数据稀缺时工作 |
泛化能力 | 仅对同分布数据泛化好 | 对不同分布数据的泛化能力更强 |
典型场景 | ImageNet 分类、MNIST 手写体识别 | 医学影像检测、小语种文本分类 |
2.3 核心术语定义:领域(Domain)与任务(Task)
迁移学习中,“领域” 和 “任务” 是两个最基础的概念,所有迁移场景都围绕这两个概念的关系展开。
2.3.1 领域(Domain):数据的 “来源地”
领域定义了数据的分布特征,通常表示为 \(D = \{X, P(X)\}\),其中:
- X:特征空间(Feature Space),即数据的表示维度。例如,图像任务中X是像素矩阵(如\(224 \times 224 \times 3\)),文本任务中X是词向量(如768维 BERT 嵌入);
- \(P(X)\):边缘概率分布(Marginal Probability Distribution),即特征在空间中的分布规律。例如,“白天的猫图像” 和 “夜晚的猫图像” 属于不同领域 —— 前者像素亮度高,后者亮度低,即\(P(X)\)不同。
当两个领域的X或\(P(X)\)不同时,我们称它们为 “不同领域”。例如:
- 源域:ImageNet 中的自然图像(X为\(224 \times 224 \times 3\),\(P(X)\)为自然场景分布);
- 目标域:医院的肺结节 CT 影像(X为\(512 \times 512 \times 1\),\(P(X)\)为医学影像分布)。
2.3.2 任务(Task):模型的 “目标”
任务定义了模型需要解决的问题,通常表示为 \(T = \{Y, f(\cdot)\}\),其中:
- Y:标签空间(Label Space),即模型输出的类别集合。例如,二分类任务中\(Y = \{0, 1\}\),1000 类分类任务中\(Y = \{0, 1, ..., 999\}\);
- \(f(\cdot)\):目标预测函数(Target Prediction Function),即模型需要学习的映射关系(\(f: X \rightarrow Y\))。例如,情感分析任务中\(f(\cdot)\)是 “文本→积极 / 消极” 的映射,目标检测任务中\(f(\cdot)\)是 “图像→边界框 + 类别” 的映射。
当两个任务的Y或\(f(\cdot)\)不同时,我们称它们为 “不同任务”。例如:
- 源任务:ImageNet 1000 类分类(Y为 1000 个自然物体类别,\(f(\cdot)\)是 “图像→物体类别”);
- 目标任务:肺结节检测(Y为 “结节 / 非结节”,\(f(\cdot)\)是 “CT 影像→结节位置 + 类别”)。
2.4 数据分布差异:迁移学习的 “拦路虎”
迁移学习的核心挑战是源域与目标域的分布差异—— 如果分布完全相同,直接用传统机器学习即可,无需迁移。根据分布差异的类型,可分为三类:
2.4.1 协变量偏移(Covariate Shift)
定义:特征空间X相同,但边缘概率分布\(P(X)\)不同;条件概率分布\(P(Y|X)\)相同(即 “输入变了,但输入到输出的映射不变”)。 例子:
- 源域:白天拍摄的猫图像(\(P(X)\)中亮度高的样本占比高);
- 目标域:夜晚拍摄的猫图像(\(P(X)\)中亮度低的样本占比高);
- 任务:猫的二分类(\(P(Y|X)\)不变 —— 无论白天黑夜,猫的特征到 “猫” 标签的映射相同)。
这是最常见的分布差异,微调(Fine-tuning)即可有效解决。
2.4.2 标签偏移(Label Shift)
定义:特征空间X相同,条件概率分布\(P(X|Y)\)相同,但标签边缘分布\(P(Y)\)不同(即 “输入到标签的映射不变,但标签的比例变了”)。 例子:
- 源域:垃圾邮件分类训练集(\(P(Y)\)中垃圾邮件占 30%,正常邮件占 70%);
- 目标域:垃圾邮件分类测试集(\(P(Y)\)中垃圾邮件占 60%,正常邮件占 40%);
- 任务:垃圾邮件二分类(\(P(X|Y)\)不变 —— 垃圾邮件的文本特征(如 “免费”“中奖”)与正常邮件的特征映射不变)。
标签偏移常见于数据收集偏差场景,可通过调整样本权重(如对少数类样本加权)解决。
2.4.3 概念偏移(Concept Shift)
定义:特征空间X相同,但条件概率分布\(P(Y|X)\)不同(即 “输入到标签的映射变了”)。 例子:
- 源域:2010 年的 “优质用户” 分类(\(P(Y|X)\)中 “消费额> 1000 元” 为优质用户);
- 目标域:2024 年的 “优质用户” 分类(\(P(Y|X)\)中 “月活跃度> 20 次” 为优质用户);
- 任务:优质用户二分类(\(P(Y|X)\)变了 —— 输入特征 “消费额” 到标签 “优质用户” 的映射改变)。
概念偏移是最棘手的差异,通常需要重新标注数据或动态调整模型。
3. 迁移学习的分类体系
迁移学习的应用场景多样,为了更好地选择方法,学术界通常从 “任务关系”“迁移内容”“分布差异” 三个维度对其分类。
3.1 按学习目标与任务关系分类
该分类基于 “源任务与目标任务是否相同”“目标域是否有标签”,是最常用的分类方式。
3.1.1 归纳式迁移学习(Inductive Transfer Learning)
核心特征:源任务与目标任务不同(\(T_S \neq T_T\)),目标域有标签(\(Y_T \neq \emptyset\))。 本质:通过源任务学习 “通用规律”,辅助目标任务的归纳学习。 例子:
- 源任务:ImageNet 1000 类分类(学习通用图像特征);
- 目标任务:宠物狗 100 品种分类(利用通用图像特征,提升狗品种分类精度);
- 逻辑:识别狗品种需要的 “边缘、纹理、形状” 等特征,在 ImageNet 分类中已被充分学习,无需从零训练。
适用场景:目标任务有少量标注数据,且与源任务存在 “特征复用性”(如所有图像任务都需要边缘检测特征)。
3.1.2 演绎式迁移学习(Transductive Transfer Learning)
核心特征:源任务与目标任务相同(\(T_S = T_T\)),源域有标签(\(Y_S \neq \emptyset\)),目标域无标签(\(Y_T = \emptyset\))。 本质:利用源域的标签信息,解决目标域的无监督学习问题(也称为 “半监督迁移学习”)。 例子:
- 源域:有标签的英语情感分析数据(\(Y_S = \{积极, 消极\}\));
- 目标域:无标签的中文情感分析数据(\(Y_T = \emptyset\));
- 任务:情感二分类(\(T_S = T_T\));
- 逻辑:英语和中文的情感表达有共性(如 “开心” 对应 “happy”),利用英语数据学习情感特征,辅助中文无标签数据的分类。
适用场景:目标域无标注数据,但与源域任务完全一致(如跨语言、跨场景的相同任务)。
3.1.3 无监督迁移学习(Unsupervised Transfer Learning)
核心特征:源任务与目标任务不同(\(T_S \neq T_T\)),目标域无标签(\(Y_T = \emptyset\))。 本质:从源域无监督学习 “结构特征”,迁移到目标域的无监督任务中。 例子:
- 源域:无标签的自然图像(学习图像的边缘、纹理等结构特征);
- 目标域:无标签的医学 CT 影像(利用自然图像的结构特征,辅助 CT 影像的聚类或分割);
- 任务:源任务是自然图像聚类,目标任务是 CT 影像聚类(\(T_S \neq T_T\))。
适用场景:目标域完全无标签,且与源域共享 “低层次结构特征”(如图像的边缘、文本的语法结构)。
3.2 按迁移内容分类
该分类基于 “从源域迁移什么类型的知识”,直接对应具体的技术实现。
3.2.1 参数迁移(Parameter Transfer)
核心思想:源域训练的模型参数(或部分参数)可作为目标域模型的初始化参数,避免从零训练。 本质:迁移模型的 “参数级知识”—— 假设源域模型的部分参数(如卷积层)对目标域任务同样有效。 典型方法:微调(Fine-tuning)、模型蒸馏(Model Distillation)。 例子:
- 源域:用 ImageNet 训练 ResNet-50,得到卷积层参数(负责提取边缘、纹理);
- 目标域:将 ResNet-50 的卷积层参数冻结,仅训练全连接层(适配目标任务的类别),或微调所有参数(让卷积层适应目标域特征)。
适用场景:源域与目标域的模型结构相似(如都是图像分类模型),且低层次特征可复用。
3.2.2 特征迁移(Feature Transfer)
核心思想:将源域和目标域的特征映射到一个 “共享特征空间”,使两者在该空间中的分布差异最小化,再用共享特征训练目标任务模型。 本质:迁移 “特征级知识”—— 不直接迁移参数,而是迁移 “特征表示能力”。 典型方法:领域自适应网络(DANN)、对比学习(Contrastive Learning)。 例子:
- 源域:有标签的自然图像,目标域:无标签的医学影像;
- 训练一个特征提取器,将自然图像和医学影像映射到同一空间,使两者的分布尽可能接近;
- 用源域的标签训练分类器,再用该分类器对目标域的映射特征进行预测。
适用场景:源域与目标域的模型结构不同,但特征可通过映射对齐(如跨模态迁移:文本→图像)。
3.2.3 实例迁移(Instance Transfer)
核心思想:从源域中筛选出与目标域 “相似” 的样本,赋予高权重,用于目标域模型的训练。 本质:迁移 “样本级知识”—— 假设源域中部分样本与目标域样本分布接近,可作为目标域的 “补充数据”。 典型方法:加权样本训练(如基于 KNN 的权重计算)、样本选择算法。 例子:
- 源域:10 万张普通汽车图像,目标域:100 张新能源汽车图像;
- 计算源域样本与目标域样本的相似度(如余弦距离),筛选出 1000 张最相似的普通汽车图像;
- 用这 1000 张高权重样本 + 100 张目标域样本训练新能源汽车分类模型。
适用场景:源域样本量大,但仅部分样本与目标域相关(如小样本目标检测)。
3.2.4 关系知识迁移(Relational Knowledge Transfer)
核心思想:迁移源域中样本之间的 “关联关系”,而非单个样本或参数。 本质:迁移 “结构级知识”—— 假设源域和目标域的样本间存在相似的关联模式。 典型方法:图谱迁移(Knowledge Graph Transfer)、关系网络(Relational Networks)。 例子:
- 源域:知识图谱 “人 - 购买 - 商品”(学习 “用户 - 行为 - 物品” 的关联模式);
- 目标域:推荐系统 “用户 - 点击 - 视频”(迁移 “用户 - 行为 - 物品” 的关联模式,提升推荐精度)。
适用场景:源域与目标域的样本关联模式相似(如推荐系统、知识图谱)。
3.3 按领域与任务分布分类
该分类基于 “源域与目标域的分布差异程度”,聚焦于解决 “领域自适应” 问题。
3.3.1 领域自适应(Domain Adaptation, DA)
核心特征:仅存在一个源域和一个目标域,目标是缩小两者的分布差异。 分类:
- 监督 DA:目标域有少量标签;
- 半监督 DA:目标域有部分标签;
- 无监督 DA:目标域无标签(最常见)。
例子:将 “白天的交通场景图像”(源域)的模型,自适应到 “夜晚的交通场景图像”(目标域)。
3.3.2 领域泛化(Domain Generalization, DG)
核心特征:存在多个源域,目标是训练一个 “泛化性强” 的模型,使其能直接应用于未见过的目标域(无需目标域数据)。 本质:从多个源域中学习 “领域不变特征”,应对未知目标域的分布差异。 例子:用 “白天、阴天、雨天” 三个源域的交通图像训练模型,使其能直接应用于 “雾天”(未见过的目标域)的交通场景检测。
适用场景:目标域数据完全不可得(如边缘设备部署,无法获取目标场景数据)。
4. 常用迁移学习方法深度解析
了解分类后,我们聚焦于工业界最常用的 4 类方法,深入讲解其原理、实现细节与适用场景。
4.1 参数迁移:站在预训练模型的肩膀上
参数迁移是最直观、最常用的迁移学习方法,核心是 “复用预训练模型的参数”。其中,微调(Fine-tuning) 是参数迁移的代表,几乎所有计算机视觉和 NLP 任务都会用到。
4.1.1 微调(Fine-tuning):原理与策略
原理:
- 预训练阶段:在大规模源域数据集(如 ImageNet、Wikipedia)上训练一个基础模型(如 ResNet、BERT),学习通用知识;
- 适配阶段:将预训练模型的输出层替换为适配目标任务的层(如将 ResNet 的 1000 类输出改为 10 类);
- 微调阶段:用目标域数据集训练整个模型(或部分层),使模型参数适应目标任务。
为什么微调有效? 预训练模型在大规模数据上学习到了 “通用特征”:
- 计算机视觉中,底层卷积层学习边缘、纹理,中层学习部件(如眼睛、耳朵),高层学习整体特征(如猫、狗);
- NLP 中,BERT 的底层学习词法、语法,高层学习语义、上下文关联。
这些通用特征对相似任务(如从 ImageNet 分类到宠物分类)具有极强的复用性,微调只需少量数据即可调整参数,适配目标任务。
4.1.2 冻结层(Layer Freezing):为什么要冻结?如何冻结?
微调时,我们通常不会直接训练所有层,而是冻结部分底层,仅训练上层或输出层。原因如下:
- 底层学习的是通用特征(如边缘、纹理),对所有图像任务都有效,无需修改;
- 高层学习的是源域特定特征(如 ImageNet 中的 “飞机、船”),需要调整以适配目标任务(如 “猫、狗”);
- 冻结底层可减少参数数量,降低过拟合风险(尤其目标域数据少时)。
冻结策略:
- 全冻结底层:仅训练输出层。适用于目标域数据极少(如几百张),且源域与目标域相似性高(如从 “动物分类” 到 “猫品种分类”);
- 部分冻结:冻结前 k 层,训练剩余层。例如,ResNet-50 有 49 个卷积层 + 1 个全连接层,可冻结前 30 层,训练后 20 层;
- 渐进式解冻:先冻结所有底层,训练输出层;再解冻部分中层,联合训练;最后解冻所有层,用小学习率微调。适用于目标域数据中等(如几千张),且相似性一般的场景。
经验法则:目标域数据越少、与源域越相似,冻结的层数越多;反之,冻结层数越少。
4.1.3 部分参数迁移:聚焦任务相关层
当源域与目标域的模型结构不同时(如源域是分类模型,目标域是检测模型),无法直接微调所有参数,此时可采用部分参数迁移:
- 提取预训练模型的 “任务无关层”(如 ResNet 的卷积层),作为目标模型的特征提取器;
- 目标模型的 “任务相关层”(如检测模型的边界框回归层)从零训练。
例如,目标检测模型 Faster R-CNN 的 backbone 通常采用预训练的 ResNet—— 将 ResNet 的卷积层作为特征提取器(迁移参数),RPN 层和 RoI Head 层(任务相关层)从零训练。
4.2 特征迁移:学习领域无关的通用特征
当源域与目标域的分布差异较大(如自然图像 vs 医学影像),直接微调效果不佳时,需要通过特征迁移将两者的特征对齐到同一空间,缩小分布差异。其中,领域自适应网络(Domain-Adversarial Neural Network, DANN) 是最经典的方法。
4.2.1 DANN:对抗训练的魔力
DANN 由纽约大学 Yann LeCun 团队提出,核心思想源于 GAN(生成对抗网络),通过 “特征提取器” 与 “领域判别器” 的对抗训练,学习领域无关的特征。
网络结构:DANN 包含三个核心模块(如图 1 所示):
- 特征提取器(Feature Extractor, G):输入源域或目标域数据,输出特征向量。目标是让特征既能被标签预测器正确分类,又能欺骗领域判别器;
- 标签预测器(Label Predictor, F):输入特征向量,预测源域数据的标签。目标是最小化源域数据的分类损失(确保特征有任务区分度);
- 领域判别器(Domain Discriminator, D):输入特征向量,判断特征来自源域还是目标域。目标是最大化领域分类损失(准确区分领域);而特征提取器 G 的目标是最小化领域分类损失(欺骗 D)。
训练过程:
- 固定 D,训练 G 和 F:最小化源域数据的分类损失(让 F 能正确预测标签),同时最小化 D 的领域分类损失(让 G 生成的特征无法被 D 区分领域);
- 固定 G,训练 D:最大化 D 的领域分类损失(让 D 能区分 G 生成的特征来自源域还是目标域);
- 交替训练,直到收敛。此时,G 生成的特征是 “领域无关且任务相关” 的,可直接用于目标域任务。
4.2.2 特征对齐(Feature Alignment):缩小领域差距
特征对齐是特征迁移的核心目标,除了 DANN 的对抗对齐,还有以下常用方法:
- 统计对齐:通过最小化源域和目标域特征的统计差异(如均值、方差、最大均值差异 MMD),实现特征对齐。例如,MMD 通过计算两个分布的核函数距离,最小化该距离以对齐特征;
- 对比对齐:通过对比学习,让源域和目标域的相似样本在特征空间中靠近,不同样本远离。例如,SimCLR 通过数据增强生成正样本对,最小化正样本对的距离,最大化负样本对的距离;
- 自监督对齐:利用目标域的无标签数据进行自监督学习(如掩码图像建模 MAE),让目标域特征与源域特征的表示方式一致。
4.3 实例迁移:给相似样本 “加权投票”
当源域样本量大,但仅部分样本与目标域相关时,实例迁移是最优选择。其核心是 “筛选相似样本,加权训练”。
4.3.1 样本权重计算:距离与密度的考量
实例迁移的关键是计算源域样本与目标域样本的 “相似度”,并赋予相似样本更高的权重。常用的权重计算方法:
- 基于距离的权重:计算源域样本xiS与目标域样本xjT的距离(如欧氏距离、余弦距离),距离越小,权重越大。公式如下:
wi=1+dist(xiS,xˉT)1,其中xˉT是目标域样本的均值; - 基于密度的权重:如果源域样本xiS位于目标域样本的 “高密度区域”(即周围有很多目标域样本),则权重更大。可通过 KNN 或核密度估计(KDE)计算;
- 基于模型的权重:用目标域少量标签训练一个初步模型,用该模型预测源域样本的置信度,置信度高的样本(即模型认为与目标域相似的样本)权重更大。
4.3.2 实例迁移的适用场景与局限
适用场景:
- 源域样本量大,但存在大量噪声或无关样本(如网络爬取的图像数据);
- 目标域样本极少(如几十张),需要补充相似样本以避免过拟合。
局限:
- 若源域与目标域的分布差异过大,可能筛选不出相似样本,迁移无效;
- 若源域中存在 “伪相似样本”(表面相似但标签不同),会导致负迁移。
4.4 关系知识迁移:迁移 “关联模式”
关系知识迁移适用于 “样本间关联模式相似” 的场景,如推荐系统、知识图谱、逻辑推理任务。其核心是迁移 “关系结构”,而非单个样本或参数。
4.4.1 关系知识的表示与迁移
关系知识通常用 “图结构” 表示,例如:
- 推荐系统中,“用户 - 物品 - 评分” 构成 bipartite 图,关系是 “用户对物品的偏好”;
- 知识图谱中,“实体 - 关系 - 实体” 构成三元组,关系是 “实体间的语义关联”。
迁移方法通常分为两步:
- 关系建模:在源域中训练一个关系模型(如图神经网络 GNN),学习样本间的关联模式;
- 关系迁移:将源域的关系模型参数(或关系嵌入)作为目标域关系模型的初始化,或直接复用关系推理规则。
4.4.2 跨任务关系迁移案例
以推荐系统的跨领域迁移为例:
- 源域:“用户 - 电影 - 评分” 数据,学习用户的观影偏好关系(如喜欢科幻电影的用户也喜欢动作电影);
- 目标域:“用户 - 书籍 - 评分” 数据,迁移源域的用户偏好关系(如喜欢科幻电影的用户可能喜欢科幻书籍);
- 实现:用源域数据训练一个 GNN 模型,学习用户和物品的嵌入;将用户嵌入迁移到目标域,作为书籍推荐的初始嵌入,提升推荐精度。
5. 迁移学习实践:PyTorch 代码实例
理论讲完后,我们通过两个完整的代码实例,分别演示计算机视觉(图像分类) 和自然语言处理(文本分类) 中的迁移学习应用。这两个案例覆盖了工业界最常见的迁移场景,代码可直接运行。
5.1 案例 1:计算机视觉 ——CIFAR-10 图像分类(ResNet 微调)
5.1.1 实验背景与目标
- 源域:ImageNet 数据集(130 万张图像,1000 类),预训练模型为 ResNet-18;
- 目标域:CIFAR-10 数据集(5 万张训练图,1 万张测试图,10 类:飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车);
- 任务:CIFAR-10 图像分类,对比 “从零训练 ResNet-18” 和 “微调预训练 ResNet-18” 的性能差异;
- 实验目标:验证微调在数据有限场景下的优势(我们将 CIFAR-10 训练集抽样至 10%,模拟小样本场景)。
5.1.2 实验环境准备
需安装以下库:
bash
pip install torch torchvision matplotlib numpy scikit-learn
5.1.3 数据加载与预处理
CIFAR-10 数据集可通过torchvision.datasets
直接下载,预处理需与预训练 ResNet-18 的输入要求一致(ImageNet 的预处理方式):
python
运行
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, Subset
from torchvision import datasets, transforms
from torchvision.models import resnet18
import matplotlib.pyplot as plt
import numpy as np
from sklearn.model_selection import train_test_split# 1. 配置超参数
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
BATCH_SIZE = 64
EPOCHS = 20
LEARNING_RATE = 1e-4 # 微调学习率通常较小
NUM_CLASSES = 10 # CIFAR-10有10类
SAMPLE_RATIO = 0.1 # 抽样10%的训练集,模拟小样本场景# 2. 数据预处理(与ImageNet预训练一致)
# 训练集:随机裁剪、水平翻转、归一化
train_transform = transforms.Compose([transforms.RandomResizedCrop(224), # ResNet-18输入尺寸为224x224transforms.RandomHorizontalFlip(),transforms.ToTensor(),transforms.Normalize(mean=[0.485, 0.456, 0.406], # ImageNet均值std=[0.229, 0.224, 0.225]) # ImageNet标准差
])# 测试集:仅 resize 和归一化
test_transform = transforms.Compose([transforms.Resize(256),transforms.CenterCrop(224),transforms.ToTensor(),transforms.Normalize(mean=[0.485, 0.456, 0.406],std=[0.229, 0.224, 0.225])
])# 3. 加载CIFAR-10数据集
full_train_dataset = datasets.CIFAR10(root="./data", train=True, download=True, transform=train_transform
)
test_dataset = datasets.CIFAR10(root="./data", train=False, download=True, transform=test_transform
)# 4. 抽样10%的训练集,模拟小样本场景
train_indices, _ = train_test_split(range(len(full_train_dataset)),test_size=1 - SAMPLE_RATIO,random_state=42,stratify=full_train_dataset.targets # 保持类别分布一致
)
train_dataset = Subset(full_train_dataset, train_indices)# 5. 创建DataLoader
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=2
)
test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=2
)# 查看数据集规模
print(f"训练集样本数:{len(train_dataset)}") # 约5000张
print(f"测试集样本数:{len(test_dataset)}") # 10000张
5.1.4 构建两种模型:从零训练 vs 微调
我们构建两个 ResNet-18 模型,分别用于对比:
- 从零训练模型:所有参数随机初始化;
- 微调模型:加载 ImageNet 预训练参数,替换输出层,冻结前 10 层(卷积层),训练剩余层。
python
运行
def build_model(fine_tune=True):"""构建模型:fine_tune=True 表示微调预训练模型,False表示从零训练"""if fine_tune:# 1. 加载预训练ResNet-18(默认加载ImageNet预训练参数)model = resnet18(pretrained=True) # PyTorch 1.13+需用 weights=ResNet18_Weights.DEFAULT# 2. 替换输出层(适配CIFAR-10的10类)in_features = model.fc.in_features # 获取全连接层输入特征数model.fc = nn.Linear(in_features, NUM_CLASSES)# 3. 冻结前10层(卷积层),仅训练后几层和全连接层# ResNet-18的层结构:conv1 -> layer1 -> layer2 -> layer3 -> layer4 -> fc# 冻结 conv1 + layer1 + layer2 的前几层(共10层)freeze_layer_num = 10for i, (name, param) in enumerate(model.named_parameters()):if i < freeze_layer_num:param.requires_grad = False # 冻结参数,不更新else:param.requires_grad = True # 解冻参数,更新else:# 从零训练:不加载预训练参数,所有参数随机初始化model = resnet18(pretrained=False)in_features = model.fc.in_featuresmodel.fc = nn.Linear(in_features, NUM_CLASSES)# 所有参数均可训练for param in model.parameters():param.requires_grad = True# 移动模型到GPU/CPUmodel = model.to(DEVICE)return model# 构建两个模型
model_scratch = build_model(fine_tune=False) # 从零训练
model_finetune = build_model(fine_tune=True) # 微调
5.1.5 模型训练与评估函数
定义训练和评估函数,用于统一训练两个模型:
python
运行
def train_model(model, train_loader, criterion, optimizer, epoch):"""训练模型一个epoch"""model.train() # 开启训练模式total_loss = 0.0total_correct = 0total_samples = 0for batch_idx, (data, target) in enumerate(train_loader):# 数据移动到DEVICEdata, target = data.to(DEVICE), target.to(DEVICE)# 前向传播output = model(data)loss = criterion(output, target)# 反向传播与优化optimizer.zero_grad()loss.backward()optimizer.step()# 统计损失和准确率total_loss += loss.item() * data.size(0)_, predicted = torch.max(output, 1)total_correct += (predicted == target).sum().item()total_samples += data.size(0)# 每100个batch打印一次进度if batch_idx % 100 == 0:print(f"Epoch [{epoch+1}/{EPOCHS}], Batch [{batch_idx}/{len(train_loader)}], "f"Loss: {loss.item():.4f}, Acc: {100.*total_correct/total_samples:.2f}%")# 计算一个epoch的平均损失和准确率avg_loss = total_loss / total_samplesavg_acc = 100. * total_correct / total_samplesreturn avg_loss, avg_accdef evaluate_model(model, test_loader, criterion):"""评估模型在测试集上的性能"""model.eval() # 开启评估模式(关闭dropout、batchnorm更新)total_loss = 0.0total_correct = 0total_samples = 0with torch.no_grad(): # 禁用梯度计算,节省内存for data, target in test_loader:data, target = data.to(DEVICE), target.to(DEVICE)output = model(data)loss = criterion(output, target)total_loss += loss.item() * data.size(0)_, predicted = torch.max(output, 1)total_correct += (predicted == target).sum().item()total_samples += data.size(0)avg_loss = total_loss / total_samplesavg_acc = 100. * total_correct / total_samplesprint(f"Test Loss: {avg_loss:.4f}, Test Acc: {avg_acc:.2f}%")return avg_loss, avg_acc
5.1.6 训练两个模型并记录结果
使用相同的损失函数(交叉熵)和优化器(Adam)训练两个模型,记录训练过程中的损失和准确率:
python
运行
# 定义损失函数和优化器(两个模型使用相同配置)
criterion = nn.CrossEntropyLoss()
optimizer_scratch = optim.Adam(model_scratch.parameters(), lr=LEARNING_RATE)
optimizer_finetune = optim.Adam(model_finetune.parameters(), lr=LEARNING_RATE)# 记录训练过程
history = {"scratch": {"train_loss": [], "train_acc": [], "test_loss": [], "test_acc": []},"finetune": {"train_loss": [], "train_acc": [], "test_loss": [], "test_acc": []}
}# 训练从零开始的模型
print("="*50)
print("Training Model from Scratch")
print("="*50)
for epoch in range(EPOCHS):train_loss, train_acc = train_model(model_scratch, train_loader, criterion, optimizer_scratch, epoch)test_loss, test_acc = evaluate_model(model_scratch, test_loader, criterion)# 记录结果history["scratch"]["train_loss"].append(train_loss)history["scratch"]["train_acc"].append(train_acc)history["scratch"]["test_loss"].append(test_loss)history["scratch"]["test_acc"].append(test_acc)# 训练微调模型
print("\n" + "="*50)
print("Training Fine-tuned Model")
print("="*50)
for epoch in range(EPOCHS):train_loss, train_acc = train_model(model_finetune, train_loader, criterion, optimizer_finetune, epoch)test_loss, test_acc = evaluate_model(model_finetune, test_loader, criterion)# 记录结果history["finetune"]["train_loss"].append(train_loss)history["finetune"]["train_acc"].append(train_acc)history["finetune"]["test_loss"].append(test_loss)history["finetune"]["test_acc"].append(test_acc)
5.1.7 结果可视化与分析
绘制训练 / 测试损失和准确率曲线,对比两个模型的性能:
python
运行
# 设置中文字体
plt.rcParams['font.sans-serif'] = ['WenQuanYi Zen Hei']
plt.rcParams['axes.unicode_minus'] = False# 创建2x2的子图
fig, axes = plt.subplots(2, 2, figsize=(15, 12))# 1. 训练损失对比
axes[0, 0].plot(range(1, EPOCHS+1), history["scratch"]["train_loss"], label="从零训练", marker='o', linewidth=2)
axes[0, 0].plot(range(1, EPOCHS+1), history["finetune"]["train_loss"], label="微调", marker='s', linewidth=2)
axes[0, 0].set_title("训练损失对比", fontsize=14)
axes[0, 0].set_xlabel("Epoch", fontsize=12)
axes[0, 0].set_ylabel("损失", fontsize=12)
axes[0, 0].legend()
axes[0, 0].grid(True)# 2. 训练准确率对比
axes[0, 1].plot(range(1, EPOCHS+1), history["scratch"]["train_acc"], label="从零训练", marker='o', linewidth=2)
axes[0, 1].plot(range(1, EPOCHS+1), history["finetune"]["train_acc"], label="微调", marker='s', linewidth=2)
axes[0, 1].set_title("训练准确率对比", fontsize=14)
axes[0, 1].set_xlabel("Epoch", fontsize=12)
axes[0, 1].set_ylabel("准确率(%)", fontsize=12)
axes[0, 1].legend()
axes[0, 1].grid(True)# 3. 测试损失对比
axes[1, 0].plot(range(1, EPOCHS+1), history["scratch"]["test_loss"], label="从零训练", marker='o', linewidth=2)
axes[1, 0].plot(range(1, EPOCHS+1), history["finetune"]["test_loss"], label="微调", marker='s', linewidth=2)
axes[1, 0].set_title("测试损失对比", fontsize=14)
axes[1, 0].set_xlabel("Epoch", fontsize=12)
axes[1, 0].set_ylabel("损失", fontsize=12)
axes[1, 0].legend()
axes[1, 0].grid(True)# 4. 测试准确率对比
axes[1, 1].plot(range(1, EPOCHS+1), history["scratch"]["test_acc"], label="从零训练", marker='o', linewidth=2)
axes[1, 1].plot(range(1, EPOCHS+1), history["finetune"]["test_acc"], label="微调", marker='s', linewidth=2)
axes[1, 1].set_title("测试准确率对比", fontsize=14)
axes[1, 1].set_xlabel("Epoch", fontsize=12)
axes[1, 1].set_ylabel("准确率(%)", fontsize=12)
axes[1, 1].legend()
axes[1, 1].grid(True)# 保存图片
plt.tight_layout()
plt.savefig("transfer_learning_cifar10.png", dpi=300)
plt.show()# 输出最终结果
print("\n" + "="*50)
print("最终结果对比")
print("="*50)
print(f"从零训练模型 - 测试准确率:{history['scratch']['test_acc'][-1]:.2f}%")
print(f"微调模型 - 测试准确率:{history['finetune']['test_acc'][-1]:.2f}%")
print(f"准确率提升:{history['finetune']['test_acc'][-1] - history['scratch']['test_acc'][-1]:.2f}%")
5.1.8 预期结果与分析
在小样本场景(CIFAR-10 训练集仅 5000 张)下,预期结果如下:
- 从零训练模型:测试准确率约 65%-70%,训练后期可能过拟合(训练准确率高,测试准确率低);
- 微调模型:测试准确率约 80%-85%,收敛速度快(前 5 个 epoch 即可达到较高准确率),过拟合风险低。
原因分析:
- 预训练 ResNet-18 的底层卷积层学习了通用图像特征(边缘、纹理),无需在小样本上重新学习;
- 仅训练上层和全连接层,参数数量少,降低了过拟合风险;
- 微调的学习率小,避免了破坏预训练的通用特征。
5.2 案例 2:自然语言处理 ——IMDB 情感分析(BERT 微调)
5.2.1 实验背景与目标
- 源域:Wikipedia 英文语料,预训练模型为 BERT-base-uncased(12 层 Transformer,768 维嵌入);
- 目标域:IMDB 电影评论数据集(5 万条评论,正 / 负情感各 2.5 万条);
- 任务:IMDB 情感二分类,对比 “从零训练 LSTM” 和 “微调 BERT” 的性能差异;
- 实验目标:验证 BERT 微调在文本分类任务中的优势,尤其是在小样本场景下。
5.2.2 实验环境准备
需安装 Hugging Face 的transformers
库(用于加载 BERT 模型和 Tokenizer):
bash
pip install torch transformers datasets matplotlib numpy scikit-learn
5.2.3 数据加载与预处理
使用datasets
库加载 IMDB 数据集,并用 BERT 的 Tokenizer 处理文本(将文本转换为 BERT 的输入格式:token_id、attention_mask、token_type_id):
python
运行
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from transformers import BertTokenizer, BertForSequenceClassification
from datasets import load_dataset
import matplotlib.pyplot as plt
import numpy as np
from sklearn.model_selection import train_test_split# 1. 配置超参数
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
BATCH_SIZE = 32
EPOCHS = 5
LEARNING_RATE = 2e-5 # BERT微调学习率通常很小(避免破坏预训练知识)
NUM_CLASSES = 2 # 情感二分类(正/负)
SAMPLE_RATIO = 0.1 # 抽样10%的训练集,模拟小样本场景
BERT_MODEL_NAME = "bert-base-uncased" # 小写英文BERT模型# 2. 加载IMDB数据集
dataset = load_dataset("imdb")
train_dataset = dataset["train"]
test_dataset = dataset["test"]# 3. 抽样10%的训练集
train_indices, _ = train_test_split(range(len(train_dataset)),test_size=1 - SAMPLE_RATIO,random_state=42,stratify=train_dataset["label"] # 保持情感分布一致
)
train_dataset = train_dataset.select(train_indices)# 4. 加载BERT Tokenizer
tokenizer = BertTokenizer.from_pretrained(BERT_MODEL_NAME)# 5. 文本预处理函数:将文本转换为BERT输入格式
def preprocess_function(examples):return tokenizer(examples["text"],padding="max_length", # 填充到BERT的最大输入长度(512)truncation=True, # 截断超过512的文本max_length=512)# 6. 应用预处理函数
train_dataset = train_dataset.map(preprocess_function, batched=True)
test_dataset = test_dataset.map(preprocess_function, batched=True)# 7. 转换为PyTorch张量格式
train_dataset.set_format(type="torch",columns=["input_ids", "attention_mask", "token_type_ids", "label"],device=DEVICE
)
test_dataset.set_format(type="torch",columns=["input_ids", "attention_mask", "token_type_ids", "label"],device=DEVICE
)# 8. 创建DataLoader
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True
)
test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False
)# 查看数据集规模
print(f"训练集样本数:{len(train_dataset)}") # 约2500条
print(f"测试集样本数:{len(test_dataset)}") # 25000条
5.2.4 构建两种模型:从零训练 LSTM vs 微调 BERT
构建两个模型用于对比:
- 从零训练 LSTM:用随机初始化的词嵌入和 LSTM 构建文本分类模型;
- 微调 BERT:加载预训练 BERT 模型,添加分类头,微调部分层。
python
运行
class LSTMClassifier(nn.Module):"""从零训练的LSTM文本分类模型"""def __init__(self, vocab_size, embedding_dim, hidden_dim, num_classes):super(LSTMClassifier, self).__init__()# 词嵌入层(随机初始化)self.embedding = nn.Embedding(vocab_size, embedding_dim)# LSTM层self.lstm = nn.LSTM(embedding_dim, hidden_dim, num_layers=2, bidirectional=True, batch_first=True)# 全连接层(双向LSTM输出维度为2*hidden_dim)self.fc = nn.Linear(hidden_dim * 2, num_classes)# Dropout层(防止过拟合)self.dropout = nn.Dropout(0.5)def forward(self, input_ids, attention_mask=None):# 输入:input_ids (batch_size, seq_len)# 词嵌入:(batch_size, seq_len, embedding_dim)embedded = self.dropout(self.embedding(input_ids))# LSTM输出:(batch_size, seq_len, 2*hidden_dim)lstm_out, _ = self.lstm(embedded)# 取最后一个时间步的输出:(batch_size, 2*hidden_dim)last_hidden = lstm_out[:, -1, :]# 全连接层输出:(batch_size, num_classes)logits = self.fc(self.dropout(last_hidden))return logitsdef build_models():# 1. 构建从零训练的LSTM模型# 词表大小:使用BERT的词表大小(避免重新构建词表)vocab_size = tokenizer.vocab_sizelstm_model = LSTMClassifier(vocab_size=vocab_size,embedding_dim=128, # 词嵌入维度hidden_dim=256, # LSTM隐藏层维度num_classes=NUM_CLASSES).to(DEVICE)# 2. 构建微调的BERT模型# 加载预训练BERT,添加分类头(num_labels=2)bert_model = BertForSequenceClassification.from_pretrained(BERT_MODEL_NAME,num_labels=NUM_CLASSES).to(DEVICE)# 冻结BERT的前6层(仅训练后6层和分类头)freeze_layer_num = 6for i, (name, param) in enumerate(bert_model.bert.named_parameters()):# BERT的层名格式:layer.0.attention.self.query.weight(第0层)if "layer." in name:layer_idx = int(name.split("layer.")[1].split(".")[0])if layer_idx < freeze_layer_num:param.requires_grad = False# 嵌入层也冻结elif "embeddings" in name:param.requires_grad = Falsereturn lstm_model, bert_model# 构建两个模型
model_lstm, model_bert = build_models()