mini_lm/day1_char_rnn.py

157 lines
4.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import math
import random
import time
from dataclasses import dataclass
import torch
import torch.nn as nn
import torch.nn.functional as F
TEXT = (
"起初只有一个简单的故事。"
"它不长,但足够教会模型去猜下一个字符。"
"我们会在这段文字上训练一个很小的语言模型,并观察它学习。"
"实践胜过空谈,所以先从小处开始,不断迭代。"
"很快我们会用更大的数据替换它。"
"现在先保持小而清晰。"
)
@dataclass
class Config:
# 序列长度(模型一次看到的字符数)
seq_len: int = 64
# 批大小
batch_size: int = 32
# 字符嵌入维度
embed_dim: int = 64
# GRU 隐藏层维度
hidden_dim: int = 128
# GRU 层数
num_layers: int = 1
# 学习率
lr: float = 2e-3
# 训练步数
steps: int = 800
# 采样频率
sample_every: int = 200
# 采样长度
sample_len: int = 200
# 采样温度(越高越随机)
temperature: float = 0.9
class CharRNN(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers):
super().__init__()
# 字符索引 -> 向量
self.embed = nn.Embedding(vocab_size, embed_dim)
# GRU 用于处理序列上下文
self.rnn = nn.GRU(
embed_dim,
hidden_dim,
num_layers=num_layers,
batch_first=True,
)
# 隐藏状态 -> 下一个字符的 logits
self.fc = nn.Linear(hidden_dim, vocab_size)
def forward(self, x, h=None):
# x: (batch, seq_len)
x = self.embed(x)
# out: (batch, seq_len, hidden_dim)
out, h = self.rnn(x, h)
# logits: (batch, seq_len, vocab_size)
logits = self.fc(out)
return logits, h
def build_vocab(text):
# 构建字符表与双向映射
chars = sorted(set(text))
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for ch, i in stoi.items()}
return chars, stoi, itos
def encode(text, stoi):
# 文本 -> 索引序列
return torch.tensor([stoi[ch] for ch in text], dtype=torch.long)
def get_batch(data, cfg):
# 随机抽取若干段序列,生成输入 x 和目标 yy 是 x 向后移一位)
max_start = len(data) - cfg.seq_len - 1
starts = torch.randint(0, max_start, (cfg.batch_size,))
x = torch.stack([data[s : s + cfg.seq_len] for s in starts])
y = torch.stack([data[s + 1 : s + cfg.seq_len + 1] for s in starts])
return x, y
def sample(model, stoi, itos, device, cfg, start="起初"):
# 从给定起始串开始,按概率逐字符采样
model.eval()
# 如果起始字符不在词表中,使用文本开头的字符兜底
safe_start = "".join(ch for ch in start if ch in stoi)
if not safe_start:
safe_start = TEXT[:1]
idx = torch.tensor([stoi[ch] for ch in safe_start], dtype=torch.long, device=device)
idx = idx.unsqueeze(0)
h = None
with torch.no_grad():
for _ in range(cfg.sample_len):
logits, h = model(idx, h)
# 只取最后一个位置的预测
logits = logits[:, -1, :] / cfg.temperature
probs = F.softmax(logits, dim=-1)
next_id = torch.multinomial(probs, num_samples=1)
idx = torch.cat([idx, next_id], dim=1)
out = "".join(itos[i] for i in idx[0].tolist())
return out
def main():
cfg = Config()
# 字符表与编码
chars, stoi, itos = build_vocab(TEXT)
data = encode(TEXT, stoi)
# Apple Silicon 上优先用 MPS
device = "mps" if torch.backends.mps.is_available() else "cpu"
model = CharRNN(len(chars), cfg.embed_dim, cfg.hidden_dim, cfg.num_layers).to(device)
opt = torch.optim.AdamW(model.parameters(), lr=cfg.lr)
start_time = time.time()
model.train()
for step in range(1, cfg.steps + 1):
x, y = get_batch(data, cfg)
x = x.to(device)
y = y.to(device)
logits, _ = model(x)
# 交叉熵:预测下一个字符
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), y.view(-1))
opt.zero_grad()
loss.backward()
# 梯度裁剪,防止梯度爆炸
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
if step % 100 == 0:
elapsed = time.time() - start_time
speed = step / max(elapsed, 1e-6)
print(f"step {step} | loss {loss.item():.4f} | {speed:.1f} steps/s")
if step % cfg.sample_every == 0:
print("-" * 60)
print(sample(model, stoi, itos, device, cfg))
print("-" * 60)
if __name__ == "__main__":
# 固定随机种子,便于复现
random.seed(42)
torch.manual_seed(42)
main()