# 勤奋的凯尔森同学10000h刻意练习Day23:GAN、训练优化全攻略与架构演进收官 —— 生成对抗、优化器到DenseNet与ViT

所属专栏: 凯尔森的10000h刻意练习
Slogan: 一个36岁程序员, 研究生(学硕),前世界500强top20软件开发Java工程师,用10000小时深耕AI, 目标Top10 AI资深工程师, 全程日更记录。

猫的图片能认了,可网络能不能反过来——从噪声里"长"出一只猫?又有哪些优化器和架构能让它走得更远?

Day22把评估、可解释和视觉应用讲透了。今天收官深度学习图像主线,三块拼图一次拼齐:第一,GAN——让两个网络一个造假、一个鉴别,在博弈中学会从噪声生成图像;第二,训练优化全攻略——从vanilla SGD到AdamW的六种优化器全景,再到LR Finder、标签平滑、Mixup/CutMix和梯度裁剪等现代训练技巧;第三,架构演进——ResNet之后的DenseNet把特征复用到极致、EfficientNet三个维度一起缩放、ViT干脆丢掉卷积把图当句子。从感知机到ViT,卷积视觉的整条骨干完整收束。

# 1.1 对抗思想:造假者与鉴定师的军备竞赛

GAN由两个网络组成,二者目标完全对立:

  • 生成器G(Generator):输入随机噪声(通常是标准正态向量),输出一张假图。它的目标是让假图骗过判别器;
  • 判别器D(Discriminator):输入一张图(真图或G的假图),输出"这是真图"的概率。它的目标是把真图判1、假图判0。

训练是一场零和博弈:D每次升级,G就面临更强的"考试",被迫画出更逼真的假图;G每次进步,D又被迫变得更敏锐。理想均衡下,G产出的假图与真图分布完全一致,D只能靠抛硬币(输出0.5)。Goodfellow把它类比为造假钞的罪犯和验钞的警察:警察越厉害,活下来的假钞越逼真。

数学上是一个极小极大(minimax)游戏:

  • D要最大化两项:真图得分高、假图得分低(即小,大);
  • G要最小化第二项:让假图得分高()。实践中G实际优化,梯度更强(早期假图太假时项梯度极小)。

# 1.2 DCGAN:用卷积搭出造假者与鉴定师

2015年的DCGAN把GAN与CNN结合,确立了沿用至今的架构纪律:G用转置卷积(Day22自编码器里用过)上采样,D用跨步卷积下采样;两者都用BatchNorm;G用ReLU、末端Tanh/Sigmoid,D用LeakyReLU。今天的实现针对28×28灰度图:

import torch, torch.nn as nn
import numpy as np

NZ = 100   # 噪声向量维度

class Generator(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc = nn.Linear(NZ, 128 * 7 * 7)        # 噪声 -> 128张7x7特征图
        self.net = nn.Sequential(
            nn.ConvTranspose2d(128, 64, 4, stride=2, padding=1),  # 7 -> 14
            nn.BatchNorm2d(64), nn.ReLU(),
            nn.ConvTranspose2d(64, 1, 4, stride=2, padding=1),   # 14 -> 28
            nn.Sigmoid())                                # 像素值压到[0,1]
    def forward(self, z):
        h = torch.relu(self.fc(z)).view(-1, 128, 7, 7)
        return self.net(h)

class Discriminator(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Conv2d(1, 64, 4, stride=2, padding=1), nn.LeakyReLU(0.2),  # 28->14
            nn.Conv2d(64, 128, 4, stride=2, padding=1),
            nn.BatchNorm2d(128), nn.LeakyReLU(0.2),                        # 14->7
            nn.Flatten(), nn.Linear(128 * 7 * 7, 1))      # 不加Sigmoid, 用BCEWithLogitsLoss
    def forward(self, x):
        return self.net(x).view(-1)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

判别器是个二分类器,但末端不加Sigmoid,配合BCEWithLogitsLoss数值更稳;LeakyReLU(负半轴保留0.2斜率)是GAN的经验标配,避免稀疏梯度让G学不动。

# 1.3 训练循环:一场交替升级的博弈

GAN训练的关键纪律是每步先训D再训G,两者各用各的目标:

G, D = Generator().to(device), Discriminator().to(device)
optG = torch.optim.Adam(G.parameters(), lr=2e-4, betas=(0.5, 0.999))
optD = torch.optim.Adam(D.parameters(), lr=2e-4, betas=(0.5, 0.999))
bce = nn.BCEWithLogitsLoss()

for x, _ in loader:                       # x: 一个batch真图
    bs = len(x); x = x.to(device)
    ones = torch.ones(bs, device=device)
    zeros = torch.zeros(bs, device=device)

    # --- 判别器: 真图判1, 假图判0 ---
    z = torch.randn(bs, NZ, device=device)
    fake = G(z).detach()                  # detach: 只训D, 不让梯度流进G
    optD.zero_grad()
    lossD = bce(D(x), ones) + bce(D(fake), zeros)
    lossD.backward(); optD.step()

    # --- 生成器: 让D把新造的假图判成1 ---
    z = torch.randn(bs, NZ, device=device)
    optG.zero_grad()
    lossG = bce(D(G(z)), ones)            # 标签是1: 越像真的越好
    lossG.backward(); optG.step()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

三个工程细节:Adam的取0.5而非默认0.9(DCGAN论文发现默认值会让训练震荡);D的损失是"真图项+假图项"之和;训练D时用detach()切断到G的梯度,否则白白给G算一遍反向。

# 1.4 真实训练:看着噪声长成衣服

在60000张FashionMNIST上训练20个epoch(Apple M4 MPS,每epoch约18秒),固定同一组64维噪声向量,在第1、5、10、20个epoch后各采样一次:

ep00 D(真图)=0.848 D(假图)=0.146 G损失=2.424
ep05 D(真图)=0.755 D(假图)=0.244 G损失=1.794
ep10 D(真图)=0.742 D(假图)=0.258 G损失=1.755
ep19 D(真图)=0.741 D(假图)=0.258 G损失=1.787
1
2
3
4

DCGAN训练动态曲线

生成器输出的进化:第1/5/10/20个epoch

第1个epoch的输出只是灰雾中的色块;到第5个epoch已经出现鞋形轮廓;第20个epoch的64个样本里,大部分已经是轮廓合理、有明暗质感的衣裤鞋包——注意,这些图在训练集里一张都不存在,它们是G从随机噪声中"创造"的。

判别器输出的读数是GAN的体检仪:开局D轻松碾压(D对真图0.85、假图0.15);随着G进步,D对假图的给分从0.15爬升到0.26,之后三条曲线全部进入平台期。这里要诚实指出一个GAN的常见现实:理论均衡点是D输出0.5,而我们的小网络训20个epoch后停在0.26——D仍然略占上风,并没有达到"完全分不清"。但看采样图,视觉质量已经很好;这种"指标没到理论值、图却已经能用"的现象在GAN实践中非常普遍,也再次说明下一节的判读原则。最终64张随机生成图:

训练完成后随机噪声生成的64张图

# 1.5 GAN为什么出名地难训

监督学习的损失会单调下降,GAN的损失不会——它是博弈,不是优化同一个函数。两个经典失败模式:

  • D太强:若D判别力碾压G,G收到的梯度几乎为零(假图全被 confidently 判0),G停止学习。对策:控制D/G训练步数比例、用LeakyReLU、标签平滑(真标签用0.9而非1.0);
  • 模式崩溃(mode collapse):G发现某一种图总能骗过D,就反复生成几乎相同的样本(比如只会画一种鞋)。对策:小batch、BN、minibatch discrimination、换用WGAN等改进损失。

判断GAN训得好不好,不要盯着G的loss数值(它没有"越低越好"的意义),要看固定噪声的采样图和D输出是否稳定在0.5附近。本次实验属于"D略强但G已可用"的健康态,且没有出现模式崩溃——最终64张样本里运动鞋、短靴、长裤、毛衣、T恤、外套都有,同一批噪声长出了多种类别。GAN的工程经验比任何单一架构都重要,这也是后来StyleGAN、扩散模型不断改进训练稳定性的原因。

# 1.6 风格迁移:把内容与风格拆开优化

同一年代另一条生成路线是2015年Gatys的神经风格迁移(Neural Style Transfer),它不训练新网络,而是直接优化一张图像本身。核心洞察来自预训练VGG(Day21见过)的分层特征:深层特征捕捉"画的是什么"(内容),浅层特征的纹理统计(Gram矩阵)捕捉"用什么笔触画的"(风格)。

做法:初始化一张噪声图,冻结VGG,定义两个损失对(不是对网络权重!)做梯度下降:

其中Gram矩阵是特征图展平后自己乘自己(),统计"哪些纹理模式总是同时出现",它不关心空间位置——所以风格能从星夜的旋涡迁移到你的照片上而内容不乱。核心代码不到30行:

# 伪代码骨架: 优化对象是图像pixels而非模型参数
x = torch.randn(1, 3, 256, 256, device=device, requires_grad=True)
optimizer = torch.optim.LBFGS([x])
for step in range(300):
    def closure():
        optimizer.zero_grad()
        fx, fc = vgg_deep(x), vgg_deep(content_img)
        loss_content = ((fx - fc.detach())**2).mean()
        loss_style = 0
        for l in range(5):
            gx = gram(vgg_features[l](x)); gs = gram(vgg_features[l](style_img).detach())
            loss_style += ((gx - gs)**2).mean()
        loss = 1.0 * loss_content + 1e6 * loss_style
        loss.backward(); return loss
    optimizer.step(closure)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

这是"把预训练网络当固定的特征提取器、反向优化输入"的独特范式,后来的DeepDream、对抗样本攻击、文本到图像生成,用的都是同一思想的变体。

# 1.7 生成模型谱系与明天预告

到今天为止,生成模型的四代路线全部亮相:

模型 思想 我们的实践
自编码器AE 压缩再重建 Day22:去噪MSE降到污染的1/7
VAE(变分自编码器) 让瓶颈向量服从正态分布,重参数化采样出新图 概念了解
GAN 生成器与判别器对抗博弈 今天:20epoch从噪声长出服装
扩散模型 学"逐步去噪",从纯噪声迭代出图像 Day22手工去噪器的极致版(Stable Diffusion)

到这里,生成模型的四代路线已全部亮相,但深度学习主线还剩两块没补:一是GAN里悄悄用了Adam却没解释它怎么来的、lr该取多少没有系统的找法;二是ResNet之后DenseNet和ViT是怎么演化的。明天Day23先把训练优化这块一次讲透——从SGD到AdamW的三次思想跃迁,再到LR Finder、标签平滑、Mixup、梯度裁剪这套"调参五件套",每一段都配真实实验。

# 1.8 起点:vanilla SGD的痛点

Day18已经写过最朴素的随机梯度下降(SGD):

是当前batch算出的梯度,是学习率。它简单到只有一行,但两个痛点在真实训练里非常刺手:

  • 梯度噪声大:一个batch(比如256张图)估计的梯度方向和全数据集的真实方向有偏差,参数在最优点附近来回弹跳,loss曲线像心电图(Day22固定lr那条就是典型);
  • 一个lr管所有参数:某些特征出现稀疏(比如NLP里低频词),这些参数的梯度时有时无,用一个全局lr意味着要么高频参数学太快、要么低频参数学不动。

后续所有改进都在解决这两件事:第一件靠加"惯性",第二件靠给每个参数"按自己的节奏"调学习率。

本节定位: §1.9–§1.12是优化器脉络的精简回顾(SGD/Momentum/AdaGrad/RMSProp/Adam的完整公式推导见Day18 §1.2,峡谷地形上的五条轨迹对比见Day20 §1.4.2);Day18/20没展开的GAN里Adam的betas(§1.12)与AdamW(§1.13)是本节的新内容,§1.14用真实CNN做六器横评。

# 1.9 Momentum:给参数加惯性

物理直觉是把优化过程想象成一个小球沿loss曲面向谷底滚:vanilla SGD是"每一步只看当前坡度,方向随时突变";加动量后小球有了惯性,即使当前坡度有起伏,之前的速度还能带着它穿过去:

常取0.9,含义是"当前速度=10%当下梯度+90%历史速度"。它本质是对最近10步梯度做指数加权平均,起到三个作用:加速在一致方向上的前进、抑制震荡方向上的弹跳、在低曲率长谷里获得冲量。

Day21训CIFAR-10、Day22调学习率调度,用的都是SGD(momentum=0.9),但当时一笔带过没讲原理。补上这一句:momentum是PyTorch里SGD的一个参数,不是另一个优化器类——torch.optim.SGD(lr=, momentum=0.9)就启用了它,如果再加nesterov=True则是Nesterov动量(先按惯性走一步再算梯度,曲率前瞻)。

# 1.10 AdaGrad:第一次自适应学习率

2011年的AdaGrad针对第二痛点:用历史梯度平方和做分母,给每个参数按自己的节奏调学习率(完整公式与NumPy实现见Day18 §1.2.4)。出现频繁的特征累积快、分母大、学习率自动变小;稀疏特征小、学习率保持大——第一次让每个参数有了自己的节奏,对稀疏数据(早期NLP词向量)效果显著。

致命缺陷:只增不减,学习率随时间单调递减,训练后期所有参数学习率趋近0,网络"冻死"在到达最优点之前。这个缺陷催生了RMSProp。

# 1.11 RMSProp:用滑动平均避免冻死

Hinton在2012年提出的RMSProp,把AdaGrad的"累积平方和"换成"指数移动平均(EMA)"(公式见Day18 §1.2.5):分母只记最近约100步的梯度尺度(常取0.99),更早的忘掉。这样既保留自适应学习率,又不会无限累积——把AdaGrad"单调衰减冻死"的病治好了,是深度学习早期主力自适应优化器。

# 1.12 Adam:Momentum + RMSProp的合体

2015年的Adam把前两条线合一:管动量(来自Momentum),管自适应学习率(来自RMSProp)。

两个细节:

  • 偏差修正(帽子项)的作用:初期,前几步的EMA严重偏小,会让前几个batch的步长异常小。修正项在很小时也接近0,把、放大回正常量级——这是Adam能快速起步的关键。
  • 的默认值来自论文:管动量(近10步平均),管尺度(近1000步平均,因为梯度平方更平稳,平均窗口可以更长)。

这就是Day23 GAN代码里Adam(lr=2e-4, betas=(0.5,0.999))那行里两个数:0.999是沿用默认,0.5是把从0.9降到0.5——DCGAN论文发现动量太强会让GAN训练震荡,减弱一阶动量后训练更稳。

# 1.13 AdamW:把权重衰减从梯度里解耦

Adam默认的weight_decay实现有个长期被忽视的bug:L2正则本意是"把权重往0拉",但Adam把它和梯度混在一起进了自适应分母,导致权重衰减的力度被梯度尺度调制,效果和纯SGD上的L2不等价。

2017年的AdamW把这件事拆开:权重衰减不再加在梯度里,而是直接作用在参数更新步上:

是weight_decay系数。这个"解耦权重衰减"(decoupled weight decay)让正则强度与梯度自适应独立,在固定weight_decay下跨lr迁移性好、调参更省心——这也是它成为Transformer/LLM训练标配的原因(GPT、LLaMA、ViT几乎清一色AdamW)。

一句话记忆:Adam=动量+自适应+偏差修正;AdamW=Adam+解耦权重衰减。torch.optim.AdamW就是它的实现。

# 1.14 实验:同一个CNN,六种优化器各跑10个epoch

实验设置:在FashionMNIST上用同一个轻量CNN(Conv-BN-ReLU-Conv-BN-ReLU-Pool ×2 + AdaptiveAvgPool + Linear,约38万参数),固定种子42,每种优化器各训10个epoch,batch=256,Apple M4 MPS后端。为公平对比,学习率取各优化器的典型推荐值(SGD=0.1,Momentum=0.05,AdaGrad=0.01,RMSProp=0.001,Adam=0.001,AdamW=0.001+wd=1e-4),不额外加学习率调度。

import torch, torch.nn as nn, torch.nn.functional as F
import numpy as np
from torchvision import datasets, transforms
from torch.utils.data import DataLoader

device = torch.device("mps")
tf = transforms.Compose([transforms.ToTensor()])
train_ds = datasets.FashionMNIST("data", train=True,  transform=tf)
test_ds  = datasets.FashionMNIST("data", train=False, transform=tf)
train_loader = DataLoader(train_ds, batch_size=256, shuffle=True)
test_loader  = DataLoader(test_ds,  batch_size=512)

class SmallCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
            nn.Conv2d(32, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
            nn.MaxPool2d(2),                                   # 28->14
            nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
            nn.MaxPool2d(2),                                   # 14->7
            nn.AdaptiveAvgPool2d(1),
        )
        self.fc = nn.Linear(64, 10)
    def forward(self, x):
        return self.fc(self.features(x).flatten(1))

def make_opt(name, params):
    if name == "SGD":      return torch.optim.SGD(params, lr=0.1)
    if name == "Momentum": return torch.optim.SGD(params, lr=0.05, momentum=0.9)
    if name == "AdaGrad":  return torch.optim.Adagrad(params, lr=0.01)
    if name == "RMSProp":  return torch.optim.RMSprop(params, lr=0.001, alpha=0.99)
    if name == "Adam":     return torch.optim.Adam(params, lr=0.001, betas=(0.9, 0.999))
    if name == "AdamW":    return torch.optim.AdamW(params, lr=0.001, betas=(0.9, 0.999),
                                                    weight_decay=1e-4)

@torch.no_grad()
def evaluate(model):
    model.eval()
    correct = sum((model(x.to(device)).argmax(1).cpu() == y).sum().item()
                  for x, y in test_loader)
    return correct / len(test_ds)

def train_one(name):
    torch.manual_seed(42)
    model = SmallCNN().to(device)
    opt = make_opt(name, model.parameters())
    accs = []
    for ep in range(10):
        model.train()
        for x, y in train_loader:
            x, y = x.to(device), y.to(device)
            loss = F.cross_entropy(model(x), y)
            opt.zero_grad(); loss.backward(); opt.step()
        accs.append(evaluate(model))
    return accs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

真实结果(完整运行,每个优化器约83秒):

优化器        final_acc   best_acc   duration
SGD            0.7970     0.8294     84.6s
Momentum       0.8598     0.8633     83.3s
AdaGrad        0.8456     0.8546     83.5s
RMSProp        0.7559     0.7926     83.4s
Adam           0.7723     0.8367     83.1s
AdamW          0.8279     0.8601     83.3s
1
2
3
4
5
6
7

六种优化器对比

诚实读这张表:

  1. Momentum最优,best_acc=0.8633。在10个epoch、38万参数的小规模实验里,调好lr的SGD+momentum收敛最快、最稳。这呼应了一条经典经验:Adam并非在所有任务、所有训练长度下都最优;小模型、短训练里,SGD+momentum+学习率调度(OneCycle/Cosine)往往更好。

  2. 测试准确率曲线明显震荡。Momentum和Adam在ep3~ep9的测试准确率会上下跳动几个点——这不是bug,而是BN运行统计量的滞后(batch=256的BN在评估时用的是训练态的滑动均值,小epoch下尚未稳态)加上小测试集评估的随机性共同造成的。训练损失曲线则平滑单调下降,说明训练本身在收敛,震荡只发生在评估侧。

  3. RMSProp和Adam在这组lr下意外地不稳。两者final_acc都低于Momentum。原因是lr=0.001对自适应优化器在小模型+BN下偏小,起步慢,10个epoch不足以体现后期优势。这也提示:自适应优化器的默认lr是"安全值",未必是某任务的最优值——这也是为什么后面要讲LR Finder。

# 1.15 LR Finder:从1e-6扫到爆炸,找loss下降最快的地方

§1.14里给六种优化器各配了一个"经验默认lr",但默认值未必是当前任务的最优。Leslie Smith在2015年提出的**LR range test(LR Finder)**给了一个不靠玄学的找法:

  • 把模型从随机初始化开始,让lr从一个极小值(如1e-6)随训练步线性或指数增长到很大(如10);
  • 每步记录loss,画成"loss—lr"曲线;
  • 曲线的形状几乎是固定的:小lr区loss高位横移(学不动)→中段loss陡降(开始学)→过大lr处loss爆炸(发散)。
  • 推荐起始lr取曲线最低点对应的lr,或最低点左侧1/10处(Smith经验:最低点本身已接近发散,留出安全余量)。

30行代码就能跑:

import torch, torch.nn.functional as F
from torchvision import datasets, transforms
from torch.utils.data import DataLoader

def lr_finder(model, loader, device, lr_start=1e-6, lr_end=10, n_iter=300):
    opt = torch.optim.SGD(model.parameters(), lr=lr_start)
    factor = (lr_end / lr_start) ** (1.0 / n_iter)   # 每步指数增长
    lrs, losses = [], []
    it = 0
    for x, y in loader:
        x, y = x.to(device), y.to(device)
        loss = F.cross_entropy(model(x), y)
        opt.zero_grad(); loss.backward(); opt.step()
        lrs.append(opt.param_groups[0]["lr"])
        losses.append(loss.item())
        it += 1
        if it >= n_iter: break
        for pg in opt.param_groups:
            pg["lr"] *= factor
        if loss.item() > 4 * min(losses): break      # 发散就停
    return lrs, losses
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

在CIFAR-10的同一个轻量CNN上跑300步,真实曲线长这样:

LR Finder曲线

steepest descent lr≈0.01502 (loss=2.155)
min loss=2.097 at lr=0.02570
推荐起始lr≈0.00257 (min-loss的1/10)
1
2
3

读图三段:lr<0.001时loss在2.3附近横移(对应10类随机的交叉熵,网络几乎没学);0.001~0.025区间loss陡降到2.10;lr再增大loss开始发散。所以这条曲线给出两条信息:下界约0.001(再小没意义),上界约0.025(再大发散)。起始lr取中间或1/10处,0.002~0.01都是合理选择——§1.14给Momentum配的0.05略偏上,这也解释了为什么那条曲线有震荡。

LR Finder的工程价值:把调lr从"试5个值看哪个准"变成"跑一次画条线"。fastai的lr_find()、HuggingFace Trainer的plot_losses底层都是这个思路。

# 1.16 标签平滑:不让网络把话说死

普通交叉熵配one-hot标签,模型会被推向"对真类输出概率1.0"。但标签本身有噪声(标注错误),且1.0意味着"我对这条永远不犯错",这会让置信度过高、对错误样本的梯度也过大。**标签平滑(Label Smoothing)**的做法:

即把真类概率从1.0降到(其余类各分),常用。效果是给真类设了0.9的"天花板",网络不再被推向输出1.0,而是停在0.9附近——一个更诚实的置信度,既抗过拟合,也让模型在校准(置信度与准确率匹配)上表现更好。

PyTorch从1.10起cross_entropy直接支持:

loss = F.cross_entropy(out, y, label_smoothing=0.1)   # 一行开启
1

# 1.17 Mixup:把两张图和它们的标签线性混合

2018年Mixup的思想极端简洁:训练时随机抽两张图,按一个从Beta分布采样的系数线性混合,标签也按同比例混合:

模型不再学"这张是猫",而是学"这张70%是猫30%是狗"。这等于在输入空间做了线性插值生成新样本,是一种强正则。它还能让决策边界更线性、更平滑,对对抗样本的鲁棒性也提升。代价是训练loss曲线不会降得那么漂亮(标签是软的),且通常需要更多epoch才收敛。

def mixup_data(x, y, alpha=0.2):
    lam = np.random.beta(alpha, alpha) if alpha > 0 else 1.0
    idx = torch.randperm(x.size(0), device=x.device)   # 随机配对
    return x, y, y[idx], lam

x, y_a, y_b, lam = mixup_data(x, y, 0.2)
out = model(x)
loss = lam * F.cross_entropy(out, y_a) + (1-lam) * F.cross_entropy(out, y_b)
1
2
3
4
5
6
7
8

# 1.18 CutMix:剪切一块贴过去,保留局部结构

Mixup把整张图按比例混合,会得到一张"两个半透明图叠在一起"的模糊图——局部结构被破坏。2019年的CutMix改为:从另一张图里剪一块矩形区域贴到当前图上,标签按剪切面积比例混合:

其中是二值掩码(剪的区域为1),按剪区域面积比设定。相比Mixup,CutMix保留了完整的局部纹理("这只猫的耳朵来自图a,身体来自图b"),模型学到的仍是真实的局部特征,而不是半透明叠加产生的伪纹理。在多数分类任务上CutMix略优于Mixup,且生成的"拼接图"对人类也更好理解。

两者在torchvision.transforms.v2里都有现成实现,也可手写。Mixup/CutMix对ViT尤其有效(Day23会介绍ViT,这类数据增强在长训练大模型上才稳定涨点)。

# 1.19 梯度裁剪:给优化步长设上限

RNN(尤其长序列)和GAN训练中,梯度偶尔会突然爆到几十几百,一步把参数推飞、loss变nan。**梯度裁剪(Gradient Clipping)**的做法是在backward()之后、step()之前,把所有参数梯度的总范数缩放到不超过阈值:

loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
opt.step()
1
2
3

它按整体范数裁剪(clip by norm),不是逐元素裁剪(clip by value)。原理:算所有梯度的拼接向量的范数,若超过max_norm,就按max_norm / ‖g‖等比缩放整个梯度向量——方向不变,只砍长度。这样既防爆,又保住了梯度的相对结构。

实测:在CIFAR-10小CNN上故意把lr开到0.5诱发大梯度,统计80个训练步:

clip前 梯度范数: mean=0.529 max=1.357 p95=0.900
clip后 梯度范数: mean=0.519 max=1.000 p95=0.900
被裁剪的step比例: 4.94%
1
2
3

梯度裁剪效果

读数:正常步的梯度范数在0.5附近波动,max_norm=1.0只切到了约5%的"突刺"——这些正是会引发一次参数飞移的尖峰。clip前最大1.357,clip后封顶1.0,方向不变。这5%的裁剪就是梯度裁剪的全部价值:不挡正常步,只砍异常步。Transformer训练(GPT系列、LLaMA)几乎都带max_norm=1.0,RNN里更是标配。

# 1.20 实验:标签平滑与Mixup到底涨不涨点

在CIFAR-10上用同一个轻量CNN,固定种子42,SGD+momentum+Cosine退火,统一lr=0.05、8个epoch,对比四个配置:

import torch, torch.nn as nn, torch.nn.functional as F

class SmallCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
            nn.Conv2d(32, 32, 3, padding=1), nn.BatchNorm2d( 32), nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
            nn.MaxPool2d(2), nn.AdaptiveAvgPool2d(1),
        )
        self.fc = nn.Linear(64, 10)
    def forward(self, x):
        return self.fc(self.features(x).flatten(1))

def train_config(use_ls=False, mixup_alpha=0.0, epochs=8):
    torch.manual_seed(42)
    model = SmallCNN().to(device)
    opt = torch.optim.SGD(model.parameters(), lr=0.05, momentum=0.9, weight_decay=5e-4)
    sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
    for ep in range(epochs):
        model.train()
        for x, y in train_loader:
            x, y = x.to(device), y.to(device)
            if mixup_alpha > 0:
                lam = np.random.beta(mixup_alpha, mixup_alpha)
                idx = torch.randperm(x.size(0), device=x.device)
                out = model(x)
                loss = lam * F.cross_entropy(out, y, label_smoothing=0.1 if use_ls else 0) \
                     + (1-lam) * F.cross_entropy(out, y[idx], label_smoothing=0.1 if use_ls else 0)
            else:
                loss = F.cross_entropy(model(x), y, label_smoothing=0.1 if use_ls else 0)
            opt.zero_grad(); loss.backward(); opt.step()
        sched.step()
    return evaluate(model)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

真实结果(完整运行):

配置              final_acc   best_acc
baseline           0.6408     0.6408
+label_smooth      0.6480     0.6480    (+0.7pp)
+mixup             0.5357     0.5357    (-10.5pp)
+ls+mixup          0.5303     0.5303    (-11.1pp)
1
2
3
4
5

标签平滑与Mixup对比

诚实读这组结果,它和"涨点技巧"的刻板印象是相悖的:

  1. 标签平滑微涨0.7个点。它只是把one-hot变成软标签,开销为零(一行参数),适合无脑加上。

  2. Mixup在这组配置下反而掉了10个点。这不是bug,而是Mixup的已知特性:它是一种强正则,正则强度和训练长度要匹配。8个epoch对Mixup来说太短——软标签让模型每步学到的信息变少,需要更多epoch才能把"interpolated样本"的知识消化完。Mixup的收益要在长训练(50+ epoch)和大模型(ResNet50/ViT)上才稳定显现,原论文的涨点也是建立在长训练基础上。短训练+小模型用Mixup,等于给一个还没学会走路的网络套上负重。

  3. 正则技巧不是越多越好。+ls+mixup比+mixup还差一点,正则叠加有边际递减。工程上的原则:先确认基线收敛到饱和,再加正则看是否突破瓶颈;在没收敛的基线上叠正则,只会让你以为"模型不行"。

这组实验的目的不是证明这些技巧没用(它们在大模型长训练里确实涨点),而是说明一个常被忽略的道理:"技巧"的收益高度依赖训练规模。§1.14的Momentum最优、§1.20的Mixup掉点,都在提醒同一件事——没有一招通吃,真实实验比论文表格诚实。

# 1.21 DenseNet:每层都和前面所有层连

ResNet的跳连是"隔层相加"——第层的输入是,输出加上之前某层的输出:。相加意味着两条路的信息"混在一起",且梯度反向流动时需要经过加法节点。

2017年DenseNet的改动更激进:让每个稠密块(Dense block)里的每一层都直接接收前面所有层的输出,在通道维上拼接而非相加。

其中是通道维拼接。若每层输出个特征图(称growth rate ),第层的输入通道数是,是进入块时的通道数。

直觉:之前每一层只看"上一层的输出",DenseNet让每一层都能直接访问最原始的特征——它假设信息不会随层数递减,反而要复用。三个直接好处:

  • 参数更省:每层只产出个新特征图(典型),而不是整层重算。DenseNet121只有6.96M参数,比ResNet18(11.17M)少38%。
  • 梯度流动更好:每层都和输入有直接连接,梯度能直通回早期层。
  • 特征复用:同一特征被多层级使用,有研究表明DenseNet学到的特征冗余度低。

代价也很明显:拼接让通道数随层数线性增长,中间特征图的存储占用大、内存访问多——所以DenseNet比同精度的ResNet慢3倍多(后面实验会实证)。为控制通道增长,DenseBlock之间用**过渡层(Transition)**做1×1卷积压缩通道数。

torchvision.models.densenet121直接可用,DenseBlock内部的层用BN-ReLU-Conv(后激活)结构,这也是DenseNet论文的命名——DenseNet = Dense Convolutional Network。

# 1.22 EfficientNet:复合缩放,三个维度一起调

2019年EfficientNet问了一个工程问题:要把网络变大,是加宽(更多通道)、加深(更多层)、还是加分辨率(更大输入图)?

之前的做法是"只调一个":ResNet主要加深,WideResNet主要加宽。EfficientNet论文发现:单纯加深/加宽/加分辨率,精度提升到一定程度就饱和了;三者按特定比例同时放大,精度才持续上升。

论文用(compound coefficient)统一控制:

其中由网格搜索在基线网络EfficientNet-B0上确定(约),从0取到7对应B0~B7。约束条件来自FLOPs近似正比于——固定加1则计算量翻倍。

效果:EfficentNet-B7在ImageNet上top-1准确率84.4%,参数量仅66M,是当年精度/参数比的天花板。它的另一个贡献是基线网络用**神经架构搜索(NAS)**得到,MBConv(倒残差+深度可分离卷积)作为基本块——这也是后来MobileNet v3、ViT后 EfficientNetV2的基础。

一句话记忆:EfficientNet = NAS搜索的基线 + 复合缩放的三维协调。它不是新结构,而是"如何把已有结构放大到最优"的工程方法。

# 1.23 ViT:卷积都不要,把图当句子

2020年ViT(Vision Transformer)做了一次范式转换:彻底放弃卷积,把图像当成一串token序列喂给纯Transformer。

做法分三步:

  1. Patch Embedding:把的图切成个不重叠的小块(patch),每个patch展平后用一个线性层(等价于一个stride=的卷积)映成一个维向量。一幅的图,取,就切成个token,每个token是维。

其中是第个patch的展平向量(维),是的投影矩阵。

  1. 加[CLS] token与位置编码:在序列最前面拼一个可学习的[CLS] token(它的最终输出做分类),再给每个token加上可学习的位置编码,告诉Transformer"这是第几号位置"。

  2. 标准Transformer编码器堆层:每层是多头自注意力(MSA)+MLP,带残差和LayerNorm。自注意力让每个patch能"看到"所有其他patch,等价于学到一个全局的、数据驱动的感受野。

patch embedding的核心代码很短:

import torch, torch.nn as nn

class PatchEmbed(nn.Module):
    def __init__(self, img_size=32, patch_size=4, in_ch=3, embed_dim=64):
        super().__init__()
        self.n = (img_size // patch_size) ** 2          # patch数
        self.proj = nn.Conv2d(in_ch, embed_dim, kernel_size=patch_size,
                             stride=patch_size)         # 等价线性投影
    def forward(self, x):
        x = self.proj(x)                                # (B, D, H/P, W/P)
        return x.flatten(2).transpose(1, 2)           # (B, N, D)
1
2
3
4
5
6
7
8
9
10
11

实测:一幅的CIFAR图,,:

输入: (2, 3, 32, 32) -> 输出tokens: (2, 64, 64)  即 (B, N_patches=64, D)
1

ViT的patch切分

ViT的关键性质:

  • 没有归纳偏置(inductive bias):CNN默认"局部性"和"平移不变"是先验,ViT不带这些先验,只靠自注意力学。这等于把约束放松了——数据多时是优势(能学到比卷积更灵活的关系),数据少时是劣势(没有先验约束容易过拟合)。原论文明确说:ViT在JFT-300M这种超大规模数据集上预训练后才超过ResNet,中等数据集上从头训反而不如CNN。这也是为什么后来DeiT、Swin Transformer都在改进"小数据下的ViT"。
  • 计算量随token数平方增长:自注意力是,是patch数。224×224图取只有196个token还好;高分辨率图token数暴涨,催生了Swin Transformer的窗口注意力把复杂度降回线性。

ViT之后,视觉进入Transformer时代——Swin、DeiT、BEiT、MAE,以及后来的多模态大模型(CLIP、BLIP)都建立在ViT基础上。

# 1.24 实验:DenseNet121 vs ResNet18,CIFAR-10从头训8个epoch

Day21训过ResNet18(15 epoch到0.9024),今天在同一CIFAR-10、同一套增强与标准化下,让DenseNet121和ResNet18在完全相同的配置(SGD+momentum,lr=0.05,Cosine退火,batch=128,seed=42)下各从头训8个epoch,公平对比。DenseNet121的首层7×7步幅2和最大池化都按CIFAR改写,保留分辨率:

import torch, torch.nn as nn, torch.nn.functional as F
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
from torchvision.models import densenet121, resnet18

def make_resnet18_cifar():
    m = resnet18(weights=None, num_classes=10)
    m.conv1 = nn.Conv2d(3, 64, 3, stride=1, padding=1, bias=False)
    m.maxpool = nn.Identity()
    return m

def make_densenet121_cifar():
    m = densenet121(weights=None, num_classes=10)
    m.features.conv0 = nn.Conv2d(3, 64, 3, stride=1, padding=1, bias=False)
    m.features.pool0 = nn.Identity()
    return m

def train_one(make_fn, epochs=8):
    torch.manual_seed(42)
    model = make_fn().to(device)
    opt = torch.optim.SGD(model.parameters(), lr=0.05, momentum=0.9, weight_decay=5e-4)
    sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
    accs = []
    for ep in range(epochs):
        model.train()
        for x, y in train_loader:
            x, y = x.to(device), y.to(device)
            loss = F.cross_entropy(model(x), y)
            opt.zero_grad(); loss.backward(); opt.step()
        sched.step()
        accs.append(evaluate(model))
    return accs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

真实结果(Apple M4 MPS,完整运行):

模型            params   final_acc   best_acc   duration
ResNet18       11.17M     0.8755     0.8755      610.2s
DenseNet121     6.96M     0.8938     0.8938     2021.8s
1
2
3

逐epoch准确率:

ResNet18:    0.5395  0.6461  0.6793  0.7530  0.7975  0.8338  0.8603  0.8755
DenseNet121: 0.5663  0.7141  0.7554  0.7956  0.8371  0.8533  0.8881  0.8938
1
2

DenseNet121 vs ResNet18

读这组数据:

  1. DenseNet121用更少参数胜出:6.96M对11.17M(少38%),但8 epoch准确率0.8938对0.8755(高1.83pp)。这正是DenseNet论文的核心卖点——特征复用让同等精度所需参数更少。看逐epoch曲线,DenseNet在前6个epoch一直领先ResNet约2~4pp,说明特征复用还让收敛更快。

  2. 代价是慢3.3倍:DenseNet121跑8个epoch用了2022秒,ResNet18只用610秒。原因正如§1.21所说:密集拼接让中间特征图通道数线性增长,内存访问成为瓶颈。这是DenseNet在工业落地时没像ResNet那样通吃的根本原因——参数省但计算不省,显存还更吃。

  3. 余弦退火的收益在末段才释放。本次8 epoch的ResNet18到0.8755,而Day21的ResNet18训满15 epoch到0.9024:轮数少一半,准确率低2.7pp。看Day21的逐轮曲线就明白——它第11轮的测试准确率才0.8799,是余弦退火把lr压到很小之后,末3轮连跳到0.9024;本次只训8轮,lr还没退到位就停了,退火红利没兑现完。两次配置接近(Day21是lr=0.1,本次是lr=0.05,增强与调度相同),主要差别在轮数。调度要给它足够的时间把lr退完。

# 1.25 架构演进总结:从LeNet到ViT的20年

把整个系列出场过的架构排成时间线:

年份 架构 关键创新 我们的实践
1998 LeNet 卷积+池化的雏形 Day20手写LeNet在MNIST
2012 AlexNet ReLU+Dropout+GPU训练 Day21训AlexNet(CIFAR)
2014 VGG 小3×3卷积堆叠,统一结构 Day21训VGG11(CIFAR)
2014 GoogleNet/Inception 多尺度并行卷积(1/3/5×5) Day20概念了解
2015 ResNet 跨层跳连,解决退化 Day21训ResNet18(CIFAR), Day22跳连消融
2017 DenseNet 密集连接+特征复用 今天训DenseNet121
2019 EfficientNet NAS基线+复合缩放 概念了解
2020 ViT 图像patch化+纯Transformer 今天patch embedding演示

一条贯穿的主线:网络越来越深,但每一层越来越"会偷懒"——从AlexNet每层重算所有特征,到ResNet隔层相加,到DenseNet跨层复用,再到EfficientNet用NAS自动搜索最优结构,最后ViT干脆放弃卷积的局部先验,让注意力自己学该看哪里。这背后是算力、数据和理论三者的共同演进:算力大了才堆得动深网,数据多了才养得起ViT这种无先验的模型,理论(跳连、BN、注意力)每前进一步都解锁了更深结构的可训性。

# 小结

要点 内容
GAN结构 生成器G(转置卷积)对抗判别器D(跨步卷积)
目标 minmax博弈, 均衡时D输出0.5
训练纪律 先D后G各一步;Adam β1=0.5;D末端不加Sigmoid;假图detach
DCGAN规范 G用BN+ReLU, D用LeakyReLU, 100维噪声起步
体检方式 看固定噪声采样图, 不看G loss数值
失败模式 D过强→G学不动; 模式崩溃→只会画一种图
风格迁移 冻结VGG, 优化图像本身:内容特征+Gram纹理
谱系 AE→VAE→GAN→扩散
优化器 公式要点 解决了什么 注意
SGD 起点 梯度噪声大, 需调lr
Momentum 加惯性, 抑制震荡 SGD的一个参数
AdaGrad 累积平方和做分母 自适应lr, 利好稀疏特征 lr单调递减会冻死
RMSProp EMA平方做分母 修正AdaGrad冻死 早期主力
Adam Momentum+RMSProp+偏差修正 合体, 起步快 默认lr未必最优
AdamW Adam+解耦权重衰减 正则强度与自适应独立 LLM/Transformer标配
技巧 一句话原理 适用 注意
LR Finder lr扫描找loss最陡处 任何新任务起步 起始lr取最低点1/10
标签平滑 one-hot→(1-ε)+ε/K 分类几乎无脑加 ε=0.1常见
Mixup 两图线性混合+软标签 大模型长训练 短训练反而掉点
CutMix 剪块拼接保留局部 分类、检测 多数情况略优于Mixup
梯度裁剪 砍梯度总范数尖峰 RNN、GAN、Transformer max_norm=1.0常见

一句话决策:小模型短训练优先SGD+momentum+调度,大模型长训练(尤其Transformer类)用AdamW;调参起步先跑LR Finder,分类任务默认开label_smoothing=0.1,大模型长训练叠Mixup/CutMix,Transformer/RNN/GAN必带梯度裁剪;GAN特殊,降到0.5。这套"调参五件套"构成了现代训练优化的标配工具箱。

架构 思想 优势 代价
DenseNet 每层拼接所有前层 参数省, 收敛快, 梯度好 显存吃, 慢3倍
EfficientNet NAS基线+复合缩放 精度/参数比高 工程复杂
ViT 图像patch化+纯Transformer 无局部先验, 数据多时上限高 数据少易过拟合, 平方复杂度

深度学习图像主线,到今天正式收官。从Day16的一个感知机起步,到Day17手撕网络与学习机制,Day18反向传播与训练稳定性,Day19 PyTorch与自动微分,Day20训练核心与CNN,Day21工程化与CIFAR-10实战,Day22评估与可解释,再到今天的GAN、优化全攻略与架构演进——卷积视觉的整条骨干已经完整。后面的路:NLP系列已用Transformer开场(见nlp-day01.md),大模型系列的预训练/微调、多模态的扩散与CLIP,都将建立在这套地基之上。10000小时,继续。

 |  Copyright © -Present Kelsen All Rights Reserved. | Author KelsenJing