神经网络:从一次前向传播讲起
目标:彻底搞懂「一层网络在做什么」,以及为什么堆叠起来就变强。
不要从「神经元模拟大脑」开始学 —— 那是个好比喻,但不是好入口。从矩阵乘法 + 非线性开始,最快。
一个线性层就是一次矩阵乘法
python
import torch, torch.nn as nn
layer = nn.Linear(in_features=4, out_features=3)
x = torch.randn(8, 4) # 8 个样本,每个 4 个特征
y = layer(x) # → (8, 3)
print(y.shape) # torch.Size([8, 3])内部就是 $y = xW^\top + b$,其中 $W$ 是 (3,4)、$b$ 是 (3,)。
形状记忆法:(B, in) × (in, out) → (B, out)。nn.Linear(in, out) 里的两个数字就是这里。
为什么需要非线性
如果只有线性层,堆 100 层等价于 1 层:
$$W_2(W_1x) = (W_2W_1)x = Wx$$
没有非线性,深度毫无意义。 激活函数就是那个「让堆叠产生表达力」的东西。
| 激活函数 | 公式 | 特点 | 用在哪 |
|---|---|---|---|
| ReLU | $\max(0,x)$ | 简单、快、缓解梯度消失 | 隐藏层默认 |
| GELU | $x\Phi(x)$ | 平滑版 ReLU | Transformer 标配 |
| Sigmoid | $\frac{1}{1+e^{-x}}$ | 压到 (0,1) | 二分类输出 |
| Tanh | 压到 (-1,1) | RNN | |
| Softmax | $\frac{e^{z_i}}{\sum e^{z_j}}$ | 变成概率分布 | 多分类输出 |
python
# 一个完整的最小网络
net = nn.Sequential(
nn.Linear(784, 256), nn.ReLU(),
nn.Linear(256, 128), nn.ReLU(),
nn.Linear(128, 10), # 10 类,输出 logits(不接 softmax)
)输出层不要接 Softmax
用 nn.CrossEntropyLoss 时它内部已经包含 log-sum-exp,你输出 logits 即可。 手动加 Softmax 再交给 CrossEntropy = 做了两次,损失会算错。
前向传播:一次完整计算
python
x = torch.randn(32, 784) # 32 张 28×28 展平的图
h1 = torch.relu(x @ W1 + b1) # (32, 256)
h2 = torch.relu(h1 @ W2 + b2) # (32, 128)
logits = h2 @ W3 + b3 # (32, 10)
probs = torch.softmax(logits, dim=1) # 概率每一层做的事:把上一层的表示,变换成一个更有用的表示。
- 第一层学到边缘、颜色块
- 中间层学到纹理、局部形状
- 深层学到物体部件、整体语义
这就是「深度」的意义:逐层抽象。
反向传播:只要记住三件事
- 前向时框架记录了计算图(谁由谁算出)
loss.backward()沿图反向用链式法则求每个参数的梯度optimizer.step()用梯度更新参数
python
loss = criterion(net(x), y)
loss.backward() # 填好每个 param.grad
optimizer.step() # param -= lr * param.grad
optimizer.zero_grad() # 清空,为下一轮准备顺序不能错
zero_grad() 在最前或最后都行,但必须在 backward() 之前清空过一次。 漏了它,梯度会跨 batch 累加 —— 表现为 loss 剧烈震荡或直接 nan。
完整可运行例子:手写数字分类
python
import torch, torch.nn as nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
device = "cuda" if torch.cuda.is_available() else "cpu"
# 数据
tf = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))])
train_ds = datasets.MNIST("./data", train=True, download=True, transform=tf)
test_ds = datasets.MNIST("./data", train=False, transform=tf)
train_dl = DataLoader(train_ds, batch_size=128, shuffle=True)
test_dl = DataLoader(test_ds, batch_size=256)
# 模型
model = nn.Sequential(
nn.Flatten(),
nn.Linear(784, 256), nn.ReLU(), nn.Dropout(0.2),
nn.Linear(256, 128), nn.ReLU(),
nn.Linear(128, 10),
).to(device)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
for epoch in range(5):
model.train()
for xb, yb in train_dl:
xb, yb = xb.to(device), yb.to(device)
loss = loss_fn(model(xb), yb)
optimizer.zero_grad()
loss.backward()
optimizer.step()
# 验证
model.eval()
correct = total = 0
with torch.no_grad():
for xb, yb in test_dl:
pred = model(xb.to(device)).argmax(1).cpu()
correct += (pred == yb).sum().item()
total += yb.size(0)
print(f"epoch {epoch+1}: test acc = {correct/total:.4f}")跑完应看到 97%+。若不到 95%,检查 Normalize、model.train()/eval()、是否 shuffle=True。
参数量与显存估算
python
def count_params(model):
return sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"{count_params(model)/1e6:.2f}M")粗算规则:
| 项目 | 估算 |
|---|---|
| 参数显存 | 参数量 × 精度字节(fp32: 4B,fp16: 2B) |
| 优化器状态(Adam) | 额外 2 × 参数显存 |
| 激活值 | 与 batch × 序列长度 × 宽度正比,训练时的大头 |
| 经验值 | 训练 7B 模型全参数微调需 ~80GB+;推理 fp16 需 ~14GB |
常见误区
| 误区 | 正解 |
|---|---|
| 「层数越多越好」 | 没有残差/归一化,深了反而训不动(见 训练) |
| 「参数量大就更准」 | 数据不够时大模型更容易过拟合 |
| 「loss 降到 0 最好」 | 那是记住了训练集,测试集往往崩 |
| 「必须懂反向传播推导」 | 要懂「在做什么」,不必手推矩阵导数 |