108 def gpt(token_id, pos_id, keys, values): # ★ in: this letter, and its position ⋯ 143 logits = linear(x, state_dict['lm_head']) 144 return logits # ★ out: 27 raw scores, not yet probabilities
75 n_layer = 1 # depth of the transformer …
76 n_embd = 16 # width of the network …
77 block_size = 16 # maximum context length …
78 n_head = 4 # number of attention heads
79 head_dim = n_embd // n_head # derived dimension …
# ★ 5 hyper-parameters: 1 layer, 16 wide, 4 heads
80 matrix = lambda nout, nin, std=0.08:
↪ [[Value(random.gauss(0, std)) for _ in range(nin)]
↪ for _ in range(nout)]
# ★ matrix: one table; Value = number + notepad
81 state_dict = {'wte': matrix(vocab_size, n_embd),
↪ 'wpe': matrix(block_size, n_embd),
↪ 'lm_head': matrix(vocab_size, n_embd)}
# ★ state_dict: where all knowledge is kept
82 for i in range(n_layer):
83 state_dict[f'layer{i}.attn_wq'] =
↪ matrix(n_embd, n_embd)
⋯
87 state_dict[f'layer{i}.mlp_fc1'] =
↪ matrix(4 * n_embd, n_embd)
⋯
89 params = [p for mat in state_dict.values()
↪ for row in mat for p in row] # flatten …
# ★ params: all 4,192 numbers in one flat list
90 print(f"num params: {len(params)}")
111 x = [t + p for t, p in zip(tok_emb, pos_emb)] # … # ★ x, tok_emb, pos_emb: the vector plus two notes ⋯ 134 x = [a + b for a, b in zip(x, x_residual)] # ★ left: the new x; right: x_residual, the old x, kept ⋯ 141 x = [a + b for a, b in zip(x, x_residual)] # ★ the identical line: once after attention, once after MLP
50 def relu(self): return Value(max(0, self.data), ↪ (self,), (float(self.data > 0),)) # ★ self.data: the number itself; parens = the note ⋯ 139 x = [xi.relu() for xi in x] # all 16 pass the threshold
# relu's _local_grads = (1,) or (0,) # 0 → no gradient flows back this round (Fig 9.5)
103 def rmsnorm(x): # ★ rmsnorm: the volume desk 104 ms = sum(xi * xi for xi in x) / len(x) # ms: mean loudness 105 scale = (ms + 1e-5) ** -0.5 # scale: back to standard 106 return [xi * scale for xi in x] # loudness only
112 x = rmsnorm(x) # before the main line ⋯ 117 x = rmsnorm(x) # before attention ⋯ 137 x = rmsnorm(x) # before the MLP
30 class Value: ⋯ 33 def __init__(self, data, children=(), ↪ local_grads=()): 34 self.data = data # ★ the number itself # forward uses .data only ⋯ 39 def __add__(self, other): ⋯ 41 return Value(self.data + other.data, ↪ (self, other), (1, 1)) # ★ add: compute the sum, note it down # “sources: these two; derivative 1 each”
108 def gpt(token_id, pos_id, keys, values): # ★ gpt: the model itself, this one function 109 tok_emb = state_dict['wte'][token_id] 110 pos_emb = state_dict['wpe'][pos_id] ⋯ 144 return logits # 27 scores, nothing more ⋯ 194 logits = gpt(token_id, pos_id, keys, values) # ★ how the outer loop calls it
189 for sample_idx in range(20): # ★ sample_idx: which name 190 keys, values = [[] for _ in range(n_layer)], ↪ [[] for _ in range(n_layer)] # ★ keys, values: the KV cache itself — append only (Fig 9) 191 token_id = BOS # ★ token_id: the letter in hand 192 sample = [] # ★ sample: letters produced so far 193 for pos_id in range(block_size): 194 logits = gpt(token_id, pos_id, keys, values) # ★ gpt: the whole model in one function; logits: 27 scores 195 probs = softmax([l / temperature for l in logits]) # ★ probs: 27 probabilities; temperature in Fig 8 196 token_id = random.choices(range(vocab_size), ↪ weights=[p.data for p in probs])[0] 197 if token_id == BOS: 198 break 199 sample.append(uchars[token_id])
24 uchars = sorted(set(''.join(docs))) # unique characters …
# ★ uchars: sorted character table; docs: all 32,033 names
25 BOS = len(uchars) # token id for a special Beginning …
# ★ BOS: start-and-end symbol = 26 (hence vocab_size = 27)
26 vocab_size = len(uchars) + 1 # total number of unique …
157 tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS] # ★ tokens: one name as numbers [26, 4, 12, 12, 0, 26]
196 token_id = random.choices(range(vocab_size), ↪ weights=[p.data for p in probs])[0] 197 if token_id == BOS: 198 break
108 def gpt(token_id, pos_id, keys, values): 109 tok_emb = state_dict['wte'][token_id] # token embedding 110 pos_emb = state_dict['wpe'][pos_id] # position embedding # the two notes from earlier — here is where they come from 111 x = [t + p for t, p in zip(tok_emb, pos_emb)] # joint … # addition, on stage for real 112 x = rmsnorm(x) # note: not redundant due to backward … # the volume desk (rmsnorm) starts work
77 block_size = 16 # maximum context length ↪ of the attention window ↪ (note: the longest name is 15 characters)
81 state_dict = {'wte': matrix(vocab_size, n_embd),
↪ 'wpe': matrix(block_size, n_embd),
↪ 'lm_head': matrix(vocab_size, n_embd)}
# wpe has only block_size = 16 rows
110 pos_emb = state_dict['wpe'][pos_id] # position … # pos_id above 15 → this line raises IndexError
114 for li in range(n_layer):
115 # 1) Multi-head Attention block
116 x_residual = x
117 x = rmsnorm(x)
118 q = linear(x, state_dict[f'layer{li}.attn_wq'])
# q, k, v are defined in Fig 4a-0; structure first
⋯
133 x = linear(x_attn, state_dict[f'layer{li}.attn_wo'])
# ★ x_attn: the four head outputs joined (16 dims, Fig 4b-0)
134 x = [a + b for a, b in zip(x, x_residual)]
135 # 2) MLP block
136 x_residual = x
137 x = rmsnorm(x)
138 x = linear(x, state_dict[f'layer{li}.mlp_fc1'])
139 x = [xi.relu() for xi in x]
140 x = linear(x, state_dict[f'layer{li}.mlp_fc2'])
141 x = [a + b for a, b in zip(x, x_residual)]
121 keys[li].append(k) 122 values[li].append(v) ⋯ 129 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))] 130 attn_weights = softmax(attn_logits) 131 head_out = [sum(attn_weights[t] * v_h[t][j] ↪ for t in range(len(v_h))) ↪ for j in range(head_dim)]
138 x = linear(x, state_dict[f'layer{li}.mlp_fc1'])
139 x = [xi.relu() for xi in x]
140 x = linear(x, state_dict[f'layer{li}.mlp_fc2'])
118 q = linear(x, state_dict[f'layer{li}.attn_wq'])
119 k = linear(x, state_dict[f'layer{li}.attn_wk'])
120 v = linear(x, state_dict[f'layer{li}.attn_wv'])
# ★ q / k / v: the query / the spine label / the contents
129 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))] # ★ q_h, k_h: the 4 dims of q, k given to this head
129 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_logits: the scores (2.7 / 0.5 / −1.2)
130 attn_weights = softmax(attn_logits) # ★ attn_weights: shares summing to 1 (0.85 / 0.13 / 0.02)
131 head_out = [sum(attn_weights[t] * v_h[t][j] ↪ for t in range(len(v_h))) ↪ for j in range(head_dim)] # ★ head_out: the 4 numbers taken back in proportion
79 head_dim = n_embd // n_head # ★ head_dim: 16 ÷ 4 = 4
124 for h in range(n_head): 125 hs = h * head_dim # ★ hs: slice start (0, 4, 8, 12) 126 q_h = q[hs:hs+head_dim] 127 k_h = [ki[hs:hs+head_dim] for ki in keys[li]] 128 v_h = [vi[hs:hs+head_dim] for vi in values[li]] # ki / vi: the k, v of each earlier position in the cache
132 x_attn.extend(head_out) # ★ x_attn: the 16 numbers from the four heads joined
124 for h in range(n_head): ⋯ 132 x_attn.extend(head_out)
116 x_residual = x
117 x = rmsnorm(x)
118 q = linear(x, state_dict[f'layer{li}.attn_wq'])
119 k = linear(x, state_dict[f'layer{li}.attn_wk'])
120 v = linear(x, state_dict[f'layer{li}.attn_wv'])
121 keys[li].append(k) # this position's k, v stored
122 values[li].append(v)
123 x_attn = []
124 for h in range(n_head):
125 hs = h * head_dim
126 q_h = q[hs:hs+head_dim] # the “split”, one line
⋯
132 x_attn.extend(head_out)
133 x = linear(x_attn, state_dict[f'layer{li}.attn_wo'])
# Wo: the only place the four heads mix
134 x = [a + b for a, b in zip(x, x_residual)]
# the bypass rejoins here
50 def relu(self): return Value(max(0, self.data), ↪ (self,), (float(self.data > 0),))
87 state_dict[f'layer{i}.mlp_fc1'] = matrix(4 * n_embd, n_embd)
88 state_dict[f'layer{i}.mlp_fc2'] = matrix(n_embd, 4 * n_embd)
# fc1 = 64 detector scores; fc2 writes back to 16
136 x_residual = x
137 x = rmsnorm(x)
138 x = linear(x, state_dict[f'layer{li}.mlp_fc1'])
139 x = [xi.relu() for xi in x]
140 x = linear(x, state_dict[f'layer{li}.mlp_fc2'])
141 x = [a + b for a, b in zip(x, x_residual)]
39 def __add__(self, other): # ★ other: the other addend 40 other = other if isinstance(other, Value) ↪ else Value(other) 41 return Value(self.data + other.data, ↪ (self, other), (1, 1)) # the note says (1, 1) — gradients pass through unchanged
114 for li in range(n_layer): # more layers = one more round ⋯ 134 x = [a + b for a, b in zip(x, x_residual)] ⋯ 141 x = [a + b for a, b in zip(x, x_residual)]
81 state_dict = {'wte': matrix(vocab_size, n_embd),
↪ 'wpe': matrix(block_size, n_embd),
↪ 'lm_head': matrix(vocab_size, n_embd)}
# lm_head has wte's shape but not its weights
94 def linear(x, w): 95 return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w] ⋯ 143 logits = linear(x, state_dict['lm_head']) # the last matrix: 27 candidate rows, one score each 144 return logits
187 temperature = 0.5 # in (0, 1], control the …
193 for pos_id in range(block_size): 194 logits = gpt(token_id, pos_id, keys, values) 195 probs = softmax([l / temperature for l in logits]) # temperature just divides the scores 196 token_id = random.choices(range(vocab_size), ↪ weights=[p.data for p in probs])[0] # the only random choice in generation, this one line 197 if token_id == BOS: 198 break
121 keys[li].append(k) # only the new one, each round 122 values[li].append(v)
190 keys, values = [[] for _ in range(n_layer)], ↪ [[] for _ in range(n_layer)] # reset for every name 191 token_id = BOS 192 sample = [] 193 for pos_id in range(block_size): 194 logits = gpt(token_id, pos_id, keys, values) 195 probs = softmax([l / temperature for l in logits]) 196 token_id = random.choices(range(vocab_size), ↪ weights=[p.data for p in probs])[0] 197 if token_id == BOS: 198 break 199 sample.append(uchars[token_id])
39 def __add__(self, other): # a + b 41 return Value(self.data + other.data, ↪ (self, other), (1, 1)) # ★ add: 1 on both sides 43 def __mul__(self, other): # a * b 45 return Value(self.data * other.data, ↪ (self, other), (other.data, self.data)) # ★ multiply: the note holds the other operand 50 def relu(self): … (float(self.data > 0),) # ★ relu: 1 or 0 — a closed path passes nothing
69 self.grad = 1 # ★ start from the loss 70 for v in reversed(topo): # one pass, back to front 71 for child, local_grad in ↪ zip(v._children, v._local_grads): 72 child.grad += local_grad * v.grad # ★ the table above = this line, run five times
156 doc = docs[step % len(docs)] # ★ step: which step 157 tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS] 158 n = min(block_size, len(tokens) - 1) # ★ n: how many signals ⋯ 163 for pos_id in range(n): 164 token_id, target_id = tokens[pos_id], ↪ tokens[pos_id + 1] # ★ target_id: the correct answer = the next letter 165 logits = gpt(token_id, pos_id, keys, values) 166 probs = softmax(logits) 167 loss_t = -probs[target_id].log() # ★ loss_t / loss: the error (−log of the correct prob) 168 losses.append(loss_t) 169 loss = (1 / n) * sum(losses) # final average loss … ⋯ 172 loss.backward() # which way each parameter lowers the loss ⋯ 175 lr_t = learning_rate * (1 - step / num_steps) # … # ★ lr_t / num_steps: step size decays / 1,000 steps total 176 for i, p in enumerate(params): # ★ p: all 4,192, one by one 177 m[i] = beta1 * m[i] + (1 - beta1) * p.grad # ★ m / v: Adam's two statistics (unrelated to attention v) 178 v[i] = beta2 * v[i] + (1 - beta2) * p.grad ** 2 ⋯ 181 p.data -= lr_t * m_hat / (v_hat ** 0.5 + eps_adam) 182 p.grad = 0
Inspecting a trained checkpoint: the lengths and angles of wpe, and what all four heads actually attend to at one moment.
scripts/dump_position.py
scripts/dump_attention.py
One character per token pushes the vocabulary past 700 while block_size shrinks — the same code, a completely different split of parameters.
microgpt.py (names from Jin Yong novels)
The loss keeps falling, but the model starts memorising: generated names land exactly on the training data — overfitting made concrete.
EXPERIMENTS.md