A First Taste of Algorithms · Lab 9: The Engine

6. The machine speaks

Generate names — feed the model a start, take its prediction, feed it back, repeat:

def sample(temp=1.0):
    ctx = [stoi["."]] * CONTEXT; out = []
    for _ in range(20):
        emb = C[np.array([ctx])].reshape(1, -1)
        h = np.tanh(emb @ W1 + b1)
        logits = (h @ W2 + b2)[0] / temp
        logits -= logits.max(); pr = np.exp(logits); pr /= pr.sum()
        ix = rng.choice(V, p=pr)
        if itos[ix] == ".": break
        out.append(itos[ix]); ctx = ctx[1:] + [ix]
    return "".join(out)

known = set(names)
print("generated names (★ = novel):")
for _ in range(25):
    name = sample(1.0)
    print(f"   {name:<16} {'★ never existed' if name not in known else '(in training)'}")

Names like Riemanujan, Dantor, Wilevin — nobody’s, everybody’s plausible. The model learned the music of the names without copying them, which is learning itself. Some exact training names may appear too; Problem 3.3 asks what that does and doesn’t prove. Then run §6 again with temp=0.4 and temp=1.6 (Problem 2.3’s prediction): cold sampling plays it safe and wordlike, hot sampling gets adventurous and strange.

7. A real attention head

The toy model read a flat window. Now build the unit behind the transformer — one self-attention head, from the chapter’s five equations, in numpy. This is the architecture of your era, small enough to read whole.

The pieces, dimensioned (D = model dimension, V = vocabulary, BLOCK = longest sequence):

```python import numpy as np rng = np.random.default_rng(0)

reuse names / chars / stoi / itos / V from §5; set up dimensions

✦ ✦ ✦

BLOCK = 16 D = 32; Dk = 32 Wtok = rng.standard_normal((V, D)) * 0.1 # character -> vector Wpos = rng.standard_normal((BLOCK, D)) * 0.1 # position -> vector WQ = rng.standard_normal((D, Dk)) * 0.1 # query projection WK = rng.standard_normal((D, Dk)) * 0.1 # key projection WV = rng.standard_normal((D, D)) * 0.1 # value projection Wout = rng.standard_normal((D, V)) * 0.1 # context -> next-char scores

def softmax_rows(M): M = M - M.max(axis=-1, keepdims=True) e = np.exp(M) return e / e.sum(axis=-1, keepdims=True)


The forward pass *is* the five equations, in order — embed, project to Q/K/V, score, mask, weight, combine, read out:

```python
def forward(seq):
    T = len(seq)
    X = Wtok[seq] + Wpos[:T]                  # (T,D)  symbols + positions
    Q = X @ WQ; K = X @ WK; Vv = X @ WV       # the three projections
    scores = Q @ K.T / np.sqrt(Dk)            # (T,T)  query · key, scaled
    mask = np.triu(np.ones((T, T)), k=1).astype(bool)
    scores = np.where(mask, -1e9, scores)     # causal: no peeking ahead
    A = softmax_rows(scores)                  # (T,T)  attention weights
    context = A @ Vv                          # (T,D)  weighted values
    logits = context @ Wout                   # (T,V)  next-char scores
    return logits, A

seq = [stoi[c] for c in "." + "riemann"]
logits, A = forward(seq)
print("attention matrix shape:", A.shape, " (should be square, one row per position)")

Read A and confirm it is lower-triangular — each position attends only to itself and earlier, the mask at work. That triangle is the difference between generating and blank-filling.

Training is the same loop (the full gradient code is provided in the notebook — it is the chapter’s backward sweep applied to each matrix, batched). Run it, then inspect what the model learned to look at:

```python

All 32 chapters of A First Taste of Algorithms · Foundations · All courses