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

> 配套 notebook：`006_02.ipynb`
> 读者：刚学完感知机、还没系统接触神经网络推导的初学者
> 目标：从一行代码都不依赖框架的状态开始，**手写**一个能解决异或（XOR）的两层神经网络

---

## 目录

- [第 0 章：阅读前的准备](#第-0-章阅读前的准备)
- [第 1 章：任务背景——为什么 XOR 非线性不可分？](#第-1-章任务背景为什么-xor-非线性不可分)
- [第 2 章：向量与矩阵基础](#第-2-章向量与矩阵基础)
- [第 3 章：求导数学基础](#第-3-章求导数学基础)
- [第 4 章：MLP（多层感知机）介绍](#第-4-章mlp多层感知机介绍)
- [第 5 章：数据初始化](#第-5-章数据初始化)
- [第 6 章：激活函数（sigmoid）](#第-6-章激活函数sigmoid)
- [第 7 章：前向传播](#第-7-章前向传播)
- [第 8 章：损失函数（交叉熵）](#第-8-章损失函数交叉熵)
- [第 9 章：反向传播](#第-9-章反向传播)
- [第 10 章：梯度下降与训练循环](#第-10-章梯度下降与训练循环)
- [第 11 章：评估模型](#第-11-章评估模型)
- [第 12 章：总结与延伸阅读](#第-12-章总结与延伸阅读)

---

## 第 0 章：阅读前的准备

这份教程假设你：

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

**你不需要预先掌握**：

- 矩阵乘法（我们第 2 章会从零讲起）
- 求导 / 链式法则（第 3 章会从零讲起）
- 反向传播（第 9 章会从零讲起，且**每一步**都会展开）

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

---

## 第 1 章：任务背景——为什么 XOR 非线性不可分？

### 1.1 什么是 XOR？

XOR 是"异或"（exclusive OR）：当两个输入**不同**时输出 1，**相同**时输出 0。

| $x_1$ | $x_2$ | $y = x_1 \oplus x_2$ |
| :--:  | :--:  | :--: |
| 0     | 0     | 0 |
| 0     | 1     | 1 |
| 1     | 0     | 1 |
| 1     | 1     | 0 |

我们想训练一个模型，输入 $(x_1, x_2)$，输出预测 $\hat y$，让它在这 4 个样本上预测尽量接近真值 $y$。

### 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)）**斜对角交叉**。

而**单层感知机**的决策边界是：

$$
w_1 x_1 + w_2 x_2 + b = 0
$$

这是一条**直线**。无论你怎样旋转、平移这条直线，都不可能把上面的红蓝点分开——这就是著名的 **XOR 不可线性可分**结论（1969 年 Minsky 在《Perceptrons》中证明）。

### 1.3 怎么办？引入"带激活函数的隐藏层"

只要在输入和输出之间**多塞一层神经元**（叫"隐藏层"），并在层与层之间加入**非线性激活函数**（比如 sigmoid），网络就能学到**弯曲的决策边界**，从而把 XOR 分开。

这就是我们要实现的**两层神经网络**（也叫 MLP，多层感知机）：

```
x  →  [线性 + 激活]  →  [线性 + 激活]  →  ŷ
输入    隐藏层(4个)      输出层(1个)     概率
```

下一章，我们先把数学工具补齐。

---

## 第 2 章：向量与矩阵基础

本章只讲本教程**用得到**的内容。如果你已经熟悉，可以快速翻一遍。

### 2.1 标量、向量、矩阵

- **标量**（scalar）：一个数，例如 `3.14`
- **向量**（vector）：一组有序的数，用**方括号**括起来，例如 $\mathbf{x} = [1, 2, 3]$，维度是 3
- **矩阵**（matrix）：一个二维数组，例如

$$
A = \begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix}
$$

$A$ 是个 $2 \times 2$ 矩阵（2 行 2 列）。

**记号**：

- $\mathbf{x} \in \mathbb{R}^{n}$：向量 $\mathbf{x}$ 是 $n$ 维实数向量
- $A \in \mathbb{R}^{m \times n}$：矩阵 $A$ 是 $m$ 行 $n$ 列

### 2.2 点积（dot product）

两个**长度相同**的向量做"对应位置相乘再求和"：

$$
\mathbf{a} = [a_1, a_2, \ldots, a_n], \quad
\mathbf{b} = [b_1, b_2, \ldots, b_n]
$$

$$
\mathbf{a} \cdot \mathbf{b} = \sum_{i=1}^{n} a_i b_i = a_1 b_1 + a_2 b_2 + \cdots + a_n b_n
$$

**例子**（$n=2$）：

$$
[1, 2] \cdot [3, 4] = 1 \times 3 + 2 \times 4 = 3 + 8 = 11
$$

**为什么神经网络爱用点积？** 因为感知机的"加权求和" $w_1 x_1 + w_2 x_2$ 正好就是向量 $\mathbf{w}$ 和 $\mathbf{x}$ 的点积 $\mathbf{w} \cdot \mathbf{x}$。

### 2.3 矩阵乘法

两个矩阵 $A \in \mathbb{R}^{m \times k}$ 和 $B \in \mathbb{R}^{k \times n}$ 相乘，得到 $C \in \mathbb{R}^{m \times n}$。

**核心规则**：**内维必须相同**（都是 $k$），结果的外维决定形状（$m \times n$）。

$$
C = A \cdot B, \quad C_{ij} = \sum_{p=1}^{k} A_{ip} \cdot B_{pj}
$$

翻译成大白话：**$C$ 的第 $i$ 行第 $j$ 列**，等于**$A$ 的第 $i$ 行**和**$B$ 的第 $j$ 列**做点积。

#### 例子 1：形状计算

$$
A \in \mathbb{R}^{2 \times 3}, \quad B \in \mathbb{R}^{3 \times 4}
$$

相乘得 $C \in \mathbb{R}^{2 \times 4}$。因为 $A$ 有 3 列，$B$ 有 3 行（"内维都是 3"），结果有 $A$ 的 2 行和 $B$ 的 4 列。

#### 例子 2：本教程的具体形状

设 $X \in \mathbb{R}^{4 \times 2}$（4 个样本，每个样本 2 个特征），$W^{(1)} \in \mathbb{R}^{2 \times 4}$：

$$
Z^{(1)} = X \cdot W^{(1)} = \mathbb{R}^{4 \times 2} \cdot \mathbb{R}^{2 \times 4} = \mathbb{R}^{4 \times 4}
$$

即 $Z^{(1)}$ 是 4 行 4 列的矩阵：第 $n$ 行是第 $n$ 个样本经过隐藏层线性变换后的 4 维结果。

> 记号约定：本教程中"行=样本"，与 PyTorch/NumPy 习惯一致。

### 2.4 矩阵转置

把矩阵的"行列互换"：

$$
A = \begin{pmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \end{pmatrix}
\;\Rightarrow\;
A^\top = \begin{pmatrix} 1 & 4 \\ 2 & 5 \\ 3 & 6 \end{pmatrix}
$$

如果 $A \in \mathbb{R}^{m \times n}$，那么 $A^\top \in \mathbb{R}^{n \times m}$。

**特别地**：列向量转置后变成行向量。

### 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 常见函数的导数

记 $f'(x) = \dfrac{df}{dx}$ 表示"$f$ 对 $x$ 求导"。

| 函数 $f(x)$ | 导数 $f'(x)$ | 说明 |
| --- | --- | --- |
| $c$（常数） | $0$ | 常数不变 |
| $x^n$ | $n x^{n-1}$ | 幂法则 |
| $e^x$ | $e^x$ | 指数函数"自己求导还是自己" |
| $e^{-x}$ | $-e^{-x}$ | 链式法则 + 上面的 |
| $\log x$（自然对数） | $\dfrac{1}{x}$ | |
| $\dfrac{1}{x} = x^{-1}$ | $-x^{-2} = -\dfrac{1}{x^2}$ | 幂法则 |

### 3.2 复合函数的链式法则

如果 $y = f(g(x))$，即 $x$ 先经过 $g$ 再经过 $f$，那么：

$$
\frac{dy}{dx} = \frac{dy}{dg} \cdot \frac{dg}{dx} = f'(g(x)) \cdot g'(x)
$$

**大白话**：链式法则就是"沿链条逐段求导，结果相乘"。

**例子**：$y = (3x + 1)^2$。

令 $u = 3x + 1$，则 $y = u^2$：

$$
\frac{dy}{dx} = \frac{dy}{du} \cdot \frac{du}{dx} = 2u \cdot 3 = 6(3x+1)
$$

### 3.3 多元函数的偏导

当函数有多个变量时，对**其中一个**求导，**其他当作常数**，这叫**偏导**。记号是 $\dfrac{\partial f}{\partial x}$（用 $\partial$ 而不是 $d$）。

**例子**：$f(x, y) = x^2 + 3xy + y^2$

$$
\frac{\partial f}{\partial x} = 2x + 3y \quad (\text{把 } y \text{ 当常数})
$$

$$
\frac{\partial f}{\partial y} = 3x + 2y \quad (\text{把 } x \text{ 当常数})
$$

### 3.4 多元链式法则（神经网络的核心工具）

如果 $L$ 依赖 $z$，$z$ 又依赖 $w$，那么 $L$ 对 $w$ 求导要"穿过去"：

$$
\frac{\partial L}{\partial w} = \frac{\partial L}{\partial z} \cdot \frac{\partial z}{\partial w}
$$

神经网络有几十层，**每一层都是这种"链式相乘"**——这就是"反向传播"算法的核心。

### 3.5 常用小技巧

- **对数求导**：$\dfrac{d}{dx} \log f(x) = \dfrac{f'(x)}{f(x)}$

  → 例：$\dfrac{d}{dx} \log(1+e^{-x}) = \dfrac{-e^{-x}}{1+e^{-x}}$

- **分式求导（商法）**：$\left(\dfrac{u}{v}\right)' = \dfrac{u'v - uv'}{v^2}$

- **乘法法则**：$(uv)' = u'v + uv'$

---

## 第 4 章：MLP（多层感知机）介绍

### 4.1 什么是 MLP？

MLP = Multi-Layer Perceptron，多层感知机。它由"一层一层"的神经元堆叠而成：

```
输入层        隐藏层        输出层
 x1 ──→ ●  ──→ ŷ
 x2 ──→ ●  ──→
        ●
        ●
```

每一层做两件事：
1. **线性变换**：$\mathbf{z} = W \mathbf{x} + \mathbf{b}$（加权求和 + 偏置）
2. **非线性激活**：$\mathbf{a} = \sigma(\mathbf{z})$（把直线"掰弯"）

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

### 4.2 本教程的网络结构

$$
x \in \mathbb{R}^{2}
\xrightarrow{\,W_1,\,b_1\,}
z_1 \in \mathbb{R}^{4}
\xrightarrow{\sigma}
a_1 \in \mathbb{R}^{4}
\xrightarrow{\,W_2,\,b_2\,}
z_2 \in \mathbb{R}^{1}
\xrightarrow{\sigma}
\hat y \in (0,1)
$$

- **输入层**：2 个神经元（$x_1, x_2$）
- **隐藏层**：4 个神经元 + sigmoid 激活
- **输出层**：1 个神经元 + sigmoid（输出 $[0, 1]$ 当成"是 1 的概率"）

### 4.3 训练流程概览

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

1. **前向传播**：从 $x$ 一路算出 $\hat y$，顺便算出损失 $L$
2. **反向传播**：用链式法则从 $\hat y$ 一路算回每个参数的"梯度"
3. **参数更新**：$W \leftarrow W - \eta \cdot \text{梯度}$
4. **重复 1~3 几百~几千次**（每一轮叫一个 epoch）

到损失不再下降（或下降得很慢），训练就结束了。

---

## 第 5 章：数据初始化

### 5.1 训练数据

XOR 的训练数据只有 4 个样本：

```python
X = np.array([[0, 0],
              [0, 1],
              [1, 0],
              [1, 1]])  # 形状 (4, 2)
y = np.array([0, 1, 1, 0])  # 形状 (4,)
```

$X$ 是 4 行 2 列的矩阵——每行是一个样本，每列是一个特征。$y$ 是长度为 4 的向量——每个样本对应一个标签。

### 5.2 超参数

```python
seed = 1          # 随机种子，让结果可复现
n_in = 2          # 输入维度
n_hidden = 4      # 隐藏层神经元个数
lr = 1.0          # learning rate，学习率
```

**学习率** $\eta$ 控制每一步"走多远"：
- 太小：下山下得很慢，要训练很多轮
- 太大：在最优点附近来回震荡，甚至越走越远
- 适中：又快又稳（本教程用 1.0，对小网络够用）

### 5.3 权重的"形状约定"

按"行=样本"布局，权重形状如下：

| 符号 | 形状 | 含义 |
| --- | --- | --- |
| $W^{(1)}$ | $(2, 4)$ | 输入 2 维 → 隐藏 4 维 |
| $b^{(1)}$ | $(4,)$ | 隐藏层偏置（4 维）|
| $W^{(2)}$ | $(4, 1)$ | 隐藏 4 维 → 输出 1 维 |
| $b^{(2)}$ | $(1,)$ | 输出层偏置（1 维）|

**$W^{(1)}_{ij}$ 的含义**：从输入的第 $i$ 维到隐藏层第 $j$ 个神经元的"连接强度"。

### 5.4 初始化方式（Xavier 简化版）

用**均值为 0、标准差为 $1/\sqrt{n_{\text{in}}}$ 的高斯分布**采样：

$$
W^{(1)}_{ij} \sim \mathcal{N}\!\left(0,\; \frac{1}{n_{\text{in}}}\right),
\quad
W^{(2)}_{ij} \sim \mathcal{N}\!\left(0,\; \frac{1}{n_{\text{hidden}}}\right)
$$

**为什么这么选？** 直觉上希望每一层激活值的"大小"差不多，否则后面几层会因为信号太大/太小而学不动。这就是 **Xavier 初始化**的核心思想。

偏置全部初始化为 0：

```python
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 是最经典的激活函数：

$$
\sigma(z) = \frac{1}{1 + e^{-z}}
$$

把任意实数 $z \in (-\infty, +\infty)$ 压缩到 $(0, 1)$ 区间。

| $z$ | $\sigma(z)$ |
| --- | --- |
| $-\infty$ | $\to 0$ |
| $0$ | $0.5$ |
| $+\infty$ | $\to 1$ |

图像像一个被压扁的 "S"，所以叫"sigmoid"（sigma + oid = "S 形的"）。

### 6.2 sigmoid 求导（关键！三种方法）

这一节我们**详细**推导 $\sigma'(z)$。这是反向传播最常用的导数之一。

设

$$
\sigma(z) = \frac{1}{1 + e^{-z}} = (1 + e^{-z})^{-1}
$$

#### 方法 1：链式法则（最直接）

把 $\sigma$ 看成三层复合：

$$
\sigma(z) = u^{-1}, \quad u = 1 + e^{-z}
$$

**最外层**（对 $1 + e^{-z}$ 求 $-1$ 次幂）：

$$
\frac{d}{du} u^{-1} = -u^{-2} = -\frac{1}{(1 + e^{-z})^2}
$$

**中间层**（对 $e^{-z}$ 求导）：

$$
\frac{d}{dz} e^{-z} = -e^{-z}
$$

> 小提示：$\dfrac{d}{dx} e^{u} = e^u$，当 $u = -x$ 时还要乘 $\dfrac{du}{dx} = -1$，所以 $\dfrac{d}{dx} e^{-x} = -e^{-x}$。

**链式法则**把两层乘起来：

$$
\sigma'(z) = \underbrace{-\frac{1}{(1 + e^{-z})^2}}_{\text{外层}} \cdot \underbrace{(-e^{-z})}_{\text{内层求导结果}} = \frac{e^{-z}}{(1 + e^{-z})^2}
$$


#### 方法 2：商法

把 $\sigma$ 写成 $\dfrac{1}{1+e^{-z}}$，用 $\left(\dfrac{u}{v}\right)' = \dfrac{u'v - uv'}{v^2}$：

- $u = 1$，$u' = 0$
- $v = 1 + e^{-z}$，$v' = -e^{-z}$

$$
\sigma'(z) = \frac{0 \cdot v - 1 \cdot (-e^{-z})}{(1 + e^{-z})^2} = \frac{e^{-z}}{(1 + e^{-z})^2}
$$

#### 方法 3：化简成 $a(1-a)$ 形式（最常用）

令 $a = \sigma(z)$。先算 $1 - a$：

$$
1 - a = 1 - \frac{1}{1+e^{-z}} = \frac{(1+e^{-z}) - 1}{1+e^{-z}} = \frac{e^{-z}}{1+e^{-z}}
$$

把方法 1 的结果拆成两个分式相乘：

$$
\frac{e^{-z}}{(1+e^{-z})^2}
= \underbrace{\frac{1}{1+e^{-z}}}_{a} \cdot \underbrace{\frac{e^{-z}}{1+e^{-z}}}_{1-a}
= a(1-a)
$$

**最终好用形式**：

$$
\boxed{\;\sigma'(z) = \sigma(z)\bigl(1 - \sigma(z)\bigr) = a(1-a)\;}
$$

这个形式在反向传播时**极其省事**——前向时已经算好 $a = \sigma(z)$ 缓存起来，反向时直接用 `a * (1 - a)`，**不必再算一次指数**。

### 6.3 NumPy 实现

```python
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)` 把 $z$ 限制在 $[-500, 500]$——$e^{-500} \approx 10^{-217}$ 已经小到能正常表示，再大就会触发数值溢出警告。

---

## 第 7 章：前向传播

前向传播就是"把输入一层层算到输出"。

### 7.1 隐藏层线性变换

$$
Z^{(1)} = X \cdot W^{(1)} + b^{(1)}
$$

形状：

$$
\underbrace{X}_{(4,2)} \cdot \underbrace{W^{(1)}}_{(2,4)} + \underbrace{b^{(1)}}_{(4,)} = \underbrace{Z^{(1)}}_{(4,4)}
$$

$b^{(1)}$ 是长度为 4 的向量，**NumPy 会自动把它"广播"到 (4, 4)**——每行都加上这个偏置。

```python
z1 = X @ W1 + b1
```

### 7.2 隐藏层激活

$$
A^{(1)} = \sigma(Z^{(1)})
$$

形状不变 $(4, 4)$，每个元素过一遍 sigmoid：

```python
a1 = sigmoid(z1)
```

### 7.3 输出层线性变换

$$
Z^{(2)} = A^{(1)} \cdot W^{(2)} + b^{(2)}
$$

形状：

$$
\underbrace{A^{(1)}}_{(4,4)} \cdot \underbrace{W^{(2)}}_{(4,1)} + \underbrace{b^{(2)}}_{(1,)} = \underbrace{Z^{(2)}}_{(4,1)}
$$

```python
z2 = a1 @ W2 + b2
```

### 7.4 输出层激活 + 拉平

$$
\hat y = \sigma(Z^{(2)})
$$

最后 `.ravel()` 把 $(4, 1)$ 拉平成 $(4,)$，便于和真值 $y$ 逐元素比较：

```python
y_pred = sigmoid(z2).ravel()
```

此时 $y_{\text{pred}} \in (0, 1)$，可以当成"预测为 1 的概率"。

---

## 第 8 章：损失函数（交叉熵）

### 8.1 什么是损失函数？

损失函数 $L(\hat y, y)$ 衡量"预测 $\hat y$ 和真值 $y$ 差多远"。差得越多，$L$ 越大；差得越少，$L$ 越小。训练的目标就是**最小化 $L$**。

### 8.2 二分类交叉熵（BCE）

对单个样本 $(x, y)$，$y \in \{0, 1\}$，$\hat y \in (0, 1)$：

$$
\ell(y, \hat y) = -\bigl[y \log \hat y + (1 - y) \log(1 - \hat y)\bigr]
$$

**直观理解**：

- 当 $y = 1$：$\ell = -\log \hat y$。$\hat y$ 越接近 1，$\ell$ 越小；$\hat y$ 接近 0，$\ell$ 爆炸。
- 当 $y = 0$：$\ell = -\log(1 - \hat y)$。$\hat y$ 越接近 0，$\ell$ 越小。

对 $N$ 个样本**取平均**：

$$
L = \frac{1}{N} \sum_{n=1}^{N} \ell\bigl(y^{(n)}, \hat y^{(n)}\bigr)
= -\frac{1}{N} \sum_{n=1}^{N} \bigl[y^{(n)} \log \hat y^{(n)} + (1 - y^{(n)}) \log(1 - \hat y^{(n)})\bigr]
$$

### 8.3 NumPy 实现

```python
loss = -np.mean(y * np.log(y_pred + 1e-8) +
                (1 - y) * np.log(1 - y_pred + 1e-8))
```

`+ 1e-8` 是为了防止 $\log(0)$ 出现 $-\infty$（数值上的小技巧）。

### 8.4 为什么用交叉熵而不是 MSE？

当输出层是 sigmoid 时，**交叉熵**比**均方误差 (MSE)** 训练更稳定，核心原因在反向传播时：MSE 会把 $\sigma'(z)$ 乘进梯度里，而 $\sigma'$ 在 $|z|$ 大时接近 0，导致**梯度消失**；交叉熵不会出现这个问题（下一章会详细推导）。

### 8.5 交叉熵对 $\hat y$ 的求导（关键！）

设

$$
L = -y \log \hat y - (1 - y) \log(1 - \hat y)
$$

我们要求 $\dfrac{\partial L}{\partial \hat y}$，其中 $y$ 当作常数。

#### 拆成两项

$$
L = \underbrace{-y \log \hat y}_{L_1} + \underbrace{[-(1-y)\log(1-\hat y)]}_{L_2}
$$

#### 算 $L_1$ 的导数

由 $\dfrac{d}{d\hat y} \log \hat y = \dfrac{1}{\hat y}$，$y$ 是常数直接提出来：

$$
\frac{\partial L_1}{\partial \hat y} = -y \cdot \frac{1}{\hat y} = -\frac{y}{\hat y}
$$

#### 算 $L_2$ 的导数（用链式法则）

设 $u = 1 - \hat y$，则 $\dfrac{du}{d\hat y} = -1$，$\dfrac{d}{du} \log u = \dfrac{1}{u}$。

$$
\frac{\partial L_2}{\partial \hat y}
= -(1-y) \cdot \frac{1}{1 - \hat y} \cdot (-1)
= \frac{1-y}{1 - \hat y}
$$


#### 合并

$$
\boxed{\;\frac{\partial L}{\partial \hat y} = -\frac{y}{\hat y} + \frac{1-y}{1-\hat y}\;}
$$

#### 通分化简（更优雅）

公分母 $\hat y (1 - \hat y)$：

$$
= \frac{-y(1-\hat y) + (1-y)\hat y}{\hat y(1-\hat y)}
$$

展开分子：

$$
-y(1-\hat y) = -y + y\hat y
$$

$$
(1-y)\hat y = \hat y - y\hat y
$$

相加：$-y + y\hat y + \hat y - y\hat y = \hat y - y$（中间两项 $y\hat y$ 抵消）

所以：

$$
\boxed{\;\frac{\partial L}{\partial \hat y} = \frac{\hat y - y}{\hat y (1 - \hat y)}\;}
$$

---

## 第 9 章：反向传播

反向传播是"沿着前向的反方向，用链式法则求每个参数的梯度"。

### 9.1 整体思路

我们从损失 $L$ 出发，按"从后往前"的顺序求偏导：

$$
L(\hat y, y)
\xleftarrow{\text{链式}}
Z^{(2)}
\xleftarrow{\text{链式}}
A^{(1)}
\xleftarrow{\text{链式}}
Z^{(1)}
\xleftarrow{\text{链式}}
W^{(1)}, b^{(1)}
$$

整个反向传播可以浓缩成 5 步，每一步都有具体的公式。下面**逐项推导**。

### 9.2 第 1 步：$dZ^{(2)} = \dfrac{\partial L}{\partial Z^{(2)}}$（最关键！）

我们已经知道：

- $\hat y = \sigma(z)$，$\sigma'(z) = \hat y(1 - \hat y)$
- $\dfrac{\partial L}{\partial \hat y} = \dfrac{\hat y - y}{\hat y(1 - \hat y)}$

**链式法则**：

$$
\frac{\partial L}{\partial z} = \frac{\partial L}{\partial \hat y} \cdot \frac{\partial \hat y}{\partial z}
= \frac{\hat y - y}{\hat y(1 - \hat y)} \cdot \hat y(1 - \hat y)
$$

$\hat y(1 - \hat y)$ 直接被约掉！

$$
\boxed{\;\frac{\partial L}{\partial z} = \hat y - y\;}
$$

对所有样本独立地成立，所以

$$
dZ^{(2)} = \hat y - y
$$

形状 $(N, 1)$（这里 $N=4$）。

#### 另一种写法（不绕 $\hat y$）

直接把 $L$ 写成 $z$ 的函数：

$$
L = -y \log \sigma(z) - (1-y) \log(1 - \sigma(z))
$$

对 $z$ 求导（注意 $\sigma'(z) = \sigma(z)(1-\sigma(z))$）：

$$
\frac{\partial L}{\partial z} = -y \cdot \frac{\sigma'(z)}{\sigma(z)} - (1-y) \cdot \frac{-\sigma'(z)}{1-\sigma(z)}
$$

代入 $\sigma'(z) = \sigma(z)(1-\sigma(z))$，分母再次被约掉：

$$
= (1-y)\sigma(z) - y(1-\sigma(z))
= \sigma(z) - y
$$

**两种方法殊途同归**。

#### 这个结果为什么这么漂亮？

- **形式简洁**：预测减真实，没了。
- **梯度方向天然合理**：
  - 预测偏高（$\hat y > y$）→ 梯度为正 → 减小 $z$ → 减小 $\hat y$
  - 预测偏低（$\hat y < y$）→ 梯度为负 → 增大 $z$ → 增大 $\hat y$
- **不会梯度消失**：没有 $\sigma'(z)$ 这一项，**预测越准、梯度越小**但不会突然消失；不像 MSE+sigmoid 那样在 $|z|$ 大时梯度直接饱和为 0。

这就是"sigmoid 输出 + 交叉熵"成为经典组合的根本原因。

### 9.3 第 2 步：$\dfrac{\partial L}{\partial W^{(2)}}$（详细推导）

我们已知 $dZ^{(2)} = \hat y - y$（形状 $(N, 1)$），现在要算 $W^{(2)}$ 的梯度。

#### 形状约定

| 量 | 形状 | 含义 |
| --- | --- | --- |
| $A^{(1)}$ | $(N, 4)$ | 隐藏层激活值 |
| $W^{(2)}$ | $(4, 1)$ | 隐藏→输出的权重 |
| $Z^{(2)}$ | $(N, 1)$ | 输出层激活前 |

#### 单样本推导

去掉 $N$ 这个 batch 维度，对第 $n$ 个样本：

$$
z_n^{(2)} = \sum_{i=1}^{4} a_{n,i}^{(1)} \cdot w_i^{(2)} + b^{(2)}
$$

对 $w_i^{(2)}$ 求偏导（链式法则）：

$$
\frac{\partial L}{\partial w_i^{(2)}}
= \frac{\partial L}{\partial z_n^{(2)}} \cdot \frac{\partial z_n^{(2)}}{\partial w_i^{(2)}}
= (\hat y_n - y_n) \cdot a_{n,i}^{(1)}
= a_{n,i}^{(1)} \cdot dZ_n^{(2)}
$$

> 注意 $\dfrac{\partial z_n^{(2)}}{\partial w_i^{(2)}} = a_{n,i}^{(1)}$：因为 $z_n^{(2)}$ 是关于 $w_i^{(2)}$ 的线性函数，$w_i^{(2)}$ 之外的 $a_{n,i'}$ 都是常数。

#### 把 $N$ 个样本合起来

对所有 $n$ 求平均（因为 $L$ 是平均损失）：

$$
\frac{\partial L}{\partial w_i^{(2)}}
= \frac{1}{N} \sum_{n=1}^{N} a_{n,i}^{(1)} \cdot dZ_n^{(2)}
$$

> "除以 $N$" 的来历：单样本时 $N=1$ 不用写；多样本时 $L = \frac{1}{N}\sum_n L_n$，对 $w_i$ 求导时这个 $\frac{1}{N}$ 自然带过来。

#### 写成矩阵形式

把上面"单样本"的偏导对所有 $n$ 求和（再除以 $N$），是 $\partial L/\partial W^{(2)}$ 的第 $i$ 行。**正好就是 $A^{(1)}$ 的转置乘 $dZ^{(2)}$**：

$$
\boxed{\;\frac{\partial L}{\partial W^{(2)}} = \frac{1}{N} (A^{(1)})^\top dZ^{(2)}\;}
$$

形状对位：$\underbrace{(A^{(1)})^\top}_{(4, N)} \times \underbrace{dZ^{(2)}}_{(N, 1)} \to \underbrace{\partial L/\partial W^{(2)}}_{(4, 1)}$ ✓

#### 直觉理解

把公式拆开看元素：

$$
\left(\frac{\partial L}{\partial W^{(2)}}\right)_{i}
= \frac{1}{N} \sum_{n=1}^{N} \underbrace{a_{n,i}^{(1)}}_{\text{第 } n \text{ 个样本的第 } i \text{ 个特征}} \cdot \underbrace{dZ_n^{(2)}}_{\text{该样本的误差}}
$$

- $a_{n,i}^{(1)}$ 越大、$dZ_n^{(2)}$ 越大 → $w_i^{(2)}$ 该被调得越多
- 对所有样本取平均：抹平单个样本的噪声

**这就是为什么线性层 $Z = AW + b$ 永远有 $\partial L/\partial W = A^\top dZ / N$**——本质上"输入特征"和"输出误差"的相关性矩阵。

### 9.4 第 2 步（续）：$\dfrac{\partial L}{\partial b^{(2)}}$

对 $b^{(2)}$，$\dfrac{\partial z_n^{(2)}}{\partial b^{(2)}} = 1$（偏置在线性项外是常数 1），所以：

$$
\frac{\partial L}{\partial b^{(2)}}\bigg|_{\text{样本 }n} = (\hat y_n - y_n) = dZ_n^{(2)}
$$

把 $N$ 个 $dZ_n^{(2)}$ 累加并平均：

$$
\boxed{\;\frac{\partial L}{\partial b^{(2)}} = \frac{1}{N} \mathbf{1}^\top dZ^{(2)}\;}
$$

形状：$(1, N) \times (N, 1) = (1, 1)$，与 $b^{(2)}$ 同形 ✓

在 NumPy 里就是 `dZ.mean(axis=0)`。

### 9.5 第 3 步：$dA^{(1)} = \dfrac{\partial L}{\partial A^{(1)}}$

$dZ^{(2)}$ 是输出层的"误差信号"，要把它沿着 $Z^{(2)} = A^{(1)} W^{(2)} + b^{(2)}$ 传回 $A^{(1)}$：

$$
dA^{(1)} = dZ^{(2)} \cdot (W^{(2)})^\top
$$

形状对位：$\underbrace{dZ^{(2)}}_{(N, 1)} \times \underbrace{(W^{(2)})^\top}_{(1, 4)} = \underbrace{dA^{(1)}}_{(N, 4)}$ ✓

```python
da1 = dz2 @ W2.T
```

### 9.6 第 4 步：$dZ^{(1)} = dA^{(1)} \odot \sigma'(Z^{(1)})$

$A^{(1)} = \sigma(Z^{(1)})$ 是逐元素的非线性，所以梯度也是逐元素相乘（$\odot$ 表示"按位乘"）：

$$
dZ^{(1)} = dA^{(1)} \odot \sigma'(Z^{(1)}) = dA^{(1)} \odot A^{(1)} \odot (1 - A^{(1)})
$$

```python
dz1 = da1 * dsigmoid(a1)
```

形状：$(N, 4)$，和 $Z^{(1)}$ 同形。

### 9.7 第 5 步：$\dfrac{\partial L}{\partial W^{(1)}}$（与第 2 步完全同构）

推导过程和 $W^{(2)}$ 完全一样，只是把 $A^{(1)}$ 换成 $X$、把 $dZ^{(2)}$ 换成 $dZ^{(1)}$：

$$
\boxed{\;\frac{\partial L}{\partial W^{(1)}} = \frac{1}{N} X^\top dZ^{(1)}\;}
$$

形状对位：$\underbrace{X^\top}_{(2, N)} \times \underbrace{dZ^{(1)}}_{(N, 4)} = \underbrace{\partial L/\partial W^{(1)}}_{(2, 4)}$ ✓

```python
dW1 = (X.T @ dz1) / N
```

### 9.8 第 5 步（续）：$\dfrac{\partial L}{\partial b^{(1)}}$

$$
\boxed{\;\frac{\partial L}{\partial b^{(1)}} = \frac{1}{N} \mathbf{1}^\top dZ^{(1)}\;}
$$

形状：$(1, N) \times (N, 4) = (1, 4)$，与 $b^{(1)}$ 同形 ✓

```python
db1 = dz1.mean(axis=0)
```

### 9.9 总结：反向传播 5 步

```python
# 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 为什么所有权重梯度公式都长成 "$X^\top dZ$" 模式？

在线性层 $Z = XW + b$ 里，$\partial L/\partial W$ 的第 $(i, j)$ 个元素

$$
= \sum_n X_{n, i} \cdot dZ_{n, j}
$$

这正好是 $(X^\top dZ)_{ij}$。所以"输入转置 × 输出梯度"是线性层的**通用模式**——在 PyTorch 里就是 `X.T @ dZ`。

---

## 第 10 章：梯度下降与训练循环

### 10.1 参数更新公式

有了梯度，沿着"损失下降最快的反方向"走一小步：

$$
W \leftarrow W - \eta \cdot \frac{\partial L}{\partial W}, \quad
b \leftarrow b - \eta \cdot \frac{\partial L}{\partial b}
$$

其中 $\eta$ 就是学习率 `lr`。

**直观理解**：

- $\partial L/\partial W$ 告诉你"山坡有多陡"
- 减去 $\eta$ 倍的坡度，就是"下山一步"
- 反复迭代就能逐步逼近局部最优点

```python
W1 -= lr * dW1
b1 -= lr * db1
W2 -= lr * dW2
b2 -= lr * db2
```

### 10.2 完整训练循环

把"前向 + 反向 + 更新"打包成循环，跑 1000 个 epoch：

```python
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 准确率

最直观的指标是"预测对的样本占比"：

```python
acc = (y_pred.round() == y).mean()
```

这行代码做了 3 件事：

1. `y_pred.round()`：把连续概率 $\hat y \in (0, 1)$ 四舍五入成 0 或 1
2. `... == y`：逐元素比较，预测对返回 `True`（=1），错返回 `False`（=0）
3. `.mean()`：把布尔当 0/1 求平均，得到正确率

### 11.2 训练后的预测

训练完用最终参数再跑一次前向，看每个样本的预测：

```python
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)}")
```

理想情况下 $\hat y$ 应该接近 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 + 交叉熵 | "$\hat y - y$" 的极简梯度 | `dz2 = y_pred - y` |
| 通用模式 $A^\top dZ$ | 线性层梯度的"通用公式" | `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 的 [神经网络视频系列](https://www.3blue1brown.com/topics/neural-networks) — 直观理解
- 《动手学深度学习》(d2l.ai) — 配套可跑代码

### 12.5 一句话回顾

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

---

如果哪里推导看不懂，欢迎随时回来重读对应章节——尤其是第 2、3 章的数学工具，和第 9 章反向传播的"5 步法"。看一遍不够，多看几遍就会了。
