1 Char-level Shakespeare generator (LSTM)
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
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.
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.
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).
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.
@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.
2 Sampling & temperature
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:
| Temperature | What it does to the odds | Resulting text |
|---|---|---|
| T β 0 | Sharpens to a spike on the top choice β becomes greedy. | Safe, repetitive, can loop. |
| T β 0.5β0.8 | Mildly sharpened; confident but still varied. | Coherent and the usual sweet spot. |
| T = 1.0 | Uses the raw learned probabilities, unchanged. | Balanced creativity. |
| T > 1.2 | Flattens the odds; rare characters get real chances. | Wild, surprising, more typos/nonsense. |
# 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))
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.)
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.
3 EnglishβFrench translation (Seq2Seq)
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
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.
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.
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."
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.
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.
@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"
EOS.
4 Evaluating & improving
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).
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.
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
| Pitfall | Symptom | Fix |
|---|---|---|
| Exploding gradients | Loss 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. |
| Overfitting | Validation loss rises; output quotes training text verbatim. | Dropout, more data, early stopping. |
| Exposure bias | Translation good with teacher forcing, bad at inference. | Lower the teacher-forcing ratio over training. |
| Tiny vocabulary | Translator can't handle new words. | Subword tokenisation (BPE); more training pairs. |
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.
β 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
- Karpathy β char-rnn (GitHub) β the original character-level RNN/LSTM code that inspired Project 1; read it after building yours.
- Karpathy β The Unreasonable Effectiveness of Recurrent Neural Networks β the classic blog post showing what char-level models can generate and why it works.
- PyTorch β Translation with a Seq2Seq Network and Attention β the official tutorial our translation project follows step by step.
- Bahdanau et al. β Neural Machine Translation by Jointly Learning to Align and Translate β the paper that introduced attention for translation (the mechanism in our decoder).
- Sutskever et al. β Sequence to Sequence Learning with Neural Networks β the foundational encoderβdecoder Seq2Seq paper.
- Papineni et al. β BLEU: a Method for Automatic Evaluation of Machine Translation β the original BLEU metric paper for grading translations.