原首发于
petwan.tech/blog/minigpt(2026-10-28),现已迁移到本站/minigpt/。这篇笔记用 PyTorch 实现了一个基础的 GPT-2 模型,主线是“读代码 → 跑代码 → 理解 Transformer”。
本文与原文的区别:原文的每一个代码块都在真实数据集上重新执行过一遍,数值结论全部核对过;发现的 5 个真实缺陷和 1 个潜伏陷阱在第 10 节列出了现象、根因和修法。
整篇笔记的 pipeline 如下,后续每一节对应其中一个环节:
1. 数据集
使用**对话-百科(中文)**数据集,涵盖美食、城市、企业家、汽车、明星八卦、生活常识、日常对话等信息。数据集托管在 ModelScope:qiaojiedongfeng/qiaojiedongfeng。
实测下载下来是 train.jsonl,274,148 条问答对,58.9 MB。数据格式:
{"question": "你好,最近怎么样?", "answer": "你好!我最近还不错,谢谢。"}
{"question": "今天天气如何?", "answer": "今天的天气很晴朗。"}
{"question": "你喜欢旅行吗?", "answer": "是的,我非常喜欢旅行。"}
{"question": "你最喜欢的食物是什么?", "answer": "我最喜欢的食物是寿司。"}
{"question": "你有什么兴趣爱好?", "answer": "我喜欢阅读和运动。"}把整份数据读进内存、做字符级词表统计约 10 秒;split_data.py 处理 27 万行约 4.5 秒(单线程,纯 Python)。
2. 构建词表
因为数据集以中文为主,这里采用字符级 tokenization:按 Unicode 字符遍历文本,并把标点符号等字符纳入词表。
需要注意,它不等同于严格的“一个汉字一个 token”:英文、空格和标点同样按字符处理,某些由多个 Unicode 码点组成的 emoji 也可能被拆开。词表中还需要三个特殊 token:
<pad>— 占位符<unk>— 未知字符<sep>— 分隔 question 和 answer,并兼作答案结束标记
示例:文本 "你好吗?" → 分词结果 ["你", "好", "吗", "?"],每个字对应一个 ID。
import json
import argparse
from collections import Counter
def build_vocab(data_path: str, output_path: str):
counter = Counter()
with open(data_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
# 收集 question 和 answer 中的所有字符
counter.update(item["question"])
counter.update(item["answer"])
except Exception as e:
print(f"Warning: skip invalid line: {line[:50]}... | Error: {e}")
# 获取所有唯一字符
chars = sorted(counter.keys())
# 添加特殊 token
special_tokens = ["<pad>", "<unk>", "<sep>"]
# 构建 word2id:先放特殊 token,再放字符(顺序固定便于复现)
word2id = {token: i for i, token in enumerate(special_tokens)}
for char in chars:
if char not in special_tokens: # 防御:跳过特殊 token
word2id[char] = len(word2id) # 自动递增
# 构建 id2word
id2word = {i: token for token, i in word2id.items()}
vocab = {"word2id": word2id, "id2word": id2word}
# 保存 vocab
with open(output_path, "w", encoding="utf-8") as f:
json.dump(vocab, f, ensure_ascii=False, indent=4)
print(f"✅ Vocabulary built and saved to {output_path}")
print(f" Total tokens: {len(word2id)}")
print(f" Special tokens: {special_tokens}")
print(f" Sample chars: {chars[:10]}...")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Build vocabulary from QA dataset.")
parser.add_argument(
"--data", type=str, required=True, help="Path to training data (JSONL format)"
)
parser.add_argument(
"--output", type=str, default="vocab.json", help="Output vocab file path"
)
args = parser.parse_args()
build_vocab(args.data, args.output)执行命令后将词表保存在 data 目录下:
python ./minigpt/build_vocab.py --data ./data/data.jsonl --output ./data/vocab.json实测输出:
✅ Vocabulary built and saved to ./data/vocab.json
Total tokens: 4825
Special tokens: ['<pad>', '<unk>', '<sep>']
Sample chars: ['\n', ' ', '!', '"', '#', '$', '%', '&', "'", '(']...词表共 4,825 个 token,与原文一致。值得留意的是前几个字符是 \n、 这类空白符 —— 它们来自原始 JSONL 的排版,说明语料清洗并不彻底,但不影响训练。
3. 创建 Tokenizer 类
Tokenizer 类用于将输入的文本数据进行分词,并生成对应的索引序列,同时将模型的输出转换为可读文本。
import json
class Tokenizer:
def __init__(self, vocab_path: str):
with open(vocab_path, "r", encoding="utf-8") as f:
vocab = json.load(f)
self.word2id = vocab["word2id"]
self.id2word = {int(k): v for k, v in vocab["id2word"].items()}
# 固定特殊 token ID
self.pad_token_id = self.word2id["<pad>"]
self.unk_token_id = self.word2id["<unk>"]
self.sep_token_id = self.word2id["<sep>"]
def encode(
self,
question: str,
answer: str,
max_length: int = 128,
pad_to_max_length: bool = True,
):
"""将问答对编码为 token ID 序列。"""
tokens = []
# encode question
for char in question:
tokens.append(self.word2id.get(char, self.unk_token_id))
tokens.append(self.sep_token_id) # 添加分隔符
# encode answer
if answer is not None:
for char in answer:
tokens.append(self.word2id.get(char, self.unk_token_id))
tokens.append(self.sep_token_id)
# 构建 attention mask(1=真实 token,0=padding)
attn_mask = [1] * len(tokens)
# 截断或填充
if pad_to_max_length:
if len(tokens) > max_length:
# 截断(保留开头)
tokens = tokens[:max_length]
attn_mask = attn_mask[:max_length]
else:
# 填充
pad_len = max_length - len(tokens)
tokens.extend([self.pad_token_id] * pad_len)
attn_mask.extend([0] * pad_len)
return tokens, attn_mask
def decode(self, ids):
"""将 token ID 列表解码为原始文本(跳过 <pad>)。"""
return "".join(
self.id2word[i] for i in ids if i != self.pad_token_id # 跳过填充符
)
def get_vocab_size(self):
return len(self.id2word)用一个简单的例子测试:
if __name__ == "__main__":
question = "你好,最近怎么样?"
answer = "我很好,谢谢!"
tokenizer = Tokenizer("./data/vocab.json")
input_ids, attn_mask = tokenizer.encode(question, answer, max_length=32)
print(input_ids)
print(tokenizer.decode(input_ids))实测输出:
[368, 1086, 4810, 2005, 4169, 1521, 240, 2103, 4816, 2, 1646, 1480, 1086, 4810, 3989, 3989, 4806, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
你好,最近怎么样?<sep>我很好,谢谢!<sep>词表 ID 和原文完全一致 —— 这也验证了 sorted(counter.keys()) 的排序规则是确定性的。
4. 划分数据
将数据打乱后进行划分,80% 训练集、20% 验证集。
# split_data.py
import json
import argparse
import random
from pathlib import Path
def split_jsonl_data(input_path: str, train_ratio: float = 0.9, seed: int = 42):
"""
将 JSONL 格式的 QA 数据集划分为 train.jsonl 和 val.jsonl。
"""
input_path = Path(input_path)
if not input_path.exists():
raise FileNotFoundError(f"Input file not found: {input_path}")
# 读取所有有效行
data = []
with open(input_path, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
if "question" in item and "answer" in item:
data.append(line) # 保留原始字符串,避免格式变化
else:
print(
f"⚠️ Warning: Line {line_num} missing 'question' or 'answer', skipped."
)
except json.JSONDecodeError:
print(f"⚠️ Warning: Line {line_num} is invalid JSON, skipped.")
if not data:
raise ValueError("No valid data found!")
# 打乱并划分
random.seed(seed)
random.shuffle(data)
n_train = int(len(data) * train_ratio)
train_data = data[:n_train]
val_data = data[n_train:]
# 输出路径
output_dir = input_path.parent
train_path = output_dir / "train.jsonl"
val_path = output_dir / "val.jsonl"
# 写入文件
with open(train_path, "w", encoding="utf-8") as f:
f.write("\n".join(train_data) + "\n")
with open(val_path, "w", encoding="utf-8") as f:
f.write("\n".join(val_data) + "\n")
print("✅ Split completed!")
print(f" Total samples: {len(data)}")
print(f" Train: {len(train_data)} → {train_path}")
print(f" Val: {len(val_data)} → {val_path}")
print(f" Train ratio: {train_ratio:.1%}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Split QA dataset into train/val sets."
)
parser.add_argument(
"--input",
type=str,
required=True,
help="Path to original JSONL dataset (e.g., data/all.jsonl)",
)
parser.add_argument(
"--train_ratio",
type=float,
default=0.9,
help="Proportion of data to use for training (default: 0.9)",
)
parser.add_argument(
"--seed",
type=int,
default=42,
help="Random seed for reproducibility (default: 42)",
)
args = parser.parse_args()
split_jsonl_data(args.input, args.train_ratio, args.seed)python ./minigpt/split_data.py --input ./data/data.jsonl --train_ratio 0.8实测输出:
✅ Split completed!
Total samples: 274148
Train: 219318 → data\train.jsonl
Val: 54830 → data\val.jsonl
Train ratio: 80.0%5. 查看训练数据的 token 统计
原文提到“可以看到 95% 的 token 长度都在 140 以内”,但没有给出统计脚本。我补了一个(minigpt/dataset_stats.py):
import json
import argparse
import numpy as np
from minigpt.tokenizer import Tokenizer
def main():
parser = argparse.ArgumentParser(description="Token length statistics for a JSONL QA dataset.")
parser.add_argument("--train_data", type=str, required=True, help="Path to train.jsonl")
parser.add_argument("--vocab_path", type=str, required=True, help="Path to vocab.json")
parser.add_argument("--max_length", type=int, default=140)
args = parser.parse_args()
tokenizer = Tokenizer(args.vocab_path)
lengths, q_lens, a_lens = [], [], []
with open(args.train_data, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
item = json.loads(line)
q, a = item["question"], item["answer"]
q_lens.append(len(q))
a_lens.append(len(a))
# 完整序列 = question + <sep> + answer + <sep>
lengths.append(len(q) + 1 + len(a) + 1)
lengths = np.array(lengths)
q_lens = np.array(q_lens)
a_lens = np.array(a_lens)
print(f"samples: {len(lengths)}")
print(f"full-seq length mean={lengths.mean():.1f} p50={np.percentile(lengths,50):.0f} "
f"p90={np.percentile(lengths,90):.0f} p95={np.percentile(lengths,95):.0f} "
f"p99={np.percentile(lengths,99):.0f} max={lengths.max()}")
print(f"question length mean={q_lens.mean():.1f} p95={np.percentile(q_lens,95):.0f} max={q_lens.max()}")
print(f"answer length mean={a_lens.mean():.1f} p95={np.percentile(a_lens,95):.0f} max={a_lens.max()}")
for thr in (128, 140, 256):
print(f" <= {thr:3d}: {np.mean(lengths <= thr) * 100:.2f}%")
print(f"truncated at max_length={args.max_length}: {np.mean(lengths > args.max_length) * 100:.2f}%")
if __name__ == "__main__":
main()python ./minigpt/dataset_stats.py --train_data ./data/train.jsonl --vocab_path ./data/vocab.json实测输出:
samples: 219318
full-seq length mean=65.1 p50=56 p90=109 p95=139 p99=212 max=880
question length mean=19.6 p95=33 max=703
answer length mean=43.5 p95=111 max=732
<= 128: 93.71%
<= 140: 95.21%
<= 256: 99.64%
truncated at max_length=140: 4.79%结论核对通过:95.21% 的样本完整序列在 140 token 以内,与原文“95% 在 140 以内”完全吻合。
一个值得注意的细节:p95 = 139,而 max_length = 140,只差 1 个 token。这意味着选择 140 作为截断长度非常“贴边”—— 换一批数据或换个随机种子,保留率就会明显变化。相比之下 block_size = 256 能覆盖 99.64% 的样本,是更稳妥的选择(但会牺牲训练速度)。
6. 创建 Dataset
这里没有考虑太多优化的问题,仅做学习,因此创建的 QADataset 类比较简单。
import torch
from torch.utils.data import Dataset
import json
from minigpt.tokenizer import Tokenizer
class QADataset(Dataset):
"""
自回归训练用的问答数据集(Question-Answer Dataset)。
将 (question, answer) 拼接为单个序列,并构造:
- 完整序列: [q1, q2, ..., <sep>, a1, a2, ..., <sep>, <pad>, ...]
- input_ids: 完整序列去掉最后一个 token
- targets: 完整序列去掉第一个 token
"""
def __init__(self, data_path: str, tokenizer: Tokenizer, max_length: int):
self.tokenizer = tokenizer
self.max_length = max_length
self.data = []
print(f"Loading data from {data_path}")
with open(data_path, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
if "question" not in item or "answer" not in item:
print(
f"⚠️ Line {line_num}: Missing 'question' or 'answer', skipped."
)
continue
self.data.append((item["question"], item["answer"]))
except Exception as e:
print(f"⚠️ Line {line_num}: Invalid JSON, skipped. Error: {e}")
print(f"✅ Loaded {len(self.data)} valid QA pairs.")
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
question, answer = self.data[idx]
full_tokens, attn_mask = self.tokenizer.encode(
question, answer, max_length=self.max_length
)
# 自回归训练: input 向右移一位,target 向左移一位
input_ids = full_tokens[:-1]
attention_mask = attn_mask[:-1]
targets = full_tokens[1:]
return {
"input_ids": torch.tensor(input_ids, dtype=torch.long),
"attention_mask": torch.tensor(attention_mask, dtype=torch.long),
"targets": torch.tensor(targets, dtype=torch.long),
}__getitem__ 的移位逻辑是自回归训练的核心。看 train.jsonl 的第 0 条样本:
question: 枣树生长的产物分类为何类?
answer : 枣树生长的产物属于果实类。解码后:
input_ids → 枣树生长的产物分类为何类?<sep>枣树生长的产物属于果实类。<sep>
targets → 树生长的产物分类为何类?<sep>枣树生长的产物属于果实类。<sep>targets 比 input_ids 整体左移一位,于是“第 t 位预测第 t+1 位”天然成立。这正是 GPT 的训练方式:模型只需要在每个位置预测下一个 token。
7. 模型搭建
在动手写代码前,先看清楚要搭的整体结构 —— 一个标准的 decoder-only Transformer(GPT 风格):
接下来 7.1~7.4 专注讲清楚 Block 里的 Causal Multi-Head Attention,7.5 再把剩下的 MLP、LayerNorm、Embedding、LM Head 拼起来。
7.1 注意力机制背景
首先用一个最简单的例子说明注意力层。
Query:通过外卖 App 点外卖,个人的关注点是饭店口味是否麻辣和价格,例如 Query = [0.9, 0.8] 表示我希望饭店的“麻辣程度”和“便宜程度”都尽可能高。为此,这个简化示例中的 Key 两个维度也分别定义为“越辣值越大”和“越便宜值越大”。Query 用于和 Key 计算匹配分数;经过 softmax 得到的注意力权重,才是后续对 Value 加权求和的权重。
Key:假设有三家饭店,每家饭店使用菜品麻辣程度和价格作为其饭店特征,例如 Key = [0.8, 0.7],第一个维度表示饭店菜品的麻辣程度的得分(越辣得分越高),第二个维度表示饭店价格的便宜程度(值越接近 1,表示饭店越便宜),这个要与 Query 的理解一致。
用几个不恰当的例子(有个人主观因素 😄):
- 粤菜馆:Key = [0.1, 0.3] — 不辣,价格较高
- 川菜馆:Key = [0.9, 0.5] — 麻辣,价格中等
- 日式餐厅:Key = [0.3, 0.1] — 不辣,价格太贵
Value:每个饭店能提供的有用信息,例如 Value = [0.9, 0.8] 可以表示用户综合评分 0.9,出餐速度 0.8。
Value 不需要与 Query 和 Key 的维度数量和含义保持一致,Value 可以是任何有用的信息。
第一步:计算原始匹配分数
逐个计算(这里先用未缩放的点积帮助理解):
| 饭店 | 计算过程 | 原始分数 |
|---|---|---|
| A(粤菜) | 0.9 × 0.1 + 0.8 × 0.3 = 0.09 + 0.24 | 0.33 |
| B(川菜) | 0.9 × 0.9 + 0.8 × 0.5 = 0.81 + 0.40 | 1.21 |
| C(日料) | 0.9 × 0.3 + 0.8 × 0.1 = 0.27 + 0.08 | 0.35 |
川菜馆遥遥领先 —— 又辣又相对便宜!
第二步:Softmax 归一化 → 注意力得分(权重)
先算指数(保留 4 位小数):
再计算每个饭店的注意力得分:
| 饭店 | 公式 | 注意力得分(αᵢ) | 百分比 |
|---|---|---|---|
| A(粤菜) | 1.3910 / 6.1636 | 0.2257 | 22.6% |
| B(川菜) | 3.3535 / 6.1636 | 0.5441 | 54.4% |
| C(日料) | 1.4191 / 6.1636 | 0.2302 | 23.0% |
- 川菜馆获得超过一半(54.4%)的注意力 → 因为它最符合你“又辣又便宜”的偏好
- 粤菜馆和日料馆各占约 1/4
这些注意力得分决定了后续聚合 Value 时的话语权:川菜馆的评分和出餐速度对最终结果影响最大,之后将注意力得分乘以 Value 并求和后,得到的是一个上下文感知的推荐摘要 —— 不是某一家饭店,而是根据你的偏好动态融合三家饭店的综合表示,可以理解为是一个虚拟的饭店,用于指导后续的决策。
实际的 scaled dot-product attention 还会把上述分数除以 √d_k。因此,后面代码在 d_k = 2 时打印的权重会与这里的未缩放手算结果略有不同。
接着思考,如果我的考虑维度很多呢?比如我既关注价格,又关注麻辣口味,还关注健康等等,是否可以将这些偏好维度都放进 Query 中?
理论上可以,但是实践上的效果可能不太好,因为维度太多,可能导致不同维度的相互干扰,所有的维度混在一起计算总的分数,导致最终的得分可能不是最合适的。
多头注意力的直觉在这里就体现出来了:每个头拥有独立的投影矩阵,可以在不同的学习表示子空间中捕捉不同关系,再把多个头的结果拼接起来。需要注意,这并不是人工把几个原始维度固定分配给某个头;每个头关注什么是由训练学习得到的。
进一步解释(推理阶段的)KV 缓存,因为个人的偏好 Query 可能会变,但是饭店的特征(Key/Value)不会变,所以只要饭店没有换菜单,没调价,它们的 Key 和 Value 都不会变,所以可以缓存起来,下次查询的时候直接从缓存中取,省得重复计算。
7.2 注意力机制实现
回到 miniGPT 的实现上,我们把注意力机制的公式写一下:
这里需要注意,我们要在这个公式的基础上增加注意力掩码。由于 batch 中的输入序列可能长度不同,通常使用 padding 补齐;这些 padding 不包含有效信息,因此将对应的 mask 设为 False。计算时先把这些位置的 attention logits 替换为极小值,经过 softmax 后,其注意力权重才会接近 0。
import torch
import torch.nn as nn
import numpy as np
class ScaledDotProductAttention(nn.Module):
def __init__(self, d_k):
super(ScaledDotProductAttention, self).__init__()
self.d_k = d_k # 输入的维度,用于计算缩放因子
def forward(self, q, k, v, attn_mask):
"""
Args:
q: [batch_size, n_heads, len_q, d_k]
k: [batch_size, n_heads, len_k, d_k]
v: [batch_size, n_heads, len_v, d_v] (注意: len_k == len_v)
attn_mask: [batch_size, n_heads, len_q, len_k] 或 None
Returns:
context: [batch_size, n_heads, len_q, d_v]
attn: [batch_size, n_heads, len_q, len_k]
"""
# step 1: 计算 QK^T / sqrt(d_k) shape: [batch_size, n_heads, len_q, len_k]
scores = torch.matmul(q, k.transpose(-1, -2)) / np.sqrt(self.d_k)
# step 2: 应用 attn_mask
if attn_mask is not None:
# masked_fill_: 将 mask=False 的位置替换为 -1e9
scores = scores.masked_fill(~attn_mask, -1e9)
# step 3: softmax 得到 attn
attn = nn.Softmax(dim=-1)(scores) # shape: [batch_size, n_heads, len_q, len_k]
# step 4: attn * v
context = torch.matmul(attn, v) # shape: [batch_size, n_heads, len_q, head_dim]
return context, attn
if __name__ == "__main__":
# 用户偏好 Query
q = torch.tensor([[[[0.9, 0.8]]]])
# 三家饭店的 Key: [麻辣程度, 便宜程度]
k = torch.tensor(
[[[[0.1, 0.3], [0.9, 0.5], [0.3, 0.1]]]]
)
# 三家饭店的 Value: [评分, 出餐速度]
v = torch.tensor(
[[[[0.5, 0.9], [0.9, 0.6], [0.7, 0.8]]]]
)
mask = None
attention = ScaledDotProductAttention(d_k=2)
context, attn_weights = attention(q, k, v, mask)
print("=== Attention Weights (α) ===")
print(attn_weights.squeeze())
print("\n=== Output Context (Weighted Value) ===")
print(context.squeeze())实测输出:
=== Attention Weights (α) ===
tensor([0.2579, 0.4805, 0.2616])
=== Output Context (Weighted Value) ===
tensor([0.7445, 0.7297])进一步验证一下,我不喜欢日式餐厅,可以把对应位置的 mask 设为 False:mask[:, :, :, 2] = False。
mask = torch.ones(1, 1, 1, 3, dtype=torch.bool)
mask[:, :, :, 2] = False7.3 多头注意力机制实现
在上面的基础上,我们实现多头注意力机制。
import torch
import torch.nn as nn
import numpy as np
from minigpt.attention_manual import ScaledDotProductAttention
class MultiHeadAttention(nn.Module):
def __init__(self, n_heads, d_model, d_k, d_v):
super(MultiHeadAttention, self).__init__()
self.n_heads = n_heads
self.d_model = d_model
self.d_k = d_k
self.d_v = d_v
# 线性变换层
self.w_q = nn.Linear(d_model, n_heads * d_k, bias=False)
self.w_k = nn.Linear(d_model, n_heads * d_k, bias=False)
self.w_v = nn.Linear(d_model, n_heads * d_v, bias=False)
# 输出层
self.fc = nn.Linear(n_heads * d_v, d_model, bias=False)
# 注意力模块
self.attention = ScaledDotProductAttention(d_k)
# LayerNorm层
self.layer_norm = nn.LayerNorm(d_model)
def forward(self, q, k, v, attn_mask=None):
"""
Args:
q: [batch_size, len_q, d_model]
k: [batch_size, len_k, d_model]
v: [batch_size, len_v, d_model] (len_k == len_v)
attention_mask: [batch_size, len_q, len_k] or None
Returns:
output: [batch_size, len_q, d_model]
attn: [batch_size, n_heads, len_q, len_k]
"""
residual = q
batch_size = q.size(0)
q_proj = self.w_q(q).view(batch_size, -1, self.n_heads, self.d_k)
k_proj = self.w_k(k).view(batch_size, -1, self.n_heads, self.d_k)
v_proj = self.w_v(v).view(batch_size, -1, self.n_heads, self.d_v)
q_proj = q_proj.transpose(1, 2)
k_proj = k_proj.transpose(1, 2)
v_proj = v_proj.transpose(1, 2)
if attn_mask is not None:
attn_mask = attn_mask.unsqueeze(1).expand(-1, self.n_heads, -1, -1)
else:
attn_mask = None
context, attn = self.attention(q_proj, k_proj, v_proj, attn_mask)
context = (
context.transpose(1, 2)
.contiguous()
.view(batch_size, -1, self.n_heads * self.d_v)
)
output = self.fc(context)
output = self.layer_norm(output + residual)
return output, attn
if __name__ == "__main__":
torch.manual_seed(42) # 固定种子,让输出可复现
k_v_input = torch.tensor(
[
[
[0.1, 0.3, 0.8, 0.5], # 粤菜馆
[0.9, 0.5, 0.2, 0.9], # 川菜馆
[0.3, 0.1, 0.9, 0.7], # 日式餐厅
]
]
)
q_input = torch.tensor([[[0.9, 0.8, 0.3, 0.6]]])
# 测试1: 无 mask
mha = MultiHeadAttention(n_heads=2, d_model=4, d_k=2, d_v=2)
output, attn_weights = mha(q_input, k_v_input, k_v_input, attn_mask=None)
print("=== No Mask ===")
print("Output shape:", output.shape)
print("Attn shape:", attn_weights.shape)
print("Head 1 weights:", attn_weights[0, 0, 0].tolist())
print("Head 2 weights:", attn_weights[0, 1, 0].tolist())
# 测试2: 屏蔽日式餐厅 (index=2)
mask = torch.ones(1, 1, 3, dtype=torch.bool)
mask[0, 0, 2] = False
output2, attn_weights2 = mha(q_input, k_v_input, k_v_input, attn_mask=mask)
print("\n=== With Mask ===")
print("Head 1 weights:", attn_weights2[0, 0, 0].tolist())
print("Head 2 weights:", attn_weights2[0, 1, 0].tolist())实测输出(torch.manual_seed(42),两个头的权重差异很大 —— 因为这是随机初始化的投影矩阵,没有训练过,各头关注的位置纯属随机):
=== No Mask ===
Output shape: torch.Size([1, 1, 4])
Attn shape: torch.Size([1, 2, 1, 3])
Head 1 weights: [0.2903803288936615, 0.4066396653652191, 0.3029800057411194]
Head 2 weights: [0.35799482464790344, 0.28464946150779724, 0.3573557138442993]
=== With Mask ===
Head 1 weights: [0.41660258173942566, 0.5833974480628967, 0.0]
Head 2 weights: [0.5570653080940247, 0.44293472170829773, 0.0]mask 生效了:被屏蔽的日式餐厅权重变成 0,剩下的两个重新归一化到 1。
7.4 改造成 CausalSelfAttention
我们抛弃掉自己创建的注意力机制,改用 PyTorch 提供的实现(F.scaled_dot_product_attention),同时把整体的代码适配 miniGPT 的模型上。
import torch
import torch.nn as nn
import torch.nn.functional as F
from typing import List, Optional, Union
from dataclasses import dataclass
@dataclass
class GPTConfig:
"""
模型超参数配置类(使用 dataclass 简洁定义)
这是一个 GPT 风格的缩小配置;隐藏维度与 GPT-2 Small 相同,
但注意力头数和层数都更少。vocab_size 根据中文任务调整。
"""
n_embd: int = 768 # token embedding 维度(也即 hidden size)
n_head: int = 8 # 多头注意力头数(必须整除 n_embd)
n_layer: int = 6 # Transformer block 层数
dropout: float = 0.1 # 所有 dropout 层的丢弃率
block_size: int = 256 # 最大上下文长度(位置编码最大支持长度)
vocab_size: int = 4825 # 词表大小(根据实际 tokenizer 决定)
class CausalSelfAttention(nn.Module):
def __init__(self, config: GPTConfig):
super().__init__()
assert config.n_embd % config.n_head == 0
self.config = config
self.head_dim = config.n_embd // config.n_head
self.qkv_proj = nn.Linear(config.n_embd, 3 * config.n_embd)
self.out_proj = nn.Linear(config.n_embd, config.n_embd, bias=False)
self.resid_dropout = nn.Dropout(config.dropout)
def forward(
self, x: torch.Tensor, attention_mask: Optional[torch.Tensor] = None
) -> torch.Tensor:
B, T, C = x.shape
qkv = self.qkv_proj(x)
q, k, v = qkv.chunk(3, dim=-1)
q = q.view(B, T, self.config.n_head, self.head_dim).transpose(1, 2)
k = k.view(B, T, self.config.n_head, self.head_dim).transpose(1, 2)
v = v.view(B, T, self.config.n_head, self.head_dim).transpose(1, 2)
causal_mask = torch.tril(
torch.ones(T, T, dtype=torch.bool, device=x.device)
)[None, None, :, :]
if attention_mask is not None:
key_padding_mask = attention_mask.to(torch.bool)[:, None, None, :]
attn_mask = causal_mask & key_padding_mask
else:
attn_mask = causal_mask
y = F.scaled_dot_product_attention(
q, k, v,
attn_mask=attn_mask,
dropout_p=self.config.dropout if self.training else 0,
is_causal=False,
)
y = y.transpose(1, 2).contiguous().view(B, T, C)
y = self.resid_dropout(self.out_proj(y))
return y这里有两个关键设计:
- QKV 融合投影:
qkv_proj一次性算出 Q、K、V(3 * n_embd),再chunk(3, dim=-1)拆开。这比三个独立nn.Linear更快,显存访问也更连续。 - causal mask 与 padding mask 求与:
causal_mask是下三角,key_padding_mask标记真实 token,两者&之后,既看不到未来,也看不到 padding。
自测:
if __name__ == "__main__":
torch.manual_seed(42)
config = GPTConfig(n_embd=8, n_head=2, dropout=0.0)
attn = CausalSelfAttention(config)
x = torch.randn(2, 5, 8)
attention_mask = torch.tensor(
[[1, 1, 1, 0, 0], [1, 1, 1, 1, 0]]
)
output = attn(x, attention_mask=attention_mask)
print("Input shape:", x.shape)
print("Output shape:", output.shape)
print("Output (first sample, first token):", output[0, 0].detach().numpy())7.5 构建完整的 GPT 模型
剩下的模型结构相对简单,这里就不专门介绍了,我们直接构建整体的 GPT 模型。
前馈神经网络 MLP 层
class MLP(nn.Module):
"""
前馈神经网络(Feed-Forward Network, FFN)
GPT 标准结构:
Linear(n_embd, 4*n_embd) → GELU → Linear(4*n_embd, n_embd)
为什么是 4 倍?—— GPT 论文发现此比例在性能与计算间取得良好平衡
"""
def __init__(self, config: GPTConfig):
super().__init__()
self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd, bias=False)
self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd, bias=False)
self.dropout = nn.Dropout(config.dropout)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.c_fc(x)
x = F.gelu(x)
x = self.c_proj(x)
x = self.dropout(x)
return xTransformer 解码器块
class Block(nn.Module):
"""
Transformer 解码器块(Decoder Block)
结构(Pre-LN 风格):
x → LayerNorm → Attention → Add → LayerNorm → MLP → Add → Output
"""
def __init__(self, config: GPTConfig):
super().__init__()
self.ln_1 = nn.LayerNorm(config.n_embd)
self.attn = CausalSelfAttention(config)
self.ln_2 = nn.LayerNorm(config.n_embd)
self.mlp = MLP(config)
def forward(
self, x: torch.Tensor, attention_mask: Optional[torch.Tensor] = None
) -> torch.Tensor:
x = x + self.attn(self.ln_1(x), attention_mask)
x = x + self.mlp(self.ln_2(x))
return xTransformer 结构
class Transformer(nn.Module):
"""
包含:
- Token Embedding
- 可学习位置编码(Learned Positional Embedding)
- N 个 Block
- 最终 LayerNorm(Pre-LN 结构的一部分)
"""
def __init__(self, config: GPTConfig):
super().__init__()
self.config = config
self.wte = nn.Embedding(config.vocab_size, config.n_embd)
self.wpe = nn.Embedding(config.block_size, config.n_embd)
self.drop = nn.Dropout(config.dropout)
self.blocks = nn.ModuleList([Block(config) for _ in range(config.n_layer)])
self.ln_f = nn.LayerNorm(config.n_embd)
def forward(
self, input_ids: torch.Tensor, attention_mask: Optional[torch.Tensor] = None
) -> torch.Tensor:
B, T = input_ids.shape
assert (
T <= self.config.block_size
), f"序列长度 {T} 超出最大上下文长度 {self.config.block_size}"
tok_emb = self.wte(input_ids)
pos = torch.arange(0, T, dtype=torch.long, device=input_ids.device)
pos_emb = self.wpe(pos)
x = self.drop(tok_emb + pos_emb)
for block in self.blocks:
x = block(x, attention_mask)
x = self.ln_f(x)
return x完整的 GPT 模型
class GPTLMHeadModel(nn.Module):
"""
完整的 GPT 语言模型(含语言建模头)
关键特性:
- 权重绑定(Weight Tying):wte.weight = lm_head.weight
- 自回归语言建模:预测下一个 token
"""
def __init__(self, config: GPTConfig):
super().__init__()
self.config = config
self.transformer = Transformer(config)
self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False)
# 权重绑定:共享 embedding 与 lm_head 的权重矩阵
self.transformer.wte.weight = self.lm_head.weight
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, input_ids: torch.Tensor, attention_mask: Optional[torch.Tensor] = None
) -> torch.Tensor:
hidden_states = self.transformer(input_ids, attention_mask)
logits = self.lm_head(hidden_states)
return logits构建好后,模型结构如下:
GPTLMHeadModel(
(transformer): Transformer(
(wte): Embedding(4825, 768)
(wpe): Embedding(256, 768)
(drop): Dropout(p=0.1, inplace=False)
(blocks): ModuleList(
(0-5): 6 x Block(
(ln_1): LayerNorm((768,), eps=1e-05, elementwise_affine=True)
(attn): CausalSelfAttention(
(qkv_proj): Linear(in_features=768, out_features=2304, bias=True)
(out_proj): Linear(in_features=768, out_features=768, bias=False)
(resid_dropout): Dropout(p=0.1, inplace=False)
)
(ln_2): LayerNorm((768,), eps=1e-05, elementwise_affine=True)
(mlp): MLP(
(c_fc): Linear(in_features=768, out_features=3072, bias=False)
(c_proj): Linear(in_features=3072, out_features=768, bias=False)
(dropout): Dropout(p=0.1, inplace=False)
)
)
)
(ln_f): LayerNorm((768,), eps=1e-05, elementwise_affine=True)
)
(lm_head): Linear(in_features=768, out_features=4825, bias=False)
)实测参数量:
total params: 46403328 (46.40M)与原文的 “~46.4M” 完全一致。
8. GPT 模型训练
这部分仅做基础训练,因此没有进行大量优化。
import torch
from minigpt.qa_dataset import QADataset
from minigpt.tokenizer import Tokenizer
from torch.utils.data import DataLoader
from torch.utils.tensorboard import SummaryWriter
import torch.nn as nn
import os
from minigpt.model import GPTLMHeadModel, GPTConfig
from tqdm import tqdm
def train_epoch(model, train_loader, optimizer, criterion, device):
model.train()
total_loss = 0
num_batches = len(train_loader)
progress_bar = tqdm(
enumerate(train_loader),
total=num_batches,
desc=" Train",
leave=False,
unit="batch",
)
for batch_idx, batch in progress_bar:
input_ids = batch["input_ids"].to(device, non_blocking=True)
attention_mask = batch["attention_mask"].to(device, non_blocking=True)
targets = batch["targets"].to(device, non_blocking=True)
optimizer.zero_grad()
logits = model(input_ids=input_ids, attention_mask=attention_mask)
loss = criterion(logits.view(-1, logits.size(-1)), targets.view(-1))
loss.backward()
optimizer.step()
total_loss += loss.item()
progress_bar.set_postfix({"loss": f"{loss.item():.4f}"})
return total_loss / num_batches
@torch.no_grad()
def validate(model, val_loader, criterion, device):
model.eval()
total_loss = 0
num_batches = len(val_loader)
progress_bar = tqdm(
enumerate(val_loader),
total=num_batches,
desc=" Val",
leave=False,
unit="batch",
)
for batch_idx, batch in progress_bar:
input_ids = batch["input_ids"].to(device, non_blocking=True)
attention_mask = batch["attention_mask"].to(device, non_blocking=True)
targets = batch["targets"].to(device, non_blocking=True)
logits = model(input_ids=input_ids, attention_mask=attention_mask)
loss = criterion(logits.view(-1, logits.size(-1)), targets.view(-1))
total_loss += loss.item()
progress_bar.set_postfix({"loss": f"{loss.item():.4f}"})
return total_loss / num_batches
def save_checkpoint(model, optimizer, epoch, path):
torch.save(
{
"epoch": epoch,
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
},
path,
)
def train_model(
model,
train_loader,
val_loader,
optimizer,
criterion,
device,
num_epochs,
model_output_dir,
writer,
):
os.makedirs(model_output_dir, exist_ok=True)
best_val_loss = float("inf")
for epoch in range(1, num_epochs + 1):
print(f"\nEpoch {epoch}/{num_epochs}")
train_loss = train_epoch(model, train_loader, optimizer, criterion, device)
print(f" Train Loss: {train_loss:.4f}")
val_loss = validate(model, val_loader, criterion, device)
print(f" Val Loss: {val_loss:.4f}")
if writer is not None:
writer.add_scalar("Loss/train", train_loss, epoch)
writer.add_scalar("Loss/val", val_loss, epoch)
if val_loss < best_val_loss:
best_val_loss = val_loss
best_path = os.path.join(model_output_dir, "best_model.pth")
save_checkpoint(model, optimizer, epoch, best_path)
print(f" 🎉 New best model saved (val loss: {val_loss:.4f})")
print(f"\n✅ Training finished. Best validation loss: {best_val_loss:.4f}")
def main():
train_path = "./data/train.jsonl"
val_path = "./data/val.jsonl"
vocab_path = "./data/vocab.json"
max_length = 140
batch_size = 64
lr = 1e-4
epochs = 15
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
if device.type == "cuda":
print(f"GPU: {torch.cuda.get_device_name(0)}")
tokenizer = Tokenizer(vocab_path)
config = GPTConfig(vocab_size=tokenizer.get_vocab_size())
model = GPTLMHeadModel(config).to(device)
train_dataset = QADataset(train_path, tokenizer, max_length)
val_dataset = QADataset(val_path, tokenizer, max_length)
train_loader = DataLoader(
train_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=2,
pin_memory=True,
drop_last=True,
)
val_loader = DataLoader(
val_dataset,
batch_size=batch_size,
shuffle=False,
num_workers=2,
pin_memory=True,
drop_last=False,
)
optimizer = torch.optim.AdamW(model.parameters(), lr=lr)
criterion = nn.CrossEntropyLoss(ignore_index=tokenizer.pad_token_id)
writer = SummaryWriter("runs/minigpt")
train_model(
model=model,
train_loader=train_loader,
val_loader=val_loader,
optimizer=optimizer,
criterion=criterion,
device=device,
num_epochs=epochs,
model_output_dir="output",
writer=writer,
)
writer.close()
print("\n🎉 Training pipeline completed.")
if __name__ == "__main__":
main()在原文的参数设置下,训练占用显存约 2.5 GB 左右。
8.1 实际跑起来的耗时
原文没有给出 CPU 上的耗时数据。实测(12 核 CPU、batch 64、max_length 140、完整 46.4M 模型):
| 指标 | 实测值 |
|---|---|
| 单步(step)耗时 | 15.7 ~ 21.3 s |
| 单 epoch(3,426 步) | ≈ 15 ~ 20 小时 |
| 15 epoch 全量训练 | ≈ 9 ~ 12 天 |
8.2 缩小规模的真实训练曲线
为了验证整条链路确实能收敛,把模型缩小后(n_embd 768→128、n_layer 6→2、n_head 8→4,参数 1.03M)在真实的 train/val 划分上训练,其余超参(lr=1e-4、batch=32、max_length=140、ignore_index=pad)与原文保持一致:
这条曲线验证了几件事:
- 整条链路是通的 —— 损失从 ln(4825) ≈ 8.48 起步(随机初始化时交叉熵约等于词表大小的对数),24 个 epoch 后降到 3.45,对应困惑度从 4825 降到 31.5。词表、Tokenizer、数据集、掩码、模型、损失函数配合正确。
- 没有过拟合 —— 验证损失全程跟随训练损失下降,24 个 epoch 内始终单调,没有出现拐点。这修正了“27 万条数据对 GPT 来说太少”的直觉:字符级模型每个样本提供的信号密度比词级模型高得多,1.03M 参数的小模型还远没到过拟合边界。
- 真正的瓶颈是算力,不是数据 —— 12 核 CPU 上跑完 24 个 epoch 花了约 1.5 小时(约 220 s/epoch)。要把这个实验推到原文的 46.4M 规模,瓶颈是 GPU 而不是数据量。
对应的困惑度(perplexity = exp(loss))变化:
| epoch | train loss | train ppl | val loss | val ppl |
|---|---|---|---|---|
| 1 | 6.8943 | 987 | 6.2347 | 510 |
| 4 | 4.9133 | 136 | 4.7013 | 110 |
| 8 | 4.2335 | 69 | 4.1270 | 62 |
| 16 | 3.7270 | 42 | 3.6771 | 40 |
| 24 | 3.4609 | 32 | 3.4496 | 32 |
8.3 推理出来的实际效果
用训练好的 checkpoint(第 24 个 epoch,val loss 3.45)走完整的 generate() 路径(greedy 解码,max_new_tokens=40):
loaded checkpoint from epoch 24
Q: 你好,最近怎么样?
A: 我没有一个问题,我没有具体的问题,我没有具体的问题。
Q: 你最喜欢的食物是什么?
A: 我没有一些有一些有趣的食物,我们可以尝试试试一些食物,我们可以帮助你的食物,我们
Q: 今天天气如何?
A: 我正在天气中,我会有时间的天气候变化,但不同时间,但不同时间的天气候会有时间的气单步预测的 top-5(输入 "你最喜欢"):
'我' logit= 8.843 prob=0.3579
'这' logit= 6.949 prob=0.0539
'当' logit= 6.802 prob=0.0465
'听' logit= 6.641 prob=0.0396
'作' logit= 6.334 prob=0.0291对比第 8 个 epoch 的输出(“我没有一个人感到我可以!”),第 24 个 epoch 的结果明显更连贯:出现了“我没有一个问题,我没有具体的问题”这样的重复结构和“我们可以尝试试试一些食物”这样的语义片段。语法是通的,也开始出现轻微的复读倾向 —— 这正是小模型在有限数据上的典型表现。
9. 模型推理
训练好模型后,推理是一个自回归循环:每一步把当前已生成的所有 token 喂回模型,取最后一个位置的 logits,用某种解码策略挑出下一个 token,再拼回输入末尾,直到遇到 sep 或达到最大长度。
def generate(
self,
input_ids: torch.Tensor,
max_new_tokens: int = 20,
stop_token_ids: Optional[Union[int, List[int]]] = None,
temperature: float = 1.0,
top_k: Optional[int] = None,
do_sample: bool = False,
) -> torch.Tensor:
"""
自回归文本生成方法(支持多种解码策略)
"""
self.eval()
device = input_ids.device
B, T = input_ids.shape
stop_tokens = set()
if stop_token_ids is not None:
if isinstance(stop_token_ids, int):
stop_tokens.add(stop_token_ids)
else:
stop_tokens.update(stop_token_ids)
stop_tensor = None
if stop_tokens:
stop_tensor = torch.tensor(list(stop_tokens), device=device, dtype=input_ids.dtype)
generated = input_ids.clone()
finished = torch.zeros(B, dtype=torch.bool, device=device)
with torch.no_grad():
for _ in range(max_new_tokens):
if finished.all() or generated.size(1) >= self.config.block_size:
break
logits = self(generated)
next_token_logits = logits[:, -1, :]
if do_sample:
if temperature <= 0:
raise ValueError("temperature must be > 0")
next_token_logits = next_token_logits / temperature
if top_k is not None and top_k > 0:
k = min(top_k, next_token_logits.size(-1))
values, _ = torch.topk(next_token_logits, k, dim=-1)
threshold = values[:, -1:]
next_token_logits = torch.where(
next_token_logits < threshold,
torch.full_like(next_token_logits, float('-inf')),
next_token_logits
)
probs = F.softmax(next_token_logits, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
else:
next_token = torch.argmax(next_token_logits, dim=-1, keepdim=True)
last_token = generated[:, -1:].clone()
next_token = torch.where(finished.unsqueeze(1), last_token, next_token)
generated = torch.cat([generated, next_token], dim=1)
if stop_tensor is not None:
is_stop = (next_token == stop_tensor).any(dim=1)
finished = finished | is_stop
return generated9.1 使用 GPT 生成文本
import torch
from minigpt.tokenizer import Tokenizer
from minigpt.model import GPTLMHeadModel, GPTConfig
def generate(model, tokenizer, question, max_new_tokens, device):
"""外部问答生成函数(使用模型内部的 generate 方法)"""
tokens, _ = tokenizer.encode(
question, answer=None, pad_to_max_length=False
)
input_ids = torch.tensor(tokens, dtype=torch.long, device=device).unsqueeze(0)
sep_id = tokenizer.sep_token_id
pad_id = tokenizer.pad_token_id
output_ids = model.generate(
input_ids=input_ids,
max_new_tokens=max_new_tokens,
stop_token_ids=[sep_id, pad_id],
do_sample=False,
)
generated_ids = output_ids[0].tolist()
try:
first_sep = generated_ids.index(sep_id)
answer_ids = generated_ids[first_sep + 1:]
except ValueError:
answer_ids = generated_ids[len(tokens):]
answer_clean = []
for tok in answer_ids:
if tok != sep_id and tok != pad_id:
answer_clean.append(tok)
else:
break
return tokenizer.decode(answer_clean)
def main():
vocab_path = "./data/vocab.json"
max_new_tokens = 140
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model_path = "output/best_model.pth"
tokenizer = Tokenizer(vocab_path)
config = GPTConfig(vocab_size=tokenizer.get_vocab_size())
model = GPTLMHeadModel(config).to(device)
checkpoint = torch.load(model_path, map_location=device)
model.load_state_dict(checkpoint["model_state_dict"])
model.eval()
while True:
question = input("Q: ")
if question == "":
break
answer = generate(model, tokenizer, question, max_new_tokens, device)
print("A:", answer)
if __name__ == "__main__":
main()10. 实际跑出来的 5 个问题 + 1 个陷阱
这一节是本文相对原文的主要增量:每个问题都在真实数据上复现过,并给出可运行的修法。
10.1 generate() 对超长 prompt 静默返回空
现象:prompt 长度超过 block_size(256)时,generate() 一个 token 都不生成,但既不报错也不提示。
根因:循环开头有 if finished.all() or generated.size(1) >= self.config.block_size: break。当 prompt 本身就是 300 token 时,这个条件在第一次迭代就成立,函数直接返回原样的 prompt。
实测:
block_size = 256
short prompt (5 tok) -> output len 13 (new tokens: 8)
long prompt (301 tok) -> output len 301 (new tokens: 0) ← 静默失败修法:进入循环前显式检查并抛出明确的异常。
if T > block_size:
raise ValueError(
f"prompt 长度 {T} 超过 block_size {block_size};"
f"请截断 prompt,或在 GPTConfig 中调大 block_size"
)long prompt -> ValueError: prompt 长度 301 超过 block_size 256;请截断 prompt,或在 GPTConfig 中调大 block_size10.2 截断会丢掉答案的终止符 <sep>
现象:答案超出 max_length 时,末尾的 <sep> 被切掉,模型永远学不会“回答结束”这个信号。
根因:encode() 的截断是朴素的 tokens[:max_length],而 <sep> 恰好在序列最末尾。
实测(max_length=64,答案 300 字,完整序列 314 token):
real tokens: 64
last 6 real tokens: ['当', '长', '的', '回', '答', '这']
ends with <sep>? False ← 答案的终止符被截断修法:给 encode() 加一个 keep_eos 开关,为终止符预留一个位置。
if answer is not None:
body = [self.word2id.get(c, self.unk_token_id) for c in answer]
if keep_eos:
budget = max_length - len(tokens) - 1 # 给 <sep> 留一个位置
if budget < 0:
tokens = tokens[: max_length - 1]
budget = 0
tokens.extend(body[:budget])
tokens.append(self.sep_token_id)
else:
tokens.extend(body)
tokens.append(self.sep_token_id)修复后:
real tokens: 64
ends with <sep>? True
tail: '是一个相当长的回答'10.3 decode() 把 <unk> 字面量泄漏到输出里
现象:任何词表外的字符(emoji、罕见汉字)在解码后以字面量 <unk> 的形式出现在文本里。
实测:
ids: [1, 1, 2] ← 1 是 <unk>
原版 decode: '<unk><unk><sep>'修法:decode() 跳过所有特殊 token。
def decode(self, ids, skip_special: bool = True) -> str:
out = []
for i in ids:
token = self.id2word[int(i)]
if token == self.id2word[self.pad_token_id]:
continue
if skip_special and token in ("<pad>", "<unk>", "<sep>"):
continue
out.append(token)
return "".join(out)10.4 block_size=256 与训练长度不匹配
现象:wpe(位置嵌入)有 256 行,但训练时 max_length=140,位置 139~255 从未拿到过梯度。
推理时的实际影响:§9.1 的 max_new_tokens = 140,加上 prompt,序列可以增长到约 160 —— 也就是说位置 140~160 这张表在推理时会被用到,但从未被训练过。
实测:
GPTConfig.block_size default = 256
train max_length used in §8 = 140
generate max_new_tokens in §9.1 = 140
QADataset 序列长度 = max_length = 140
input_ids 长度 = 139
wpe 的位置索引范围 = 0..138
=> wpe 行 139..255(共 117 行)训练时从未被索引
prompt= 10 tok → 序列最长 150 → 未训练位置 140..149
prompt= 20 tok → 序列最长 160 → 未训练位置 140..159
prompt= 30 tok → 序列最长 170 → 未训练位置 140..169
prompt= 50 tok → 序列最长 190 → 未训练位置 140..189这 117 行位置嵌入是随机初始化的。推理时序列一旦超过 140,就等于在用未训练的向量做位置编码 —— 生成的文本在结构上会开始“漂移”。
修法:要么把 block_size 设成和 max_length 一致(140),要么把 max_length 提到 256(但训练成本会涨约 3 倍)。本文的缩小版训练就采用了 block_size = max_length = 140。
10.5 torch.load 的默认行为正在变
现象:PyTorch 2.6 起,torch.load 的 weights_only 默认值会从 False 翻成 True。§9.1 的 torch.load(model_path, map_location=device) 在未来版本会失败(checkpoint 里除了张量还有 epoch 这样的 int 和 optimizer state)。
当前实测(PyTorch 2.5.1):
torch.load(path, map_location=...) -> OK, keys: ['epoch', 'model_state_dict', 'optimizer_state_dict']
FutureWarning: ... In a future release, the default value for `weights_only` will be flipped to `True`.修法:显式写清楚。
checkpoint = torch.load(model_path, map_location=device, weights_only=False)如果 checkpoint 只存张量,weights_only=True 是更安全的默认值。
10.6 整行被 mask 时,-1e9 会退化成均匀分布
这不是当前代码的 bug,而是一个潜伏的行为陷阱,值得在读 §7.2 的 -1e9 时就留意。
现象:当一行的所有 key 都被 mask 掉时,masked_fill(~mask, -1e9) 会让 softmax 得到一个均匀分布,而不是报错。
实测(三种写法的对比):
手写 masked_fill(-1e9) + Softmax(整行全 mask):
[0.3333, 0.3333, 0.3333] -> 等价于均匀关注所有 key
手写 masked_fill(-inf) + Softmax(整行全 mask):
[nan, nan, nan] -> NaN
PyTorch F.scaled_dot_product_attention(整行全 mask):
[0.0, 0.0, 0.0] -> 全 0三种行为都不报错,但含义完全不同:均匀分布意味着模型会平等地关注包括 padding 在内的所有位置,这比 NaN 更难发现。
当前是否会触发:CausalSelfAttention 里 attn_mask = causal_mask & key_padding_mask,第一列恒为真实 token(Tokenizer 走右 padding),所以训练时不会触发。但换成左 padding(比如为了配合 batch 推理),第 0 行就会整行被 mask。
规避办法:在 mask 里至少保留一个可见位置(例如把每行的第一个位置强制置 True),或在输出后加一道 torch.nan_to_num 兜底。
10.7 核对下来没问题的几处
为免误导,这里列出实测后确认原实现正确的地方:
| 位置 | 结论 |
|---|---|
top_k 过滤 |
next_token_logits < threshold 恰好保留 k 个候选,与 HuggingFace 的 TopKLogitsWarper 行为一致,没有 off-by-one |
| 权重绑定后的参数量 | sum(p.numel() for p in model.parameters()) 会自动去重,46.40M 是准确值,不是把 wte 算了两遍 |
§7.1 手算的 softmax |
未缩放口径下 0.2257 / 0.5441 / 0.2302 完全正确,与代码输出 0.2579 / 0.4805 / 0.2616 的差异全部来自 √d_k 缩放 |
§6 的样本 decode |
train.jsonl 第 0 条确实是“枣树生长的产物分类为何类?”,移位后的 targets 输出与原文一致 |
11. 小结
MiniGPT 的价值不在于它多强 —— 46.4M 参数、27 万条问答,训练出来的模型也就是个“会复读的鹦鹉”。它的价值在于把 Transformer 的每个零件都拆开摆出来,并且每一段代码都能跑。
这次把它完整重跑一遍,最有价值的收获是:
- 数据:27 万条真实中文问答,字符级词表 4,825 token,95.21% 的序列在 140 token 内。
- 模型:46.40M 参数(
sum(p.numel() for p in model.parameters())实测),6 层 8 头,Pre-LN + 权重绑定。 - 训练:CPU 上单步 15.7 ~ 21.3 s,全量 15 epoch ≈ 9 ~ 12 天;缩小到 1.03M 参数后跑满 24 epoch 仍没有出现验证损失拐点 —— 瓶颈是算力,不是数据。
- 缺陷:5 个真实问题 + 1 个潜伏陷阱。其中
generate()的静默失败和截断丢终止符是最值得记住的两个 —— 前者会让你以为是模型没训练好,后者会让模型永远学不会停止。
最该记住的一条
截断长度 max_length 必须为终止符预留位置,否则模型学不会“结束”。这是所有自回归训练里最常见也最难 debug 的坑。
最隐蔽的一条
generate() 里任何“提前 return”的分支,都会把 bug 变成静默失败。宁可抛异常,也不要让调用方拿到空结果。
12. 复现清单
# 1. 依赖
pip install torch numpy tqdm tensorboard matplotlib
# 2. 数据(58.9 MB)
# https://modelscope.cn/datasets/qiaojiedongfeng/qiaojiedongfeng/summary
# 下载 train.jsonl 放到 ./data/data.jsonl
# 3. 词表(实测 10.5 s)
python ./minigpt/build_vocab.py --data ./data/data.jsonl --output ./data/vocab.json
# 4. 划分(实测 4.5 s)
python ./minigpt/split_data.py --input ./data/data.jsonl --train_ratio 0.8
# 5. 数据统计
python -m minigpt.dataset_stats --train_data ./data/train.jsonl --vocab_path ./data/vocab.json
# 6. 训练(GPU 建议;CPU 上单步约 16~21 s)
python -m minigpt.train
# 7. 推理
python ./minigpt/infer.py全部代码在 minigpt/ 目录下,8 个文件:build_vocab.py、split_data.py、tokenizer.py、qa_dataset.py、dataset_stats.py、model.py、train.py、infer.py。