从零实现莎士比亚风 GPT科普解说

立委按:鉴于语言大模型GPT的重要性,特此根据AI大神Karpathy的nanoGPT讲座,编纂此科普系列,计五篇,一篇没有代码和数学公式,是最通俗的科普。其他四篇包括一篇英文,均附带可验证的Python代码,并给予不同角度的详细解说,面对有一定工程背景的对象。

下面这篇科普文章,以Karpathy讲座“从零实现莎士比亚风 GPT”为例,把每一行代码背后的思路拆透,感受“技术硬核”的魅力。


一、引子:为什么要自己写一个“小 GPT”?

  • 脚踏实地:商用 GPT 模型动辄上百亿参数,看不清内部;自己写一个小模型,才有机会把每个细节掰开了啃。
  • 入门示范:字符级模型更轻量,50 步训练就能出点儿“莎士比亚味”,足以演示 Transformer 的核心套路。
  • 学习曲线:从准备数据、编码、搭网络、训练到采样生成,完整跑一趟,就能圆满理解 GPT 的「流水线」。

二、数据篇:把“文字”编码成“数字”

  1. 原始文本
    把莎士比亚全集放到 data/shakespeare_char/input.txt,整个文本可能上百万字符。
  2. 字符表(Vocabulary)
    python
    chars = sorted(list(set(open(...).read())))
    stoi = {ch:i for i,ch in enumerate(chars)}
    itos = {i:ch for i,ch in enumerate(chars)}
  3. 代码解释:
    • set (字符集合)自然去重后有 65 个字符,包括字母、标点、换行符等。
    • stoiitos 分别是一一映射,可做「文字 ↔ 索引」互转,即编码解码。
  4. 高效加载
    训练前把所有文本编码成 uint16 二进制文件 train.bin,运行时直接:
    train_data = torch.frombuffer(open(…,'rb').read(), dtype=torch.uint16).long()
    一次性读入张量,快又省事。

三、模型篇:一个“小型 GPT”长啥样

class SimpleGPT(nn.Module):
    def __init__(self, vocab_size, n_embd=128, n_head=4, n_layer=3):
        super().__init__()
        # 1. Token 嵌入 & 位置嵌入
        self.embedding     = nn.Embedding(vocab_size, n_embd)
        self.pos_embedding = nn.Embedding(1000, n_embd)
        # 2. N 层 TransformerDecoderLayer
        self.transformer = nn.ModuleList([
            nn.TransformerDecoderLayer(
                d_model=n_embd, nhead=n_head,
                dim_feedforward=n_embd*4,
                batch_first=True, dropout=0.1
            ) for _ in range(n_layer)
        ])
        # 3. 归一化 + 线性头
        self.ln_f = nn.LayerNorm(n_embd)
        self.head = nn.Linear(n_embd, vocab_size)
  • Embedding
    • Token 嵌入:把每个字符索引映射成 128 维向量;
    • 位置嵌入:告诉模型「这个字符是句子中的第几位」。
  • TransformerDecoderLayer
    • 多头自注意力(Multi-Head Self-Attention):在无掩码时,让每个位置都能「看」到其他位置,用不同的“视角”捕捉语义关联;
    • 前馈网络 FFN:FFN内部是两层全连接,扩大特征维度后再压回增强非线性表达:第一层linear1把维度从 n_embd(128)→ dim_feedforward(512);Relu激活;第二层linear2把维度再从 dim_feedforward(512)→ n_embd(128));
    • 残差连接 + LayerNorm + Dropout:保证信息流通、稳定训练、防止过拟合。
    • 两层全连接(Feed-Forward Network, FFN)和残差连接(Residual Connection)部分被 PyTorch 的 nn.TransformerDecoderLayer 给封装起来了。
    • 每个 block 里总共是两层前馈线性变换(加一个激活);*4 把隐藏层的宽度调成原来的 4 倍(128*4=512)。 n_head=4 是指 注意力头 数量是 4 个。
  • 输出头
    最后把每个位置的 128 维特征 → 65 个字符的分数(logits),为下一步采样做准备。

每个 TransformerBlock(TransformerDecoderLayer)内部:

# 注意力子层(Self-Attention)
_attn_output = self.self_attn(x, x, x, attn_mask=tgt_mask)
x = x + self.dropout1(_attn_output)    # 残差 + Dropout
x = self.norm1(x)                      # LayerNorm

# 前馈全连接子层(Feed-Forward)
_ffn_output = self.linear2(self.dropout(self.activation(self.linear1(x))))
x = x + self.dropout2(_ffn_output)     # 残差 + Dropout
x = self.norm2(x)                      # LayerNorm

残差连接(Residual Connection)在哪里?

同样,TransformerDecoderLayer 在每个子层的输出上都做了:

x = x + SubLayer(x)

也就是将子层(注意力/前馈)的输出与原输入相加,然后再做 LayerNorm。这能让梯度更容易向前/向后流动,避免深层网络训练困难。


为什么常常用这样的封装?

  • 代码简洁:把注意力、FFN、残差、归一化、Dropout——所有常见操作都打包好,调用一行就能用。
  • 可配置:你可以在构造时传参数,比如 activation='gelu'norm_first=True(预归一化)等。

想要完全掌握内部细节,你可以自己写一个自定义的 DecoderLayer,大概长这样:

class MyDecoderLayer(nn.Module):
    def __init__(self, d_model, nhead, dim_ff):
        super().__init__()
        self.self_attn = nn.MultiheadAttention(d_model, nhead, batch_first=True)
        self.linear1   = nn.Linear(d_model, dim_ff)
        self.linear2   = nn.Linear(dim_ff, d_model)
        self.norm1     = nn.LayerNorm(d_model)
        self.norm2     = nn.LayerNorm(d_model)
        self.dropout   = nn.Dropout(0.1)
        self.act       = nn.ReLU()

    def forward(self, x, mask=None):
        # 注意力 + 残差 + 归一化
        attn_out, _ = self.self_attn(x, x, x, attn_mask=mask)
        x = x + self.dropout(attn_out)
        x = self.norm1(x)
        # FFN + 残差 + 归一化
        ffn_out = self.linear2(self.dropout(self.act(self.linear1(x))))
        x = x + self.dropout(ffn_out)
        x = self.norm2(x)
        return x

把 N 层这样的 MyDecoderLayer 串起来,就和 nn.TransformerDecoderLayer 是一模一样的套路。

这样你就明确地知道:GPT 的每一层都是「注意力→残差→LayerNorm→前馈→残差→LayerNorm」的循环组合。希望这下彻底搞明白了!

四、前向传播:一步步把输入变预测

def forward(self, x):
    b, t = x.shape                  # batch 大小 b,序列长度 t
    pos  = torch.arange(t).unsqueeze(0)
    x = self.embedding(x) + self.pos_embedding(pos)
    mask = torch.triu(torch.ones(t, t), diagonal=1).bool()
    for layer in self.transformer:
        x = layer(x, x, tgt_mask=mask)
    x = self.ln_f(x)
    return self.head(x)             # (b, t, vocab_size)
  1. 拼接嵌入
    每个字符向量 + 对应位置向量,融合语义与顺序信息。
  2. 因果掩码
    用上三角布尔矩阵屏蔽未来位置,确保模型只能用“历史”信息预测“下一步”。
  3. 层叠计算
    N 层解码器层交替执行「注意力→前馈→残差→归一化」,不断提炼上下文特征。
  4. 输出 logits
    每个位置都对应一个 vocab_size 维的分数向量,代表模型对下一个字符的「喜好程度」。

五、训练篇:教模型学“接龙文字”

optimizer = torch.optim.Adam(model.parameters(), lr=3e-4)
model.train()
for step in range(50):
    # 1. 随机抓 8 段长度为 block_size 的序列
    ix = torch.randint(len(train_data)-block_size, (8,))
    x  = torch.stack([train_data[i:i+block_size]       for i in ix])
    y  = torch.stack([train_data[i+1:i+block_size+1]   for i in ix])
    # 2. 前向 + 损失
    logits = model(x)
    loss   = nn.functional.cross_entropy(
                logits.view(-1, vocab_size), y.view(-1)
             )
    # 3. 反向 + 更新
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    if step%10==0: print(f"Step {step}: loss={loss.item():.4f}")
  • 随机采样:每次从不同起点取小段,让模型见识多种上下文,避免只学到固定模式。
  • 交叉熵损失:衡量预测分布 vs. 真实下一个字符的差距。
  • Adam 优化器:智能调整各参数的学习率,加快收敛。

六、生成篇:让模型「写莎翁诗」

def generate_text(prompt="", max_tokens=200, temperature=0.8, top_k=20):
    model.eval()
    tokens = encode(prompt) if prompt else [encode("ROMEO:")[0]]
    with torch.no_grad():
        for _ in range(max_tokens):
            ctx    = torch.tensor([tokens[-block_size:]])
            logits = model(ctx)[0, -1]       # 取最新位置的 logits
            logits = logits / temperature    # 温度调节
            if top_k>0:
                kth, _ = torch.topk(logits, top_k)
                logits[logits < kth[-1]] = -float('inf')
            probs = torch.softmax(logits, dim=-1)
            nxt   = torch.multinomial(probs, 1).item()
            tokens.append(nxt)
            if nxt==encode('\n')[0] and len(tokens)>10: break
    return decode(tokens)
  • 温度(Temperature)
    • <1:分布更陡峭,生成更“保守”;
    • >1:分布更平坦,生成更“大胆”。
  • Top-k 采样
    只保留概率最高的 k 个候选,把其余置零,然后再做随机采样,平衡“连贯”与“创造”。

七、核心技术要点小结

技术环节功能与作用
嵌入层离散字符→连续向量,便于神经网络处理
位置编码注入顺序信息,让模型区分“先后”
自注意力动态计算序列各位置间的相互影响,捕捉长程依赖
因果掩码严格屏蔽「未来信息」,模拟人写作时只能一步步推进
前馈网络增加非线性表达能力
残差+LayerNorm保持梯度稳定,助力深层网络收敛
温度 & Top-k控制生成文本的“保守度”与“多样性”

八、结语

  • 小模型 ≠ 小原理:虽然参数量小,但骨架设计、数据流程、采样策略与大规模 GPT 完全一致。
  • 动手才真懂:自己从头跑一遍,不仅能看懂代码,更能体会每一层、每一个技巧为何如此设计。
  • 一路上升:掌握这些基础,你就拥有了阅读和改造任何 Transformer-based 模型的「通行证」。

下次想要扩充到单词级、加上多 GPU、混合精度训练,或者接入更大语料,就能顺理成章地在这些模块之上“造船”了。Go build your own GPT!

GPT科普系列

发布者

立委

立委博士,多模态大模型应用高级咨询。出门问问大模型团队前工程副总裁,聚焦大模型及其AIGC应用。Netbase前首席科学家10年,期间指挥研发了18种语言的理解和应用系统,鲁棒、线速,scale up to 社会媒体大数据,语义落地到舆情挖掘产品,成为美国NLP工业落地的领跑者。Cymfony前研发副总八年,曾荣获第一届问答系统第一名(TREC-8 QA Track),并赢得17个小企业创新研究的信息抽取项目(PI for 17 SBIRs)。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理