Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

用 NumPy 从零实现两层神经网络 —— 解决 XOR 问题

读者:刚学完感知机、还没系统接触神经网络推导的初学者

目标:从一行代码都不依赖框架的状态开始,手写一个能解决异或(XOR)的两层神经网络


目录


第 0 章:阅读前的准备

这份教程假设你:

  • 知道什么是感知机(perceptron),能用 w·x + b 这样的形式表达"加权求和 + 偏置"
  • 知道什么是梯度下降(每一步沿反梯度方向更新参数)
  • 会用 Python 基础语法和 NumPy 的基本数组运算

你不需要预先掌握

  • 矩阵乘法(我们第 2 章会从零讲起)
  • 求导 / 链式法则(第 3 章会从零讲起)
  • 反向传播(第 9 章会从零讲起,且每一步都会展开)

如果中途卡住,不要慌——可以先跳到第 2、3 章把数学工具补齐再回来读。


第 1 章:任务背景——为什么 XOR 非线性不可分?

1.1 什么是 XOR?

XOR 是"异或"(exclusive OR):当两个输入不同时输出 1,相同时输出 0。

000
011
101
110

我们想训练一个模型,输入 ,输出预测 ,让它在这 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 ──→      ●  ──→
             ●
             ●

每一层做两件事:

  1. 线性变换(加权求和 + 偏置)
  2. 非线性激活(把直线"掰弯")

激活函数是关键——没有它,多层网络等价于单层(数学上能证明),就又回到了"直线分不开 XOR"的死胡同。

4.2 本教程的网络结构

  • 输入层:2 个神经元(
  • 隐藏层:4 个神经元 + sigmoid 激活
  • 输出层:1 个神经元 + sigmoid(输出 当成"是 1 的概率")

4.3 训练流程概览

训练就是不断重复下面 4 步:

  1. 前向传播:从 一路算出 ,顺便算出损失
  2. 反向传播:用链式法则从 一路算回每个参数的"梯度"
  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 件事:

  1. y_pred.round():把连续概率 四舍五入成 0 或 1
  2. ... == y:逐元素比较,预测对返回 True(=1),错返回 False(=0)
  3. .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真的为正的里面,被找出来的比例关注"少漏报"
F1Precision 和 Recall 的调和平均类别不平衡时
AUC-ROC任意阈值下的分类能力综合排序能力
Confusion Matrix4 格表,看每类错多少详细分析

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 不能用一条直线分开,但用"线性 + 非线性 + 线性 + 非线性"的两层神经网络可以。 训练过程就是反复"前向算预测 → 反向算梯度 → 沿负梯度更新参数"——简单的事情重复做,就得到了能解决非线性问题的模型。