ARTICLE · 1078626
AI + 量化开发工程师学习笔记 Day 16:从零组装 MiniGPT——第一次写出完整 GPT 模型
classMiniGPT(nn.Module):
今天的目标非常明确:
从零实现一个可以接收 Token IDs、经过多层 Transformer、最终输出 Vocabulary Logits 的 MiniGPT。
但今天不正式训练。
按照我们的课程计划:
Day16 搭模型
Day17 制作语言模型 Dataset
Day18 真正训练
Day19 文本生成与评估
Day20 第一阶段综合项目
所以今天最重要的是:把模型本身彻底搞明白。
一、我们到底要造什么?
先看最终结构:
Token IDs
[B, T]
│
┌────────────┴────────────┐
↓ ↓
Token Embedding Position Embedding
[B, T, C] [T, C]
└────────────┬────────────┘
↓
+
↓
Dropout
↓
Transformer Block
↓
Transformer Block
↓
...
↓
LayerNorm
↓
LM Head
↓
Logits
[B, T, V]
这里:
B = Batch Size
T = Sequence Length
C = Embedding Dimension
V = Vocabulary Size
比如:
B = 4
T = 64
C = 128
V = 5000
那么:
输入:
[4, 64]
↓
模型
↓
输出:
[4, 64, 5000]
意思是:
4 个样本,每个样本 64 个位置,每个位置都预测 5000 个 Token 的 Logit。
这就是一个语言模型。
二、今天需要哪些组件?
我们前面其实都写过:
MiniGPT
│
├── TokenEmbedding Day3
├── PositionEmbedding Day9
│
├── TransformerBlock
│ │
│ ├── LayerNorm Day7
│ ├── MultiHeadAttention Day5~7
│ ├── CausalMask Day6
│ ├── Residual Day7
│ └── FeedForward Day7
│
├── Dropout Day14
│
├── Final LayerNorm
│
└── LM Head Day8 / Day10
所以今天几乎没有“神秘的新东西”。
真正困难的是:
把所有 Shape 和数据流正确连接起来。
三、先确定 MiniGPT 配置
真实 GPT 参数很多。
我们不希望到处写:
embed_dim=128
num_heads=4
num_layers=4
所以定义配置:
from dataclasses import dataclass
@dataclass
classGPTConfig:
vocab_size: int = 5000
max_seq_len: int = 128
embed_dim: int = 128
num_heads: int = 4
num_layers: int = 4
ff_dim: int = 512
dropout: float = 0.1
以后:
config = GPTConfig()
所有模型参数统一管理。
这是非常常见的工程设计。
四、这些参数分别是什么意思?
vocab_size | |
max_seq_len | |
embed_dim | |
num_heads | |
num_layers | |
ff_dim | |
dropout |
比如:
embed_dim = 128
num_heads = 4
那么:
head_dim
=
128 / 4
=
32
每个 Attention Head:
32 维
五、第一个组件:Multi-Head Causal Self-Attention
我们重新整理成一个正式版本。
classCausalSelfAttention(nn.Module):
def__init__(
self,
config
):
super().__init__()
assert (
config.embed_dim
%
config.num_heads
==
0
)
self.embed_dim = (
config.embed_dim
)
self.num_heads = (
config.num_heads
)
self.head_dim = (
config.embed_dim
//
config.num_heads
)
如果:
C = 128
H = 4
那么:
D = 32
六、Q / K / V 投影
以前写过:
self.q_proj = nn.Linear(C, C)
self.k_proj = nn.Linear(C, C)
self.v_proj = nn.Linear(C, C)
今天仍然这样写:
self.q_proj = nn.Linear(
self.embed_dim,
self.embed_dim
)
self.k_proj = nn.Linear(
self.embed_dim,
self.embed_dim
)
self.v_proj = nn.Linear(
self.embed_dim,
self.embed_dim
)
另外:
self.out_proj = nn.Linear(
self.embed_dim,
self.embed_dim
)
用于把多个 Head 拼回来以后重新融合。
七、Attention 也加入 Dropout
真实 Transformer 通常还会:
self.attn_dropout = nn.Dropout(
config.dropout
)
self.resid_dropout = nn.Dropout(
config.dropout
)
分别作用于:
Attention Weight
以及
Attention Output
八、Attention Forward
输入:
x
[B, T, C]
首先:
B, T, C = x.shape
Q / K / V:
Q = self.q_proj(x)
K = self.k_proj(x)
V = self.v_proj(x)
仍然:
[B, T, C]
九、拆 Head
Q = Q.view(
B,
T,
self.num_heads,
self.head_dim
)
得到:
[B, T, H, D]
然后:
Q = Q.transpose(
1,
2
)
得到:
[B, H, T, D]
K、V 一样。
这是今天必须再次熟悉的 Shape:
[B, T, C]
↓
[B, T, H, D]
↓
[B, H, T, D]
十、Attention Score
scores = (
Q
@
K.transpose(
-2,
-1
)
)
Shape:
[B, H, T, T]
然后:
scores = (
scores
/
math.sqrt(
self.head_dim
)
)
这就是:
(Q Kᵀ) / √d_k
十一、Causal Mask
Day6 学过:
当前位置不能看到未来
今天我们把 Mask 注册到模型里:
mask = torch.triu(
torch.ones(
config.max_seq_len,
config.max_seq_len
),
diagonal=1
).bool()
然后:
self.register_buffer(
"causal_mask",
mask
)
十二、为什么用 register_buffer()?
这是今天一个新的工程知识点。
Causal Mask:
是模型状态的一部分
但:
不需要训练
也就是说:
Parameter?
❌
需要跟模型一起移动到 CPU / MPS / GPU?
✅
所以非常适合:
register_buffer()
以后:
model.to("mps")
Mask 也会跟着移动。
但 Optimizer 不会把它当成可训练参数。
可以记:
Parameter
=
需要学习
Buffer
=
模型需要保存 / 使用
但不学习
十三、真正应用 Causal Mask
因为当前:
T
不一定等于:
max_seq_len
所以:
mask = self.causal_mask[
:T,
:T
]
然后:
scores = scores.masked_fill(
mask,
float("-inf")
)
于是未来:
Attention Weight
=
0
十四、Softmax + V
weights = F.softmax(
scores,
dim=-1
)
加入 Dropout:
weights = self.attn_dropout(
weights
)
然后:
output = (
weights
@
V
)
Shape:
[B, H, T, D]
十五、把 Head 拼回来
output = (
output
.transpose(1, 2)
.contiguous()
.view(
B,
T,
C
)
)
回到:
[B, T, C]
最后:
output = self.out_proj(
output
)
output = self.resid_dropout(
output
)
Attention 完成。
十六、第二个组件:Feed Forward
今天 FFN:
classFeedForward(nn.Module):
def__init__(
self,
config
):
super().__init__()
self.net = nn.Sequential(
nn.Linear(
config.embed_dim,
config.ff_dim
),
nn.GELU(),
nn.Linear(
config.ff_dim,
config.embed_dim
),
nn.Dropout(
config.dropout
)
)
defforward(self, x):
return self.net(x)
Shape:
[B, T, C]
↓
[B, T, FF]
↓
[B, T, C]
例如:
128
↓
512
↓
128
十七、第三个组件:Transformer Block
我们采用前面学过的:
Pre-Norm
classTransformerBlock(nn.Module):
def__init__(
self,
config
):
super().__init__()
self.norm1 = nn.LayerNorm(
config.embed_dim
)
self.attention = (
CausalSelfAttention(
config
)
)
self.norm2 = nn.LayerNorm(
config.embed_dim
)
self.ffn = FeedForward(
config
)
Forward:
defforward(self, x):
x = (
x
+
self.attention(
self.norm1(x)
)
)
x = (
x
+
self.ffn(
self.norm2(x)
)
)
return x
就是两句话:
x = x + Attention(Norm(x))
x = x + FFN(Norm(x))
这两句话现在你应该已经能真正看懂。
十八、终于开始 MiniGPT
classMiniGPT(nn.Module):
def__init__(
self,
config
):
super().__init__()
self.config = config
第一层:
Token Embedding
self.token_embedding = nn.Embedding(
config.vocab_size,
config.embed_dim
)
Shape:
[B, T]
↓
[B, T, C]
十九、Position Embedding
self.position_embedding = nn.Embedding(
config.max_seq_len,
config.embed_dim
)
Day9:
Position 0
Position 1
...
都有自己的向量。
二十、Embedding Dropout
self.embedding_dropout = nn.Dropout(
config.dropout
)
于是:
Token Embedding
+
Position Embedding
↓
Dropout
二十一、堆 Transformer Blocks
这是非常漂亮的一段:
self.blocks = nn.ModuleList([
TransformerBlock(
config
)
for _ in range(
config.num_layers
)
])
如果:
num_layers = 4
相当于:
Block 1
↓
Block 2
↓
Block 3
↓
Block 4
二十二、Final LayerNorm
self.final_norm = nn.LayerNorm(
config.embed_dim
)
经过所有 Blocks:
Transformer Blocks
↓
Final Norm
二十三、LM Head
最后:
self.lm_head = nn.Linear(
config.embed_dim,
config.vocab_size,
bias=False
)
也就是:
[B, T, C]
↓
Linear
↓
[B, T, V]
终于得到:
Logits
二十四、MiniGPT Forward
完整逻辑:
defforward(
self,
token_ids
):
B, T = token_ids.shape
先检查:
if T > self.config.max_seq_len:
raise ValueError(
"Sequence too long"
)
然后 Position:
positions = torch.arange(
T,
device=token_ids.device
)
Embedding:
token_vectors = (
self.token_embedding(
token_ids
)
)
position_vectors = (
self.position_embedding(
positions
)
)
相加:
x = (
token_vectors
+
position_vectors
)
Dropout:
x = self.embedding_dropout(
x
)
进入 Blocks:
for block in self.blocks:
x = block(x)
最后:
x = self.final_norm(
x
)
logits = self.lm_head(
x
)
return logits
二十五、现在把完整 MiniGPT 一次写出来
建议今天工程目录:
day16/
├── mini_gpt.py
└── test_model.py
下面这个 mini_gpt.py是今天真正要保留下来的代码。
import math
from dataclasses import dataclass
import torch
import torch.nn as nn
import torch.nn.functional as F
# ============================================================
# 1. Configuration
# ============================================================
@dataclass
classGPTConfig:
# Vocabulary 大小
vocab_size: int = 5000
# 最大 Sequence Length
max_seq_len: int = 128
# Embedding / Hidden Dimension
embed_dim: int = 128
# Attention Head 数量
num_heads: int = 4
# Transformer Block 数量
num_layers: int = 4
# FFN Hidden Dimension
ff_dim: int = 512
# Dropout
dropout: float = 0.1
# ============================================================
# 2. Multi-Head Causal Self-Attention
# ============================================================
classCausalSelfAttention(nn.Module):
def__init__(
self,
config
):
super().__init__()
# ----------------------------------------------------
# embed_dim 必须能够整除 num_heads
# ----------------------------------------------------
assert (
config.embed_dim
%
config.num_heads
==
0
)
self.embed_dim = (
config.embed_dim
)
self.num_heads = (
config.num_heads
)
self.head_dim = (
config.embed_dim
//
config.num_heads
)
# ----------------------------------------------------
# Q / K / V Projection
# ----------------------------------------------------
self.q_proj = nn.Linear(
self.embed_dim,
self.embed_dim
)
self.k_proj = nn.Linear(
self.embed_dim,
self.embed_dim
)
self.v_proj = nn.Linear(
self.embed_dim,
self.embed_dim
)
# ----------------------------------------------------
# Output Projection
# ----------------------------------------------------
self.out_proj = nn.Linear(
self.embed_dim,
self.embed_dim
)
# ----------------------------------------------------
# Dropout
# ----------------------------------------------------
self.attn_dropout = nn.Dropout(
config.dropout
)
self.resid_dropout = nn.Dropout(
config.dropout
)
# ----------------------------------------------------
# Causal Mask
#
# True 表示禁止 Attention。
# ----------------------------------------------------
mask = torch.triu(
torch.ones(
config.max_seq_len,
config.max_seq_len
),
diagonal=1
).bool()
# Mask 不是可训练参数,
# 但应该跟模型一起移动设备。
self.register_buffer(
"causal_mask",
mask
)
defforward(
self,
x
):
# x:
#
# [B, T, C]
B, T, C = x.shape
# ====================================================
# Q / K / V
# ====================================================
Q = self.q_proj(
x
)
K = self.k_proj(
x
)
V = self.v_proj(
x
)
# ====================================================
# Split Heads
#
# [B, T, C]
#
# ->
#
# [B, T, H, D]
#
# ->
#
# [B, H, T, D]
# ====================================================
Q = Q.view(
B,
T,
self.num_heads,
self.head_dim
).transpose(
1,
2
)
K = K.view(
B,
T,
self.num_heads,
self.head_dim
).transpose(
1,
2
)
V = V.view(
B,
T,
self.num_heads,
self.head_dim
).transpose(
1,
2
)
# ====================================================
# Attention Scores
#
# Q:
# [B, H, T, D]
#
# K.transpose:
# [B, H, D, T]
#
# Result:
# [B, H, T, T]
# ====================================================
scores = (
Q
@
K.transpose(
-2,
-1
)
)
# ====================================================
# Scaled Attention
# ====================================================
scores = (
scores
/
math.sqrt(
self.head_dim
)
)
# ====================================================
# Causal Mask
# ====================================================
mask = self.causal_mask[
:T,
:T
]
scores = scores.masked_fill(
mask,
float("-inf")
)
# ====================================================
# Softmax
# ====================================================
weights = F.softmax(
scores,
dim=-1
)
# ====================================================
# Attention Dropout
# ====================================================
weights = self.attn_dropout(
weights
)
# ====================================================
# Weighted Sum of V
#
# [B, H, T, T]
#
# @
#
# [B, H, T, D]
#
# =
#
# [B, H, T, D]
# ====================================================
output = (
weights
@
V
)
# ====================================================
# Merge Heads
#
# [B, H, T, D]
#
# ->
#
# [B, T, H, D]
#
# ->
#
# [B, T, C]
# ====================================================
output = (
output
.transpose(
1,
2
)
.contiguous()
.view(
B,
T,
C
)
)
# ====================================================
# Output Projection
# ====================================================
output = self.out_proj(
output
)
# ====================================================
# Residual Dropout
# ====================================================
output = self.resid_dropout(
output
)
return output
# ============================================================
# 3. Feed Forward Network
# ============================================================
classFeedForward(nn.Module):
def__init__(
self,
config
):
super().__init__()
self.net = nn.Sequential(
# ------------------------------------------------
# Expand
#
# C -> FF
# ------------------------------------------------
nn.Linear(
config.embed_dim,
config.ff_dim
),
# ------------------------------------------------
# Non-Linearity
# ------------------------------------------------
nn.GELU(),
# ------------------------------------------------
# Compress
#
# FF -> C
# ------------------------------------------------
nn.Linear(
config.ff_dim,
config.embed_dim
),
# ------------------------------------------------
# Dropout
# ------------------------------------------------
nn.Dropout(
config.dropout
)
)
defforward(
self,
x
):
return self.net(
x
)
# ============================================================
# 4. Transformer Block
# ============================================================
classTransformerBlock(nn.Module):
def__init__(
self,
config
):
super().__init__()
# ----------------------------------------------------
# Pre-Norm 1
# ----------------------------------------------------
self.norm1 = nn.LayerNorm(
config.embed_dim
)
# ----------------------------------------------------
# Multi-Head Causal Self-Attention
# ----------------------------------------------------
self.attention = (
CausalSelfAttention(
config
)
)
# ----------------------------------------------------
# Pre-Norm 2
# ----------------------------------------------------
self.norm2 = nn.LayerNorm(
config.embed_dim
)
# ----------------------------------------------------
# Feed Forward
# ----------------------------------------------------
self.ffn = FeedForward(
config
)
defforward(
self,
x
):
# ====================================================
# Attention
#
# x
# +
# Attention(
# LayerNorm(x)
# )
# ====================================================
x = (
x
+
self.attention(
self.norm1(
x
)
)
)
# ====================================================
# Feed Forward
#
# x
# +
# FFN(
# LayerNorm(x)
# )
# ====================================================
x = (
x
+
self.ffn(
self.norm2(
x
)
)
)
return x
# ============================================================
# 5. MiniGPT
# ============================================================
classMiniGPT(nn.Module):
def__init__(
self,
config
):
super().__init__()
self.config = config
# ====================================================
# Token Embedding
#
# Token ID
#
# [B, T]
#
# ->
#
# [B, T, C]
# ====================================================
self.token_embedding = nn.Embedding(
config.vocab_size,
config.embed_dim
)
# ====================================================
# Position Embedding
#
# Position:
#
# 0, 1, 2, ..., T-1
#
# ->
#
# [T, C]
# ====================================================
self.position_embedding = nn.Embedding(
config.max_seq_len,
config.embed_dim
)
# ====================================================
# Embedding Dropout
# ====================================================
self.embedding_dropout = nn.Dropout(
config.dropout
)
# ====================================================
# Transformer Blocks
# ====================================================
self.blocks = nn.ModuleList([
TransformerBlock(
config
)
for _ in range(
config.num_layers
)
])
# ====================================================
# Final LayerNorm
# ====================================================
self.final_norm = nn.LayerNorm(
config.embed_dim
)
# ====================================================
# Language Model Head
#
# [B, T, C]
#
# ->
#
# [B, T, V]
# ====================================================
self.lm_head = nn.Linear(
config.embed_dim,
config.vocab_size,
bias=False
)
defforward(
self,
token_ids
):
# token_ids:
#
# [B, T]
B, T = token_ids.shape
# ====================================================
# Sequence Length Check
# ====================================================
if T > self.config.max_seq_len:
raise ValueError(
f"Sequence length {T} "
f"exceeds max_seq_len "
f"{self.config.max_seq_len}"
)
# ====================================================
# Position IDs
# ====================================================
positions = torch.arange(
T,
device=token_ids.device
)
# ====================================================
# Token Embedding
# ====================================================
token_vectors = (
self.token_embedding(
token_ids
)
)
# [B, T, C]
# ====================================================
# Position Embedding
# ====================================================
position_vectors = (
self.position_embedding(
positions
)
)
# [T, C]
# ====================================================
# Token + Position
#
# Broadcasting:
#
# [B, T, C]
# +
# [T, C]
#
# =
#
# [B, T, C]
# ====================================================
x = (
token_vectors
+
position_vectors
)
# ====================================================
# Embedding Dropout
# ====================================================
x = self.embedding_dropout(
x
)
# ====================================================
# Transformer Blocks
# ====================================================
for block in self.blocks:
x = block(
x
)
# ====================================================
# Final Norm
# ====================================================
x = self.final_norm(
x
)
# ====================================================
# Language Model Head
# ====================================================
logits = self.lm_head(
x
)
# logits:
#
# [B, T, V]
return logits
# ============================================================
# 6. Parameter Count Utility
# ============================================================
defcount_parameters(
model
):
return sum(
parameter.numel()
for parameter
in model.parameters()
if parameter.requires_grad
)
到这里,我们的:
mini_gpt.py
才真正完整。
二十六、先不要训练,先测试模型能不能 Forward
创建:
test_model.py
完整代码:
import torch
from mini_gpt import (
GPTConfig,
MiniGPT,
count_parameters
)
# ============================================================
# 1. Random Seed
# ============================================================
torch.manual_seed(42)
# ============================================================
# 2. Config
# ============================================================
config = GPTConfig(
vocab_size=5000,
max_seq_len=128,
embed_dim=128,
num_heads=4,
num_layers=4,
ff_dim=512,
dropout=0.1
)
# ============================================================
# 3. Create Model
# ============================================================
model = MiniGPT(
config
)
# ============================================================
# 4. Print Model
# ============================================================
print(
"============================"
)
print(
"MiniGPT"
)
print(
"============================"
)
print(
model
)
# ============================================================
# 5. Parameter Count
# ============================================================
parameter_count = count_parameters(
model
)
print(
"\nParameter Count:"
)
print(
f"{parameter_count:,}"
)
print(
f"{parameter_count / 1_000_000:.2f} M"
)
# ============================================================
# 6. 模拟 Token IDs
#
# Batch = 2
# Sequence = 16
# ============================================================
token_ids = torch.randint(
low=0,
high=config.vocab_size,
size=(
2,
16
)
)
print(
"\nInput Shape:"
)
print(
token_ids.shape
)
# ============================================================
# 7. Forward
# ============================================================
model.eval()
with torch.no_grad():
logits = model(
token_ids
)
# ============================================================
# 8. Output Shape
# ============================================================
print(
"\nLogits Shape:"
)
print(
logits.shape
)
# ============================================================
# 9. 检查预期 Shape
# ============================================================
expected_shape = (
2,
16,
config.vocab_size
)
assert (
logits.shape
==
expected_shape
)
print(
"\nShape Test Passed!"
)
运行:
python test_model.py
最重要的结果:
Input Shape:
torch.Size([2, 16])
Logits Shape:
torch.Size([2, 16, 5000])
Shape Test Passed!
如果看到这个:
我们的 MiniGPT 已经可以完整 Forward 了。
虽然还没有训练。
二十七、现在真正理解 [B, T, V]
例如:
[2, 16, 5000]
不是说模型生成了:
5000 个 Token
而是:
Batch 0
│
├── Position 0 → 5000 个候选 Token Logits
├── Position 1 → 5000 个候选 Token Logits
├── Position 2 → 5000 个候选 Token Logits
│
└── Position 15 → 5000 个候选 Token Logits
Batch 1
│
└── 同样如此
也就是:
每一个位置,都在做一次 Vocabulary 分类。
二十八、如果现在输入一句话,它会说人话吗?
不会。
这是今天必须理解的一点。
现在:
model = MiniGPT(config)
参数全部:
随机初始化
所以:
Attention
随机
Embedding
随机
FFN
随机
LM Head
随机
最终:
Logits
也是没有语言意义的。
所以:
模型结构正确
≠
模型已经有能力
能力来自:
Training
二十九、模型怎么从“随机”变成“会说话”?
Day17~18 就要做:
大量文本
↓
Tokenizer
↓
Token IDs
例如:
[10, 20, 30, 40, 50]
构造:
X:
[10, 20, 30, 40]
Y:
[20, 30, 40, 50]
进入:
MiniGPT(X)
↓
Logits
[B, T, V]
然后:
CrossEntropy(
logits,
Y
)
得到:
Loss
再:
Backward
↓
AdamW
↓
参数改变
重复成千上万次。
最终模型才逐渐:
学习 Token 关系 学习词序 学习上下文 学习语言模式
三十、现在可以把前面课程全部映射到代码
看看今天的 mini_gpt.py:
GPTConfig
│
↓
MiniGPT
│
├── nn.Embedding
│ ↑
│ Day3
│
├── Position Embedding
│ ↑
│ Day9
│
├── TransformerBlock × N
│ │
│ ├── LayerNorm
│ │ ↑
│ │ Day7
│ │
│ ├── CausalSelfAttention
│ │ │
│ │ ├── Q / K / V
│ │ │ ↑
│ │ │ Day5
│ │ │
│ │ ├── MultiHead
│ │ │ ↑
│ │ │ Day7
│ │ │
│ │ └── Causal Mask
│ │ ↑
│ │ Day6
│ │
│ ├── Residual
│ │ ↑
│ │ Day7
│ │
│ └── FFN
│ ↑
│ Day7
│
├── Dropout
│ ↑
│ Day14
│
├── Final LayerNorm
│
└── LM Head
↑
Day8 / Day10
这就是为什么我们没有在 Day7 直接冲去写 MiniGPT。
现在再写这段代码,每一块你都应该知道:
它为什么存在。
三十一、我们的 MiniGPT 有多少参数?
具体数字运行:
count_parameters(
model
)
就知道。
但我们可以先理解参数主要在哪里。
Token Embedding
5000 × 128
=
640,000
Position Embedding
128 × 128
=
16,384
Attention
每层主要:
Q
128 × 128
K
128 × 128
V
128 × 128
Output
128 × 128
大约:
4 × 16384
≈
65K
还没算 Bias。
FFN
128 → 512
512 → 128
大约:
128 × 512
+
512 × 128
≈
131K
所以 FFN 其实非常吃参数。
三十二、为什么 FFN 参数这么多?
很多初学者会以为:
Transformer 参数主要都在 Attention。
其实很多经典 Transformer 中:
FFN
也是参数大户。
因为通常:
ff_dim
≈
4 × embed_dim
于是两个 Linear:
C → 4C
4C → C
参数约:
8 C²
而标准 Attention 的 Q / K / V / O 投影大致:
4 C²
所以 FFN 的参数量可能比 Attention 投影还多。
这是一个很重要的认识。
三十三、LM Head 也很大
我们这里:
128 × 5000
=
640,000
所以:
Token Embedding
和:
LM Head
各有约:
64 万
参数。
实际 GPT 中有一种常见技巧:
Weight Tying
也就是:
Token Embedding Weight
和:
LM Head Weight
共享。
概念上:
self.lm_head.weight = (
self.token_embedding.weight
)
这样可以:
减少参数
让输入 / 输出 Token 表示共享
但今天我们先保持相互独立,便于理解。
等后面优化 MiniGPT 时,可以尝试:
self.lm_head.weight = (
self.token_embedding.weight
)
这叫:
Weight Tying / 权重共享
今天知道这个概念即可,不修改主模型。
三十四、一个重要问题:为什么 LM Head 不需要 Softmax?
我们:
logits = self.lm_head(
x
)
return logits
没有:
F.softmax(...)
是不是少了?
没有。
Day10 已经学过:
F.cross_entropy(
logits,
targets
)
应该直接接收:
Raw Logits
所以训练:
MiniGPT
↓
LM Head
↓
Raw Logits
↓
CrossEntropyLoss
而不是:
Logits
↓
Softmax
↓
CrossEntropyLoss
以后 Day18 会真正这样训练。
三十五、但生成文字的时候呢?
生成时我们可能需要:
Logits
↓
Temperature
↓
Softmax
↓
Probability
↓
Sampling
所以:
Training
和:
Generation
不要混淆。
Training
loss = F.cross_entropy(
logits,
targets
)
Generation
可能:
probs = F.softmax(
logits,
dim=-1
)
然后采样。
这个 Day19 会正式实现。
三十六、今日实践 1:改变 Layer 数量
修改:
num_layers=1
运行:
test_model.py
记录:
Parameter Count
然后:
num_layers=2
再:
num_layers=4
再:
num_layers=8
你会看到:
Layers ↑
↓
Parameters ↑
而且基本呈近似线性增加。
因为:
每增加一个 Block
就增加一套:
Q K V Out Projection FFN LayerNorm
参数。
三十七、今日实践 2:改变 Embedding Dimension
分别测试:
embed_dim = 64
ff_dim = 256
embed_dim = 128
ff_dim = 512
embed_dim = 256
ff_dim = 1024
注意参数量不会简单:
2 倍
因为很多 Linear 参数:
C × C
所以:
Embedding Dimension × 2
某些部分参数量可能大约:
× 4
这就是为什么:
模型宽度对参数量影响非常大。
三十八、今日实践 3:故意制造错误
我很建议你做。
设置:
embed_dim=128
num_heads=3
运行。
会触发:
assert (
config.embed_dim
%
config.num_heads
==
0
)
为什么?
因为:
128 / 3
不能均匀分给三个 Head。
所以必须满足:
embed_dim % num_heads == 0
例如:
128 / 4 = 32 ✓
128 / 8 = 16 ✓
128 / 3 ✗
三十九、今日实践 4:测试最大上下文
我们的:
max_seq_len=128
所以:
token_ids = torch.randint(
0,
config.vocab_size,
(2, 128)
)
可以。
但:
token_ids = torch.randint(
0,
config.vocab_size,
(2, 129)
)
会触发:
Sequence length 129
exceeds max_seq_len 128
现在你应该真正理解 Day9 的:
Position Embedding
Context Length
为什么会进入模型代码。
四十、今日实践 5:观察 Causal Mask
可以临时增加:
print(
model
.blocks[0]
.attention
.causal_mask[:8, :8]
)
你会看到类似:
False True True True True ...
False False True True True ...
False False False True True ...
...
解释:
False
=
允许看
True
=
禁止看
于是:
Token0
只能看 Token0
Token1
可以看 Token0、Token1
Token2
可以看 Token0、Token1、Token2
这就是 Day6 的 Causal Mask 现在真正进入我们的 GPT。
四十一、建议你用 Debugger 看一次完整 Shape
这对你这种程序员尤其重要。
在:
CausalSelfAttention.forward()
里面打断点。
输入假设:
B = 2
T = 16
C = 128
H = 4
D = 32
V = 5000
依次观察:
token_ids
[2, 16]
↓
Token Embedding
[2, 16, 128]
↓
Q / K / V
[2, 16, 128]
↓
拆 Head
[2, 4, 16, 32]
↓
Attention Scores
[2, 4, 16, 16]
↓
Attention Output
[2, 4, 16, 32]
↓
Merge Heads
[2, 16, 128]
↓
Transformer Block
[2, 16, 128]
↓
4 层后仍然:
[2, 16, 128]
↓
LM Head:
[2, 16, 5000]
如果你能不看代码把这一整套 Shape 推出来:
Day5~Day16 的 Transformer 主干就真的吃透了。
四十二、我们今天为什么没有把 Loss 写进 MiniGPT.forward()?
很多 GPT 实现会写:
defforward(
self,
token_ids,
targets=None
):
...
if targets isnotNone:
loss = F.cross_entropy(...)
return logits, loss
这种写法完全可以。
但我们今天故意只:
return logits
因为我希望你把两个概念先严格分开:
Model
负责:
Token IDs → Logits
和:
Training System
负责:
Logits + Targets → Loss
Day18 我们再决定项目里采用哪种封装。
从工程设计上,两种都可以。
四十三、模型结构和训练系统是两件事
这是 Day16 很重要的认识。
模型
MiniGPT
Token IDs
↓
Logits
数据系统
Dataset
↓
DataLoader
↓
X / Y
训练系统
MiniGPT(X)
↓
Logits
↓
CrossEntropy(Logits, Y)
↓
Backward
↓
AdamW
↓
Scheduler
↓
Checkpoint
生成系统
Prompt
↓
MiniGPT
↓
Last Token Logits
↓
Sampling
↓
Next Token
↓
Append
↓
重复
最终工程其实是:
Tokenizer
+
Dataset
+
Model
+
Trainer
+
Generator
而不是只有一个 model.py。
四十四、这也正是 Day16~19 的结构
Day16
MODEL
MiniGPT
↓
Day17
DATA
LanguageModelDataset
↓
Day18
TRAINER
Train MiniGPT
↓
Day19
GENERATOR
Generate Text
到 Day20:
全部整理成一个完整项目
四十五、和未来量化模型做一个类比
以后第二阶段做 ML 选股系统,也可以拆成:
DATA
TuShare
↓
PE / PB / ROE
↓
Future Return Label
MODEL
LightGBM / CatBoost
TRAINER
Fit
↓
Validation
PREDICTOR
股票因子
↓
Expected Return
STRATEGY
Rank
↓
Top-K
↓
Portfolio
BACKTEST
净值
↓
Sharpe
↓
Drawdown
你会发现:
好的 AI / 量化工程都应该把数据、模型、训练、推理分开。
这个工程思维后面会一直沿用。
四十六、Day16 今日作业
MiniGPT 输入和输出 Shape 分别是什么? 为什么 embed_dim必须能被num_heads整除?register_buffer()和nn.Parameter最大区别是什么?Causal Mask 为什么是上三角? Transformer Block 为什么输入 [B, T, C],输出仍然是[B, T, C]?FFN 为什么通常先扩大维度再压回来? LM Head 做了什么? 为什么 LM Head 后面没有 Softmax? 为什么现在的 MiniGPT 不能正常生成语言? num_layers增大主要会产生什么变化?embed_dim增大为什么会让参数量快速增加?Model、Trainer 和 Generator 有什么区别?
四十七、作业参考答案
1. 输入输出 Shape
输入:
Token IDs
[B, T]
输出:
Logits
[B, T, V]
其中:
B = Batch
T = Sequence Length
V = Vocabulary Size
2. 为什么必须整除?
因为:
C
要均匀拆成:
H 个 Head
每个:
D = C / H
例如:
128 / 4 = 32
才能 reshape:
[B, T, 128]
↓
[B, T, 4, 32]
3. Buffer 和 Parameter
nn.Parameter:
参与训练 需要 Gradient Optimizer 会更新
Buffer:
属于模型状态 但不需要训练
例如:
Causal Mask
适合注册为 Buffer。
4. 为什么是上三角?
Attention Matrix:
行
=
当前 Query
列
=
它想看的 Key
当前 Token 右边的位置:
未来 Token
位于矩阵上三角区域。
所以:
上三角
→ Mask
阻止看到未来。
5. 为什么 Block Shape 不变?
Attention:
[B, T, C]
→
[B, T, C]
FFN:
[B, T, C]
→
[B, T, FF]
→
[B, T, C]
Residual 也要求维度匹配。
所以整个 Block:
[B, T, C]
→
[B, T, C]
因此可以连续堆很多层。
6. FFN 为什么扩维?
可以理解为:
C
↓
更大的特征空间
↓
非线性变换
↓
重新压缩回 C
增强每个 Token 表示的非线性处理能力。
7. LM Head
把:
Hidden State
C 维
映射到:
Vocabulary
V 维
即:
[B, T, C]
↓
[B, T, V]
让每个位置拥有整个词表的预测 Logits。
8. 为什么不 Softmax?
因为训练时:
F.cross_entropy(
logits,
targets
)
直接接受 Raw Logits,并内部完成数值稳定的相关计算。
所以模型输出保持:
Raw Logits
更合适。
9. 为什么还不会语言?
因为参数:
随机初始化
模型虽然:
结构正确
但还没有从文本数据中学习任何语言规律。
需要:
Dataset
↓
Loss
↓
Backward
↓
AdamW
↓
大量训练
10. num_layers 增大
意味着增加更多:
Transformer Blocks
因此通常:
参数量增加 计算量增加 显存增加 模型表达能力增加
但也更难训练,不代表无限增加一定更好。
11. 为什么 embed_dim 影响很大?
Attention 和 FFN 中很多矩阵规模类似:
C × C
如果:
C → 2C
矩阵参数:
C²
→
4 C²
所以模型宽度对参数量和计算量影响非常明显。
12. Model / Trainer / Generator
Model
Token IDs
↓
Logits
负责神经网络 Forward。
Trainer
Dataset
↓
Model
↓
Loss
↓
Backward
↓
Optimizer
负责让模型学习。
Generator
Prompt
↓
Model
↓
Next Token
↓
Append
↓
重复
负责使用训练后的模型生成文本。
四十八、今天建议真正做的事情
今天不要急着训练。
建议你真正完成:
day16/
├── mini_gpt.py
└── test_model.py
然后做四组实验:
① Forward Shape
[B, T]
→
[B, T, V]
② num_layers
1 / 2 / 4 / 8
③ embed_dim
64 / 128 / 256
④ max_seq_len
128 正常
129 触发错误
最后用 VSCode Debugger 单步走一次:
Token IDs
↓
Embedding
↓
Q / K / V
↓
Split Heads
↓
Attention Scores
↓
Causal Mask
↓
Softmax
↓
Attention Output
↓
Merge Heads
↓
FFN
↓
Transformer Blocks
↓
LM Head
↓
Logits
这比今天再多写 500 行代码都有价值。
Day 16 最重要的一句话
GPT 并不是一个神秘的整体,它就是 Token / Position Embedding + 多层 Causal Transformer Block + Final LayerNorm + LM Head;真正让这些结构拥有语言能力的,是后续的数据和训练。
把今天整个模型压缩成一张图:
Token IDs
[B, T]
│
┌─────────────┴─────────────┐
↓ ↓
Token Embedding Position Embedding
[B, T, C] [T, C]
└─────────────┬─────────────┘
+
↓
Dropout
↓
┌──────────────────┐
│ Transformer Block│
│ │
│ LayerNorm │
│ ↓ │
│ Causal MHA │
│ ↓ │
│ Residual │
│ ↓ │
│ LayerNorm │
│ ↓ │
│ FFN │
│ ↓ │
│ Residual │
└────────┬─────────┘
│
× N
│
↓
Final LayerNorm
↓
LM Head
↓
Logits
[B, T, V]
而一个 Transformer Block 可以压缩成两行:
x = x + attention(norm1(x))
x = x + ffn(norm2(x))
这两行实际上就是今天 MiniGPT 最核心的积木。
Day16 到底完成了什么?
我们现在已经拥有:
mini_gpt.py
它可以真正执行:
token_ids = torch.randint(
0,
5000,
(2, 16)
)
logits = model(
token_ids
)
得到:
Input:
[2, 16]
↓
MiniGPT
↓
Output:
[2, 16, 5000]
所以:
模型结构已经完成。
但现在模型的状态仍然是:
结构:✅
Forward:✅
Causal Attention:✅
输出 Logits:✅
语言能力:❌
训练:❌
生成:❌
原因很简单:
所有 Weight
≈
随机数
下一步就是想办法让这些随机参数学习语言。
预告:Day 17
现在假设我们有文本:
人工智能正在改变软件开发方式,
越来越多的程序员开始使用 AI 辅助编程……
不能直接:
model("人工智能...")
MiniGPT 只认识:
Token IDs
而训练还需要:
Input X
Target Y
所以 Day17 要建立:
Language Model Dataset
完整过程:
原始文本
↓
Tokenizer
↓
一条很长的 Token 流
[12, 38, 71, 9, 42, 16, 87, ...]
↓
Sliding Window
↓
┌────────────┴────────────┐
↓ ↓
X Y
[12, 38, 71, 9] [38, 71, 9, 42]
↓
DataLoader
↓
[B, T] + [B, T]
↓
MiniGPT
↓
[B, T, V]
↓
CrossEntropyLoss
Day17 会重点解决四个概念:
Language Model Dataset Context Length Sliding Window X / Y Shift
而且我们会开始准备真正用于训练 MiniGPT 的文本语料。
然后 Day18:
Dataset
+
MiniGPT
+
CrossEntropy
+
AdamW
+
Warmup
+
Gradient Clipping
+
Checkpoint
全部汇合。
到那一天:
我们的 MiniGPT 才会第一次真正开始“学习”。
Day16 至此完整结束。