采样 · 让模型开口
逐行读 generate——自回归地「猜一个、接上、再猜」,以及 temperature 和 top-k 怎么调节模型的「脑洞」。再看 sample.py 怎么载入模型、用 meta.pkl 把吐出的数字翻译回文本。
采样 · 让模型开口
训练好的模型,肚子里装着「下一个 token 该是谁」的知识。这一站,我们让它开口——把这知识一步步变成新文本。核心就一个方法:generate。
generate:猜一个、接上、再猜
model.py 约 305–330 行:
@torch.no_grad()
def generate(self, idx, max_new_tokens, temperature=1.0, top_k=None):
for _ in range(max_new_tokens):
# 上下文太长就裁到最后 block_size 个
idx_cond = idx if idx.size(1) <= self.config.block_size else idx[:, -self.config.block_size:]
# 前向,取最后一个位置的 logits
logits, _ = self(idx_cond)
logits = logits[:, -1, :] / temperature
# 可选:只保留 top_k 个候选
if top_k is not None:
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
logits[logits < v[:, [-1]]] = -float('Inf')
# softmax 转概率,按概率采样一个
probs = F.softmax(logits, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1)
# 接到序列末尾,继续
idx = torch.cat((idx, idx_next), dim=1)
return idx这就是自回归生成——第一站说的「自己吃自己尾巴」。逐行看这个循环:
@torch.no_grad():生成不训练,不用算梯度。idx_cond = ... idx[:, -block_size:]:模型一次最多看block_size个 token,上下文超长就只留最后一窗(前面的看不到了——这正是《大模型解构》长上下文那章要解决的痛点:窗口就这么大)。logits, _ = self(idx_cond):前向。注意没给 targets,所以触发了GPT.forward 的推理期优化——只算最后一个位置。logits = logits[:, -1, :] / temperature:取最后一个位置的分数,除以温度(下面细说)。top_k分支:只保留最高的k个分数,其余打成-inf(下面细说)。probs = softmax(logits):把分数变成概率分布。idx_next = torch.multinomial(probs, 1):按概率随机抽一个——注意不是取最大(argmax),而是采样,这样每次生成才有变化、有「创造性」。idx = torch.cat((idx, idx_next), dim=1):把抽中的 token 接到序列末尾,进入下一轮。
两个旋钮:temperature 和 top-k
生成质量,全靠这两个旋钮调。
temperature(温度):调「胆子」
logits = logits[:, -1, :] / temperaturesoftmax 之前除以温度 T:
T < 1(如 0.8):相当于把分数拉得更陡,高分的更突出——模型更「保守自信」,爱选最可能的词,文本更连贯但也更平淡。T > 1:把分数压平,低分词也有机会——模型更「放飞」,更有创意但也更容易胡来。T → 0:退化成argmax,永远选最可能的,完全确定(没有随机性)。
一句话:温度就是模型的「胆子」旋钮——低了循规蹈矩,高了天马行空。
top-k:掐掉长尾
if top_k is not None:
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
logits[logits < v[:, [-1]]] = -float('Inf')只保留分数最高的 top_k 个候选,其余全部打成 -inf(softmax 后概率为 0)。作用是掐掉概率极低的长尾——防止模型偶尔抽到一个莫名其妙的词把句子带沟里。v[:, [-1]] 是第 k 名的分数,低于它的一律清零。
温度调「分布多陡」,top-k 调「从几个里挑」,两者常配合使用(sample.py 默认 temperature=0.8, top_k=200)。这些采样策略的系统研究,可见 Holtzman et al.(2019)那篇著名的《The Curious Case of Neural Text Degeneration》。
sample.py:把它跑起来
generate 是引擎,sample.py(89 行)是钥匙。它做三件事:
1. 载入模型(resume 或 gpt2):
if init_from == 'resume':
checkpoint = torch.load(ckpt_path, map_location=device)
gptconf = GPTConfig(**checkpoint['model_args'])
model = GPT(gptconf)
model.load_state_dict(state_dict)
elif init_from.startswith('gpt2'):
model = GPT.from_pretrained(init_from, dict(dropout=0.0))
model.eval()和训练那站对称:从 ckpt.pt 恢复自己训的模型,或直接拉 GPT-2。model.eval() 关掉 dropout。
2. 准备好 encode / decode——这里 meta.pkl 又登场了:
if load_meta: # 字符级:用训练时存的对照表
stoi, itos = meta['stoi'], meta['itos']
encode = lambda s: [stoi[c] for c in s]
decode = lambda l: ''.join([itos[i] for i in l])
else: # 否则默认用 GPT-2 的 BPE 分词
enc = tiktoken.get_encoding("gpt2")
encode = lambda s: enc.encode(s, allowed_special={"<|endoftext|>"})
decode = lambda l: enc.decode(l)这就是当初费事存 meta.pkl 的收尾:模型吐出的是一串数字,得靠 itos 把它们翻译回人能读的字符。字符级用那两个字典,GPT-2 级则用 tiktoken。
3. 编码起始提示,反复生成:
start_ids = encode(start)
x = (torch.tensor(start_ids, dtype=torch.long, device=device)[None, ...])
with torch.no_grad():
with ctx:
for k in range(num_samples):
y = model.generate(x, max_new_tokens, temperature=temperature, top_k=top_k)
print(decode(y[0].tolist()))把提示词(默认 "\n")编码成 x,喂给 generate,生成 max_new_tokens 个新 token,再 decode 回文本打印。循环 num_samples 次,出几段样本。
跑一下第一站那个字符级莎士比亚,你就看到全链路闭环了:prepare.py 造数据 → train.py 训模型 + 存 ckpt → sample.py 载入 + generate + decode 出文本。 六百行代码,从一段莎士比亚,真的长出了一个会写伪莎士比亚的 GPT。
主线通关,还剩「更大更快更省」
到这里,「数据 → 模型 → 训练 → 采样」的主干你已经全程逐行走完了。剩下的是让它上规模的进阶手艺:怎么装载 OpenAI 的 GPT-2 权重(那个有点烧脑的 Conv1D 转置)、怎么多卡并行、怎么用编译和 Flash Attention 榨干 GPU。这是第五站。👉 装载 GPT-2:from_pretrained 与 Conv1D 转置
参考文献
- [1] Holtzman et al. “The Curious Case of Neural Text Degeneration.” ICLR 2020. arXiv:1904.09751. —— 温度、top-k、nucleus 采样的系统研究。
- 源码:Andrej Karpathy, nanoGPT,
model.py的generate与sample.py. https://github.com/karpathy/nanoGPT