用 NumPy 从零搭建完整 MLP:从前向传播到反向传播,彻底理解神经网络如何训练
一、为什么要用 NumPy 手写一个 MLP?
学习深度学习的时候,我们经常会直接使用 PyTorch:
model = nn.Sequential(
nn.Linear(4, 8),
nn.ReLU(),
nn.Linear(8, 3)
)
几行代码就可以创建一个神经网络。
这种方式非常方便,但对于初学者来说,也容易产生一个问题:
nn.Linear()到底帮我们做了什么?
loss.backward()到底计算了什么?
optimizer.step()到底修改了什么?
如果只会调用这些 API,却不知道内部发生了什么,那么神经网络很容易变成一个“黑盒”。
因此,我们可以先不用 PyTorch,而是使用 NumPy 自己实现一个最简单的 MLP。
我们希望最终实现这样一个网络:
输入层
│4个特征
↓
Linear
4 → 8
↓
ReLU
│ 8个隐藏神经元
↓
Linear
8 → 3
↓
Logits
↓
Cross Entropy
↓
Loss
↓
反向传播
↓
计算梯度
↓
更新参数
最终通过不断训练,让 Loss 从:
1.074253
下降到:
0.105083
这意味着我们亲手实现了一个真正能够学习的神经网络。
二、先理解 MLP 的整体结构
我们这次搭建的网络非常简单:
4 → 8 → 3
分别代表:
4:输入特征数量
8:隐藏层神经元数量
3:分类类别数量
例如,一个样本可以有 4 个特征:
x = [x1, x2, x3, x4]
网络首先将它送入第一层:
4 → 8
然后经过 ReLU 激活函数,再进入第二层:
8 → 3
最终得到三个 Logits:
[class0_score, class1_score, class2_score]
这三个数分别代表模型对三个类别的原始预测分数。
所以整个过程可以写成:
X
↓
Linear(W1, b1)
↓
ReLU
↓
Linear(W2, b2)
↓
Logits
↓
Cross Entropy
↓
Loss
训练时再反过来:
Loss
↓
反向传播
↓
Gradient
↓
更新 W1、b1、W2、b2
第一步:准备 NumPy 环境
首先导入 NumPy:
import numpy as np
NumPy 是 Python 中非常重要的数值计算库,它提供了数组、矩阵运算、广播、线性代数等功能。
我们这里不用 PyTorch 的 Tensor,而是直接使用:
np.ndarray
来表示神经网络中的数据、权重、偏置和梯度。
例如:
x = np.array([1, 2, 3, 4])
它就是一个 NumPy 数组。
神经网络中的矩阵运算,本质上大量依赖这种数组运算。
第二步:准备训练数据
为了简单起见,我们可以准备 5 个样本,每个样本有 4 个特征。
例如:
X = np.array([
[1.0, 0.5, 0.2, 0.8],
[0.2, 0.8, 0.6, 0.1],
[0.9, 0.1, 0.3, 0.7],
[0.1, 0.3, 0.9, 0.2],
[0.7, 0.2, 0.4, 0.6],
])
这里:
X.shape = (5, 4)
为什么是 (5, 4)?
因为:
5 → 5个样本
4 → 每个样本有4个特征
可以把它理解成一张表:
特征1 特征2 特征3 特征4
样本1 x1 x2 x3 x4
样本2 x1 x2 x3 x4
样本3 x1 x2 x3 x4
样本4 x1 x2 x3 x4
样本5 x1 x2 x3 x4
因此:X = 5 × 4
准备正确答案 y
假设这是一个三分类问题:
类别0
类别1
类别2
那么我们可以设置:
y = np.array([0, 1, 2, 1, 0])
这里:y.shape = (5,)
代表 5 个样本分别对应的正确类别。
例如:
样本1 → 类别0
样本2 → 类别1
样本3 → 类别2
样本4 → 类别1
样本5 → 类别0
这里的 y 是我们训练网络时的“标准答案”。
第三步:初始化第一层权重 W1
我们的第一层是:
4 → 8
所以第一层权重需要:
W1 = np.random.randn(4, 8) * 0.01
于是:
W1.shape = (4, 8)
这就是程序运行时看到的:
Parameter shape: (4, 8)
为什么是 (4, 8)?
因为:
输入4维
↓
隐藏层8个神经元
因此需要:
4 × 8 = 32
个权重。
第四步:初始化第一层 Bias
第一层有 8 个神经元,所以:
b1 = np.zeros(8)
它的形状:
b1.shape = (8,)
因此程序输出:
Parameter shape: (8,)
每一个隐藏神经元都有一个 Bias。
所以:
b1 = [
b1,
b2,
...
b8
]
总共有 8 个。
第五步:第一层 Linear 计算
有了:
X
W1
b1
我们就可以进行第一层线性计算:
z1 = X @ W1 + b1
这里的 @ 表示矩阵乘法。
现在看一下形状:
X → (5, 4)
W1 → (4, 8)
矩阵乘法:(5, 4) @ (4, 8)
中间两个 4 可以对应起来:
(5, 4) × (4, 8)
↑
对应
最终得到:(5, 8)
所以:z1.shape = (5, 8)
这意味着:
5 个样本,每个样本经过第一层后产生 8 个神经元的输出。
Linear 层到底干了什么?
很多初学者看到:
z1 = X @ W1 + b1
会觉得只是一个矩阵计算。
但从神经网络角度来看,它实际上完成了:
输入特征
↓
不同权重进行组合
↓
加上 Bias
↓
得到新的特征表示
对于其中一个神经元来说,本质上就是:
z = x1w1 + x2w2 + x3w3 + x4w4 + b
也就是说,一个神经元会对输入的不同特征赋予不同的重要程度。
例如:
x1 × w1
x2 × w2
x3 × w3
x4 × w4
然后全部加起来:
x1w1 + x2w2 + x3w3 + x4w4
最后再加上:b
这就是一个神经元最基本的计算。
第六步:加入 ReLU 激活函数
如果只有:
X → Linear → Linear
那么整个网络实际上仍然可以合并成一个线性变换。
因此我们需要加入非线性激活函数。
这里使用最经典的:
ReLU
ReLU 的定义非常简单:
ReLU(x) = max(0, x)
代码:
def relu(x):
return np.maximum(0, x)
例如:
输入:
[-2, -1, 0, 2, 5]
经过 ReLU:
[0, 0, 0, 2, 5]
也就是说:
小于 0 的数直接变成 0,大于 0 的数保持不变。
为什么需要 ReLU?
这是理解隐藏层非常重要的一步。
假设我们只有:
Linear
↓
Linear
第一层:
z1 = XW1 + b1
第二层:
z2 = z1W2 + b2
把它们合起来,仍然可以表示成一个大的线性变换。
也就是说:
Linear
+
Linear
如果中间没有激活函数,网络的表达能力并没有真正得到大幅提升。
加入:
ReLU
之后:
Linear
↓
ReLU
↓
Linear
网络就拥有了非线性表达能力,可以学习更加复杂的关系。
所以隐藏层的重要作用之一,就是通过:
Linear + 激活函数
不断对原始数据进行特征变换。
第七步:得到隐藏层输出
代码:
h = relu(z1)
此时:
h.shape = (5, 8)
这意味着:
5个样本
↓
每个样本经过8个隐藏神经元
↓
得到8维隐藏表示
可以把它理解成:
原始数据
4维
↓
第一层
↓
隐藏表示
8维
因此:
4维输入
经过网络之后,被转换成:
8维特征
这就是隐藏层在做的事情。
第八步:第二层 Linear
现在有:
h.shape = (5, 8)
第二层负责:
8 → 3
因此初始化:
W2 = np.random.randn(8, 3) * 0.01
b2 = np.zeros(3)
对应:
W2.shape = (8, 3)
b2.shape = (3,)
这正是程序输出:
Parameter shape: (8, 3) | Gradient shape: (8, 3)
Parameter shape: (3,) | Gradient shape: (3,)
计算最终输出 Logits
第二层计算:
logits = h @ W2 + b2
来看形状:
h → (5, 8)
W2 → (8, 3)
进行矩阵乘法:
(5, 8) @ (8, 3)
最终得到:
(5, 3)
所以程序输出:
Logits shape: (5, 3)
终于出现了。
这说明我们的整个前向传播已经走完:
X
↓
Linear
↓
ReLU
↓
Linear
↓
Logits
为什么最后得到的是 Logits,而不是概率?
第二个 Linear 层直接输出:
logits = h @ W2 + b2
这里没有使用:
softmax()
所以得到的只是原始分数:
Logits
例如某一个样本可能得到:
[2.4, 0.7, -1.1]
网络认为:
类别0:分数最高
类别1:其次
类别2:最低
如果需要概率,可以进行 Softmax。
Softmax:把 Logits 转换成概率
Softmax 的作用是:
把一组任意的 Logits 转换成一组总和为 1 的概率。
公式:
softmax(z_i) = exp(z_i) / Σ exp(z_j)
例如:
Logits:
[2.4, 0.7, -1.1]
经过 Softmax 后:
[0.81, 0.15, 0.04]
这时候:
0.81 + 0.15 + 0.04 = 1
因此可以解释为:
类别0 → 81%
类别1 → 15%
类别2 → 4%
不过在实际实现 Cross Entropy 时,通常不会真的先计算 Softmax 再计算交叉熵,而是直接使用更加稳定的 LogSumExp 形式。
这是因为直接计算:
np.exp(logits)
在 Logits 很大时可能产生数值溢出。
第九步:计算 Cross Entropy Loss
我们现在有:
Logits
也有:
真实标签 y
接下来需要回答:
网络预测得怎么样?
所以计算:
Cross Entropy
最终得到:
Loss
程序中的结果是:
Loss: 1.0742530086092246
这就是当前模型的损失。
Cross Entropy 到底在计算什么?
假设正确答案是:
类别0
如果模型预测:
[0.95, 0.03, 0.02]
说明模型非常相信正确答案。
Loss 会比较小。
但是如果模型预测:
[0.05, 0.90, 0.05]
模型不仅预测错了,而且对错误答案非常自信。
这时候 Loss 会比较大。
因此交叉熵特别关注:
模型给正确类别分配了多大的概率。
正确类别概率越大:
Loss越小
正确类别概率越小:
Loss越大
三、完成了前向传播
现在把前面所有步骤连起来:
X
│ (5,4)
↓
Linear
X @ W1 + b1
│ (5,8)
↓
ReLU
│ (5,8)
↓
Linear
h @ W2 + b2
│ (5,3)
↓
Logits
↓
Cross Entropy
↓
Loss
这整个过程叫:前向传播(Forward Propagation)
所谓前向传播,就是:
数据从输入层开始,一步一步向前经过网络,最终得到预测结果和 Loss。
四、神经网络学习
到这里很多人会误以为:
“Loss 算出来了,网络是不是已经学会了?”
并没有。
现在网络只是:
输入
↓
计算
↓
预测
↓
计算Loss
它还没有改变自己的参数。
我们需要让网络知道:
“到底应该修改哪些参数?修改多少?向哪个方向修改?”
这就进入了神经网络最重要的机制之一:反向传播
五、反向传播
什么是 Gradient?
程序输出:

这里的:
Gradient
就是:梯度
可以简单理解成:
Loss 对每一个参数的“影响程度”。
例如:
W1中的某一个参数
如果稍微改变它,Loss 发生了明显变化,那么这个参数的梯度就比较大。
如果改变它几乎没有影响,那么梯度就比较小。
数学上可以理解为:
∂Loss / ∂W
意思是:
Loss 对 W 的偏导数。
为什么 Parameter 和 Gradient 的形状一样?
这是非常重要的知识点。
例如:
W1.shape = (4,8)
那么:
dW1.shape = (4,8)
也就是程序中的:
Parameter shape: (4, 8)
Gradient shape: (4, 8)
为什么?
因为:
每一个参数都需要一个对应的梯度。
如果有 32 个权重:
W1
就必须有 32 个对应的梯度:
dW1
可以理解成:
W1:
[ w11 w12 ... ]
[ w21 w22 ... ]
[ ... ... ]
dW1:
[ dw11 dw12 ... ]
[ dw21 dw22 ... ]
[ ... ... ]
参数和梯度一一对应。
四组参数到底是什么?
我们的 MLP 一共有四组需要学习的参数:
W1
b1
W2
b2
对应:
| 参数 | Shape | 作用 |
|---|---|---|
| W1 | (4,8) | 输入层 → 隐藏层的权重 |
| b1 | (8,) | 隐藏层的偏置 |
| W2 | (8,3) | 隐藏层 → 输出层的权重 |
| b2 | (3,) | 输出层的偏置 |
参数总数量:
W1:4 × 8 = 32
b1:8
W2:8 × 3 = 24
b2:3
所以:
32 + 8 + 24 + 3 = 67
整个网络一共有:
67 个可学习参数
虽然这个网络非常小,但它已经具备神经网络训练的完整机制。
反向传播到底在做什么?
前向传播是:
X
↓
W1、b1
↓
ReLU
↓
W2、b2
↓
Logits
↓
Loss
反向传播则完全反过来:
Loss
↓
W2、b2 的梯度
↓
ReLU
↓
W1、b1 的梯度
它利用的是:链式法则
例如:
X
↓
Z1
↓
H
↓
Z2
↓
Loss
Loss 最终受到 W1 的影响。
所以:
Loss
↓
Z2
↓
H
↓
Z1
↓
W1
需要一步一步计算每个环节的梯度。
这就是反向传播。
反向传播-“追责”
如果你刚开始学反向传播,可以先不要被复杂的公式吓到。
可以把神经网络想象成一个团队。
最终结果不好:
Loss很大
现在老板问:
“为什么结果不好?”
于是开始往回查:
最终Loss
↓
输出层
↓
隐藏层
↓
第一层
↓
每一个参数
最终确定:
W1应该往这个方向调整
b1应该往这个方向调整
W2应该往这个方向调整
b2应该往这个方向调整
这个过程就是:
反向传播计算梯度。
Gradient 有了之后怎么办?
仅仅知道:
Gradient
还不够。
我们还需要修改参数。
这就是:梯度下降
最基本的参数更新公式:
参数 = 参数 - 学习率 × 梯度
例如:
W1 -= learning_rate * dW1
同样:
b1 -= learning_rate * db1
W2 -= learning_rate * dW2
b2 -= learning_rate * db2
“减去”梯度
因为梯度指向的是:
Loss 增长最快的方向。
我们训练神经网络希望:
Loss下降
所以需要朝相反方向走:
参数
↓
减去
↓
梯度
因此:
W_new = W_old - η × dW
其中:
η
就是:Learning Rate(学习率)
它控制每次参数移动的步子有多大。
六、程序中的第一次更新
现在回到你的运行结果
Loss before update: 1.0742530086092246
更新之前:
Loss ≈ 1.074
然后执行一次参数更新。
结果:
Loss after update: 1.034096437803589
也就是说:
更新前:
1.074253
↓ 参数更新
更新后:
1.034096
Loss 从:
1.074253
下降到了
1.034096
这说明:
这次参数更新方向是正确的。
网络经过一次学习以后,预测结果变得更接近真实答案。
七、神经网络“学习”的本质
很多人第一次学习神经网络时,会觉得:
“神经网络是怎么自己学会东西的?”
实际上并没有什么神秘的过程。
它本质上一直重复下面这几步:
① 输入数据
↓
② 前向传播
↓
③ 得到预测结果
↓
④ 计算 Loss
↓
⑤ 反向传播
↓
⑥ 得到 Gradient
↓
⑦ 更新参数
↓
⑧ 再次预测
然后不断重复。
所以可以把神经网络训练概括成:
前向传播 → 算 Loss → 反向传播 → 更新参数 → 重复
八、真正的“神经网络训练”
因此,当我们说:
“模型正在学习。”
实际上指的就是:
W1、b1、W2、b2
这些参数正在不断变化。
例如一开始:
W1 = 随机值
W2 = 随机值
网络预测能力很差。
训练之后:
W1 = 学习后的值
W2 = 学习后的值
这些参数已经编码了一部分训练数据中的规律。
所以:
神经网络所谓的“学习”,本质上就是不断调整参数,使 Loss 尽可能降低。
九、完整训练过程
现在把所有步骤放到一起:
训练数据 X
↓
┌─────────────┐
│ Linear Layer │
│ 4 → 8 │
└─────────────┘
↓
ReLU
↓
┌─────────────┐
│ Linear Layer │
│ 8 → 3 │
└─────────────┘
↓
Logits
↓
Cross Entropy
↓
Loss
↓
反向传播
↓
Gradients
↓
参数更新
↓
W1、b1、W2、b2
↓
再训练
这就是一个完整的 MLP 训练循环。
为什么这个程序可以称为“完整 MLP”?
因为它已经包含了 MLP 的主要组成部分。
1. 输入
X
提供训练数据。
2. 第一层 Linear
X @ W1 + b1
完成第一次特征变换。
3. ReLU
max(0, x)
提供非线性能力。
4. 第二层 Linear
h @ W2 + b2
将隐藏特征映射到类别空间。
5. Logits
(5,3)
得到每个样本对三个类别的原始分数。
6. Cross Entropy
计算预测与真实标签之间的差异。
7. Backward
通过链式法则计算梯度。
8. Update
根据梯度更新参数。
9. Repeat
不断重复训练。
因此,它已经不是简单的:
矩阵运算练习
而是一个真正意义上的:
可以进行训练的神经网络。
十、最终运行结果
程序最终输出:

可以逐行翻译成:
Logits shape: (5, 3)
表示:
5个样本,每个样本有3个类别的输出分数。
Loss: 1.074253
表示:
当前模型的预测误差。
(4,8)
表示:
第一层权重,从4维输入连接到8个隐藏神经元。
(8,)
表示:
第一层8个Bias。
(8,3)
表示:
第二层权重,从8个隐藏神经元连接到3个输出类别。
(3,)
表示:
第二层3个Bias。
Gradient shape
表示:
每个参数都成功计算出了对应的梯度。
Loss before update
表示:
参数更新之前的损失。
Loss after update
表示:
参数更新一次之后的损失。
Loss after 200 steps
表示:
重复进行前向传播、Loss计算、反向传播和参数更新200次之后的损失。
总结:从零搭建 MLP 真正学到了什么?
这次使用 NumPy 从零实现 MLP,真正重要的并不是记住几行代码,而是理解下面这条完整链路:
数据
↓
Linear
↓
ReLU
↓
Linear
↓
Logits
↓
Cross Entropy
↓
Loss
↓
反向传播
↓
Gradient
↓
参数更新
↓
重复训练
其中:
Linear
负责进行特征变换;
ReLU
负责提供非线性能力;
Logits
是模型最后输出的原始类别分数;
Softmax
可以把 Logits 转换为概率;
Cross Entropy
负责衡量预测结果与真实答案之间的差距;
Gradient
告诉我们参数应该如何调整;
Gradient Descent
负责根据梯度更新参数。
最终:
Loss:
1.074
↓
1.034
↓
0.105
这几个数字背后真正发生的是:
模型不断进行“预测 → 计算错误 → 反向追责 → 修改参数”的循环,最终让自己的预测结果越来越接近训练数据中的正确答案。
这就是一个神经网络最核心的学习过程。
更多推荐
所有评论(0)