-
-
Save JonasLoos/c935738d191dcc49b3603f2688d103b3 to your computer and use it in GitHub Desktop.
microgpt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| The most atomic way to train and inference a GPT LLM in pure, dependency-free Python. | |
| Differences from GPT-2 are minor: layer norm -> rmsnorm, no biases, GeLU -> square ReLU, no weight tying. | |
| The contents of this file is everything algorithmically needed to train a GPT. Everything else is just efficiency. | |
| Art project by @karpathy. | |
| """ | |
| import os # for os.path.exists | |
| import time # for time.perf_counter | |
| import math # for math.log, math.exp | |
| import random # for random.seed, random.choices | |
| import argparse # for argparse.ArgumentParser | |
| # CLI arguments | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument('--n-embd', type=int, default=16, help='Number of channels in the Transformer') | |
| parser.add_argument('--n-layer', type=int, default=1, help='Number of layers in the Transformer') | |
| parser.add_argument('--block-size', type=int, default=8, help='Maximum sequence length') | |
| parser.add_argument('--num-steps', type=int, default=500, help='Number of training steps') | |
| parser.add_argument('--n-head', type=int, default=4, help='Number of attention heads in the Transformer') | |
| parser.add_argument('--learning-rate', type=float, default=1e-2, help='Learning rate') | |
| args = parser.parse_args() | |
| n_embd, block_size, n_layer, n_head = args.n_embd, args.block_size, args.n_layer, args.n_head | |
| head_dim = n_embd // n_head | |
| random.seed(42) | |
| # Dataset example: the names dataset (one name per line). rest of the code just assumes docs: list[str] | |
| if not os.path.exists('input.txt'): | |
| import urllib.request | |
| urllib.request.urlretrieve('https://raw.githubusercontent.com/karpathy/makemore/refs/heads/master/names.txt', 'input.txt') | |
| docs = [l.strip() for l in open('input.txt').read().strip().split('\n') if l.strip()] # list[str] of documents | |
| random.shuffle(docs) | |
| # Tokenizer: simple character-level tokenization with a BOS token delimiter | |
| chars = ['<BOS>'] + sorted(set(''.join(docs))) | |
| vocab_size = len(chars) | |
| stoi = { ch:i for i, ch in enumerate(chars) } # string to integer | |
| itos = { i:ch for i, ch in enumerate(chars) } # integer to string | |
| BOS = stoi['<BOS>'] | |
| print(f"vocab size: {vocab_size}, num docs: {len(docs)}") | |
| # Autograd engine | |
| class Value: | |
| """ stores a single scalar value and its gradient """ | |
| def __init__(self, data, _children=(), _op='', _bw=[]): | |
| def _backward(): | |
| for child, grad in zip(_children, _bw): | |
| child.grad += grad * self.grad | |
| self.data = data | |
| self.grad = 0 | |
| self._backward = _backward | |
| self._prev = set(_children) | |
| self._op = _op # the op that produced this node, for graphviz / debugging / etc | |
| def __add__(self, other): | |
| other = other if isinstance(other, Value) else Value(other) | |
| return Value(self.data + other.data, (self, other), '+', [1, 1]) | |
| def __mul__(self, other): | |
| other = other if isinstance(other, Value) else Value(other) | |
| return Value(self.data * other.data, (self, other), '*', [other.data, self.data]) | |
| def __pow__(self, other): | |
| assert isinstance(other, (int, float)), "only supporting int/float powers for now" | |
| return Value(self.data**other, (self,), f'**{other}', [(other * self.data**(other-1))]) | |
| def log(self): | |
| return Value(math.log(self.data), (self,), 'log', [(1 / self.data)]) | |
| def exp(self): | |
| return Value(math.exp(self.data), (self,), 'exp', [math.exp(self.data)]) | |
| def relu(self): | |
| return Value(0 if self.data < 0 else self.data, (self,), 'ReLU', [(self.data > 0)]) | |
| def backward(self): | |
| # topological order all of the children in the graph | |
| topo = [] | |
| visited = set() | |
| def build_topo(v): | |
| if v not in visited: | |
| visited.add(v) | |
| for child in v._prev: | |
| build_topo(child) | |
| topo.append(v) | |
| build_topo(self) | |
| # go one variable at a time and apply the chain rule to get its gradient | |
| self.grad = 1 | |
| for v in reversed(topo): | |
| v._backward() | |
| def __neg__(self): return self * -1 | |
| def __radd__(self, other): return self + other | |
| def __sub__(self, other): return self + (-other) | |
| def __rsub__(self, other): return other + (-self) | |
| def __rmul__(self, other): return self * other | |
| def __truediv__(self, other): return self * other**-1 | |
| def __rtruediv__(self, other): return other * self**-1 | |
| def __repr__(self): return f"Value(data={self.data}, grad={self.grad})" | |
| # Model parameter initialization | |
| matrix = lambda nout, nin, std=0.02: [[Value(random.gauss(0, std)) for _ in range(nin)] for _ in range(nout)] | |
| state_dict = {'wte': matrix(vocab_size, n_embd), 'wpe': matrix(block_size, n_embd), 'lm_head': matrix(vocab_size, n_embd)} | |
| for i in range(n_layer): | |
| state_dict[f'layer{i}.attn_wq'] = matrix(n_embd, n_embd) | |
| state_dict[f'layer{i}.attn_wk'] = matrix(n_embd, n_embd) | |
| state_dict[f'layer{i}.attn_wv'] = matrix(n_embd, n_embd) | |
| state_dict[f'layer{i}.attn_wo'] = matrix(n_embd, n_embd, std=0) | |
| state_dict[f'layer{i}.mlp_fc1'] = matrix(4 * n_embd, n_embd) | |
| state_dict[f'layer{i}.mlp_fc2'] = matrix(n_embd, 4 * n_embd, std=0) | |
| params = [p for mat in state_dict.values() for row in mat for p in row] # flatten params into a single list[Value] | |
| print(f"num params: {len(params)}") | |
| # Model architecture | |
| def linear(x, w): | |
| return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w] | |
| def softmax(logits): | |
| max_val = max(val.data for val in logits) | |
| exps = [(val - max_val).exp() for val in logits] | |
| total = sum(exps) | |
| return [e / total for e in exps] | |
| def rmsnorm(x): | |
| ms = sum(xi * xi for xi in x) / len(x) | |
| scale = (ms + 1e-5) ** -0.5 | |
| return [xi * scale for xi in x] | |
| def gpt(token_id, pos_id, keys, values): | |
| tok_emb = state_dict['wte'][token_id] # token embedding | |
| pos_emb = state_dict['wpe'][pos_id] # position embedding | |
| x = [t + p for t, p in zip(tok_emb, pos_emb)] # joint token and position embedding | |
| x = rmsnorm(x) | |
| for li in range(n_layer): | |
| # 1) Multi-head attention block | |
| x_residual = x | |
| x = rmsnorm(x) | |
| q = linear(x, state_dict[f'layer{li}.attn_wq']) | |
| k = linear(x, state_dict[f'layer{li}.attn_wk']) | |
| v = linear(x, state_dict[f'layer{li}.attn_wv']) | |
| keys[li].append(k) | |
| values[li].append(v) | |
| x_attn = [] | |
| for h in range(n_head): | |
| hs = h * head_dim | |
| q_h = q[hs:hs+head_dim] | |
| k_h = [ki[hs:hs+head_dim] for ki in keys[li]] | |
| v_h = [vi[hs:hs+head_dim] for vi in values[li]] | |
| attn_logits = [sum(q_h[j] * k_h[t][j] for j in range(head_dim)) / head_dim**0.5 for t in range(len(k_h))] | |
| attn_weights = softmax(attn_logits) | |
| head_out = [sum(attn_weights[t] * v_h[t][j] for t in range(len(v_h))) for j in range(head_dim)] | |
| x_attn.extend(head_out) | |
| x = linear(x_attn, state_dict[f'layer{li}.attn_wo']) | |
| x = [a + b for a, b in zip(x, x_residual)] | |
| # 2) MLP block | |
| x_residual = x | |
| x = rmsnorm(x) | |
| x = linear(x, state_dict[f'layer{li}.mlp_fc1']) | |
| x = [xi.relu() ** 2 for xi in x] | |
| x = linear(x, state_dict[f'layer{li}.mlp_fc2']) | |
| x = [a + b for a, b in zip(x, x_residual)] | |
| logits = linear(x, state_dict['lm_head']) | |
| return logits | |
| # Adam optimizer | |
| learning_rate = args.learning_rate | |
| beta1, beta2, eps_adam = 0.9, 0.95, 1e-8 | |
| m = [0.0] * len(params) # first moment | |
| v = [0.0] * len(params) # second moment | |
| # Training loop | |
| lossf_history = [] | |
| t_start = time.perf_counter() | |
| for step in range(args.num_steps): | |
| # Take a single training document, tokenize it, surround it with BOS special token on both sides | |
| doc = docs[step % len(docs)] | |
| tokens = [BOS] + [stoi[ch] for ch in doc] + [BOS] | |
| n = min(block_size, len(tokens) - 1) | |
| # Forward/backward through the document over time dimension | |
| keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)] | |
| losses = [] | |
| for pos_id in range(n): | |
| token_id, target_id = tokens[pos_id], tokens[pos_id + 1] | |
| logits = gpt(token_id, pos_id, keys, values) | |
| probs = softmax(logits) | |
| loss_t = -probs[target_id].log() | |
| losses.append(loss_t) | |
| loss = (1 / n) * sum(losses) # average loss over the sequence | |
| loss.backward() | |
| # Adam update (optimizer) | |
| lr_t = learning_rate * (1 - step / args.num_steps) | |
| for i, p in enumerate(params): | |
| m[i] = beta1 * m[i] + (1 - beta1) * p.grad | |
| v[i] = beta2 * v[i] + (1 - beta2) * p.grad ** 2 | |
| m_hat = m[i] / (1 - beta1 ** (step + 1)) | |
| v_hat = v[i] / (1 - beta2 ** (step + 1)) | |
| p.data -= lr_t * m_hat / (v_hat ** 0.5 + eps_adam) | |
| p.grad = 0 | |
| lossf_history.append(loss.data) | |
| print(f"step {step+1:4d} / {args.num_steps:4d} | loss {loss.data:.4f}") | |
| print(f"mean loss last 50 steps: {sum(lossf_history[-50:]) / len(lossf_history[-50:]):.4f}") # ~usable for basic kwarg tuning | |
| print(f"training time: {time.perf_counter() - t_start:.2f}s") # ~usable for basic performance benchmarking | |
| # Inference: generate 5 samples | |
| temperature = 0.5 # number in (0, 1] that controls the "creativity" of generated text, low to high | |
| print("\n--- inference ---") | |
| for sample_idx in range(20): | |
| keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)] | |
| token_id = BOS | |
| print(f"sample {sample_idx+1}: ", end="") | |
| for pos_id in range(block_size): | |
| logits = gpt(token_id, pos_id, keys, values) | |
| probs = softmax([l / temperature for l in logits]) | |
| token_id = random.choices(range(vocab_size), weights=[p.data for p in probs])[0] | |
| if token_id == BOS: | |
| break | |
| print(itos[token_id], end="") | |
| print() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment