第 7 课:第一个 MLP —— 教会神经网络算 XOR

2026-09-08 干徒
RustLLMMLP

第 7 课:第一个 MLP —— 教会神经网络算 XOR

代码位置:src/main.rsdemo_xor 函数) 前置知识:第 5 课 Linear / 激活函数、第 6 课 CrossEntropy / SGD

1. 本课要搞懂的问题

  1. XOR 为什么"看着简单却学不会"?什么是线性不可分
  2. 为什么必须加隐藏层和非线性激活?
  3. demo_xor 的每一行代码在干什么?
  4. 为什么 XOR 训练到 4/4 就能证明反向传播是对的

2. XOR:简单到"一眼看穿",难到"一条直线搞不定"

异或(XOR):两个输入相同输出 0,不同输出 1。

输入 x1 输入 x2 输出 y
0 0 0
0 1 1
1 0 1
1 1 0

把四个点画在平面上(● = 输出 1,○ = 输出 0):

x2
1 ─  ●(0,1)   ○(1,1)
   │
0 ─  ○(0,0)   ●(1,0)
   └──────────────── x1

问题来了:能不能画一条直线,把 ● 和 ○ 分开?不能。 一条直线最多把平面分成两半,而两个 ● 在对角线上——无论直线怎么放,总有一侧同时混着 ● 和 ○。这就是线性不可分

3. 为什么必须非线性:单层线性模型必败

  • 第 2 课的线性回归只有一层:pred = xW + b,它学出的边界本质是一条直线
  • 对 XOR,直线永远分不开 → 单层模型最多只能对 3 个样本正确(3/4),永远到不了 4/4
  • 解法:隐藏层 + 非线性激活(第 5 课学的 Tanh)。非线性可以把输入空间"折叠 / 扭曲",让原本线性不可分的点在高层变得线性可分

直觉(4 个样本为什么能学会):

  • 隐藏层 4 个神经元 ≈ 学出 4 种"特征组合"
  • Tanh 把每个特征压缩到 (-1, 1),给特征带来非线性
  • 第二层再线性组合这些特征,就能画出"折线"边界,把四个点完美分开

4. 数据集与网络结构:2 → 4 → 2

数据集(4 个样本,一次全部喂入,batch = 4):

// 数据集:4 个样本
let x_data = Tensor::from_vec(
    vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0],
    vec![4, 2],
);
let y_targets = vec![0usize, 1, 1, 0]; // XOR 真值表
  • x_data:[4, 2],四行正好是 (0,0)、(0,1)、(1,0)、(1,1)
  • y_targets:对应输出 0、1、1、0

网络(两个 Linear 叠成 MLP):

let fc1 = Linear::new(2, 4, &mut rng);   // 输入 2 维 → 隐藏层 4 维
let fc2 = Linear::new(4, 2, &mut rng);   // 隐藏层 4 维 → 输出 2 维

结构图:

x [4,2] ─► fc1 (2→4) ─► Tanh ─► h [4,4] ─► fc2 (4→2) ─► logits [4,2]
  • 输出层是 2 个神经元:第 0 个是"类别 0 的分数",第 1 个是"类别 1 的分数"
  • 为什么不直接用 1 个输出(0 或 1)?因为配合 CrossEntropy 分类更自然,两个分数取 argmax 就是预测

收集参数 + 创建优化器

let params: Vec<Tensor> = {
    let mut ps = fc1.parameters();
    ps.extend(fc2.parameters());
    ps
};
let opt = SGD::new(0.5, params);
  • fc1.parameters() + fc2.parameters():共 2×4+4 + 4×2+2 = 22 个参数
  • 学习率 0.5:XOR 问题简单,步长可以大一点
  • 随机种子固定为 42(Rng::new(42)),每次运行初始化相同、结果可复现

5. 训练循环:1000 步

for step in 0..1000 {
    // 前向:tanh(x @ W1 + b1) @ W2 + b2
    let h = tanh(&fc1.forward(&x_data));
    let logits = fc2.forward(&h);
    let loss = cross_entropy_loss(&logits, &y_targets);

    loss.backward();
    opt.step();
    opt.zero_grad();

    if step % 200 == 0 {
        println!("  step {:>4} | loss {:.4}", step, loss.data()[0]);
    }
}

对应第 6 课的"四步舞":

步骤 代码 说明
前向 fc1.forward(&x_data)tanhfc2.forward(&h) 4 个样本一次算完
损失 cross_entropy_loss(&logits, &y_targets) 分类交叉熵
反向 loss.backward() 自动微分算出 22 个参数的梯度
更新 opt.step() θ = θ − 0.5·g
清零 opt.zero_grad() 防止梯度跨轮累加

观察 loss:每 200 步打印一次,趋势是单调下降并趋近 0(具体数值由种子 42 的初始化决定,不必纠结;刚初始化时接近随机猜,2 类问题约等于 −ln 0.5 ≈ 0.69)。

6. 验证:4/4 才算赢

训练完要"考试":

// 验证正确率
let h = tanh(&fc1.forward(&x_data));
let logits = fc2.forward(&h);
let data = logits.data();
let mut correct = 0;
for i in 0..4 {
    let pred = if data[i * 2] > data[i * 2 + 1] { 0 } else { 1 };
    if pred == y_targets[i] {
        correct += 1;
    }
}
println!("  训练后正确率:{}/4(100% 说明反向传播正确)\n", correct);
  • 对每个样本取 argmax:类别 0 的分数 data[i*2] 大于类别 1 的分数 data[i*2+1] → 预测 0,否则预测 1
  • 期望输出:训练后正确率:4/4

7. 为什么 4/4 就能证明反向传播正确

推理链条:

  1. 参数是随机初始化的(种子 42),绝不是"碰巧答对";
  2. 梯度完全来自自动微分:任何一个运算(matmul / add / relu / softmax / log / sum ...)的反向实现出错,梯度就会偏一点,参数越走越偏,1000 步后必然累积成大错;
  3. XOR 没有捷径:它是"必须两层非线性"的问题,梯度要穿透 Tanh 的导数(1 - t²)、两层 matmul 的转置路径、softmax 的雅可比路径——整条反向传播链路被真实训练"压测"了一遍;
  4. 结果 100% 正确 + loss 单调下降,还同时验证了优化器(SGD 的 set_data 写回)和训练循环(backward → step → zero_grad 的顺序)都是对的。

换句话说:XOR 训练收敛到 4/4,等于对"张量库 + 网络层 + 损失 + 优化器"整条链路做了一次端到端集成测试

8. 运行

cargo run

输出第一段(loss 数值随种子确定,趋势如下):

=== 演示 1:MLP 学习 XOR(第 7 课)===
  step    0 | loss ...
  step  200 | loss ...
  step  400 | loss ...
  step  600 | loss ...
  step  800 | loss ...
  训练后正确率:4/4(100% 说明反向传播正确)

9. 动手练习

  1. 把隐藏层大小从 4 改成 2(Linear::new(2, 2, ...)),观察还能不能到 4/4(提示:2 个隐藏神经元很可能不够)。
  2. tanh 换成 relugelu,其他不变,观察收敛速度差异。
  3. 把学习率改成 0.05 和 5.0,对比 loss 下降曲线(太小慢、太大震荡)。
  4. 去掉 tanhh = fc1.forward(&x_data) 直接喂给 fc2),验证"没有非线性就学不会 XOR",观察正确率卡在 3/4。
  5. 把训练步数改成 100(可能还没收敛)和 10000(数据只有 4 个样本,看会不会过拟合),观察 loss 与正确率。

10. 本课总结

  • XOR 线性不可分 → 必须隐藏层 + 非线性激活
  • demo_xor 完整走通:数据集 → 网络 2→4→2 → 训练 1000 步 → 验证 4/4
  • 4/4 = 反向传播正确的强证据(端到端集成测试)
  • 从现在起,我们有了一个能训练任意小分类问题的完整工具箱
  • 下一课:给模型喂真正的文本——BPE 分词器
Rust 大语言模型 学习指南共 22 章
1从零用 Rust 实现大语言模型 —— 学习计划2第 1 课:张量 Tensor —— 一切的基础3第 2 课:自动微分 Autograd —— 让模型学会「自我修正」4第 3 课:张量运算扩展 —— 广播、归约、softmax、批量矩阵乘法5第 4 课:模块化重构 —— 项目结构分层与 Rc<RefCell> 架构6第 5 课:线性层与激活函数 —— 神经网络的「积木」7第 6 课:损失函数与优化器 —— 让模型知道「错在哪、怎么改」8第 7 课:第一个 MLP —— 教会神经网络算 XOR本篇9第 8 课:BPE 分词器 —— 让模型「读懂」文字10第 9 课:注意力机制 —— 让 token 互相「看」11第 10 课:多头注意力 —— 让模型「多角度」看世界12第 11 课:位置编码与归一化 —— 让序列带上「位置感」13第 12 课:完整 GPT 模型 —— 把积木拼成能预测下一个词的模型14第 13 课:训练循环 —— 让模型真正开始学习15第 14 课:数据加载 —— 文本如何变成训练样本16第 15 课:推理与采样 —— 让模型「创造性」地生成17第 16 课:训练小 GPT —— 看 loss 从 1.46 一路降到 0.1618第 17 课:AdamW 优化器 —— 给梯度下降装上「惯性」和「自适应步长」19第 18 课:KV Cache —— 让逐 token 生成不再重复计算20第 19 课:RoPE 旋转位置编码 —— 把「相对位置」揉进注意力21第 20 课:学习率调度与收尾 —— warmup、cosine decay 与全项目总结22第 21 课:GPU 加速训练与推理