Aphasia

Cogito ergo sum.

《从 GPU 到 LLM 微调:一条面向 RTX 5070 Ti 的实践学习路线》中,除了基本的numpy理论学习之外,还希望能够完成:

  1. 能判断 shape、broadcasting 和矩阵乘是否合法;
  2. 能把逐样本 Python 循环改写为向量化计算;
  3. 能实现稳定的 softmax、cross-entropy 和两层 MLP;
  4. 不依赖 autograd 写出 forward → loss → backward → update
  5. 能用梯度检查和训练曲线证明实现是正确的。

《深入 NumPy:从 ndarray 内存模型到科学计算与张量生态》已经完成了 ndarray、strides、广播、dtype、向量化与 BLAS/LAPACK 的理论铺垫。本文不再重复 API,而是把这些概念放进一个可以运行、可以失败、也可以验证的项目:只用 NumPy 训练一个两层 MLP,对双月牙数据进行分类。

本文对应的 Notebook 已保存执行结果,也可以直接在浏览器里修改参数、重启 Kernel 和重新运行。浏览器版本基于 Pyodide/WASM,适合验证算法和数值结果;文中的耗时只代表本次本地 CPU 执行,不应拿来评价本机 OpenBLAS、MKL 或 GPU 性能。

NumPy 两层 MLP:完整实验 Notebook Python 在浏览器内运行,首次启动需要加载运行时。

下文代码均直接对应 Notebook 中的 Cell,不是为了讲解重新编造的伪代码。代码里的 ①②③…… 与紧随其后的说明一一对应;Notebook 本身则使用更适合实际阅读的逐行中文注释,解释每个参数、axis、广播和 shape 变化。

一、项目设计:把框架自动完成的工作全部显式展开

选择二维双月牙数据(数据在坐标系中的分布,类似双月牙),是因为它不是线性可分问题。一条直线无法把两类样本正确分开,模型必须通过隐藏层和非线性激活学习弯曲的决策边界。相比直接使用 MNIST,这个数据集的输入只有两个维度,数据、错误样本和决策边界都可以直接画出来。

整个实验的数据流是:

1双月牙数据
2  → 分层切分 train / validation
3  → 仅用训练集统计量做标准化
4  → Linear(2, 32)
5  → ReLU
6  → Linear(32, 2)
7  → Stable Softmax + Cross Entropy
8  → 手写反向传播
9  → Mini-batch SGD

这里有两个刻意的约束:

  • 数据生成、标准化、前向、反向和参数更新全部由 NumPy 完成;
  • Matplotlib 只负责绘图,不参与任何模型计算。

为了让 Notebook 能独立运行,也为了把随机数、索引、广播和统计量真正串起来,本实现基于纯 NumPy 实现。

二、数据准备:标准化本身也是一次 shape 练习

逐行构造双月牙

Notebook 没有调用 sklearn.datasets.make_moons,而是把两个半圆的坐标构造完整展开:

 1def make_moons_numpy(n_samples=1200, noise=0.22, seed=42):
 2    rng = np.random.default_rng(seed)                 # ① 局部、可复现的随机数生成器
 3    n_first = n_samples // 2                          # ② 第一类样本数
 4    n_second = n_samples - n_first                    # ③ 第二类接收余数
 5
 6    first_angle = np.linspace(0.0, np.pi, n_first)    # ④ 第一段半圆角度
 7    second_angle = np.linspace(0.0, np.pi, n_second)  # ⑤ 第二段半圆角度
 8    first = np.c_[                                    # ⑥ 两列拼成 (n_first, 2)
 9        np.cos(first_angle),
10        np.sin(first_angle),
11    ]
12    second = np.c_[                                   # ⑦ 翻转并平移第二个半圆
13        1.0 - np.cos(second_angle),
14        0.5 - np.sin(second_angle),
15    ]
16
17    x = np.vstack([first, second])                    # ⑧ 沿样本轴合并
18    x += rng.normal(0.0, noise, size=x.shape)         # ⑨ 加入同 shape 高斯噪声
19    y = np.concatenate([                              # ⑩ 构造整数类别索引
20        np.zeros(n_first),
21        np.ones(n_second),
22    ]).astype(np.int64)
23
24    order = rng.permutation(n_samples)                # ⑪ 生成随机样本排列
25    return x[order], y[order]                         # ⑫ 特征与标签同步打乱

逐行看这段代码:

  1. default_rng(seed) 比直接调用全局 np.random.seed 更容易控制作用域;数据随机性不会意外影响参数初始化。
  2. ②③ 分开计算两类数量,使 n_samples 为奇数时仍不会丢样本。
  3. ④⑤ np.linspace(start, stop, num)[0,π] 上生成等距角度,输出是一维数组。
  4. ⑥⑦ np.c_ 按列拼接两个一维数组。cos 成为第 0 列,sin 成为第 1 列,因此每一行是一条二维样本。第二类通过平移与翻转形成交错月牙。
  5. vstack 在第 0 轴拼接两类,feature shape 从两个 (N/2,2) 变成 (N,2)
  6. size=x.shape 保证噪声与每个坐标一一对应;原地加法不改变 shape。
  7. 分类标签必须是整数索引,因为后续 probs[rows, y] 会直接用标签选择正确类别概率。
  8. ⑪⑫ 同一个 order 同时索引 xy。如果分别打乱,代码仍能运行,但标签已经与样本错位,是一种不会立即报错的数据错误。

逐类切分,避免类别比例漂移

 1def stratified_split(x, y, val_ratio=0.25, seed=42):
 2    rng = np.random.default_rng(seed)                 # ① 切分随机性独立可复现
 3    train_indices, val_indices = [], []               # ② 分类别暂存索引
 4
 5    for label in np.unique(y):                        # ③ 逐个类别处理
 6        label_indices = np.flatnonzero(y == label)    # ④ 布尔条件转一维索引
 7        rng.shuffle(label_indices)                    # ⑤ 只打乱当前类别
 8        val_count = int(label_indices.size * val_ratio)  # ⑥ 当前类验证数量
 9        val_indices.append(label_indices[:val_count])    # ⑦ 前段进入验证集
10        train_indices.append(label_indices[val_count:])  # ⑧ 余下进入训练集
11
12    train_indices = np.concatenate(train_indices)     # ⑨ 合并各类训练索引
13    val_indices = np.concatenate(val_indices)         # ⑩ 合并各类验证索引
14    rng.shuffle(train_indices)                        # ⑪ 消除合并后的类别分块
15    rng.shuffle(val_indices)
16    return (                                           # ⑫ 同步索引特征与标签
17        x[train_indices],
18        y[train_indices],
19        x[val_indices],
20        y[val_indices],
21    )

③到⑧ 是“分层”的核心:不是先打乱全部数据再切一刀,而是在每个类别内部按相同比例切分。⑨到⑪ 再把各类别索引合并并打乱,避免训练 batch 先全部看到类别 0、再看到类别 1。 使用 NumPy 高级索引一次产出四个数组,四者第 0 轴分别保持训练或验证样本数一致。

数据生成后,先按类别做分层切分,得到 900 条训练样本和 300 条验证样本:

1train: (900, 2) (900,)
2val:   (300, 2) (300,)

标准化只允许使用训练集统计量:

1mean = x_train.mean(axis=0, keepdims=True)             # ① 每列均值:(1, 2)
2std = x_train.std(axis=0, keepdims=True)               # ② 每列标准差:(1, 2)
3
4x_train = ((x_train - mean) / std).astype(np.float64) # ③ 广播并固定精度
5x_val = ((x_val - mean) / std).astype(np.float64)     # ④ 复用训练统计量

①②axis=0 消掉样本轴,只为两列特征分别计算统计量;keepdims=True 把结果保留成 (1,2)(900,2)-(1,2) 从尾部维度对齐,首维 1 广播到 900,所以每条样本都减去同一组训练均值。 只能复用训练集的 mean/std,不能在验证集重新估计。

keepdims=True 并非必需,(2,) 同样能广播。但保留二维形式更明确地表达“这是按列统计得到的一行参数”,也能减少扩展到更高维数据时的 shape 歧义。

本次运行结果为:

1train mean: [ 0. -0.]
2train std:  [1. 1.]

验证集必须复用训练集的 meanstd。如果对验证集单独 fit,就把验证集分布提前泄漏进了预处理过程。

三、向量化:先证明结果相同,再讨论速度

阶段一要求分别用循环和 NumPy 计算 10,000 个 128 维样本到一个查询向量的欧氏距离。逐样本版本是:

1distance = np.empty(points.shape[0])                  # ① 预分配 (10000,)
2for i in range(points.shape[0]):                     # ② Python 调度 10000 次
3    delta = points[i] - query                        # ③ (128,) - (128,)
4    distance[i] = np.sqrt(np.sum(delta ** 2))        # ④ 特征归约成标量

向量化版本利用 query: (128,)points: (10000, 128) 广播:

1delta = points - query                               # ⑤ (128,) 广播到 (10000,128)
2squared = delta ** 2                                 # ⑥ 逐元素平方,shape 不变
3sum_per_row = np.sum(squared, axis=1)                # ⑦ 特征轴归约为 (10000,)
4distance = np.sqrt(sum_per_row)                      # ⑧ 逐元素开平方

预分配输出避免在循环中不断扩展 Python list;②到④ 每次只处理一个样本,因此算术由 NumPy 完成,但循环调度仍发生在 Python。 是关键:NumPy 将 query 看成 (1,128) 并沿样本轴扩展;⑥⑦⑧ 分别对应平方、沿 feature 归约和开平方。Notebook 为了少创建命名变量,把 ⑤到⑧ 合并成一条表达式,执行顺序并没有变化。

性能比较之前先执行 np.testing.assert_allclose(loop_distance, vector_distance),否则“更快”可能只是悄悄改变了计算语义。本次重新执行 Notebook 时,循环耗时约 0.1010s,向量化耗时约 0.0196s,约为 5.1x

这个数字不是 NumPy 的固定加速比,甚至同一台机器重复执行也会波动:数组规模、CPU cache、系统负载、NumPy 构建方式以及是否产生临时数组都会影响结果。真正重要的是执行边界发生了变化:Python 不再调度 10,000 次逐样本循环,而是把整块数组交给 NumPy 的底层循环处理。进入 PyTorch 和 CUDA 后,减少 Python dispatch、增加单次 kernel 的工作量,仍然是同一条原则。

四、前向传播:每条公式都先写出 shape

设 batch size 为 \(B\),输入维度为 \(D=2\),隐藏维度为 \(H=32\),类别数为 \(C=2\)。两层 MLP 的前向传播为:

$$ Z_1 = XW_1 + b_1,\qquad A_1 = \operatorname{ReLU}(Z_1) $$$$ Z_2 = A_1W_2 + b_2,\qquad P = \operatorname{softmax}(Z_2) $$

参数初始化逐行拆解

 1def init_params(input_dim, hidden_dim, output_dim, seed=42):
 2    rng = np.random.default_rng(seed)                    # ① 初始化可复现
 3    w1 = rng.normal(                                     # ② 第一层 He 初始化
 4        loc=0.0,
 5        scale=np.sqrt(2.0 / input_dim),
 6        size=(input_dim, hidden_dim),
 7    )
 8    b1 = np.zeros(hidden_dim)                             # ③ (H,)
 9    w2 = rng.normal(                                     # ④ 第二层按 fan-in 缩放
10        loc=0.0,
11        scale=np.sqrt(2.0 / hidden_dim),
12        size=(hidden_dim, output_dim),
13    )
14    b2 = np.zeros(output_dim)                             # ⑤ (C,)
15    return MLPParams(w1, b1, w2, b2)                     # ⑥ 具名返回

让相同 seed 产生相同初始权重,学习率对照才能只改变一个变量。loc 是正态分布均值,scale 是标准差,size 明确参数 shape;ReLU 会截断一部分激活,He 初始化用 sqrt(2/fan_in) 控制前向方差。③⑤ 使用一维 bias,后续加法会沿 batch 轴广播。fan_in 已变成隐藏维度 H 用 dataclass 按名称组织参数,防止把四个匿名数组的位置顺序传错。

forward 的六行分别做什么

 1def forward(x, params):
 2    z1 = x @ params.w1 + params.b1       # ① (B,D)@(D,H)+(H,) -> (B,H)
 3    a1 = relu(z1)                        # ② 逐元素非线性,shape 不变
 4    logits = a1 @ params.w2 + params.b2  # ③ (B,H)@(H,C)+(C,) -> (B,C)
 5    probs = softmax(logits)              # ④ 每行转换成类别概率
 6    return ForwardCache(                 # ⑤ 保存 backward 所需状态
 7        x=x,
 8        z1=z1,
 9        a1=a1,
10        logits=logits,
11        probs=probs,
12    )

是第一层仿射变换,b1(H,) 广播到 (B,H) 如果省略 ReLU,两层矩阵乘仍可合并成一个线性变换,无法学习弯曲边界; 把隐藏特征投影为每个类别的未归一化分数; 只在类别轴归一化; 说明手写反向传播需要主动保存输入和中间激活,而 autograd 会替框架用户维护这部分状态。

对应的 shape 是:

张量Shape含义
\(X\)\((B,D)\)一批输入
\(W_1\) / \(b_1\)\((D,H)\) / \((H,)\)第一层参数
\(Z_1\) / \(A_1\)\((B,H)\)隐藏层线性输出与激活
\(W_2\) / \(b_2\)\((H,C)\) / \((C,)\)第二层参数
\(Z_2\) / \(P\)\((B,C)\)logits 与类别概率

两个 bias 都依赖广播:b1: (H,) 被加到 Z1 的每一行,b2: (C,) 被加到每条样本的 logits。Notebook 对 8 条样本实际打印:

1x       (8, 2)
2w1      (2, 32)
3b1      (32,)
4z1      (8, 32)
5a1      (8, 32)
6w2      (32, 2)
7b2      (2,)
8logits  (8, 2)
9probs   (8, 2)

稳定 softmax 不是可选优化

直接计算 \(e^{z_i}\) 会在 logits 很大时溢出。softmax 对所有 logits 同时平移一个常数不会改变结果,所以实现时先减去每行最大值:

1def softmax(logits):
2    row_max = np.max(logits, axis=1, keepdims=True)  # ① 每条样本的最大分数:(B,1)
3    shifted = logits - row_max                       # ② 广播回 (B,C)
4    exp = np.exp(shifted)                            # ③ 最大指数固定为 exp(0)=1
5    denominator = np.sum(exp, axis=1, keepdims=True) # ④ 每行归一化因子:(B,1)
6    return exp / denominator                         # ⑤ 广播除法,输出 (B,C)

①② 利用 softmax 的平移不变性,把每行最大值移到 0; 因而不会计算大于 1 的指数;axis=1 表示类别维,keepdims=True 保证分母是 (B,1) 再沿类别维广播,使输出每行和为 1。

cross-entropy 没有构造完整 one-hot 矩阵,而是直接索引正确类别:

1def cross_entropy_loss(probs, y):
2    rows = np.arange(y.shape[0])                     # ① [0,1,...,B-1]
3    correct_probs = probs[rows, y]                   # ② 每行选择标签对应概率
4    safe_probs = correct_probs + 1e-12               # ③ 防止 log(0)
5    return float(-np.mean(np.log(safe_probs)))       # ④ batch 平均负对数似然

是二维高级索引:rows 决定行,y 决定该行的类别列,输出 shape 为 (B,) 是数值保护,不改变正常概率的有效精度。 先逐样本取负对数,再对 batch 求均值,所以 backward 中也必须除以 B

五、反向传播:autograd 到底替我们做了什么

对 softmax 和 cross-entropy 联合求导,可以得到:

$$ dZ_2 = \frac{P-\operatorname{onehot}(y)}{B} $$

然后沿计算图反向传播:

$$ dW_2=A_1^TdZ_2,\qquad db_2=\sum_{i=1}^{B}dZ_2^{(i)} $$$$ dA_1=dZ_2W_2^T,\qquad dZ_1=dA_1\odot \mathbb{1}[Z_1>0] $$$$ dW_1=X^TdZ_1,\qquad db_1=\sum_{i=1}^{B}dZ_1^{(i)} $$

Notebook 中的 backward 沿着上面公式的逆序逐行执行。最值得核对的不是变量名,而是每一行输入输出的 shape:

 1def backward(cache, y, params):
 2    batch_size = y.shape[0]                           # ① B
 3
 4    dlogits = cache.probs.copy()                      # ② 不修改前向概率
 5    dlogits[np.arange(batch_size), y] -= 1.0         # ③ P-one_hot(y)
 6    dlogits /= batch_size                             # ④ 对应 mean loss
 7
 8    dw2 = cache.a1.T @ dlogits                        # ⑤ (H,B)@(B,C)
 9    db2 = np.sum(dlogits, axis=0)                     # ⑥ 样本轴归约
10    da1 = dlogits @ params.w2.T                       # ⑦ (B,C)@(C,H)
11    dz1 = da1 * (cache.z1 > 0.0)                     # ⑧ ReLU 局部梯度
12    dw1 = cache.x.T @ dz1                             # ⑨ (D,B)@(B,H)
13    db1 = np.sum(dz1, axis=0)                        # ⑩ 样本轴归约
14
15    return MLPParams(dw1, db1, dw2, db2)             # ⑪ 与参数同结构

逐行对应关系是:

  1. 从标签数量取得 B,不依赖调用者额外传入。
  2. 必须 copy;如果直接写 dlogits = cache.probs,③会原地破坏 forward 结果,后续 loss、accuracy 或调试输出都可能出错。
  3. 同一条高级索引语句只修改每条样本的正确类别,等价于减去 one-hot 标签,却不需要分配 (B,C) 的 one-hot 数组。
  4. forward 的 loss 使用 mean,所以梯度也要除以 batch size。漏掉这一行不会改变梯度方向,却会让有效学习率随 batch size 改变。
  5. ⑤⑨ 权重梯度都由“层输入转置 @ 层输出梯度”得到;转置既来自矩阵微分,也保证输出与权重同形。
  6. ⑥⑩ bias 被广播到 batch 中每条样本,因此反向要把所有样本对 bias 的贡献沿 axis=0 求和。
  7. 通过 W2.T 把类别空间梯度传回隐藏空间。
  8. 比较表达式生成布尔 mask;NumPy 在乘法中将 True/False 当作 1/0,负激活位置的梯度被截断。
  9. 返回与参数相同的 dataclass,让更新函数可以逐字段操作,并直接检查参数/梯度 shape 是否一致。

最终每个梯度必须和对应参数同形:

1w1 (2, 32) (2, 32)
2b1 (32,)   (32,)
3w2 (32, 2) (32, 2)
4b2 (2,)    (2,)

这也解释了为什么框架需要 autograd:真实 Transformer 的计算图包含大量矩阵乘、残差、归一化、reshape 和广播,手工保存 cache、按逆序应用链式法则,既繁琐又容易错。autograd 消除的是机械工作,不是 shape、数值稳定性和优化问题。

六、梯度检查:让 backward 具备可证伪性

代码能运行、loss 能下降,都不能严格证明梯度实现正确。有限差分用参数两侧的 loss 估算数值梯度:

$$ \frac{\partial L}{\partial \theta} \approx \frac{L(\theta+\varepsilon)-L(\theta-\varepsilon)}{2\varepsilon} $$

Notebook 的实现只抽查少量参数元素:

 1def gradient_check(seed=7):
 2    rng = np.random.default_rng(seed)                 # ① 固定微型问题
 3    tiny_x = rng.normal(size=(5, 3))                  # ② 5 条 3 维输入
 4    tiny_y = np.array([0, 1, 2, 1, 0], dtype=np.int64)
 5    params = init_params(3, 4, 3, seed)               # ③ 3→4→3 网络
 6    grads = backward(forward(tiny_x, params), tiny_y, params)  # ④ 解析梯度
 7    epsilon = 1e-5                                    # ⑤ 中心差分步长
 8    max_error = 0.0                                   # ⑥ 聚合最大误差
 9
10    checks = [                                        # ⑦ 权重和偏置都覆盖
11        ("w1", (0, 0)), ("w1", (2, 3)), ("b1", (1,)),
12        ("w2", (0, 1)), ("w2", (3, 2)), ("b2", (2,)),
13    ]
14    for name, index in checks:
15        parameter = getattr(params, name)             # ⑧ 按名称取得 ndarray
16        original = parameter[index]                   # ⑨ 保存原值
17
18        parameter[index] = original + epsilon         # ⑩ 正向扰动
19        loss_plus = cross_entropy_loss(
20            forward(tiny_x, params).probs,
21            tiny_y,
22        )
23        parameter[index] = original - epsilon         # ⑪ 负向扰动
24        loss_minus = cross_entropy_loss(
25            forward(tiny_x, params).probs,
26            tiny_y,
27        )
28        parameter[index] = original                   # ⑫ 必须恢复
29
30        numerical = (loss_plus - loss_minus) / (2.0 * epsilon)  # ⑬
31        analytical = getattr(grads, name)[index]      # ⑭ 同位置解析梯度
32        max_error = max(                              # ⑮ 保留最坏情况
33            max_error,
34            abs(numerical - analytical),
35        )
36    return max_error

①到③ 刻意构造小型 float64 问题,避免完整训练模型让检查变得昂贵; 只计算一次手写解析梯度; 在截断误差与浮点舍入误差之间取折中;⑧到⑫ 对同一个参数元素先加后减,并在每次 forward 后恢复原值; 得到中心差分;⑭⑮ 比较同一位置的解析梯度并记录最大误差。⑫如果遗漏,后面的检查就会在已经被扰动的模型上进行。

Notebook 从 W1b1W2b2 中抽取若干元素,把数值梯度和手写 backward 的解析梯度逐个比较。在 \varepsilon=10^{-5} 时:

1gradient check max abs error: 1.51e-11

这个结果说明被抽查的梯度与有限差分在浮点误差范围内一致。梯度检查通常只在小模型上运行;应优先使用 float64,还要避开 ReLU 在 0 附近的不可导点。抽样通过不是数学证明,但能高效发现大多数符号、转置和归约轴错误。

七、训练循环与实验结果

训练循环之前,Notebook 先把“打乱并切成 batch”封装成生成器:

1def iterate_minibatches(x, y, batch_size, rng):
2    indices = rng.permutation(x.shape[0])             # ① 每轮重新打乱
3    for start in range(0, x.shape[0], batch_size):   # ② 最后一个 batch 可更小
4        batch_indices = indices[start:start + batch_size]  # ③ 当前窗口索引
5        yield x[batch_indices], y[batch_indices]      # ④ 特征标签同步返回

只打乱索引,不复制完整特征矩阵; range 不要求样本数整除 batch size; 最后一片会自然停在数组尾部; 使用 yield 延迟生成 batch,不需要预先把所有 batch 保存为列表。

核心训练循环没有隐藏步骤:

 1params = init_params(...)                             # ① 初始化一次
 2rng = np.random.default_rng(seed)                    # ② batch 随机性
 3history = {                                          # ③ 四条指标曲线
 4    "train_loss": [],
 5    "train_acc": [],
 6    "val_loss": [],
 7    "val_acc": [],
 8}
 9
10for epoch in range(1, epochs + 1):
11    for xb, yb in iterate_minibatches(x_train, y_train, batch_size, rng):
12        cache = forward(xb, params)                   # ④ 当前参数做前向
13        grads = backward(cache, yb, params)           # ⑤ 当前 batch 梯度
14        update(params, grads, learning_rate)          # ⑥ 立即执行 SGD
15
16    train_loss, train_acc = evaluate(x_train, y_train, params)  # ⑦
17    val_loss, val_acc = evaluate(x_val, y_val, params)          # ⑧
18    history["train_loss"].append(train_loss)           # ⑨ 记录而不更新
19    history["train_acc"].append(train_acc)
20    history["val_loss"].append(val_loss)
21    history["val_acc"].append(val_acc)

参数只能在训练开始时初始化一次,放进 epoch 循环会让模型每轮“失忆”; 单独维护 batch RNG,使数据生成和参数初始化不受 batch 调度影响; 同时记录 train/validation 的 loss 与 accuracy,才能区分优化速度和泛化表现;④⑤⑥ 严格对应 forward、backward、update;⑦⑧ 只评估,不产生梯度; 历史指标不参与参数更新,只作为诊断证据。

固定随机种子后,400 epoch 的最终结果是:

1epoch=0400 train_loss=0.0945 train_acc=0.956
2           val_loss=0.0782   val_acc=0.967

验证准确率略高于训练准确率并不反常:当前验证集更小,也可能恰好稍容易。重要的是同时观察 loss、accuracy 和决策边界:loss 稳定下降、训练/验证曲线没有持续分离,边界也确实弯曲并贴合双月牙结构。

决策边界并不是模型额外输出的对象,而是对密集二维网格批量推理后画出的概率等高线:

 1xx, yy = np.meshgrid(                                # ① 二维坐标网格
 2    np.linspace(x_min, x_max, 220),
 3    np.linspace(y_min, y_max, 220),
 4)
 5grid = np.c_[xx.ravel(), yy.ravel()]                 # ② (48400,2)
 6class_one_probability = forward(                     # ③ 一次批量推理
 7    grid,
 8    params,
 9).probs[:, 1].reshape(xx.shape)                     # ④ 恢复 (220,220)
10axes[2].contourf(                                    # ⑤ 概率填色
11    xx,
12    yy,
13    class_one_probability,
14    levels=25,
15    cmap="coolwarm",
16    alpha=0.55,
17)

meshgrid 把两个一维坐标轴展开为所有二维组合; ravel 后按列拼接,正好得到模型接受的二维 feature; 展示了向量化的另一个用途——一次预测 48,400 个网格点; 为绘图恢复网格结构; 将类别 1 概率画成连续色块,0.5 附近就是分类边界。

学习率实验:更快的单步不等于更快收敛

保持初始化、数据和 batch 顺序不变,训练 120 epoch:

 1learning_rates = [0.005, 0.08, 0.8, 5.0]             # ① 唯一自变量
 2lr_histories = {}                                     # ② 保存完整曲线
 3
 4for learning_rate in learning_rates:
 5    _, lr_history = train(                            # ③ 重新从同一 seed 初始化
 6        x_train,
 7        y_train,
 8        x_val,
 9        y_val,
10        epochs=120,                                   # ④ 固定训练预算
11        learning_rate=learning_rate,                  # ⑤ 只替换学习率
12        verbose=False,
13    )
14    lr_histories[learning_rate] = lr_history          # ⑥ 按学习率索引结果

同时覆盖过小、合适和过大的区间;②⑥ 不只保存最终 accuracy,还保留每一轮曲线; 每次 train 都使用相同 seed,因此初始参数和 batch 顺序一致;④⑤ 固定 epoch,只改变学习率,实验才具有可比性。如果复用上一次训练后的 params,比较的就不再是学习率,而是不同起点和不同训练时长的混合影响。

Learning rate最终训练 loss验证 accuracy观察
0.0050.290286.7%下降稳定,但明显偏慢
0.080.109697.0%稳定收敛
0.80.096597.3%本实验中收敛较快
5.00.244183.0%更新过大,结果明显退化

不能由此推出“0.8 是两层 MLP 的最佳学习率”。这个结论只在当前初始化、数据尺度、batch size 和 epoch 数下成立。换成未标准化输入、更深网络或不同初始化,稳定区间都会变化。正确做法是记录完整实验条件,再解释曲线,而不是孤立保存一个最佳数字。

八、从 NumPy 迁移到 PyTorch

完成这个项目后,PyTorch 的核心对象不再神秘:

NumPy 项目中的显式工作PyTorch 中的对应机制
ndarray 与参数 dataclassTensornn.Parameter
手工保存 ForwardCacheautograd graph 保存反向所需状态
backward(cache, y, params)loss.backward()
params -= lr * gradsoptimizer.step()
手写 mini-batch 索引Dataset / DataLoader
有限差分 gradient checktorch.autograd.gradcheck

真正需要带入下一阶段的是三种习惯:

  1. 先推 shape,再运行。 广播成功不代表语义正确,很多错误不会抛异常。
  2. 先构造可证伪检查,再调参。 梯度检查、概率行和、数值一致性断言都比观察“好像在下降”可靠。
  3. 把实验条件和结果一起记录。 随机种子、数据切分、dtype、学习率、batch size 缺一不可。

九、如何复现实验

点击文首的“在文章内加载 Notebook”,或者在新窗口打开。首次启动需要下载浏览器 Python 运行时;之后可以执行 Restart Kernel and Run All Cells。浏览器版适合验证算法,不适合判断本机 OpenBLAS/MKL、SIMD 和线程性能。

也可以下载 numpy-two-layer-mlp.ipynb 后在本地运行:

1python -m venv .venv
2source .venv/bin/activate
3python -m pip install numpy matplotlib jupyterlab
4jupyter lab numpy-two-layer-mlp.ipynb

Notebook 的模型实现不依赖 scikit-learn。建议第一次选择 Restart Kernel and Run All Cells,确认所有 Cell 在干净状态下可以顺序执行。

十、阶段一复盘

到这里,阶段一的理论和项目实践形成了闭环:

  • ndarray、内存布局、广播和 dtype:由理论文章建立心智模型;
  • shape、标准化和向量化:由数据与距离计算实验验证;
  • softmax、cross-entropy 和数值稳定性:由前向传播验证;
  • 矩阵梯度和链式法则:由手写 backward 验证;
  • 实现正确性:由有限差分梯度检查验证;
  • 训练行为:由 loss、accuracy、学习率对照和决策边界验证。

这还不是“学会深度学习”,但已经足以进入 PyTorch 阶段。下一步不是忘掉这些细节,而是观察框架如何把参数注册、计算图、梯度累积、优化器和设备迁移系统化,并继续追问:哪些机制只是减少样板代码,哪些机制真正改变了执行方式与性能边界。