ode-pessoana / model.py
vreabernardo's picture
ode: 10.7M char-level GPT (tinygrad) trained on Pessoana — best val 1.361 nats
9b31c19 verified
Raw
History Blame Contribute Delete
5.9 kB
"""
A small character-level GPT (decoder-only transformer) in tinygrad.
Token + position embeddings -> a stack of pre-norm blocks (causal self-attention +
MLP, each added residually) -> a weight-tied head giving next-character logits.
"""
import math
from dataclasses import dataclass
from tinygrad import Tensor, nn
@dataclass
class GPTConfig:
vocab_size: int = 162 # number of distinct characters (set from the data's meta.pkl)
block_size: int = 256 # context window length (positions the model can see)
n_layer: int = 6 # number of transformer blocks
n_head: int = 6 # attention heads per block
n_embd: int = 384 # width of the residual stream
dropout: float = 0.2
class LayerNorm:
"""Layer norm with a learnable scale and no bias: normalize each position's
feature vector to zero mean / unit variance, then rescale per channel."""
def __init__(self, dim: int):
self.weight = Tensor.ones(dim)
def __call__(self, x: Tensor) -> Tensor:
return x.layernorm() * self.weight
class CausalSelfAttention:
"""Multi-head self-attention where each position may only attend to earlier ones."""
def __init__(self, cfg: GPTConfig):
self.c_attn = nn.Linear(cfg.n_embd, 3 * cfg.n_embd, bias=False) # q, k, v in one matmul
self.c_proj = nn.Linear(cfg.n_embd, cfg.n_embd, bias=False) # mix heads back together
self.n_head, self.dropout = cfg.n_head, cfg.dropout
def __call__(self, x: Tensor) -> Tensor:
B, T, C = x.shape
q, k, v = self.c_attn(x).split(C, dim=2)
split_heads = lambda t: t.reshape(B, T, self.n_head, C // self.n_head).transpose(1, 2)
q, k, v = split_heads(q), split_heads(k), split_heads(v)
p = self.dropout if Tensor.training else 0.0
y = q.scaled_dot_product_attention(k, v, is_causal=True, dropout_p=p) # causal mask applied here
y = y.transpose(1, 2).reshape(B, T, C) # concatenate heads
return self.c_proj(y).dropout(self.dropout)
class MLP:
"""Position-wise feed-forward network: expand 4x, GELU, project back."""
def __init__(self, cfg: GPTConfig):
self.c_fc = nn.Linear(cfg.n_embd, 4 * cfg.n_embd, bias=False)
self.c_proj = nn.Linear(4 * cfg.n_embd, cfg.n_embd, bias=False)
self.dropout = cfg.dropout
def __call__(self, x: Tensor) -> Tensor:
return self.c_proj(self.c_fc(x).gelu()).dropout(self.dropout)
class Block:
"""One transformer block: pre-norm attention and MLP, each added residually."""
def __init__(self, cfg: GPTConfig):
self.ln_1, self.attn = LayerNorm(cfg.n_embd), CausalSelfAttention(cfg)
self.ln_2, self.mlp = LayerNorm(cfg.n_embd), MLP(cfg)
def __call__(self, x: Tensor) -> Tensor:
x = x + self.attn(self.ln_1(x))
x = x + self.mlp(self.ln_2(x))
return x
class GPT:
"""The full model: embeddings, a stack of blocks, and a tied output head."""
def __init__(self, cfg: GPTConfig):
self.cfg = cfg
self.wte = nn.Embedding(cfg.vocab_size, cfg.n_embd) # token embedding (reused as output head)
self.wpe = nn.Embedding(cfg.block_size, cfg.n_embd) # learned position embedding
self.dropout = cfg.dropout
self.blocks = [Block(cfg) for _ in range(cfg.n_layer)]
self.ln_f = LayerNorm(cfg.n_embd)
self._init_weights()
def _init_weights(self):
# Small-Gaussian init. Projections that feed a residual add are scaled down by
# 1/sqrt(2*n_layer) so the variance of the residual stream stays stable with depth.
randn = lambda shape, std: Tensor.randn(*shape) * std
self.wte.weight = randn(self.wte.weight.shape, 0.02)
self.wpe.weight = randn(self.wpe.weight.shape, 0.02)
scaled = 0.02 / math.sqrt(2 * self.cfg.n_layer)
for b in self.blocks:
for lin in (b.attn.c_attn, b.mlp.c_fc):
lin.weight = randn(lin.weight.shape, 0.02)
for lin in (b.attn.c_proj, b.mlp.c_proj):
lin.weight = randn(lin.weight.shape, scaled)
def __call__(self, idx: Tensor) -> Tensor:
B, T = idx.shape
pos = Tensor.arange(T)
x = (self.wte(idx) + self.wpe(pos)).dropout(self.dropout) # combine token + position info
for b in self.blocks:
x = b(x)
x = self.ln_f(x)
return x.linear(self.wte.weight.transpose()) # tied output projection -> per-position logits
def generate(self, idx: Tensor, max_new_tokens: int, temperature: float = 1.0,
top_k: int | None = None) -> Tensor:
"""Autoregressively extend `idx` (shape (B, T)) by sampling one token at a time.
temperature scales the logits (lower = greedier); top_k, if set, restricts
sampling to the k most likely next tokens. Returns the prompt plus new tokens.
The context is always right-padded to block_size so every forward pass has the
same shape (one kernel compile instead of one per length); causal masking means
the padding never influences the real last position.
"""
bs = self.cfg.block_size
for _ in range(max_new_tokens):
ctx = idx[:, -bs:]
t = ctx.shape[1]
if t < bs:
ctx = ctx.cat(Tensor.zeros(ctx.shape[0], bs - t, dtype=idx.dtype), dim=1)
logits = self(ctx)[:, t - 1, :] / temperature # logits at the true last position
if top_k:
kth = logits.topk(min(top_k, logits.shape[-1]))[0][:, -1:] # k-th largest per row
logits = (logits < kth).where(-float("inf"), logits) # mask the rest out
next_id = logits.softmax(-1).multinomial(1).cast(idx.dtype) # sample one token
idx = idx.cat(next_id, dim=1).realize() # run this step now (don't accumulate a lazy graph)
return idx