Skip to content
发布于 更新于

神经网络与反向传播:模型为什么能学习 ​

CNN、LSTM、GRU、Transformer 和 ViT 的结构不同,但训练过程完全遵循同一条主线:模型根据参数做出预测,损失函数衡量预测误差,反向传播计算每个参数对误差的影响,优化器再调整参数。

text
输入 x → 模型 f(x; θ) → 预测值 → 损失 L → 梯度 ∂L/∂θ → 更新参数 θ

注意:Loss 下降不等于模型可以上线

训练集和验证集必须严格隔离,归一化统计量只能由训练集计算;上线前还要检查类别偏差、异常输入、概率校准和回滚方案。本文的小数据案例用于观察梯度变化,不能据此判断招聘、医疗、信贷或设备安全等高风险结果。

1. 从一个神经元开始 ​

一个线性神经元先对输入做加权求和,再经过激活函数:

z=xw+b,y=σ(z)

其中 w 和 b 是需要学习的参数。只有线性变换时,多层网络仍然可以合并成一个线性变换;激活函数引入非线性后,网络才能拟合弯曲的决策边界。

常见激活函数:

激活函数表达式特点
ReLUmax(0,x)计算简单,是 CNN 和 MLP 的常用默认选择
Sigmoid1/(1+e−x)输出在 0 到 1,常用于门控和二分类输出
Tanhtanh⁡(x)输出在 -1 到 1,传统循环网络中常见
GELUxΦ(x)平滑,Transformer 中常见

2. 全连接层究竟做了什么 ​

设输入 x.shape == [B, D_in],全连接层有 D_out 个神经元,则:

Y=XWT+b

形状关系为:

text
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 的核心计算:

python
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))  # True

3. 损失函数把“好坏”变成一个数 ​

以 K 分类为例,模型输出的 logits 是未归一化分数。Softmax 把分数变成概率:

pk=ezk∑j=1Kezj

交叉熵只关心正确类别 y 的概率:

L=−log⁡py

实际代码应直接使用 nn.CrossEntropyLoss(),输入原始 logits,不要提前手动 Softmax。它内部组合了数值更稳定的 log_softmax 与负对数似然:

python
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. 反向传播就是链式法则 ​

考虑一个很小的计算:

z=wx+b,y^=z2,L=(y^−y)2

参数 w 对损失的影响需要沿计算图从后向前相乘:

∂L∂w=∂L∂y^∂y^∂z∂z∂w=2(y^−y)⋅2z⋅x

PyTorch 会在前向计算时记录运算关系,调用 backward() 后沿图反向应用局部导数:

python
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 上。这对梯度累积有用,但常规训练时必须在每一步反向传播前清空:

python
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 数据验证完整训练闭环。代码不依赖数据集下载,可以直接运行:

python
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 逐段解读训练代码 ​

  1. nn.Module 是所有模型的基类。把子层赋值给 self.xxx 时,它们会被自动注册。
  2. model.parameters() 递归找到所有 nn.Parameter,优化器因此知道要更新谁。
  3. model(features) 最终调用自定义的 forward,不要在训练代码里手动写 model.forward(...)。
  4. loss.backward() 只负责把梯度写入参数的 .grad。
  5. optimizer.step() 读取 .grad 并原地修改参数,两步职责不同。
  6. loss.item() 取出 Python 数值用于日志;不要把 .item() 后的数参与反向传播。

6. nn.Module 源码的关键机制 ​

不需要一开始就读完整个 PyTorch 仓库,先抓住以下调用路径:

text
model(x)
  └─ Module.__call__
      └─ Module._call_impl
          ├─ 执行 forward 前置 hook
          ├─ 调用子类 forward
          └─ 执行 forward/ backward hook

6.1 参数为什么会自动出现 ​

当执行 self.fc = nn.Linear(...) 时,Module.__setattr__ 识别到右侧也是 Module,将它放进 _modules。nn.Linear 内部的 weight 和 bias 是 Parameter,会进入 _parameters。因此:

python
model = MLP()

print(model._modules.keys())
for name, parameter in model.named_parameters():
    print(name, tuple(parameter.shape), parameter.requires_grad)

可以看到类似:

text
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)) 或使用已有层:

python
class WrongLayer(nn.Module):
    def __init__(self):
        super().__init__()
        self.scale = torch.ones(1, requires_grad=True)  # 不是注册参数

6.2 Linear 的职责边界 ​

从 Python 层看,nn.Linear.forward 基本只把参数交给函数式接口:

python
def forward(self, input):
    return torch.nn.functional.linear(input, self.weight, self.bias)

函数式接口最终进入底层张量算子完成矩阵乘法与加偏置。模块层主要负责持有参数、初始化和展示;真正的大规模数值计算交给优化过的 CPU/CUDA 内核。

7. 参数是怎样更新的 ​

最简单的随机梯度下降为:

θt+1=θt−η∇θL

η 是学习率。下面不用优化器,手动更新一次参数,可以看清 step() 的本质:

python
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()。

python
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 ​

先降低学习率并检查输入尺度,再打印损失和梯度是否为有限值。循环网络中可使用梯度裁剪:

python
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 神经元观察一次更新,初始参数不是训练结果,而是固定随机初始化后的简化数值:

W=[0.2,−0.1],b=0,z=xWT+b=0p=σ(z)=0.5,L=−log⁡(p)=0.6931

二元交叉熵与 Sigmoid 组合后,∂L/∂z=p−y=−0.5,因此:

∂L∂W=(p−y)x=[−0.25,−0.5]

学习率为 0.1 时:

Wnew=W−0.1∇WL=[0.225,−0.05],bnew=0.05

更新后同一样本的通过概率从 0.5000 变为约 0.5281,损失从 0.6931 降到约 0.6385。这就是“反向传播让模型学习”最直接的数值变化。

下面代码先复现这一步,再用四条候选人记录继续训练 20 轮:

python
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)

源代码中的状态变化应按时间顺序理解:

text
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,训练闭环本身不会改变。

基于 VitePress + GitHub Actions 自动部署