夜雨聆风学习资料网

ARTICLE · 1078626

AI + 量化开发工程师学习笔记 Day 16:从零组装 MiniGPT——第一次写出完整 GPT 模型

AI + 量化开发工程师学习笔记 Day 16:从零组装 MiniGPT——第一次写出完整 GPT 模型
终于到 Day16 了。
前 15 天我们一直在造零件,今天第一次把它们真正组装成:
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
Tokenizer 词表大小
max_seq_len
最大上下文长度
embed_dim
Token 隐藏向量维度
num_heads
Attention Head 数量
num_layers
Transformer Block 数量
ff_dim
FFN 中间层维度
dropout
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 今日作业

  1. MiniGPT 输入和输出 Shape 分别是什么?
  2. 为什么 embed_dim必须能被 num_heads整除?
  3. register_buffer()和 nn.Parameter最大区别是什么?
  4. Causal Mask 为什么是上三角?
  5. Transformer Block 为什么输入 [B, T, C],输出仍然是 [B, T, C]?
  6. FFN 为什么通常先扩大维度再压回来?
  7. LM Head 做了什么?
  8. 为什么 LM Head 后面没有 Softmax?
  9. 为什么现在的 MiniGPT 不能正常生成语言?
  10. num_layers增大主要会产生什么变化?
  11. embed_dim增大为什么会让参数量快速增加?
  12. 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 会重点解决四个概念:

  1. Language Model Dataset
  2. Context Length
  3. Sliding Window
  4. X / Y Shift

而且我们会开始准备真正用于训练 MiniGPT 的文本语料。

然后 Day18:

Dataset
+
MiniGPT
+
CrossEntropy
+
AdamW
+
Warmup
+
Gradient Clipping
+
Checkpoint

全部汇合。

到那一天:

我们的 MiniGPT 才会第一次真正开始“学习”。

Day16 至此完整结束。

相关学习资料