πŸ“š Study Notes / Home / Neural Nets / Session 18
Session 18 Β· Project β€” Text Generation & Translation

Build a Shakespeare bot and a translator

This is a project week β€” less lecture, more building. We'll write two complete, runnable PyTorch programs from scratch: a tiny AI that writes Shakespeare one letter at a time (an LSTM language model), and a little machine translator that turns English into French (an encoder–decoder with attention). We assume you've followed Sessions 12–14, but we'll re-explain everything gently. By the end you'll have code you can actually run and tinker with.

⏱ 21 min readπŸ“– 4 topics

1 Char-level Shakespeare generator (LSTM)


Explain like I'm 5

Imagine a kid who has read one giant Shakespeare play so many times that they can play a guessing game: I show them a few letters, and they guess the next letter. Show them "To be or not to b" and they shout "e!". If we let them keep guessing β€” taking their own last guess and adding it to the front of the next guess β€” they'll eventually scribble out a whole new fake-Shakespeare speech, one letter at a time. That guessing machine is what we're building.

Our first project is a character-level language model: a neural network whose only job is, given the characters seen so far, to predict the next character. "Character-level" means our basic unit isn't a word or a token β€” it's a single letter, space, or punctuation mark. This is the classic char-rnn idea popularised by Andrej Karpathy, and it's a beautiful first project because the whole thing fits in one short file.

We'll build it with an LSTM (Long Short-Term Memory network) β€” the gated recurrent network you met in Sessions 12–13. Recall the key idea: an LSTM reads a sequence one step at a time, carrying a hidden state (its short-term working memory) and a cell state (its long-term memory conveyor belt), and special gates decide what to remember, forget, and output at each step. That memory is exactly what lets it track context like an open quote or the rhythm of a line.

The plan, end to end

πŸ“œ
1. Data
Read text, build char↔index maps
β†’
βœ‚οΈ
2. Chunk
Cut into (input, target) windows
β†’
🧠
3. Model
Embed β†’ LSTM β†’ Linear
β†’
πŸ‹οΈ
4. Train
Predict next char, minimise loss
β†’
✍️
5. Sample
Generate new text letter by letter

Step 1 & 2 β€” Data preparation

A language model can't read letters directly; it needs numbers. So we collect every unique character into a vocabulary, then build two lookup tables: stoi ("string to integer") maps each character to a number, and itos maps back. The training data is then just the whole text rewritten as a long list of integers.

For training we slide a window over that long sequence. The input is a chunk of characters, and the target is the same chunk shifted one step to the right β€” because at every position we want the model to predict the character that actually came next.

Worked example β€” turning text into training pairs
import torch
import torch.nn as nn
import torch.nn.functional as F

# --- 1. Load data (download tinyshakespeare.txt, ~1MB of plays) ---
text = open("tinyshakespeare.txt", "r").read()

# --- 2. Build the vocabulary and the two lookup tables ---
chars   = sorted(list(set(text)))   # every unique character
vocab_size = len(chars)                  # ~65 for tinyshakespeare
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for ch, i in stoi.items()}

encode = lambda s: [stoi[c] for c in s]          # text  -> list of ints
decode = lambda l: "".join(itos[i] for i in l)   # ints -> text

data = torch.tensor(encode(text), dtype=torch.long)

# --- 3. A batch is many (input, target) windows; target is input shifted by 1 ---
block_size = 128   # how many chars of context the model sees
batch_size = 64

def get_batch(data):
    ix = torch.randint(len(data) - block_size - 1, (batch_size,))
    x = torch.stack([data[i : i + block_size]       for i in ix])
    y = torch.stack([data[i + 1 : i + block_size + 1] for i in ix])
    return x, y   # shapes: (batch_size, block_size)

So if a window of x is "To be or not to b", the matching y is "o be or not to be" β€” each position's label is "the next character."

Step 3 β€” The model

The network has three parts, and you've met all of them: an embedding layer turns each character index into a learnable vector (its "meaning code"); the LSTM processes the sequence carrying memory through time; and a final linear layer projects each step's hidden state into vocab_size scores β€” one logit per possible next character.

Worked example β€” the LSTM language model
class CharLSTM(nn.Module):
    def __init__(self, vocab_size, embed_dim=128, hidden_dim=256, num_layers=2):
        super().__init__()
        self.embed = nn.Embedding(vocab_size, embed_dim)
        self.lstm  = nn.LSTM(embed_dim, hidden_dim, num_layers,
                             batch_first=True, dropout=0.2)
        self.fc    = nn.Linear(hidden_dim, vocab_size)

    def forward(self, x, hidden=None):
        # x: (batch, seq) of char indices
        emb = self.embed(x)                  # (batch, seq, embed_dim)
        out, hidden = self.lstm(emb, hidden) # out: (batch, seq, hidden_dim)
        logits = self.fc(out)                # (batch, seq, vocab_size)
        return logits, hidden            # return hidden so we can carry memory

Notice forward returns the LSTM's hidden state. During training we don't need it, but during generation we'll feed it back in so the model "remembers" what it already wrote.

Step 4 β€” The training loop

Training is the standard recipe from earlier sessions: get a batch, run it forward to get logits, measure how wrong they are with cross-entropy loss (it compares the predicted probability distribution over characters to the true next character), then backpropagate and let the optimiser nudge the weights. One extra trick for recurrent nets: gradient clipping, which caps the size of gradients so a single wild batch can't blow up the weights (RNNs are prone to exploding gradients, from Session 12).

Worked example β€” full training loop
device = "cuda" if torch.cuda.is_available() else "cpu"
model = CharLSTM(vocab_size).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3)

model.train()
for step in range(5000):
    x, y = get_batch(data)
    x, y = x.to(device), y.to(device)

    logits, _ = model(x)                          # (batch, seq, vocab)
    # cross-entropy wants (N, vocab) vs (N,), so flatten the batch+seq dims
    loss = F.cross_entropy(
        logits.view(-1, vocab_size),
        y.view(-1)
    )

    optimizer.zero_grad()
    loss.backward()
    nn.utils.clip_grad_norm_(model.parameters(), 1.0)  # clip exploding grads
    optimizer.step()

    if step % 500 == 0:
        print(f"step {step:5d} | loss {loss.item():.3f}")

You'll watch the loss fall from ~4.2 (random guessing over 65 characters is ln(65) β‰ˆ 4.17) toward ~1.4–1.5. That drop is the model learning English spelling, then Shakespeare's punctuation, then his rhythm.

Step 5 β€” Sampling / generation

Now the fun part. To generate, we feed the model a starting character, take its prediction, append it, and feed the result back in β€” exactly the autoregressive loop. We carry the LSTM's hidden state forward so it doesn't forget what it already wrote.

Worked example β€” generating text
@torch.no_grad()
def generate(model, start="ROMEO:", length=500, temperature=1.0):
    model.eval()
    idx = torch.tensor([encode(start)], device=device)  # (1, len(start))
    hidden = None
    out = list(start)

    # warm up the hidden state on the prompt, keep only the last step
    logits, hidden = model(idx, hidden)
    last = idx[:, -1:]                                    # (1, 1)

    for _ in range(length):
        logits, hidden = model(last, hidden)            # (1, 1, vocab)
        logits = logits[:, -1, :] / temperature       # scale by temperature
        probs  = F.softmax(logits, dim=-1)
        last   = torch.multinomial(probs, num_samples=1)  # sample one char
        out.append(itos[last.item()])

    return "".join(out)

print(generate(model, start="ROMEO:", length=500, temperature=0.8))

Early in training this spits out gibberish like "qx; zj!Rk". After a few thousand steps it produces convincing fake-Shakespeare: invented character names, line breaks, even "Enter" stage directions β€” all hallucinated, none memorised verbatim.

Recap A char-level LSTM language model learns to predict the next character. We map chars↔ints, cut the text into shifted (input, target) windows, run Embedding β†’ LSTM β†’ Linear, train with cross-entropy and gradient clipping, then generate autoregressively by feeding the model its own last character while carrying the hidden state forward.

2 Sampling & temperature


Explain like I'm 5

Imagine our Shakespeare-guesser has a bag of letters and the bag's odds say "e" is most likely. Three ways to pick: always grab "e" (boring but safe), close your eyes and draw fairly (sometimes a surprise!), or turn a "wildness knob" first β€” turn it down and "e" almost always wins; turn it up and even rare letters get a real chance. That knob is temperature.

In Topic 1 we already used the wildness knob (temperature=0.8). Let's slow down and understand the three picking strategies, because they decide whether your generator is repetitive, balanced, or chaotic. This is the same concept you'll meet for big LLMs β€” see GenAI Session 1, where temperature controls a chatbot's creativity.

Greedy decoding β€” always take the top pick

Greedy decoding just takes the highest-probability character every single step (torch.argmax). It's deterministic β€” same prompt, same output forever. For a character model that's a problem: it tends to fall into repetitive loops like "the the the the" because the locally-safest choice repeated forever isn't globally interesting.

Sampling β€” draw from the probabilities

Sampling instead treats the model's softmax output as a real probability distribution and draws from it (torch.multinomial). A character the model rates at 30% genuinely shows up about 30% of the time. This brings variety and surprise, and it's why each run of generate() gives a different speech.

Temperature β€” reshaping the odds before sampling

Temperature T divides the logits before softmax: softmax(logits / T). It reshapes the distribution:

TemperatureWhat it does to the oddsResulting text
T β†’ 0Sharpens to a spike on the top choice β€” becomes greedy.Safe, repetitive, can loop.
T β‰ˆ 0.5–0.8Mildly sharpened; confident but still varied.Coherent and the usual sweet spot.
T = 1.0Uses the raw learned probabilities, unchanged.Balanced creativity.
T > 1.2Flattens the odds; rare characters get real chances.Wild, surprising, more typos/nonsense.
Worked example β€” same model, three temperatures
# T = 0.3  (timid): clean words but dull and loopy
#   "the streath the streath the streath of the..."

# T = 0.8  (sweet spot): coherent fake-Shakespeare
#   "ROMEO: I am the son of such a heart, / And in the cause..."

# T = 1.5  (wild): inventive but messy
#   "ROMEO: Wzt! quoth-pluck'd yon glimmer-jib of zorn..."

# All three come from the SAME trained model -- only T changed.
for T in [0.3, 0.8, 1.5]:
    print(f"--- T={T} ---")
    print(generate(model, start="ROMEO:", length=200, temperature=T))
One more knob: top-k

A popular refinement is top-k sampling: before sampling, keep only the k most likely characters and zero out the rest. This stops bizarre rare characters from sneaking in while still allowing variety. In PyTorch you'd mask the logits below the k-th largest, then softmax + sample. (GenAI Session 1 covers the close cousin top-p / nucleus sampling.)

Watch out

There's no single "right" temperature β€” it's a creativity-vs-coherence trade-off. Too low and the text loops; too high and it dissolves into noise. Always sample a few temperatures and read the output yourself; the loss number alone won't tell you which feels best.

Recap Greedy always takes the top character (deterministic, can loop). Sampling draws from the probabilities (varied). Temperature divides the logits before softmax: low = sharp/safe, high = flat/wild, T=1 = unchanged. Top-k trims the unlikely tail. Tune by reading samples, not just the loss.

3 English→French translation (Seq2Seq)


Explain like I'm 5

Picture two friends. The first reads an English sentence and squeezes its whole meaning into a little note. The second friend can't read English β€” only the note β€” and writes the French sentence one word at a time, glancing back at the note for hints. The first friend is the encoder, the second is the decoder, and the "glancing back" is attention. Together they translate.

Our second project is sequence-to-sequence (Seq2Seq) translation: input one sequence (English words), output a different-length sequence (French words). The architecture is an encoder–decoder with attention β€” exactly the machinery from Session 14. We'll build the canonical version from the PyTorch translation tutorial.

The big picture

πŸ‡¬πŸ‡§
Encoder
Reads English, outputs hidden states
β†’
🎯
Attention
Decoder picks which words to focus on
β†’
πŸ‡«πŸ‡·
Decoder
Writes French one word at a time

Step 1 β€” Data preparation

Translation works on words, not characters, so we build a word-level vocabulary per language and add two special tokens: SOS ("start of sentence", tells the decoder to begin) and EOS ("end of sentence", tells it to stop). We pair each English sentence with its French translation and turn both into lists of word-indices.

Worked example β€” vocabulary and pairs
SOS_token, EOS_token = 0, 1

class Lang:                       # one per language
    def __init__(self):
        self.word2index = {}
        self.index2word = {0: "SOS", 1: "EOS"}
        self.n_words = 2
    def add_sentence(self, sentence):
        for word in sentence.split():
            if word not in self.word2index:
                self.word2index[word] = self.n_words
                self.index2word[self.n_words] = word
                self.n_words += 1

# pairs = [("i am cold", "j ai froid"), ...]  (lowercased, punctuation-stripped)
def tensor_from(lang, sentence):
    idxs = [lang.word2index[w] for w in sentence.split()] + [EOS_token]
    return torch.tensor(idxs, dtype=torch.long, device=device).view(-1, 1)

To keep the project small and fast, the tutorial filters to short sentences (≀ 10 words) and a few simple sentence templates β€” enough to learn real translations in minutes on a laptop.

Step 2 β€” Encoder

The encoder is a GRU (a lighter LSTM cousin from Session 13) that reads the English sentence word by word and outputs a hidden state at every step. We keep all those per-word states (not just the last one) because attention will need them.

Worked example β€” the encoder
class EncoderRNN(nn.Module):
    def __init__(self, input_size, hidden_size):
        super().__init__()
        self.embedding = nn.Embedding(input_size, hidden_size)
        self.gru = nn.GRU(hidden_size, hidden_size, batch_first=True)

    def forward(self, input):
        embedded = self.embedding(input)            # (1, seq, hidden)
        outputs, hidden = self.gru(embedded)        # outputs: per-word states
        return outputs, hidden

Step 3 β€” Attention decoder

The decoder generates French one word at a time. At each step, instead of relying only on a single squished summary vector, it uses attention to compute a weighted blend of the encoder's per-word states β€” focusing on the English words most relevant to the word it's about to produce. Those attention weights are also a lovely visualisation: you can literally see "chat" line up with "cat."

Worked example β€” attention decoder
class AttnDecoderRNN(nn.Module):
    def __init__(self, hidden_size, output_size, max_len=10):
        super().__init__()
        self.embedding = nn.Embedding(output_size, hidden_size)
        self.attn      = nn.Linear(hidden_size * 2, max_len)
        self.attn_comb = nn.Linear(hidden_size * 2, hidden_size)
        self.gru       = nn.GRU(hidden_size, hidden_size, batch_first=True)
        self.out       = nn.Linear(hidden_size, output_size)

    def forward(self, input, hidden, encoder_outputs):
        emb = self.embedding(input)                            # (1,1,hidden)
        # 1. score how much to attend to each encoder state
        attn_w = F.softmax(
            self.attn(torch.cat((emb[0], hidden[0]), 1)), dim=1)
        # 2. weighted sum of encoder outputs = the "context"
        context = torch.bmm(attn_w.unsqueeze(1), encoder_outputs)
        # 3. mix context with the input word, run the GRU
        x = self.attn_comb(torch.cat((emb[0], context[0]), 1)).unsqueeze(0)
        x = F.relu(x)
        out, hidden = self.gru(x, hidden)
        # 4. predict the next French word
        logits = self.out(out[0])
        return logits, hidden, attn_w

Step 4 β€” Training with teacher forcing

Here's the key training trick. The decoder predicts French word-by-word, but during training we have a choice for what to feed it as the next input: its own (possibly wrong) prediction, or the real correct French word. Feeding the real word is called teacher forcing. It makes training faster and more stable (one early mistake doesn't derail the whole sentence), but if we only ever teacher-force, the model never learns to recover from its own errors at inference time. So we use it randomly, some fraction of the time.

Worked example β€” one training step
def train_step(input_tensor, target_tensor, encoder, decoder,
               enc_opt, dec_opt, criterion, tf_ratio=0.5):
    enc_opt.zero_grad(); dec_opt.zero_grad()

    encoder_outputs, enc_hidden = encoder(input_tensor.view(1, -1))
    dec_input  = torch.tensor([[SOS_token]], device=device)
    dec_hidden = enc_hidden
    loss = 0

    use_tf = random.random() < tf_ratio
    for t in range(target_tensor.size(0)):
        logits, dec_hidden, _ = decoder(dec_input, dec_hidden, encoder_outputs)
        loss += criterion(logits, target_tensor[t])
        if use_tf:
            dec_input = target_tensor[t].view(1, 1)   # feed the TRUE word
        else:
            dec_input = logits.argmax(1).view(1, 1)  # feed its OWN guess
            if dec_input.item() == EOS_token:
                break

    loss.backward()
    enc_opt.step(); dec_opt.step()
    return loss.item() / target_tensor.size(0)

We use nn.NLLLoss / cross_entropy as the criterion, and two separate SGD or Adam optimisers (one for the encoder, one for the decoder).

Step 5 β€” Inference

At translation time there is no correct answer to peek at, so teacher forcing is off: we always feed the decoder its own previous output, starting from SOS and stopping when it emits EOS.

Worked example β€” translating a sentence
@torch.no_grad()
def translate(sentence, encoder, decoder, eng, fra, max_len=10):
    encoder.eval(); decoder.eval()
    input_tensor = tensor_from(eng, sentence)
    encoder_outputs, hidden = encoder(input_tensor.view(1, -1))

    dec_input = torch.tensor([[SOS_token]], device=device)
    words = []
    for _ in range(max_len):
        logits, hidden, _ = decoder(dec_input, hidden, encoder_outputs)
        top = logits.argmax(1).item()
        if top == EOS_token:
            break
        words.append(fra.index2word[top])
        dec_input = torch.tensor([[top]], device=device)
    return " ".join(words)

print(translate("i am cold", encoder, decoder, eng, fra))  # -> "j ai froid"
Recap Seq2Seq translation = an encoder GRU reads English into per-word hidden states, and an attention decoder writes French one word at a time, attending to the relevant English words each step. Train with teacher forcing applied randomly (~50%) so the model is both stable to train and robust at inference; at inference, feed its own outputs until EOS.

4 Evaluating & improving


Explain like I'm 5

How do you know your robot writer is getting better? Two ways: watch its "mistake score" go down on a graph (numbers), and actually read what it writes (eyeballs). A low score with bad-looking output means something's off β€” so always do both, like checking a cake's timer and tasting it.

You've built both models β€” now how do you tell if they're any good, and how do you make them better? Evaluation for generative models is part number, part judgement.

Loss curves β€” the health monitor

Log the loss every N steps and plot it. A healthy run shows training loss falling smoothly. To catch overfitting (memorising instead of learning), hold out a slice of data the model never trains on and also plot its validation loss: when validation loss starts rising while training loss keeps falling, you've overfit β€” stop there (this is early stopping).

Worked example β€” tracking and plotting loss
losses = []
for step in range(5000):
    # ... compute loss as before ...
    losses.append(loss.item())

import matplotlib.pyplot as plt
plt.plot(losses)
plt.xlabel("step"); plt.ylabel("cross-entropy loss")
plt.title("Training loss"); plt.show()

For language models people often report perplexity = exp(loss) β€” intuitively "how many characters the model is choosing between on average." Lower is better; a perplexity of 1 would mean perfect certainty.

Qualitative samples β€” read the output

The loss can't fully capture "does this feel like Shakespeare?" or "is this a correct translation?" So periodically print samples during training. Watching the Shakespeare model evolve from random characters β†’ real words β†’ proper line structure is the most satisfying part of the project, and it catches problems (looping, garbage) that the loss alone hides.

BLEU β€” scoring translations (high level)

For translation we want a number that says "how close is the model's French to a human's French?" The standard metric is BLEU (Bilingual Evaluation Understudy). At a high level, BLEU measures how many short word-sequences (n-grams: single words, word pairs, triples) the model's output shares with one or more reference translations, with a penalty for being too short. It ranges 0–100; higher is better.

Example β€” BLEU intuition
Reference : "the cat is on the mat"
Model out : "the cat is on mat"

# Most 1-grams (the, cat, is, on, mat) match -> high unigram overlap.
# A missing "the" lowers the 2-gram overlap, and the shorter
# length triggers the brevity penalty -> BLEU below a perfect 100.

from torchtext.data.metrics import bleu_score
score = bleu_score([["the","cat","is","on","mat"]],
                   [[["the","cat","is","on","the","mat"]]])

BLEU is a rough proxy β€” it rewards word overlap, not true meaning, so a perfectly fluent paraphrase can score low. Treat it as a directional signal, paired with reading real examples.

Pitfalls and next steps

PitfallSymptomFix
Exploding gradientsLoss suddenly jumps to NaN.Gradient clipping (we did this); lower learning rate.
Repetitive output"the the the…"Sample with temperature/top-k instead of greedy.
OverfittingValidation loss rises; output quotes training text verbatim.Dropout, more data, early stopping.
Exposure biasTranslation good with teacher forcing, bad at inference.Lower the teacher-forcing ratio over training.
Tiny vocabularyTranslator can't handle new words.Subword tokenisation (BPE); more training pairs.
Where this leads next

Both projects use recurrent networks that read sequentially and can struggle with very long dependencies. The fix β€” replacing recurrence with pure attention β€” is the Transformer, which we build from scratch in Session 19 (Mini-Transformer). Today's char-LM and attention decoder are the perfect stepping stones to it.

Recap Evaluate generative models with both numbers (loss curves, perplexity, BLEU for translation) and eyeballs (qualitative samples). Watch for exploding gradients, repetition, overfitting, and exposure bias β€” each has a standard fix. The natural next step is replacing recurrence with attention: the Transformer.

β˜… Putting it all together


You shipped two real neural networks this week. Here's the one-paragraph story tying the projects together:

Both projects are sequence models trained to predict the next symbol. The char-level LSTM reads characters and predicts the next character, learning to write fake-Shakespeare; we generate from it autoregressively, steering creativity with temperature and the choice of greedy vs sampling. The Seq2Seq translator splits the job in two: an encoder reads English into hidden states and an attention decoder writes French one word at a time, focusing on the relevant input words; we train it efficiently with teacher forcing. We judge both with loss curves plus qualitative samples, and grade translations with BLEU β€” then carry these ideas straight into the Transformer next session.

Quick self-check

In the char-LSTM, what is the training "target" for a given input window?

The same window shifted one character to the right β€” at every position the label is the character that actually comes next.

You set temperature to 0. What kind of decoding does that become, and what's the risk?

It becomes greedy decoding (always the top character). The risk is repetitive loops like "the the the" because the locally-safest choice repeated forever isn't interesting.

What does the attention mechanism let the decoder do?

At each output step it computes a weighted blend of the encoder's per-word hidden states, focusing on the input words most relevant to the word it's about to produce β€” instead of relying on one fixed summary vector.

What is teacher forcing, and why not use it 100% of the time?

Feeding the decoder the true previous word during training (instead of its own guess). It speeds and stabilises training, but used exclusively the model never learns to recover from its own mistakes at inference time β€” so we apply it only a fraction of the time.

Your translation loss is low but BLEU and the actual outputs are poor. What might be going on?

Likely overfitting or exposure bias (great with teacher forcing, weak feeding its own outputs), or the loss simply doesn't capture meaning. Always check qualitative samples, not just loss.

What architecture replaces recurrence with pure attention, and when do we build it?

The Transformer β€” built from scratch next session (Session 19, Mini-Transformer).

πŸ“š References & Further Reading


Class material

  • SST Deep Learning handout (Session 18) β€” your course handout for this project week.

Papers, docs & deep dives