优化理论入门-损失函数与梯度下降实战
> **本文的定位**:这是第3章数学基础的最后一篇,属于实战篇。把前面三篇学过的数学工具(线性代数的矩阵运算、微积分的梯度、概率论的损失函数)组合起来,看一个完整的训练过程是什么样的。同时深入理解过拟合问题——它是机器学习中最常见、最重要的挑战之一。
优化理论入门:损失函数与梯度下降实战
本文的定位:这是第3章数学基础的最后一篇,属于实战篇。把前面三篇学过的数学工具(线性代数的矩阵运算、微积分的梯度、概率论的损失函数)组合起来,看一个完整的训练过程是什么样的。同时深入理解过拟合问题——它是机器学习中最常见、最重要的挑战之一。
一、优化是什么:找最优解的艺术
"优化"是一个听起来高大上但其实非常直觉的概念。
你每天都在做优化:用最少的钱买到质量最好的商品;找一条从家到公司距离最短的路;在有限时间内完成最重要的任务。这些都是优化问题——在某些约束条件下,找到让某个目标最大化或最小化的最优方案。
机器学习里的优化,目标很明确:找到一组模型参数,使得损失函数(预测误差)最小化。
这个问题之所以难,是因为:
- 参数空间极其巨大(大模型有几十亿个参数)
- 损失函数的"地形"非常复杂(到处都是山峰和山谷)
- 不能穷举(随机尝试需要天文数字的时间)
前面几篇学的梯度和梯度下降,就是解决这个问题的核心工具。本篇把这些工具组合起来,看一个完整的训练循环。
二、一个完整的训练循环
下面这段代码是深度学习训练的标准模板。把它理解透,你就能看懂几乎所有深度学习的训练代码。
这段代码在做什么:创建一个简单的两层神经网络,在随机生成的二分类数据上训练100轮,每隔20轮打印一次损失值,观察损失是否在下降。
import torch
import torch.nn as nn
import torch.optim as optim
# 1. 准备数据
X = torch.randn(100, 10) # 100个样本,10个特征
y = torch.randint(0, 2, (100,)) # 二分类标签(0或1)
# 2. 定义模型
# nn.Sequential:把多个层按顺序叠在一起,数据从上到下流过每一层
# nn.Linear(10, 64):全连接层,把10维输入变换成64维
# nn.ReLU():激活函数,给网络加入非线性能力(把负数变成0)
model = nn.Sequential(
nn.Linear(10, 64),
nn.ReLU(),
nn.Linear(64, 2)
)
# 3. 定义损失函数和优化器
criterion = nn.CrossEntropyLoss() # 分类任务用交叉熵损失
optimizer = optim.Adam(model.parameters(), lr=1e-3) # Adam优化器,学习率0.001
# 4. 训练循环
for epoch in range(100):
# 前向传播:数据从输入层流向输出层,得到预测结果
outputs = model(X)
loss = criterion(outputs, y) # 计算预测结果和真实标签的差距
# 反向传播:从输出向输入方向计算每个参数的梯度
optimizer.zero_grad() # 清空上一步的梯度(PyTorch会累加梯度,必须先清空)
loss.backward() # 自动计算所有参数的梯度(用链式法则)
optimizer.step() # 按梯度更新参数(沿梯度反方向走一步)
if epoch % 20 == 0:
print(f"Epoch {epoch}, Loss: {loss.item():.4f}")
运行这段代码,你应该看到类似这样的输出:
Epoch 0, Loss: 0.7021
Epoch 20, Loss: 0.6183
Epoch 40, Loss: 0.5492
Epoch 60, Loss: 0.4830
Epoch 80, Loss: 0.4215
损失值从0.7021逐渐下降到0.4215,这说明模型在不断学习,预测越来越准。
关键的三行代码:
optimizer.zero_grad() # ① 清空梯度(必须在backward之前)
loss.backward() # ② 计算梯度(反向传播)
optimizer.step() # ③ 更新参数(梯度下降一步)
这三行代码的顺序是固定的,不能颠倒。它们构成了所有深度学习训练的基本节奏。
三、过拟合:死记硬背 vs 真正理解

三种拟合状态——欠拟合误差大、恰好拟合泛化好、过拟合记住了噪声
用一个学生类比建立直觉
考虑两个学生:
学生A(死记硬背):把100道练习题的答案全部背下来,考试时只要题目和练习题一模一样,能满分;但题目稍有变化,就完全不会。
学生B(真正理解):理解了解题方法,对练习题和新题目都能举一反三,成绩稳定。
过拟合就是学生A的情况:模型在训练集上表现完美(背答案),但在没见过的新数据上表现很差(不会举一反三)。
欠拟合则是另一个极端:连练习题都做不好,说明模型根本没有学到任何有用的规律。
过拟合在数据上的表现
# 过拟合的典型症状:
# 训练集准确率: 99%
# 测试集准确率: 65% ← 差距巨大,过拟合了
# 欠拟合的典型症状:
# 训练集准确率: 65%
# 测试集准确率: 63% ← 差距很小,但两个都很低,欠拟合了
# 理想状态:
# 训练集准确率: 92%
# 测试集准确率: 89% ← 差距小,且两个都高,恰当拟合
为什么过拟合是大问题?
模型最终是要用在新数据上的,不是用来"背"训练数据的。如果模型只在训练集上好,对新数据一塌糊涂,那这个模型就没有实用价值。
怎么看出过拟合
最直观的方法是学习曲线:同时画出训练集损失和验证集损失,随训练轮数的变化趋势。
- 过拟合:训练集损失持续下降,但验证集损失先下降后上升——两条线分叉了
- 欠拟合:两条线都高,说明模型太简单
- 正常:两条线都下降,且保持接近
四、常见损失函数
不同任务需要不同的损失函数:
# 回归任务(预测连续数值):均方误差
mse = nn.MSELoss()
# predictions:模型预测的房价
# targets:真实房价
# 损失 = 平均((预测值 - 真实值)²)
loss = mse(predictions, targets)
# 二分类(只有两种结果:是/否):二元交叉熵
bce = nn.BCEWithLogitsLoss()
# logits是未经sigmoid(把任意数值压缩到0-1区间的函数)的原始输出
loss = bce(logits, labels)
# 多分类(有多种结果:猫/狗/鸟...):交叉熵
ce = nn.CrossEntropyLoss()
# labels是类别索引(猫=0, 狗=1, 鸟=2...)
loss = ce(logits, labels)
# 语言模型(LLM训练):也是交叉熵,但针对每个位置的词
# LLM训练的目标:预测下一个词,用交叉熵衡量预测准不准
选哪个损失函数?
一个简单的判断方法:
- 输出是一个连续的数字(房价、温度)→ MSE
- 输出是"是/否"两种可能 → BCE
- 输出是多个类别中的一个 → CrossEntropy
- 输出是一段文字 → CrossEntropy(每个位置单独算)
五、解决过拟合的三种武器
武器1:Dropout(随机关闭神经元)
训练时随机把一部分神经元(通常20%~50%)"关掉",强迫网络不能过度依赖任何单个神经元,必须学到更分散、更鲁棒的特征。
类比:让学生随机忘掉一部分知识,强迫他必须真正理解,而不能依赖死记硬背的特定答案。
model = nn.Sequential(
nn.Linear(10, 64),
nn.ReLU(),
nn.Dropout(0.5), # 训练时随机关闭50%的神经元
nn.Linear(64, 2)
)
# 重要:Dropout在训练和推理时行为不同
model.train() # 训练模式:Dropout生效,随机关闭神经元
model.eval() # 推理模式:Dropout关闭,所有神经元都开着
武器2:权重衰减(L2正则化)
在损失函数中加入对参数"过大"的惩罚。参数越大,惩罚越重。这迫使模型学到更简单、更泛化的规律,而不是去"记住"每个训练样本的细节。
类比:限制学生备忘录的长度,迫使他写最重要的规律,而不是把所有答案都抄上去。
# AdamW:Adam优化器 + 权重衰减
# weight_decay=1e-4 表示对参数大小施加惩罚
optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)
武器3:早停(Early Stopping)
监控验证集损失:如果验证集损失连续N轮没有改善,就停止训练。
原理:训练越久,模型越可能把训练数据的噪声也"学"进去,导致过拟合。在过拟合开始恶化之前及时停下来,保存最好的那个版本。
best_val_loss = float('inf')
patience = 10 # 连续10轮不改善就停止
counter = 0
for epoch in range(1000):
train_loss = train_one_epoch(model, train_loader)
val_loss = evaluate(model, val_loader)
if val_loss < best_val_loss:
best_val_loss = val_loss
counter = 0
torch.save(model.state_dict(), 'best_model.pt') # 保存最好的模型
else:
counter += 1
if counter >= patience:
print(f"早停:{epoch}轮后验证集损失不再改善")
break
# 训练结束后,加载最好的模型版本
model.load_state_dict(torch.load('best_model.pt'))
六、过拟合与欠拟合的诊断速查
| 症状 | 原因 | 解决方案 |
|---|---|---|
| 训练集高、验证集低 | 过拟合(记答案了) | 加Dropout、权重衰减、早停、增加数据量 |
| 训练集和验证集都低 | 欠拟合(模型太简单) | 增加模型层数/宽度、训练更长时间 |
| 两者都高且接近 | 正常 | 继续调优或部署 |
七、学习率调度:让训练更稳定
固定学习率往往不是最优的。
直觉:训练初期可以大步走(快速逼近最优点),训练后期要小步走(精细调整,避免跨过最优点)。
import torch.optim as optim
# 假设 model 和 optimizer 已定义
# optimizer = optim.AdamW(model.parameters(), lr=1e-3)
# 余弦退火:学习率按余弦曲线从初始值降到接近0(先快后慢地降低)
# scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100)
# Warmup + 余弦退火(Transformer训练常用)
# 先从极小值线性增大到目标值,再余弦衰减
# from transformers import get_cosine_schedule_with_warmup
# scheduler = get_cosine_schedule_with_warmup(
# optimizer,
# num_warmup_steps=100,
# num_training_steps=1000
# )
# 在训练循环中使用:
# for epoch in range(100):
# train(model, train_loader)
# scheduler.step()
八、批归一化(Batch Normalization)
批归一化是让深层网络训练更稳定的关键技术。
为什么需要它?
想象神经网络的每一层都在"适应"来自上一层的数据分布。但上一层的参数在不断更新,每次更新后分布都会变化,这一层就需要重新适应——像在追一个移动的靶子。这个现象叫"内部协变量偏移",会让训练变慢、不稳定。
批归一化(Batch Normalization)的解决方案:在每层之后,把这批数据的输出归一化到均值为0、方差为1的标准分布,然后再乘以可学习的参数做缩放和平移。这样每层的输入分布保持稳定,训练更快更稳。
# 让每一层的输入分布更稳定,加速训练
model = nn.Sequential(
nn.Linear(10, 64),
nn.BatchNorm1d(64), # 批归一化:稳定这一层的输入分布
nn.ReLU(),
nn.Linear(64, 2)
)
九、实际调参建议
- 先跑通,再调优:先让代码跑起来不报错,再开始调参数
- 学习率最重要:先试
1e-3,不好再试1e-4或1e-2 - 批大小影响稳定性:批大小(每次送入模型的样本数)大,训练更稳定但显存占用多
- 用验证集选模型:永远不要用测试集来调参,那是最终评估用的
- 记录实验:用MLflow(开源机器学习实验跟踪工具)或wandb(可视化训练过程的工具)记录每次实验的参数和结果
小结
优化理论的核心流程:
1. 定义损失函数(量化"模型有多差")
2. 计算梯度(找"哪个方向能让损失下降")
3. 更新参数(往梯度反方向走一步)
4. 重复,直到损失不再明显下降(收敛)
这个循环贯穿所有深度学习训练,从简单的线性回归到大语言模型的预训练,本质都是一样的。
过拟合是最常见的敌人:模型把训练数据的噪声也学进去了,在新数据上表现差。Dropout、权重衰减、早停是对抗过拟合的三大武器。
后续应用:本文的训练循环模板将在以下章节直接用到:
- 第5章 PyTorch入门(用PyTorch实现完整的训练循环)
- 第16章 模型微调(微调本质上是对预训练模型继续执行训练循环)