查看全课大纲(第 14 / 40 节)
  1. 1.从规则到学习:深度学习解决什么问题
  2. 2.表示学习与端到端训练
  3. 3.张量、计算图与自动微分
  4. 4.训练三要素:数据、模型、目标函数
  5. 5.从感知机到多层感知机
  6. 6.反向传播:手推一个两层网络
  7. 7.激活函数与初始化
  8. 8.优化器:SGD → Momentum → Adam
  9. 9.正则化:权重衰减、Dropout、BN/LN
  10. 10.卷积的直觉与参数共享
  11. 11.池化、感受野与下采样
  12. 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
  13. 13.残差连接与归一化为什么有效
  14. 14.迁移学习与特征可视化
  15. 15.RNN 与 BPTT
  16. 16.LSTM/GRU:门控如何缓解梯度问题
  17. 17.Seq2Seq 与编码器-解码器
  18. 18.注意力前史:Bahdanau 加性注意力
  19. 19.Self-Attention:QKV 与缩放点积
  20. 20.多头注意力:并行的多种关系
  21. 21.位置编码:正弦与可学习
  22. 22.残差、LayerNorm 与前馈层
  23. 23.从零实现一个最小 Transformer block
  24. 24.训练稳定性、复杂度与常见误区
  25. 25.自监督与语言建模目标
  26. 26.BERT:掩码语言模型与双向编码
  27. 27.GPT:自回归生成与缩放定律
  28. 28.指令微调与对齐:SFT → RLHF/DPO
  29. 29.参数高效微调:LoRA 与适配器
  30. 30.自编码器与 VAE
  31. 31.GAN:对抗训练与模式崩塌
  32. 32.扩散模型:前向加噪与反向去噪
  33. 33.扩散模型的应用与采样加速
  34. 34.对比学习与 CLIP
  35. 35.ViT:把图像当序列
  36. 36.多模态架构:对齐、融合与生成
  37. 37.能力边界、幻觉与评测
  38. 38.PyTorch 2.x 训练循环与混合精度
  39. 39.实验管理、评测与复现
  40. 40.推理部署与成本:量化/蒸馏概览

迁移学习与特征可视化

迁移学习与特征可视化:卷积网络到底学到了什么?

小象实战讲义 · 深度学习基础:从神经网络到 Transformer

学完本讲,你将能回答:一个在 ImageNet 上预训练好的卷积网络,其内部特征图(Feature Maps)究竟长什么样?我们如何将这些“知识”迁移到新的、数据量更少的任务上?本节将带你亲手提取并可视化网络中间层的激活,并实现一个简单的迁移学习流程,让你直观理解卷积网络的表征能力。

先修:你应已掌握卷积层、池化层的基本操作,以及如何使用 PyTorch 搭建简单的 CNN(如 LeNet)。后续,我们将进入序列模型章节,探讨循环神经网络(RNN)。

💡 核心导读

  1. 特征图可视化:通过将卷积层的输出(特征图)反向映射回像素空间,我们可以“看到”网络在每一层关注图像的哪些模式(如边缘、纹理、物体部件)。
  2. 迁移学习原理:在大规模数据集(如 ImageNet)上预训练的模型,其浅层学习通用特征(边缘、角点),深层学习任务特定特征。冻结浅层、微调深层,可高效适配新任务。
  3. 实现流程:加载预训练模型 → 提取中间层输出 → 进行可视化或作为新分类器的输入。
  4. 代码实践:用 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)上预训练好的模型,通过少量调整,使其适应新的、通常数据量较小的任务。

为什么有效?—— 特征的层次性

  1. 浅层特征通用:无论任务是识别猫、狗还是汽车,网络前几层学习的边缘、角点、纹理等低级视觉特征都是通用的。
  2. 深层特征专用:网络深层学习的是这些低级特征的特定组合,用于识别高级语义概念(如“车轮”、“猫耳”),这些概念与原始训练任务(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()。

本章小结

  1. 特征图可视化让我们能直观看到 CNN 每一层学习到的特征模式,从低级的边缘到高级的语义部件。
  2. 迁移学习基于卷积网络特征的层次性:浅层通用,深层专用。通过冻结浅层、微调深层,可以高效地将大模型适配到小数据集新任务上。
  3. 在 PyTorch 中,使用 register_forward_hook 可以捕获中间层输出用于可视化。
  4. 迁移学习的关键操作是设置参数的 requires_grad 属性,并确保优化器只更新需要训练的参数。
  5. 微调(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 为准。

  1. Deep Residual Learning for Image Recognition(He et al., 2015, arXiv:1512.03385)—— ResNet 原始论文,本节「换成真实预训练模型」时用的就是它 - https://arxiv.org/abs/1512.03385
  2. How transferable are features in deep neural networks?(Yosinski et al., 2014, NeurIPS, arXiv:1411.1792)—— 系统实验揭示深度网络特征的可迁移性随深度如何变化 - https://arxiv.org/abs/1411.1792
  3. PyTorch 官方教程 Transfer Learning for Computer Vision Tutorial—— 使用 torchvision 预训练模型的完整迁移学习示例 - https://pytorch.org/tutorials/beginner/transfer_learning_tutorial.html

—— 小象教研组