我非常乐意为你深入讲解 Andrej Karpathy 的 nanoGPT 项目(https://github.com/karpathy/nanoGPT)。这是一个极好的学习资源,因为它用最简洁、最核心的代码实现了一个完整的GPT模型。

我会从高层次的概述开始,然后深入到代码的每个关键部分,解释其工作原理和设计思想。


1. 高层次概述:nanoGPT是什么?

nanoGPT 是一个用于训练和微调中等规模GPT(Generative Pre-trained Transformer)模型的代码库。它的作者 Andrej Karpathy(前特斯拉AI总监,OpenAI创始成员之一)设计这个项目的核心目的是教学

它摒弃了大型框架(如Hugging Face Transformers)中的大量抽象和复杂性,用几百行纯粹的、易于阅读的PyTorch代码,为你揭示GPT模型内部的每一个细节。

核心特点:

  • 简洁性 (Simplicity): 核心模型定义在 model.py 一个文件中,代码量小,可读性极强。

  • 教育性 (Educational): 它是学习Transformer和GPT架构的最佳起点之一。Karpathy 还有一个配套的 YouTube教学视频,手把手从零构建它。

  • 完整性 (Complete): 项目包含了数据预处理、模型定义、训练、生成(推理)的所有环节。

  • 可复现性 (Reproducible): 它能够复现OpenAI GPT-2 (124M) 模型的性能,证明了这个小巧的代码库是正确且有效的。


2. 代码库结构解析

我们先来看一下这个项目的目录结构,了解每个文件是做什么的:

code Code

downloadcontent_copy

expand_less

    nanoGPT/
├── model.py          # 核心!定义了GPT模型架构的所有组件。
├── train.py          # 训练脚本,负责加载数据、模型、优化器,并执行训练循环。
├── sample.py         # 生成脚本,加载一个训练好的模型来生成文本。
├── data/               # 数据处理相关
│   └── shakespeare_char/
│       ├── prepare.py  # 下载莎士比亚数据集并进行分词(tokenize)的脚本。
│       └── input.txt   # 原始数据。
├── config/             # 存放不同实验的配置文件。
├── gpt2.py             # 用于加载OpenAI官方GPT-2权重的辅助脚本。
└── ... (其他文件如 .gitignore, LICENSE, README.md)
  

我们的讲解将主要聚焦于 model.py、train.py 和 data/shakespeare_char/prepare.py。


3. 深入 model.py:GPT模型的心脏

这是整个项目最核心的文件。它从零开始构建了一个GPT模型。我们自底向上地来看它的构成。

3.1 CausalSelfAttention (因果自注意力)

这是Transformer模型最关键的创新点。它让模型在处理一个词(token)时,能够“关注”到句子中其他相关的词,并根据这些词来调整当前词的表示。

code Python

downloadcontent_copy

expand_less

IGNORE_WHEN_COPYING_START

IGNORE_WHEN_COPYING_END

    class CausalSelfAttention(nn.Module):
    def __init__(self, config):
        # ...
        # K, Q, V 的线性投影层 (一个大的线性层完成,效率更高)
        self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
        # 输出的线性投影层
        self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)
        # ...
        # 因果掩码 (causal mask)
        self.register_buffer("bias", torch.tril(torch.ones(config.block_size, config.block_size))
                                     .view(1, 1, config.block_size, config.block_size))

    def forward(self, x):
        # ...
        # 1. 计算 Q, K, V
        q, k, v  = self.c_attn(x).split(self.n_embd, dim=2)
        # ... (reshape for multi-head)

        # 2. 计算注意力分数: (Q * K^T) / sqrt(d_k)
        att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))

        # 3. 应用因果掩码
        att = att.masked_fill(self.bias[:,:,:T,:T] == 0, float('-inf'))

        # 4. Softmax
        att = F.softmax(att, dim=-1)

        # 5. 用注意力分数加权 V
        y = att @ v # (B, nh, T, T) x (B, nh, T, hs) -> (B, nh, T, hs)
        # ... (reshape back)

        # 6. 输出投影
        y = self.c_proj(y)
        return y
  

核心思想解读:

  1. Q, K, V (Query, Key, Value): 输入的每个 token 都会被一个线性层 (c_attn) 映射成三个向量:Query、Key 和 Value。

    • Query (Q): 代表当前 token "想要查询"什么信息。

    • Key (K): 代表其他 token "携带"什么信息(的关键标识)。

    • Value (V): 代表其他 token "实际"的信息内容。

  2. 注意力分数: 通过计算当前 token 的 Q 与所有其他 token 的 K 的点积(q @ k.transpose(...)),来衡量当前 token 对其他 token 的"关注度"。

  3. 因果掩码 (Causal Mask): 这是GPT与BERT等模型的一个关键区别。GPT是一个自回归模型,预测下一个词时只能看到前面的词,不能看到未来的词。这个掩码 (self.bias) 是一个下三角矩阵,它将所有未来的位置设置为负无穷大,这样在 softmax 之后,这些位置的注意力权重就变成了0。

  4. 加权求和: 将计算出的注意力分数(权重)与所有 token 的 V 进行加权求和,得到当前 token 融合了上下文信息后的新表示。

  5. 多头 (Multi-Head): 代码中通过 reshape 操作将Q, K, V 分成多个“头”(n_head)。这允许模型在不同的“表示子空间”中同时学习不同类型的关系。比如一个头可能关注语法关系,另一个头关注语义关系。

3.2 MLP (多层感知机)

在自注意力层之后,每个 token 的输出会经过一个简单的前馈神经网络(Feed-Forward Network)。

code Python

downloadcontent_copy

expand_less

IGNORE_WHEN_COPYING_START

IGNORE_WHEN_COPYING_END

    class MLP(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.c_fc    = nn.Linear(config.n_embd, 4 * config.n_embd, bias=config.bias)
        self.c_proj  = nn.Linear(4 * config.n_embd, config.n_embd, bias=config.bias)
        self.dropout = nn.Dropout(config.dropout)
        # 使用 GELU 激活函数
        self.gelu = nn.GELU()

    def forward(self, x):
        x = self.c_fc(x)
        x = self.gelu(x)
        x = self.c_proj(x)
        x = self.dropout(x)
        return x
  

作用: 这部分可以被看作是模型的"思考"或"处理"单元。自注意力层负责信息的融合,而MLP层则对融合后的信息进行更复杂的非线性变换和加工。通常,中间层的维度会扩大4倍,然后再压缩回来。

3.3 Block (Transformer 块)

一个标准的Transformer块由一个自注意力层和一个MLP层组成。

code Python

downloadcontent_copy

expand_less

IGNORE_WHEN_COPYING_START

IGNORE_WHEN_COPYING_END

    class Block(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.ln_1 = LayerNorm(config.n_embd, bias=config.bias)
        self.attn = CausalSelfAttention(config)
        self.ln_2 = LayerNorm(config.n_embd, bias=config.bias)
        self.mlp = MLP(config)

    def forward(self, x):
        # 残差连接 (Residual Connection)
        x = x + self.attn(self.ln_1(x))
        x = x + self.mlp(self.ln_2(x))
        return x
  

关键设计:

  • LayerNorm (层归一化): 在进入注意力层和MLP层之前,对数据进行归一化。这有助于稳定训练过程。

  • Residual Connection (残差连接): x = x + ... 这种结构。它允许梯度在反向传播时直接流过,极大地缓解了深度神经网络中的梯度消失问题,使得训练非常深的模型成为可能。

3.4 GPT (最终模型)

最后,GPT 类将所有组件组装在一起。

code Python

downloadcontent_copy

expand_less

IGNORE_WHEN_COPYING_START

IGNORE_WHEN_COPYING_END

    class GPT(nn.Module):
    def __init__(self, config):
        super().__init__()
        # ...
        self.transformer = nn.ModuleDict(dict(
            wte = nn.Embedding(config.vocab_size, config.n_embd),       # Token 嵌入
            wpe = nn.Embedding(config.block_size, config.n_embd),      # Position 嵌入
            drop = nn.Dropout(config.dropout),
            h = nn.ModuleList([Block(config) for _ in range(config.n_layer)]), # 多个 Block 堆叠
            ln_f = LayerNorm(config.n_embd, bias=config.bias),
        ))
        self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False) # 输出层
        # ...

    def forward(self, idx, targets=None):
        # ...
        tok_emb = self.transformer.wte(idx) # (B, T, n_embd)
        pos_emb = self.transformer.wpe(torch.arange(0, T, dtype=torch.long, device=device)) # (T, n_embd)
        x = self.transformer.drop(tok_emb + pos_emb)
        
        for block in self.transformer.h:
            x = block(x)
        x = self.transformer.ln_f(x)

        if targets is not None:
            # 计算损失
            logits = self.lm_head(x)
            loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1)
        else:
            # 推理时,只计算最后一个 token 的 logits
            logits = self.lm_head(x[:, [-1], :]) # (B, 1, vocab_size)
            loss = None

        return logits, loss
  

组装流程:

  1. 输入 (idx): 一个由 token ID 组成的序列,形状为 (batch_size, sequence_length)。

  2. 嵌入层:

    • wte (Word Token Embedding): 将每个 token ID 转换为一个高维向量。

    • wpe (Word Position Embedding): 为每个位置(0, 1, 2...)创建一个向量。Transformer本身没有顺序概念,位置编码将序列的顺序信息注入模型。

    • 将两者相加,得到既包含内容又包含位置信息的初始输入。

  3. Transformer Blocks (h): 将嵌入后的向量输入一个由 n_layer 个 Block 堆叠而成的网络中。每一层都会对输入序列进行更深层次的特征提取和信息融合。

  4. 输出层 (lm_head): 经过所有Block处理后,最终的输出向量通过一个线性层 (lm_head) 映射到词汇表的大小。这个输出称为 logits,它代表了在当前位置,词汇表中每个词出现的可能性得分。

  5. 损失计算: 在训练时,使用 cross_entropy (交叉熵) 损失函数来比较模型预测的logits和真实的下一个词 targets。这个损失值会用于反向传播,更新模型的所有参数。


4. 深入 train.py:模型的训练师

这个脚本是训练模型的总指挥。

主要步骤:

  1. 配置加载: 从 config/ 目录加载超参数,如学习率、批大小、层数等。

  2. 数据加载:

    • 调用 get_batch 函数从预处理好的数据 (train.bin, val.bin) 中随机采样一小批数据。

    • get_batch 的巧妙之处在于,它取一个数据块 x 作为输入,然后取同一个数据块向右移动一位的 y 作为目标。这就是在教模型:看到 x 时,要预测出 y。

  3. 模型初始化: 根据配置实例化一个 GPT 模型。

  4. 优化器: 通常使用 AdamW 优化器,它在训练Transformer时表现很好。

  5. 训练循环 (Training Loop):

    • for iter in range(max_iters):

    • X, Y = get_batch('train'):获取一批训练数据。

    • logits, loss = model(X, Y):前向传播 (Forward Pass),计算模型的预测和损失。

    • optimizer.zero_grad(set_to_none=True):清空上一轮的梯度。

    • loss.backward():反向传播 (Backward Pass),计算损失函数对模型每个参数的梯度。

    • optimizer.step():参数更新,优化器根据计算出的梯度来更新模型的权重。

    • 学习率衰减 (Learning Rate Decay): 随着训练的进行,逐渐降低学习率,有助于模型收敛到更好的点。

    • 评估 (Evaluation): 定期在验证集上计算损失,以监控模型是否过拟合。

    • 保存模型 (Checkpointing): 定期保存模型的权重,以便中断后可以继续训练,或者用于后续的推理。


5. data/shakespeare_char/prepare.py:数据的准备

这个脚本展示了语言模型训练的第一步:数据预处理

主要步骤:

  1. 下载数据: 下载莎士比亚作品集 input.txt。

  2. 构建词汇表 (Vocabulary):

    • 对于这个char-level(字符级)模型,它会统计文本中所有出现过的独立字符。

    • 创建一个 stoi (string to integer) 字典和一个 itos (integer to string) 字典,用于字符和整数ID之间的相互转换。

  3. 编码 (Encode): 遍历整个文本,使用 stoi 将每个字符转换成对应的整数ID。

  4. 划分数据集: 将编码后的数据按90%/10%的比例划分为训练集和验证集。

  5. 保存为二进制文件: 使用numpy.memmap将处理好的数据保存为 .bin 文件。这种格式可以快速地从磁盘加载数据,而不需要把整个数据集都读入内存。


总结与学习建议

nanoGPT 的价值在于它揭示了 "魔法" 背后的数学和代码。

  • 从 model.py 开始: 通读并理解 CausalSelfAttention, Block, GPT 这几个类的forward方法,这是理解数据如何在模型中流动的关键。

  • 连接 train.py: 理解 train.py 中的训练循环是如何调用 model 并利用 loss 来更新参数的。

  • 动手实践:

    1. 克隆仓库: git clone https://github.com/karpathy/nanoGPT.git

    2. 准备数据: cd nanoGPT/data/shakespeare_char && python prepare.py

    3. 开始训练: cd ../.. && python train.py config/train_shakespeare_char.py

    4. 生成文本: python sample.py --out_dir=out-shakespeare-char

当你能成功运行这个流程,并理解每一步背后的代码逻辑时,你就已经对GPT模型的工作原理有了非常扎实的理解。从这里出发,再去学习更复杂的模型和框架就会事半功倍。

     可以结合这篇数学思维来看的《损失函数 L(W, b) ,与nanoGPT项目的源码对应起来说

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐