很多人学 PyTorch,第一个正儿八经的练手项目就是自己写一个两层神经网络。这个项目看着简单,其实把前向传播、反向传播、梯度下降、损失函数、优化器这些深度学习的核心机制全串了一遍。我见过不少新手在这里卡住,不是不知道公式,而是不知道代码每一行到底在做什么,维度是怎么对的,梯度是怎么回传的。所以我一直觉得,两层的全连接网络值得每个入门者认真手写一次,它比直接调框架里的高级模型更能帮你建立直觉。
这篇文章就专注一件事:用 PyTorch 从零实现一个两层神经网络,并把它训练起来。我会把网络结构怎么定、维度怎么算、梯度怎么回传、代码怎么写、踩坑怎么排查全部拆开讲。适合有一定 Python 基础、想真正理解深度学习训练流程的人,也适合已经跑过一些框架 Demo 但想回头补齐底层细节的读者。
1. 两层神经网络到底在算什么
1.1 单层与两层的本质区别:非线性是灵魂
先看单层网络长什么样。输入一个特征向量 x,经过一个线性变换 y = xW + b,输出一个结果。这个结构不管怎么调权重,本质上就是在学习一个线性模型。在线性回归里它是直线,在逻辑回归里是一条直线分割平面。问题是,现实里很多问题并不是一条直线能分得开的。
经典的例子是异或问题。四个点分布在平面四个角落,对角线上的点属于同一类,这种分布无论如何画一条直线都没法分开。单层网络遇到这种问题基本无解,因为它的决策边界天生只能是线性的。
两层网络就不一样了。它在输入和输出之间多了一个隐藏层,并且在隐藏层后面加了一个非线性激活函数,比如 ReLU。于是整个计算变成了:
h = ReLU(X @ W1 + b1) y = h @ W2 + b2中间的 ReLU 会把负值截成 0,这一下就让网络能把直线“折”起来。多个隐藏神经元组合起来,就能拼出复杂的非线性决策边界。你可以简单理解为:单层网络只能画直线,两层网络能画折线,层数更多、神经元更多,就能画更复杂的曲线。
这也是为什么标题写的是“两层神经网络”,而不是“两个线性层”。如果中间没有激活函数,两层线性变换叠在一起,数学上可以合并成一个线性变换,那和一层没有任何区别。激活函数才是整个结构的灵魂。
1.2 输入、隐藏、输出三个维度怎么定
刚接触神经网络的人,最容易挂在维度上。其实维度规则非常固定,只要记住三个数怎么来就行。
输入维度 input_dim 由数据的特征数量决定。你的每条样本有几个数,input_dim 就是几。比如一个人有三个特征:年龄、收入、消费分数,那 input_dim = 3。如果输入是一张 28x28 的图片,通常先把图片展平成 784 个数,那 input_dim = 784。
输出维度 output_dim 由任务决定。二分类问题输出一个值就行,比如 0 到 1 之间代表某个类别的概率;多分类问题有 C 个类别,输出就是 C 个数,每个数对应一个类别的得分。回归问题通常只输出一个连续值。
隐藏层维度 hidden_dim 是自由超参数,需要自己定。它决定了网络的学习能力。太小了学不动,太大了容易过拟合,而且训练变慢。入门阶段设 10 到 128 之间都很常见。
维度确定后,权重矩阵的形状跟着就固定了:
| 参数 | 形状 | 说明 |
|---|---|---|
| W1 | [input_dim, hidden_dim] | 输入层到隐藏层的权重 |
| b1 | [hidden_dim] | 隐藏层偏置 |
| W2 | [hidden_dim, output_dim] | 隐藏层到输出层的权重 |
| b2 | [output_dim] | 输出层偏置 |
举个例子。平面上有正负两类点,每个点只有两个坐标,所以 input_dim = 2。我们想用神经网络判断它属于哪一类,二分类,所以 output_dim = 1。隐藏层先定为 10。于是 W1 形状是 [2, 10],b1 是 [10],W2 是 [10, 1],b2 是 [1]。是不是一下就清晰了?
2. 核心细节拆解:前向、反向与参数更新
2.1 前向传播:矩阵乘法就是一次坐标变换
前向传播是数据从输入到输出的计算过程。我们一次喂进去的是一个批次的数据,形状是 [batch_size, input_dim]。每一行是一条样本,每一列是一个特征。
第一层计算是 X @ W1 + b1。这里的 @ 是矩阵乘法,不是逐元素乘法。矩阵乘法的规则是左边矩阵的最后一维必须等于右边矩阵的倒数第二维。X 的形状是 [N, 2],W1 的形状是 [2, 10],相乘得到 [N, 10]。这个结果的物理含义是:每一条原始样本都被映射到了一个 10 维的中间空间。
然后过 ReLU 激活函数。ReLU 就是 max(0, x),把负数清零,正数保留。这一步引入非线性。第二层计算是 h @ W2 + b2,h 是 [N, 10],W2 是 [10, 1],得到 [N, 1] 的输出。
如果做二分类,建议输出层不要加 ReLU,也不要自己手动加 Sigmoid。PyTorch 里有个损失函数叫 BCELoss,它要求模型输出已经是概率值,所以你需要提前加 Sigmoid。但我更推荐用 BCEWithLogitsLoss,它内部把线性输出和 Sigmoid 合并计算,数值稳定性更好。意味着 forward 里最后一层直接输出裸的 logits 就行。
对应代码就是这样:
class TwoLayerNet(nn.Module): def __init__(self, input_dim, hidden_dim, output_dim): super().__init__() self.fc1 = nn.Linear(input_dim, hidden_dim) self.relu = nn.ReLU() self.fc2 = nn.Linear(hidden_dim, output_dim) def forward(self, x): x = self.fc1(x) x = self.relu(x) x = self.fc2(x) return xnn.Linear 内部已经包含了权重和偏置,你不用自己手动维护 W 和 b,它自己会完成 x @ W.T + b。这一点容易被忽略,很多新手手动实现线性层时把 W 写反了,导致维度报错。用 nn.Linear 就省去了这个麻烦。
2.2 反向传播:PyTorch 帮我们做了哪件事
正向往前算出了预测值,接下来要计算损失,然后更新参数。更新参数需要知道每个参数对损失的影响程度,也就是梯度。手动推导两层网络的梯度其实只有几条链式法则公式,但真正写起来很繁琐,尤其是当网络变大时。
PyTorch 的自动求导机制把这些全包了。你只需要在定义了 model 之后,对算出来的 loss 调用 loss.backward(),PyTorch 就会沿着计算图,把 loss 对每个需要梯度的参数求导,并把结果存在对应张量的 .grad 属性里。
这里有一个关键点:梯度默认是累加的。也就是说,如果你调用了两次 backward,第二次的梯度会加到第一次的梯度上。所以每轮更新参数之前,必须把梯度清零。对应的代码是:
optimizer.zero_grad() loss.backward() optimizer.step()顺序不能乱。zero_grad 清零,backward 算梯度,step 用梯度更新参数。新手最常见的错误之一就是忘了 zero_grad,结果梯度不断累积,参数更新越来越离谱,loss 曲线变得乱七八糟。
另外,在验证或测试阶段,模型不需要计算梯度,这时应该用 torch.no_grad() 包裹起来。它既不会构建计算图,也能省内存和加快速度。虽然对小网络来说省不了多少,但这个习惯一定要养成。
2.3 损失函数与优化器的选择逻辑
损失函数选什么,取决于任务。不要乱选。
| 任务类型 | 推荐损失函数 | 说明 |
|---|---|---|
| 二分类 | BCEWithLogitsLoss | 内部合并 Sigmoid,数值稳定 |
| 多分类 | CrossEntropyLoss | 内部合并 Softmax,输出 C 个 logits |
| 回归 | MSELoss | 输出一个连续值,直接算均方误差 |
很多教程会告诉你用 CrossEntropyLoss,但它其实包含两部:先对 logits 算 Softmax 转成概率,再算交叉熵。所以网络输出层不要额外加 Softmax,直接输出原始 logits 就行。这一点和 BCEWithLogitsLoss 的逻辑是一致的,都是把激活函数并进损失函数里,避免中间过程出现数值溢出。
优化器方面,最基础的是 SGD,公式是 w = w - lr * grad。它简单直观,适合理解原理。Adam 增加了自适应学习率,通常收敛更快,对新手更友好。但你我都清楚,如果只是学原理,先用 SGD 会让你更清楚地看见梯度下降的每一步。学习率怎么定?SGD 通常从 0.01 到 0.1 开始试;Adam 一般从 1e-3 开始试。跑几轮看看 loss 是下降还是震荡再微调。
还有一个概念叫 batch size,也就是每次喂给模型多少条样本。全量数据一起算叫批量梯度下降,一次只喂一条叫随机梯度下降,折中叫 Mini-batch。batch size 越大,梯度方向越稳定;越小,梯度噪声越大,但有时候能帮助跳出局部最优。入门阶段先用一个固定的 batch 跑通,再调它。
3. 实操过程:从零搭建并训练一个两层网络
3.1 先造一份“看得见”的数据集
不要一上来就套用数据集,建议先自己生成一份二维的二分类数据。原因很简单:数据只有两个特征,可以直接画在平面上,你一眼就能看出网络到底学到了什么。如果用一个高维数据集,训练时你只能看 loss 数字,完全不知道模型内部学成了什么样。
生成数据的逻辑很简单:正类样本集中在右上角,负类样本集中在左下角。这样两个类别是可分的,但边界有重叠,网络需要学一条非线性边界才能达到较好的效果。
import torch import torch.nn as nn import torch.optim as optim torch.manual_seed(0) # 正类:中心在 (2, 2) X1 = torch.randn(200, 2) + torch.tensor([2.0, 2.0]) # 负类:中心在 (-2, -2) X2 = torch.randn(200, 2) + torch.tensor([-2.0, -2.0]) X = torch.cat([X1, X2], dim=0) y = torch.cat([torch.ones(200), torch.zeros(200)]).float() # 标准化,让数据均值为 0,标准差为 1 X = (X - X.mean(dim=0)) / X.std(dim=0) # 数据划分:前 320 条训练,后 80 条测试 X_train, X_test = X[:320], X[320:] y_train, y_test = y[:320], y[320:]标准化的习惯建议早点养成。如果特征的值一个在几万、一个在零点几,梯度更新时不同维度的尺度差异会非常大,训练会非常不稳定。虽然这里生成的数据本来就在 2 附近,但我还是顺手做了标准化,就是希望你以后面对真实数据时也能条件反射地做这一步。
3.2 完整代码:网络定义与训练循环
网络定义就按前面写的 TwoLayerNet 类。实例化的时候指定维度,hidden_dim 可以先取 10。
input_dim = 2 hidden_dim = 10 output_dim = 1 model = TwoLayerNet(input_dim, hidden_dim, output_dim) criterion = nn.BCEWithLogitsLoss() optimizer = optim.SGD(model.parameters(), lr=0.1)训练循环是整个项目的核心。每轮迭代做五件事:前向传播、计算损失、清零梯度、反向传播、更新参数。
epochs = 200 model.train() for epoch in range(epochs): # 前向传播 logits = model(X_train) loss = criterion(logits.squeeze(), y_train) # 反向传播与参数更新 optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 20 == 0: print(f"epoch {epoch}, loss {loss.item():.4f}")注意 logits.squeeze() 这一步。模型输出的形状是 [320, 1],而 y_train 的形状是 [320]。两者计算损失时形状不匹配,虽然 PyTorch 有时候会自动广播,但很容易产生隐藏错误。用 squeeze() 去掉维度为 1 的那一维,让形状对齐,保险。
训练结束后测试一下效果:
model.eval() with torch.no_grad(): logits = model(X_test) preds = (logits.squeeze() > 0).int() correct = (preds == y_test.int()).float().mean().item() print(f"test accuracy: {correct:.4f}")这里的关键点是,BCEWithLogitsLoss 的输出是裸的 logits,所以预测时判断 logits 是否大于 0,就相当于是 Sigmoid 后的概率是否大于 0.5。不需要再额外套 Sigmoid。
如果你想写得更简洁,也可以用 nn.Sequential 一步搭完:
model = nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, output_dim), )两种方式等价。继承 nn.Module 的好处是灵活,以后想在 forward 里加更多逻辑时方便。Sequential 适合快速验证。我建议入门时用继承方式,打印每一层参数、观察中间结果都更直观。
3.3 训练指标解读与调参方向
训练跑完后,你可能会看到这样的输出:
epoch 0, loss 0.6931 epoch 20, loss 0.6112 epoch 40, loss 0.4805 epoch 60, loss 0.3324 epoch 80, loss 0.2268 epoch 100, loss 0.1582 epoch 120, loss 0.1140 epoch 140, loss 0.0846 epoch 160, loss 0.0642 epoch 180, loss 0.0498loss 从 0.6931 附近开始下降,这个数字是有意义的。二分类交叉熵的初始值,在模型还没学到任何信息时,正好接近 ln(2) ≈ 0.6931。如果你看到 loss 一直停在 0.693 附近不动,说明模型完全没有学进去,基本都在输出 0.5 的概率。
测试准确率如果在 0.95 以上,说明这个两层网络已经成功学会了这个二分类任务。
接下来是调参。新手调参最大的问题是没有方向,乱试。我建议按这个顺序来:
- 先定学习率。loss 震荡或者爆炸,多半是学习率太大;loss 下降慢得像蜗牛,多半是学习率太小。把学习率调到一个能稳定下降的值,再谈其他。
- 再调隐藏层大小。hidden_dim 太小,loss 可能一开始就很差,而且怎么训练都降不到低位。hidden_dim 适当增大,模型容量变大,训练 loss 能降得更低。但过大容易过拟合。
- 然后调 batch size。注意,如果你的数据一次全喂进去,那 batch size 等于全部样本数。batch size 影响训练稳定性和收敛速度,但不像学习率那么敏感。
- 最后才考虑换优化器、换激活函数、调初始化方式。
| 超参数 | 调大时的效果 | 调小时的效果 | 建议起始值 |
|---|---|---|---|
| learning rate | 收敛快但震荡 | 收敛慢但稳定 | 0.01 到 0.1 |
| hidden_dim | 模型容量大,易过拟合 | 模型容量小,欠拟合 | 10 到 128 |
| batch size | 梯度稳定但可能陷局部最优 | 梯度噪声大但更灵活 | 32 到 256 |
4. 常见问题与排查技巧实录
4.1 损失不下降:先按这个顺序排查
训练跑了几十轮,loss 纹丝不动,这个问题我见过太多次了。不要慌,按顺序排查。
先看数据。特征是否标准化过?如果特征尺度差异太大,梯度下降会很别扭。再看标签。二分类的标签必须是 0 和 1,不要用 1 和 -1,也不要直接用类别索引。再看损失函数的输入形状。如果 model 输出是 [N, 1] 而标签是 [N],计算损失时可能不会报错,但结果可能是错的。用 squeeze() 对齐。
然后看梯度。如果所有参数的梯度都是 None,说明 backward 根本没算到它们,可能是网络某些地方断开了计算图。可以手动打印梯度范数:
total_norm = 0.0 for p in model.parameters(): if p.grad is not None: total_norm += p.grad.norm().item() print(total_norm)如果梯度范数趋近于 0,网络学不动;如果梯度范数爆炸,变成几百几千,说明梯度爆炸了,学习率要调小。
最常见的低级错误是忘了 zero_grad。如果不清零,梯度会累加,loss 可能会降低一下又突然弹飞,曲线毫无规律。把 optimizer.zero_grad() 放在 loss.backward() 之前,形成固定习惯。
4.2 维度不匹配与数据形状的坑
维度不匹配是 PyTorch 新手绕不过去的坎。最常见的报错是这样的:
mat1 and mat2 shapes cannot be multiplied (2x3 and 2x4)意思是两个矩阵没法做乘法。前面说了,矩阵乘法要求左边矩阵的最后一维等于右边矩阵的倒数第二维。你以为是 input_dim 和 hidden_dim 的问题,其实很可能是 batch 维度搞混了。
调试维度问题,最高效的方法就是打印 shape。在每个关键节点加一句:
print(x.shape, y.shape) print(logits.shape)还有一种情况是数据加载器导致的。如果你用 DataLoader,每次迭代返回的通常是一个元组 (data, label),data 的形状是 [batch_size, ...]。有些人直接把整个元组传进 model,结果 model 收到两个参数,直接报错。解决办法是显式解包:for batch_x, batch_y in dataloader。
标签形状的坑也很多。二分类标签是 [N],而模型输出是 [N, 1],计算 BCEWithLogitsLoss 时要么把输出 squeeze,要么把标签 unsqueeze(1),二选一即可。不要两个都不做,也不要两个都做。
4.3 过拟合、欠拟合与数据不平衡
两层网络虽然结构简单,但同样存在过拟合问题,尤其是数据量少的时候。训练集 loss 降到接近 0,测试集准确率却上不去,基本就是过拟合了。解决思路有几种:增加更多训练数据、降低 hidden_dim、加入正则化、早停。
在两层网络里,最直接的正则化手段是加 Dropout。Dropout 会在训练时随机把一部分神经元的输出置零,迫使网络不过度依赖某些神经元。注意 Dropout 只在训练时生效,eval 模式下自动关闭。
还有一种情况是数据不平衡。比如 95% 是负类、5% 是正类,模型把所有样本都预测为负类,准确率也有 95%,但没有任何实际价值。这种情况不要只看准确率,要关注精确率、召回率和 F1 分数。对于分类问题,最好打印完整指标:
from sklearn.metrics import precision_recall_fscore_support当然,为了让代码不引入额外依赖,你可以手动计算 TP、FP、FN 再算指标。这个习惯从两层网络就开始养成,后面做真实项目会很受益。
最后再分享一个我调试这类小网络时最常用的习惯:每次改动结构或数据后,先故意把训练集缩到很小,比如只取 50 条样本,跑几十轮。如果 loss 能稳定降到接近 0,说明整个链路是通的,再上完整数据。如果小样本都学不进去,那问题基本出在网络结构、损失计算或数据形状上,排查范围会小很多。这个习惯帮我省过非常多无效的调参时间,建议你直接试一下。