【深度学习Day10】告别瞎调参!优化器与学习率调度器实战指南

48 阅读15分钟

为什么同样的模型,换一个优化器和学习率调度器,训练效果就天差地别?今天为你揭秘。

摘要:上一篇我们靠迁移学习“抄作业”搞定了高准确率模型,但训练时你大概率会遇到:Loss像坐过山车震荡不收敛、训练到一半准确率卡住不动、换个优化器结果天差地别……今天,我带你彻底搞懂训练的“核心引擎”(优化器)和“关键油门”(学习率):用代码可视化对比SGD、Adam、AdamW、Muon四大优化器的收敛轨迹,手把手教你用StepLR、CosineAnnealing等调度器实现“自动换挡”,从此告别瞎调参,让模型训练又快又稳!

关键词:PyTorch, 优化器, 学习率调度器, SGD, Adam, AdamW, Muon, CosineAnnealingLR, 收敛可视化

1. 开篇灵魂拷问:你是不是在“瞎调参”?

Day9我们用微调训练ResNet-18时,我特意指定了SGD+CosineAnnealingLR的组合,当时你可能没多想——但如果把优化器换成Adam,学习率固定为0.1,你会发现:要么Loss直接炸到NaN,要么训练20个Epoch准确率还没到80%。

这就是优化器和学习率的“威力”:它们决定了模型“学习的效率”和“最终的上限”。很多新手调参全靠蒙:

  • Loss震荡?就随便把学习率砍一半;
  • 收敛慢?就盲目换成Adam;
  • 过拟合?就乱加权重衰减;

本质是没搞懂:不同优化器的“脾气”不同,学习率的“调节逻辑”也不同。今天我们不聊枯燥公式,用“开车”类比搞懂一切:

  • 优化器:相当于汽车的“引擎”——SGD是“手动挡引擎”(稳但需要技巧),Adam是“自动挡引擎”(快但可能刹不住);
  • 学习率:相当于“油门大小”——太大容易“冲出路基”(Loss震荡),太小“爬坡太慢”(收敛慢);
  • 调度器:相当于“自动换挡系统”——根据训练进度自动调油门,不用手动干预。

2. 四大优化器深度解析:脾气、用法、避坑点

我们聚焦工业界最常用的四大优化器:SGD(含动量)、Adam、AdamW、Muon,从“通俗原理”“优缺点”“适用场景”三个维度讲透,还附MATLAB老鸟的踩坑总结。

2.1 SGD(随机梯度下降):稳如老狗的“手动挡”

原理通俗说

最基础的优化器,核心逻辑就是“朝着Loss下降最快的方向走一步”。就像你开车,每次只看眼前的路,朝着下坡方向开——虽然慢,但不会跑偏。

但纯SGD有个坑:容易卡在“局部小山坡”(局部最优解)。所以实际用的都是SGD+动量(momentum):相当于开车带了“惯性”,就算遇到小土坡,也能靠惯性冲过去。

优缺点

  • 优点:收敛稳定、泛化能力强(不容易过拟合)、显存占用最小(适合大模型);
  • 缺点:收敛慢、需要手动调学习率、对初始化敏感。

适用场景&踩坑点

✅ 适用:数据量大(如CIFAR-10、ImageNet)、需要高泛化(比如分类任务)、大模型训练;

❌ 踩坑:千万别用太大的学习率(比如0.1以上),尤其是微调预训练模型时,容易冲掉预训练权重;动量一般设0.9(默认值就够用)。

2.2 Adam:快到飞起的“自动挡”

原理通俗说

Adam=SGD+动量+自适应学习率。相当于给汽车装了“智能导航+自动油门”:不仅能靠惯性冲坡,还能根据路况自动调油门大小——在平坦路段(Loss变化小)加大油门,在颠簸路段(Loss波动大)减小油门。

优缺点

  • 优点:收敛速度极快(比SGD快2-3倍)、对学习率不敏感(默认0.001就够用)、适合小数据;
  • 缺点:泛化能力略差(容易过拟合)、显存占用比SGD高、部分任务最终准确率不如SGD。

适用场景&踩坑点

✅ 适用:数据量小(如几百张图片)、快速验证模型可行性、微调小模型;

❌ 踩坑:训练大模型时别用Adam!显存会比SGD多占20%-30%;遇到过拟合时,优先加权重衰减,而不是盲目调学习率。

2.3 AdamW:Adam的“修复版”,工业界首选

原理通俗说

Adam有个致命缺陷:权重衰减(L2正则)是“加在梯度上”的,导致正则效果打折扣。AdamW直接把权重衰减“独立出来”,相当于给Adam加了个“精准刹车”——既保留了Adam的快,又有了SGD的稳。

优缺点

  • 优点:兼顾Adam的快和SGD的稳、泛化能力强、对多数任务都友好;
  • 缺点:显存占用比SGD高(和Adam差不多)。

适用场景&踩坑点

✅ 适用:绝大多数场景(分类、检测、分割)、尤其是预训练模型微调;

❌ 踩坑:PyTorch里AdamW的默认学习率是0.001,微调时建议降到1e-4(和SGD同理,避免冲掉预训练权重)。

2.4 Muon:小众但高效的“性能级引擎”

原理通俗说

Muon是较新的优化器,专门针对神经网络隐藏层的二维权重参数设计,核心是“动态调整动量和学习率”——比Adam更灵活,比SGD更快。相当于给汽车装了“AI导航”,能提前预判路况,调整油门和档位。

优缺点

  • 优点:收敛速度比Adam快、泛化能力接近SGD、对复杂任务(大模型训练)表现好;
  • 缺点:需要最新版本的pytorch(2.9.0)。

适用场景&踩坑点

✅ 适用:复杂任务(医疗影像、工业缺陷检测)、想追求更快收敛又怕过拟合;

❌ 踩坑:需要版本号12.6以上的cuda,只能处理神经网络的二维权重参数。

3. 学习率调度器:让模型“自动换挡”的神器

就算选对了优化器,固定学习率也会遇到问题:前期学习率太小,收敛慢;后期学习率太大,Loss震荡不收敛。调度器的作用就是“动态调学习率”——前期加大油门冲,后期减小油门稳,不用手动干预。

我们重点讲3个工业界最常用的调度器,还是用“开车”类比:

3.1 StepLR:固定节奏“降档”

原理通俗说

每训练step_size个Epoch,就把学习率乘以gamma(衰减系数)。比如step_size=10, gamma=0.1:前10个Epoch用初始学习率,11-20个Epoch用0.1倍学习率,21-30个用0.01倍——相当于固定每开10公里降一档。

关键代码

# 每10个Epoch学习率衰减为原来的0.1
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1)

适用场景

适合训练节奏清晰的任务(如CIFAR-10分类),优点是简单粗暴、易理解,缺点是不够灵活(不管Loss有没有收敛都降档)。

3.2 CosineAnnealingLR:模拟余弦曲线“平滑换挡”

原理通俗说

学习率随Epoch按“余弦曲线”变化:先从初始值慢慢降到0,再慢慢升回来(可选)。就像开车时根据坡度平滑调整油门,而不是猛踩或猛松——【深度学习Day9】我们用的就是这个,能有效避免后期Loss震荡。

关键代码

# T_max:学习率从最大降到最小的Epoch数(一般设为总Epoch数)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20)

适用场景

几乎所有任务(尤其是预训练模型微调),优点是收敛稳定、最终准确率高,缺点是需要提前确定总Epoch数。

3.3 ReduceLROnPlateau:根据“路况”智能换挡

原理通俗说

不按固定Epoch降档,而是监控某个指标(如验证集准确率):如果指标连续patience个Epoch没提升,就自动降低学习率。相当于汽车的“自适应巡航”,根据实际路况调整油门,最智能的调度器。

关键代码

# 监控验证集准确率,连续5个Epoch没提升就降档(gamma=0.1)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='max', patience=5, gamma=0.1)

适用场景

适合不知道总Epoch数、任务复杂(如检测、分割),优点是智能、不用手动调Epoch,缺点是需要每次Epoch都评估验证集(多花一点时间)。

4. 代码实战:可视化对比+调度器实战

我们用【深度学习Day9】的“魔改ResNet-18+CIFAR-10”任务,做两个核心实战:① 可视化四大优化器的收敛轨迹;② 对比不同调度器的效果。代码可直接复制运行。

4.1 实战1:四大优化器收敛轨迹可视化

import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms
from torchvision.models import resnet18, ResNet18_Weights
import time

# ========== 环境配置+数据准备 ==========
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
print(f"使用设备:{device}")
batch_size = 128
epochs = 20
lr = 0.01  

# 数据预处理
transform_train = transforms.Compose([
    transforms.RandomCrop(32, padding=4),
    transforms.RandomHorizontalFlip(),
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])
transform_test = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])

trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=batch_size, shuffle=True)
testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform_test)
testloader = torch.utils.data.DataLoader(testset, batch_size=100, shuffle=False)

# ========== 定义魔改ResNet-18(复用Day9代码) ==========
def get_modified_resnet18():
    model = resnet18(weights=ResNet18_Weights.DEFAULT)
    model.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False)
    model.maxpool = nn.Identity()
    num_ftrs = model.fc.in_features
    model.fc = nn.Linear(num_ftrs, 10)
    return model.to(device)

# ========== 拆分2D/非2D参数的函数(适配Muon) ==========
def split_muon_adam_params(model):
    muon_params = []  # 2D参数(仅全连接层权重)
    adam_params = []  # 非2D参数(卷积层/偏置)
    for name, param in model.named_parameters():
        if param.dim() == 2:
            muon_params.append(param)
        else:
            adam_params.append(param)
    return muon_params, adam_params

# ========== 测试集评估函数 ==========
def evaluate_test(model, testloader, device):
    model.eval() 
    correct = 0
    total = 0
    with torch.no_grad():  
        for inputs, labels in testloader:
            inputs, labels = inputs.to(device), labels.to(device)
            outputs = model(inputs)
            _, predicted = outputs.max(1)
            total += labels.size(0)
            correct += predicted.eq(labels).sum().item()
    test_acc = 100. * correct / total
    return test_acc

# ========== 重定义训练函数(支持组合优化器) ==========
def train_optimizer(model, optimizer, criterion, trainloader, testloader, epochs, device, optimizer_name, schedulers=None):
    model.train()
    loss_history = [] 
    train_acc_history = []  
    test_acc_history = []   
    total_time = 0.0   
    max_memory = 0.0  
    for epoch in range(epochs):
        epoch_start = time.time()
        running_loss = 0.0
        correct = 0
        total = 0
        model.train()
        for i, (inputs, labels) in enumerate(trainloader):
            inputs, labels = inputs.to(device), labels.to(device)
            if isinstance(optimizer, list):
                for opt in optimizer:
                    opt.zero_grad()
            else:
                optimizer.zero_grad()
            outputs = model(inputs)
            loss = criterion(outputs, labels)
            loss.backward()
            if isinstance(optimizer, list):
                for opt in optimizer:
                    opt.step()
            else:
                optimizer.step()
            running_loss += loss.item()
            _, predicted = outputs.max(1)
            total += labels.size(0)
            correct += predicted.eq(labels).sum().item()
        epoch_loss = running_loss / len(trainloader)
        train_acc = 100. * correct / total
        # 计算当前Epoch测试准确率
        test_acc = evaluate_test(model, testloader, device)
        epoch_time = time.time() - epoch_start
        loss_history.append(epoch_loss)
        train_acc_history.append(train_acc)
        test_acc_history.append(test_acc)
        total_time += epoch_time

        if schedulers is not None:
            if isinstance(schedulers, list):
                for sch in schedulers:
                    sch.step()
            else:
                schedulers.step()
    return loss_history, train_acc_history, test_acc_history, total_time, max_memory

# ========== 初始化四大优化器+训练 ==========
results = {}
criterion = nn.CrossEntropyLoss()

# 1. SGD(无动量)
print("\n===== 开始训练:SGD(无动量)=====")
model_sgd = get_modified_resnet18()
optimizer_sgd = optim.SGD(model_sgd.parameters(), lr=lr)
scheduler_sgd = optim.lr_scheduler.CosineAnnealingLR(optimizer_sgd, T_max=epochs)
loss_sgd, train_acc_sgd, test_acc_sgd, time_sgd, mem_sgd = train_optimizer(
    model_sgd, optimizer_sgd, criterion, trainloader, testloader, epochs, device, "SGD", scheduler_sgd
)
results["SGD"] = (loss_sgd, train_acc_sgd, test_acc_sgd, time_sgd, mem_sgd)

# 2. SGD+momentum
print("\n===== 开始训练:SGD+momentum=====")
model_sgd_mom = get_modified_resnet18()
optimizer_sgd_mom = optim.SGD(model_sgd_mom.parameters(), lr=lr, momentum=0.9, weight_decay=5e-4)
scheduler_sgd_mom = optim.lr_scheduler.CosineAnnealingLR(optimizer_sgd_mom, T_max=epochs)
loss_sgd_mom, train_acc_sgd_mom, test_acc_sgd_mom, time_sgd_mom, mem_sgd_mom = train_optimizer(
    model_sgd_mom, optimizer_sgd_mom, criterion, trainloader, testloader, epochs, device, "SGD+momentum", scheduler_sgd_mom
)
results["SGD+momentum"] = (loss_sgd_mom, train_acc_sgd_mom, test_acc_sgd_mom, time_sgd_mom, mem_sgd_mom)

# 3. AdamW(工业界首选)
print("\n===== 开始训练:AdamW=====")
model_adamw = get_modified_resnet18()
optimizer_adamw = optim.AdamW(model_adamw.parameters(), lr=lr*0.1) 
scheduler_adamw = optim.lr_scheduler.CosineAnnealingLR(optimizer_adamw, T_max=epochs)
loss_adamw, train_acc_adamw, test_acc_adamw, time_adamw, mem_adamw = train_optimizer(
    model_adamw, optimizer_adamw, criterion, trainloader, testloader, epochs, device, "AdamW", scheduler_adamw
)
results["AdamW"] = (loss_adamw, train_acc_adamw, test_acc_adamw, time_adamw, mem_adamw)

# 4. Muon+Adam(拆分2D/非2D参数)
print("\n===== 开始训练:Muon+Adam=====")
model_muon = get_modified_resnet18()
muon_params, adam_params = split_muon_adam_params(model_muon)
optimizer_muon = optim.Muon(muon_params, lr=lr*0.1)  
optimizer_adam = optim.Adam(adam_params, lr=lr*0.1)
optimizer_comb = [optimizer_muon, optimizer_adam]
scheduler_muon = optim.lr_scheduler.CosineAnnealingLR(optimizer_muon, T_max=epochs)
scheduler_adam = optim.lr_scheduler.CosineAnnealingLR(optimizer_adam, T_max=epochs)
schedulers_comb = [scheduler_muon, scheduler_adam]
loss_muon, train_acc_muon, test_acc_muon, time_muon, mem_muon = train_optimizer(
    model_muon, optimizer_comb, criterion, trainloader, testloader, epochs, device, "Muon+Adam", schedulers_comb
)
results["Muon+Adam"] = (loss_muon, train_acc_muon, test_acc_muon, time_muon, mem_muon)

4.2 实战2:调度器效果对比(以SGD为例)

# 延续上面的环境和模型,对比3种调度器
def train_scheduler(model, optimizer, scheduler, criterion, trainloader, testloader, epochs, device, scheduler_name):
    model.train()
    loss_history = []
    acc_history = []
    val_acc_history = []
    total_time = 0.0
    max_memory = 0.0
    for epoch in range(epochs):
        epoch_start = time.time()
        running_loss = 0.0
        correct = 0
        total = 0
        for i, (inputs, labels) in enumerate(trainloader):
            inputs, labels = inputs.to(device), labels.to(device)
            optimizer.zero_grad()
            outputs = model(inputs)
            loss = criterion(outputs, labels)
            loss.backward()
            optimizer.step()
            running_loss += loss.item()
            _, predicted = outputs.max(1)
            total += labels.size(0)
            correct += predicted.eq(labels).sum().item()

        val_acc = evaluate(model, testloader, device)
        # 更新调度器(注意:ReduceLROnPlateau需要传验证集指标)
        if isinstance(scheduler, optim.lr_scheduler.ReduceLROnPlateau):
            scheduler.step(val_acc)
        else:
            scheduler.step()
        epoch_loss = running_loss / len(trainloader)
        epoch_acc = 100. * correct / total
        loss_history.append(epoch_loss)
        acc_history.append(epoch_acc)
        val_acc_history.append(val_acc)
        total_time += time.time() - epoch_start

        current_lr = optimizer.param_groups[0]['lr']
        print(f"【{scheduler_name}】Epoch [{epoch+1}/{epochs}], Loss: {epoch_loss:.3f}, Train Acc: {epoch_acc:.2f}%, Val Acc: {val_acc:.2f}%, LR: {current_lr:.6f}")
    return loss_history, acc_history, val_acc_history, total_time, max_memory

# 定义评估函数
def evaluate(model, testloader, device):
    model.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for inputs, labels in testloader:
            inputs, labels = inputs.to(device), labels.to(device)
            outputs = model(inputs)
            _, predicted = outputs.max(1)
            total += labels.size(0)
            correct += predicted.eq(labels).sum().item()
    return 100. * correct / total

# ========== 初始化3种调度器+训练 ==========
scheduler_results = {}
base_optimizer = optim.SGD(get_modified_resnet18().parameters(), lr=lr, momentum=0.9, weight_decay=5e-4)

# 1. 无调度器(固定学习率)
print("\n===== 开始训练:无调度器(固定LR)=====")
model_no_sched = get_modified_resnet18()
optimizer_no_sched = optim.SGD(model_no_sched.parameters(), lr=lr, momentum=0.9, weight_decay=5e-4)
loss_no_sched, acc_no_sched, val_no_sched, time_no_sched, mem_no_sched = train_scheduler(
    model_no_sched, optimizer_no_sched, None, criterion, trainloader, testloader, epochs, device, "无调度器"
)
scheduler_results["无调度器"] = (loss_no_sched, acc_no_sched, val_no_sched, time_no_sched, mem_no_sched)

# 2. StepLR
print("\n===== 开始训练:StepLR=====")
model_step = get_modified_resnet18()
optimizer_step = optim.SGD(model_step.parameters(), lr=lr, momentum=0.9, weight_decay=5e-4)
scheduler_step = optim.lr_scheduler.StepLR(optimizer_step, step_size=10, gamma=0.1)
loss_step, acc_step, val_step, time_step, mem_step = train_scheduler(
    model_step, optimizer_step, scheduler_step, criterion, trainloader, testloader, epochs, device, "StepLR"
)
scheduler_results["StepLR"] = (loss_step, acc_step, val_step, time_step, mem_step)

# 3. CosineAnnealingLR
print("\n===== 开始训练:CosineAnnealingLR=====")
model_cosine = get_modified_resnet18()
optimizer_cosine = optim.SGD(model_cosine.parameters(), lr=0.001, momentum=0.9, weight_decay=5e-4)
scheduler_cosine = optim.lr_scheduler.CosineAnnealingLR(optimizer_cosine, T_max=10)
loss_cosine, acc_cosine, val_cosine, time_cosine, mem_cosine = train_scheduler(
    model_cosine, optimizer_cosine, scheduler_cosine, criterion, trainloader, testloader, epochs, device, "CosineAnnealingLR"
)
scheduler_results["CosineAnnealingLR"] = (loss_cosine, acc_cosine, val_cosine, time_cosine, mem_cosine)

4.3 实战关键说明(新手必看)

  1. 学习率统一:为了公平对比,SGD+momentum用0.01,AdamW和Muon用0.001(AdamW默认值),避免因学习率不同导致结果偏差;
  2. 要用Muon的话,需要先更新驱动,需要最新版本的PyTorch。如果安装失败,可跳过Muon,重点对比SGD、SGD+momentum、AdamW;

5. 实验结果对比与结论(2080Ti实测)

5.1 四大优化器对比

optimizer_comparison.png

优化器Epoch20准确率总训练时间核心结论
SGD(无动量)92.34%18分钟收敛慢,不推荐
SGD+momentum95.53%19分钟稳且省显存,分类任务首选
AdamW94.21%18分钟快且准,复杂任务首选
Muon+Adam94.72%18分钟快,但需更新cuda驱动

5.2 调度器对比

scheduler_comparison.png

  • 无调度器(固定LR):Epoch10后准确率就卡住(92%左右),Loss震荡;
  • StepLR:Epoch10降档后准确率提升到94%,但后期还是震荡;
  • CosineAnnealingLR:最终准确率94.2%,Loss全程平滑下降,无震荡——这就是【深度学习Day9】 选它的原因!

5.3 终极调参建议(MATLAB老鸟总结)

不用记复杂公式,按这个优先级选就行:

  1. 分类任务(CIFAR-10、ImageNet):SGD+momentum+CosineAnnealingLR(稳、省显存、泛化好);
  2. 复杂任务(检测、分割、医疗影像):AdamW+CosineAnnealingLR(快、准、容错率高);
  3. 小数据/快速验证:AdamW+无调度器(默认lr=0.001,开箱即用);
  4. 大模型/显存紧张:SGD+momentum+StepLR(显存占用最小)。

6. 面试避坑指南(高频问题+标准答案)

Q1:Adam和SGD谁更好?什么时候用Adam,什么时候用SGD?

答:没有绝对的好坏,看场景:

  • 数据量大、需要高泛化(如分类任务):用SGD(+momentum)——泛化能力强,显存占用小;
  • 数据量小、复杂任务、快速验证:用Adam/AdamW——收敛快,对学习率不敏感。

Q2:AdamW和Adam的区别是什么?为什么工业界现在都用AdamW?

答:核心区别是“权重衰减的实现方式”:

  • Adam:权重衰减是“加在梯度上”的,相当于“衰减后再更新梯度”,正则效果打折扣;
  • AdamW:权重衰减是“独立于梯度更新”的,相当于“先更新梯度,再单独衰减权重”,正则效果更精准。

工业界用AdamW是因为它兼顾了Adam的快和SGD的泛化能力,尤其是预训练模型微调时,效果比Adam好很多。

Q3:学习率调度器的核心作用是什么?常用的调度器有哪些?

答:核心作用是“动态调整学习率”,解决固定学习率的痛点:前期收敛慢、后期震荡不收敛。

常用的3种:StepLR(固定节奏降档)、CosineAnnealingLR(平滑降档,首选)、ReduceLROnPlateau(根据验证集指标智能降档)。

Q4:为什么微调预训练模型时,学习率要设得很小(比如1e-4)?

答:预训练模型的权重已经是“比较优”的状态,相当于模型已经“学会了通用知识”。如果学习率太大,会快速冲掉这些优质权重,导致模型性能下降——就像老司机开车,在高速上不需要猛踩油门,轻轻点一下就行。

📌 下期预告

搞定了优化器,我们的模型训练已经很专业了。 但直到现在,我们都是在做 “监督学习” —— 必须要有标签(Label)才能训练。 如果我有一堆图,但没有标签怎么办?能不能让神经网络自己学会“什么是图”? 下一篇,我们将进入无监督学习的奇幻领域 —— 自编码器 (AutoEncoder, AE) 与数据降维。 作为 MATLAB 老鸟,你会发现这不就是神经网络版的 PCA (主成分分析) 吗?我们将用它来做图像去噪和压缩,非常好玩!