diff --git a/README.md b/README.md index 90dfc41..a2048b2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Core machine-learning and deep-learning building blocks implemented from scratch ## What this demonstrates -Every deep learning framework is, at its core, a graph that records operations and replays them backward to compute gradients. This repo builds that machinery by hand and then uses it to train models, so the chain rule, backpropagation, and gradient descent are all visible in a few hundred lines you can read end to end. Each concept lands as its own tested module with the intuition and the math written out, working up to a from-scratch attention block that learns a toy task. +Every deep learning framework is, at its core, a graph that records operations and replays them backward to compute gradients. This repo builds that machinery by hand and then uses it to train models, so the chain rule, backpropagation, and gradient descent are all visible in a few hundred lines you can read end to end. Each concept lands as its own tested module with the intuition and the math written out, working up to a from-scratch char-level transformer language model that learns to continue a toy sequence. ## Concepts demonstrated @@ -40,6 +40,12 @@ Every deep learning framework is, at its core, a graph that records operations a - Heavy-ball / Polyak momentum: velocity accumulation that carries steps through elongated valleys - Adam (adaptive moment estimation): bias-corrected first and second moments, per-parameter step sizes - Fair convergence comparison: same MLP init, same minibatches, only the update rule changes +- Decoder-only (GPT-style) language modeling: next-token prediction under a causal attention mask +- Character-level tokenization with a learned token embedding table +- Learned absolute position embeddings summed into the residual stream +- Single-head scaled dot-product self-attention with hand-derived backprop through softmax(QKᵀ/√d)V +- Residual attention + ReLU feed-forward block trained end-to-end with Adam +- Autoregressive sampling (temperature-controlled) from a trained tiny GPT ## What's implemented @@ -49,6 +55,7 @@ Every deep learning framework is, at its core, a graph that records operations a - **Multilayer perceptron with hand-derived backprop**: `src/mlp.py` stacks affine layers with `tanh` or `relu` and a softmax head, and trains a multiclass classifier by minibatch SGD. The backward pass is written out by hand as one recursion on the per-layer delta rather than delegated to an autodiff engine: the output delta is the `softmax - onehot` residual, each hidden delta is `(delta_next @ W_nextᵀ) ⊙ act'(z)`, and the parameter gradients are `dW = a_prevᵀ @ delta` and `db = Σ delta`. Weights use He init for `relu` and Xavier for `tanh` so the signal variance holds across depth. The gradients are verified against central finite differences to a tight tolerance, and the model learns XOR and a three-arm spiral, targets a single hyperplane provably cannot separate. Ships with `make_xor` and `make_spiral` toy generators. - **Activation functions + weight initialization (Xavier/He) and why they matter**: `src/activations.py` is the dedicated treatment of the nonlinearity and the initial scale. Each activation (`linear`, `tanh`, `sigmoid`, `relu`, `leaky_relu`) exposes `forward` and a local `backward(z, grad_out)` that multiplies by `act'(z)`, so a hand-written backprop step can drop it in. Xavier/Glorot draws `N(0, 2/(fan_in+fan_out))` (or the matching uniform bound) to keep both forward and backward variance stable for symmetric activations; He/Kaiming draws `N(0, 2/fan_in)` so a ReLU stack does not quietly die after a few layers. `forward_variance_profile` stacks affine+activation layers from unit-variance noise and returns the per-layer activation variance: naive `N(0,1)` weights explode, He keeps a ReLU stack `O(1)`, and Xavier on the same ReLU stack fades, which is the usual silent failure mode when the scheme and the nonlinearity disagree. - **SGD, Momentum, Adam from scratch, convergence compared on the same net**: `src/optimizers.py` implements the three standard first-order update rules as numpy-only classes that own their state (velocity for momentum, bias-corrected moments for Adam) and mutate a flat list of parameter arrays in place. The MLP training loop calls `optimizer.step(params, grads)` after the hand-written backprop pass, so swapping the rule never touches the gradient math. `compare_optimizers` retrains the same architecture on the same data with the same seed for each factory, which keeps init and minibatch order fixed and isolates the update rule; on XOR, all three cut loss, and Adam typically pulls ahead of plain SGD early because its per-parameter rates absorb the uneven scale of the gradient. +- **A tiny char-level transformer that learns to continue a toy sequence**: `src/tiny_gpt.py` is a one-block decoder-only language model over characters. Token and position embeddings feed a causal self-attention head (scores lower-triangular, then softmax and V), a residual ReLU FFN, and a linear vocab head. Next-token cross-entropy is minimized with hand-written gradients through the attention path (including the softmax Jacobian) and the same flat-list Adam optimizer used by the MLP. On a repeating `ABAB…` corpus, loss drops and the model assigns higher logit mass to the correct next character; `generate` samples continuations autoregressively under a temperature. ## Usage @@ -148,6 +155,26 @@ print(forward_variance_profile(10, 64, "relu", "xavier")[-1]) print(forward_variance_profile(6, 64, "linear", "naive")[-1]) ``` +Train a tiny GPT on a toy character sequence and sample a continuation: + +```python +from src.tiny_gpt import fit + +model, tok, history = fit( + "AB" * 100, + d_model=16, + block_size=8, + steps=300, + lr=0.08, + seed=0, +) +print(history[0], "->", history[-1]) # cross-entropy falls + +prompt = tok.encode("ABA") +ids = model.generate(prompt.reshape(1, -1), max_new_tokens=8, seed=1) +print(tok.decode(ids[0])) # keeps alternating A/B after training +``` + Differentiate an arbitrary scalar expression: ```python diff --git a/src/tiny_gpt.py b/src/tiny_gpt.py new file mode 100644 index 0000000..3045b59 --- /dev/null +++ b/src/tiny_gpt.py @@ -0,0 +1,301 @@ +"""Char-level decoder-only transformer language model (a tiny GPT). + +Next-token prediction over characters: each position predicts the next +character from a causal window of earlier ones. One residual block with +single-head scaled dot-product self-attention and a ReLU FFN, plus token and +learned position embeddings and a linear LM head. Gradients are written out by +hand so the chain through attention is visible end to end. + + logits_t = f(x_{≤t}); L = mean CE(softmax(logits_t), x_{t+1}) + +Causal masking zeros the upper triangle of the score matrix so position t +cannot read t+1..T, which is what makes generation autoregressive. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +from numpy.typing import NDArray + +from src.optimizers import Adam, Optimizer + +Array = NDArray[np.float64] +IntArray = NDArray[np.int64] + + +def _softmax_last(x: Array) -> Array: + x = x - x.max(axis=-1, keepdims=True) + e = np.exp(x) + return e / e.sum(axis=-1, keepdims=True) + + +def _xavier(rng: np.random.Generator, n_in: int, n_out: int) -> Array: + return rng.normal(0.0, np.sqrt(2.0 / (n_in + n_out)), size=(n_in, n_out)) + + +class CharTokenizer: + """Sorted unique characters of a corpus; encode/decode as int ids.""" + + def __init__(self, text: str) -> None: + if not text: + raise ValueError("tokenizer needs non-empty text") + self.chars = sorted(set(text)) + self.stoi = {c: i for i, c in enumerate(self.chars)} + self.itos = {i: c for i, c in enumerate(self.chars)} + + @property + def vocab_size(self) -> int: + return len(self.chars) + + def encode(self, text: str) -> IntArray: + try: + return np.array([self.stoi[c] for c in text], dtype=np.int64) + except KeyError as e: + raise ValueError(f"unknown character {e.args[0]!r}") from e + + def decode(self, ids: IntArray | list[int]) -> str: + return "".join(self.itos[int(i)] for i in np.asarray(ids).reshape(-1)) + + +@dataclass +class TinyGPT: + """Trainable one-block char LM. Parameters are plain numpy arrays.""" + + vocab_size: int + d_model: int + block_size: int + tok_emb: Array + pos_emb: Array + W_q: Array + W_k: Array + W_v: Array + W_o: Array + W1: Array + b1: Array + W2: Array + b2: Array + W_lm: Array + b_lm: Array + + @classmethod + def create( + cls, + vocab_size: int, + d_model: int = 32, + block_size: int = 16, + seed: int = 0, + ) -> TinyGPT: + if vocab_size < 1 or d_model < 1 or block_size < 1: + raise ValueError("vocab_size, d_model, block_size must be positive") + rng = np.random.default_rng(seed) + d = d_model + return cls( + vocab_size=vocab_size, + d_model=d, + block_size=block_size, + tok_emb=rng.normal(0.0, 0.02, size=(vocab_size, d)), + pos_emb=rng.normal(0.0, 0.02, size=(block_size, d)), + W_q=_xavier(rng, d, d), + W_k=_xavier(rng, d, d), + W_v=_xavier(rng, d, d), + W_o=_xavier(rng, d, d), + W1=_xavier(rng, d, 4 * d), + b1=np.zeros(4 * d), + W2=_xavier(rng, 4 * d, d), + b2=np.zeros(d), + W_lm=_xavier(rng, d, vocab_size), + b_lm=np.zeros(vocab_size), + ) + + def parameters(self) -> list[Array]: + return [ + self.tok_emb, + self.pos_emb, + self.W_q, + self.W_k, + self.W_v, + self.W_o, + self.W1, + self.b1, + self.W2, + self.b2, + self.W_lm, + self.b_lm, + ] + + def _as_batch(self, idx: IntArray, *, max_t: int) -> IntArray: + arr = np.asarray(idx, dtype=np.int64) + if arr.ndim == 1: + arr = arr.reshape(1, -1) + if arr.ndim != 2 or arr.shape[1] == 0: + raise ValueError("idx must be non-empty (T,) or (B, T)") + if arr.shape[1] > max_t: + raise ValueError(f"length {arr.shape[1]} exceeds limit {max_t}") + if arr.min() < 0 or arr.max() >= self.vocab_size: + raise ValueError("token id out of vocabulary range") + return arr + + def logits(self, idx: IntArray) -> Array: + """(B, T, V) next-token logits for each position (causal).""" + return self._forward(self._as_batch(idx, max_t=self.block_size))[0] + + def _forward(self, idx: IntArray) -> tuple[Array, dict[str, Array]]: + b, t = idx.shape + d = self.d_model + x = self.tok_emb[idx] + self.pos_emb[:t] + q, k, v = x @ self.W_q, x @ self.W_k, x @ self.W_v + scale = 1.0 / np.sqrt(float(d)) + scores = (q @ np.swapaxes(k, -1, -2)) * scale + scores = np.where(np.tril(np.ones((t, t), dtype=bool)), scores, -np.inf) + attn = np.nan_to_num(_softmax_last(scores), nan=0.0) + att_out = attn @ v + y = x + att_out @ self.W_o + z1 = y @ self.W1 + self.b1 + a1 = np.maximum(z1, 0.0) + z = y + a1 @ self.W2 + self.b2 + logits = z @ self.W_lm + self.b_lm + cache = { + "idx": idx, + "x": x, + "q": q, + "k": k, + "v": v, + "attn": attn, + "att_out": att_out, + "y": y, + "z1": z1, + "a1": a1, + "z": z, + } + return logits, cache + + def loss_and_grads(self, idx: IntArray) -> tuple[float, list[Array]]: + """Next-token CE on idx[:, :-1] → idx[:, 1:].""" + arr = self._as_batch(idx, max_t=self.block_size + 1) + if arr.shape[1] < 2: + raise ValueError("need at least 2 tokens for next-token loss") + inp, tgt = arr[:, :-1], arr[:, 1:] + logits, cache = self._forward(inp) + b, t, _ = logits.shape + log_p = logits - np.logaddexp.reduce(logits, axis=-1, keepdims=True) + rows = np.arange(b)[:, None] + cols = np.arange(t)[None, :] + loss = float((-log_p[rows, cols, tgt]).mean()) + dlogits = _softmax_last(logits) + dlogits[rows, cols, tgt] -= 1.0 + dlogits /= b * t + return loss, self._backward(dlogits, cache) + + def _backward(self, dlogits: Array, cache: dict[str, Array]) -> list[Array]: + idx, x = cache["idx"], cache["x"] + q, k, v = cache["q"], cache["k"], cache["v"] + attn, att_out = cache["attn"], cache["att_out"] + y, z1, a1, z = cache["y"], cache["z1"], cache["a1"], cache["z"] + b, t, d = x.shape + scale = 1.0 / np.sqrt(float(d)) + flat = b * t + + dW_lm = z.reshape(flat, d).T @ dlogits.reshape(flat, -1) + db_lm = dlogits.sum(axis=(0, 1)) + dz = dlogits @ self.W_lm.T + + dy = dz.copy() + da1 = dz @ self.W2.T + dW2 = a1.reshape(flat, -1).T @ dz.reshape(flat, d) + db2 = dz.sum(axis=(0, 1)) + dz1 = da1 * (z1 > 0.0) + dW1 = y.reshape(flat, d).T @ dz1.reshape(flat, -1) + db1 = dz1.sum(axis=(0, 1)) + dy += dz1 @ self.W1.T + + dx = dy.copy() + datt_out = dy @ self.W_o.T + dW_o = att_out.reshape(flat, d).T @ dy.reshape(flat, d) + dattn = datt_out @ np.swapaxes(v, -1, -2) + dv = np.swapaxes(attn, -1, -2) @ datt_out + sum_d = (dattn * attn).sum(axis=-1, keepdims=True) + dscores = attn * (dattn - sum_d) + dscores = np.where(np.tril(np.ones((t, t), dtype=bool)), dscores, 0.0) + dq = (dscores @ k) * scale + dk = (np.swapaxes(dscores, -1, -2) @ q) * scale + dW_q = x.reshape(flat, d).T @ dq.reshape(flat, d) + dW_k = x.reshape(flat, d).T @ dk.reshape(flat, d) + dW_v = x.reshape(flat, d).T @ dv.reshape(flat, d) + dx += dq @ self.W_q.T + dk @ self.W_k.T + dv @ self.W_v.T + + d_tok = np.zeros_like(self.tok_emb) + np.add.at(d_tok, idx.reshape(-1), dx.reshape(flat, d)) + d_pos = np.zeros_like(self.pos_emb) + d_pos[:t] = dx.sum(axis=0) + return [ + d_tok, + d_pos, + dW_q, + dW_k, + dW_v, + dW_o, + dW1, + db1, + dW2, + db2, + dW_lm, + db_lm, + ] + + def generate( + self, + idx: IntArray, + max_new_tokens: int, + *, + temperature: float = 1.0, + seed: int | None = None, + ) -> IntArray: + """Autoregressive sample; returns prompt plus new tokens.""" + if max_new_tokens < 0 or temperature <= 0.0: + raise ValueError("max_new_tokens >= 0 and temperature > 0 required") + rng = np.random.default_rng(seed) + out = self._as_batch(idx, max_t=self.block_size) + for _ in range(max_new_tokens): + ctx = out[:, -self.block_size :] + probs = _softmax_last(self.logits(ctx)[:, -1, :] / temperature) + nxt = np.array( + [rng.choice(self.vocab_size, p=probs[i]) for i in range(out.shape[0])], + dtype=np.int64, + ) + out = np.concatenate([out, nxt[:, None]], axis=1) + return out + + +def fit( + text: str, + *, + d_model: int = 32, + block_size: int = 16, + steps: int = 400, + batch_size: int = 16, + lr: float = 0.05, + seed: int = 0, + optimizer: Optimizer | None = None, +) -> tuple[TinyGPT, CharTokenizer, list[float]]: + """Train on `text` by next-token CE; returns model, tokenizer, loss history.""" + if len(text) < block_size + 1: + raise ValueError("text must be longer than block_size") + tok = CharTokenizer(text) + data = tok.encode(text) + model = TinyGPT.create( + tok.vocab_size, d_model=d_model, block_size=block_size, seed=seed + ) + opt: Optimizer = Adam(lr=lr) if optimizer is None else optimizer + rng = np.random.default_rng(seed + 1) + history: list[float] = [] + params = model.parameters() + max_start = data.size - block_size - 1 + for _ in range(steps): + starts = rng.integers(0, max_start + 1, size=batch_size) + batch = np.stack([data[s : s + block_size + 1] for s in starts]) + loss, grads = model.loss_and_grads(batch) + opt.step(params, grads) + history.append(loss) + return model, tok, history diff --git a/tests/test_tiny_gpt.py b/tests/test_tiny_gpt.py new file mode 100644 index 0000000..f0d7e12 --- /dev/null +++ b/tests/test_tiny_gpt.py @@ -0,0 +1,93 @@ +"""Tests for the char-level tiny GPT: shapes, causal mask, grads, learning.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from src.tiny_gpt import CharTokenizer, TinyGPT, fit + + +def test_tokenizer_roundtrip(): + tok = CharTokenizer("abca") + assert tok.vocab_size == 3 + assert tok.decode(tok.encode("cab")) == "cab" + with pytest.raises(ValueError, match="non-empty"): + CharTokenizer("") + with pytest.raises(ValueError, match="unknown"): + tok.encode("z") + + +def test_logits_shape_and_causal_mask(): + model = TinyGPT.create(vocab_size=5, d_model=8, block_size=6, seed=0) + logits = model.logits(np.array([[0, 1, 2, 3]], dtype=np.int64)) + assert logits.shape == (1, 4, 5) + a = model.logits(np.array([[0, 1, 2]], dtype=np.int64)) + b = model.logits(np.array([[0, 1, 4]], dtype=np.int64)) + np.testing.assert_allclose(a[0, :2], b[0, :2], atol=1e-12) + assert not np.allclose(a[0, 2], b[0, 2]) + + +def test_rejects_empty_oob_and_overlong(): + model = TinyGPT.create(vocab_size=4, d_model=4, block_size=4, seed=0) + with pytest.raises(ValueError, match="non-empty"): + model.logits(np.zeros((1, 0), dtype=np.int64)) + with pytest.raises(ValueError, match="exceeds"): + model.logits(np.zeros((1, 5), dtype=np.int64)) + with pytest.raises(ValueError, match="vocabulary"): + model.logits(np.array([[0, 9]], dtype=np.int64)) + with pytest.raises(ValueError, match="at least 2"): + model.loss_and_grads(np.array([[1]], dtype=np.int64)) + + +def test_generate_extends_prompt(): + model = TinyGPT.create(vocab_size=4, d_model=8, block_size=8, seed=1) + prompt = np.array([[0, 1]], dtype=np.int64) + out = model.generate(prompt, max_new_tokens=3, seed=0) + assert out.shape == (1, 5) + np.testing.assert_array_equal(out[0, :2], [0, 1]) + with pytest.raises(ValueError, match="temperature"): + model.generate(prompt, 1, temperature=0.0) + + +def test_grads_match_finite_differences(): + model = TinyGPT.create(vocab_size=6, d_model=4, block_size=5, seed=2) + idx = np.array([[0, 1, 2, 3, 1], [1, 2, 0, 1, 2]], dtype=np.int64) + loss, grads = model.loss_and_grads(idx) + assert np.isfinite(loss) + eps = 1e-5 + for pi in (10, 2): # W_lm, W_q + p = model.parameters()[pi].reshape(-1) + num = np.zeros(min(6, p.size)) + for j in range(num.size): + old = p[j] + p[j] = old + eps + lp, _ = model.loss_and_grads(idx) + p[j] = old - eps + lm, _ = model.loss_and_grads(idx) + p[j] = old + num[j] = (lp - lm) / (2 * eps) + np.testing.assert_allclose( + grads[pi].reshape(-1)[: num.size], num, rtol=2e-2, atol=5e-3 + ) + + +def test_learns_toy_alternating_sequence(): + text = "AB" * 80 + model, tok, history = fit( + text, d_model=16, block_size=8, steps=250, batch_size=16, lr=0.08, seed=0 + ) + assert history[-1] < history[0] + assert history[-1] < 0.35 + a_id, b_id = tok.stoi["A"], tok.stoi["B"] + la = model.logits(np.array([[a_id, b_id, a_id]], dtype=np.int64))[0, -1] + lb = model.logits(np.array([[a_id, b_id, a_id, b_id]], dtype=np.int64))[0, -1] + assert la[b_id] > la[a_id] + assert lb[a_id] > lb[b_id] + decoded = tok.decode(model.generate(np.array([[a_id]]), 6, seed=3)[0]) + assert decoded.count("AB") + decoded.count("BA") >= 2 + + +def test_fit_rejects_short_text(): + with pytest.raises(ValueError, match="longer"): + fit("AB", block_size=8, steps=1)