Build your own tiny GPT

Can you build a GPT from scratch, train it, and chat with it?

This is a real GPT: the same kind of model as ChatGPT, built from the pieces in the earlier lessons and trained right here, in PHP, on this server. It is thousands of times smaller than GPT-2 and millions of times smaller than ChatGPT, so it can only learn a very small text. But every part is really there, and you can watch it learn.

Chat with it

Hi! I am a tiny GPT. I only learned from about 50 short conversations, so ask me something like "tell me a joke" or "what is attention".

Shading shows how sure it was of each word: darker = more sure. Hover a word for the exact number. It can only see the last 32 tokens, so in a long chat it forgets the start.

Settings

0.4
10,396weights
204words and marks it knows
867tokens of training text
0.21loss (guessing: 5.32)

Temperature changes how it picks. Low (0.2): it almost always takes its top guess, so it plays safe and repeats itself. High (1.5): unlikely tokens get picked more, so it gets creative, then nonsensical.

Loss while it learned

00150031,00041,50062,000training step
each step average random guessing

Loss is the log loss from lesson 5: how surprised it was by the real next token, averaged. Lower means better guesses. The red line is a model that knows nothing and guesses evenly.

What it wrote as it learned

StepSame prompt, same dice
0helping train what's get was like goes hey text computer small thanks no model help rest sorry down math weight neuron: but like
25guesses you are. i do: i oh: my thousand learn are. ↵ you: i upon help!!?
50i anuman too. i am cross activated piece adds. i am joke. ↵ you: no. ↵ you: i
100hey! plus a token is a love you ↵ bot: kumusta ↵ bot: what is a token ↵ bot: i
200good night! did you: good. i am happy to hear that from examples. ↵ you: thank you: i
300hey! what's up? ↵ you: good morning ↵ bot: good morning! did you: no. ↵ you:
500i live inside a php file. ↵ you: what can you do you pressed train. ↵ you: where are you
750thank you are cute ↵ bot: thank you! you are nice. ↵ you: you are welcome! ↵ you:
1,000salamat ↵ bot: walang anuman! ↵ you: bye ↵ bot: bye! come back soon. ↵ you: goodbye
1,500i live inside a php file. ↵ you: what can you do ↵ bot: i can guess the next letter.
2,000how are you ↵ bot: i am fine, thank you. and you? ↵ you: how are you ↵ bot

At step 0 the weights are random, so it types random words. First it learns which words are common, then which ones follow which, and only much later anything like meaning.

Follow one prediction through the model

This is what happens inside, for the text you: hi↵bot:, when it predicts what comes next.

  1. Text becomes token IDs (lesson 9)
    you202:7hi73↵0bot27:7
  2. Each ID becomes 16 numbers (an embedding), plus 16 numbers for its position These start random, and training moves them. Tokens used in similar ways end up with similar rows. The last few rows:
    hi0.7−0.20.9−0.5−0.5−0.50.60.50.30.80.2−0.2−1.2−0.1−0.31.4
    ↵1.41.00.80.40.0−0.2−1.00.01.00.6−0.6−0.8−0.1−0.5−1.20.1
    bot0.31.50.70.40.8−1.10.9−0.5−0.4−0.8−0.6−0.6−1.00.9−0.20.4
    :1.2−0.1−0.1−0.1−0.80.30.10.2−0.91.4−0.80.0−0.20.40.0−0.4
  3. Attention: the last token looks back at the others It pays most attention to hi#3 82%, bot#5 11%, :#6 3%, ↵#4 2%. It blends their values in those amounts, so information from earlier tokens can reach this one.
  4. A small network thinks about the result 16 numbers go into 64 hidden ReLU neurons, and back down to 16. For this token, 20 of the 64 neurons switched on.
  5. A score for every one of the 204 tokens, turned into probabilities
    hello
    89.3%
    no
    7.3%
    sure
    0.8%
    hey
    0.7%
    how
    0.6%
    attention
    0.4%
    training
    0.4%
    hi
    0.2%
  6. Pick one, add it to the text, and go back to step 1 That's how every reply above was written: one token per trip through the model.

Who looks at whom

youyouyou → you: 100%::: → you: 61%: → :: 39%hihihi → you: 91%hi → :: 9%hi → hi: 0%↵↵↵ → you: 5%↵ → :: 91%↵ → hi: 2%↵ → ↵: 2%botbotbot → you: 44%bot → :: 24%bot → hi: 0%bot → ↵: 30%bot → bot: 1%::: → you: 2%: → :: 1%: → hi: 82%: → ↵: 2%: → bot: 11%: → :: 3%

Each row is one token. Dark squares show which earlier tokens it took its information from; each row adds up to 100%. The grey half is the future, which it's never allowed to see. Hover a square for the number.

What to notice

It only knows its 50 conversations. Ask something from its training text and it usually answers well. Ask something new ("what is a transformer") and it stitches together pieces of answers it has seen. That's a tiny version of hallucination: it always produces something that looks like an answer, whether or not it knows.

It has no idea it is chatting. The page just sends it you: …↵bot: and lets it continue the text. A chat is a document it's finishing. ChatGPT works the same way, with a much longer and more careful document.

Try 0 steps. With random weights it produces nonsense. Everything it can do came from nudging 10,396 numbers downhill on the loss, 128 predictions per step.

From here to ChatGPT

The difference isn't in the ideas; it's in the size. Every row below is the same kind of model: a GPT.

This pageGPT-2 small (2019)GPT-3 (2020)
Weights10,396124 million175 billion
Transformer blocks11296
Attention heads per block11296
Numbers per token1676812,288
Tokens it can look back at321,0242,048
Training text867 tokensabout 40 GB of web pagesabout 300 billion tokens

Also missing here, for simplicity: layer normalisation (keeps the numbers inside each block at a steady size), dropout (fights overfitting), and byte-pair tokens instead of letters or whole words (lesson 9). Then, to turn a text predictor into a helpful assistant like ChatGPT or Claude, it's trained further on example conversations and on human feedback. That's the final stage of this course.

The code that just ran

Every number on this page came from this class, which lives at the top of this same file. The page trains it, saves it and asks it to write.

Show or hide the source
final class TinyGPT
{
    public array $vocab = [];   // token ID → the text of that token
    public array $ids = [];     // the text of a token → its ID
    public int $V;              // how many different tokens it knows
    public array $w = [];       // every weight, by name. Each one is a table of rows.
    private array $m = [];      // Adam: running average of each weight's gradient
    private array $s = [];      // Adam: running average of each gradient squared
    public int $steps = 0;      // training steps done so far

    public function __construct(string $text, public string $unit = 'letter', public int $T = 32, public int $d = 16, int $seed = 1)
    {
        // The vocabulary is every different token in the training text, sorted.
        $pieces = array_values(array_unique($this->split($text)));
        sort($pieces, SORT_STRING);
        $this->vocab = $pieces;
        $this->ids = array_flip($pieces);
        $this->V = count($pieces);

        seed_rng($seed);
        $d = $this->d; $h = 4 * $d;
        $shapes = [                         // [rows, columns, size of the random starting values]
            'tok'  => [$this->V, $d, 0.3],  // token embedding: d numbers for every token
            'pos'  => [$this->T, $d, 0.3],  // position embedding: d numbers for every place in the window
            'q'    => [$d, $d, 1 / sqrt($d)], // attention: query, key, value and output matrices
            'k'    => [$d, $d, 1 / sqrt($d)],
            'v'    => [$d, $d, 1 / sqrt($d)],
            'o'    => [$d, $d, 1 / sqrt($d)],
            'up'   => [$d, $h, 1 / sqrt($d)], // feed-forward: widen to 4×d neurons, ReLU, shrink back to d
            'upb'  => [1, $h, 0],
            'dn'   => [$h, $d, 1 / sqrt($h)],
            'dnb'  => [1, $d, 0],
            'out'  => [$d, $this->V, 1 / sqrt($d)], // a score for every token in the vocabulary
            'outb' => [1, $this->V, 0],
        ];
        foreach ($shapes as $name => [$rows, $cols, $scale]) {
            for ($i = 0; $i < $rows; $i++) for ($j = 0; $j < $cols; $j++) $this->w[$name][$i][$j] = gauss() * $scale;
            $this->m[$name] = $this->s[$name] = array_fill(0, $rows, array_fill(0, $cols, 0.0));
        }
    }

    // TOKENS (lesson 9): either single letters, or whole words and punctuation marks.
    public function split(string $text): array
    {
        if ($this->unit === 'letter') return preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];
        preg_match_all("/\n|[\\p{L}\\p{N}']+|[^\\s\\p{L}\\p{N}']/u", $text, $m);
        return $m[0];
    }

    public function encode(string $text): array
    {
        $out = [];
        foreach ($this->split($text) as $piece) if (isset($this->ids[$piece])) $out[] = $this->ids[$piece];
        return $out;
    }

    public function decode(array $ids, string $before = ''): string
    {
        $s = $before;
        foreach ($ids as $i) {
            $piece = $this->vocab[$i];
            // words need their spaces put back, except before punctuation or after a new line
            $gap = $this->unit === 'word' && $s !== '' && !str_ends_with($s, "\n") && $piece !== "\n" && !preg_match('/^[.,!?;:]$/', $piece);
            $s .= ($gap ? ' ' : '') . $piece;
        }
        return substr($s, strlen($before));
    }

    public function paramCount(): int
    {
        $n = 0;
        foreach ($this->w as $W) $n += count($W) * count($W[0]);
        return $n;
    }

    /* ---------- small matrix helpers ---------- */
    private static function mul(array $x, array $W): array      // row of numbers × matrix
    {
        $out = array_fill(0, count($W[0]), 0.0);
        foreach ($x as $i => $xi) {
            if ($xi == 0.0) continue;
            foreach ($W[$i] as $j => $wij) $out[$j] += $xi * $wij;
        }
        return $out;
    }
    private static function mulT(array $g, array $W): array     // send a gradient back through W
    {
        $out = [];
        foreach ($W as $i => $row) {
            $sum = 0.0;
            foreach ($row as $j => $wij) $sum += $g[$j] * $wij;
            $out[$i] = $sum;
        }
        return $out;
    }
    private static function addOuter(array &$G, array $x, array $g): void   // G += every x[i] × g[j]
    {
        foreach ($x as $i => $xi) {
            if ($xi == 0.0) continue;
            $row = &$G[$i];
            foreach ($g as $j => $gj) $row[$j] += $xi * $gj;
            unset($row);
        }
    }
    private static function add(array $a, array $b): array { foreach ($b as $i => $v) $a[$i] += $v; return $a; }
    private static function softmax(array $z): array                   // scores → probabilities that add up to 1
    {
        $max = max($z); $sum = 0.0;
        foreach ($z as $i => $v) { $z[$i] = exp($v - $max); $sum += $z[$i]; }
        foreach ($z as $i => $v) $z[$i] = $v / $sum;
        return $z;
    }

    /* ---------- FORWARD PASS: tokens in, next-token probabilities out ---------- */
    public function forward(array $x, float $temperature = 1.0): array
    {
        $W = $this->w; $n = count($x); $scale = 1 / sqrt($this->d);
        $f = ['x' => $x];
        for ($t = 0; $t < $n; $t++) {
            // 1. EMBED: look up the token's row of numbers and add its position's row
            $e = self::add($W['tok'][$x[$t]], $W['pos'][$t]);
            $f['e'][$t] = $e;
            // 2. Every position makes a query ("what am I looking for?"),
            //    a key ("what do I contain?") and a value ("what I pass on").
            $f['q'][$t] = self::mul($e, $W['q']);
            $f['k'][$t] = self::mul($e, $W['k']);
            $f['v'][$t] = self::mul($e, $W['v']);
        }
        for ($t = 0; $t < $n; $t++) {
            // 3. ATTENTION: compare my query with the key of every EARLIER position
            //    (never later ones: that would be peeking at the answer), turn the
            //    scores into weights that add up to 1, and blend those positions' values.
            $score = [];
            for ($s = 0; $s <= $t; $s++) {
                $dot = 0.0;
                foreach ($f['q'][$t] as $i => $qi) $dot += $qi * $f['k'][$s][$i];
                $score[$s] = $dot * $scale;
            }
            $a = self::softmax($score);
            $mix = array_fill(0, $this->d, 0.0);
            foreach ($a as $s => $as) foreach ($f['v'][$s] as $i => $vi) $mix[$i] += $as * $vi;
            $f['a'][$t] = $a; $f['mix'][$t] = $mix;
            // 4. RESIDUAL: add what attention found on top of the original embedding
            $h1 = self::add($f['e'][$t], self::mul($mix, $W['o']));
            // 5. FEED-FORWARD: a small 2-layer network (lesson 6), run at every position
            $pre = self::add(self::mul($h1, $W['up']), $W['upb'][0]);
            $hid = array_map(fn($z) => $z > 0 ? $z : 0.0, $pre);          // ReLU
            $h2 = self::add($h1, self::add(self::mul($hid, $W['dn']), $W['dnb'][0]));
            // 6. OUTPUT: one score per token in the vocabulary → softmax → probabilities
            $logits = self::add(self::mul($h2, $W['out']), $W['outb'][0]);
            if ($temperature != 1.0) foreach ($logits as $i => $z) $logits[$i] = $z / $temperature;
            $f['h1'][$t] = $h1; $f['hid'][$t] = $hid; $f['h2'][$t] = $h2;
            $f['p'][$t] = self::softmax($logits);
        }
        return $f;
    }

    /* ---------- BACKWARD PASS: every weight's share of the blame (lesson 7) ---------- */
    private function backward(array $f, array $y, array &$G): float
    {
        $W = $this->w; $n = count($y); $scale = 1 / sqrt($this->d); $loss = 0.0;
        $dq = $dk = $dv = $de = [];
        for ($t = $n - 1; $t >= 0; $t--) {
            $loss -= log(max($f['p'][$t][$y[$t]], 1e-12));     // log loss: small when the right token got a high probability
            $dz = $f['p'][$t]; $dz[$y[$t]] -= 1.0;              // blame on each score = probability − (1 for the right token)
            self::addOuter($G['out'], $f['h2'][$t], $dz);
            $G['outb'][0] = self::add($G['outb'][0], $dz);
            $dh2 = self::mulT($dz, $W['out']);
            // back through the feed-forward network (dh2 also flows straight to h1: the residual)
            self::addOuter($G['dn'], $f['hid'][$t], $dh2);
            $G['dnb'][0] = self::add($G['dnb'][0], $dh2);
            $dhid = self::mulT($dh2, $W['dn']);
            foreach ($dhid as $j => $g) if ($f['hid'][$t][$j] <= 0) $dhid[$j] = 0.0;   // ReLU passes blame only where it was on
            self::addOuter($G['up'], $f['h1'][$t], $dhid);
            $G['upb'][0] = self::add($G['upb'][0], $dhid);
            $dh1 = self::add($dh2, self::mulT($dhid, $W['up']));
            // back through attention's output matrix
            self::addOuter($G['o'], $f['mix'][$t], $dh1);
            $dmix = self::mulT($dh1, $W['o']);
            $de[$t] = $dh1;
            // back through the blend: to each value, and to each attention weight
            $da = []; $dot = 0.0;
            foreach ($f['a'][$t] as $s => $as) {
                $g = 0.0;
                foreach ($dmix as $i => $dm) { $g += $dm * $f['v'][$s][$i]; $dv[$s][$i] = ($dv[$s][$i] ?? 0.0) + $as * $dm; }
                $da[$s] = $g; $dot += $as * $g;
            }
            // back through the softmax, then through the query·key scores
            foreach ($f['a'][$t] as $s => $as) {
                $ds = $as * ($da[$s] - $dot) * $scale;
                foreach ($f['k'][$s] as $i => $ki) {
                    $dq[$t][$i] = ($dq[$t][$i] ?? 0.0) + $ds * $ki;
                    $dk[$s][$i] = ($dk[$s][$i] ?? 0.0) + $ds * $f['q'][$t][$i];
                }
            }
        }
        // finally, back into the embeddings of each token and each position
        for ($t = 0; $t < $n; $t++) {
            foreach (['q' => $dq, 'k' => $dk, 'v' => $dv] as $name => $D) {
                if (!isset($D[$t])) continue;
                self::addOuter($G[$name], $f['e'][$t], $D[$t]);
                $de[$t] = self::add($de[$t], self::mulT($D[$t], $W[$name]));
            }
            $G['tok'][$f['x'][$t]] = self::add($G['tok'][$f['x'][$t]], $de[$t]);
            $G['pos'][$t] = self::add($G['pos'][$t], $de[$t]);
        }
        return $loss;
    }

    /* ---------- ONE TRAINING STEP ---------- */
    public function trainStep(array $data, int $batch, float $lr, int $seed): float
    {
        seed_rng($seed * 1000003 + $this->steps);   // step N always picks the same windows, however training is split up
        $G = [];
        foreach ($this->w as $name => $M) $G[$name] = array_fill(0, count($M), array_fill(0, count($M[0]), 0.0));
        $loss = 0.0;
        $ring = array_merge($data, array_slice($data, 0, $this->T + 1));   // wrap around, so the first lines get as much practice as the rest
        for ($b = 0; $b < $batch; $b++) {
            $start = mt_rand(0, count($data) - 1);
            $x = array_slice($ring, $start, $this->T);          // a window of the training text…
            $y = array_slice($ring, $start + 1, $this->T);      // …and the same window moved on by one: the right answers
            $loss += $this->backward($this->forward($x), $y, $G);
        }
        $n = $batch * $this->T;
        // ADAM: gradient descent with momentum (lesson 6), where each weight also
        // gets its own step size based on how big its gradients have been.
        $this->steps++;
        $b1 = 0.9; $b2 = 0.99;
        $c1 = 1 - $b1 ** $this->steps; $c2 = 1 - $b2 ** $this->steps;
        foreach ($G as $name => $rows) {
            foreach ($rows as $i => $row) {
                $wr = &$this->w[$name][$i]; $mr = &$this->m[$name][$i]; $sr = &$this->s[$name][$i];
                foreach ($row as $j => $g) {
                    $g /= $n;
                    $mr[$j] = $b1 * $mr[$j] + (1 - $b1) * $g;
                    $sr[$j] = $b2 * $sr[$j] + (1 - $b2) * $g * $g;
                    $wr[$j] -= $lr * ($mr[$j] / $c1) / (sqrt($sr[$j] / $c2) + 1e-8);
                }
                unset($wr, $mr, $sr);
            }
        }
        return $loss / $n;
    }

    /* ---------- WRITING: predict, pick one, add it to the text, repeat ---------- */
    public function generate(array $context, int $count, float $temperature, ?string $stopAt = null): array
    {
        $picked = [];
        if (!$context) $context = [0];                          // nothing it can read: start from the first token
        for ($c = 0; $c < $count; $c++) {
            $window = array_slice($context, -$this->T);        // it can only see the last T tokens
            $f = $this->forward($window, $temperature);
            $p = end($f['p']);                                  // probabilities for the token after the last one
            $r = rnd(); $next = count($p) - 1;                  // pick one at random, weighted by probability
            foreach ($p as $i => $pi) { $r -= $pi; if ($r <= 0) { $next = $i; break; } }
            $context[] = $next;
            $picked[] = [$next, $p[$next]];
            if ($stopAt !== null && $this->vocab[$next] === $stopAt) break;
        }
        return $picked;
    }

    public function export(): array { return ['w' => $this->w, 'm' => $this->m, 's' => $this->s, 'steps' => $this->steps]; }
    public function import(array $a): void { $this->w = $a['w']; $this->m = $a['m']; $this->s = $a['s']; $this->steps = (int) $a['steps']; }
}