目录

第一章:激活函数与基础组件(TorchCode)

第一章:激活函数与基础组件

本章涵盖神经网络最底层的构建模块:激活函数、线性变换、嵌入、正则化和损失函数。


ReLU 是最简单也最常用的激活函数,将所有负值置零,正值保持不变。

ReLU(x)=max(0,x)\text{ReLU}(x) = \max(0, x)

等价实现:x * (x > 0),利用布尔掩码与逐元素乘法。

神经网络需要非线性变换来拟合复杂函数。没有激活函数,多层线性层的叠加仍然是线性变换。ReLU 相比 Sigmoid/Tanh 的优势:

  • 计算极快(仅比较和乘法)
  • 正区间梯度恒为 1,缓解梯度消失
  • 产生稀疏激活(负值区域输出为 0)
  • Dead ReLU:如果某个神经元的输入始终为负,梯度永远为 0,该神经元"死亡"
  • 输出非零中心化,可能影响后续层的收敛
import torch

def relu(x: torch.Tensor) -> torch.Tensor:
    return x * (x > 0).float()

# 测试
x = torch.tensor([-2., -1., 0., 1., 2.])
print(relu(x))  # tensor([0., 0., 0., 1., 2.])

# 验证梯度流
x = torch.tensor([-1., 0., 1.], requires_grad=True)
y = relu(x).sum()
y.backward()
print(x.grad)  # tensor([0., 0., 1.])  — 负值区域梯度为 0
  • CNN 隐藏层的默认激活函数
  • 简单前馈网络
  • 不适合输出层(因为只有非负输出)

GELU 是一种平滑的激活函数,可以看作 ReLU 的概率化版本。它根据输入值的大小,以一定概率"保留"或"丢弃"该值。

GELU(x)=xΦ(x)=x0.5(1+erf(x/2))\text{GELU}(x) = x \cdot \Phi(x) = x \cdot 0.5 \cdot (1 + \text{erf}(x / \sqrt{2}))

其中 Φ(x)\Phi(x) 是标准正态分布的累积分布函数(CDF),erf\text{erf} 是误差函数。

  • 比 ReLU 更平滑,在 x=0 附近有连续的梯度过渡
  • 允许小的负值通过(不像 ReLU 完全截断),缓解 Dead ReLU 问题
  • 在 Transformer 架构中表现优于 ReLU

GELU(x) ≈ x(当 x 很大时),GELU(x) ≈ 0(当 x 很小时)。过渡区域是平滑的 S 形曲线,而非 ReLU 的硬拐点。

import torch
import math

def my_gelu(x: torch.Tensor) -> torch.Tensor:
    return x * 0.5 * (1.0 + torch.erf(x / math.sqrt(2.0)))

# 测试
x = torch.tensor([-2., -1., 0., 1., 2.])
print(my_gelu(x))
# tensor([-0.0454, -0.1587,  0.0000,  0.8413,  1.9546])
# 注意:负值区域不完全为 0,而是接近 0 的小值
  • Transformer 中 FFN 的默认激活函数(BERT、GPT-2)
  • 现代 LLM 中常被 SiLU/Swish 替代(用于 SwiGLU)

Softmax 将任意实数向量转换为概率分布(所有元素非负且和为 1)。

softmax(xi)=exijexj\text{softmax}(x_i) = \frac{e^{x_i}}{\sum_j e^{x_j}}

直接计算 exie^{x_i}xix_i 很大时会溢出。解决方法:先减去最大值。

softmax(xi)=eximax(x)jexjmax(x)\text{softmax}(x_i) = \frac{e^{x_i - \max(x)}}{\sum_j e^{x_j - \max(x)}}

这不改变结果(分子分母同乘常数),但避免了数值溢出。

import torch

def my_softmax(x: torch.Tensor, dim: int = -1) -> torch.Tensor:
    x_max = x.max(dim=dim, keepdim=True).values
    x_exp = torch.exp(x - x_max)
    return x_exp / x_exp.sum(dim=dim, keepdim=True)

# 测试
x = torch.tensor([1.0, 2.0, 3.0])
print(my_softmax(x))  # tensor([0.0900, 0.2447, 0.6652])
print(my_softmax(x).sum())  # tensor(1.)

# 数值稳定性测试
x_large = torch.tensor([1000., 1001., 1002.])
print(my_softmax(x_large))  # 正常输出,不会 NaN
  • 分类任务的输出层
  • 注意力权重计算
  • 任何需要将 logits 转为概率的场景

线性层执行仿射变换 y=xWT+by = xW^T + b,是神经网络最基本的参数化组件。

  • weight:形状 (out_features, in_features),可学习参数
  • bias:形状 (out_features,),可学习参数
  • 前向计算:x @ weight.T + bias

使用 WN(0,1/nin)W \sim \mathcal{N}(0, 1/\sqrt{n_{in}}) 初始化,确保输出方差不随层数增长而爆炸或消失。

import torch

class SimpleLinear:
    def __init__(self, in_features: int, out_features: int):
        # Xavier/LeCun 风格初始化
        self.weight = torch.nn.Parameter(
            torch.randn(out_features, in_features) / (in_features ** 0.5)
        )
        self.bias = torch.nn.Parameter(torch.zeros(out_features))

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return x @ self.weight.T + self.bias

# 测试
layer = SimpleLinear(8, 4)
x = torch.randn(2, 8)
y = layer.forward(x)
print(y.shape)  # torch.Size([2, 4])

nn.Parameter 自动设置 requires_grad=True,使得 PyTorch 的 autograd 系统能追踪梯度,实现反向传播。


嵌入层是一个可学习的查找表,将离散的整数索引(如 token ID)映射为连续的稠密向量。

本质上就是一个矩阵索引操作:weight[indices]。权重矩阵形状为 (vocab_size, embed_dim),输入是整数张量,输出是对应行的向量。

  • 神经网络无法直接处理离散符号(如单词、字符)
  • 嵌入向量可以学习到语义关系(相似词的向量距离近)
  • 比 one-hot 编码高效得多(one-hot 维度 = 词表大小,通常 30k-100k)
import torch
import torch.nn as nn

class MyEmbedding(nn.Module):
    def __init__(self, num_embeddings: int, embedding_dim: int):
        super().__init__()
        self.weight = nn.Parameter(torch.randn(num_embeddings, embedding_dim))

    def forward(self, indices: torch.Tensor) -> torch.Tensor:
        return self.weight[indices]

# 测试
emb = MyEmbedding(1000, 64)  # 词表大小 1000,嵌入维度 64
idx = torch.tensor([0, 42, 999])
print(emb(idx).shape)  # torch.Size([3, 64])

# 支持任意形状的索引
batch_idx = torch.tensor([[1, 2, 3], [4, 5, 6]])
print(emb(batch_idx).shape)  # torch.Size([2, 3, 64])
  • NLP 模型的第一层(word embedding)
  • 推荐系统中的 item/user embedding
  • 任何需要将离散 ID 转为向量的场景

Dropout 是一种正则化技术。训练时随机将一部分神经元的输出置零,推理时不做任何操作。

  • 训练模式:以概率 pp 将每个元素置零,剩余元素乘以 11p\frac{1}{1-p}(inverted dropout)
  • 推理模式:直接返回输入(恒等映射)

训练时丢弃了 pp 比例的神经元,如果不缩放,推理时(所有神经元都活跃)的期望输出会比训练时大。乘以 11p\frac{1}{1-p} 使得训练和推理时的期望输出一致。

import torch
import torch.nn as nn

class MyDropout(nn.Module):
    def __init__(self, p: float = 0.5):
        super().__init__()
        self.p = p

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        if not self.training or self.p == 0.0:
            return x
        mask = (torch.rand_like(x) > self.p).float()
        return x * mask / (1.0 - self.p)

# 测试
d = MyDropout(p=0.5)
x = torch.ones(10)

d.train()
print(d(x))  # 约一半为 0,其余为 2.0(= 1.0 / 0.5)

d.eval()
print(d(x))  # tensor([1., 1., 1., 1., 1., 1., 1., 1., 1., 1.])
  • Transformer 中的注意力权重和 FFN 输出
  • 全连接层之间
  • 通常 p=0.1(Transformer)或 p=0.5(经典网络)

交叉熵损失是分类任务的标准损失函数,衡量模型预测的概率分布与真实标签之间的差异。

CE(x,y)=logexyjexj\text{CE}(x, y) = -\log\frac{e^{x_y}}{\sum_j e^{x_j}}

其中 xx 是 logits 向量(未归一化的分数),yy 是正确类别的索引。

展开后等价于:

CE(x,y)=xy+logjexj\text{CE}(x, y) = -x_y + \log\sum_j e^{x_j}logjexj=m+logjexjm,m=max(x)\log\sum_j e^{x_j} = m + \log\sum_j e^{x_j - m}, \quad m = \max(x)
import torch

def cross_entropy_loss(logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
    # logits: (B, C), targets: (B,)
    # 使用 logsumexp 保证数值稳定
    log_sum_exp = torch.logsumexp(logits, dim=-1)  # (B,)
    # 取出正确类别的 logit
    correct_logits = logits.gather(1, targets.unsqueeze(1)).squeeze(1)  # (B,)
    # CE = -log(softmax) = -(logit - logsumexp)
    loss = log_sum_exp - correct_logits
    return loss.mean()

# 测试
logits = torch.tensor([[2.0, 1.0, 0.1],
                        [0.5, 2.5, 0.3]])
targets = torch.tensor([0, 1])
print(cross_entropy_loss(logits, targets))
# 与 PyTorch 内置实现对比
print(torch.nn.functional.cross_entropy(logits, targets))

Cross-Entropy Loss = Softmax + Negative Log-Likelihood。实践中直接对 logits 计算(而非先 softmax 再取 log),数值更稳定。

  • 所有分类任务(图像分类、语言模型的 next-token prediction)
  • 语言模型训练中,对每个位置的 token 预测计算 CE loss

相关内容