深度学习笔记(3):损失函数与优化器,训练的核心

本文系统讲解深度学习训练中的关键组件:损失函数(多分类CrossEntropyLoss、二分类BCELoss、回归MAE/MSE/Smooth L1)与优化器演进(SGD、Momentum、AdaGrad、RMSProp、Adam),涵盖反向传播原理、Epoch/Batch/Iteration定义、学习率衰减策略及Dropout/BatchNorm/L1-L2正则化等过拟合抑制方法,强调任务匹配与工程实践中的典型易错点。

这一篇主要讲损失函数和优化器。损失函数决定"往哪优化",优化器决定"怎么迈步",这两个选择做错,网络结构再好也白搭,也算是十分重要的角色。

一、损失函数:评估模型的好坏

损失函数衡量模型输出和真实值的差距,差距越大损失越大,训练的目标就是把它降到最小。选择的第一依据是任务类型。

1. 多分类:CrossEntropyLoss

多分类任务标配交叉熵损失。它有一个最大的特点:PyTorch 的 nn.CrossEntropyLoss 内部已经包含了 softmax,传给它的应该是网络原始输出(logits),如果先自己 softmax 再传进去,等于做了两次归一化,结果必错。

import torch.nn as nn

y_true = torch.tensor([1, 2], dtype=torch.int64) # 真实类别标签 必须是int64
y_pred = torch.tensor([[0.2, 2.0, 0.1], [0.8, 0.3, 3.0]], # 网络原始输出 未经softmax
                      requires_grad=True, dtype=torch.float32)
loss = nn.CrossEntropyLoss()
my_loss = loss(y_pred, y_true) # 直接传logits 内部自动做softmax再算交叉熵
print(my_loss) # tensor(0.2188, grad_fn=<NllLossBackward0>)

两个样本各预测三个类别,真实标签是类别 1 和类别 2。注意 y_true 传的是类别索引,不需要转成 one-hot,函数内部自己处理。

它的工作流程拆开是三步:softmax 转概率、取对数、挑出真实类别对应的值取负。目标一句话:让正确类别的预测概率尽可能大。

2. 二分类:BCELoss

二分类不再用 softmax,输出层换 sigmoid,损失函数相应换成 BCELoss。它和 CrossEntropyLoss 的输入要求正好相反:BCELoss 要求输入是 sigmoid 之后的结果,所以要自己先做激活:

y_pred = torch.tensor([0.6901, 0.5459, 0.2469], requires_grad=True) # 必须是sigmoid后的概率值
y_true = torch.tensor([0, 1, 0], dtype=torch.float32) # 二分类标签用0和1
loss = nn.BCELoss()
print(loss(y_pred, y_true)) # tensor(0.6868, grad_fn=<BinaryCrossEntropyLossBackward>)

3. 回归:MAE、MSE、Smooth L1 怎么选

回归任务的三个候选,差异在"怎么计算差距":

MAE(L1Loss):误差取绝对值再平均。对离群点不敏感,但零点处不光滑,收敛末期可能在极小值附近来回跳。

MSE(MSELoss):误差取平方再平均。光滑、好求导,是默认选择。缺点是大误差被平方放大,数据里有离群点时梯度容易被带偏。

Smooth L1Loss:分段函数,误差小于 1 时按 MSE 算,大于 1 时按 MAE 算。结合了两边的优点:小区间光滑好优化,大区间不爆梯度,目标检测里定位损失几乎都用它。

y_pred = torch.tensor([1.0, 1.0, 1.9], requires_grad=True)
y_true = torch.tensor([2.0, 2.0, 2.0], dtype=torch.float32)

print(nn.L1Loss()(y_pred, y_true)) # tensor(0.7000) 绝对误差平均
print(nn.MSELoss()(y_pred, y_true)) # tensor(0.6700) 平方误差平均

二、训练的三个量词:Epoch、Batch、Iteration

这三个词很常见,必须做到理解:

Epoch:全部训练数据完整过一遍,叫一轮。

Batch Size:一次参数更新所用的样本数。

Iteration:一次参数更新,一个 iteration 处理一个 batch。

比如:50000 个样本,batch size 取 256。每个 epoch 需要的 batch 数是 50000 除以 256 约等于 195.3,向上取整得 196(最后一批不满 256 个也要用掉)。也就是说一个 epoch 有 196 个 iteration,训练 10 个 epoch 就是 1960 次参数更新。

三、梯度下降的三种模式

按每次更新用多少数据,梯度下降分三种:

模式每次更新用的数据特点
BGD 批量梯度下降全部样本方向稳但一步算得太慢
SGD 随机梯度下降单个样本快但方向抖得厉害
Mini-Batch 小批量一个 batch折中方案,深度学习默认

PyTorch 里说的 SGD 优化器,实际用法几乎都是配合 DataLoader 按 batch 喂,所以工程语境下的"SGD"基本都指 Mini-Batch 模式。上篇线性回归代码里 batch_size=16 就是它。

Mini-Batch 仍有一个遗留问题:不同 batch 算出的梯度方向有随机性,收敛路径会震荡。这正是后面优化器要解决的事。

四、反向传播:链式法则的自动化

第 2 篇把 backward() 当黑盒用了,现在补上原理,其实就是链式法则。

前向传播:数据从输入层逐层算到输出层,得到预测值,再算出损失。

反向传播:从损失出发,从后往前逐层求损失对每个参数的偏导数。

核心逻辑是复合函数求导。损失 L 依赖输出层激活值,输出层激活值依赖该层加权求和,加权求和依赖权重 w。想求 L 对 w 的导数,就把这条链上每一段的导数乘起来:

∂L/∂w = ∂L/∂a × ∂a/∂z × ∂z/∂w

放到具体网络里,一个权重到损失的路径可能不止一条(某个隐藏神经元同时影响多个输出),那就把每条路径的乘积加起来。第 2 篇说 PyTorch 自动微分"记录计算过程",记录的就是这张依赖图;backward() 就是从损失节点出发,沿图反向逐节点算导数。没有反向传播,多层网络根本不可训练;有了它,多深都能算,只受梯度是否衰减约束。

五、优化器演进线

原始梯度下降在真实训练中会碰上三个困境:平缓区域梯度太小走不动;鞍点梯度为零直接卡死;局部最小值困在半山腰。历史上每款优化器都在解决其中的问题。

1. 前置知识:指数加权平均

后面两款优化器都建立在这个概念上。普通平均对所有数值一视同仁,指数加权平均则让近期的数据权重更大,远期的数据权重指数级衰减。类比:预测明天气温,昨天的气温参考价值大,一个月前的气温参考价值小。

公式 Sₜ = β·Sₜ₋₁ + (1-β)·Yₜ,β 通常取 0.9。意思是:当前平均值 = 0.9 份历史 + 0.1 份当前。β 越大曲线越平缓,对新数据反应越迟钝。

import torch
import matplotlib.pyplot as plt

ELEMENT_NUMBER = 30 # 模拟30天
torch.manual_seed(0) # 固定随机种子
temperature = torch.randn(size=[ELEMENT_NUMBER]) * 10 # 30天的随机气温

exp_weight_avg = [] # 存放每天的指数加权平均值
for idx, temp in enumerate(temperature, 1):
    if idx == 1:
        exp_weight_avg.append(temp) # 第一天的平均值等于自身
        continue
    new_temp = exp_weight_avg[idx - 2] * 0.9 + 0.1 * temp # 0.9份历史 + 0.1份当前
    exp_weight_avg.append(new_temp)

days = torch.arange(1, ELEMENT_NUMBER + 1, 1)
plt.plot(days, temperature, color='gray') # 原始气温 波动大
plt.plot(days, exp_weight_avg, color='r') # 加权平均后 平缓得多
plt.show()

灰色原始曲线抖动剧烈,红色加权平均曲线明显平滑。

2. Momentum:给梯度加上惯性

更新参数时不再只看当前梯度,而是用历史梯度的指数加权平均:sₜ = β·sₜ₋₁ + (1-β)·gₜ,然后 w = w - η·sₜ。

两个困境的解法都来自"惯性":在鞍点处当前梯度为零,但历史累积的动量还在,很可能冲过去;Mini-Batch 的梯度震荡,被加权平均抹平,前进方向更稳定。

3. AdaGrad:各参数各用各的学习率

思路:梯度一直很大的参数,学习率调小;梯度一直很小的参数,学习率调大。实现是把历史梯度的平方累加起来,用累加值缩放每个参数的学习率。代价是累加只增不减,学习率衰减得太快,训练后期步子太小走不动。

4. RMSProp:给 AdaGrad 换上指数加权

只改一处:把"历史梯度平方的累加和"换成"历史梯度平方的指数加权平均"。远期梯度淡出,学习率不会过早塌掉。

5. Adam:集大成者

Adam 同时做两件事:梯度方向用指数加权平均(继承 Momentum),学习率用梯度平方的指数加权平均自适应(继承 RMSProp),再加一步偏差修正(修正训练初期平均值的偏差)。实践中不知道选什么优化器时,选 Adam。

四个优化器跑同一个函数 y = w²/2,观察两次更新的差异:

w = torch.tensor([1.0], requires_grad=True, dtype=torch.float32)
loss = ((w ** 2) / 2.0).sum() # 目标函数 y = w²/2 梯度就是w本身

optimizer = torch.optim.Adam([w], lr=0.01, betas=[0.9, 0.99]) # 换成SGD/Adagrad/RMSprop观察差异

optimizer.zero_grad() # 梯度清零
loss.backward() # 反向传播 第一次梯度为1.0
optimizer.step() # 更新参数
print('第1次: 梯度:%f, 更新后的权重:%f' % (w.grad.item(), w.detach().item()))

loss = ((w ** 2) / 2.0).sum() # 用更新后的权重重新计算
optimizer.zero_grad()
loss.backward()
optimizer.step()
print('第2次: 梯度:%f, 更新后的权重:%f' % (w.grad.item(), w.detach().item()))

把第一行优化器换成 SGD、Adagrad、RMSprop 跑一遍,对比两次更新后的权重:同为 lr=0.01,多数优化器第一步都从 1.0 走到 0.99 附近,RMSprop 一步就跨到 0.968,从第二步开始各家步幅明显分化,因为对"历史"的处理方式不同。这个动手对比比背公式有效得多。

六、学习率衰减

训练前期需要大学习率快速逼近,后期需要小学习率精细收敛,一个固定学习率两头都照顾不好。PyTorch 的 lr_scheduler 模块提供三种常用衰减策略:

from torch import optim

optimizer = optim.SGD([w], lr=0.1, momentum=0.9) # 先有一个优化器

scheduler1 = optim.lr_scheduler.StepLR(optimizer, step_size=50, gamma=0.5) # 每50个epoch乘0.5
scheduler2 = optim.lr_scheduler.MultiStepLR(optimizer, milestones=[50, 125, 160], gamma=0.5) # 到指定轮次才乘0.5
scheduler3 = optim.lr_scheduler.ExponentialLR(optimizer, gamma=0.95) # 每个epoch都乘0.95 连续衰减

用法上有一个固定位置:每个 epoch 结束后调用一次 scheduler.step(),它负责把优化器的学习率改到新值。

for epoch in range(max_epoch):
    for x, y in dataloader:
        output = model(x)
        loss = criterion(output, y)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
    scheduler_lr.step() # 每轮训练结束更新学习率 注意别和optimizer.step()搞混

三种曲线形状:StepLR 是阶梯状往下跳,MultiStepLR 是在自定义的轮次跳,ExponentialLR 是平滑下坡。初学先用等间隔的 StepLR 即可。

七、正则化:抑制过拟合

网络参数一多、数据一少,过拟合就来了:训练集表现很好,测试集一塌糊涂。

1. Dropout 随机失活

训练时,每个神经元以概率 p 临时"下岗",输出直接置 0,剩下的神经元按 1/(1-p) 放大。测试时不失活,全部神经元正常工作。

import torch
import torch.nn as nn

dropout = nn.Dropout(p=0.4) # 每个神经元有40%的概率被置0
inputs = torch.randint(0, 10, size=[1, 4]).float() # 模拟某层的输入
layer = nn.Linear(4, 5)
y = torch.relu(layer(inputs)) # 先过一个全连接层加激活
print("失活前:\n", y)
y = dropout(y) # 训练时随机失活
print("失活后:\n", y) # 出现了0 其余值被放大1/(1-0.4)倍

为什么放大?失活后期望输出变小了,放大 1/(1-p) 保证这一层输出的总量级不变,测试时就不需要额外补偿。

直觉理解:每个神经元都不能指望队友,必须自己学会有用的特征,相当于每次训练的都是不同的子网络,最终效果像多个模型的集成。注意 model.eval() 会自动关闭 Dropout,这也是评估函数里必须写它的原因之一。

2. BatchNorm 批量归一化

对每个 mini-batch 的数据先标准化(减均值除标准差),再通过可学习的缩放参数 γ 和平移参数 β 重构。治的是"内部协变量偏移":每层输入的分布随训练不断变化,后面的层疲于奔命。归一化后分布稳定,训练更快更稳,在视觉网络里几乎是标配。

m = nn.BatchNorm2d(2, eps=1e-05, momentum=0.1, affine=True) # 2个通道 affine=True表示γ和β可学习
input = torch.randn(1, 2, 3, 4) # 批1 通道2 高3 宽4
output = m(input)
print(m.weight) # γ 初始全1
print(m.bias) # β 初始全0

3. L1,L2正则化

L1正则化:L1 是在损失里加权重绝对值之和,容易把不重要权重压成 0,产生稀疏解、能做特征选择

L2正则化:L2 是加权重平方和,让权重整体变小更平滑,也就是权重衰减,主要用来防过拟合。

八、易错点提醒

  1. CrossEntropyLoss 前自己做了 softmax:双重归一化,损失值不对,这是分类任务第一坑
  2. BCELoss 前忘了 sigmoid:和上一条正好相反,BCE 不含激活
  3. 标签类型不对:CrossEntropyLoss 要 int64 标签,BCELoss 要 float 的 0 和 1
  4. batch 数算错:50000 样本 batch 256 是 196 批,向上取整,不是 195
  5. scheduler.step() 写进了 batch 循环:它是按 epoch 调用的,放错位置学习率衰减过快
  6. 评估时忘了 model.eval():Dropout 还在随机失活,评估结果每次都不一样
  7. MSE 遇上离群点:平方放大误差,回归数据有异常值时优先考虑 Smooth L1
  8. 以为 Adam 万能就不管学习率:Adam 对学习率相对鲁棒,但 lr 大一个量级照样训崩

九、面试官视角

问:CrossEntropyLoss 和手动 softmax 加 NLLLoss 是什么关系?

回答:CrossEntropyLoss 内部就是 softmax + log + NLLLoss 的组合,所以模型输出直接传 logits,不要再手动 softmax。

问:Adam 为什么是默认首选?它组合了什么?

回答:Adam = Momentum(梯度方向的指数加权平均)+ RMSProp(梯度平方的指数加权平均做自适应学习率)+ 偏差修正。加分项:说出它的两个超参 betas 分别管一阶矩和二阶矩,默认 0.9 和 0.999。

问:Dropout 在训练和测试时的行为差异?

回答:训练时以概率 p 随机失活并按 1/(1-p) 放大,测试时全部正常工作。深一层的回答:放大是为了保持输出量级一致;model.eval() 自动切换行为;Dropout 本质是训练了大量子网络做隐式集成。

问:为什么需要学习率衰减?

固定学习率的两难:大了后期在最优点附近震荡,小了前期收敛太慢。衰减策略让前期大步快走、后期小步微调,相当于先粗调后精调。

小结

这一篇补齐了训练的核心控制件:损失函数按任务选(多分类交叉熵、二分类 BCE、回归 MSE 起手)、优化器沿"SGD 三困境到 Adam"的演进线理解、学习率按 epoch 调度、过拟合上 Dropout 和 BN。

到目前为止,网络处理的都是一维特征向量。但图像天然是三维的(高、宽、通道),全连接网络硬吃图像会怎样?参数会爆炸到不可接受。下一篇进入卷积神经网络,看 CNN 用什么结构解决图像问题,这也是理解视觉大模型的第一步。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值