从零实现多层感知机(MLP)前向传播:从单神经元到 Transformer FFN 的完整指南
【免费下载链接】leetcodeLeetcode solutions项目地址: https://gitcode.com/GitHub_Trending/leetcode1/leetcode
导读
本文围绕 LeetCode 仓库中 mlp-from-scratch.md 这篇文档展开,系统讲解多层感知机(Multi-Layer Perceptron, MLP)的前向传播:如何在给定权重矩阵与偏置向量的情况下,用 NumPy 的@运算符逐层完成线性变换、偏置相加与 ReLU 激活,并正确跳过输出层的激活函数。读完本文,你将掌握 MLP 的数学定义、矩阵形状规则、线性层塌缩的原理,以及它在 Transformer 前馈网络(FFN)中的真实应用形态,从而为理解 GPT 类模型打下坚实基础。
前置知识
在动手实现 MLP 之前,需要先掌握以下三个基础概念,本仓库的配套文章可以帮你补齐:
- 单神经元前向传播:MLP 本质上是大量神经元按层组织的集合,每个神经元都在计算"加权和加偏置,再经过激活函数"。单神经元前向传播 一文给出了最基本的 $\sigma(x \cdot w + b)$ 运算,MLP 只是把它向量化、层叠化。
- 矩阵乘法:每一层通过乘以权重矩阵来变换输入,形状规则 $(B, d_{\text{in}}) \times (d_{\text{in}}, d_{\text{out}}) = (B, d_{\text{out}})$ 贯穿所有层的计算。线性回归前向传播 展示的 $\hat{y} = Xw$ 正是"一层无激活"的特例。
- ReLU 激活:层与层之间的 ReLU 引入非线性,这是深度网络表达能力的来源。激活函数的取值范围与梯度特性可参考 Sigmoid 与 ReLU。
概念:MLP 是什么
多层感知机是含有一个或多个隐藏层的神经网络。每一层都执行一次线性变换加上一次非线性激活:
$$h^{(l)} = \sigma(h^{(l-1)} W^{(l)} + b^{(l)})$$
其中 $h^{(l-1)}$ 是第 $l$ 层的输入,$W^{(l)}$ 是权重矩阵,$b^{(l)}$ 是偏置向量,$\sigma$ 是激活函数。输入层到第一个隐藏层之间、隐藏层之间、隐藏层到输出层之间,全部遵循这一公式;区别只在于激活函数是否施加。
为什么需要多层:线性层的塌缩
关键在于:为什么不能只用一层?
- 单个线性层只能学习线性决策边界(超平面),表达能力有限。
- 而两层线性层之间如果没有激活函数,会塌缩成一层,因为:
$$W_2(W_1 x + b_1) + b_2 = W_2 W_1 x + W_2 b_1 + b_2$$
这仍然是一个关于 $x$ 的线性函数——两个线性变换的复合还是线性变换。无论堆多少层,都不增加表达能力。
- 层与层之间的ReLU 阻止了这种塌缩。每一层都能以新的方式"弯曲"数据的表示,堆叠足够多的层(配合足够宽度)后,网络可以逼近任意连续函数(万能逼近定理的精神)。
输出层不加激活
输出层通常不加激活(回归场景输出原始预测值),或加 softmax(分类场景输出概率分布)。本文实现的场景中,最终层不加激活,直接产生raw logits(原始 logit)。
解决方案
直觉
遍历权重矩阵列表与偏置向量列表:对每一层,用@运算符完成矩阵乘法,加上偏置,然后施加 ReLU。最后一层跳过激活,输出原始 logits。整个过程完全向量化,不需要任何显式 for 循环逐神经元计算。
实现
import numpy as np from numpy.typing import NDArray from typing import List class Solution: def forward(self, x: NDArray[np.float64], weights: List[NDArray[np.float64]], biases: List[NDArray[np.float64]]) -> NDArray[np.float64]: h = x for i in range(len(weights)): h = h @ weights[i] + biases[i] # Linear transformation if i < len(weights) - 1: h = np.maximum(0, h) # ReLU on hidden layers only return np.round(h, 5)代码要点:
h @ weights[i]:NumPy 的@运算符等价于np.matmul,对任意维度的数组执行矩阵乘法。当输入是形状 $(B, d_{\text{in}})$ 的批数据时,一次调用即可同时处理 $B$ 个样本,这正是 Transformer 中批量前向传播的写法。+ biases[i]:NumPy 广播(broadcasting)机制会自动把形状为 $(d_{\text{out}},)$ 的偏置向量加到每个样本的每个输出维度上。np.maximum(0, h):向量化的 ReLU,对数组中每个元素取 $\max(0, \cdot)$。注意不能用 Python 内置的max(它只接受标量,且对数组会报错或产生歧义)。np.round(h, 5):将最终输出四舍五入到 5 位小数,保证结果可复现、可比较。- 当
len(weights) == 1(单层网络)时,循环体不进入if分支,等价于一个不带激活的线性回归层,与 线性回归前向传播 中的np.matmul(X, weights)语义一致。
逐步演练
给定一个 2 层 MLP:输入 $x = [1, 2]$,$W_1$ 形状 $2 \times 3$,$b_1$ 形状 $3$,$W_2$ 形状 $3 \times 1$,$b_2$ 形状 $1$:
| 层 | 操作 | 示例 |
|---|---|---|
| 1(隐藏层) | $h_1 = x @ W_1 + b_1$ | $[-0.5, 1.2, 0.3]$ |
| ReLU | $\max(0, h_1)$ | $[0, 1.2, 0.3]$ |
| 2(输出层) | $h_2 = h_1 @ W_2 + b_2$ | $[0.9]$(不加激活) |
注意观察:负值 $-0.5$ 被 ReLU 清零为 $0$。这种稀疏性是 ReLU 的一个特性:对任意给定输入,只有一部分神经元"激活"(输出非零),每个输入拥有自己独特的激活模式,这也使得激活模式本身携带了输入的有用信息。
时间与空间复杂度
- 时间:$O(\sum_{l} d_{l-1} \cdot d_l)$,其中 $d_l$ 是第 $l$ 层的维度,计算量主要由矩阵乘法主导。
- 空间:$O(\max_l d_l)$,主要存储最大的隐藏层表示(在不保存全部中间激活的前提下)。
补充:如果考虑批量大小 $B$,时间应记为 $O(B \cdot \sum_l d_{l-1} \cdot d_l)$。若后续要训练(反向传播),还需要额外保存每一层的输入 $h^{(l-1)}$ 与预激活 $z^{(l)}$,这正是 多层反向传播 中 forward pass 阶段缓存中间值的由来——训练阶段的内存开销因此高于推理阶段。
常见陷阱
陷阱一:对输出层施加激活
输出层应当产生原始 logits(分类场景)或原始预测值(回归场景)。若对输出层施加 ReLU,会把负值全部裁剪为 0,导致模型永远无法预测负数——这在回归任务中往往是致命的。
# Wrong: ReLU on every layer including output for i in range(len(weights)): h = h @ weights[i] + biases[i] h = np.maximum(0, h) # don't do this on the last layer! # Correct: skip activation on the last layer for i in range(len(weights)): h = h @ weights[i] + biases[i] if i < len(weights) - 1: h = np.maximum(0, h)判别标准:只有隐藏层需要激活函数,输出层永远不加(除非是 softmax 之类的输出专用变换)。
陷阱二:权重矩阵形状不匹配
每个权重矩阵的输入维度必须等于上一层输出的输出维度。形状 $(3, 4)$ 的权重矩阵期望 3 维输入,产生 4 维输出。
# Wrong: weights[1] has incompatible input dimension weights = [np.zeros((2, 3)), np.zeros((4, 1))] # 3 != 4 # Correct: output dim of layer i matches input dim of layer i+1 weights = [np.zeros((2, 3)), np.zeros((3, 1))] # 3 == 3自检方法:逐个检查相邻两层——第 $i$ 层权重形状 $(d_{i-1}, d_i)$,第 $i+1$ 层权重形状必须是 $(d_i, d_{i+1})$,链式传播下来整体形状必须闭合:输入维度 $d_0$ 与第一层权重行数一致,最后一层权重列数 $d_L$ 与输出维度一致。
在 GPT 项目中:FFN 就是两层的 MLP
原文档指出,本实现对应 GPT 课程项目中的foundations/mlp.py(该文件属于外部课程仓库,不在当前仓库内)。在当前仓库中,最能印证 MLP 实际形态的是 Transformer 块:每个 Transformer 块内部的前馈网络(FFN)恰好就是一个两层 MLP——先上投影(up-projection)将维度扩大 4 倍,经过 ReLU,再下投影(down-projection)回到原始维度。
该文中VanillaNeuralNetwork的源码给出了精确实现:
self.up_projection = nn.Linear(model_dim, model_dim * 4) self.relu = nn.ReLU() self.down_projection = nn.Linear(model_dim * 4, model_dim) self.dropout = nn.Dropout(0.2) # using p = 0.2def forward(self, x): return self.dropout(self.down_projection(self.relu(self.up_projection(x))))对照本文的实现可以清晰看到对应关系:
h @ W1 + b1⇔up_projection:维度从 $d_{\text{model}}$ 扩张到 $4 \cdot d_{\text{model}}$(例如 GPT-2 中从 768 到 3072);np.maximum(0, h)⇔ReLU:引入非线性;h @ W2 + b2⇔down_projection:维度从 $4 \cdot d_{\text{model}}$ 压缩回 $d_{\text{model}}$;- 额外的
Dropout(0.2)是训练期防止过拟合的正则化手段,推理时可关闭。
在这个架构中,注意力机制负责 token 之间的通信,而 FFN(MLP)负责每个 token 自身的独立计算。MLP 的 4 倍扩张为网络提供了额外的参数容量——在 GPT-2 中,FFN 占据了每个 Transformer 块约三分之二的可学习参数。
进一步的延伸:前向传播只是训练的一半。MLP 训练时对每一层求梯度依赖链式法则逐层回传,具体推导(含 ReLU 导数作为 0/1 掩码、权重梯度等于误差信号与层输入的外积)见 多层反向传播;而 PyTorch 中调用loss.backward()时,自动微分引擎做的正是这些手算链式法则的机械化版本。
关键要点
- MLP 将线性变换与 ReLU 激活链式组合,只要有足够的宽度和深度,就能逼近任意连续函数。
@运算符为矩阵乘法提供了简洁的语法,形状规则 $(B, d_{\text{in}}) @ (d_{\text{in}}, d_{\text{out}}) = (B, d_{\text{out}})$ 支配着每一层的计算。- 层与层之间若没有非线性激活,无论堆叠多少层,整个网络都会塌缩为单个线性变换,表达能力不增反减。
- 输出层必须跳过激活函数,直接输出 raw logits 或回归预测值。
- 在 Transformer 中,FFN 正是这种"扩展 4 倍 → ReLU → 压缩回来"的两层 MLP,与注意力机制分工协作,构成 GPT 类模型的核心计算单元。
【免费下载链接】leetcodeLeetcode solutions项目地址: https://gitcode.com/GitHub_Trending/leetcode1/leetcode
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考