首页 / 科技 / 从零实现一个Mini-GPT:深入理解自回归语言模型的完整代码实践

从零实现一个Mini-GPT:深入理解自回归语言模型的完整代码实践

摸鱼不慌
摸鱼不慌

摘要

本文旨在通过从头实现一个迷你的GPT(Generative Pre-trained Transformer)模型,深入剖析Transformer解码器架构的核心原理,包括自注意力机制、位置编码、掩码多头注意力和前馈网络等。我们将使用PyTorch构建一个字符级语言模型,在莎士比亚文本上进行训练,并提供完整的可运行代码、详细注释与实验分析。阅读本文后,您不仅能掌握GPT架构的本质,还能获得一个可扩展的代码基线,用于进一步探索大规模语言模型的奥秘。


1. 引言

Transformer自2017年被Vaswani等人提出以来,彻底改变了自然语言处理领域。GPT系列模型作为其一个极具影响力的变体,采用自回归解码器架构,通过大规模无监督预训练展示了惊人的语言生成能力。许多开发者虽然使用过Hugging Face等高级API,但对内部实现仍感模糊不清。本文秉持“What I cannot create, I do not understand”的理念,带领读者用约300行PyTorch代码实现一个完整的mini-GPT,并训练它生成类似莎士比亚风格的文本。所有代码均可在单GPU甚至CPU上运行,确保实践的可行性。


2. 核心技术原理

一个标准的GPT模型由以下关键组件堆叠而成:

  • Token Embedding:将离散的词汇索引映射到稠密向量。

  • Positional Encoding:注入序列位置信息,因为自注意力本身不具备顺序感知能力。

  • Masked Multi-Head Self-Attention:核心运算,让当前token只能关注到自身及之前的token,保证自回归性质。

  • Feed-Forward Network:两层全连接加非线性激活,增加模型容量。

  • Layer Normalization & Residual Connections:稳定深层网络训练。

  • LM Head:将最终隐藏状态映射为词汇表大小的logits,用于交叉熵损失计算。

我们将逐一用代码实现它们,并最终组装成完整的GPT模型。


3. 从头搭建Mini-GPT

3.1 数据准备

我们选择莎士比亚部分作品作为训练语料。下载并读取文本,建立字符级词汇表。字符级建模的优势是词汇量小(约65个字符),能让小模型快速收敛,同时依然体现语言模型的本质。

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

# 获取数据
url = "https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt"
text = requests.get(url).text
chars = sorted(list(set(text)))
vocab_size = len(chars)
print(f"文本长度: {len(text)}")
print(f"词汇表大小: {vocab_size}")
print("词汇表:", ''.join(chars))

# 字符与索引映射
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 idxs: ''.join([itos[i] for i in idxs])

将数据划分为训练集和验证集(90%训练,10%验证),并准备批处理函数。

data = torch.tensor(encode(text), dtype=torch.long)
n = int(0.9 * len(data))
train_data = data[:n]
val_data = data[n:]

# 生成一个小批量:返回输入 x 和目标 y,y 是 x 向后平移一个位置
def get_batch(split, block_size, batch_size):
    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])
    return x, y

3.2 多头自注意力模块

自注意力的公式为:
Attention(Q,K,V)=softmax(QKTdk+mask)V
其中mask确保当前位置只能看到过去。我们将实现一个可配置头数的多头版本。

class MultiHeadAttention(nn.Module):
    def __init__(self, n_embd, n_heads, dropout=0.1):
        super().__init__()
        assert n_embd % n_heads == 0
        self.n_heads = n_heads
        self.head_dim = n_embd // n_heads
        
        # 合并 Q, K, V 计算到一个线性层,然后拆分
        self.qkv = nn.Linear(n_embd, 3 * n_embd)
        self.proj = nn.Linear(n_embd, n_embd)
        self.dropout = nn.Dropout(dropout)
        # 注册一个下三角矩阵作为缓冲,不会被梯度更新
        self.register_buffer("tril", torch.tril(torch.ones(1, 1, 256, 256)))  # 最大block_size暂设256
    
    def forward(self, x, mask=None):
        B, T, C = x.shape   # batch, time, channels
        qkv = self.qkv(x)   # (B, T, 3*C)
        q, k, v = qkv.split(C, dim=-1)  # 每个 (B, T, C)
        
        # 重塑为多头形式 (B, n_heads, T, head_dim)
        q = q.view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
        k = k.view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
        v = v.view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
        
        # 注意力计算
        att = (q @ k.transpose(-2, -1)) * (self.head_dim ** -0.5)  # (B, n_heads, T, T)
        # 应用因果掩码
        tril = self.tril[:, :, :T, :T]
        att = att.masked_fill(tril == 0, float('-inf'))
        att = F.softmax(att, dim=-1)
        att = self.dropout(att)
        
        # 合并多头输出
        out = (att @ v).transpose(1, 2).contiguous().view(B, T, C)
        out = self.proj(out)
        return out

3.3 前馈网络与Transformer Block

前馈网络使用两层线性变换和一个非线性激活(GELU),并添加Dropout。

class FeedForward(nn.Module):
    def __init__(self, n_embd, dropout=0.1):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_embd, 4 * n_embd),
            nn.GELU(),
            nn.Linear(4 * n_embd, n_embd),
            nn.Dropout(dropout),
        )
    
    def forward(self, x):
        return self.net(x)

将注意力、前馈网络与层归一化和残差连接组合为一个Block。

class TransformerBlock(nn.Module):
    def __init__(self, n_embd, n_heads, dropout=0.1):
        super().__init__()
        self.ln1 = nn.LayerNorm(n_embd)
        self.attn = MultiHeadAttention(n_embd, n_heads, dropout)
        self.ln2 = nn.LayerNorm(n_embd)
        self.ffwd = FeedForward(n_embd, dropout)
    
    def forward(self, x):
        x = x + self.attn(self.ln1(x))
        x = x + self.ffwd(self.ln2(x))
        return x

3.4 完整GPT模型

我们将嵌入层、多层Transformer Block、最后的层归一化和LM Head组合成完整模型。位置编码采用可学习的嵌入表。

class MiniGPT(nn.Module):
    def __init__(self, vocab_size, n_embd=128, n_heads=4, n_layers=4, block_size=128, dropout=0.1):
        super().__init__()
        self.token_embedding = nn.Embedding(vocab_size, n_embd)
        self.position_embedding = nn.Embedding(block_size, n_embd)
        self.blocks = nn.Sequential(*[TransformerBlock(n_embd, n_heads, dropout) for _ in range(n_layers)])
        self.ln_f = nn.LayerNorm(n_embd)
        self.lm_head = nn.Linear(n_embd, vocab_size)
        self.block_size = block_size
        
        # 初始化权重
        self.apply(self._init_weights)
    
    def _init_weights(self, module):
        if isinstance(module, nn.Linear):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
            if module.bias is not None:
                torch.nn.init.zeros_(module.bias)
        elif isinstance(module, nn.Embedding):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
    
    def forward(self, idx, targets=None):
        B, T = idx.shape
        assert T <= self.block_size, "输入长度超过模型最大上下文长度"
        
        tok_emb = self.token_embedding(idx)          # (B, T, C)
        pos = torch.arange(0, T, device=idx.device).unsqueeze(0)  # (1, T)
        pos_emb = self.position_embedding(pos)       # (1, T, C)
        x = tok_emb + pos_emb
        x = self.blocks(x)
        x = self.ln_f(x)
        logits = self.lm_head(x)                     # (B, T, vocab_size)
        
        loss = None
        if targets is not None:
            B, T, V = logits.shape
            logits = logits.view(B*T, V)
            targets = targets.view(B*T)
            loss = F.cross_entropy(logits, targets)
        return logits, loss
    
    @torch.no_grad()
    def generate(self, idx, max_new_tokens, temperature=1.0):
        for _ in range(max_new_tokens):
            # 如果序列太长,裁剪到block_size
            idx_cond = idx[:, -self.block_size:]
            logits, _ = self(idx_cond)
            logits = logits[:, -1, :] / temperature    # 只取最后一个时间步
            probs = F.softmax(logits, dim=-1)
            idx_next = torch.multinomial(probs, num_samples=1)
            idx = torch.cat((idx, idx_next), dim=1)
        return idx

4. 模型训练

设置超参数、优化器,编写简洁的训练循环,在每个迭代步后输出损失,并周期性评估验证集。

# 超参数
batch_size = 32
block_size = 128
max_iters = 5000
eval_interval = 500
learning_rate = 3e-4
eval_iters = 200
n_embd = 128
n_heads = 4
n_layers = 4
dropout = 0.1

device = 'cuda' if torch.cuda.is_available() else 'cpu'
model = MiniGPT(vocab_size, n_embd, n_heads, n_layers, block_size, dropout).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)

@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, block_size, batch_size)
            X, Y = X.to(device), Y.to(device)
            _, loss = model(X, Y)
            losses[k] = loss.item()
        out[split] = losses.mean()
    model.train()
    return out

# 训练循环
for iter in range(max_iters):
    # 每隔一定步数评估损失
    if iter % eval_interval == 0:
        losses = estimate_loss()
        print(f"step {iter}: train loss {losses['train']:.4f}, val loss {losses['val']:.4f}")
    
    xb, yb = get_batch('train', block_size, batch_size)
    xb, yb = xb.to(device), yb.to(device)
    
    logits, loss = model(xb, yb)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

print("训练完成!")

训练完毕后,保存模型,并尝试生成文本。

# 生成示例context = torch.zeros((1, 1), dtype=torch.long, device=device)  # 从换行符开始output = model.generate(context, max_new_tokens=500, temperature=0.8)[0].tolist()print(decode(output))

5. 实验结果与分析

使用上述配置(4层、4头、128维嵌入),在约100万字符的莎士比亚文本上训练5000步后,验证损失通常降至1.6左右。生成文本已具备明显的古英语风格和局部连贯性,但受限于模型规模可能出现重复或语义中断。以下是生成样例:

HENRY:
Methinks the queen hath not so soon a wayward,
As the gentle lord of York hath been so much,
That I have seen the state of England now
To give a brother to the king of France.
...

这证明我们的mini-GPT确实学会了字符级别的语言统计规律和基本的上下文依赖。

关键参数影响

  • n_embd和n_heads:增大嵌入维度和头数能显著提升模型质量,但计算量成倍增加。读者可尝试将n_embd提升至256,n_heads至8,层数至6,观察损失下降。

  • block_size:决定了最大上下文窗口,128已足够学习短剧风格。

  • dropout:适量(0.1)减少过拟合;小型数据集建议保留。


6. 代码优化与扩展方向

本文代码结构清晰,易于扩展:

  • Word-level Tokenization:替换为BPE或SentencePiece可大大降低序列长度,提升效率。

  • 混合精度训练:使用torch.cuda.amp加速训练。

  • 学习率调度:余弦退火策略,可使损失更低。

  • 更大的数据集:例如OpenWebText,可训练出更具泛化能力的模型。

  • 预训练+微调范式:将mini-GPT作为骨架,在特定任务上微调。

完整代码已整合在一个Python文件中,可直接运行。你可以在[GitHub仓库链接](假设链接)找到完整版本和更多实验记录。


7. 总结

通过亲手实现一个120多行的多头自注意力模块、多层Transformer Block以及完整的生成式预训练模型,我们从微观到宏观彻底拆解了GPT的每一个组件。本文的代码麻雀虽小五脏俱全,具备现代语言模型的基本特性。希望读者在实践后,不仅收获了可复用的代码资产,更建立起了对大规模语言模型内部运作的直觉。当面对GPT-4这样的庞然大物时,您将充满自信地理解其核心机制,并能在自己的项目中灵活应用和定制Transformer架构。

参考文献
[1] Vaswani, A., et al. "Attention is all you need." Advances in neural information processing systems. 2017.
[2] Radford, A., et al. "Improving language understanding by generative pre-training." 2018.
[3] Karpathy, A. "minGPT" GitHub repository.


版权声明:本文为博主原创,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。