查看全课大纲(第 14 / 40 节)
- 1.从规则到学习:深度学习解决什么问题
- 2.表示学习与端到端训练
- 3.张量、计算图与自动微分
- 4.训练三要素:数据、模型、目标函数
- 5.从感知机到多层感知机
- 6.反向传播:手推一个两层网络
- 7.激活函数与初始化
- 8.优化器:SGD → Momentum → Adam
- 9.正则化:权重衰减、Dropout、BN/LN
- 10.卷积的直觉与参数共享
- 11.池化、感受野与下采样
- 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
- 13.残差连接与归一化为什么有效
- 14.迁移学习与特征可视化
- 15.RNN 与 BPTT
- 16.LSTM/GRU:门控如何缓解梯度问题
- 17.Seq2Seq 与编码器-解码器
- 18.注意力前史:Bahdanau 加性注意力
- 19.Self-Attention:QKV 与缩放点积
- 20.多头注意力:并行的多种关系
- 21.位置编码:正弦与可学习
- 22.残差、LayerNorm 与前馈层
- 23.从零实现一个最小 Transformer block
- 24.训练稳定性、复杂度与常见误区
- 25.自监督与语言建模目标
- 26.BERT:掩码语言模型与双向编码
- 27.GPT:自回归生成与缩放定律
- 28.指令微调与对齐:SFT → RLHF/DPO
- 29.参数高效微调:LoRA 与适配器
- 30.自编码器与 VAE
- 31.GAN:对抗训练与模式崩塌
- 32.扩散模型:前向加噪与反向去噪
- 33.扩散模型的应用与采样加速
- 34.对比学习与 CLIP
- 35.ViT:把图像当序列
- 36.多模态架构:对齐、融合与生成
- 37.能力边界、幻觉与评测
- 38.PyTorch 2.x 训练循环与混合精度
- 39.实验管理、评测与复现
- 40.推理部署与成本:量化/蒸馏概览
迁移学习与特征可视化
迁移学习与特征可视化:卷积网络到底学到了什么?
小象实战讲义 · 深度学习基础:从神经网络到 Transformer
学完本讲,你将能回答:一个在 ImageNet 上预训练好的卷积网络,其内部特征图(Feature Maps)究竟长什么样?我们如何将这些“知识”迁移到新的、数据量更少的任务上?本节将带你亲手提取并可视化网络中间层的激活,并实现一个简单的迁移学习流程,让你直观理解卷积网络的表征能力。
先修:你应已掌握卷积层、池化层的基本操作,以及如何使用 PyTorch 搭建简单的 CNN(如 LeNet)。后续,我们将进入序列模型章节,探讨循环神经网络(RNN)。
💡 核心导读
- 特征图可视化:通过将卷积层的输出(特征图)反向映射回像素空间,我们可以“看到”网络在每一层关注图像的哪些模式(如边缘、纹理、物体部件)。
- 迁移学习原理:在大规模数据集(如 ImageNet)上预训练的模型,其浅层学习通用特征(边缘、角点),深层学习任务特定特征。冻结浅层、微调深层,可高效适配新任务。
- 实现流程:加载预训练模型 → 提取中间层输出 → 进行可视化或作为新分类器的输入。
- 代码实践:用
register_forward_hook抓取中间层特征图,用「冻结特征层 + 换分类头」实现迁移学习。为了让代码在纯 CPU、不下载预训练权重的环境里也能跑通,正文用一个自建小 CNN 演示;换成torchvision.models.resnet18(weights=...)时,钩子与冻结的写法完全一样。
🧭 前置知识
- 卷积神经网络(CNN)的基本结构:卷积、ReLU、池化。
- PyTorch 中
nn.Module的前向传播流程和钩子(hook)的基本概念。 - softmax 函数与交叉熵损失的定义。
- (不展开)梯度下降与反向传播,见站内相关章节。
特征图:网络内部的“眼睛”
卷积神经网络通过层层堆叠,将原始像素转换为高级语义特征。每一层卷积输出的多个二维数组被称为“特征图”(Feature Maps)。可视化这些特征图,是理解网络“看到”什么的最直接方式。
如何提取特征图?
在 PyTorch 中,我们可以使用前向钩子(forward hook)来捕获指定层在前向传播时的输出。
import torch
import torch.nn as nn
torch.manual_seed(0)
# 本节用自建的小 CNN 演示同一套 API;真实项目里把这里换成
# torchvision.models.resnet18(weights=...) 即可,钩子与冻结的写法完全一致。
class TinyCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.conv1 = nn.Conv2d(3, 8, kernel_size=3, stride=2, padding=1) # 32 -> 16
self.bn1 = nn.BatchNorm2d(8)
self.relu = nn.ReLU()
self.pool = nn.MaxPool2d(2) # 16 -> 8
self.conv2 = nn.Conv2d(8, 16, kernel_size=3, padding=1)
self.fc = nn.Linear(16 * 8 * 8, num_classes)
def forward(self, x):
x = self.relu(self.bn1(self.conv1(x)))
x = self.pool(x)
x = self.relu(self.conv2(x))
x = x.flatten(1)
return self.fc(x)
model = TinyCNN().eval()
# 一张 32x32 的随机「图像」(真实场景请用 224x224 的图片,并做与预训练一致的归一化)
input_tensor = torch.randn(1, 3, 32, 32)
print("输入图像张量形状:", tuple(input_tensor.shape))
# 定义钩子来捕获指定层的输出
feature_maps = {}
def get_feature_map(name):
def hook(module, input, output):
feature_maps[name] = output.detach() # 分离计算图,避免梯度累积
return hook
handle_conv1 = model.conv1.register_forward_hook(get_feature_map('conv1'))
handle_conv2 = model.conv2.register_forward_hook(get_feature_map('conv2'))
# 执行前向传播(不计算梯度)
with torch.no_grad():
_ = model(input_tensor)
# 移除钩子,避免后续前向重复累积
handle_conv1.remove()
handle_conv2.remove()
print("\n捕获到的特征图形状:")
for name, fm in feature_maps.items():
print(f"{name}: {tuple(fm.shape)}")输入图像张量形状: (1, 3, 32, 32)
捕获到的特征图形状:
conv1: (1, 8, 16, 16)
conv2: (1, 16, 8, 8)形状解读:
conv1:输入(1, 3, 32, 32)经过第一个卷积层(步长 2)后,输出(1, 8, 16, 16)。8 个通道就是 8 张特征图,每张 16×16。第一层的特征图通常对应低级特征,如边缘、颜色和纹理。conv2:再经池化(16→8)和第二个卷积层,得到(1, 16, 8, 8)。通道变多、空间变小,特征开始组合低级模式,形成中级特征,如纹理组合或简单形状。
这也是 CNN 的普遍规律:越往深走,空间尺寸越小、通道数越多——网络用「更多的通道」换「更粗的空间分辨率」,把像素逐步抽象成语义。
可视化特征图:用字符画看边缘响应
真实项目里会把特征图归一化到 0–255 存成图片;在没有图形界面的环境里,我们可以用字符的疏密把它画出来。下面用一个手设的垂直边缘核,对一张「左黑右白」的 8×8 小图做卷积,看响应落在哪。
import torch
import torch.nn.functional as F
# 左半黑、右半白的 8x8 合成图
img = torch.zeros(1, 1, 8, 8)
img[:, :, :, 4:] = 1.0
# 垂直边缘核:左列 +1、右列 -1
kernel = torch.tensor([[[[1., 0., -1.],
[1., 0., -1.],
[1., 0., -1.]]]])
out = F.conv2d(img, kernel) # 不加 padding,避免边界补零产生「假边缘」
print("输入图像(# = 白,. = 黑):")
for row in img[0, 0].tolist():
print(" " + "".join("#" if v > 0.5 else "." for v in row))
print("\n特征图响应(负值 = 左黑右白的边缘):")
for row in out[0, 0].tolist():
print(" " + " ".join(f"{v:5.1f}" for v in row))输入图像(# = 白,. = 黑):
....####
....####
....####
....####
....####
....####
....####
....####
特征图响应(负值 = 左黑右白的边缘):
0.0 0.0 -3.0 -3.0 0.0 0.0
0.0 0.0 -3.0 -3.0 0.0 0.0
0.0 0.0 -3.0 -3.0 0.0 0.0
0.0 0.0 -3.0 -3.0 0.0 0.0
0.0 0.0 -3.0 -3.0 0.0 0.0
0.0 0.0 -3.0 -3.0 0.0 0.0响应只出现在黑白交界的相邻两列(−3、−3),其余位置全是 0;符号为负是因为这个核是「左正右负」,遇到左黑右白的边缘自然是负响应——换成「左负右正」的核,同一位置就会变成 +3。这就是「特征图」的本质:它是某个模式在图像各处出现的强度图。真实网络不用人来指定核,而是通过训练自动学出成百上千个这样的核:浅层的核接近边缘/颜色检测器,深层的核则对应纹理组合、物体部件。所谓「可视化」,就是把某个通道的响应按上面的方式画出来,看它对什么模式敏感。
核心启示:卷积核是可学习的滤波器。通过在大规模数据上训练,网络自动学会了从通用(边缘)到特定(语义部件)的特征提取器。
迁移学习:借用已有的“知识”
从头训练一个深度卷积网络需要海量数据和计算资源。迁移学习(Transfer Learning)利用在大数据集(如 ImageNet)上预训练好的模型,通过少量调整,使其适应新的、通常数据量较小的任务。
为什么有效?—— 特征的层次性
- 浅层特征通用:无论任务是识别猫、狗还是汽车,网络前几层学习的边缘、角点、纹理等低级视觉特征都是通用的。
- 深层特征专用:网络深层学习的是这些低级特征的特定组合,用于识别高级语义概念(如“车轮”、“猫耳”),这些概念与原始训练任务(ImageNet 1000类)强相关。
因此,迁移学习的标准做法是:
- 冻结(Freeze) 预训练模型的浅层参数(保持其提取的通用特征不变)。
- 替换并训练(Replace & Fine-tune) 网络的最后几层(通常是全连接分类头),使其适应新任务的类别。
动手实现:迁移学习用于 5 分类任务
假设我们有一个新的小数据集,包含 5 个类别。做法是:冻结预训练模型的特征层,只替换并训练最后的分类头。下面仍用上一块的小模型演示(换成 torchvision 的 ResNet-18 时,只需改模型构造那一行)。
import torch
import torch.nn as nn
import torch.optim as optim
torch.manual_seed(0)
class TinyCNN(nn.Module):
"""与上一块同构的「预训练」模型;真实项目换成 torchvision 的 resnet18"""
def __init__(self, num_classes=10):
super().__init__()
self.conv1 = nn.Conv2d(3, 8, kernel_size=3, stride=2, padding=1)
self.bn1 = nn.BatchNorm2d(8)
self.relu = nn.ReLU()
self.pool = nn.MaxPool2d(2)
self.conv2 = nn.Conv2d(8, 16, kernel_size=3, padding=1)
self.fc = nn.Linear(16 * 8 * 8, num_classes)
def forward(self, x):
x = self.relu(self.bn1(self.conv1(x)))
x = self.pool(x)
x = self.relu(self.conv2(x))
return self.fc(x.flatten(1))
model = TinyCNN()
# 1. 冻结全部预训练参数
for p in model.parameters():
p.requires_grad = False
# 2. 替换分类头:新任务是 5 类,新建的层默认 requires_grad=True
num_ftrs = model.fc.in_features
model.fc = nn.Linear(num_ftrs, 5)
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.fc.parameters(), lr=0.01) # 只把新头交给优化器
total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print("总参数数:", total)
print("可训练参数数:", trainable)
print("可训练占比: {:.2%}".format(trainable / total))
print("conv1.weight.requires_grad =", model.conv1.weight.requires_grad)
print("fc.weight.requires_grad =", model.fc.weight.requires_grad)
# 3. 走一步训练,检查梯度只落在新头上
model.train()
inputs = torch.randn(4, 3, 32, 32)
labels = torch.randint(0, 5, (4,))
optimizer.zero_grad()
loss = criterion(model(inputs), labels)
loss.backward()
optimizer.step()
print("\n一步训练后的损失: {:.4f}".format(loss.item()))
print("conv1.weight 的梯度是否为 None:", model.conv1.weight.grad is None)
print("fc.weight 的梯度是否为 None:", model.fc.weight.grad is None)总参数数: 6533
可训练参数数: 5125
可训练占比: 78.45%
conv1.weight.requires_grad = False
fc.weight.requires_grad = True
一步训练后的损失: 1.6234
conv1.weight 的梯度是否为 None: True
fc.weight 的梯度是否为 None: False代码解读:
- 冻结之后,
conv1.weight.requires_grad是False,新换的fc.weight是True;反向传播后冻结层的.grad是None(根本没有分配),新头才有梯度。 - 这个玩具模型只有 6533 个参数、特征层又极小,所以「可训练占比 78%」看着并不省。真实网络的对比才有冲击力:ResNet-18 共 1169 万个参数,换掉 1000 类分类头后,可训练的只有 512×5+5 = 2565 个,占比约 0.02%。模型越大、预训练特征越通用,「只训一个小头」省下的就越多——这正是迁移学习在小数据集上最实用的地方。
更激进的微调(Fine-tuning)
如果新数据集与 ImageNet 差异较大(如医学影像),也可以解冻更深层的部分网络进行微调。
# 伪代码:按名字选择性解冻(换成 ResNet 时就把名字换成 layer3 / layer4 / fc)
for name, param in model.named_parameters():
if name.startswith(('conv2', 'fc')): # 本节的小模型只有这两组
param.requires_grad = True
else:
param.requires_grad = False
# 优化器只收可训练参数
params_to_update = filter(lambda p: p.requires_grad, model.parameters())
optimizer = optim.SGD(params_to_update, lr=1e-4)判断要不要解冻更多层,看两件事:新数据集的大小与和预训练任务的相似度。数据少、任务相似(猫狗分类 vs ImageNet),只训分类头往往就够;数据多、差异大(医学影像、卫星图),才逐步解冻更深层,并把学习率降到 1e-4 量级,避免一步就把预训练权重冲坏。
📝 动手练一练
问题:你接到一个 5 类的小数据集(每类约 40 张图)。请写代码:以上一块的 TinyCNN 充当「预训练模型」,冻结全部特征层、把分类头换成 5 类,再额外解冻 conv2 做微调,最后打印「总参数 / 可训练参数 / 可训练占比」,并用 filter 只把可训练参数交给优化器。
👉 点击查看参考答案
思路:迁移学习的三个动作是「冻结 → 换头 → 选择性解冻」。换头得到的新层默认 requires_grad=True,所以它天然可训练;再单独把 conv2 的 requires_grad 置回 True,就实现了「只微调最后一组卷积 + 新头」。优化器必须用 filter(lambda p: p.requires_grad, ...),否则会把冻结参数也塞进去。
import torch
import torch.nn as nn
import torch.optim as optim
torch.manual_seed(0)
class TinyCNN(nn.Module):
"""与正文同构;真实项目换成 torchvision.models.resnet34(weights=...)"""
def __init__(self, num_classes=10):
super().__init__()
self.conv1 = nn.Conv2d(3, 8, kernel_size=3, stride=2, padding=1)
self.bn1 = nn.BatchNorm2d(8)
self.relu = nn.ReLU()
self.pool = nn.MaxPool2d(2)
self.conv2 = nn.Conv2d(8, 16, kernel_size=3, padding=1)
self.fc = nn.Linear(16 * 8 * 8, num_classes)
def forward(self, x):
x = self.relu(self.bn1(self.conv1(x)))
x = self.pool(x)
x = self.relu(self.conv2(x))
return self.fc(x.flatten(1))
model = TinyCNN()
# 1. 冻结全部参数
for p in model.parameters():
p.requires_grad = False
# 2. 换分类头(新任务 5 类)
model.fc = nn.Linear(model.fc.in_features, 5)
# 3. 选择性解冻:让最后一组卷积也参与微调
for p in model.conv2.parameters():
p.requires_grad = True
total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print("总参数:", total)
print("可训练参数:", trainable)
print("可训练占比: {:.2%}".format(trainable / total))
# 4. 只把可训练参数交给优化器
optimizer = optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4)
print("优化器里的参数量:", sum(p.numel() for g in optimizer.param_groups for p in g["params"]))总参数: 6533
可训练参数: 6293
可训练占比: 96.33%
优化器里的参数量: 6293注意:这个玩具模型太小,解冻 conv2 后可训练占比高达 96%,看起来「冻结没省多少」——这不是算法问题,而是模型规模的错觉。换成真实网络:ResNet-34 共 2179 万参数,光是 layer4 这一组就占了约 1430 万(约六成五),解冻它同样不「省」;真正只占零头的是「只训新头」(512×5+5 = 2565 个,约 0.01%)。所以判断「解冻多少」的标准不是比例好看,而是新数据集有多大、与预训练任务有多像:数据越少、越相似,冻得越多;数据多且差异大(如医学影像),才逐步解冻更深层,并把学习率调小到 1e-4 量级。
常见错误
| 错误理解或写法 | 现象或报错 | 原因 | 正确做法 |
|---|---|---|---|
| 迁移学习时忘记冻结预训练层参数。 | 训练速度慢,容易过拟合,新任务的小数据无法有效更新庞大的参数。 | 所有参数都在更新,破坏了预训练好的通用特征。 | 加载模型后,立即循环设置 param.requires_grad = False 冻结参数,再修改分类头。 |
替换分类头后,优化器仍传入 model.parameters()。 | 能运行,冻结层也不会被更新;但语义不清,后续一旦解冻或调整参数组,容易把不该训的参数也训了。 | 优化器只按 requires_grad 决定是否更新某个参数,把全部参数传进去会掩盖「哪些该训」的意图;requires_grad=False 的参数反向传播后 .grad 为 None,本身不占梯度内存。 | 用 filter(lambda p: p.requires_grad, model.parameters()) 显式只传可训练参数。 |
| 微调时学习率设置得与预训练时一样大。 | 模型性能震荡甚至下降,破坏预训练权重。 | 预训练权重已接近一个较好的局部最优,需要更小的步长(学习率)进行精细调整。 | 对微调的层使用较小的学习率(如 1e-4, 1e-5),远小于从头训练时的学习率(如 1e-2)。 |
可视化时未将模型设置为 eval() 模式。 | 如果模型包含 Dropout 或 BatchNorm,其随机行为会导致每次前向传播的特征图不一致,可视化结果不稳定。 | Dropout 会随机丢弃激活;BatchNorm 在训练模式下用当前 mini-batch 的均值/方差归一化、并更新 running 统计量,同一张输入会随批次组合不同而输出不同(调用 eval() 后才固定使用训练阶段累积的移动统计量)。 | 在提取特征图进行可视化前,务必调用 model.eval()。 |
本章小结
- 特征图可视化让我们能直观看到 CNN 每一层学习到的特征模式,从低级的边缘到高级的语义部件。
- 迁移学习基于卷积网络特征的层次性:浅层通用,深层专用。通过冻结浅层、微调深层,可以高效地将大模型适配到小数据集新任务上。
- 在 PyTorch 中,使用
register_forward_hook可以捕获中间层输出用于可视化。 - 迁移学习的关键操作是设置参数的
requires_grad属性,并确保优化器只更新需要训练的参数。 - 微调(Fine-tuning)时,应使用比预训练更小的学习率,以保护已有的知识表征。
📋 行动清单
- 运行特征图提取代码,把钩子从
conv2改挂到conv1,对比两层的通道数与空间尺寸,理解「越深越抽象、越小」。 - 修改迁移学习代码,解冻
conv1与conv2两组卷积,比较可训练参数占比的变化。 - (进阶)思考:如果新任务的数据是灰度图(1 通道),而预训练模型第一层是 3 通道(RGB),该如何处理输入或改造第一层?
延伸阅读
本段 PyTorch 官方文档/教程条目的框架出处:PyTorch: An Imperative Style, High-Performance Deep Learning Library(Paszke et al., 2019,arXiv:1912.01703);页面内容以 PyTorch 2.14.1 为准。
Deep Residual Learning for Image Recognition(He et al., 2015, arXiv:1512.03385)—— ResNet 原始论文,本节「换成真实预训练模型」时用的就是它 - https://arxiv.org/abs/1512.03385How transferable are features in deep neural networks?(Yosinski et al., 2014, NeurIPS, arXiv:1411.1792)—— 系统实验揭示深度网络特征的可迁移性随深度如何变化 - https://arxiv.org/abs/1411.1792- PyTorch 官方教程
Transfer Learning for Computer Vision Tutorial—— 使用 torchvision 预训练模型的完整迁移学习示例 - https://pytorch.org/tutorials/beginner/transfer_learning_tutorial.html
—— 小象教研组