神经网络与反向传播:模型为什么能学习
CNN、LSTM、GRU、Transformer 和 ViT 的结构不同,但训练过程完全遵循同一条主线:模型根据参数做出预测,损失函数衡量预测误差,反向传播计算每个参数对误差的影响,优化器再调整参数。
输入 x → 模型 f(x; θ) → 预测值 → 损失 L → 梯度 ∂L/∂θ → 更新参数 θ注意:Loss 下降不等于模型可以上线
训练集和验证集必须严格隔离,归一化统计量只能由训练集计算;上线前还要检查类别偏差、异常输入、概率校准和回滚方案。本文的小数据案例用于观察梯度变化,不能据此判断招聘、医疗、信贷或设备安全等高风险结果。
1. 从一个神经元开始
一个线性神经元先对输入做加权求和,再经过激活函数:
其中
常见激活函数:
| 激活函数 | 表达式 | 特点 |
|---|---|---|
| ReLU | 计算简单,是 CNN 和 MLP 的常用默认选择 | |
| Sigmoid | 输出在 0 到 1,常用于门控和二分类输出 | |
| Tanh | 输出在 -1 到 1,传统循环网络中常见 | |
| GELU | 平滑,Transformer 中常见 |
2. 全连接层究竟做了什么
设输入 x.shape == [B, D_in],全连接层有 D_out 个神经元,则:
形状关系为:
X: [B, D_in]
W: [D_out, D_in]
b: [D_out] # 广播到一批样本
Y: [B, D_out]PyTorch 的 nn.Linear(in_features, out_features) 将权重保存为 [out_features, in_features],所以前向传播使用的是 x @ weight.T。
下面直接复现 nn.Linear 的核心计算:
import torch
from torch import nn
torch.manual_seed(7)
x = torch.randn(4, 3) # 4 个样本,每个样本 3 个特征
layer = nn.Linear(3, 2) # 输出 2 维
y_api = layer(x)
y_manual = x @ layer.weight.T + layer.bias
print(y_api.shape) # torch.Size([4, 2])
print(torch.allclose(y_api, y_manual)) # True3. 损失函数把“好坏”变成一个数
以 logits 是未归一化分数。Softmax 把分数变成概率:
交叉熵只关心正确类别
实际代码应直接使用 nn.CrossEntropyLoss(),输入原始 logits,不要提前手动 Softmax。它内部组合了数值更稳定的 log_softmax 与负对数似然:
logits = torch.tensor([[2.0, 0.5, -1.0]])
target = torch.tensor([0])
criterion = nn.CrossEntropyLoss()
loss = criterion(logits, target)
print(loss.item())如果先 Softmax 再传给 CrossEntropyLoss,相当于把概率当 logits 再归一化一次,目标函数已经改变。
4. 反向传播就是链式法则
考虑一个很小的计算:
参数
PyTorch 会在前向计算时记录运算关系,调用 backward() 后沿图反向应用局部导数:
import torch
x = torch.tensor(2.0)
y = torch.tensor(9.0)
w = torch.tensor(1.5, requires_grad=True)
b = torch.tensor(0.5, requires_grad=True)
z = w * x + b
y_hat = z ** 2
loss = (y_hat - y) ** 2
loss.backward()
manual_grad_w = 2 * (y_hat.detach() - y) * 2 * z.detach() * x
print(w.grad, manual_grad_w) # 两者相等4.1 为什么每轮要 zero_grad
PyTorch 默认把新梯度累加到参数已有的 .grad 上。这对梯度累积有用,但常规训练时必须在每一步反向传播前清空:
optimizer.zero_grad() # 清空上一轮梯度
loss.backward() # 计算这一轮梯度
optimizer.step() # 用梯度更新参数4.2 detach、no_grad 和 eval 不一样
| 写法 | 作用 |
|---|---|
tensor.detach() | 返回与原张量共享数据、但脱离当前计算图的张量 |
with torch.no_grad() | 代码块内不记录梯度,适合验证和推理 |
model.eval() | 切换 Dropout、BatchNorm 等层的行为,不等于关闭梯度 |
正确的验证通常同时使用 model.eval() 和 torch.no_grad()。
5. 从零组织一个可训练的 MLP
下面用二维 XOR 数据验证完整训练闭环。代码不依赖数据集下载,可以直接运行:
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
torch.manual_seed(42)
# 生成带少量噪声的 XOR 数据
n = 2000
x = torch.rand(n, 2) * 2 - 1
y = ((x[:, 0] > 0) ^ (x[:, 1] > 0)).long()
x = x + 0.08 * torch.randn_like(x)
loader = DataLoader(TensorDataset(x, y), batch_size=64, shuffle=True)
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(2, 16), # [B, 2] -> [B, 16]
nn.ReLU(), # 非线性使网络能拟合 XOR
nn.Linear(16, 16), # 在隐藏空间继续组合特征
nn.ReLU(),
nn.Linear(16, 2), # 输出两个类别的 logits
)
def forward(self, inputs):
return self.net(inputs)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = MLP().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-2)
for epoch in range(30):
model.train()
total_loss = 0.0
correct = 0
total = 0
for features, targets in loader:
features, targets = features.to(device), targets.to(device)
optimizer.zero_grad()
logits = model(features)
loss = criterion(logits, targets)
loss.backward()
optimizer.step()
total_loss += loss.item() * targets.size(0)
correct += (logits.argmax(dim=1) == targets).sum().item()
total += targets.size(0)
if (epoch + 1) % 5 == 0:
print(f"epoch={epoch + 1:02d} "
f"loss={total_loss / total:.4f} "
f"acc={correct / total:.3f}")
model.eval()
with torch.no_grad():
test = torch.tensor([[0.8, 0.7], [0.8, -0.7]], device=device)
print(model(test).argmax(dim=1).cpu()) # 预期 tensor([0, 1])5.1 逐段解读训练代码
nn.Module是所有模型的基类。把子层赋值给self.xxx时,它们会被自动注册。model.parameters()递归找到所有nn.Parameter,优化器因此知道要更新谁。model(features)最终调用自定义的forward,不要在训练代码里手动写model.forward(...)。loss.backward()只负责把梯度写入参数的.grad。optimizer.step()读取.grad并原地修改参数,两步职责不同。loss.item()取出 Python 数值用于日志;不要把.item()后的数参与反向传播。
6. nn.Module 源码的关键机制
不需要一开始就读完整个 PyTorch 仓库,先抓住以下调用路径:
model(x)
└─ Module.__call__
└─ Module._call_impl
├─ 执行 forward 前置 hook
├─ 调用子类 forward
└─ 执行 forward/ backward hook6.1 参数为什么会自动出现
当执行 self.fc = nn.Linear(...) 时,Module.__setattr__ 识别到右侧也是 Module,将它放进 _modules。nn.Linear 内部的 weight 和 bias 是 Parameter,会进入 _parameters。因此:
model = MLP()
print(model._modules.keys())
for name, parameter in model.named_parameters():
print(name, tuple(parameter.shape), parameter.requires_grad)可以看到类似:
net.0.weight (16, 2) True
net.0.bias (16,) True
net.2.weight (16, 16) True
...普通 Tensor 不会自动成为可训练参数。下面的 scale 不会出现在 model.parameters() 中,必须写成 nn.Parameter(torch.ones(1)) 或使用已有层:
class WrongLayer(nn.Module):
def __init__(self):
super().__init__()
self.scale = torch.ones(1, requires_grad=True) # 不是注册参数6.2 Linear 的职责边界
从 Python 层看,nn.Linear.forward 基本只把参数交给函数式接口:
def forward(self, input):
return torch.nn.functional.linear(input, self.weight, self.bias)函数式接口最终进入底层张量算子完成矩阵乘法与加偏置。模块层主要负责持有参数、初始化和展示;真正的大规模数值计算交给优化过的 CPU/CUDA 内核。
7. 参数是怎样更新的
最简单的随机梯度下降为:
step() 的本质:
model = nn.Linear(2, 1)
x = torch.tensor([[1.0, 2.0]])
y = torch.tensor([[1.0]])
prediction = model(x)
loss = ((prediction - y) ** 2).mean()
loss.backward()
learning_rate = 0.1
with torch.no_grad(): # 参数更新本身不能进入计算图
for parameter in model.parameters():
parameter -= learning_rate * parameter.grad
parameter.grad.zero_()Adam 还会保存梯度的一阶、二阶矩估计,对每个参数自适应调整步长。它通常更容易作为初始选择,但并不意味着一定比 SGD 得到更好的泛化结果。
8. 梯度问题如何排查
8.1 梯度为 None
常见原因:参数没有参与损失计算、误用了 detach()/no_grad()、参数没有注册,或者在得到损失前调用了 .item()。
for name, parameter in model.named_parameters():
status = None if parameter.grad is None else parameter.grad.norm().item()
print(name, status)8.2 梯度爆炸或出现 NaN
先降低学习率并检查输入尺度,再打印损失和梯度是否为有限值。循环网络中可使用梯度裁剪:
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()注意:发现 NaN 后不要继续保存或发布权重
先停止该次训练,保留出现 NaN 前的 checkpoint、batch 和随机种子,再检查输入有限性、除零、对数定义域、混合精度溢出和学习率。梯度裁剪只能限制范数,不能修复脏数据或错误公式;含 NaN 的优化器状态也不能靠下一轮自动恢复。
8.3 原地操作报错
自动微分可能需要保留前向值计算梯度。对参与计算图的张量执行不安全的 x += ...,会破坏版本记录。除非明确知道梯度公式不依赖原值,否则优先使用非原地写法 x = x + ...。
9. 完整案例:一次“是否通过面试”预测怎样改变权重
假设已经把“项目经验”和“面试表现”缩放为两个数。样本 x=[0.5, 1.0] 表示项目经验中等、面试表现较好,真实标签 y=1 表示通过。先用一个 Sigmoid 神经元观察一次更新,初始参数不是训练结果,而是固定随机初始化后的简化数值:
二元交叉熵与 Sigmoid 组合后,
学习率为 0.1 时:
更新后同一样本的通过概率从 0.5000 变为约 0.5281,损失从 0.6931 降到约 0.6385。这就是“反向传播让模型学习”最直接的数值变化。
下面代码先复现这一步,再用四条候选人记录继续训练 20 轮:
import torch
import torch.nn.functional as F
from torch import nn
torch.manual_seed(0)
# 每行:[归一化项目经验, 归一化面试表现]
features = torch.tensor([
[ 0.5, 1.0], # 表现较好,通过
[ 1.0, 0.6], # 经验丰富,通过
[-0.8, -0.5], # 两项偏低,不通过
[-0.4, -1.0], # 面试较差,不通过
])
targets = torch.tensor([[1.0], [1.0], [0.0], [0.0]])
model = nn.Linear(2, 1)
with torch.no_grad():
model.weight.copy_(torch.tensor([[0.2, -0.1]]))
model.bias.zero_()
# 第一个样本的一次前向传播
x_one, y_one = features[:1], targets[:1]
weight_before = model.weight.detach().clone()
logit = model(x_one)
probability = torch.sigmoid(logit)
loss = F.binary_cross_entropy_with_logits(logit, y_one)
print("初始 W:", weight_before)
print("z, p, loss:", logit.item(), probability.item(), loss.item())
# 反向传播只计算梯度,尚未改参数
loss.backward()
print("dL/dW:", model.weight.grad) # tensor([[-0.2500, -0.5000]])
print("dL/db:", model.bias.grad) # tensor([-0.5000])
# SGD 的一步更新
learning_rate = 0.1
with torch.no_grad():
model.weight -= learning_rate * model.weight.grad
model.bias -= learning_rate * model.bias.grad
model.weight.grad.zero_()
model.bias.grad.zero_()
new_probability = torch.sigmoid(model(x_one))
new_loss = F.binary_cross_entropy_with_logits(model(x_one), y_one)
print("更新后 W:", model.weight.detach())
print("更新后 p, loss:", new_probability.item(), new_loss.item())
# 在全部四条记录上继续训练,观察多轮损失变化
optimizer = torch.optim.SGD(model.parameters(), lr=0.2)
for epoch in range(1, 21):
optimizer.zero_grad()
logits = model(features)
batch_loss = F.binary_cross_entropy_with_logits(logits, targets)
batch_loss.backward()
optimizer.step()
if epoch in {1, 5, 10, 20}:
print(f"epoch={epoch:02d}, loss={batch_loss.item():.4f}")
with torch.no_grad():
final_probabilities = torch.sigmoid(model(features)).squeeze(1)
print("最终 W:", model.weight.detach())
print("最终预测概率:", final_probabilities)源代码中的状态变化应按时间顺序理解:
forward:Parameter 不变,生成 logit 和 loss
backward:Parameter 数值仍不变,只向 Parameter.grad 写入梯度
step/手动更新:读取 grad,原地修改 Parameter
下一轮 forward:使用新 Parameter,所以概率和损失发生变化这里使用 binary_cross_entropy_with_logits,而不是先 Sigmoid 再取 Log,因为组合函数会采用更稳定的数学形式。这个案例只有两个特征,CNN、LSTM 和 Transformer 的参数更多,但每个参数的变化仍遵循同一过程。
10. 最小自检清单
- 分类模型最后一层输出 logits,训练前不手动 Softmax。
- 训练循环包含
zero_grad → forward → loss → backward → step。 - 验证时同时调用
model.eval()和torch.no_grad()。 - 先用很小的一批数据尝试过拟合;如果连小批数据都学不会,优先检查实现而不是增加数据。
- 记录输入、每层输出和标签形状,尤其关注 batch 维是否被误删。
- 固定随机种子只能提高可复现性,GPU 上某些算子仍可能存在非确定性。
掌握这一篇之后,后续所有模型只是换了一种更适合数据结构的 forward,训练闭环本身不会改变。