NOTE这是一篇个人向的笔记,如果你感兴趣,不妨查阅一些更加专业的文章交叉阅读。 阅读前提:请先了解 PyTorch、NumPy的基础数据概念。并配置好深度学习环境(问 AI 就行,我这里是 Miniconda + dl/nerf 双环境)。
训练神经网络要做的事情便是给定一个输入,经过网络里一层层的计算(乘权重、加偏置),最终得到一个输出,我们每次训练都要让这个输出逼近那个正确的值。
为了衡量离正确答案有多远,我们引入 Loss(损失值),最经典的就是均方误差(平方确保偏差为正数,且放大偏差效应):
高数告诉我们:如果知道 Loss 对每个参数(旋钮)的敏感程度(即梯度/偏导数),我们就可以顺着这个敏感程度的反方向去微调参数,让loss减小。
Autograd#
在真正的神经网络(比如 NeRF 的 8 层全连接层)里,我们要算“最终的 Loss”对“第一层第三个权重 W 的敏感度,就要用到链式求导法则——把每一层对上一层的敏感度一路乘回去。PyTorch 提供了 Autograd避免我们手算,它让我们只管写前向传播(forward)的代码(一堆 Tensor 运算),反向求导(backward)PyTorch 全包。
计算图——Autograd核心机制#
当我们做运算时,比如 c = a + b,PyTorch 在内存里画一张图:
- 节点代表所有变量abc
- 边表示运算。
如果a被标记为参数,PyTorch 不但记录a参与了加法,还会记录加法的反向求导公式,方便backward。叶节点是计算图的起点,即我们亲自定义的那些参数。由于中间变量很多,PyTorch 做了一个极其高效的策略:
它只会把算出来的最终梯度,存放在叶节点(即定义的参数)的 .grad 属性里,至于中间变量的梯度直接丢弃以节约显存。
.backward()#
当我们算出loss后,调用 loss.backward():
- 开始先给出loss对自己的导数(即1),然后从Loss节点出发。
- PyTorch 倒着走,先遇到生成loss的操作,查看其记录的反向求导操作,得到流向下一层的数据。
- 如此,不断往上走,当遇到分叉时,分别计算不同分支的值,直到数据到达所有叶节点。把它们的敏感度存入
.grad属性,至此,反向传播结束。
WARNING
- PyTorch 的
backward()函数只能被标量调用,如果输出是向量,正确做法是先对向量求和(.sum())或求平均(.mean()),然后对其进行backward,这是 MSE Loss 的标准做法。数学依据:和的导数等于导数的和。- PyTorch 算出的梯度是累加的,而不是覆盖的。这意味着,如果你在循环里连续调用两次 backward,梯度会变成两倍叠加。在每次反向传播前,必须手动调用
optimizer.zero_grad()
用MLP拟合sin(x)#
MLP全称为多层感知机(Multilayer Perceptron),分为三层:
- 输入层(Input Layer):数据的入口。比如拟合
sin(x),这一层只有 1 个神经元(就是 x) - 隐藏层(Hidden Layers):中间夹着的多层结构。每一层一般都有几百神经元。“多层”指的就是这个部分。层数越多,网络能拟合的形状就越复杂。
- 输出层(Output Layer):最终结果的出口。
sin(x)输出 1 个数,所以是 1 个神经元
隐藏层每一个神经元的核心计算公式:当前神经元的值 = 激活函数( 上一层所有值的加权和 + 偏置 )
- 权重(
W):控制每个输入的重要性 - 偏置(
b):控制这个神经元被激活的难易程度
nn.Module#
在MLP里面参数可能数万个,手动定义手动跟踪梯度显然不合适,于是我们可以使用nn.Module这个基类解决这个参数管理问题。
nn.Linear内部#
nn.Linear是nn.Moudle的一个子类,它实现了一个仿射变换,可以看做是一个功能组件。
nn.Linear(in_features, out_features)本质上就是一个仿射变换:
为什么要这样写仿射变换(为何要求转置):在 PyTorch 里,数据的批次维度(Batch)永远放在最前面,输入的x通常是一批数据,假设x有2个样本,每个样本3个特征,则x形状(2, 3),可以表示为一个矩阵。我们的目标是把这3个特征映射为5个特征,那么y的期望形状为(2, 5)。由线性代数知识可知,带转置的仿射变换形式可以完美满足我们的需求。W 的shape是(out_features, in_features),b的shape是(out_features,)。下面的代码可以帮助理解:
1# 输入:3个样本,每个有4个特征2x = torch.randn(3, 4)3
4# 线性层:把4个特征映射到5个特征5layer = nn.Linear(in_features=4, out_features=5)6
7# 查看内部形状8print(layer.weight.shape) # 输出: torch.Size([5, 4]) —— 果然是 (out, in)9print(layer.bias.shape) # 输出: torch.Size([5]) —— 果然是 (out,)10
11# 前向传播12y = layer(x)13print(y.shape) # 输出: torch.Size([3, 5]) —— 果然是 (batch, out)当创建这一线性层时,nn.Linear 除了自动设置 requires_grad=True,还要进行合理的参数初始化。
- 权重W:默认采用 Kaiming 均匀分布(针对 ReLU 激活函数优化)
- 偏置b:默认初始化为0.
激活函数(ReLU)#
激活函数的作用是在层之间插入非线性(因为多线性叠加还是线性的),让网络真正具备拟合复杂函数的能力。这里我们用ReLU(max(0,x)),是最常用、计算最快的激活函数。
.backward()只是把梯度算出来存到.grad里,并不会自动更新参数。真正让参数往梯度反方向挪一点的是optimizer.step()(优化器更新)。以最基础的梯度下降为例,它内部做的事等价于:
我们这里用Adam而不是最朴素的SGD,因为Adam会给每个参数自适应地调整学习率(大致思路是:梯度在某一区间一直很大的参数学习率小一点、梯度一直很小的参数学习率大一点),收敛通常比朴素SGD快很多,也更不容易调参导致炸掉。
至此我们可以来看看训练循环的四步了:
1optimizer.zero_grad() # 先把上一轮残留的梯度清零2output = model(x) # forward:算预测值3loss = loss_fn(output, y_true) # 算loss(标量)4loss.backward() # backward:自动算出loss对每个参数的梯度,存入.grad5optimizer.step() # 用.grad里的梯度,更新每个参数代码实现#
下面我们用代码来拟合 sinx 曲线
1import torch2import torch.nn as nn3import matplotlib.pyplot as plt4
5device = torch.device("cuda" if torch.cuda.is_available() else "cpu")6print("使用设备:", device)7
8# --------- 准备数据 ---------9# 目标:让网络学会拟合 y = sin(x),x的范围取 [-2π, 2π]10# 用 500 个点作为训练数据11x_train = torch.linspace(-2 * torch.pi, 2 * torch.pi, 500).unsqueeze(1) # 输入特征,shape (500, 1)12y_train = torch.sin(x_train) # 真实值,shape (500, 1)13
14# linspace 在区间内均匀生成指定个数的数字15# unsqueeze 给数据加一个中括号:因为在 linspace 后得到的数字是shape(500, ),纯粹的一组数据16# unsqueeze(1) 把它从 (500,) 变成 (500,1),因为 nn.Linear 要求输入shape是 (batch数, 特征数)17
18x_train = x_train.to(device)19y_train = y_train.to(device)20
21# --------- 定义网络结构 ---------22class SineMLP(nn.Module):23 def __init__(self):24 super().__init__()25 # 3层MLP:1 -> 64 -> 64 -> 126 # 输入是x(1维),输出是预测的sin(x)(1维),中间隐藏层64个神经元27 self.net = nn.Sequential(nn.Linear(1, 64), nn.ReLU(), nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 1))28 # 注意:最后一层输出前不加ReLU29 # 因为sin(x)有负值,而ReLU会把所有负值截断成0,30 # 如果输出层前加ReLU,网络永远学不出负数,拟合会直接失败31
32 def forward(self, x):33 return self.net(x)34
35model = SineMLP().to(device) # 将模型参数搬到GPU36
37total_params = sum(p.numel() for p in model.parameters())38print("模型参数总数:", total_params)39
40loss_fn = nn.MSELoss() # 均方误差41optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)42# model.parameters() 会自动收集net里所有Linear层的W和b,一次性交给optimizer管理43
44# ---------- 训练循环 -----------45num_epochs = 200046loss_history = [] # 记录每轮的loss,用来画收敛曲线47
48for epoch in range(num_epochs):49 optimizer.zero_grad()50 y_pred = model(x_train)51 loss = loss_fn(y_pred, y_train)52 loss.backward()53 optimizer.step()54
55 loss_history.append(loss.item())56
57 if (epoch + 1) % 200 == 0:58 print(f"epoch {epoch+1}/{num_epochs}, loss = {loss.item():.6f}")59
60# ----------- 验证结果,画图对比 ---------------61# 用no_grad(),模型已经训练完成,只是推理而不是训练,所以我们不需要反向传播62with torch.no_grad():63 y_pred_final = model(x_train)64
65# 将显卡数据传输到CPU,并转换成python绘图库(matplotlib)能认识的格式66# 把 PyTorch 的张量(Tensor)格式,转换成 NumPy 的数组(ndarray)格式。67x_plot = x_train.cpu().numpy()68y_true_plot = y_train.cpu().numpy()69y_pred_plot = y_pred_final.cpu().numpy()70
71fig, axes = plt.subplots(1, 2, figsize=(12, 5))72
73axes[0].plot(x_plot, y_true_plot, label="true sin(x)", linewidth=2)74axes[0].plot(x_plot, y_pred_plot, label="MLP prediction", linestyle="--")75axes[0].legend()76axes[0].set_title("Fit result")77
78axes[1].plot(loss_history)79axes[1].set_yscale("log") # loss用log坐标看得更清楚,因为它下降幅度很大80axes[1].set_title("Loss curve (log scale)")81axes[1].set_xlabel("epoch")82
83plt.tight_layout()84plt.savefig("sine_fit_result.png", dpi=120)85plt.show()86print("图已保存为 sine_fit_result.png")如果一切顺利,你应该会看到以下输出:
1使用设备: cuda2模型参数总数: 43533epoch 200/2000, loss = 0.0759574epoch 400/2000, loss = 0.0095735epoch 600/2000, loss = 0.0013826epoch 800/2000, loss = 0.0004277epoch 1000/2000, loss = 0.0001768epoch 1200/2000, loss = 0.0017639epoch 1400/2000, loss = 0.00007610epoch 1600/2000, loss = 0.00005711epoch 1800/2000, loss = 0.00004712epoch 2000/2000, loss = 0.00004913图已保存为 sine_fit_result.png
拟合曲线与loss趋势
IMPORTANT看loss曲线(右图),有个规律性的现象:loss在整体下降的过程中,会周期性地出现向上的尖峰,而且这些尖峰随着训练推进幅度越来越大、间隔越来越短(从epoch~500附近开始出现,到后面越来越密集)。
原因:Adam优化器本身的机制,Adam会维护每个参数的梯度”历史一阶矩”和”二阶矩”(滑动平均),用二阶矩去自适应缩放每一步的更新幅度。当loss越降越低、逼近一个很窄的”山谷”(loss landscape在某个方向上曲率变化剧烈)时,Adam的自适应步长机制有时会导致某一步的更新”迈过头”,loss瞬间跳高一下,然后下一步Adam根据这个新的大梯度重新自适应调整,loss又迅速被拉回来、继续下降。训练越到后期,loss landscape越陡峭尖锐(因为在逼近一个精确解),这种”过冲—修正”就越容易发生、越频繁
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
