SONUB
EN English 한국어 日本語 中文 हिन्दी বাংলা اردو Español Português Français Deutsch Русский العربية Bahasa Indonesia Tiếng Việt ไทย Türkçe

How to enable notifications

Notifications are unavailable in this browser or on this address. Try a supported browser on the secure SONUB site.

If notifications are blocked, allow them in your browser settings.

  1. Open the site controls beside the address bar, or your browser’s site settings. Set Notifications for this site to Allow.
  2. In your device settings, allow notifications for your browser and check Do Not Disturb or Focus.
  3. Return to SONUB and reload the page. Once notifications are on, the bell disappears.

On iPhone or iPad, add SONUB to your Home Screen, open it there, then allow notifications.

Log in Log out Display theme Default theme Dark theme Pink theme Chat 1:1 Chat Group Chat Forum Notices News Suggestions Introductions Main menu

Now, let's build your very own AI.

Write a complete tiny and its gradients. Build engine.py and model.py — , and an — then check the output shape.

A row of word blocks ends in an empty slot. Above it, three candidate blocks have bars of different heights; one is picked, attached to the end of the row, and an arrow loops back to choose again.
5

GPT architecture

looks up a of numbers for each . A records where it appears. asks what to look for, key describes each earlier position, and value carries its information. combine those values.

The uses one causal attention head and one block. keys and values contain only the current and earlier positions, so the model cannot peek ahead. preserve the input; a two-layer changes features. stabilizes their scale; the output head scores tokens. For readable , this uses , parameter-free normalization and no .

The whole structure — from token IDs to next-token probabilities

’s does the calculation below once for each input token. The structure is a like , just with 1 block and 1 head.

  • Take a vector (8 slots) from the embedding table by , take a position vector by position number, and add them.
  • Normalize, mix in information from earlier positions with causal attention, and add it to the original vector (residual).
  • Normalize again, process with the MLP, and add it to the original vector again (residual).
  • Finally, normalize and use the output head (head) to give a score () for each of the 40 vocabulary tokens.
  • Turning the scores into with softmax gives the next-token probabilities.

A real GPT stacks the middle block (attention + MLP) many times. GPT-2 Small stacks 12, GPT-3 stacks 96. Each block takes the vectors made by the previous block and refines them a little more. A block’s have the same shape, so you can stack as many as you like.

One GPT pass — from token IDs to next-token probabilities
  1. Token IDs
  2. Embedding + position
  3. Causal attention
  4. MLP
  5. Output head scores
  6. Next-token probabilities

Embedding — an ID picks one row of a table

An embedding is a table of vocabulary size × width. In XSLM it is 40 rows × 8 slots. If the token ID is 5, it just takes the 8 numbers in row five (counting from 0). It is a table lookup with no multiplication and no search.

Why turn into vectors instead of using them directly? An ID has only size and order, so it cannot hold relations like "9 and 23 are similar". A vector of 8 numbers can be near or far from others in many directions, so can move the vectors of tokens used in similar ways closer together.

The embedding table is also a model weight. It starts as random numbers, and training adjusts it in the direction that lowers the . No separately built is imported. In the experiment below, look at the randomly initialized vector of token 5 and the vector of position 0, and count the sizes of the 9 .

Check it yourself lab/embedding.py
from model import GPT

m = GPT(40, 8, 12)
print('token 5   :', [round(v.data, 3) for v in m.weights['token'][5]])
print('position 0:', [round(v.data, 3) for v in m.weights['position'][0]])
for name, matrix in m.weights.items():
    rows, cols = len(matrix), len(matrix[0])
    print(name, rows, 'x', cols, '=', rows * cols)
print('total', len(m.parameters))
cd ~/xslm
mkdir -p lab
micro lab/embedding.py
python3 -m lab.embedding

Output

token 5   : [-0.081, 0.024, 0.043, -0.149, 0.005, 0.131, -0.201, -0.032]
position 0: [-0.102, -0.036, 0.089, 0.01, -0.085, -0.084, 0.267, 0.114]
token 40 x 8 = 320
position 12 x 8 = 96
query 8 x 8 = 64
key 8 x 8 = 64
value 8 x 8 = 64
project 8 x 8 = 64
expand 16 x 8 = 128
contract 8 x 16 = 128
head 40 x 8 = 320
total 1248

Embedding space — close means similar

An embedding writes a thing or a word as a few numbers. Each number is one axis. The map below writes things with 2 numbers: speed (across) and size (up and down). 0 is the slowest and smallest, and 1 is the fastest and biggest. A cow is [0.1, 0.8], so it is slow and big. An eagle is [0.9, 0.4], so it is fast and small.

Once things are numbers, you can measure "how similar" as a distance. The between two points [x₁, y₁] and [x₂, y₂] is √((x₁ − x₂)² + (y₁ − y₂)²). The shorter the distance, the more similar they are. Measured from the alarm clock [0, 0], it looks like this.

  • Bicycle [0.3, 0.3]: √(0.09 + 0.09) = 0.424
  • Cow [0.1, 0.8]: √(0.01 + 0.64) = 0.806
  • Eagle [0.9, 0.4]: √(0.81 + 0.16) = 0.985

So the thing most like the alarm clock is the bicycle. On the map, click a point to make it the starting point, or click an empty spot to start from there. The closest-first order is worked out again right away.

embeddings use the same idea, with two differences. There are not 2 axes but hundreds to thousands (XSLM has 8), and training decides what each axis means, not people. So the axes have no names like "speed", but the vectors of tokens that are used in similar ways gather close together.

Measuring distance is used all over AI services. A finds the documents closest to the embedding of a question, and hands those documents to the along with the question. like MariaDB do the same math with a VECTOR column and the VEC_DISTANCE_EUCLIDEAN function. The experiment below works out the same map in .

An embedding map written with 2 numbers: speed and size. Click a point or an empty spot to measure the closest-first order from there.

Size

1 Big0.50 Small

0 Slow0.51 Fast

Speed

Closest first

Starting point Alarm clock [0, 0]

  1. 1 Alarm clock 0.000
  2. 2 Bicycle 0.424
  3. 3 Me 0.680
  4. 4 Cow 0.806
  5. 5 Elephant 0.906
  6. 6 Ship 0.949
  7. 7 Eagle 0.985
  8. 8 Horse 0.990
  9. 9 Car 1.238
  10. 10 Rocket 1.372

Distance = √((x₁ − x₂)² + (y₁ − y₂)²)

Check it yourself lab/distance.py
import math

# A 2-number embedding: [speed, size]. 0 is the slowest and smallest, 1 the fastest and biggest.
things = {
    'alarm clock': [0.0, 0.0],
    'cow': [0.1, 0.8],
    'bicycle': [0.3, 0.3],
    'eagle': [0.9, 0.4],
    'elephant': [0.1, 0.9],
    'me': [0.2, 0.65],
    'horse': [0.7, 0.7],
    'car': [0.9, 0.85],
    'ship': [0.3, 0.9],
    'rocket': [0.99, 0.95],
}


def distance(a, b):
    return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)


for query in [[0.0, 0.0], [0.1, 0.8], [0.5, 0.5]]:
    print('from', query)
    nearest = sorted(things, key=lambda name: distance(things[name], query))
    for name in nearest[:4]:
        print(f'  {name:12} {distance(things[name], query):.3f}')
cd ~/xslm
mkdir -p lab
micro lab/distance.py
python3 -m lab.distance

Output

from [0.0, 0.0]
  alarm clock  0.000
  bicycle      0.424
  me           0.680
  cow          0.806
from [0.1, 0.8]
  cow          0.000
  elephant     0.100
  me           0.180
  ship         0.224
from [0.5, 0.5]
  horse        0.283
  bicycle      0.283
  me           0.335
  eagle        0.412

Position embedding — tells which position a token is in

Attention ignores order when mixing earlier positions. Without position information, the token vectors of "dog can run" and "run can dog" form the same set. So each position gets one learned position vector that is added to the token vector.

XSLM’s position table is (12) rows × 8 slots. With only 12 rows, at most 12 tokens can go in at once. The on the first line of forward checks this, and that is why generate passes only the last 12 tokens, as in ids[-12:]. This limit is called the context length (). GPT-2 uses 1,024 tokens; recent models use hundreds of thousands.

GPT-2 and XSLM learn a vector for each position. The original Transformer paper computed position vectors with sin and cos, and recent models often use , which rotates Query and Key according to position. The methods differ, but the goal is the same: to let the model know the order.

Linear layers, normalization and ReLU — the three calculations a model is made of

Three in engine.py do most of the model’s math.

  • linear(x, W): takes the of x with each row of matrix W (multiply matching slots and add them all). If W has 3 rows, the result has 3 slots. In the experiment, the dot product of x = [1, 2] and the row [0.5, 0.5] is 0.5 + 1.0 = 1.5. Changing the number of rows lets you grow or shrink the number of slots in a vector.
  • norm(x): subtracts the mean of the slots and divides by the standard deviation, giving mean 0 and variance 1. [2, 4, 6] becomes [-1.225, 0, 1.225]. It keeps numbers from getting too big or too small as calculations pile up. It adds 1e-5 to the variance to avoid dividing by 0.
  • relu(v): turns negatives into 0 and leaves positives as they are.

A nonlinear function like ReLU is essential. Two linear layers joined together are still just one linear layer, so no matter how many you stack, they can only express straight-line relations. A bending function in between lets the model learn complex relations.

XSLM’s linear has no added value (bias), and its norm has no learned scale and shift (γ, β). ’s nn.Linear and nn.LayerNorm have both.

Check it yourself lab/linear.py
from engine import linear, norm

x = [1.0, 2.0]
W = [[1.0, 0.0], [0.5, 0.5], [-1.0, 1.0]]
print('linear:', linear(x, W))
print('norm  :', [round(v, 3) for v in norm([2.0, 4.0, 6.0])])
print('relu  :', [max(0.0, v) for v in [-2.0, 0.5, 3.0]])
cd ~/xslm
mkdir -p lab
micro lab/linear.py
python3 -m lab.linear

Output

linear: [1.0, 1.5, 1.0]
norm  : [-1.225, 0.0, 1.225]
relu  : [0.0, 0.5, 3.0]

Attention — mixing earlier positions with Query, Key and Value

Attention decides which earlier positions the current position should consult and how much, and mixes their information. Each position makes three vectors.

  • Query (q): what the current position is looking for.
  • Key (k): the feature each position shows, saying "I have this kind of information".
  • Value (v): the content each position actually passes on.

The calculation has four steps.

  • Score: the dot product of the current q with each position’s k, divided by √width (√8 ≈ 2.83 in XSLM).
  • Weight: softmax turns the scores into weights that add up to 1.
  • Mix: multiply each position’s v by its weight and add them up.
  • Tidy: transform the mixed result once more with the project matrix and add it to the original vector.

As a formula, it is softmax(q·kᵀ / √d)·v. The experiment below does this calculation with 3 handmade keys and one query (width 2, so it divides by √2). The query [2, 0] looks for the first feature, so positions 1 and 3, which have it, get 0.446 each, and position 2, which lacks it, gets 0.108. The result is [6.687, 3.313], leaning toward the values of positions 1 and 3.

For example, in "the cat sat because it was tired", attention at the position of it can learn to give a large weight to the position of cat. Real models use several heads (12 in GPT-2 Small), and each head looks at a different relation (what a word refers to, the word just before, format markers and so on).

Check it yourself lab/attention.py
from engine import Value, softmax

keys = [[1, 0], [0, 1], [1, 1]]
values = [[10, 0], [0, 10], [5, 5]]
query = [2, 0]
scores = [sum(q * k for q, k in zip(query, key)) / 2 ** 0.5 for key in keys]
weights = softmax([Value(s) for s in scores])
mixed = [sum(w.data * v[i] for w, v in zip(weights, values)) for i in range(2)]
print('scores ', [round(s, 3) for s in scores])
print('weights', [round(w.data, 3) for w in weights])
print('mixed  ', [round(m, 3) for m in mixed])
cd ~/xslm
mkdir -p lab
micro lab/attention.py
python3 -m lab.attention

Output

scores  [1.414, 0.0, 1.414]
weights [0.446, 0.108, 0.446]
mixed   [6.687, 3.313]

Causal mask — no peeking at future tokens

During training, the whole window goes in at once and each position must predict its next token. If a position could see the tokens after it, it would be peeking at the answer and would learn nothing. So each position sees only itself and earlier positions. This is called causal attention.

The PyTorch implementation fills the upper part of the score table above the diagonal (the future) with −∞ so it becomes 0 after softmax (a lower-triangular mask). XSLM is simpler. It processes tokens one by one from the front and keeps only what has come so far in the keys and values , so future tokens are not in the lists yet.

So adding tokens at the end must not change the results for earlier positions. The experiment below feeds [1, 2] and [1, 2, 3] and checks that the scores for the first two positions are identical. True is evidence that it does not look at the future.

These keys and values lists are the same idea as the in real models. If you store the keys and values of earlier tokens while generating, you only need to compute the one new token. XSLM’s generate keeps it simple and recomputes the whole context for every token.

Check it yourself lab/causal.py
from model import GPT

m = GPT(10, 8, 12)
short = m.forward([1, 2])
long = m.forward([1, 2, 3])
same = [[v.data for v in r] for r in short] == [[v.data for v in r] for r in long[:2]]
print('first two positions unchanged:', same)
print('scores at position 3:', [round(v.data, 3) for v in long[2][:4]], '...')
cd ~/xslm
mkdir -p lab
micro lab/causal.py
python3 -m lab.causal

Output

first two positions unchanged: True
scores at position 3: [0.159, 0.011, -0.239, 0.428] ...

softmax — scores to probabilities, and numerical stability

softmax turns a list of scores into probabilities between 0 and 1 that add up to 1. It applies exp to each score and divides by the total. exp is always positive and makes big scores much bigger, so it keeps the order while widening the gaps. In the experiment, the scores [2, 1, 0.1] become the probabilities [0.659, 0.242, 0.099].

The problem is that exp grows very fast. exp(1000) goes beyond the range a computer’s number can hold and raises OverflowError (the math range error in the experiment). So the softmax in engine.py first subtracts the largest score (peak) from every score. Subtracting the same number from every score does not change the probabilities, and the largest value becomes exp(0) = 1, so nothing overflows. [1000, 999] also comes out correctly as [0.731, 0.269].

softmax is used twice: to get the attention weights and to turn output head scores into next-token probabilities.

Check it yourself lab/softmax.py
import math
from engine import Value, softmax

probs = softmax([Value(s) for s in [2.0, 1.0, 0.1]])
print([round(p.data, 3) for p in probs], round(sum(p.data for p in probs), 3))
try:
    print([math.exp(s) for s in [1000.0, 999.0]])
except OverflowError as error:
    print('without max:', error)
print('with max:', [round(p.data, 3) for p in softmax([Value(1000.0), Value(999.0)])])
cd ~/xslm
mkdir -p lab
micro lab/softmax.py
python3 -m lab.softmax

Output

[0.659, 0.242, 0.099] 1.0
without max: math range error
with max: [0.731, 0.269]

MLP — processing information at each position

If attention gathers information by deciding "where to look", the MLP (multilayer perceptron, also called ) handles "how to transform what was gathered". It computes each position separately, with the same weights.

  • expand: widens 8 slots to 16.
  • ReLU: bends negatives to 0.
  • contract: narrows 16 slots back to 8 and adds the result to the original vector.

The reason to widen and then narrow is to give room, in a wider space, to switch combinations of features on and off separately. Most of a big GPT’s are in this MLP, and knowledge such as learned facts is thought to build up mainly here.

XSLM widens by 2 times and uses ReLU. GPT-2 widens by 4 times and use . GELU is a function that bends smoothly near 0 and trains a little better in big models. XSLM chose ReLU because its is easy to read by hand.

Residual connections and pre-normalization — trainable even when stacked deep

The line x = [a + b for a, b in zip(x, …)] in model.py is a residual connection. Instead of replacing x with the layer’s result, it adds the result to the original x.

  • The original information stays, so the layer only needs to learn the difference to fix.
  • In backward, addition passes the through unchanged, so gradients flow by a shortcut all the way to earlier layers. Even with dozens of blocks stacked, gradients do not vanish.

Where normalization happens also matters. Like GPT-2, XSLM applies norm before attention, before the MLP and before the output head (Pre-LN). It normalizes only the values going into a layer and leaves the residual untouched, so even deep models train stably. The original Transformer normalized after the addition (Post-LN).

As formulas, one block is this.

  • x = x + Attention(norm(x))
  • x = x + MLP(norm(x))

Output head — a score (logit) for each vocabulary entry

After the block, the vector is normalized one last time and multiplied by the head matrix (40 rows × 8 slots), giving one score for each of the 40 vocabulary tokens. These raw scores are called logits. Logits can be negative and do not add up to 1; they become probabilities only after softmax.

forward returns one score list for each input position. Feed 2 tokens and you get 2 × 40 scores. Training uses the scores of every position (each position tries to predict its next token), while generation uses only the scores of the last position, [-1].

GPT-2 does not keep a separate output head matrix; it shares the token embedding table (weight tying). This reduces parameters and uses the same vectors for reading and producing tokens. XSLM keeps them separate to show their different roles.

Automatic differentiation — following the computation graph backward

To train, you need to know how much the loss changes when each weight changes a little (the gradient). Wiggling all 1,248 one at a time would be far too slow, so the of derivatives finds them all at once. This is called , and the tool that does it automatically is (autograd). Value in engine.py is exactly that tool.

  • Every time Value computes, it records the result along with (input, local derivative with respect to that input) in parents. Addition gives 1, multiplication the other value, exp the result itself, 1/x, and ReLU 1 if positive, otherwise 0.
  • backward starts from the loss with gradient 1.0 and walks the graph in reverse, adding "my gradient × local derivative" to each input’s gradient (the chain rule).
  • If one value is used in several places, the gradients from every path are added up. That is why grad accumulates with +=, not =.

In the experiment below, y = x² + 2x differentiates by hand to 2x + 2, which is 8 at x = 3. The 8.0 from autograd matches the numerical derivative of 8.0 measured by moving x a tiny bit. When a is used twice, as in c = a·b + a, dc/da = b + 1 = 6. The gradients from the two paths were added.

PyTorch’s loss.backward() does the same thing. It just records and computes in tensors instead of single numbers.

Check it yourself lab/grad.py
from engine import Value

x = Value(3)
y = x * x + 2 * x
y.backward()
f = lambda v: v * v + 2 * v
h = 1e-6
print('autograd:', x.grad)
print('numeric :', round((f(3 + h) - f(3 - h)) / (2 * h), 6))
a, b = Value(2), Value(5)
c = a * b + a
c.backward()
print('dc/da =', a.grad, ' dc/db =', b.grad)
cd ~/xslm
mkdir -p lab
micro lab/grad.py
python3 -m lab.grad

Output

autograd: 8.0
numeric : 8.0
dc/da = 6.0  dc/db = 2.0

Counting parameters — where are the 1,248?

Parameters are the individual numbers that training adjusts. They are also called weights. XSLM holds all its parameters in 9 matrices, and adding up the slots of every matrix gives 1,248. Compare the table below with the output of the earlier embedding experiment.

You can read a few things from the table.

  • The two vocabulary-bound tables, token and head, hold 640 numbers, more than half. Growing the vocabulary grows these two tables.
  • The 4 attention matrices (query, key, value, project) hold 256, and the 2 MLP matrices hold 256. Doubling the width makes this part 4 times bigger.
  • In real models, blocks (attention + MLP) are stacked dozens of times and take up most of the parameters.

The B in " model" means billion: 8 billion parameters. Storage size is the number of parameters × the size of one number. With floats (4 ) that is about 32GB, with 16-bit 16GB, reduced to 8-bit () 8GB, and 4-bit about 4GB. Running it needs extra on top for intermediate values and the KV cache. XSLM’s model.json is with numbers written as text, so even with 1,248 numbers it is about 35KB.

The 9 XSLM weights — default data (40-token vocabulary), width 8, context 12
Weight name Shape (rows × columns) Number count What it does
token 40 × 8 320 One vector row per token ID (token embedding)
position 12 × 8 96 One vector row per position (position embedding)
query 8 × 8 64 What the current position looks for (Query)
key 8 × 8 64 The feature each position shows (Key)
value 8 × 8 64 The content each position passes on (Value)
project 8 × 8 64 Reshapes the attention result
expand 16 × 8 128 First MLP layer — widens 8 slots to 16
contract 8 × 16 128 Second MLP layer — narrows 16 slots to 8
head 40 × 8 320 Next-token score for each vocabulary entry (output head)

What XSLM cut — differences from a real GPT

XSLM removed or reduced a few things to stay readable. Here is how it compares with GPT-2 Small.

  • Number of blocks: XSLM 1, GPT-2 Small 12. The more blocks you stack, the more complex the relations it can learn.
  • : XSLM 1, GPT-2 Small 12 (each head 768 ÷ 12 = 64 slots wide). Multiple heads look at different relations at the same time.
  • MLP: XSLM 2 times with ReLU, GPT-2 4 times with GELU.
  • bias and the normalization γ and β: none in XSLM, present in GPT-2.
  • dropout: none in XSLM. It is an guard that randomly sets some of the values (usually 10%) to 0 during training so the model does not rely on any particular path. It is switched off when generating.
  • Initial values: a normal distribution with standard deviation 0.1 in XSLM and 0.02 in GPT-2.
  • How it computes: XSLM one number at a time; PyTorch computes tensors shaped (batch B, length T, width C) all at once on the .

Everything removed is machinery for training bigger and more stably. The core calculation that produces next-token probabilities is the same.

If you know embeddings only as vectors for search, it is easy to misunderstand that "GPT picks the next word by finding the closest vector". In reality it works like this.

  • Vector search (RAG and the like): documents are turned into vectors and stored, and the documents closest to the question vector are found and taken out. The result is a document that already existed.
  • GPT’s prediction: the blocks transform the input vectors by calculation, then the output head scores the whole vocabulary, and one token is sampled from those probabilities. The result is a newly calculated distribution.

Attention is not search either. It only computes weights among the earlier positions inside the input sentence and mixes them; it does not look up outside documents.

The two are used together. Real services use the question to find related documents by vector search, attach those documents to the , and have GPT generate the answer (RAG). Search handles up-to-date information; GPT handles writing sentences.

Type and try

Write engine.py first and run its derivative check; then write model.py. In Value, each operation remembers its local derivative. backward visits the graph in reverse order and adds contributions, so shared parameters learn from every use. Trace token → position → attention → MLP → head in forward.

cd ~/xslm
micro engine.py
micro model.py
python3 engine.py
python3 -c "from model import GPT; m=GPT(10, 8, 12); print(len(m.forward([1, 2])), len(m.forward([1, 2])[0]))"
Practice in the terminal
engine.pyComplete source to type

is a number; grad is its effect on the loss; parents store local derivatives. Addition distributes a gradient; multiplication scales it by the other input. softmax converts scores to probabilities.

import math


class Value:
    def __init__(self, data, parents=()):
        self.data = float(data)
        self.grad = 0.0
        self.parents = parents

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data + other.data, ((self, 1), (other, 1)))

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data * other.data,
                     ((self, other.data), (other, self.data)))

    def __pow__(self, power):
        return Value(self.data ** power,
                     ((self, power * self.data ** (power - 1)),))

    def __neg__(self):
        return self * -1

    def __sub__(self, other):
        return self + (-other)

    def __truediv__(self, other):
        return self * (other ** -1)

    __radd__ = __add__
    __rmul__ = __mul__

    def exp(self):
        result = math.exp(self.data)
        return Value(result, ((self, result),))

    def log(self):
        return Value(math.log(self.data), ((self, 1 / self.data),))

    def relu(self):
        return Value(max(0, self.data), ((self, float(self.data > 0)),))

    def backward(self):
        order, seen, stack = [], set(), [(self, False)]
        while stack:
            node, expanded = stack.pop()
            if expanded:
                order.append(node)
            elif node not in seen:
                seen.add(node)
                stack.append((node, True))
                stack.extend((parent, False) for parent, _ in node.parents)
        for node in order:
            node.grad = 0.0
        self.grad = 1.0
        for node in reversed(order):
            for parent, derivative in node.parents:
                parent.grad += derivative * node.grad


def softmax(values):
    peak = max(v.data for v in values)
    exps = [(v - peak).exp() for v in values]
    total = sum(exps)
    return [v / total for v in exps]


def linear(x, weights):
    return [sum(a * b for a, b in zip(x, row)) for row in weights]


def norm(x):
    mean = sum(x) / len(x)
    variance = sum((v - mean) ** 2 for v in x) / len(x)
    return [(v - mean) * (variance + 1e-5) ** -0.5 for v in x]


if __name__ == '__main__':
    x = Value(3)
    y = x * x + 2 * x
    y.backward()
    print('y =', y.data, 'dy/dx =', x.grad)
    assert y.data == 15 and x.grad == 8
model.pyComplete source to type

Read shapes first, then forward. Every Value in weights is trainable. Lists store vectors and matrices. loss compares each position with its next-token target. state and load serialize numbers, not .

import random
from engine import Value, linear, norm, softmax


class GPT:
    def __init__(self, vocab_size, width, context, seed=7):
        self.width, self.context = width, context
        rng = random.Random(seed)
        shapes = {
            'token': (vocab_size, width), 'position': (context, width),
            'query': (width, width), 'key': (width, width),
            'value': (width, width), 'project': (width, width),
            'expand': (2 * width, width), 'contract': (width, 2 * width),
            'head': (vocab_size, width),
        }
        self.weights = {
            name: [[Value(rng.gauss(0, 0.1)) for _ in range(cols)]
                   for _ in range(rows)] for name, (rows, cols) in shapes.items()
        }
        self.parameters = [v for matrix in self.weights.values()
                           for row in matrix for v in row]

    def forward(self, ids):
        assert 0 < len(ids) <= self.context
        w, keys, values, output = self.weights, [], [], []
        for position, token in enumerate(ids):
            x = [a + b for a, b in zip(w['token'][token], w['position'][position])]
            h = norm(x)
            q = linear(h, w['query'])
            keys.append(linear(h, w['key']))
            values.append(linear(h, w['value']))
            scores = [sum(a * b for a, b in zip(q, k)) / self.width ** 0.5
                      for k in keys]
            attention = softmax(scores)
            mixed = [sum(p * v[i] for p, v in zip(attention, values))
                     for i in range(self.width)]
            x = [a + b for a, b in zip(x, linear(mixed, w['project']))]
            hidden = [v.relu() for v in linear(norm(x), w['expand'])]
            x = [a + b for a, b in zip(x, linear(hidden, w['contract']))]
            output.append(linear(norm(x), w['head']))
        return output

    def loss(self, inputs, targets):
        logits = self.forward(inputs)
        losses = [-softmax(row)[target].log()
                  for row, target in zip(logits, targets)]
        return sum(losses) / len(losses)

    def state(self):
        return {name: [[v.data for v in row] for row in matrix]
                for name, matrix in self.weights.items()}

    def load(self, state):
        assert set(state) == set(self.weights), 'checkpoint keys'
        for name, matrix in self.weights.items():
            assert len(state[name]) == len(matrix), 'checkpoint rows'
            for row, saved in zip(matrix, state[name]):
                assert len(saved) == len(row), 'checkpoint columns'
                for value, number in zip(row, saved):
                    value.data = float(number)

Line-by-line walkthrough

engine.py Value — one number and its history

class Value:
    def __init__(self, data, parents=()):
        self.data = float(data)
        self.grad = 0.0
        self.parents = parents

Value wraps one number. data is the value, grad is the derivative of the loss with respect to this value (0 at first), and parents is the list of inputs that made this value along with the local derivative for each input. Values that exist from the start, like weights, have empty parents. It converts with float(data), so even if you pass an integer, the math is done in floating point.

engine.py Operators — recording derivatives while computing

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data + other.data, ((self, 1), (other, 1)))

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        return Value(self.data * other.data,
                     ((self, other.data), (other, self.data)))

    def __pow__(self, power):
        return Value(self.data ** power,
                     ((self, power * self.data ** (power - 1)),))

    def __neg__(self):
        return self * -1

    def __sub__(self, other):
        return self + (-other)

    def __truediv__(self, other):
        return self * (other ** -1)

    __radd__ = __add__
    __rmul__ = __mul__

Python turns a + b into a call to a.__add__(b). Defining these special methods lets Value objects be computed like ordinary formulas.

  • __add__: result = a + b. The local derivative is 1 for a and 1 for b.
  • __mul__: result = a × b. The local derivative is the value of b for a, and the value of a for b.
  • __pow__: a to the power n. The local derivative is n × a to the power (n − 1).
  • __neg__, __sub__, __truediv__: subtraction becomes multiplying by −1 and adding, and division becomes multiplying by the power −1, reusing the three above. No new derivative rules are needed.
  • If other is not a Value (an ordinary number like 2), it is wrapped in a Value.
  • __radd__ and __rmul__: called when the left side is an ordinary number, as in 2 * x. sum() starts with 0 + the first value, so __radd__ is needed to sum a list of Values.

engine.py exp, log, relu — derivatives of functions

    def exp(self):
        result = math.exp(self.data)
        return Value(result, ((self, result),))

    def log(self):
        return Value(math.log(self.data), ((self, 1 / self.data),))

    def relu(self):
        return Value(max(0, self.data), ((self, float(self.data > 0)),))

The derivative of exp is itself, so it records the result value result as is. The derivative of log x is 1/x. relu records a gradient of 1 if positive and 0 if 0 or less. float(self.data > 0) is 1.0 if true and 0.0 if false. These three plus the four basic operations are all you need to find the derivatives of the whole model.

engine.py backward — collecting gradients while walking the graph in reverse

    def backward(self):
        order, seen, stack = [], set(), [(self, False)]
        while stack:
            node, expanded = stack.pop()
            if expanded:
                order.append(node)
            elif node not in seen:
                seen.add(node)
                stack.append((node, True))
                stack.extend((parent, False) for parent, _ in node.parents)
        for node in order:
            node.grad = 0.0
        self.grad = 1.0
        for node in reversed(order):
            for parent, derivative in node.parents:
                parent.grad += derivative * node.grad

There are two stages.

  • Building the order (lines 46–54): explores the graph depth-first with a stack to build order. When a node is first popped as (node, False), it is marked in seen, (node, True) is pushed again, and then its parents are pushed. Because (node, True) is added to order only when it is popped after all its parents are processed, inputs always come before results in order (topological sort).
  • Spreading gradients (lines 55–60): clears grad of every node in the graph to 0 and sets the loss itself to 1.0 (dL/dL = 1). It walks order in reverse (from results to inputs) and adds "local derivative × my grad" to each parent. Because it walks in reverse, a node’s grad is passed to its parents only after it has been collected from every use.

engine.py softmax — probabilities that never overflow

def softmax(values):
    peak = max(v.data for v in values)
    exps = [(v - peak).exp() for v in values]
    total = sum(exps)
    return [v / total for v in exps]

peak is the largest of the scores (an ordinary number taken out with .data). It subtracts peak from every score before exp, so the largest value becomes 1 and nothing overflows. Dividing by the sum total gives probabilities that add up to 1. peak is subtracted as an ordinary number, so it is not recorded in the graph, but subtracting the same number from every score does not change the probabilities, so the derivatives remain correct.

engine.py linear and norm — matrix multiplication and normalization

def linear(x, weights):
    return [sum(a * b for a, b in zip(x, row)) for row in weights]


def norm(x):
    mean = sum(x) / len(x)
    variance = sum((v - mean) ** 2 for v in x) / len(x)
    return [(v - mean) * (variance + 1e-5) ** -0.5 for v in x]

linear gives, for each row of weights, the sum of x multiplied slot by slot with that row. So weights is (number of output slots) rows × (number of input slots) slots, and the result has as many slots as there are rows. PyTorch’s nn.Linear also stores weights in the shape (output, input). norm finds the mean mean and the variance variance, then turns each value into (value − mean) ÷ √(variance + 0.00001). Raising to the power -0.5 is the same as dividing by the square root.

engine.py Checking itself

if __name__ == '__main__':
    x = Value(3)
    y = x * x + 2 * x
    y.backward()
    print('y =', y.data, 'dy/dx =', x.grad)
    assert y.data == 15 and x.grad == 8

Running engine.py directly computes y = x² + 2x at x = 3 and differentiates it. y = 9 + 6 = 15 and dy/dx = 2·3 + 2 = 8 must hold, and assert checks this. If you change the engine, run this check again first.

model.py Creating the 9 weights

class GPT:
    def __init__(self, vocab_size, width, context, seed=7):
        self.width, self.context = width, context
        rng = random.Random(seed)
        shapes = {
            'token': (vocab_size, width), 'position': (context, width),
            'query': (width, width), 'key': (width, width),
            'value': (width, width), 'project': (width, width),
            'expand': (2 * width, width), 'contract': (width, 2 * width),
            'head': (vocab_size, width),
        }
        self.weights = {
            name: [[Value(rng.gauss(0, 0.1)) for _ in range(cols)]
                   for _ in range(rows)] for name, (rows, cols) in shapes.items()
        }
        self.parameters = [v for matrix in self.weights.values()
                           for row in matrix for v in row]

The shapes sets (rows, slots) for each matrix. A dictionary comprehension makes rows × slots Value(rng.gauss(0, 0.1)) for each name. These are small numbers drawn from a normal distribution with mean 0 and standard deviation 0.1. If everything were filled with 0, every slot would move identically and could not learn different features, so it starts randomly. rng is made from seed, so the initial values are always the same. parameters spreads the Values of the 9 matrices into one list: every number training will adjust.

model.py Start of forward — adding token and position

    def forward(self, ids):
        assert 0 < len(ids) <= self.context
        w, keys, values, output = self.weights, [], [], []
        for position, token in enumerate(ids):
            x = [a + b for a, b in zip(w['token'][token], w['position'][position])]
            h = norm(x)

assert limits the input length to at least 1 and at most context. The position table has context rows, so anything longer has no position vector. keys and values are lists that collect the keys and values of earlier positions. Each iteration takes position (from 0) and the token ID, and adds the token vector and position vector slot by slot to make x. h = norm(x) is the normalized value fed into attention (Pre-LN).

model.py One head of causal attention

            q = linear(h, w['query'])
            keys.append(linear(h, w['key']))
            values.append(linear(h, w['value']))
            scores = [sum(a * b for a, b in zip(q, k)) / self.width ** 0.5
                      for k in keys]
            attention = softmax(scores)
            mixed = [sum(p * v[i] for p, v in zip(attention, values))
                     for i in range(self.width)]
            x = [a + b for a, b in zip(x, linear(mixed, w['project']))]

q is the query of the current position, and the key and value of the current position are added to the end of the lists. The lists hold only the positions so far, so the future cannot be seen. scores is the dot product of q with each key ÷ √width, attention is its softmax, and mixed is the vector made by adding the values slot by slot with the attention weights. The last line transforms mixed with project and adds it to x (residual).

model.py The two MLP layers

            hidden = [v.relu() for v in linear(norm(x), w['expand'])]
            x = [a + b for a, b in zip(x, linear(hidden, w['contract']))]

It normalizes x, widens it to 16 slots with expand, and applies relu to each slot to make hidden. contract narrows it back to 8 slots and adds it to x (residual). Unlike attention, it does not look at other positions; it processes only the vector of the current position.

model.py Output head scores

            output.append(linear(norm(x), w['head']))
        return output

Normalizing x one last time and multiplying by head gives as many scores as the vocabulary size, which are collected in output for each position. When the ends, output is a table of (input length) × (vocabulary size) scores.

model.py loss — the average −log probability over positions

    def loss(self, inputs, targets):
        logits = self.forward(inputs)
        losses = [-softmax(row)[target].log()
                  for row, target in zip(logits, targets)]
        return sum(losses) / len(losses)

It gets the scores of each position with forward, takes the log of the probability of the correct target among that position’s softmax probabilities, and flips the sign. It returns the average: the sum of all position losses divided by the number of positions. The result is also a Value, so you can call loss.backward().

model.py state and load — saving and reading weights

    def state(self):
        return {name: [[v.data for v in row] for row in matrix]
                for name, matrix in self.weights.items()}

    def load(self, state):
        assert set(state) == set(self.weights), 'checkpoint keys'
        for name, matrix in self.weights.items():
            assert len(state[name]) == len(matrix), 'checkpoint rows'
            for row, saved in zip(matrix, state[name]):
                assert len(saved) == len(row), 'checkpoint columns'
                for value, number in zip(row, saved):
                    value.data = float(number)

state returns a dictionary of the same shape holding only ordinary numbers (.data) instead of Values. A Value object carries links in the , so it cannot be saved as JSON. load does the reverse and puts the saved numbers into the data of each Value. Before that, it uses assert to check that the matrix names, rows and slots match the current model, so a checkpoint of a different size is never silently misread.

Check your understanding

engine.py must print y = 15.0 and dy/dx = 8.0. The model shape check prints 2 10: two positions, ten vocabulary scores each. Explain why appending a future token must not change the earlier scores.

If you get stuck

Keep matrix rows and zip inputs aligned. A count-based is not a substitute for these layers. This model is deliberately tiny; all embedding, attention, MLP and head weights still learn through backpropagation.

Frequently asked questions

Are weights and vectors the same thing?

The difference is role versus shape. A weight (parameter) refers to the role of a number that training adjusts, and a vector refers to the shape of numbers lined up in a row. The whole embedding table is weights, and one row taken from it is a token’s vector. Vectors produced during calculation, like q, k and v, are not weights but temporary intermediate values.

Where in the model is knowledge stored?

No sentence sits in a fixed slot. Just as a photo is a picture because of the arrangement of all its , not any one pixel, knowledge is spread across the calculation habits that all the weights make together. That is why it is hard to pick out and delete or fix one particular fact.

Why divide by √width?

The dot product of two random vectors of width d swings more widely as d grows (its standard deviation is √d times larger). With large scores, softmax gives almost all of the 1 to a single position, and gradients stop flowing to the other positions. Dividing by √d keeps the size of the scores similar regardless of width.

Why does backward use a stack instead of recursion?

In one , the computation graph is a long chain of more than 30,000 nodes. Following it with a recursive function can exceed Python’s recursion limit (1,000 levels by default) and raise RecursionError. Managing the order yourself with a stack safely builds a topological order (inputs come first) however deep the graph is.

Guidelines

AI

Agent Tools

Vibe Coding

AI in Practice

Certificate

AI Class

IT Basics

Chat

Forum

Create

Community

More

Glossary

Pick a word you don't know. You'll see a simple explanation and related terms.

No matching term yet.

Pick a term to see its explanation.