第 5 课:线性层与激活函数 —— 神经网络的「积木」

2026-09-06 干徒
RustLLM神经网络

第 5 课:线性层与激活函数 —— 神经网络的"积木"

代码位置:

演示入口:src/main.rs

1. 本课要搞懂的问题

  1. 什么是"层"(layer)?为什么神经网络要由层堆叠而成?
  2. Linear 层的数学本质 y = xW + b 是什么?代码怎么写?
  3. 权重为什么要初始化?xavier 风格的 ±1/√in 是怎么来的?
  4. 激活函数是干什么的?ReLU / GELU / Tanh 的公式和反向导数各是什么?
  5. 怎么统一管理所有可训练参数?—— Module trait

2. 从"一个参数"到"一层"

第 2 课我们用手写更新 w、b 的方式学会了 y = 2x + 1。但真实问题(图像分类、语言模型)输入是几千维的向量、输出是几万个类别,不可能靠手写公式。

于是我们把"一批神经元"打包成一个层(layer)

  • 每个神经元:对输入做加权和 + 偏置,再过一层非线性函数
  • 一层 = 很多个这样的神经元,它们的权重堆在一起就是矩阵 W

神经网络 = 一层一层地堆叠。这一课实现最基本的积木:Linear 层激活函数

3. Linear 层:y = xW + b

Linear 又叫"全连接层 / 稠密层"。它把输入向量线性变换成另一个向量:

名字 形状 含义
x [B, in](或 [B, T, in]) 输入
W(weight) [in, out] 权重
b(bias) [out] 偏置
y [B, out](或 [B, T, out]) 输出

数学公式(对每个样本 i):

y[i, j] = Σ_k x[i, k] · W[k, j] + b[j]

等价写法:y = x @ W + b(这就是第 1 课学的矩阵乘法 + 第 3 课学的广播加法)。

对应的 Rust 结构(layers.rs):

/// 线性层:y = x @ W + b
///
/// - weight: [in_features, out_features]
/// - bias:   [out_features]
pub struct Linear {
    pub weight: Tensor,
    pub bias: Tensor,
}

前向计算的核心就一行:

// y = x @ W + b(b 是 [out],与 [B, out] 广播相加)
let y = x.matmul(&self.weight).add(&self.bias);

两个关键点:

  • weightbias 都用 Tensor::param(...) 构造(requires_grad = true),反向传播时自动得到梯度;
  • bias 形状是 [out],和结果 [B, out] 相加时按广播规则每一行都加上同一个偏置——这正是第 3 课广播的用武之地。

4. 权重初始化:为什么是 ±1/√in

Linear::new 创建时给权重赋随机初始值:

pub fn new(in_features: usize, out_features: usize, rng: &mut Rng) -> Self {
    let std = (2.0 / (in_features + out_features) as f32).sqrt();
    let w: Vec<f32> = (0..in_features * out_features)
        .map(|_| rng.randn() * std)
        .collect();
    Linear {
        weight: Tensor::param(w, vec![in_features, out_features]),
        bias: Tensor::param(vec![0.0; out_features], vec![out_features]),
    }
}

也就是从正态分布 N(0, std) 采样,std = √(2/(in+out))(Xavier 正态初始化),bias 初始化为全零。

为什么不能随便初始化?

初始化方式 后果
全 0 所有神经元输出相同、梯度相同,永远学不出差异
太大(如 ±5) 激活值迅速膨胀 → 数值溢出 / 梯度爆炸
太小(如 ±1e-4) 梯度极小 → 收敛慢如蜗牛
√(2/(in+out)) 激活值的方差随层数基本不放大,训练稳定

直觉推导:假设输入 x 的方差为 1,输出是 in 个项 wᵢxᵢ 的和。若 wᵢ ~ N(0, σ²),则 Var(wᵢ) = σ²,于是:

Var(Σ wᵢxᵢ) ≈ in · Var(wᵢ) · Var(x) = in · σ²

想让方差保持不变,需要 σ = √(1/in);Xavier 正态取 σ = √(2/(in+out))(兼顾前向和反向),核心思想——让每一层的输出量级都保持在 1 附近

随机数从哪来?标准库没有 RNG,我们自己在 rng.rs 实现了一个 xorshift64(几十行位运算,零依赖、可复现):

/// 生成 [lo, hi) 的均匀浮点数
pub fn uniform(&mut self, lo: f32, hi: f32) -> f32 {
    lo + (hi - lo) * self.next_f32()
}

用固定种子创建(如第 7 课的 Rng::new(42)),每次运行得到的初始化完全相同,实验结果可复现。

5. 支持 2D 与 3D 输入

一个 batch 的输入通常是 2D([B, in]);但语言模型里一批数据是 3D([B, T, in],B=batch、T=序列长度)。Linear 的 forward 两者都支持:

pub fn forward(&self, x: &Tensor) -> Tensor {
    // 支持 [B, in] 和 [B, T, in];3D 输入在内部展平计算,输出保持 3D
    let is_3d = x.rank() == 3;
    let orig_shape = x.shape().to_vec();
    let x = match x.rank() {
        2 => x.clone(),
        3 => x.reshape(vec![x.shape()[0] * x.shape()[1], x.shape()[2]]),
        _ => panic!("Linear 输入必须为 2D 或 3D"),
    };
    let y = x.matmul(&self.weight).add(&self.bias);
    if is_3d {
        // 3D 输入 [B, T, in] -> 输出 [B, T, out](最后一维换成 out_features)
        let mut out_shape = orig_shape;
        let n = out_shape.len();
        out_shape[n - 1] = self.weight.shape()[1];
        y.reshape(out_shape)
    } else {
        y
    }
}

处理方式:3D 先 reshape[B·T, in] 当 2D 算,算完再把最后一维换回 outreshape[B, T, out]

输入形状 内部计算形状 输出形状
[B, in] [B, in](原样) [B, out]
[B, T, in] [B·T, in](展平) [B, T, out]

reshape 不改变数据顺序,梯度也能 1:1 传回,所以这个"展平再恢复"的技巧在自动微分下完全安全。

6. 参数管理:Module trait

深度学习里一切"可训练的结构"都是模块。我们定义统一的接口(module.rs):

/// 模块接口:任何可训练结构都实现它
pub trait Module {
    /// 返回模块的所有参数(含嵌套子模块)
    fn parameters(&self) -> Vec<Tensor>;
}

/// 便捷方法:清零所有参数的梯度
pub fn zero_grad_all(module: &dyn Module) {
    for p in module.parameters() {
        p.zero_grad();
    }
}

Linear 的实现(layers.rs):

impl Module for Linear {
    fn parameters(&self) -> Vec<Tensor> {
        vec![self.weight.clone(), self.bias.clone()]
    }
}

为什么需要这个 trait? 因为优化器(第 6 课)只认"参数列表"。多个模块拼在一起时:

let params: Vec<Tensor> = {
    let mut ps = fc1.parameters();
    ps.extend(fc2.parameters());
    ps
};
let opt = SGD::new(0.5, params);   // 优化器拿到全部 22 个参数

有了 Module,今后 LayerNorm、Embedding、Transformer Block 都能用同一套方式"一键收集参数",这正是第 7 课 demo_xor 的做法。

7. 激活函数:没有它,多层等于一层

先想一个问题:如果只有线性层会怎样?

f(x) = (xW1 + b1)·W2 + b2 = x·(W1W2) + (b1W2 + b2)

两个线性变换复合还是一个线性变换!堆多少层都等价于一层。所以必须夹入非线性激活函数,多层才有意义。

layers.rs 提供了三个激活函数(都是对第 4 课 tensor.rs 中张量方法的薄包装):

/// ReLU:max(0, x),简单、计算快、缓解梯度消失
pub fn relu(x: &Tensor) -> Tensor { x.relu() }

/// GELU:GPT 系列的默认激活,用 tanh 近似,比 ReLU 更平滑
pub fn gelu(x: &Tensor) -> Tensor { x.gelu() }

/// Tanh:S 型,输出 (-1, 1)
pub fn tanh(x: &Tensor) -> Tensor { x.tanh() }

7.1 ReLU

公式与导数

c = max(0, x)
dc/dx = 1  (x > 0)
      = 0  (x ≤ 0)

对应代码tensor.rs 中的 relu,核心是反向用 mask 乘梯度):

// 前向:c = max(0, x);同时记录 mask:x > 0 的位置为 1,其余为 0
let mask: Vec<f32> = sd.iter().map(|&a| if a > 0.0 { 1.0 } else { 0.0 }).collect();
// 反向:∂x = g * mask(即 g * (x>0))
sgm[i] += g[i] * mask[i];

特点:计算最快;x<0 时梯度为 0(优点:缓解梯度消失;缺点:可能"神经元死亡")。

7.2 GELU

公式(tanh 近似,GPT 系列默认激活):

c = 0.5·x·(1 + tanh(√(2/π)·(x + 0.044715·x³)))

反向导数(令 a = √(2/π)(x + 0.044715x³)t = tanh(a)):

da/dx = √(2/π) · (1 + 3·0.044715·x²)
dc/dx = 0.5·(1 + t) + 0.5·x·(1 - t²)·da/dx

对应代码tensor.rs 中的 gelu,前向缓存 t 供反向复用):

const SQRT_2_PI: f32 = 0.797_884_560_8; // sqrt(2/π)
const COEF: f32 = 0.044_715;
// 前向:t_vals 缓存 tanh(a)
let a = SQRT_2_PI * (x + COEF * x * x * x);
let t = a.tanh();
t_vals[i] = t;
data[i] = 0.5 * x * (1.0 + t);
// 反向
let da_dx = SQRT_2_PI * (1.0 + 3.0 * COEF * x * x);
let dy_dx = 0.5 * (1.0 + t) + 0.5 * x * (1.0 - t * t) * da_dx;
sgm[i] += g[i] * dy_dx;

特点:处处可导、比 ReLU 平滑,x=0 附近没有"尖角"。

7.3 Tanh

公式与导数

c = tanh(x) = (e^x - e^-x) / (e^x + e^-x)
dc/dx = 1 - tanh²(x) = 1 - c²

对应代码tensor.rs 中的 tanh,反向直接用前向输出值 c 算 1-c²):

// 前向:c = tanh(x)
// 反向:∂x = g * (1 - c²)
sgm[i] += g[i] * (1.0 - data[i] * data[i]);

特点:S 形,输出落在 (-1, 1),需要居中输出时好用;两端梯度趋近 0。

7.4 三种激活对比

激活 输出范围 公式 反向导数 特点
ReLU [0, +∞) max(0, x) 1(x>0)否则 0 最快,隐藏层最常用
GELU 约 (-0.17, +∞) 0.5x(1+tanh(√(2/π)(x+0.044715x³))) 见 7.2 平滑,GPT 系列默认
Tanh (-1, 1) (ex−e-x)/(ex+e-x) 1−c² 输出居中,两端饱和

补充:layers.rs 里还有一个 softmax(把一组分数变成概率分布),它是 CrossEntropy 的一部分,下一课会用到。学习计划表里第 5 课原写的是"Sigmoid",实际代码中用 GELU 取代了它(GPT 时代更常用),ReLU / Tanh 均已实现。

8. 运行与测试

cargo test     # 测试全部通过(含 relu 梯度、softmax、线性回归收敛等)
cargo run      # 演示 1:MLP 学习 XOR(第 7 课完整讲解)

9. 动手练习

  1. Linear::new 里的 std 改成 0.01 或 5.0,跑第 7 课的 demo_xor,观察收敛速度变化,解释原因。
  2. 给 Linear 加一个"无 bias"选项(bias 全 0 且不出现在 parameters() 里),想想 parameters() 该怎么改。
  3. 手动推导:GELU 在 x=0 处的值(应为 0)和导数;tanh 在 x=0 处的导数(应为 1)。
  4. 思考:代码里 ReLU 在 x=0 处的梯度定义为 0(a > 0 才为 1)。如果定义为 1 会怎样?训练结果会有区别吗?
  5. (选做)查资料对比 xavier 与 kaiming 初始化,并解释为什么 Embedding 用 0.02 的 bound(见 layers.rsEmbedding::new)。

10. 本课总结

  • Linear:y = xW + b,weight [in, out]、bias [out],支持 2D / 3D 输入
  • 初始化:均匀分布 ±1/√in(xavier 风格),保证激活量级不随层数放大
  • Module traitparameters() 统一收集参数,优化器直接消费
  • 激活函数:ReLU / GELU / Tanh,公式、反向导数与代码一一对应
  • 下一课:损失函数与优化器,让模型"知道自己错在哪、怎么改"
Rust 大语言模型 学习指南共 22 章
1从零用 Rust 实现大语言模型 —— 学习计划2第 1 课:张量 Tensor —— 一切的基础3第 2 课:自动微分 Autograd —— 让模型学会「自我修正」4第 3 课:张量运算扩展 —— 广播、归约、softmax、批量矩阵乘法5第 4 课:模块化重构 —— 项目结构分层与 Rc<RefCell> 架构6第 5 课:线性层与激活函数 —— 神经网络的「积木」本篇7第 6 课:损失函数与优化器 —— 让模型知道「错在哪、怎么改」8第 7 课:第一个 MLP —— 教会神经网络算 XOR9第 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 加速训练与推理