本笔记基于 Andrej Karpathy 于 2023 年 1 月发布的视频课程《Let’s build GPT: from scratch, in code, spelled out.》(时长约 2 小时),并综合了配套仓库 karpathy/ng-video-lecture 、karpathy/nanoGPT 与社区笔记整理而成。
一、课程概况与资源
二、这门课在做什么(核心动机)
目标 :从零、用代码、逐行拼出一个 GPT(生成式预训练 Transformer),最终能生成以假乱真的莎士比亚风格文本。
语言模型 = 概率系统 :对同一个 prompt 能生成多个不同但都合理的回答(如 ChatGPT)。
底层架构 = Transformer :2017 年论文 Attention is All You Need 提出的神经网络架构。
核心思想:下一个 token 预测 (自监督学习)。把莎士比亚全集当训练集,模型学的是”给定前文,下一个字符是什么”的概率分布。
三、数据准备:tiny Shakespeare + 字符级分词 3.1 读取与字符集 with open ('input.txt' , 'r' , encoding='utf-8' ) as f: text = f.read() chars = sorted (list (set (text))) vocab_size = len (chars)
3.2 编码/解码映射(最朴素的分词器) stoi = {ch: i for i, ch in enumerate (chars)} itos = {i: ch for i, ch in enumerate (chars)} encode = lambda s: [stoi[c] for c in s] decode = lambda l: '' .join([itos[i] for i in l])
这是字符级分词 :每个字符一个 token。真实 GPT 用的是 BPE 子词分词(见 Tokenizer 课程),本课先用最简单版本。
数据划分:前 90% 训练、后 10% 验证(n = int(0.9*len(data)))。
四、数据加载与训练循环 4.1 取一个 batch def get_batch (split ): data = train_data if split == 'train' else val_data ix = torch.randint(len (data) - block_size, (batch_size,)) x = torch.stack([data[i:i+block_size] for i in ix]) y = torch.stack([data[i+1 :i+block_size+1 ] for i in ix]) x, y = x.to(device), y.to(device) return x, y
block_size (=8/256):最大上下文长度,即”模型最多看前多少个字符”;
batch_size (=32/64):一次并行处理的独立序列数(GPU 并行友好);
x 与 y 是”平移一对”:给定 x 的每个位置,要预测 y 的对应位置。
4.2 平滑的损失估计 @torch.no_grad() def estimate_loss (): out = {} model.eval () for split in ['train' , 'val' ]: losses = torch.zeros(eval_iters) for k in range (eval_iters): X, Y = get_batch(split) logits, loss = model(X, Y) losses[k] = loss.item() out[split] = losses.mean() model.train() return out
@torch.no_grad():推理时不计算梯度,省显存;
model.eval() / model.train():切换评估/训练模式(影响 Dropout 行为)。
五、Bigram 语言模型(最简单的基线) class BigramLanguageModel (nn.Module): def __init__ (self, vocab_size ): super ().__init__() self .token_embedding_table = nn.Embedding(vocab_size, vocab_size) def forward (self, idx, targets=None ): logits = self .token_embedding_table(idx) if targets is None : loss = None else : B, T, C = logits.shape logits = logits.view(B*T, C) targets = targets.view(B*T) loss = F.cross_entropy(logits, targets) return logits, loss def generate (self, idx, max_new_tokens ): for _ in range (max_new_tokens): logits, loss = self (idx) logits = logits[:, -1 , :] probs = F.softmax(logits, dim=-1 ) idx_next = torch.multinomial(probs, num_samples=1 ) idx = torch.cat((idx, idx_next), dim=1 ) return idx
关键点:
Bigram = 只根据上一个字符预测下一个 ,完全不看更早的上下文;
一个 nn.Embedding(vocab_size, vocab_size) 就完成了全部”模型”:查表直接得 logits;
初始损失理论值:均匀分布下 -ln(1/65) ≈ 4.17(视频里实测验证);
训练(AdamW,lr=1e-2,3000 步)后 val loss 降到 ≈2.5 ;
生成时 softmax 后 torch.multinomial 采样 (不是取 argmax,保留随机性);
生成的文本毫无意义(”大舅二舅”级别的乱码),但格式上已经像莎士比亚 ——这就是基线的意义。
六、自注意力机制(课程核心,占视频大半篇幅) 6.1 为什么需要”通信” Bigram 的缺陷:每个字符完全独立决策,token 之间不交流 。语言模型需要让序列中的 token 互相传递信息——例如”Harry”后面的空格应该让模型期待”Potter”。
6.2 三步推导(Karpathy 的经典教学路径) 第 1 步:平均池化(太粗糙) 对每个位置,取它之前所有 token embedding 的平均。能通信但无差别——所有历史 token 权重相同。
第 2 步:加权和(手工权重) 给不同位置的 token 不同的权重(如最近的重要、远的次要),但仍然不是数据驱动的。
第 3 步:key / query / value(真正的 self-attention)
wei = q @ k.transpose(-2 , -1 ) * k.shape[-1 ]**-0.5 wei = wei.masked_fill(self .tril[:T, :T] == 0 , float ('-inf' )) wei = F.softmax(wei, dim=-1 ) out = wei @ v
Query(查询) :我在找什么信息;
Key(键) :我有什么信息可以分享;
Value(值) :你找到我后,我实际告诉你的内容;
注意力分数 = query 与 key 的点积,softmax 归一化成权重,再对 value 加权求和;
这是数据驱动的加权通信 :每个 token 自己决定”关注谁、多关注”。
6.3 缩放因子与因果掩码
**缩放 * head_size**-0.5**:点积结果随维度变大而方差变大,softmax 会饱和(梯度消失)。除以 √d 把方差拉回 1——这就是 “scaled dot-product attention” 的 “scaled”;
因果掩码(causal mask) :torch.tril 下三角矩阵 + masked_fill(0, -inf),让位置 t 只能看到 ≤t 的 token。未来信息必须隐藏 ,否则训练时模型”作弊”(答案就在输入里),生成时也无法自回归;
实际实现里 tril 注册为 register_buffer(不是可训练参数,随模型搬设备)。
6.4 多头注意力 class MultiHeadAttention (nn.Module): def __init__ (self, num_heads, head_size ): super ().__init__() self .heads = nn.ModuleList([Head(head_size) for _ in range (num_heads)]) self .proj = nn.Linear(head_size * num_heads, n_embd) self .dropout = nn.Dropout(dropout) def forward (self, x ): out = torch.cat([h(x) for h in self .heads], dim=-1 ) out = self .dropout(self .proj(out)) return out
多个 head 并行 做不同的注意力(”多种关注模式”),结果拼接后经 proj 线性投影回原维度;
head_size = n_embd // n_head(如 384/6 = 64);
类比:多头像”多个视角同时开会”,每个 head 关注不同类型的关系。
7.1 前馈网络(计算) class FeedFoward (nn.Module): """ a simple linear layer followed by a non-linearity """ def __init__ (self, n_embd ): super ().__init__() self .net = nn.Sequential( nn.Linear(n_embd, 4 * n_embd), nn.ReLU(), nn.Linear(4 * n_embd, n_embd), nn.Dropout(dropout), )
class Block (nn.Module): """ Transformer block: communication followed by computation """ def __init__ (self, n_embd, n_head ): head_size = n_embd // n_head self .sa = MultiHeadAttention(n_head, head_size) self .ffwd = FeedFoward(n_embd) self .ln1 = nn.LayerNorm(n_embd) self .ln2 = nn.LayerNorm(n_embd) def forward (self, x ): x = x + self .sa(self .ln1(x)) x = x + self .ffwd(self .ln2(x)) return x
残差连接 (x + ...):梯度可以”抄近道”流过深层网络,防止退化;
预 LayerNorm (pre-norm):归一化在注意力/前馈之前 做(视频中讨论过 post-norm 与 pre-norm 的区别,最终采用 pre-norm,训练更稳定);
一句话总结 Block:先让 token 之间通信(注意力),再各自独立计算(前馈) 。
八、完整的 GPT 模型 class GPTLanguageModel (nn.Module): def __init__ (self ): super ().__init__() self .token_embedding_table = nn.Embedding(vocab_size, n_embd) self .position_embedding_table = nn.Embedding(block_size, n_embd) self .blocks = nn.Sequential(*[Block(n_embd, n_head=n_head) for _ in range (n_layer)]) self .ln_f = nn.LayerNorm(n_embd) self .lm_head = nn.Linear(n_embd, vocab_size) self .apply(self ._init_weights) def forward (self, idx, targets=None ): B, T = idx.shape tok_emb = self .token_embedding_table(idx) pos_emb = self .position_embedding_table(torch.arange(T, device=device)) x = tok_emb + pos_emb x = self .blocks(x) x = self .ln_f(x) logits = self .lm_head(x)
Token embedding + Position embedding :attention 本身”不知道顺序”,必须显式注入位置信息(这里是可学习的绝对位置嵌入表,长 256);
小配置超参数:n_embd=384, n_head=6, n_layer=6, block_size=256, dropout=0.2,参数约 10.7M ;
模型初始化 (视频后补的关键点):_init_weights 把 Linear/Embedding 初始化为 N(0, 0.02)。Karpathy 在 README 里专门说明:视频里没细讲初始化,但好的初始化对收敛速度影响很大 ,正式实现见 nanoGPT 的 model.py;
生成时 idx_cond = idx[:, -block_size:]:只保留最后 block_size 个 token 作为上下文(否则序列超长会越界)。
九、训练与生成(结果) optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4 ) context = torch.zeros((1 , 1 ), dtype=torch.long, device=device)print (decode(m.generate(context, max_new_tokens=500 )[0 ].tolist()))
阶段
val loss(视频实测)
均匀随机初始
≈4.17(= ln 65)
Bigram 训练后
≈2.5
GPT 训练后
≈1.5
从 4.17 → 2.5 → 1.5,每一步都能看到模型”变聪明”;1.5 意味着模型对下一个字符平均只有 e^1.5 ≈ 4.5 个合理候选;
生成的 500 字符输出是格式正确但内容荒诞的莎士比亚 (”VIOLA: …” 之类的角色名和台词结构);
关键工程细节:torch.manual_seed(1337) 保证可复现。
十、关键洞见与要点总结
语言模型 = 下一个 token 预测 :整个 GPT 就是”给定前文,预测下一个字符/子词”的自监督学习,训练数据无需人工标注;
**Self-attention 是”通信”,FFN 是”计算”**:注意力让 token 按数据驱动的方式互相交换信息,前馈网络让每个位置独立”思考”;
因果性是自回归的基石 :三角掩码保证模型看不到未来,训练与生成才能一致;
位置信息必须显式注入 :注意力是”集合操作”(无序),没有位置编码模型就不知道语序;
缩放点积是工程细节也是理论细节 :√d 缩放防止 softmax 饱和,这是 Attention 论文的命名来源;
残差 + LayerNorm + Dropout 三件套让深层 Transformer 可训练、防过拟合;
小处见大 :65 字符的玩具模型 ≈ 10.7M 参数,架构与 GPT-4 同构——把 vocab 换成 BPE、数据换成互联网,就是真 GPT;
初始化很重要 :同样的架构,随机初始化位置不同,收敛速度天差地别(nanoGPT 的 _init_weights)。
十一、与其他课程的衔接
前置 :micrograd(理解反向传播)、makemore(字符级语言模型、多层感知机、BatchNorm);
本课 :从 bigram 逐步进化到完整 Transformer(GPT);
后续 :
Let’s build the GPT Tokenizer :把字符级分词升级为 GPT 实际使用的 BPE 子词分词,并解释大量 LLM 怪癖;
Let’s build GPT-2 :把 nanoGPT 扩展到 GPT-2 的真实规模(124M 参数、更大的数据与上下文),讲分布式训练;
完整路线见 karpathy.ai/zero-to-hero.html 。
十二、推荐阅读链接汇总