第 4 章 LLMGPTTransformer

第 4 章 从零实现一个 GPT 模型来生成文本

第 4 章 从零实现一个 GPT 模型来生成文本

本章来源:本文翻译整理自 LLMs-from-scratch 仓库的 ch04/01_main-chapter-code/ch04.ipynb,原书为 Sebastian Raschka《Build a Large Language Model (From Scratch)》。

本章要做什么

在本章,我们实现一个 GPT 类 LLM 架构;下一章将聚焦训练这个 LLM。具体包括:编写 LLM 架构、用层归一化归一化激活、用 GELU 激活实现前馈网络、添加捷径连接、在 transformer 块里连接注意力和线性层、编写 GPT 模型、生成文本。

本 notebook 使用的包

from importlib.metadata import version

print("matplotlib version:", version("matplotlib"))
print("torch version:", version("torch"))
print("tiktoken version:", version("tiktoken"))

4.1 编写一个 LLM 架构

第 1 章讨论了 GPT 和 Llama 这类模型,它们顺序生成词,基于原始 transformer 架构的解码器部分。因此,这些 LLM 常被称为"解码器类"(decoder-like)LLM。

和传统深度学习模型相比,LLM 更大,主要是因为它们海量的参数,而不是代码量。我们会看到,LLM 架构里很多元素是重复的。

在之前的章节里,我们为 token 输入和输出使用了很小的嵌入维度,便于说明,确保它们能排进一页。在本章,我们考虑与一个小型 GPT-2 模型相近的嵌入和模型尺寸。我们会具体编写最小的 GPT-2 模型(1.24 亿参数)的架构,如 Radford 等人的 Language Models are Unsupervised Multitask Learners 所述(注意,最初的报告把它列为 117M 参数,但后来在模型权重仓库里被修正了)。第 6 章会展示如何把预训练权重加载进我们的实现,它兼容 3.45 亿、7.62 亿和 15.42 亿参数的模型尺寸。

1.24 亿参数 GPT-2 模型的配置细节包括:

GPT_CONFIG_124M = {
    "vocab_size": 50257,    # Vocabulary size
    "context_length": 1024, # Context length
    "emb_dim": 768,         # Embedding dimension
    "n_heads": 12,          # Number of attention heads
    "n_layers": 12,         # Number of layers
    "drop_rate": 0.1,       # Dropout rate
    "qkv_bias": False       # Query-Key-Value bias
}

我们用短变量名来避免后面出现很长的代码行。

  • "vocab_size" 表示 50,257 词的词汇表大小,由第 2 章讨论的 BPE 分词器支持。
  • "context_length" 表示模型的最大输入 token 数,由第 2 章覆盖的位置嵌入启用。
  • "emb_dim" 是 token 输入的嵌入大小,把每个输入 token 转换成一个 768 维向量。
  • "n_heads" 是第 3 章实现的多头注意力机制里的注意力头数。
  • "n_layers" 是模型里 transformer 块的数量,我们在接下来的小节里实现。
  • "drop_rate" 是 dropout 机制的强度,第 3 章讨论过;0.1 表示训练期间丢弃 10% 的隐藏单元以缓解过拟合。
  • "qkv_bias" 决定多头注意力机制(来自第 3 章)里的 Linear 层在计算查询(Q)、键(K)和值(V)张量时是否包含偏置向量;我们禁用这个选项,这是现代 LLM 的标准做法;不过,在第 5 章把 OpenAI 的预训练 GPT-2 权重加载进我们的重新实现时,我们会重新讨论它。

先写一个"占位"版本的 GPT 模型(transformer 块和 LayerNorm 用空实现占位),验证整体架构的形状:

import torch
import torch.nn as nn


class DummyGPTModel(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"])
        self.pos_emb = nn.Embedding(cfg["context_length"], cfg["emb_dim"])
        self.drop_emb = nn.Dropout(cfg["drop_rate"])
        
        # Use a placeholder for TransformerBlock
        self.trf_blocks = nn.Sequential(
            *[DummyTransformerBlock(cfg) for _ in range(cfg["n_layers"])])
        
        # Use a placeholder for LayerNorm
        self.final_norm = DummyLayerNorm(cfg["emb_dim"])
        self.out_head = nn.Linear(
            cfg["emb_dim"], cfg["vocab_size"], bias=False
        )

    def forward(self, in_idx):
        batch_size, seq_len = in_idx.shape
        tok_embeds = self.tok_emb(in_idx)
        pos_embeds = self.pos_emb(torch.arange(seq_len, device=in_idx.device))
        x = tok_embeds + pos_embeds
        x = self.drop_emb(x)
        x = self.trf_blocks(x)
        x = self.final_norm(x)
        logits = self.out_head(x)
        return logits


class DummyTransformerBlock(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        # A simple placeholder

    def forward(self, x):
        # This block does nothing and just returns its input.
        return x


class DummyLayerNorm(nn.Module):
    def __init__(self, normalized_shape, eps=1e-5):
        super().__init__()
        # The parameters here are just to mimic the LayerNorm interface.

    def forward(self, x):
        # This layer does nothing and just returns its input.
        return x

用一个小批次测试占位模型:

import tiktoken

tokenizer = tiktoken.get_encoding("gpt2")

batch = []

txt1 = "Every effort moves you"
txt2 = "Every day holds a"

batch.append(torch.tensor(tokenizer.encode(txt1)))
batch.append(torch.tensor(tokenizer.encode(txt2)))
batch = torch.stack(batch, dim=0)
print(batch)
torch.manual_seed(123)
model = DummyGPTModel(GPT_CONFIG_124M)

logits = model(batch)
print("Output shape:", logits.shape)
print(logits)

注意

  • 如果你在 Windows 或 Linux 上运行这段代码,上面得到的值可能看起来像下面这样:
Output shape: torch.Size([2, 4, 50257])
tensor([[[-0.9289,  0.2748, -0.7557,  ..., -1.6070,  0.2702, -0.5888],
         ...
  • 因为这些只是随机数,所以这不是需要担心的问题,你可以不受影响地继续本章剩余部分。
  • 这种差异的一个可能原因是 nn.Dropout 在不同操作系统上、取决于 PyTorch 的编译方式而有不同行为,如这里 PyTorch issue tracker 所讨论的。

4.2 用层归一化归一化激活

层归一化(layer normalization),也叫 LayerNorm(Ba et al. 2016),把神经网络层的激活围绕均值 0 居中,并把它们的方差归一化到 1。这稳定了训练,并支持更快收敛到有效的权重。

层归一化在 transformer 块里的多头注意力模块之前和之后都应用(我们稍后实现),也应用在最终输出层之前。

让我们通过把一个小输入样本传给一个简单的神经网络层,看看层归一化怎么工作:

torch.manual_seed(123)

# create 2 training examples with 5 dimensions (features) each
batch_example = torch.randn(2, 5) 

layer = nn.Sequential(nn.Linear(5, 6), nn.ReLU())
out = layer(batch_example)
print(out)

让我们计算上面 2 个输入各自的均值和方差:

mean = out.mean(dim=-1, keepdim=True)
var = out.var(dim=-1, keepdim=True)

print("Mean:\n", mean)
print("Variance:\n", var)

归一化独立地应用到两个输入(行)中的每一个;用 dim=-1 把计算应用到最后那个维度(在这个例子里是特征维度),而不是行维度。

减去均值并除以方差的平方根(标准差),把输入在列(特征)维度上居中到均值 0、方差 1:

out_norm = (out - mean) / torch.sqrt(var)
print("Normalized layer outputs:\n", out_norm)

mean = out_norm.mean(dim=-1, keepdim=True)
var = out_norm.var(dim=-1, keepdim=True)
print("Mean:\n", mean)
print("Variance:\n", var)

每个输入被居中到 0,并有 1 的单位方差;为了提高可读性,我们可以禁用 PyTorch 的科学计数法:

torch.set_printoptions(sci_mode=False)
print("Mean:\n", mean)
print("Variance:\n", var)

上面,我们归一化了每个输入的特征。现在,用同样的想法,我们可以实现一个 LayerNorm 类:

class LayerNorm(nn.Module):
    def __init__(self, emb_dim):
        super().__init__()
        self.eps = 1e-5
        self.scale = nn.Parameter(torch.ones(emb_dim))
        self.shift = nn.Parameter(torch.zeros(emb_dim))

    def forward(self, x):
        mean = x.mean(dim=-1, keepdim=True)
        var = x.var(dim=-1, keepdim=True, unbiased=False)
        norm_x = (x - mean) / torch.sqrt(var + self.eps)
        return self.scale * norm_x + self.shift

缩放和偏移(Scale and shift)

  • 注意,除了执行"减去均值、除以方差"的归一化,我们还加了两个可训练参数:scaleshift
  • 初始的 scale(乘以 1)和 shift(加上 0)值没有任何效果;然而,scaleshift 是可训练参数,如果 LLM 判定这样做能提高它在训练任务上的表现,它会在训练期间自动调整它们。
  • 这允许模型学习最适合它处理的数据的缩放和偏移。
  • 注意,我们在计算方差的平方根之前还加了一个小值(eps);这是为了避免方差为 0 时除零错误。

有偏方差(Biased variance)

  • 在上面的方差计算里,设置 unbiased=False 意味着用公式 $\frac{\sum_i (x_i - \bar{x})^2}{n}$ 计算方差,其中 n 是样本大小(这里是特征或列的数量);这个公式不包括贝塞尔校正(分母用 n-1),因此对方差提供有偏估计。
  • 对 LLM 来说,嵌入维度 n 非常大,用 n 和 n-1 的差异可以忽略不计。
  • 然而,GPT-2 训练时在归一化层里用有偏方差,这也是为什么我们也采用这个设置,以便和后面章节要加载的预训练权重兼容。

现在让我们在实践中试一下 LayerNorm

ln = LayerNorm(emb_dim=6)
out_ln = ln(out)
mean = out_ln.mean(dim=-1, keepdim=True)
var = out_ln.var(dim=-1, unbiased=False, keepdim=True)

print("Mean:\n", mean)
print("Variance:\n", var)

方差不完全等于 1,因为我们用了 eps

4.3 用 GELU 激活实现一个前馈网络

在本节,我们实现一个小型神经网络子模块,它在 LLM 里作为 transformer 块的一部分使用。我们从激活函数开始。

在深度学习中,ReLU(修正线性单元)激活函数因为它在各种神经网络架构里的简单性和有效性而常被使用。在 LLM 里,除了传统的 ReLU,还使用各种其它类型的激活函数;两个显著的例子是 GELU(高斯误差线性单元)和 SwiGLU(Swish 门控线性单元)。

GELU 和 SwiGLU 是更复杂、平滑的激活函数,分别融入高斯和 sigmoid 门控线性单元,为深度学习模型提供更好的表现,而不像 ReLU 那样是更简单的分段线性函数。

GELU(Hendrycks and Gimpel 2016)可以用几种方式实现;精确版本定义为 GELU(x)=x⋅Φ(x),其中 Φ(x) 是标准高斯分布的累积分布函数。在实践中,通常实现一个计算上更便宜的近似:$\text{GELU}(x) \approx 0.5 \cdot x \cdot \left(1 + \tanh\left[\sqrt{\frac{2}{\pi}} \cdot \left(x + 0.044715 \cdot x^3\right)\right]\right)$(原始 GPT-2 模型也是用这个近似训练的)。

class GELU(nn.Module):
    def __init__(self):
        super().__init__()

    def forward(self, x):
        return 0.5 * x * (1 + torch.tanh(
            torch.sqrt(torch.tensor(2.0 / torch.pi)) * 
            (x + 0.044715 * torch.pow(x, 3))
        ))
import matplotlib.pyplot as plt

gelu, relu = GELU(), nn.ReLU()

# Some sample data
x = torch.linspace(-3, 3, 100)
y_gelu, y_relu = gelu(x), relu(x)

plt.figure(figsize=(8, 3))
for i, (y, label) in enumerate(zip([y_gelu, y_relu], ["GELU", "ReLU"]), 1):
    plt.subplot(1, 2, i)
    plt.plot(x, y)
    plt.title(f"{label} activation function")
    plt.xlabel("x")
    plt.ylabel(f"{label}(x)")
    plt.grid(True)

plt.tight_layout()
plt.show()

正如我们看到的,ReLU 是一个分段线性函数,如果输入为正就直接输出输入,否则输出零。GELU 是一个平滑的非线性函数,它近似 ReLU,但对负值有非零梯度(除了大约 -0.75 处)。

接下来,让我们实现小型神经网络模块 FeedForward,它稍后会用在 LLM 的 transformer 块里:

class FeedForward(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.layers = nn.Sequential(
            nn.Linear(cfg["emb_dim"], 4 * cfg["emb_dim"]),
            GELU(),
            nn.Linear(4 * cfg["emb_dim"], cfg["emb_dim"]),
        )

    def forward(self, x):
        return self.layers(x)
print(GPT_CONFIG_124M["emb_dim"])
ffn = FeedForward(GPT_CONFIG_124M)

# input shape: [batch_size, num_token, emb_size]
x = torch.rand(2, 3, 768) 
out = ffn(x)
print(out.shape)

4.4 添加捷径连接

接下来,让我们谈谈捷径连接(shortcut connections)的概念,也叫跳跃连接(skip)或残差连接(residual connections)。

最初,捷径连接是在计算机视觉的深度网络(残差网络)里提出的,用来缓解梯度消失问题。捷径连接为梯度在网络里流动创建了一条替代的、更短的路径。这通过把一层的输出加到后面一层的输出来实现,通常跳过中间的一层或多层。

让我们用一个小示例网络来说明这个想法:

在代码里,它长这样:

class ExampleDeepNeuralNetwork(nn.Module):
    def __init__(self, layer_sizes, use_shortcut):
        super().__init__()
        self.use_shortcut = use_shortcut
        self.layers = nn.ModuleList([
            nn.Sequential(nn.Linear(layer_sizes[0], layer_sizes[1]), GELU()),
            nn.Sequential(nn.Linear(layer_sizes[1], layer_sizes[2]), GELU()),
            nn.Sequential(nn.Linear(layer_sizes[2], layer_sizes[3]), GELU()),
            nn.Sequential(nn.Linear(layer_sizes[3], layer_sizes[4]), GELU()),
            nn.Sequential(nn.Linear(layer_sizes[4], layer_sizes[5]), GELU())
        ])

    def forward(self, x):
        for layer in self.layers:
            # Compute the output of the current layer
            layer_output = layer(x)
            # Check if shortcut can be applied
            if self.use_shortcut and x.shape == layer_output.shape:
                x = x + layer_output
            else:
                x = layer_output
        return x


def print_gradients(model, x):
    # Forward pass
    output = model(x)
    target = torch.tensor([[0.]])

    # Calculate loss based on how close the target
    # and output are
    loss = nn.MSELoss()
    loss = loss(output, target)
    
    # Backward pass to calculate the gradients
    loss.backward()

    for name, param in model.named_parameters():
        if 'weight' in name:
            # Print the mean absolute gradient of the weights
            print(f"{name} has gradient mean of {param.grad.abs().mean().item()}")

让我们先打印没有捷径连接的梯度值:

layer_sizes = [3, 3, 3, 3, 3, 1]  

sample_input = torch.tensor([[1., 0., -1.]])

torch.manual_seed(123)
model_without_shortcut = ExampleDeepNeuralNetwork(
    layer_sizes, use_shortcut=False
)
print_gradients(model_without_shortcut, sample_input)

接下来,让我们打印捷径连接的梯度值:

torch.manual_seed(123)
model_with_shortcut = ExampleDeepNeuralNetwork(
    layer_sizes, use_shortcut=True
)
print_gradients(model_with_shortcut, sample_input)

正如我们基于上面的输出看到的,捷径连接防止了梯度在早期层(朝向 layer.0)消失。接下来我们实现 transformer 块时会用到捷径连接这个概念。

4.5 在 transformer 块里连接注意力和线性层

在本节,我们现在把前面的概念组合成一个所谓的 transformer 块。一个 transformer 块把上一章的因果多头注意力模块和我们之前实现的前馈神经网络(线性层)结合起来。此外,transformer 块还使用 dropout 和捷径连接。

# If the `previous_chapters.py` file is not available locally,
# you can import it from the `llms-from-scratch` PyPI package.
# For details, see: https://github.com/rasbt/LLMs-from-scratch/tree/main/pkg
# E.g.,
# from llms_from_scratch.ch03 import MultiHeadAttention

from previous_chapters import MultiHeadAttention


class TransformerBlock(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.att = MultiHeadAttention(
            d_in=cfg["emb_dim"],
            d_out=cfg["emb_dim"],
            context_length=cfg["context_length"],
            num_heads=cfg["n_heads"], 
            dropout=cfg["drop_rate"],
            qkv_bias=cfg["qkv_bias"])
        self.ff = FeedForward(cfg)
        self.norm1 = LayerNorm(cfg["emb_dim"])
        self.norm2 = LayerNorm(cfg["emb_dim"])
        self.drop_shortcut = nn.Dropout(cfg["drop_rate"])

    def forward(self, x):
        # Shortcut connection for attention block
        shortcut = x
        x = self.norm1(x)
        x = self.att(x)  # Shape [batch_size, num_tokens, emb_size]
        x = self.drop_shortcut(x)
        x = x + shortcut  # Add the original input back

        # Shortcut connection for feed forward block
        shortcut = x
        x = self.norm2(x)
        x = self.ff(x)
        x = self.drop_shortcut(x)
        x = x + shortcut  # Add the original input back

        return x

假设我们有 2 个输入样本,每个 4 个 token,每个 token 是一个 768 维的嵌入向量;那么这个 transformer 块应用自注意力,然后是线性层,产生相似大小的输出。你可以把输出想成我们上一章讨论的上下文向量的增强版本:

torch.manual_seed(123)

x = torch.rand(2, 4, 768)  # Shape: [batch_size, num_tokens, emb_dim]
block = TransformerBlock(GPT_CONFIG_124M)
output = block(x)

print("Input shape:", x.shape)
print("Output shape:", output.shape)

4.6 编写 GPT 模型

我们快到了:现在让我们把 transformer 块插进本章最开头编写的架构里,这样我们就得到一个能用的 GPT 架构。注意 transformer 块被重复多次;在最小的 124M GPT-2 模型的情况下,我们重复它 12 次:

对应的代码实现,其中 cfg["n_layers"] = 12

class GPTModel(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"])
        self.pos_emb = nn.Embedding(cfg["context_length"], cfg["emb_dim"])
        self.drop_emb = nn.Dropout(cfg["drop_rate"])
        
        self.trf_blocks = nn.Sequential(
            *[TransformerBlock(cfg) for _ in range(cfg["n_layers"])])
        
        self.final_norm = LayerNorm(cfg["emb_dim"])
        self.out_head = nn.Linear(
            cfg["emb_dim"], cfg["vocab_size"], bias=False
        )

    def forward(self, in_idx):
        batch_size, seq_len = in_idx.shape
        tok_embeds = self.tok_emb(in_idx)
        pos_embeds = self.pos_emb(torch.arange(seq_len, device=in_idx.device))
        x = tok_embeds + pos_embeds  # Shape [batch_size, num_tokens, emb_size]
        x = self.drop_emb(x)
        x = self.trf_blocks(x)
        x = self.final_norm(x)
        logits = self.out_head(x)
        return logits

使用 124M 参数模型的配置,我们现在可以用随机初始权重实例化这个 GPT 模型,如下所示:

torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)

out = model(batch)
print("Input batch:\n", batch)
print("\nOutput shape:", out.shape)
print(out)

我们会在下一章训练这个模型。不过,关于它的大小有个快速说明:我们之前称它为 124M 参数模型;我们可以如下核实这个数字:

total_params = sum(p.numel() for p in model.parameters())
print(f"Total number of parameters: {total_params:,}")

正如我们上面看到的,这个模型有 163M 而不是 124M 参数;为什么?

在原始 GPT-2 论文里,研究者应用了权重绑定(weight tying),意思是他们复用 token 嵌入层(tok_emb)作为输出层,也就是设置 self.out_head.weight = self.tok_emb.weight。token 嵌入层把 50,257 维的 one-hot 编码输入 token 投影成 768 维的嵌入表示。输出层把 768 维嵌入投影回 50,257 维表示,这样我们就能把它们转回词(更多内容见下一节)。所以,嵌入层和输出层有相同数量的权重参数,正如我们基于它们权重矩阵的形状看到的。

print("Token embedding layer shape:", model.tok_emb.weight.shape)
print("Output layer shape:", model.out_head.weight.shape)

在原始 GPT-2 论文里,研究者复用了 token 嵌入矩阵作为输出矩阵。相应地,如果我们减去输出层的参数数量,我们就得到一个 124M 参数模型:

total_params_gpt2 =  total_params - sum(p.numel() for p in model.out_head.parameters())
print(f"Number of trainable parameters considering weight tying: {total_params_gpt2:,}")

在实践中,作者发现不使用权重重绑更容易训练模型,这也是为什么我们这里不实现它。不过,我们会在第 5 章加载预训练权重时重新讨论并应用权重重绑的想法。

最后,我们可以如下计算模型的内存需求,它可以作为一个有用的参考点:

# Calculate the total size in bytes (assuming float32, 4 bytes per parameter)
total_size_bytes = total_params * 4

# Convert to megabytes
total_size_mb = total_size_bytes / (1024 * 1024)

print(f"Total size of the model: {total_size_mb:.2f} MB")

练习:你还可以试试下面这些配置,它们也在 GPT-2 论文 里被引用。

  • GPT2-small(我们已经实现的 124M 配置):

    • "emb_dim" = 768
    • "n_layers" = 12
    • "n_heads" = 12
  • GPT2-medium:

    • "emb_dim" = 1024
    • "n_layers" = 24
    • "n_heads" = 16
  • GPT2-large:

    • "emb_dim" = 1280
    • "n_layers" = 36
    • "n_heads" = 20
  • GPT2-XL:

    • "emb_dim" = 1600
    • "n_layers" = 48
    • "n_heads" = 25

4.7 生成文本

像我们上面实现的 GPT 模型这样的 LLM,被用来一次生成一个词。

下面的 generate_text_simple 函数实现贪婪解码(greedy decoding),这是一种简单快速的文本生成方法。在贪婪解码里,每一步模型选择概率最高的词(或 token)作为下一个输出(最高的 logit 对应最高的概率,所以技术上我们甚至不必显式计算 softmax 函数)。在下一章,我们会实现一个更高级的 generate_text 函数。

下图描绘了 GPT 模型在给定输入上下文的情况下,如何生成下一个词 token:

def generate_text_simple(model, idx, max_new_tokens, context_size):
    # idx is (batch, n_tokens) array of indices in the current context
    for _ in range(max_new_tokens):
        
        # Crop current context if it exceeds the supported context size
        # E.g., if LLM supports only 5 tokens, and the context size is 10
        # then only the last 5 tokens are used as context
        idx_cond = idx[:, -context_size:]
        
        # Get the predictions
        with torch.no_grad():
            logits = model(idx_cond)
        
        # Focus only on the last time step
        # (batch, n_tokens, vocab_size) becomes (batch, vocab_size)
        logits = logits[:, -1, :]  

        # Apply softmax to get probabilities
        probas = torch.softmax(logits, dim=-1)  # (batch, vocab_size)

        # Get the idx of the vocab entry with the highest probability value
        idx_next = torch.argmax(probas, dim=-1, keepdim=True)  # (batch, 1)

        # Append sampled index to the running sequence
        idx = torch.cat((idx, idx_next), dim=1)  # (batch, n_tokens+1)

    return idx

上面的 generate_text_simple 实现了一个迭代过程,一次创建一个 token。

让我们准备一个输入示例:

start_context = "Hello, I am"

encoded = tokenizer.encode(start_context)
print("encoded:", encoded)

encoded_tensor = torch.tensor(encoded).unsqueeze(0)
print("encoded_tensor.shape:", encoded_tensor.shape)
model.eval() # disable dropout

out = generate_text_simple(
    model=model,
    idx=encoded_tensor, 
    max_new_tokens=6, 
    context_size=GPT_CONFIG_124M["context_length"]
)

print("Output:", out)
print("Output length:", len(out[0]))

去掉批量维度并转回文本:

decoded_text = tokenizer.decode(out.squeeze(0).tolist())
print(decoded_text)

注意,模型是未训练的,所以上面的输出文本是随机的。我们会在下一章训练模型。

总结与要点

  • 参见 ./gpt.py 脚本,一个自包含的脚本,包含我们在本 Jupyter notebook 里实现的 GPT 模型。
  • 你可以在 ./exercise-solutions.ipynb 找到练习解答。

关键概念

  • 解码器类 LLM(decoder-like LLM):基于 transformer 解码器部分的模型,顺序生成词。
  • LayerNorm:把激活归一化到均值 0、方差 1,稳定训练;带可训练的 scale 和 shift。
  • GELU:平滑激活函数,GPT-2 用它(带 tanh 近似)。
  • FeedForward:两层线性 + GELU,中间维度是嵌入维度的 4 倍。
  • 捷径连接(shortcut/residual connections):把一层输出加到后面一层,防止梯度消失。
  • TransformerBlock:因果多头注意力 + 前馈网络 + LayerNorm + dropout + 捷径连接。
  • 权重重绑(weight tying):复用 token 嵌入层作为输出层;原始 GPT-2 用它,124M 参数由此而来。
  • 贪婪解码:每次选概率最高的 token,简单但次优。

练习

练习解答见仓库的 ch04/01_main-chapter-code/exercise-solutions.ipynb