立委按:鉴于语言大模型GPT的重要性,特此根据AI大神Karpathy的nanoGPT讲座,编纂此科普系列,计五篇,一篇没有代码和数学公式,是最通俗的科普。其他四篇包括一篇英文,均附带可验证的Python代码,并给予不同角度的详细解说,面对有一定工程背景的对象。
下面这篇科普文章,以Karpathy讲座“从零实现莎士比亚风 GPT”为例,把每一行代码背后的思路拆透,感受“技术硬核”的魅力。
一、引子:为什么要自己写一个“小 GPT”?
- 脚踏实地:商用 GPT 模型动辄上百亿参数,看不清内部;自己写一个小模型,才有机会把每个细节掰开了啃。
- 入门示范:字符级模型更轻量,50 步训练就能出点儿“莎士比亚味”,足以演示 Transformer 的核心套路。
- 学习曲线:从准备数据、编码、搭网络、训练到采样生成,完整跑一趟,就能圆满理解 GPT 的「流水线」。
二、数据篇:把“文字”编码成“数字”
- 原始文本
把莎士比亚全集放到data/shakespeare_char/input.txt
,整个文本可能上百万字符。 - 字符表(Vocabulary)
pythonchars = sorted(list(set(open(...).read())))
stoi = {ch:i for i,ch in enumerate(chars)}
itos = {i:ch for i,ch in enumerate(chars)}
- 代码解释:
set
(字符集合)自然去重后有 65 个字符,包括字母、标点、换行符等。stoi
、itos
分别是一一映射,可做「文字 ↔ 索引」互转,即编码解码。
- 高效加载
训练前把所有文本编码成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
维向量; - 位置嵌入:告诉模型「这个字符是句子中的第几位」。
- Token 嵌入:把每个字符索引映射成
- 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)
- 拼接嵌入
每个字符向量 + 对应位置向量,融合语义与顺序信息。 - 因果掩码
用上三角布尔矩阵屏蔽未来位置,确保模型只能用“历史”信息预测“下一步”。 - 层叠计算
N 层解码器层交替执行「注意力→前馈→残差→归一化」,不断提炼上下文特征。 - 输出 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!