从零理解模型训练:参数、损失与梯度下降的最小闭环
从零理解模型训练:参数、损失与梯度下降的最小闭环
一、先看一个具体问题
假设你手上有一份房屋数据,每条记录包含三个特征:面积、房龄、到地铁的距离,以及一个目标值:成交价。你想让机器学会"给定特征,预测价格"。
用最朴素的语言描述这件事,机器需要做三件事:
- 先猜一个规则,比如"价格 = 2 × 面积 − 3 × 房龄 + 0.5 × 距离 + 1.2";
- 拿这个规则去算已有的数据,看算出来的价格和真实价格差多少;
- 根据差多少,把规则里的数字调一调,让下次算得更准。
然后重复第 2、3 步,直到误差小到可以接受。
这三步就是**模型训练(model training)**的全部骨架。听起来简单得不像"人工智能",但无论你后面用多大的 Transformer、多少张 GPU,底层逻辑仍然是这三步,只是"规则"变复杂了、"调数字"的方法变精细了。
这篇文章的目标,是把这三步拆成可以手推、可以手写、可以运行的完整闭环。读完你应该能回答:
- 参数(parameter)到底是什么,和超参数(hyperparameter)差在哪;
- 损失函数(loss function)在衡量什么;
- 梯度(gradient)为什么能指示"往哪调";
- 学习率(learning rate)在控制什么;
- 一次训练迭代里,代码层面究竟发生了什么。
二、把"规则"形式化:参数与假设空间
机器学习里的"规则",标准叫法是模型(model),或者更严谨地说,是假设(hypothesis)。上面那个线性规则可以写成:
[
\hat{y} = w_1 x_1 + w_2 x_2 + w_3 x_3 + b
]
其中:
- (x_1, x_2, x_3) 是输入特征(feature),来自数据,训练过程中不会被改动;
- (w_1, w_2, w_3) 和 (b) 是参数,它们才是训练要调整的对象;
- (\hat{y})(读作 y-hat)是模型的预测值(prediction);
- (b) 通常叫**偏置(bias)**或截距,作用是把整条直线上下平移,没有它,当所有特征为 0 时预测必然为 0,表达能力受限。
用矩阵写法更紧凑。把 (N) 条样本、每条 (D) 个特征堆成矩阵 (X \in \mathbb{R}^{N \times D}),参数堆成向量 (w \in \mathbb{R}^{D}),那么所有样本的预测就是:
[
\hat{y} = Xw + b\mathbf{1}
]
这里的 (Xw) 是一次矩阵乘法,形状从 ((N, D)) 变成 ((N,))。
**假设空间(hypothesis space)**指的是"参数取遍所有可能值之后,能得到的所有模型的集合"。对线性模型来说,就是所有直线/超平面;对神经网络来说,是所有可能的非线性映射。训练的本质,是在这个空间里找一组参数,使某个"好坏标准"达到最优。
这就引出一个关键区分:
| 类别 | 含义 | 例子 |
|---|---|---|
| 参数 | 训练过程中由优化算法自动更新 | 权重 (w)、偏置 (b)、神经网络每层的矩阵 |
| 超参数 | 由人在训练前或训练中设定,不通过梯度更新 | 学习率、批量大小、层数、正则化系数 |
这个区分不是学术洁癖,而是工程分工:参数是"算出来的",超参数是"选出来的"。后面所有关于调参的讨论,基本都围绕超参数展开。
三、把"差多少"形式化:损失函数
有了模型,就需要一个量化标准来衡量预测和真实值的差距。这个标准就是损失函数,也叫目标函数(objective function)或代价函数(cost function)。注意:实践中这三个词经常混用,但严格说,"目标函数"是优化器真正去最小化的那个量,损失函数是其中衡量误差的部分,二者在有正则项时会不同(这一点会在后续讨论正则化时展开)。
回归任务最常用的是均方误差(Mean Squared Error, MSE):
[
L(w, b) = \frac{1}{N}\sum_{i=1}^{N}(\hat{y}_i - y_i)^2
]
为什么平方?三个理由:
- 消除符号:预测偏高和偏低都算"错",不相互抵消;
- 放大严重错误:差 10 的样本,惩罚是差 1 的 100 倍,模型会优先修正离谱的预测;
- 数学性质好:平方函数处处可导,且导数是线性的,求梯度非常方便。
但平方也有代价:对离群点(outlier)极其敏感。一条被错误标注成天价的房子,会让模型整体被拉偏。这时候可以考虑**平均绝对误差(Mean Absolute Error, MAE)**或 Huber 损失。选择哪种损失,本质是在回答"我更怕哪种错误"。
关键认知:损失函数是你对"什么是好模型"的定义。损失设计错了,优化过程再完美,得到的也是错误的目标。这个坑在训练大型模型时尤其致命——模型会很忠实地优化你写下的东西,而不是你以为你写下的东西。
四、把"怎么调"形式化:梯度与梯度下降
现在到核心问题:知道当前损失是 3.5,怎么决定参数该往哪个方向调?
答案是梯度(gradient)。梯度是多元函数对所有参数的偏导数组成的向量:
[
\nabla L = \left(\frac{\partial L}{\partial w_1}, \frac{\partial L}{\partial w_2}, \frac{\partial L}{\partial w_3}, \frac{\partial L}{\partial b}\right)
]
它有两个几何含义,第二个才是关键:
- 方向:梯度指向函数值上升最快的方向;
- 大小:梯度的模长表示这个方向上的变化率有多陡。
既然梯度指向上升最快方向,那么沿着梯度的反方向走一小步,函数值就会下降。这就是梯度下降(Gradient Descent):
[
w \leftarrow w - \eta \nabla_w L
]
其中 (\eta) 是学习率,控制每一步走多远。
对上面的 MSE 和线性模型,偏导数可以手推出来:
[
\frac{\partial L}{\partial w} = \frac{2}{N}X^{\top}(\hat{y} - y), \qquad
\frac{\partial L}{\partial b} = \frac{2}{N}\sum_{i=1}^{N}(\hat{y}_i - y_i)
]
推导并不复杂:令残差 (r_i = \hat{y}i - y_i),则 (L = \frac{1}{N}\sum r_i^2),链式法则给出 (\frac{\partial L}{\partial w_j} = \frac{2}{N}\sum_i r_i \frac{\partial r_i}{\partial w_j}),而 (\frac{\partial r_i}{\partial w_j} = x{ij}),写成矩阵形式就是上面的结果。
这里有个容易被忽略的点:梯度是局部信息。它只告诉你"在当前这个点上,往哪走下降最快",不保证指向全局最优。对于线性回归这类凸问题(convex problem),局部最优即全局最优,梯度下降有理论保证;对神经网络的非凸损失曲面,梯度下降只能保证找到一个不错的驻点。这不是缺陷,而是深度学习能工作的前提之一——我们并不需要全局最优。
五、手写一个最小闭环(NumPy 版)
下面用纯 NumPy 实现完整闭环,不依赖任何深度学习框架。这样你能看清每一步在算什么。
环境与依赖
- Python 3.9 及以上
- NumPy 1.22 及以上(仅用到基础线性代数与随机数接口)
运行方式:保存为 gd_numpy.py,执行 python gd_numpy.py。
"""用纯 NumPy 实现线性回归的梯度下降,展示训练的最小闭环。"""
import numpy as np
# ---------- 1. 造一份可控的合成数据 ----------
rng = np.random.default_rng(seed=0) # 固定随机种子,保证结果可复现
N, D = 200, 3 # 200 条样本,每条 3 个特征
X = rng.normal(size=(N, D)) # 特征:标准正态分布
w_true = np.array([2.0, -3.0, 0.5]) # 真实参数(只有造数据时才知道)
b_true = 1.2
noise = rng.normal(scale=0.1, size=N) # 观测噪声
y = X @ w_true + b_true + noise # 目标值
# ---------- 2. 初始化参数 ----------
w = np.zeros(D)
b = 0.0
lr = 0.1 # 学习率(超参数)
n_steps = 200 # 迭代步数(超参数)
# ---------- 3. 训练循环:预测 -> 算损失 -> 求梯度 -> 更新 ----------
for step in range(n_steps):
y_hat = X @ w + b # 前向:算出预测
residual = y_hat - y # 残差
loss = np.mean(residual ** 2) # 损失:均方误差
grad_w = 2.0 * X.T @ residual / N # 对 w 的梯度
grad_b = 2.0 * np.mean(residual) # 对 b 的梯度
w -= lr * grad_w # 梯度下降更新
b -= lr * grad_b
if step % 40 == 0 or step == n_steps - 1:
print(f"step {step:3d} loss={loss:.5f} b={b:+.3f}")
# ---------- 4. 检查结果 ----------
print("\n学到的 w:", np.round(w, 3))
print("真实的 w:", w_true)
print(f"学到的 b: {b:.3f} 真实的 b: {b_true}")
一次典型运行的输出大致如下(不同 NumPy 版本的随机数实现一致,结果应当稳定):
step 0 loss=24.93086 b=+0.063
step 40 loss=0.01117 b=+1.184
step 80 loss=0.01001 b=+1.193
step 120 loss=0.00988 b=+1.196
step 160 loss=0.00986 b=+1.198
step 199 loss=0.00986 b=+1.198
学到的 w: [ 1.995 -2.994 0.499]
真实的 w: [ 2. -3. 0.5]
学到的 b: 1.198 真实的 b: 1.2
注意几点:
- 学到的参数不会精确等于真实参数,因为数据里有噪声(
scale=0.1),模型只能逼近生成数据的规律,无法还原被噪声掩盖的真值; - 损失下降到约 0.0099 后基本不动,这个下限大致等于噪声方差 (0.1^2 = 0.01),说明模型已经学到了信号部分,剩下的误差是数据本身无法解释的;
- 如果我们把
noise设为 0,损失会一路降到非常接近 0,参数也会非常接近真值。
关于学习率的实验,值得动手改一改:
lr = 0.001:收敛极慢,200 步远远不够;lr = 0.5:可能仍然收敛,但曲线更抖;lr = 10:几乎必然发散,损失变成nan或天文数字。
这直观说明了学习率是训练中最敏感的超参数之一。
六、换成 PyTorch:同一件事,自动化了梯度
手工推导梯度只对最简单的模型可行。一旦模型变成几百万参数的神经网络,必须靠自动微分(automatic differentiation)。下面用 PyTorch 重写同一段逻辑。
环境与依赖
- Python 3.9 及以上
- PyTorch 1.13 / 2.x(
torch.optim.SGD、Tensor.backward()等接口长期稳定,2.x 系列均适用)
"""用 PyTorch 实现同一套训练闭环,梯度由自动微分计算。"""
import torch
torch.manual_seed(0)
# ---------- 数据 ----------
N, D = 200, 3
X = torch.randn(N, D)
w_true = torch.tensor([2.0, -3.0, 0.5])
b_true = 1.2
y = X @ w_true + b_true + 0.1 * torch.randn(N)
# ---------- 参数:requires_grad=True 表示需要计算梯度 ----------
w = torch.zeros(D, requires_grad=True)
b = torch.zeros(1, requires_grad=True)
optimizer = torch.optim.SGD([w, b], lr=0.1) # 优化器接管"更新"这一步
loss_fn = torch.nn.MSELoss() # 损失函数
# ---------- 训练循环 ----------
for step in range(200):
optimizer.zero_grad() # 1) 清空上一轮的梯度(重要!)
y_hat = X @ w + b # 2) 前向计算
loss = loss_fn(y_hat, y) # 3) 计算损失
loss.backward() # 4) 反向传播,填充 w.grad / b.grad
optimizer.step() # 5) 按梯度更新参数
if step % 40 == 0 or step == 199:
print(f"step {step:3d} loss={loss.item():.5f}")
print("\n学到的 w:", w.detach().numpy().round(3))
print("学到的 b:", b.item().round(3))
对照两版代码,五步流程完全一致:
optimizer.zero_grad():清空历史梯度。PyTorch 的梯度是累加的,不清零会导致梯度越滚越大。这是新手最常见的 bug 之一。- 前向计算:得到预测值。
- 计算损失:把预测和真值交给损失函数。
loss.backward():从损失出发,沿计算图反向求出所有requires_grad=True张量的梯度。optimizer.step():用梯度更新参数。
注意 loss_fn(y_hat, y) 的参数顺序:PyTorch 的 MSELoss 计算的是 (input - target)^2 的均值,交换顺序对 MSE 无影响,但对交叉熵等损失函数顺序至关重要,习惯上应写成"预测在前,真值在后"。
七、几个必须澄清的概念
epoch、batch、step 不是一回事
- 样本(sample):一条数据;
- 批量(batch):一次前向/反向计算同时处理的一组样本;
- 迭代步(step / iteration):处理一个 batch 并更新一次参数;
- 轮次(epoch):把训练集完整过一遍。
若训练集有 10000 条样本,批量大小为 100,则 1 个 epoch = 100 个 step。这三个词在日常交流中被大量混用,写实验记录和复现他人结果时务必区分。
梯度下降是"贪心"的
每一步都只根据当前位置的局部斜率做决策,没有全局规划。这意味着:
- 损失曲面上如果存在"峡谷"地形,普通梯度下降会沿着陡峭方向来回震荡,收敛缓慢;
- 初始参数的位置会影响最终结果;
- 学习率过大时,可能直接跳过谷底,甚至越走越高。
训练不是"教会机器知识",而是"拟合数据分布"
模型学到的是训练数据中体现出来的统计规律。如果训练数据有偏、有噪声、覆盖不全,模型就会忠实地学到这些缺陷。这也是为什么数据工程在真实项目中往往比模型结构更耗时。
八、常见误区
误区一:损失降到 0 就是成功。
损失为 0 通常意味着模型把训练集"背"下来了,包括其中的噪声和标注错误,泛化能力反而更差。这是**过拟合(overfitting)**的典型信号。
误区二:训练集上的表现能代表真实能力。
不能。必须用模型从未见过的数据来评估。如何划分数据、如何避免信息泄漏,是下一篇要处理的问题。
误区三:梯度下降必然找到最优解。
只有凸问题才有这个保证。深度学习中我们追求的是"足够好的解",而非"全局最优解"。
误区四:参数越多越强。
参数数量决定模型的容量(capacity),容量必须与数据量和任务复杂度匹配。容量远大于需求时,正则化和早停等手段就变得必要。
误区五:调参就是调学习率。
学习率极其重要,但批量大小、初始化方式、优化器选择、损失函数设计同样影响结果,而且它们之间存在耦合。
九、适用边界与延伸方向
本文的闭环适用于任何基于梯度的训练,但有几个前提:
- 可导:损失函数必须对参数可导(或在几乎处处可导),否则无法直接用梯度下降。强化学习中的策略梯度、进化策略属于绕开这一限制的路线。
- 有监督信号:我们假设每条样本都有对应的目标值 (y)。无监督和自监督学习需要自己构造监督信号(比如用"预测被遮住的词"来替代人工标注)。
- 能承受计算量:每更新一次都要遍历一遍参与计算的样本。数据规模极大时,需要靠采样、分布式和梯度累积等手段。
从这三点出发,可以自然延伸出后续一系列问题:监督信号从哪来(学习范式)、如何保证评估可靠(数据划分与评估指标)、如何让更新更高效(优化器与学习率调度)、如何在有限显存下训练大模型(分布式与显存优化)。
十、小结
把训练的最小闭环压缩成一句话:
用当前参数做出预测,用损失函数衡量差距,用梯度指示调整方向,用学习率控制调整幅度,然后重复。
这句话在 20 行的 NumPy 脚本里成立,在千亿参数模型的训练框架里同样成立。区别只在于:模型更复杂、梯度靠自动微分、更新由优化器完成、计算被切分到成千上万张卡上。
理解了这个闭环,后面所有内容——损失函数的选择、优化器的演进、正则化的作用、分布式训练的动机——都能挂到这条主线上,而不是零散的知识点。
动手练习:保留上面这份 NumPy 脚本,试着在损失函数里加一项 (\lambda \lVert w \rVert^2)((\lambda) 取 0.01、0.1、1.0),观察学到的参数范数如何变化,以及损失下限如何上升。这一项就是 L2 正则化,它体现了"训练目标"与"损失函数"在加入惩罚项后并不完全等同。
- 点赞
- 收藏
- 关注作者
评论(0)