用 NumPy 从零实现两层神经网络 —— 解决 XOR 问题
读者:刚学完感知机、还没系统接触神经网络推导的初学者
目标:从一行代码都不依赖框架的状态开始,手写一个能解决异或(XOR)的两层神经网络
目录
- 第 0 章:阅读前的准备
- 第 1 章:任务背景——为什么 XOR 非线性不可分?
- 第 2 章:向量与矩阵基础
- 第 3 章:求导数学基础
- 第 4 章:MLP(多层感知机)介绍
- 第 5 章:数据初始化
- 第 6 章:激活函数(sigmoid)
- 第 7 章:前向传播
- 第 8 章:损失函数(交叉熵)
- 第 9 章:反向传播
- 第 10 章:梯度下降与训练循环
- 第 11 章:评估模型
- 第 12 章:总结与延伸阅读
第 0 章:阅读前的准备
这份教程假设你:
- 知道什么是感知机(perceptron),能用
w·x + b这样的形式表达"加权求和 + 偏置" - 知道什么是梯度下降(每一步沿反梯度方向更新参数)
- 会用 Python 基础语法和 NumPy 的基本数组运算
你不需要预先掌握:
- 矩阵乘法(我们第 2 章会从零讲起)
- 求导 / 链式法则(第 3 章会从零讲起)
- 反向传播(第 9 章会从零讲起,且每一步都会展开)
如果中途卡住,不要慌——可以先跳到第 2、3 章把数学工具补齐再回来读。
第 1 章:任务背景——为什么 XOR 非线性不可分?
1.1 什么是 XOR?
XOR 是"异或"(exclusive OR):当两个输入不同时输出 1,相同时输出 0。
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
我们想训练一个模型,输入 ,输出预测 ,让它在这 4 个样本上预测尽量接近真值 。
1.2 为什么单层感知机解不了?
把 4 个样本画在二维平面上:
x2
^
1 | (0,1) ● 1 (1,0) ● 1
|
0 | (0,0) ● 0 (1,1) ● 0
+--------------------> x1
0 1
红色的两个点((0,1) 和 (1,0))和蓝色的两个点((0,0) 和 (1,1))斜对角交叉。
而单层感知机的决策边界是:
这是一条直线。无论你怎样旋转、平移这条直线,都不可能把上面的红蓝点分开——这就是著名的 XOR 不可线性可分结论(1969 年 Minsky 在《Perceptrons》中证明)。
1.3 怎么办?引入"带激活函数的隐藏层"
只要在输入和输出之间多塞一层神经元(叫"隐藏层"),并在层与层之间加入非线性激活函数(比如 sigmoid),网络就能学到弯曲的决策边界,从而把 XOR 分开。
这就是我们要实现的两层神经网络(也叫 MLP,多层感知机):
x → [线性 + 激活] → [线性 + 激活] → ŷ
输入 隐藏层(4个) 输出层(1个) 概率
下一章,我们先把数学工具补齐。
第 2 章:向量与矩阵基础
本章只讲本教程用得到的内容。如果你已经熟悉,可以快速翻一遍。
2.1 标量、向量、矩阵
- 标量(scalar):一个数,例如
3.14 - 向量(vector):一组有序的数,用方括号括起来,例如 ,维度是 3
- 矩阵(matrix):一个二维数组,例如
是个 矩阵(2 行 2 列)。
记号:
- :向量 是 维实数向量
- :矩阵 是 行 列
2.2 点积(dot product)
两个长度相同的向量做"对应位置相乘再求和":
例子():
为什么神经网络爱用点积? 因为感知机的"加权求和" 正好就是向量 和 的点积 。
2.3 矩阵乘法
两个矩阵 和 相乘,得到 。
核心规则:内维必须相同(都是 ),结果的外维决定形状()。
翻译成大白话: 的第 行第 列,等于** 的第 行和 的第 列**做点积。
例子 1:形状计算
相乘得 。因为 有 3 列, 有 3 行("内维都是 3"),结果有 的 2 行和 的 4 列。
例子 2:本教程的具体形状
设 (4 个样本,每个样本 2 个特征),:
即 是 4 行 4 列的矩阵:第 行是第 个样本经过隐藏层线性变换后的 4 维结果。
记号约定:本教程中"行=样本",与 PyTorch/NumPy 习惯一致。
2.4 矩阵转置
把矩阵的"行列互换":
如果 ,那么 。
特别地:列向量转置后变成行向量。
2.5 NumPy 中的对应
| 概念 | NumPy 写法 |
|---|---|
| 向量 | np.array([1, 2, 3]),形状 (3,) |
| 矩阵 | np.array([[1,2],[3,4]]),形状 (2,2) |
| 点积 | np.dot(a, b) 或 a @ b |
| 矩阵乘法 | A @ B |
| 转置 | A.T |
第 3 章:求导数学基础
本章只覆盖会用到的求导规则和链式法则。
3.1 常见函数的导数
记 表示" 对 求导"。
| 函数 | 导数 | 说明 |
|---|---|---|
| (常数) | 常数不变 | |
| 幂法则 | ||
| 指数函数"自己求导还是自己" | ||
| 链式法则 + 上面的 | ||
| (自然对数) | ||
| 幂法则 |
3.2 复合函数的链式法则
如果 ,即 先经过 再经过 ,那么:
大白话:链式法则就是"沿链条逐段求导,结果相乘"。
例子:。
令 ,则 :
3.3 多元函数的偏导
当函数有多个变量时,对其中一个求导,其他当作常数,这叫偏导。记号是 (用 而不是 )。
例子:
3.4 多元链式法则(神经网络的核心工具)
如果 依赖 , 又依赖 ,那么 对 求导要"穿过去":
神经网络有几十层,每一层都是这种"链式相乘"——这就是"反向传播"算法的核心。
3.5 常用小技巧
-
对数求导:
→ 例:
-
分式求导(商法):
-
乘法法则:
第 4 章:MLP(多层感知机)介绍
4.1 什么是 MLP?
MLP = Multi-Layer Perceptron,多层感知机。它由"一层一层"的神经元堆叠而成:
输入层 隐藏层 输出层
x1 ──→ ● ──→ ŷ
x2 ──→ ● ──→
●
●
每一层做两件事:
- 线性变换:(加权求和 + 偏置)
- 非线性激活:(把直线"掰弯")
激活函数是关键——没有它,多层网络等价于单层(数学上能证明),就又回到了"直线分不开 XOR"的死胡同。
4.2 本教程的网络结构
- 输入层:2 个神经元()
- 隐藏层:4 个神经元 + sigmoid 激活
- 输出层:1 个神经元 + sigmoid(输出 当成"是 1 的概率")
4.3 训练流程概览
训练就是不断重复下面 4 步:
- 前向传播:从 一路算出 ,顺便算出损失
- 反向传播:用链式法则从 一路算回每个参数的"梯度"
- 参数更新:
- 重复 1~3 几百~几千次(每一轮叫一个 epoch)
到损失不再下降(或下降得很慢),训练就结束了。
第 5 章:数据初始化
5.1 训练数据
XOR 的训练数据只有 4 个样本:
X = np.array([[0, 0],
[0, 1],
[1, 0],
[1, 1]]) # 形状 (4, 2)
y = np.array([0, 1, 1, 0]) # 形状 (4,)
是 4 行 2 列的矩阵——每行是一个样本,每列是一个特征。 是长度为 4 的向量——每个样本对应一个标签。
5.2 超参数
seed = 1 # 随机种子,让结果可复现
n_in = 2 # 输入维度
n_hidden = 4 # 隐藏层神经元个数
lr = 1.0 # learning rate,学习率
学习率 控制每一步"走多远":
- 太小:下山下得很慢,要训练很多轮
- 太大:在最优点附近来回震荡,甚至越走越远
- 适中:又快又稳(本教程用 1.0,对小网络够用)
5.3 权重的"形状约定"
按"行=样本"布局,权重形状如下:
| 符号 | 形状 | 含义 |
|---|---|---|
| 输入 2 维 → 隐藏 4 维 | ||
| 隐藏层偏置(4 维) | ||
| 隐藏 4 维 → 输出 1 维 | ||
| 输出层偏置(1 维) |
的含义:从输入的第 维到隐藏层第 个神经元的"连接强度"。
5.4 初始化方式(Xavier 简化版)
用均值为 0、标准差为 的高斯分布采样:
为什么这么选? 直觉上希望每一层激活值的"大小"差不多,否则后面几层会因为信号太大/太小而学不动。这就是 Xavier 初始化的核心思想。
偏置全部初始化为 0:
rng = np.random.default_rng(seed)
W1 = rng.normal(0, 1/np.sqrt(n_in), size=(n_in, n_hidden))
b1 = np.zeros(n_hidden)
W2 = rng.normal(0, 1/np.sqrt(n_hidden), size=(n_hidden, 1))
b2 = np.zeros(1)
第 6 章:激活函数(sigmoid)
6.1 定义
sigmoid 是最经典的激活函数:
把任意实数 压缩到 区间。
图像像一个被压扁的 "S",所以叫"sigmoid"(sigma + oid = "S 形的")。
6.2 sigmoid 求导(关键!三种方法)
这一节我们详细推导 。这是反向传播最常用的导数之一。
设
方法 1:链式法则(最直接)
把 看成三层复合:
最外层(对 求 次幂):
中间层(对 求导):
小提示:,当 时还要乘 ,所以 。
链式法则把两层乘起来:
方法 2:商法
把 写成 ,用 :
- ,
- ,
方法 3:化简成 形式(最常用)
令 。先算 :
把方法 1 的结果拆成两个分式相乘:
最终好用形式:
这个形式在反向传播时极其省事——前向时已经算好 缓存起来,反向时直接用 a * (1 - a),不必再算一次指数。
6.3 NumPy 实现
def sigmoid(z):
# np.clip 防止指数溢出
return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))
def dsigmoid(a):
"""sigmoid 对 a 求导:σ'(z) = a(1-a),a 已经是 σ(z)"""
return a * (1.0 - a)
np.clip(z, -500, 500) 把 限制在 —— 已经小到能正常表示,再大就会触发数值溢出警告。
第 7 章:前向传播
前向传播就是"把输入一层层算到输出"。
7.1 隐藏层线性变换
形状:
是长度为 4 的向量,NumPy 会自动把它"广播"到 (4, 4)——每行都加上这个偏置。
z1 = X @ W1 + b1
7.2 隐藏层激活
形状不变 ,每个元素过一遍 sigmoid:
a1 = sigmoid(z1)
7.3 输出层线性变换
形状:
z2 = a1 @ W2 + b2
7.4 输出层激活 + 拉平
最后 .ravel() 把 拉平成 ,便于和真值 逐元素比较:
y_pred = sigmoid(z2).ravel()
此时 ,可以当成"预测为 1 的概率"。
第 8 章:损失函数(交叉熵)
8.1 什么是损失函数?
损失函数 衡量"预测 和真值 差多远"。差得越多, 越大;差得越少, 越小。训练的目标就是最小化 。
8.2 二分类交叉熵(BCE)
对单个样本 ,,:
直观理解:
- 当 :。 越接近 1, 越小; 接近 0, 爆炸。
- 当 :。 越接近 0, 越小。
对 个样本取平均:
8.3 NumPy 实现
loss = -np.mean(y * np.log(y_pred + 1e-8) +
(1 - y) * np.log(1 - y_pred + 1e-8))
+ 1e-8 是为了防止 出现 (数值上的小技巧)。
8.4 为什么用交叉熵而不是 MSE?
当输出层是 sigmoid 时,交叉熵比均方误差 (MSE) 训练更稳定,核心原因在反向传播时:MSE 会把 乘进梯度里,而 在 大时接近 0,导致梯度消失;交叉熵不会出现这个问题(下一章会详细推导)。
8.5 交叉熵对 的求导(关键!)
设
我们要求 ,其中 当作常数。
拆成两项
算 的导数
由 , 是常数直接提出来:
算 的导数(用链式法则)
设 ,则 ,。
合并
通分化简(更优雅)
公分母 :
展开分子:
相加:(中间两项 抵消)
所以:
第 9 章:反向传播
反向传播是"沿着前向的反方向,用链式法则求每个参数的梯度"。
9.1 整体思路
我们从损失 出发,按"从后往前"的顺序求偏导:
整个反向传播可以浓缩成 5 步,每一步都有具体的公式。下面逐项推导。
9.2 第 1 步:(最关键!)
我们已经知道:
- ,
链式法则:
直接被约掉!
对所有样本独立地成立,所以
形状 (这里 )。
另一种写法(不绕 )
直接把 写成 的函数:
对 求导(注意 ):
代入 ,分母再次被约掉:
两种方法殊途同归。
这个结果为什么这么漂亮?
- 形式简洁:预测减真实,没了。
- 梯度方向天然合理:
- 预测偏高()→ 梯度为正 → 减小 → 减小
- 预测偏低()→ 梯度为负 → 增大 → 增大
- 不会梯度消失:没有 这一项,预测越准、梯度越小但不会突然消失;不像 MSE+sigmoid 那样在 大时梯度直接饱和为 0。
这就是"sigmoid 输出 + 交叉熵"成为经典组合的根本原因。
9.3 第 2 步:(详细推导)
我们已知 (形状 ),现在要算 的梯度。
形状约定
| 量 | 形状 | 含义 |
|---|---|---|
| 隐藏层激活值 | ||
| 隐藏→输出的权重 | ||
| 输出层激活前 |
单样本推导
去掉 这个 batch 维度,对第 个样本:
对 求偏导(链式法则):
注意 :因为 是关于 的线性函数, 之外的 都是常数。
把 个样本合起来
对所有 求平均(因为 是平均损失):
"除以 " 的来历:单样本时 不用写;多样本时 ,对 求导时这个 自然带过来。
写成矩阵形式
把上面"单样本"的偏导对所有 求和(再除以 ),是 的第 行。正好就是 的转置乘 :
形状对位: ✓
直觉理解
把公式拆开看元素:
- 越大、 越大 → 该被调得越多
- 对所有样本取平均:抹平单个样本的噪声
这就是为什么线性层 永远有 ——本质上"输入特征"和"输出误差"的相关性矩阵。
9.4 第 2 步(续):
对 ,(偏置在线性项外是常数 1),所以:
把 个 累加并平均:
形状:,与 同形 ✓
在 NumPy 里就是 dZ.mean(axis=0)。
9.5 第 3 步:
是输出层的"误差信号",要把它沿着 传回 :
形状对位: ✓
da1 = dz2 @ W2.T
9.6 第 4 步:
是逐元素的非线性,所以梯度也是逐元素相乘( 表示"按位乘"):
dz1 = da1 * dsigmoid(a1)
形状:,和 同形。
9.7 第 5 步:(与第 2 步完全同构)
推导过程和 完全一样,只是把 换成 、把 换成 :
形状对位: ✓
dW1 = (X.T @ dz1) / N
9.8 第 5 步(续):
形状:,与 同形 ✓
db1 = dz1.mean(axis=0)
9.9 总结:反向传播 5 步
# 1. 输出层误差(交叉熵 + sigmoid 的极简结论)
dz2 = y_pred - y.reshape(-1, 1) # (N, 1)
# 2. W2、b2 梯度
dW2 = (a1.T @ dz2) / N # (4, 1)
db2 = dz2.mean(axis=0) # (1,)
# 3. 把误差信号传回隐藏层
da1 = dz2 @ W2.T # (N, 4)
# 4. 乘上 sigmoid 导数
dz1 = da1 * dsigmoid(a1) # (N, 4)
# 5. W1、b1 梯度
dW1 = (X.T @ dz1) / N # (2, 4)
db1 = dz1.mean(axis=0) # (4,)
9.10 为什么所有权重梯度公式都长成 "" 模式?
在线性层 里, 的第 个元素
这正好是 。所以"输入转置 × 输出梯度"是线性层的通用模式——在 PyTorch 里就是 X.T @ dZ。
第 10 章:梯度下降与训练循环
10.1 参数更新公式
有了梯度,沿着"损失下降最快的反方向"走一小步:
其中 就是学习率 lr。
直观理解:
- 告诉你"山坡有多陡"
- 减去 倍的坡度,就是"下山一步"
- 反复迭代就能逐步逼近局部最优点
W1 -= lr * dW1
b1 -= lr * db1
W2 -= lr * dW2
b2 -= lr * db2
10.2 完整训练循环
把"前向 + 反向 + 更新"打包成循环,跑 1000 个 epoch:
for ep in range(1, 1000 + 1):
# ---- 前向 ----
z1 = X @ W1 + b1 # (4, 4)
a1 = sigmoid(z1) # (4, 4)
z2 = a1 @ W2 + b2 # (4, 1)
y_pred = sigmoid(z2).ravel() # (4,)
# ---- 计算损失 ----
loss = -np.mean(y * np.log(y_pred + 1e-8) +
(1 - y) * np.log(1 - y_pred + 1e-8))
# ---- 打印进度 ----
if ep % 100 == 0 or ep == 1:
acc = (y_pred.round() == y).mean()
print(f"epoch {ep:5d} loss={loss:.4f} acc={acc:.2f}")
# ---- 反向 ----
N = len(X)
y_pred_2d = y_pred.reshape(-1, 1)
dz2 = y_pred_2d - y.reshape(-1, 1)
dW2 = (a1.T @ dz2) / N
db2 = dz2.mean(axis=0)
da1 = dz2 @ W2.T
dz1 = da1 * dsigmoid(a1)
dW1 = (X.T @ dz1) / N
db1 = dz1.mean(axis=0)
# ---- 参数更新 ----
W1 -= lr * dW1
b1 -= lr * db1
W2 -= lr * dW2
b2 -= lr * db2
由于训练数据只有 4 个样本,每次更新都用上全部数据,相当于 full-batch 梯度下降。
10.3 训练过程
跑出来大致是这样:
epoch 1 loss=0.6906 acc=0.25
epoch 100 loss=0.6921 acc=0.25
epoch 200 loss=0.6917 acc=0.25
...
epoch 1000 loss=0.6931 acc=0.50
不同随机种子会得到不同曲线。XOR 是个"小山丘"地形,网络可能卡在某个局部最优——重设种子或加大学习率/隐藏层通常能跳出。
第 11 章:评估模型
11.1 准确率
最直观的指标是"预测对的样本占比":
acc = (y_pred.round() == y).mean()
这行代码做了 3 件事:
y_pred.round():把连续概率 四舍五入成 0 或 1... == y:逐元素比较,预测对返回True(=1),错返回False(=0).mean():把布尔当 0/1 求平均,得到正确率
11.2 训练后的预测
训练完用最终参数再跑一次前向,看每个样本的预测:
z1 = X @ W1 + b1
a1 = sigmoid(z1)
z2 = a1 @ W2 + b2
y_pred = sigmoid(z2).ravel()
for xi, yi, pi in zip(X, y, y_pred):
print(f"x={xi} y={yi} ŷ={pi:.4f} pred={round(pi)}")
理想情况下 应该接近 0 或 1:
x=[0 0] y=0 ŷ=0.02 pred=0 ✓
x=[0 1] y=1 ŷ=0.98 pred=1 ✓
x=[1 0] y=1 ŷ=0.98 pred=1 ✓
x=[1 1] y=0 ŷ=0.02 pred=0 ✓
acc = 1.0
11.3 进一步评估(更严谨)
对于真实数据集,准确率只是入门指标。还可以看:
| 指标 | 含义 | 适合场景 |
|---|---|---|
| Precision | 预测为正的里面,真的为正的比例 | 关注"少报假阳性" |
| Recall | 真的为正的里面,被找出来的比例 | 关注"少漏报" |
| F1 | Precision 和 Recall 的调和平均 | 类别不平衡时 |
| AUC-ROC | 任意阈值下的分类能力 | 综合排序能力 |
| Confusion Matrix | 4 格表,看每类错多少 | 详细分析 |
XOR 只有 4 个样本,准确率就够了;真上项目,建议至少看 Precision / Recall / F1。
第 12 章:总结与延伸阅读
12.1 整篇教程的核心链路
数据 (X, y)
↓ 随机初始化 W1, b1, W2, b2
↓
前向: z1 → a1 → z2 → ŷ
↓
损失: L = BCE(ŷ, y)
↓
反向: dZ2=ŷ-y → dW2, db2 → dA1 → dZ1 → dW1, db1
↓
更新: W ← W - η·dW
↓
循环 1000 轮
↓
评估: 准确率
12.2 你已经学到的"核心招数"
| 招数 | 含义 | 本教程用到的地方 |
|---|---|---|
| 矩阵乘法 | 把"对每个样本做线性变换"一次性算完 | X @ W1 |
| 链式法则 | 多层网络反向传播的理论基础 | 反向传播全流程 |
| sigmoid + 交叉熵 | "" 的极简梯度 | dz2 = y_pred - y |
| 通用模式 | 线性层梯度的"通用公式" | dW1, dW2 |
| Xavier 初始化 | 让每层激活值大小可控 | 1/sqrt(n_in) 初始化 |
12.3 下一步可以学什么?
- 更大的数据集:MNIST 手写数字识别(28×28 像素 → 10 类)
- 更深的网络:3 层、4 层、10 层——会遇到梯度消失,需要 BatchNorm、ReLU、残差连接
- 更多的优化器:SGD → Adam → 学习率调度
- 正则化:Dropout、L2 权重衰减、数据增强
- CNN / RNN / Transformer:处理图像 / 序列时专用结构
12.4 推荐阅读
- 《深度学习》(花书)Ian Goodfellow et al. — 第 6 章讲前馈网络
- 3Blue1Brown 的 神经网络视频系列 — 直观理解
- 《动手学深度学习》(d2l.ai) — 配套可跑代码
12.5 一句话回顾
XOR 不能用一条直线分开,但用"线性 + 非线性 + 线性 + 非线性"的两层神经网络可以。 训练过程就是反复"前向算预测 → 反向算梯度 → 沿负梯度更新参数"——简单的事情重复做,就得到了能解决非线性问题的模型。