Fall 2026
  • Discord
  • Gradescope
  • Syllabus
  • Spring 2026

Demo: A Transformer That Writes

The reading assembled attention into full transformer blocks; here we watch the assembled machine learn to write. We train a tiny character-level transformer from scratch (two blocks, \(d = 64\), about 100K parameters) on roughly 2KB of text: the opening sentence of A Tale of Two Cities plus the Gettysburg Address. Training takes about half a minute on a plain CPU. Then we steer the model with prompts, and turn the temperature knob.

import time
import torch
import torch.nn as nn
import torch.nn.functional as F
import matplotlib.pyplot as plt
import scienceplots

plt.style.use(["science", "no-latex"])
TEAL, CARDINAL, GRAY = "#009090", "#9c1b33", "#c9c9c9"
torch.manual_seed(145)

DICKENS = (
    "It was the best of times, it was the worst of times, it was the age of wisdom, "
    "it was the age of foolishness, it was the epoch of belief, it was the epoch of "
    "incredulity, it was the season of Light, it was the season of Darkness, it was "
    "the spring of hope, it was the winter of despair, we had everything before us, "
    "we had nothing before us, we were all going direct to Heaven, we were all going "
    "direct the other way - in short, the period was so far like the present period, "
    "that some of its noisiest authorities insisted on its being received, for good "
    "or for evil, in the superlative degree of comparison only. "
)

LINCOLN = (
    "Four score and seven years ago our fathers brought forth on this continent, a "
    "new nation, conceived in Liberty, and dedicated to the proposition that all men "
    "are created equal. Now we are engaged in a great civil war, testing whether that "
    "nation, or any nation so conceived and so dedicated, can long endure. We are met "
    "on a great battle-field of that war. We have come to dedicate a portion of that "
    "field, as a final resting place for those who here gave their lives that that "
    "nation might live. It is altogether fitting and proper that we should do this. "
    "But, in a larger sense, we can not dedicate - we can not consecrate - we can not "
    "hallow - this ground. The brave men, living and dead, who struggled here, have "
    "consecrated it, far above our poor power to add or detract. The world will "
    "little note, nor long remember what we say here, but it can never forget what "
    "they did here. It is for us the living, rather, to be dedicated here to the "
    "unfinished work which they who fought here have thus far so nobly advanced. It "
    "is rather for us to be here dedicated to the great task remaining before us - "
    "that from these honored dead we take increased devotion to that cause for which "
    "they gave the last full measure of devotion - that we here highly resolve that "
    "these dead shall not have died in vain - that this nation, under God, shall "
    "have a new birth of freedom - and that government of the people, by the people, "
    "for the people, shall not perish from the earth. "
)

corpus = DICKENS + LINCOLN
vocab = sorted(set(corpus))
m = len(vocab)
stoi = {ch: i for i, ch in enumerate(vocab)}
encode = lambda s: [stoi[c] for c in s]
decode = lambda ids: "".join(vocab[i] for i in ids)

print(f"corpus: {len(corpus)} characters, vocabulary: {m} distinct characters")
print("vocabulary:", "".join(vocab))
corpus: 2081 characters, vocabulary: 37 distinct characters
vocabulary:  ,-.BDFGHILNTWabcdefghiklmnopqrstuvwy

The model

This is exactly the reading’s block: \(h = 4\) heads with \(d_k = d/h = 16\), causal masking by setting future scores to \(-\infty\), and each sublayer wrapped in a residual connection and a layer normalization (callback: depth-enablers), with a \(d \to 4d \to d\) MLP. One piece here is not in the reading: we add a learned table of position vectors to the token embeddings. Without some position information, last lecture’s permutation equivariance means the model couldn’t tell “dog bites man” from “man bites dog”. Why that fix works, and a much better one, is next lecture’s entire subject.

The reading counted \(12d^2 + 4d = 49{,}408\) parameters per block; the model had better agree.

CONTEXT, D, HEADS, BLOCKS, D_FF = 64, 64, 4, 2, 256

class Block(nn.Module):
    def __init__(self):
        super().__init__()
        self.ln1, self.ln2 = nn.LayerNorm(D), nn.LayerNorm(D)
        self.Wq = nn.Linear(D, D, bias=False)
        self.Wk = nn.Linear(D, D, bias=False)
        self.Wv = nn.Linear(D, D, bias=False)
        self.Wo = nn.Linear(D, D, bias=False)
        self.mlp = nn.Sequential(nn.Linear(D, D_FF, bias=False), nn.ReLU(),
                                 nn.Linear(D_FF, D, bias=False))

    def forward(self, x):
        B, n, _ = x.shape
        dk = D // HEADS
        h = self.ln1(x)                                       # pre-norm
        q = self.Wq(h).view(B, n, HEADS, dk).transpose(1, 2)  # split into heads
        k = self.Wk(h).view(B, n, HEADS, dk).transpose(1, 2)
        v = self.Wv(h).view(B, n, HEADS, dk).transpose(1, 2)
        scores = q @ k.transpose(-2, -1) / dk**0.5            # scaled dot products
        mask = torch.triu(torch.ones(n, n, dtype=torch.bool), diagonal=1)
        scores = scores.masked_fill(mask, float("-inf"))      # causal: no peeking
        a = F.softmax(scores, dim=-1)
        out = (a @ v).transpose(1, 2).reshape(B, n, D)        # concatenate heads
        x = x + self.Wo(out)                                  # residual connection
        x = x + self.mlp(self.ln2(x))                         # residual again
        return x

class TinyTransformer(nn.Module):
    def __init__(self):
        super().__init__()
        self.tok = nn.Embedding(m, D)
        self.pos = nn.Embedding(CONTEXT, D)   # position info: next lecture's subject
        self.blocks = nn.ModuleList([Block() for _ in range(BLOCKS)])
        self.ln = nn.LayerNorm(D)
        self.head = nn.Linear(D, m, bias=False)

    def forward(self, idx):
        B, n = idx.shape
        x = self.tok(idx) + self.pos(torch.arange(n))
        for block in self.blocks:
            x = block(x)
        return self.head(self.ln(x))          # logits, one row per position

model = TinyTransformer()
print(f"parameters per block: {sum(p.numel() for p in model.blocks[0].parameters()):,}")
print(f"total parameters:     {sum(p.numel() for p in model.parameters()):,}")
parameters per block: 49,408
total parameters:     107,776

Training

Predicting the next character is classification over \(m = 37\) characters, so the loss is cross-entropy, exactly as in logistic regression. The causal mask makes all 64 positions of every sampled window valid, simultaneous training examples. A random guesser pays \(\ln 37 \approx 3.6\) nats, so the loss should start there and fall.

data = torch.tensor(encode(corpus))

def get_batch(B=32):
    ix = torch.randint(len(data) - CONTEXT - 1, (B,))
    x = torch.stack([data[i:i + CONTEXT] for i in ix])
    y = torch.stack([data[i + 1:i + CONTEXT + 1] for i in ix])
    return x, y

opt = torch.optim.Adam(model.parameters(), lr=3e-3)
start = time.time()
for step in range(1201):
    xb, yb = get_batch()
    loss = F.cross_entropy(model(xb).view(-1, m), yb.view(-1))
    opt.zero_grad(); loss.backward(); opt.step()
    if step % 200 == 0:
        print(f"step {step:5d}: loss {loss.item():.3f}")
print(f"training time: {time.time() - start:.0f}s")
step     0: loss 3.789
step   200: loss 1.644
step   400: loss 0.286
step   600: loss 0.157
step   800: loss 0.120
step  1000: loss 0.118
step  1200: loss 0.100
training time: 27s

Steering with prompts

The weights are frozen from here on, so the only control left is the prompt. Three prompts, one model, temperature \(T = 0.8\): each prompt steers the model into a different groove of its training text.

def generate(prompt, n_chars=250, temperature=1.0):
    idx = torch.tensor([encode(prompt)])
    with torch.no_grad():
        for _ in range(n_chars):
            logits = model(idx[:, -CONTEXT:])[0, -1]   # logits for the next character
            if temperature == 0:
                nxt = logits.argmax()                  # greedy decoding
            else:
                p = F.softmax(logits / temperature, dim=-1)
                nxt = torch.multinomial(p, 1)[0]       # sample
            idx = torch.cat([idx, nxt.view(1, 1)], dim=1)
    return decode(idx[0].tolist())

for prompt in ["It was the ", "Four score", "The world"]:
    print(f'--- prompt: "{prompt}" ---')
    print(generate(prompt, 250, temperature=0.8), "\n")
--- prompt: "It was the " ---
It was the spring of hope, it was the winter of despair, we had everything before us, we had nothing before us, we were all going direct to Heaven, we were all going direct the other way - in short, the period was so herthing before us, work which the season, u 

--- prompt: "Four score" ---
Four score and seven years ago our fathers brought forth on this continent, a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal. Now we are engaged in a great civil war, testing whether that nation, or any nation 

--- prompt: "The world" ---
The world will little note, noredicred long end in a great cand long endure. We are met on a great battle-field of that war. We have come to dedicate a portion of that field, as a final resting place for those who here gave their lives that that case for rath 

The temperature knob

We sample the next character from \(\operatorname{softmax}(\mathbf{z}/T)\). In class we proved the two limits: \(T \to 0\) recovers greedy argmax decoding, and \(T \to \infty\) the uniform distribution. Same prompt, four temperatures.

for T in [0.0, 0.5, 1.0, 2.0]:
    print(f"--- T = {T} ---")
    print(generate("It was the ", 200, temperature=T), "\n")
--- T = 0.0 ---
It was the spring of hope, it was the winter of despair, we had everything before us, we had nothing before us, we were all going direct to Heaven, we were all going direct the other way - in short, the period w 

--- T = 0.5 ---
It was the age of wisdom, it was the age of foolishness, it was the epoch of incredulity, it was the season of Light, it was the season of Darkness, it was the spring of hope, it was the winter of despair, we ha 

--- T = 1.0 ---
It was the epoch of incredulity, it was the season of Light, it was the season of Darkness, it was the spring of hope, it was the winter of despair, we holl enothis. Bed ded in vad styhomeaste a goitties the dea 

--- T = 2.0 ---
It was the worst of times, it was the age of foolishness, it was car sonseceme a gil, birthorth liver, to ve -ut c. It is altogTo Heaven, we were all going direct tke the proposirtion that cont all goinent, yo g 

Greedy repeats the training text verbatim (rerun the cell and \(T = 0\) gives the identical string), and \(T = 0.5\) stays close to it; \(T = 1\) starts inside the memorized text and then drifts out of it; \(T = 2\) dissolves into letter soup.

To see why, look at the distribution being sampled. The corpus contains “it was the” nine times, followed by four different characters: s three times (season, season, spring), w twice (worst, winter), a twice (age, age), and e twice (epoch, epoch). So the trained model’s next-character distribution is genuinely multimodal, and temperature reshapes it without reordering it.

with torch.no_grad():
    logits = model(torch.tensor([encode("it was the ")]))[0, -1]

chars = [c if c != " " else "_" for c in vocab]
top = int(logits.argmax())
fig, axes = plt.subplots(3, 1, figsize=(7, 4.5), sharex=True, sharey=True)
for ax, T in zip(axes, [0.5, 1.0, 2.0]):
    p = F.softmax(logits / T, dim=-1).numpy()
    ax.bar(range(m), p, color=[CARDINAL if i == top else TEAL for i in range(m)], width=0.7)
    ax.text(0.99, 0.78, f"$T = {T}$", transform=ax.transAxes, ha="right")
    ax.set_ylabel("Probability")
axes[0].set_ylim(0, 0.99)
axes[-1].set_xticks(range(m))
axes[-1].set_xticklabels(chars, fontsize=6)
axes[-1].set_xlabel('Next character after "it was the " (the underscore is the space character)')
plt.show()

for T in [0.5, 1.0, 2.0]:
    p = F.softmax(logits / T, dim=-1)
    print(f"T = {T}: greedy pick '{vocab[top]}' holds probability {p[top]:.2f}")

T = 0.5: greedy pick 's' holds probability 0.79
T = 1.0: greedy pick 's' holds probability 0.51
T = 2.0: greedy pick 's' holds probability 0.28

The cardinal bar is the same character at every temperature, since dividing every logit by a common \(T\) never changes their order, so greedy’s choice is fixed. What changes is its share, which falls from \(0.79\) to \(0.28\); at \(T = 2\) probability leaks onto characters that never once followed “it was the” in the corpus, such as the b of “best”.

What if?

Change the prompt, the temperature, or the length. Good starting points: the prompt "we can not ", or temperature 5. (One caution: the tokenizer only knows the 37 characters printed at the top, so a prompt with any other character, like a question mark, will crash encode.)

prompt = "we can not "   # change me!
temperature = 0.8        # change me!
n_chars = 200            # change me!

print(generate(prompt, n_chars, temperature))
we can not consecrate - we can not hallow - this ground. The brave men, living and dead, who struggled here, have consecrated it, far above our poorthat we sh of herlishng pecrople, by the people, for the people

Punchline: a transformer is a next-token distribution plus a loop. The prompt picks where in its training distribution the model starts, the temperature picks how boldly it samples, and everything it “writes” comes, one character at a time, from a softmax we understand end to end.