1 参数

n_head = 4  # 多头注意力的头数
n_layer = 4  # transformer层数
dropout = 0.0  # dropout率

context_size = 32
embd_dim = 64
batch_size = 16
lr = 1e-3
epochs = 5000
eval_epochs = 200
eval_interval = 100

device = "cuda" if torch.cuda.is_available() else "cpu"

2 模型结构

import torch
import torch.nn as nn
import torch.nn.functional as F

from constant import *


class Attention(nn.Module):
    def __init__(self, head_dim, dropout=0.1):
        super().__init__()
        # QKV 线性变换层(无偏置项)
        self.Q = nn.Linear(embd_dim, head_dim, bias=False)  # 输入维度embd_dim → 头维度head_dim
        self.K = nn.Linear(embd_dim, head_dim, bias=False)
        self.V = nn.Linear(embd_dim, head_dim, bias=False)

        # 三角掩码(用于自注意力的因果掩码)
        self.register_buffer("mask", torch.tril(torch.ones(context_size, context_size)))  # 下三角掩码

        # Dropout层
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        B, T, C = x.shape  # B=批量大小, T=时间步长, C=嵌入维度

        # 计算QKV向量
        q, k, v = self.Q(x), self.K(x), self.V(x)  # (B, T, head_dim)

        # Attention计算
        weights = q @ k.transpose(-1, -2)  # 点积注意力分数 (B, T, T)
        weights *= C ** -0.5  # 缩放因子(防止梯度爆炸)

        # 应用掩码(将未来位置置为-inf)
        weights = weights.masked_fill(self.mask[:T, :T] == 0, float("-inf"))

        # 计算softmax得到权重分布
        weights = F.softmax(weights, dim=-1)
        weights = self.dropout(weights)  # Dropout

        # 加权求和得到输出
        out = weights @ v  # (B, T, head_dim)

        return out


class MultiAttention(nn.Module):
    def __init__(self, n_head, dropout=0.1):
        super().__init__()
        head_dim = embd_dim // n_head  # 每个头的维度

        # 创建多头注意力层列表
        self.heads = nn.ModuleList([
            Attention(head_dim, dropout) for _ in range(n_head)
        ])

        # 输出投影层(将多头结果合并为原始维度)
        self.proj = nn.Linear(embd_dim, embd_dim)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        # 并行计算所有头的注意力输出
        out = torch.cat([head(x) for head in self.heads], dim=-1)  # (B, T, embd_dim)

        # 投影合并并添加Dropout
        out = self.dropout(self.proj(out))
        return out


class FeedForward(nn.Module):
    def __init__(self, hidden_dim, dropout=0.1):
        super().__init__()
        # 前馈网络结构:Linear -> ReLU -> Linear -> Dropout
        self.ffn = nn.Sequential(
            nn.Linear(embd_dim, hidden_dim),  # 输入到隐藏层
            nn.ReLU(),
            nn.Linear(hidden_dim, embd_dim),  # 隐藏层到输出
            nn.Dropout(dropout)
        )

    def forward(self, x):
        return self.ffn(x)


class Block(nn.Module):
    def __init__(self, n_head, dropout=0.1):
        super().__init__()
        # 多头注意力层
        self.ma = MultiAttention(n_head, dropout)
        self.ln1 = nn.LayerNorm(embd_dim)  # 第一个层归一化

        # 前馈网络层
        self.ffn = FeedForward(embd_dim * 4, dropout)  # 隐藏层维度通常是4倍embd_dim
        self.ln2 = nn.LayerNorm(embd_dim)  # 第二个层归一化

    def forward(self, x):
        # 使用Pre-Normalization(先归一化后计算)
        # 多头注意力分支
        x = x + self.ma(self.ln1(x))  # 残差连接
        # 前馈网络分支
        x = x + self.ffn(self.ln2(x))  # 残差连接
        return x


class GPT(nn.Module):
    def __init__(self, vocab_size, n_layer, n_head, dropout=0.1):
        super().__init__()
        # 词嵌入和位置嵌入层
        self.token_embedding_table = nn.Embedding(vocab_size, embd_dim)  # 词表到嵌入空间
        self.position_embedding_table = nn.Embedding(context_size, embd_dim)  # 位置编码
        # transformer块堆叠
        self.blocks = nn.Sequential(*[
            Block(n_head, dropout) for _ in range(n_layer)
        ])
        self.ln = nn.LayerNorm(embd_dim)  # 最终归一化层
        # 预测层(将嵌入维度映射到词表大小)
        self.liner = nn.Linear(embd_dim, vocab_size)

    def forward(self, idx, y=None):
        B, T = idx.shape
        device = idx.device

        # 嵌入层组合(词嵌入+位置嵌入)
        token_emb = self.token_embedding_table(idx)
        pos_emb = self.position_embedding_table(torch.arange(T, device=device))
        x = token_emb + pos_emb

        # 通过所有Block层
        x = self.blocks(x)
        x = self.ln(x)  # 最终归一化

        # 预测层输出logits
        logits = self.liner(x)

        # 计算损失(如果标签y存在)
        if y is not None:
            B, T, C = logits.shape
            logits = logits.view(B * T, C)  # 展平为(B*T, C)
            y = y.view(B * T)  # 展平为(B*T)
            loss = F.cross_entropy(logits, y)
        else:
            loss = None

        return logits, loss

    def generate(self, idx, max_new_tokens):
        """生成文本的推理函数"""
        for _ in range(max_new_tokens):
            # 截断输入到context_size长度(防止超出位置编码范围)
            idx_cond = idx[:, -context_size:]

            # 前向计算得到最后一个token的logits
            logits, _ = self(idx_cond)
            logits = logits[:, -1, :]  # 取最后一个时间步

            # 生成概率分布并采样
            probs = F.softmax(logits, dim=-1)
            idx_next = torch.multinomial(probs, num_samples=1)  # 随机采样

            # 拼接新生成的token
            idx = torch.cat((idx, idx_next), dim=1)

        return idx  # 返回完整序列(包含原始输入和生成部分)

3 工具

3.1 数据预处理

with open('../data/tinyshakespeare.txt', 'r', encoding='utf-8') as f:
    text = f.read()

print("数据集中的总字符数: ", len(text))
# 文本中出现的所有的字符
chars = sorted(list(set(text)))
vocab_size = len(chars)
print("文本中出现的字符: ", ''.join(chars))
print("文本中出现的字符数量: ", vocab_size)

## 数据集划分以及数据分批
data = torch.tensor(encode(text), dtype=torch.long)
# 90%划分训练集和验证集
n = int(0.9 * len(data))
train_data = data[:n]
val_data = data[n:]

3.2 简易分词

# 简单的tokenizer实现
# 这是一个简单的tokenizer实现,就是简单的把文本中出现过的所有字符按顺序给予一个整数0~vocab_size-1
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for i, ch in enumerate(chars)}

# 简单的编码和解码规则
# 编码:给定一个字符串输出一个list的整数
encode = lambda s: [stoi[c] for c in s]
# 解码:给定一个list的整数输出一个字符串
decode = lambda l: ''.join([itos[i] for i in l])

3.3 数据切片

def get_batch(split):
# 生成一个batch的数据,x为输入,y是target
data = train_data if split == 'train' else val_data
ix = torch.randint(len(data) - context_size, (batch_size,))
# y相对于x移位一个token,因为训练的目标就是预测下一个token
x = torch.stack([data[i:i + context_size] for i in ix])
y = torch.stack([data[i + 1:i + context_size + 1] for i in ix])
x, y = x.to(device), y.to(device)
return x, y

3.4 模型验证

@torch.no_grad()
def estimate_loss(model):
out = {}
model.eval()
for split in ["train", "val"]:
losses = torch.zeros(eval_epochs)
for it in range(eval_epochs):
x, y = get_batch(split)
logits, loss = model(x, y)
losses[it] = loss
out[split] = losses.mean()
return out

4 模型训练

model = GPT(vocab_size, n_layer, n_head, dropout)
model = model.to(device)

optimizer = torch.optim.AdamW(model.parameters(), lr=lr)
train_losses = []
val_losses = []
for epoch in range(epochs):
    # 训练
    model.train()
    x, y = get_batch("train")
    logits, loss = model(x, y)
    optimizer.zero_grad(set_to_none=True)
    loss.backward()
    optimizer.step()

    # 评估
    if epoch % eval_interval == 0 or epoch == epochs - 1:
        losses = estimate_loss(model)
        print(f"step {epoch}: train loss {losses['train']:.4f}, val loss {losses['val']:.4f}")
        train_losses.append(losses["train"])
        val_losses.append(losses["val"])

plt.figure(figsize=(10, 5))
plt.plot(train_losses, label='Train Loss')
plt.plot(val_losses, label='Validation Loss')
plt.xlabel('Iteration')
plt.ylabel('Loss')
plt.title('Training and Validation Loss')
plt.legend()
plt.savefig("loss.png")
plt.show()

# 保存模型
torch.save(model.state_dict(), "output/nanoGPT_5000_epochs.pt")

5000 epoch 的 loss 曲线如下:

image-nvfp.png

5 测试生成

model_path = "./output/nanoGPT_5000_epochs.pt"
model = GPT(65, 4, 4, 0)
model.load_state_dict(torch.load(model_path))
model.eval()
model = model.to("cuda")

input = torch.zeros((1, 1), dtype=torch.long).to("cuda")
print(decode(model.generate(input, 1000)[0].tolist()))

生成效果展示,至少能拼单词了👀️ :

image-skdz.png