上一篇文章从 Encoder-Decoder 结构出发,完整介绍了 Transformer 的 Self-Attention、Multi-Head Attention、位置编码、Mask、Encoder 和 Decoder。

这一次,我们不再调用 nn.Transformer,而是只使用 PyTorch 的基础层,从零手写一个能够训练和生成文本的 Mini-GPT。

它会包含 GPT 最核心的组件:

  • 字符级 Tokenizer。
  • Token Embedding 和可学习 Position Embedding。
  • 手写 Multi-Head Causal Self-Attention。
  • Pre-Norm Transformer Block。
  • GELU 前馈网络。
  • 自回归语言模型损失。
  • AdamW、学习率 warmup、余弦衰减和梯度裁剪。
  • Temperature、Top-k 与多项式采样。
  • 权重绑定和 KV Cache 原理。

最终模型只有约 35 万个参数,可以在 CPU 上训练。它当然不是 ChatGPT,也不会拥有通用知识,但它足以把 GPT 内部最重要的数据流完整跑通。

一句话概括:

GPT 接收一段 token 前缀,通过带因果遮罩的 Transformer Block 预测每个位置的下一个 token,再把新 token 接回输入继续生成。

Mini-GPT整体结构

1. GPT 到底是什么

GPT 是 Generative Pre-trained Transformer 的缩写:

  • Generative:通过预测和生成后续 token 建模文本。
  • Pre-trained:先在大量文本上进行语言模型预训练。
  • Transformer:核心网络使用 Transformer 架构。

本篇文章主要实现其中的“Generative Transformer”。因为我们只使用一小段教学语料,所以更准确地说,它是一个从零训练的 Mini-GPT,而不是经过大规模预训练的通用模型。

1.1 GPT 与上一篇完整 Transformer 的关系

原始 Transformer:

Encoder
  -> 产生 Memory
  -> Decoder 通过 Cross-Attention 读取 Memory

GPT:

只有 Decoder-only Block
  -> Causal Self-Attention
  -> 不需要 Encoder
  -> 不需要 Cross-Attention

这里有一个容易误解的地方:

GPT 的“Decoder-only”并不是把原始 Transformer Decoder 原封不动地拿出来。

原始 Decoder 每层有三个子层:

  1. Masked Self-Attention。
  2. 与 Encoder 交互的 Cross-Attention。
  3. FFN。

GPT 没有 Encoder,因此删除 Cross-Attention,只保留:

  1. Causal Self-Attention。
  2. FFN。

2. GPT 的学习目标:预测下一个 token

给定 token 序列:

$$
x_1,x_2,\ldots,x_T
$$

自回归语言模型把整段序列的联合概率分解为:

$$
P(x_1,x_2,\ldots,x_T)
=\prod_{t=1}^{T}P(x_t\mid x_1,\ldots,x_{t-1})
$$

也就是:

看到前文                  预测
<开始>                    第 1 个 token
x1                        x2
x1 x2                     x3
x1 x2 x3                  x4
...
x1 x2 ... x(T-1)          xT

GPT 不需要人为提供“主题”“语法规则”或“下一词类别”。训练文本自身就能构造监督信号。

例如文本是:

语言模型

可以得到:

输入 x:[语, 言, 模]
目标 y:[言, 模, 型]

输入和目标只错开一个位置。

语言模型输入与目标错位

3. 为什么一次前向传播能训练多个位置

训练时,模型一次接收:

x = [语, 言, 模]

并同时输出三个位置的词表 logits:

位置 0:根据“语”预测“言”
位置 1:根据“语言”预测“模”
位置 2:根据“语言模”预测“型”

因果遮罩保证位置 0 看不到位置 1 和 2,位置 1 看不到位置 2。因此,虽然整个矩阵并行计算,每个位置仍然只使用合法的历史信息。

如果:

  • batch size 为 $B$。
  • 上下文长度为 $T$。
  • 词表大小为 $V$。

那么:

输入 idx: [B, T]
目标 y:   [B, T]
logits:   [B, T, V]

一次前向传播实际上产生了 $B\times T$ 个 next-token 训练样本。

4. Tokenizer:模型如何看见文字

神经网络不能直接接收字符串。Tokenizer 需要建立:

文字 -> token -> token id

真实 GPT 通常使用子词或字节级 Tokenizer。为了把注意力放在模型结构上,这里使用字符级 Tokenizer。

4.1 构造字符词表

raw_text = """春天来了,花儿在风中开放。
夏天来了,蝉儿在树上歌唱。
秋天来了,果实在枝头成熟。
冬天来了,雪花在天空飞舞。
清晨,太阳从东方升起。
傍晚,月亮在云间出现。
小河穿过安静的村庄。
微风吹过绿色的田野。
我们读书,也观察世界。
我们思考,也记录问题。
机器学习从数据中寻找规律。
神经网络用参数表示知识。
注意力让词语彼此交换信息。
语言模型根据上文预测下一个字符。
"""

text = raw_text * 120
chars = sorted(set(text))

stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for ch, i in stoi.items()}
  • stoi 表示 string to integer。
  • itos 表示 integer to string。
  • 本例实测词表大小为 123。

4.2 编码与解码

def encode(s):
    return [stoi[ch] for ch in s]


def decode(ids):
    return "".join(itos[i] for i in ids)

测试:

ids = encode("春天")
print(ids)
print(decode(ids))

字符级 Tokenizer 的优点:

  • 代码简单。
  • 词表小。
  • 不容易遇到未知字符拆分问题。

缺点:

  • 同样一句话需要更多 token。
  • 单个 token 的语义较弱。
  • 序列变长,Attention 成本上升。

Mini-GPT 使用字符级分词是为了教学,不代表它是实际大模型的最佳方案。

5. 把连续文本切成训练窗口

先把完整文本编码成一维张量:

import torch

data = torch.tensor(encode(text), dtype=torch.long)
n = int(0.9 * len(data))

train_data = data[:n]
val_data = data[n:]

这里使用前 90% 训练,后 10% 验证。

定义最大上下文长度:

block_size = 64

从随机起点取 65 个连续 token:

t0 t1 t2 ... t63 t64

前 64 个作为输入,后 64 个作为目标:

x = t0 t1 t2 ... t63
y = t1 t2 t3 ... t64
def get_batch(split,block_size, batch_size=32):
    source = train_data if split == "train" else val_data
    starts = torch.randint(
        len(source) - block_size - 1,
        (batch_size,),
    )

    x = torch.stack([
        source[i:i + block_size]
        for i in starts
    ])
    y = torch.stack([
        source[i + 1:i + block_size + 1]
        for i in starts
    ])
	#
    return x.to(device), y.to(device)

5.1 block_size 是什么

block_size 是模型一次最多能使用的上下文 token 数,也称 context length 或 context window。

本例中:

block_size = 64

表示模型预测下一个字符时,最多参考最近 64 个字符。

它不是 batch size:

参数 含义
batch_size 一次并行训练多少段文本
block_size 每段文本包含多少个 token
vocab_size 可以预测多少种 token
n_embd 每个 token 的向量维度

6. Mini-GPT 配置

使用 dataclass 集中管理结构参数:

from dataclasses import dataclass


@dataclass
class GPTConfig:
    vocab_size: int
    block_size: int = 64
    n_layer: int = 3
    n_head: int = 4
    n_embd: int = 96
    dropout: float = 0.1

这些参数分别控制:

参数 作用 本例
vocab_size 输出类别数 123
block_size 最大上下文长度 64
n_layer GPT Block 数量 3
n_head 每层注意力头数 4
n_embd 隐藏维度 96
dropout Dropout 概率 0.1

每个头的维度为:

$$
d_{head}=\frac{n_{embd}}{n_{head}}
=\frac{96}{4}=24
$$

因此 n_embd 必须能被 n_head 整除。

7. Token Embedding 与 Position Embedding

输入 idx 只是整数编号:

[B, T]

Token Embedding 把每个编号查表转换成向量:

$$
E_{token}=W_{te}[idx]
$$

self.token_embedding = nn.Embedding(
    config.vocab_size,
    config.n_embd,
)

输出形状:

[B, T, C]

其中 $C=n_{embd}$。

7.1 为什么还需要位置向量

Causal Mask 只能限制“能看哪些位置”,并不能单独提供每个位置的完整顺序表示。因此还要加入 Position Embedding:

self.position_embedding = nn.Embedding(
    config.block_size,
    config.n_embd,
)
pos = torch.arange(t, device=idx.device)

token_emb = self.token_embedding(idx)      # [B, T, C]
position_emb = self.position_embedding(pos) # [T, C]
x = token_emb + position_emb               # [B, T, C]

相加时 [T,C] 会在 batch 维广播成 [B,T,C]

数学上:

$$
x_{b,t}=E_{token}(idx_{b,t})+E_{position}(t)
$$

上一篇文章使用了固定正弦位置编码。本篇采用 GPT 风格的可学习绝对位置向量,它们会与模型其他参数一起通过反向传播更新。

8. GPT 的因果自注意力

上一篇文章已经介绍了普通 Self-Attention:

$$
\operatorname{Attention}(Q,K,V)
=\operatorname{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V
$$

GPT 需要额外加入 Causal Mask:

$$
\operatorname{CausalAttention}(Q,K,V)
=\operatorname{softmax}\left(
\frac{QK^T}{\sqrt{d_k}}+M
\right)V
$$

其中:

$$
M_{ij}=
\begin{cases}
0,&j\le i\
-\infty,&j>i
\end{cases}
$$

含义是第 $i$ 个 Query:

  • 可以查看自己和过去位置。
  • 不能查看未来位置。

GPT因果注意力计算

9. 手写多头因果自注意力

下面不使用 nn.MultiheadAttention,完整写出 QKV 投影、拆头、遮罩、Softmax、加权和合并头。

9.1 初始化层和 Mask

import math
import torch
import torch.nn.functional as F
from torch import nn


class CausalSelfAttention(nn.Module):
    def __init__(self, config):
        super().__init__()

        if config.n_embd % config.n_head != 0:
            raise ValueError("n_embd must be divisible by n_head")

        self.n_head = config.n_head
        self.head_dim = config.n_embd // config.n_head

        self.qkv = nn.Linear(
            config.n_embd,
            3 * config.n_embd,
        )
        self.proj = nn.Linear(
            config.n_embd,
            config.n_embd,
        )

        self.attn_dropout = nn.Dropout(config.dropout)
        self.resid_dropout = nn.Dropout(config.dropout)

        mask = torch.tril(torch.ones(
            config.block_size,
            config.block_size,
        ))
        self.register_buffer(
            "causal_mask",
            mask.view(
                1, 1,
                config.block_size,
                config.block_size,
            ),
        )

下三角矩阵形如:

$$
\begin{bmatrix}
1&0&0&0\
1&1&0&0\
1&1&1&0\
1&1&1&1
\end{bmatrix}
$$

这里约定:

  • 1 表示允许查看。
  • 0 表示需要遮住。

causal_mask 不是可训练参数,但需要跟随模型移动到 GPU 并保存到 state_dict,所以使用 register_buffer

9.2 一次性产生 Q、K、V

q, k, v = self.qkv(x).split(c, dim=-1)

这里使用一个大线性层,而不是三个独立线性层:

x:       [B, T, C]
qkv(x):  [B, T, 3C]
q:       [B, T, C]
k:       [B, T, C]
v:       [B, T, C]

一个 Linear(C, 3C) 在数学上等价于把三组投影矩阵拼在一起:

$$
[Q,K,V]=X[W_Q,W_K,W_V]+b
$$

这样写通常能减少小矩阵算子的调度开销。

9.3 拆成多个头

q = q.view(
    b, t, self.n_head, self.head_dim
).transpose(1, 2)

k = k.view(
    b, t, self.n_head, self.head_dim
).transpose(1, 2)

v = v.view(
    b, t, self.n_head, self.head_dim
).transpose(1, 2)

形状变化:

[B, T, C]
-> [B, T, H, D]
-> [B, H, T, D]

其中:

$$
C=H\times D
$$

本例是:

C = 96
H = 4
D = 24

Mini-GPT QKV张量形状

9.4 计算分数并应用 Mask

scores = q @ k.transpose(-2, -1)
scores = scores / math.sqrt(self.head_dim)

scores = scores.masked_fill(
    self.causal_mask[:, :, :t, :t] == 0,
    float("-inf"),
)

形状:

q:             [B, H, T, D]
k.transpose:   [B, H, D, T]
scores:        [B, H, T, T]
causal_mask:   [1, 1, T, T]

Mask 在 batch 和 head 维自动广播。

为什么填充 $-\infty$?因为:

$$
e^{-\infty}=0
$$

经过 Softmax 后,未来位置的注意力权重会变成 0。

9.5 加权求和并合并头

weights = F.softmax(scores, dim=-1)
weights = self.attn_dropout(weights)

y = weights @ v

y = y.transpose(1, 2).contiguous().view(b, t, c)
y = self.proj(y)
y = self.resid_dropout(y)

return y

形状变化:

weights:       [B, H, T, T]
v:             [B, H, T, D]
weights @ v:   [B, H, T, D]
transpose:     [B, T, H, D]
view:          [B, T, C]
output proj:   [B, T, C]

9.6 完整注意力类

class CausalSelfAttention(nn.Module):
    def __init__(self, config):
        super().__init__()

        if config.n_embd % config.n_head != 0:
            raise ValueError(
                "n_embd must be divisible by n_head"
            )

        self.n_head = config.n_head
        self.head_dim = config.n_embd // config.n_head

        self.qkv = nn.Linear(
            config.n_embd,
            3 * config.n_embd,
        )
        self.proj = nn.Linear(
            config.n_embd,
            config.n_embd,
        )
        self.attn_dropout = nn.Dropout(config.dropout)
        self.resid_dropout = nn.Dropout(config.dropout)

        mask = torch.tril(torch.ones(
            config.block_size,
            config.block_size,
        ))
        self.register_buffer(
            "causal_mask",
            mask.view(
                1, 1,
                config.block_size,
                config.block_size,
            ),
        )

    def forward(self, x):
        b, t, c = x.shape

        q, k, v = self.qkv(x).split(c, dim=-1)

        q = q.view(
            b, t, self.n_head, self.head_dim
        ).transpose(1, 2)
        k = k.view(
            b, t, self.n_head, self.head_dim
        ).transpose(1, 2)
        v = v.view(
            b, t, self.n_head, self.head_dim
        ).transpose(1, 2)

        scores = q @ k.transpose(-2, -1)
        scores = scores / math.sqrt(self.head_dim)
        scores = scores.masked_fill(
            self.causal_mask[:, :, :t, :t] == 0,
            float("-inf"),
        )

        weights = F.softmax(scores, dim=-1)
        weights = self.attn_dropout(weights)

        y = weights @ v
        y = y.transpose(1, 2).contiguous().view(b, t, c)

        y = self.proj(y)
        return self.resid_dropout(y)

10. 为什么 transpose 后要调用 contiguous

transpose 通常不会真的复制数据,而是改变张量的 stride,让同一块内存以不同顺序被解释。

例如:

y = y.transpose(1, 2)

此时逻辑形状虽然是 [B,T,H,D],底层内存排列却可能不是连续的。直接用 view 合并维度可能报错或不符合预期。

y = y.transpose(1, 2).contiguous().view(b, t, c)

contiguous() 会把数据整理成当前维度顺序对应的连续内存,再交给 view

也可以使用 reshape,它在必要时会复制数据。但在这里显式写出 contiguous().view(...) 更容易看清拆头与合并头的过程。

11. MLP:对每个位置进行非线性变换

Attention 负责 token 之间的信息交换,MLP 负责每个 token 内部的特征变换。

GPT 风格 MLP 通常先把维度扩展到 4 倍,再缩回原维度:

$$
\operatorname{MLP}(x)
=W_2\operatorname{GELU}(W_1x+b_1)+b_2
$$

class MLP(nn.Module):
    def __init__(self, config):
        super().__init__()

        self.net = nn.Sequential(
            nn.Linear(
                config.n_embd,
                4 * config.n_embd,
            ),
            nn.GELU(),
            nn.Linear(
                4 * config.n_embd,
                config.n_embd,
            ),
            nn.Dropout(config.dropout),
        )

    def forward(self, x):
        return self.net(x)

本例形状变化:

[B, T, 96]
-> [B, T, 384]
-> GELU
-> [B, T, 96]

11.1 GELU 的数学原理

GELU 全称 Gaussian Error Linear Unit,常见定义为:

$$
\operatorname{GELU}(x)=x\Phi(x)
$$

$\Phi(x)$ 是标准正态分布的累积分布函数。

常用近似形式是:

$$
\operatorname{GELU}(x)
\approx\frac{x}{2}
\left[
1+\tanh\left(
\sqrt{\frac{2}{\pi}}
\left(x+0.044715x^3\right)
\right)
\right]
$$

与 ReLU 直接丢弃所有负数不同,GELU 会平滑地调节输入,负值附近仍可能保留少量信息。

12. Pre-Norm GPT Block

一个 Mini-GPT Block 包含:

  1. LayerNorm。
  2. Causal Self-Attention。
  3. 残差连接。
  4. LayerNorm。
  5. MLP。
  6. 残差连接。

Mini-GPT Pre-Norm Block

代码非常短:

class Block(nn.Module):
    def __init__(self, config):
        super().__init__()

        self.ln_1 = nn.LayerNorm(config.n_embd)
        self.attn = CausalSelfAttention(config)
        self.ln_2 = nn.LayerNorm(config.n_embd)
        self.mlp = MLP(config)

    def forward(self, x):
        x = x + self.attn(self.ln_1(x))
        x = x + self.mlp(self.ln_2(x))
        return x

数学形式:

$$
x’=x+\operatorname{Attention}(\operatorname{LN}(x))
$$

$$
y=x’+\operatorname{MLP}(\operatorname{LN}(x’))
$$

12.1 为什么叫 Pre-Norm

因为 LayerNorm 位于 Attention 或 MLP 之前:

x -> LayerNorm -> 子层 -> 与 x 相加

Post-Norm 则是:

x -> 子层 -> 与 x 相加 -> LayerNorm

上一篇原始 Transformer 主要展示了 Post-Norm。GPT-2 风格模型常使用 Pre-Norm,并在全部 Block 后再增加一次最终 LayerNorm。

12.2 残差连接为什么不能省

残差连接:

$$
y=x+F(x)
$$

让模型可以学习“在已有表示上增加修正”,也为深层网络提供更直接的梯度路径。

如果删除残差连接,每一层都必须完全重建表示,深层模型通常更难优化。

13. 组合完整 Mini-GPT

class MiniGPT(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.config = config

        self.token_embedding = nn.Embedding(
            config.vocab_size,
            config.n_embd,
        )
        self.position_embedding = nn.Embedding(
            config.block_size,
            config.n_embd,
        )
        self.dropout = nn.Dropout(config.dropout)

        self.blocks = nn.ModuleList([
            Block(config)
            for _ in range(config.n_layer)
        ])

        self.ln_f = nn.LayerNorm(config.n_embd)
        self.lm_head = nn.Linear(
            config.n_embd,
            config.vocab_size,
            bias=False,
        )

        self.apply(self._init_weights)

        self.lm_head.weight = self.token_embedding.weight

nn.ModuleList 能让 PyTorch 正确注册每个 Block 的参数。普通 Python list 虽然能保存对象,但其中模块不会自动出现在 model.parameters()state_dict() 中。

13.1 参数初始化

@staticmethod
def _init_weights(module):
    if isinstance(module, nn.Linear):
        nn.init.normal_(
            module.weight,
            mean=0.0,
            std=0.02,
        )
        if module.bias is not None:
            nn.init.zeros_(module.bias)

    elif isinstance(module, nn.Embedding):
        nn.init.normal_(
            module.weight,
            mean=0.0,
            std=0.02,
        )

这里使用均值为 0、标准差为 0.02 的正态分布初始化线性层和 Embedding。Mini-GPT 教学实现可以使用这个简单策略;更深模型还会针对残差分支进一步缩放初始化。

14. Weight Tying:输入词向量与输出分类器共享参数

输入层把 token id 映射成向量:

token_embedding.weight: [V, C]

输出层把隐藏向量映射回词表 logits:

lm_head.weight: [V, C]

两者形状相同,因此可以共享同一份参数:

self.lm_head.weight = self.token_embedding.weight

输入时:

$$
e_t=W_E[x_t]
$$

输出时:

$$
z_t=h_tW_E^T
$$

可以把它理解为:

  • 输入阶段从词表向量表中取出当前 token。
  • 输出阶段计算隐藏状态与所有词表向量的匹配程度。

共享参数的好处:

  • 减少参数量。
  • 让输入表示空间与输出分类空间保持联系。

本例若不共享,会额外增加:

$$
V\times C=123\times96=11808
$$

个参数。

15. 前向传播与语言模型头

def forward(self, idx, targets=None):
    b, t = idx.shape

    if t > self.config.block_size:
        raise ValueError(
            "sequence is longer than block_size"
        )

    pos = torch.arange(t, device=idx.device)

    token_emb = self.token_embedding(idx)
    position_emb = self.position_embedding(pos)
    x = token_emb + position_emb
    x = self.dropout(x)

    for block in self.blocks:
        x = block(x)

    x = self.ln_f(x)
    logits = self.lm_head(x)

    loss = None
    if targets is not None:
        loss = F.cross_entropy(
            logits.reshape(
                -1,
                self.config.vocab_size,
            ),
            targets.reshape(-1),
        )

    return logits, loss

完整形状变化:

idx                         [B, T]
token embedding             [B, T, C]
position embedding          [T, C]
相加并广播                   [B, T, C]
N 个 GPT Block              [B, T, C]
final LayerNorm             [B, T, C]
lm_head                     [B, T, V]

16. 交叉熵为什么就是语言模型损失

对第 $t$ 个位置,模型输出词表 logits:

$$
z_t=(z_{t,1},z_{t,2},\ldots,z_{t,V})
$$

Softmax 转为概率:

$$
p_{t,j}=\frac{e^{z_{t,j}}}{\sum_{k=1}^{V}e^{z_{t,k}}}
$$

如果正确下一个 token 是 $y_t$,该位置的负对数似然为:

$$
\mathcal{L}t=-\log p{t,y_t}
$$

整个 batch 的平均损失:

$$
\mathcal{L}
=-\frac{1}{BT}
\sum_{b=1}^{B}
\sum_{t=1}^{T}
\log P(y_{b,t}\mid x_{b,\le t})
$$

Mini-GPT语言模型损失

16.1 一个三分类手算例子

假设某位置 logits 为:

$$
z=[2.0,1.0,0.0]
$$

Softmax 分母:

$$
e^2+e^1+e^0
\approx7.389+2.718+1=11.107
$$

概率约为:

$$
p\approx[0.665,0.245,0.090]
$$

如果正确 token 是第 1 类:

$$
\mathcal{L}=-\log(0.665)\approx0.408
$$

如果正确 token 是第 3 类:

$$
\mathcal{L}=-\log(0.090)\approx2.408
$$

模型越确信错误答案,惩罚越大。

16.2 为什么不用手动 Softmax

F.cross_entropy 内部会结合 LogSoftmax 与负对数似然,直接接收 logits:

loss = F.cross_entropy(logits, targets)

错误写法:

loss = F.cross_entropy(
    F.softmax(logits, dim=-1),
    targets,
)

提前 Softmax 不仅重复计算,还会损失数值稳定性。

17. 困惑度 Perplexity 是什么

语言模型还经常报告困惑度:

$$
\operatorname{PPL}=e^{\mathcal{L}}
$$

如果平均交叉熵为:

$$
\mathcal{L}=2.0
$$

那么:

$$
\operatorname{PPL}=e^2\approx7.39
$$

可以粗略理解为,模型在每一步像是在约 7.39 个同等可能候选中犹豫。

如果模型在 $V$ 个 token 上完全均匀随机预测:

$$
p=\frac{1}{V}
$$

则:

$$
\mathcal{L}=-\log\frac{1}{V}=\log V
$$

本例 $V=123$,随机模型的理论损失约为:

$$
\log(123)\approx4.812
$$

困惑度越低通常表示对验证文本预测得越好,但不同 Tokenizer、词表和数据集上的困惑度不宜直接横向比较。

18. Mini-GPT 有多少参数

本例配置:

V = 123
T = 64
C = 96
H = 4
L = 3

18.1 Embedding

Token Embedding:

$$
123\times96=11808
$$

Position Embedding:

$$
64\times96=6144
$$

18.2 每个 Block

QKV 投影:

$$
96\times288+288=27936
$$

输出投影:

$$
96\times96+96=9312
$$

两次 LayerNorm:

$$
2\times(96+96)=384
$$

MLP 第一层:

$$
96\times384+384=37248
$$

MLP 第二层:

$$
384\times96+96=36960
$$

每个 Block 合计:

$$
27936+9312+384+37248+36960=111840
$$

三个 Block:

$$
3\times111840=335520
$$

最终 LayerNorm:

$$
96+96=192
$$

由于 lm_head 与 Token Embedding 权重绑定,总参数量是:

$$
11808+6144+335520+192=353664
$$

代码验证:

parameter_count = sum(
    p.numel() for p in model.parameters()
)
print(parameter_count)  # 353664

19. 使用 AdamW 配置参数组

并非所有参数都适合权重衰减。一个实用的教学规则是:

  • 二维及以上的权重矩阵使用 weight decay。
  • 一维 bias 和 LayerNorm 参数不使用 weight decay。
decay = [
    p for p in model.parameters()
    if p.requires_grad and p.dim() >= 2
]

no_decay = [
    p for p in model.parameters()
    if p.requires_grad and p.dim() < 2
]

optimizer = torch.optim.AdamW(
    [
        {
            "params": decay,
            "weight_decay": 0.1,
        },
        {
            "params": no_decay,
            "weight_decay": 0.0,
        },
    ],
    lr=3e-3,
    betas=(0.9, 0.95),
)

为什么 LayerNorm 参数通常不衰减?它们主要负责缩放与平移归一化结果,对这些一维参数施加同样衰减往往没有必要。

按维数分组适用于这个结构规整的 Mini-GPT。复杂生产模型更适合根据模块类型和参数名显式分组,避免误分类特殊参数。

20. Warmup 与余弦学习率衰减

训练刚开始时,参数和 AdamW 的动量统计都还不稳定。直接使用最大学习率可能让更新过猛,因此先 warmup:

$$
\eta_t=\eta_{max}\frac{t+1}{T_{warmup}}
$$

warmup 结束后使用余弦衰减:

$$
r=\frac{t-T_{warmup}}
{T_{max}-T_{warmup}}
$$

$$
\eta_t=\eta_{min}
+\frac{1}{2}
(1+\cos(\pi r))
(\eta_{max}-\eta_{min})
$$

max_steps = 1000
warmup_steps = 100
max_lr = 3e-3
min_lr = 3e-4


def get_lr(step):
    if step < warmup_steps:
        return max_lr * (step + 1) / warmup_steps

    ratio = (
        (step - warmup_steps)
        / (max_steps - warmup_steps)
    )
    coeff = 0.5 * (
        1.0 + math.cos(math.pi * ratio)
    )

    return min_lr + coeff * (max_lr - min_lr)

训练每一步更新参数组学习率:

lr = get_lr(step)

for group in optimizer.param_groups:
    group["lr"] = lr

21. 完整训练循环

device = torch.device(
    "cuda" if torch.cuda.is_available() else "cpu"
)

config = GPTConfig(vocab_size=len(chars))
model = MiniGPT(config).to(device)

model.train()

for step in range(max_steps):
    lr = get_lr(step)
    for group in optimizer.param_groups:
        group["lr"] = lr

    x, y = get_batch("train")
    _, loss = model(x, y)

    optimizer.zero_grad(set_to_none=True)
    loss.backward()

    grad_norm = torch.nn.utils.clip_grad_norm_(
        model.parameters(),
        max_norm=1.0,
    )

    optimizer.step()

    if (step + 1) % 200 == 0:
        print(
            f"step={step + 1}, "
            f"loss={loss.item():.4f}, "
            f"lr={lr:.6f}, "
            f"grad={grad_norm:.3f}"
        )

Mini-GPT训练流程

21.1 为什么先清梯度再反向传播

PyTorch 默认累加梯度。若不清理:

loss.backward()

会把当前梯度加到上一步的 .grad 中。

标准单步更新顺序是:

forward
-> loss
-> zero_grad
-> backward
-> clip_grad_norm
-> optimizer.step

set_to_none=True 让梯度变回 None,通常比逐元素填 0 更节省一些操作。

21.2 梯度裁剪做了什么

如果所有参数梯度的总范数为:

$$
|g|_2
=\sqrt{\sum_i|g_i|_2^2}
$$

当它超过阈值 $c$ 时,统一缩放:

$$
g_i\leftarrow g_i\frac{c}{|g|_2}
$$

这样保持梯度方向大致不变,只限制单次更新幅度。

22. 正确评估训练集与验证集

评估时要关闭 Dropout 和梯度:

@torch.no_grad()
def estimate_loss(eval_iters=20):
    model.eval()
    result = {}

    for split in ("train", "val"):
        losses = []

        for _ in range(eval_iters):
            x, y = get_batch(split)
            _, loss = model(x, y)
            losses.append(loss.item())

        result[split] = sum(losses) / len(losses)

    model.train()
    return result

为什么平均多个 batch?单个随机窗口的损失波动可能较大,多次采样平均更能反映趋势。

实测 1000 步结果:

step=200,  train=0.0191, val=0.0187
step=400,  train=0.0156, val=0.0157
step=600,  train=0.0124, val=0.0130
step=800,  train=0.0118, val=0.0127
step=1000, train=0.0115, val=0.0115

这个损失看起来很低,主要因为教学语料只有 14 行,并被重复了 120 次。模型很容易记住其中模式。

而且这里是在重复之后再按位置切分,训练集和验证集包含相同句式。因此验证损失主要用于检查训练流程,并不能衡量对陌生文本的真正泛化。正式实验应先准备互不重复的训练语料与验证语料,再分别编码。

因此这个结果说明:

  • 数据构造和目标错位是正确的。
  • 因果注意力能够学习局部与长距离关系。
  • 完整训练链路已经跑通。

它不说明:

  • 模型理解了开放世界知识。
  • 模型能对陌生主题稳定泛化。
  • 重复小语料是训练真实语言模型的合理方案。

23. 自回归生成的基本过程

训练完成后,给模型一个提示词:

春天

生成流程是:

春天       -> 预测“来”
春天来     -> 预测“了”
春天来了   -> 预测“,”
春天来了, -> 预测“花”
...

每次只使用最后一个位置的 logits:

logits = logits[:, -1, :]

因为前面位置对应的是“过去各位置的下一个 token 分布”,我们现在只关心当前完整前缀之后是什么。

Mini-GPT自回归生成

24. Temperature、贪心与随机采样

模型输出 logits $z$ 后,可以通过 Temperature 调整分布:

$$
p_i=\frac{\exp(z_i/\tau)}
{\sum_j\exp(z_j/\tau)}
$$

其中 $\tau$ 是 temperature。

24.1 Temperature 小于 1

temperature = 0.7

logits 被放大,概率分布更尖锐,模型更偏向高概率 token,输出通常更稳定但更保守。

24.2 Temperature 大于 1

temperature = 1.3

logits 被缩小,概率分布更平坦,低概率 token 更容易被采到,输出更多样但更容易混乱。

24.3 Temperature 不能为 0

因为代码要计算:

logits = logits / temperature

如果希望完全确定性地选最大概率 token,应直接使用:

next_token = logits.argmax(dim=-1, keepdim=True)

而不是把 temperature 设为 0。

25. Top-k 采样

Top-k 只保留概率最高的 $k$ 个候选,其余 logits 设为 $-\infty$:

k = min(top_k, logits.size(-1))
threshold = torch.topk(
    logits,
    k,
).values[:, [-1]]

logits = logits.masked_fill(
    logits < threshold,
    float("-inf"),
)

再计算概率并采样:

probs = F.softmax(logits, dim=-1)
next_token = torch.multinomial(
    probs,
    num_samples=1,
)

Top-k 的作用是避免从词表长尾中采到极低概率 token。

策略 做法 特点
Greedy 每次选最大概率 稳定、确定,但可能重复
Temperature 调整分布尖锐程度 控制保守与随机程度
Top-k 只保留前 $k$ 个 去除低概率长尾
Top-p 保留累计概率达到 $p$ 的集合 候选数量动态变化

26. 完整生成函数

@torch.no_grad()
def generate(
    self,
    idx,
    max_new_tokens,
    temperature=1.0,
    top_k=None,
):
    self.eval()

    if temperature <= 0:
        raise ValueError("temperature must be greater than 0")

    for _ in range(max_new_tokens):
        idx_cond = idx[:, -self.config.block_size:]

        logits, _ = self(idx_cond)
        logits = logits[:, -1, :] / temperature

        if top_k is not None:
            k = min(top_k, logits.size(-1))
            threshold = torch.topk(
                logits,
                k,
            ).values[:, [-1]]

            logits = logits.masked_fill(
                logits < threshold,
                float("-inf"),
            )

        probs = F.softmax(logits, dim=-1)
        next_token = torch.multinomial(
            probs,
            num_samples=1,
        )

        idx = torch.cat((idx, next_token), dim=1)

    return idx

调用:

prompt = "春天"
context = torch.tensor(
    [encode(prompt)],
    dtype=torch.long,
    device=device,
)

result = model.generate(
    context,
    max_new_tokens=100,
    temperature=0.8,
    top_k=8,
)

print(decode(result[0].tolist()))

一次实测输出为:

春天来了,花儿在风中开放。
夏天来了,蝉儿在树上歌唱。
秋天来了,果实在枝头成熟。
冬天来了,雪花在天空飞舞。
清晨,太阳从东方升起。
傍晚,月亮在云间出现。
小河穿过安静的村庄。
微风吹过绿色的田野。

它基本复现了训练语料。这再次说明模型学会并记忆了语料模式,不应把它误解为开放式创作能力。

本例词表没有单独定义 <EOS>,因此使用 max_new_tokens 强制结束生成。需要让模型自主停止时,应把 <EOS> 加入词表和训练文本,并在采到它时退出循环。

26.1 为什么每次截取最后 block_size 个 token

位置 Embedding 只定义了:

0, 1, ..., block_size - 1

如果输入无限增长,既会超过位置表范围,也会让注意力矩阵持续增大。

idx_cond = idx[:, -self.config.block_size:]

表示模型始终只看最近的上下文窗口。更早的 token 会被遗忘。

27. 训练与生成为什么一个并行、一个串行

训练时完整目标已知:

输入:春 天 来 了
目标:天 来 了 ,

利用 Causal Mask,可以一次并行计算所有位置。

生成时未来 token 不存在:

先生成 token t
才能把它接回输入
再生成 token t+1

所以自回归生成在 token 维度上天然串行。

这不意味着生成内部没有并行:

  • batch 中多个请求可以并行。
  • 每层的大矩阵乘法可以并行。
  • 多个注意力头可以并行。

但同一条序列的第 $t+1$ 个 token 必须等待第 $t$ 个 token 产生。

28. KV Cache:为什么生成不必重复计算全部历史

当前教学版 generate 每一步都把最近的完整上下文重新传入模型。

假设已经有:

x1 x2 x3 x4

生成 x5 时计算了前四个位置的 K、V。接下来生成 x6 时,x1x4 的 K、V 没有变化,却又被计算一遍。

KV Cache 会保存每层历史 token 的 Key 和 Value:

第 1 层:K_cache¹、V_cache¹
第 2 层:K_cache²、V_cache²
...
第 L 层:K_cacheᴸ、V_cacheᴸ

新 token 到来时,只计算它自己的:

$$
q_t, k_t, v_t
$$

然后追加缓存:

$$
K_{1:t}=\operatorname{Concat}(K_{1:t-1},k_t)
$$

$$
V_{1:t}=\operatorname{Concat}(V_{1:t-1},v_t)
$$

当前 Query 与全部缓存 Key 计算:

$$
\operatorname{Attention}
(q_t,K_{1:t},V_{1:t})
$$

KV Cache生成流程

28.1 KV Cache 改变了什么

没有缓存:

每一步重新计算所有历史 token 的 Q、K、V 和 Block 输出

有缓存:

每一步主要计算新 token,并复用历史 K、V

KV Cache 显著减少重复计算,但会占用额外显存。缓存规模与以下量近似成正比:

$$
2\times L\times B\times H\times T\times D
$$

其中前面的 2 代表 Key 和 Value。

如果缓存元素使用 $s$ 字节,显存近似为:

$$
M_{KV}
\approx2LBHTDs
$$

因为 $H\times D=C$,也可以写成:

$$
M_{KV}\approx2LBTCs
$$

上下文越长、层数越多、batch 越大,KV Cache 越占显存。

28.2 为什么通常不缓存 Query

历史 Query 只在对应历史位置产生输出时使用。生成当前 token 时,只需要当前 Query 去匹配所有历史 Key,因此长期保存历史 Query 通常没有必要。

29. 用 PyTorch 高效 Attention 替换手写矩阵

为了教学,我们显式创建了 [B,H,T,T] 分数矩阵。理解原理后,可以使用:

from torch.nn import functional as F

y = F.scaled_dot_product_attention(
    q,
    k,
    v,
    attn_mask=None,
    dropout_p=(
        self.attn_dropout.p
        if self.training
        else 0.0
    ),
    is_causal=True,
)

它仍然计算同一个核心公式,但 PyTorch 会根据设备与输入选择更合适的实现。

需要注意两个细节:

  1. 使用 is_causal=True 时,不要再同时传普通 attn_mask
  2. 函数式接口不会自动根据 model.eval() 关闭 Dropout,所以推理时要显式传 dropout_p=0.0

Mask 的布尔语义也要看具体 API:本篇手写代码中 1/True 表示允许查看;上一篇 nn.Transformer 的布尔屏蔽 Mask 中 True 常表示禁止查看。不要凭记忆混用,应检查所调用函数的文档。

30. 如何验证因果 Mask 真的有效

只看训练损失下降还不够。可以构造一个针对未来信息泄漏的测试。

先取一段前缀:

prefix = x[:, :20]

只修改第 15 个位置之后的 token:

changed = prefix.clone()
changed[:, 15:] = torch.randint(
    0,
    config.vocab_size,
    changed[:, 15:].shape,
)

比较前 15 个位置的 logits:

model.eval()

base_logits, _ = model(prefix)
changed_logits, _ = model(changed)

max_diff = (
    base_logits[:, :15]
    - changed_logits[:, :15]
).abs().max()

print(max_diff)

实测结果:

tensor(0.)

这说明修改未来 token 不会影响过去位置的输出。

注意测试时必须调用 model.eval()。如果 Dropout 仍然开启,两次前向传播即使输入相同也可能有随机差异。

31. 常见错误

31.1 输入和目标没有错开一位

错误:

x = t0 t1 t2 t3
y = t0 t1 t2 t3

模型只需复制当前 token,不是在预测下一个 token。

正确:

x = t0 t1 t2 t3
y = t1 t2 t3 t4

31.2 没有 Causal Mask

模型训练时能直接看到未来答案,损失会异常漂亮,但生成时完全失效。

31.3 Softmax 维度写错

weights = F.softmax(scores, dim=-1)

最后一维是 Key 位置。每个 Query 对所有 Key 的权重之和应为 1。

31.4 缩放用了 n_embd

错误:

scores /= math.sqrt(config.n_embd)

正确:

scores /= math.sqrt(self.head_dim)

点积发生在每个头的 $D=head_dim$ 维空间中。

31.5 合并头时忘记转置

[B,H,T,D] 不能直接当成 [B,T,C]。必须先把 T 移回第二维:

y = y.transpose(1, 2).contiguous()
y = y.view(b, t, c)

31.6 使用普通 list 保存 Block

错误:

self.blocks = [Block(config) for _ in range(n_layer)]

正确:

self.blocks = nn.ModuleList([
    Block(config) for _ in range(n_layer)
])

31.7 block_size 与输入长度不匹配

输入长度超过位置 Embedding 范围会报错。训练数据和生成函数都必须遵守最大上下文长度。

31.8 训练时忘记 model.train()

Dropout 会保持关闭,训练行为与预期不一致。

31.9 生成时忘记 model.eval()

Dropout 会使同一前缀的 logits 额外波动,也浪费不必要的随机计算。

31.10 Temperature 设为 0

会发生除零。需要确定性生成时使用 argmax

31.11 Top-k 大于词表大小

应先限制:

k = min(top_k, logits.size(-1))

31.12 把小语料低损失当成智能

重复数据极易记忆。要观察泛化,应使用独立且不重复的验证文本,并检查模型对未见组合的预测。

32. 完整模型代码如何组织

完整可运行脚本:/code/nlp-blog/mini_gpt.py

推荐按下面的依赖顺序放在一个脚本中:

1. import 与随机种子
2. 准备语料
3. encode / decode
4. train / val 切分
5. GPTConfig
6. get_batch
7. CausalSelfAttention
8. MLP
9. Block
10. MiniGPT
11. optimizer 与 get_lr
12. estimate_loss
13. 训练循环
14. generate
15. 保存模型

保存 checkpoint:

from dataclasses import asdict

torch.save(
    {
        "model": model.state_dict(),
        "optimizer": optimizer.state_dict(),
        "config": asdict(config),
        "stoi": stoi,
        "itos": itos,
    },
    "mini_gpt.pt",
)

恢复:

checkpoint = torch.load(
    "mini_gpt.pt",
    map_location=device,
    weights_only=True,
)

config = GPTConfig(**checkpoint["config"])
model = MiniGPT(config).to(device)
model.load_state_dict(checkpoint["model"])

decay = [
    p for p in model.parameters()
    if p.requires_grad and p.dim() >= 2
]
no_decay = [
    p for p in model.parameters()
    if p.requires_grad and p.dim() < 2
]
optimizer = torch.optim.AdamW([
    {"params": decay, "weight_decay": 0.1},
    {"params": no_decay, "weight_decay": 0.0},
], lr=3e-3, betas=(0.9, 0.95))
optimizer.load_state_dict(checkpoint["optimizer"])
stoi = checkpoint["stoi"]
itos = checkpoint["itos"]

Tokenizer 词表必须与训练时一致,否则相同 token id 会被解释成不同字符。

33. Mini-GPT 与真实大模型还差什么

Mini-GPT 已经包含 GPT 的核心计算骨架,但与真实系统仍有巨大差距。

方面 本文 Mini-GPT 真实语言模型
Tokenizer 字符级 BPE、字节或子词 Tokenizer
参数量 35 万 数亿到更大规模
数据 少量重复句子 大规模清洗语料
上下文 64 token 通常远长于 64
位置编码 可学习绝对位置 也可能使用相对位置、RoPE 等
Attention 标准 MHA 还可能使用 MQA、GQA 等
训练 单机 CPU 教学 大规模分布式训练
精度 FP32 BF16、FP16 或混合精度
推理 重算上下文 KV Cache、批处理与高效内核
能力塑造 next-token 训练 还可能经过指令微调与偏好对齐

规模扩大不会改变最核心的目标:

$$
P(x_t\mid x_{<t})
$$

但数据、计算、训练稳定性和推理工程会变得复杂得多。

34. 总结

Mini-GPT 的完整数据流是:

原始文本
  -> Tokenizer
  -> token id
  -> 随机上下文窗口 x 与右移目标 y
  -> Token Embedding + Position Embedding
  -> 多层 Pre-Norm GPT Block
       -> Causal Multi-Head Self-Attention
       -> Residual Connection
       -> MLP + GELU
       -> Residual Connection
  -> Final LayerNorm
  -> LM Head
  -> 每个位置的词表 logits
  -> Cross Entropy
  -> Backpropagation + AdamW

生成流程是:

提示 token
  -> 取最后 block_size 个 token
  -> 模型预测最后位置的 logits
  -> Temperature / Top-k
  -> 采样下一个 token
  -> 拼接回输入
  -> 重复

需要真正掌握的核心包括:

  • GPT 是不含 Cross-Attention 的 Decoder-only 自回归 Transformer。
  • 语言模型通过右移一位的输入和目标学习 next-token prediction。
  • Causal Mask 让并行训练不会泄漏未来信息。
  • QKV 投影后要正确拆头、计算注意力并合并头。
  • GPT Block 由 Causal Attention、MLP、LayerNorm 和残差连接组成。
  • LM Head 把每个隐藏向量投影成词表 logits。
  • 交叉熵等价于最小化正确 token 的负对数似然。
  • Temperature 和 Top-k 只改变生成策略,不改变模型参数。
  • KV Cache 复用历史 Key 和 Value,以显存换取生成速度。
  • 小数据上的低损失很可能来自记忆,不能直接等同于泛化能力。

当你能解释下面三条公式之间的关系时,就已经抓住了 GPT 的数学主线:

$$
P(x_1,\ldots,x_T)
=\prod_{t=1}^{T}P(x_t\mid x_{<t})
$$

$$
\operatorname{CausalAttention}(Q,K,V)
=\operatorname{softmax}\left(
\frac{QK^T}{\sqrt{d_k}}+M
\right)V
$$

$$
\mathcal{L}
=-\frac{1}{BT}
\sum_{b,t}
\log P(y_{b,t}\mid x_{b,\le t})
$$

它们分别回答:

  1. GPT 要建模什么概率。
  2. GPT 怎样从历史 token 中提取上下文。
  3. GPT 怎样通过训练让正确下一个 token 的概率变大。

35. 手动模拟完整流程:从 Prompt“春天”推测下一个 Token

前面已经把 Tokenizer、Embedding、Causal Attention、MLP、LM Head 和采样分别讲过。最后使用一组可以手算的参数,把一次完整推理串起来:

输入 Prompt“春天”
  -> 编码成 token id
  -> Token Embedding + Position Embedding
  -> Pre-Norm Causal Self-Attention
  -> 第一次残差连接
  -> Pre-Norm MLP
  -> 第二次残差连接
  -> Final LayerNorm
  -> LM Head 得到 logits
  -> Softmax 得到下一个 token 概率
  -> 选择“来”
  -> 新 Prompt 变成“春天来”

从Prompt手算下一个Token

35.1 为什么需要缩小模型

正文中的真实 Mini-GPT 使用:

词表大小 V = 123
隐藏维度 C = 96
注意力头数 H = 4
Block 数量 L = 3
MLP 中间维度 = 384

真实模型一次前向传播会涉及数十万次乘加,无法在纸面逐项展开。因此手算示例保留完全相同的数据流,只缩小矩阵:

项目 正文模型 手算模型
词表大小 123 4
隐藏维度 96 2
注意力头数 4 1
Block 数量 3 1
MLP 中间维度 384 8,即 $4C$
Dropout 0.1 推理时关闭

这是一组为了展示计算过程而设计的“已训练参数快照”,不是正文 35 万参数模型训练后真实权重的截取。

35.2 第一步:Tokenizer 把 Prompt 转成 ID

假设手算词表为:

token token id
0
1
2
3

输入 Prompt:

春天

经过 encode

$$
idx=[0,1]
$$

加入 batch 维后,模型真正接收:

$$
idx=
\begin{bmatrix}
0&1
\end{bmatrix}
$$

形状为:

[B, T] = [1, 2]

因为 $T=2$ 没有超过 block_size,无需截断 Prompt。

35.3 第二步:查询 Token Embedding

假设训练后的 Token Embedding 表为:

$$
E_{token}=
\begin{bmatrix}
1&0\
0&1\
-1&1\
1&-1
\end{bmatrix}
$$

每一行分别对应“春、天、来、了”。

查表得到:

$$
E_{token}([0,1])=
\begin{bmatrix}
1&0\
0&1
\end{bmatrix}
$$

此时形状为 [1,2,2];为了让矩阵更容易阅读,后面的公式省略 batch 维。

35.4 第三步:加入 Position Embedding

假设前两个位置的可学习位置向量为:

$$
E_{position}(0)=[0.1,0]
$$

$$
E_{position}(1)=[0,0.1]
$$

Token Embedding 与 Position Embedding 逐元素相加:

$$
X=
\begin{bmatrix}
1&0\
0&1
\end{bmatrix}
+
\begin{bmatrix}
0.1&0\
0&0.1
\end{bmatrix}
=
\begin{bmatrix}
1.1&0\
0&1.1
\end{bmatrix}
$$

第一行表示位置 0 的“春”,第二行表示位置 1 的“天”。

35.5 第四步:Attention 前的 LayerNorm

为了简化,假设 LayerNorm 的可学习参数为:

$$
\gamma=[1,1],\qquad\beta=[0,0]
$$

并在手算中忽略很小的 $\epsilon$。

对“春”的向量 $[1.1,0]$:

$$
\mu=\frac{1.1+0}{2}=0.55
$$

$$
\sigma
=\sqrt{\frac{(1.1-0.55)^2+(0-0.55)^2}{2}}
=0.55
$$

所以:

$$
\operatorname{LN}([1.1,0])=[1,-1]
$$

同理:

$$
\operatorname{LN}([0,1.1])=[-1,1]
$$

得到:

$$
X_{norm}=
\begin{bmatrix}
1&-1\
-1&1
\end{bmatrix}
$$

35.6 第五步:线性投影得到 Q、K、V

真实模型会学习不同的 $W_Q$、$W_K$、$W_V$。为了手算,令:

$$
W_Q=W_K=W_V=I, b_Q=b_K=b_V=0
$$

于是:

$$
Q=K=V=X_{norm}
=
\begin{bmatrix}
1&-1\
-1&1
\end{bmatrix}
$$

因为只有一个注意力头:

H = 1
D = C / H = 2

所以拆头只增加 head 维,不改变每个向量中的两个数。

35.7 第六步:计算缩放点积分数

先计算:

$$
QK^T=
\begin{bmatrix}
1&-1\
-1&1
\end{bmatrix}
\begin{bmatrix}
1&-1\
-1&1
\end{bmatrix}
=
\begin{bmatrix}
2&-2\
-2&2
\end{bmatrix}
$$

除以 $\sqrt{D}=\sqrt{2}$:

$$
S=\frac{QK^T}{\sqrt{2}}
\approx
\begin{bmatrix}
1.414&-1.414\
-1.414&1.414
\end{bmatrix}
$$

矩阵第 2 行表示 token“天”对“春”和“天”的匹配分数。

35.8 第七步:应用 Causal Mask

长度为 2 的加法型因果 Mask 是:

$$
M=
\begin{bmatrix}
0&-\infty\
0&0
\end{bmatrix}
$$

加到分数矩阵:

$$
S+M=
\begin{bmatrix}
1.414&-\infty\
-1.414&1.414
\end{bmatrix}
$$

它表达:

  • “春”只能看自己,不能看未来的“天”。
  • “天”可以同时看“春”和自己。

当前要预测 Prompt 后面的 token,因此只关心最后一行。

35.9 第八步:Softmax 得到注意力权重

第一行只有一个可见位置:

$$
A_1=[1,0]
$$

第二行:

$$
A_2
=\operatorname{softmax}([-1.414,1.414])
$$

$$
A_2\approx[0.0558,0.9442]
$$

这表示计算“天”的新表示时:

  • 从“春”的 Value 取约 5.58%。
  • 从“天”的 Value 取约 94.42%。

完整权重矩阵:

$$
A\approx
\begin{bmatrix}
1&0\
0.0558&0.9442
\end{bmatrix}
$$

每一行之和都是 1。

35.10 第九步:对 Value 加权求和

最后一个位置的 Attention 输出:

$$
o_2
=0.0558[1,-1]
+0.9442[-1,1]
$$

$$
o_2\approx[-0.8884,0.8884]
$$

假设注意力输出投影 $W_O=I$、bias 为 0,输出保持不变。

35.11 第十步:第一次残差连接

Pre-Norm Block 的残差连接使用进入 LayerNorm 前的原始 $X$:

$$
u_2=x_2+o_2
$$

$$
u_2
=[0,1.1]+[-0.8884,0.8884]
$$

$$
u_2\approx[-0.8884,1.9884]
$$

这一点很重要:残差不是与 $\operatorname{LN}(x_2)$ 相加,而是与原始 $x_2$ 相加。

35.12 第十一步:第二次 LayerNorm 和 MLP

对 $u_2$ 做第二次 LayerNorm。二维向量归一化后近似为:

$$
\operatorname{LN}(u_2)\approx[-1,1]
$$

真实 Mini-GPT 的 MLP 会执行:

2 -> 8 -> GELU -> 2

假设第一层只把两个输入复制到前两个中间神经元,其余 6 个输出为 0:

$$
W_1
\operatorname{LN}(u_2)
=
[-1,1,0,0,0,0,0,0]
$$

GELU 的近似值:

$$
\operatorname{GELU}(-1)\approx-0.1587
$$

$$
\operatorname{GELU}(1)\approx0.8413
$$

再假设 $W_2$ 只把前两个中间值映射回两个输出维度,则:

$$
\operatorname{MLP}(\operatorname{LN}(u_2))
\approx[-0.1587,0.8413]
$$

第二次残差连接:

$$
v_2
=u_2+\operatorname{MLP}(\operatorname{LN}(u_2))
$$

$$
v_2
\approx[-0.8884,1.9884]
+[-0.1587,0.8413]
$$

$$
v_2\approx[-1.0471,2.8297]
$$

这就是唯一一个 GPT Block 在最后位置的输出。

35.13 第十二步:Final LayerNorm

所有 Block 之后还要进行最终 LayerNorm:

$$
h_2=\operatorname{LN}(v_2)
$$

忽略 $\epsilon$ 并取三位小数:

$$
h_2\approx[-1,1]
$$

推测下一个 token 时只需要最后一个位置的 $h_2$,前面位置的输出不用送入采样器。

35.14 第十三步:LM Head 计算词表 Logits

假设使用 Weight Tying,因此 LM Head 与 Token Embedding 共享矩阵:

$$
W_{LM}=E_{token}
$$

logits 为:

$$
z=h_2E_{token}^T
$$

逐个 token 计算:

$$
z_{春}=[-1,1]\cdot[1,0]=-1
$$

$$
z_{天}=[-1,1]\cdot[0,1]=1
$$

$$
z_{来}=[-1,1]\cdot[-1,1]=2
$$

$$
z_{了}=[-1,1]\cdot[1,-1]=-2
$$

所以:

$$
z=[-1,1,2,-2]
$$

logit 只是未归一化分数,还不是概率。

35.15 第十四步:Temperature 和 Softmax

先使用:

temperature = 1.0
top_k = None

所以 logits 不变。

Softmax 分母:

$$
e^{-1}+e^1+e^2+e^{-2}
\approx0.3679+2.7183+7.3891+0.1353
\approx10.6106
$$

各 token 概率:

$$
P(春\mid春天)\approx0.0347
$$

$$
P(天\mid春天)\approx0.2562
$$

$$
P(来\mid春天)\approx0.6964
$$

$$
P(了\mid春天)\approx0.0128
$$

整理成表格:

候选 token logit 概率
-1 3.47%
1 25.62%
2 69.64%
-2 1.28%

35.16 第十五步:选择下一个 Token

如果使用 Greedy Search:

$$
next_id=\arg\max(z)=2
$$

itos

2 -> “来”

因此模型对 Prompt 的一次续写是:

输入 Prompt:春天
预测 Token:来
新 Prompt:  春天来

如果使用 top_k=2 的随机采样,只保留“天”和“来”,重新归一化后大约为:

P(天) = 26.89%
P(来) = 73.11%

这时“来”最可能被采到,但并非百分之百确定。

35.17 怎样继续生成第二个新 Token

生成函数把刚得到的 token id 2 拼回输入:

$$
[0,1]+[2]=[0,1,2]
$$

也就是:

春天 -> 春天来

下一轮重新执行:

[春, 天, 来]
  -> Embedding + Position Embedding
  -> GPT Block
  -> 取最后位置 logits
  -> 预测“了”或其他 token

循环直到:

  • 生成了 max_new_tokens 个 token。
  • 或者模型生成 <EOS>,如果词表和训练数据定义了它。

当前教学版没有 KV Cache,所以每一轮都会重新计算整个可见 Prompt;加入 KV Cache 后,可以复用“春”“天”等历史 token 的 Key 和 Value。

35.18 如果这是训练过程,还会发生什么

推理阶段在选出或采样下一个 token 后就结束,不计算损失,也不执行反向传播。

如果这是一个训练样本,并且正确目标确实是“来”,交叉熵为:

$$
\mathcal{L}
=-\log P(来\mid春天)
$$

$$
\mathcal{L}
=-\log(0.6964)
\approx0.3618
$$

交叉熵对四个 logits 的梯度为:

$$
\frac{\partial\mathcal{L}}{\partial z_j}
=p_j-\mathbb{1}[j=y]
$$

因此:

$$
\frac{\partial\mathcal{L}}{\partial z}
\approx
[0.0347,0.2562,-0.3036,0.0128]
$$

含义是:

  • “春、天、了”的 logit 梯度为正,梯度下降会倾向于降低它们。
  • 正确答案“来”的 logit 梯度为负,梯度下降会倾向于提高它。

梯度会继续经过 LM Head、Final LayerNorm、MLP、Attention 和 Embedding 反向传播,最后由 AdamW 更新所有相关参数。

35.19 用 PyTorch 复核手算结果

下面的代码只用于验证本节数字:

import math
import torch
import torch.nn.functional as F

q = torch.tensor([
    [1.0, -1.0],
    [-1.0, 1.0],
])

scores = q @ q.T / math.sqrt(2)
scores[0, 1] = float("-inf")
weights = F.softmax(scores, dim=-1)
attention = weights @ q

x = torch.tensor([
    [1.1, 0.0],
    [0.0, 1.1],
])

u = x[1] + attention[1]
ln_2 = (u - u.mean()) / u.std(unbiased=False)
mlp = F.gelu(ln_2)
v = u + mlp
h = (v - v.mean()) / v.std(unbiased=False)

embedding = torch.tensor([
    [1.0, 0.0],   # 春
    [0.0, 1.0],   # 天
    [-1.0, 1.0],  # 来
    [1.0, -1.0],  # 了
])

logits = h @ embedding.T
probs = F.softmax(logits, dim=-1)

print(weights[1])
# tensor([0.0558, 0.9442])

print(logits)
# tensor([-1., 1., 2., -2.])

print(probs)
# tensor([0.0347, 0.2562, 0.6964, 0.0128])

print(torch.argmax(probs))
# tensor(2),即“来”

35.20 把整个推理流程压缩成一张表

步骤 输入 输出
Tokenizer 春天 [0,1]
Token Embedding [0,1] [[1,0],[0,1]]
加位置向量 Token + Position [[1.1,0],[0,1.1]]
Pre-LayerNorm $X$ [[1,-1],[-1,1]]
QKV 投影 $X_{norm}$ $Q=K=V=X_{norm}$
Causal Attention $Q,K,V,M$ 最后位置 [-0.8884,0.8884]
第一次残差 $x_2+o_2$ [-0.8884,1.9884]
LN + MLP $u_2$ [-0.1587,0.8413]
第二次残差 $u_2+MLP$ [-1.0471,2.8297]
Final LayerNorm $v_2$ [-1,1]
LM Head $h_2E^T$ [-1,1,2,-2]
Softmax logits [3.47%,25.62%,69.64%,1.28%]
Greedy 选择 最大概率 id 2
拼回上下文 春天 + 春天来

这个例子把“根据 Prompt 推测下一个 token”从一句抽象描述展开成了具体数字。真实 Mini-GPT 只是把相同过程扩展到更大词表、更高维向量、更多注意力头和更多 Block。

参考资料

  1. Alec Radford et al., Improving Language Understanding by Generative Pre-Training: https://cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf
  2. Alec Radford et al., Language Models are Unsupervised Multitask Learners: https://cdn.openai.com/better-language-models/language-models.pdf
  3. Ashish Vaswani et al., Attention Is All You Need: https://arxiv.org/abs/1706.03762
  4. PyTorch Documentation, scaled_dot_product_attention: https://docs.pytorch.org/docs/stable/generated/torch.nn.functional.scaled_dot_product_attention.html
  5. PyTorch Documentation, CrossEntropyLoss: https://docs.pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html
  6. PyTorch Documentation, torch.multinomial: https://docs.pytorch.org/docs/stable/generated/torch.multinomial.html