一、为什么要用 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

这几个数字背后真正发生的是:

模型不断进行“预测 → 计算错误 → 反向追责 → 修改参数”的循环,最终让自己的预测结果越来越接近训练数据中的正确答案。

这就是一个神经网络最核心的学习过程。


更多推荐