Classical Text Generation - A Friendly Guide
Classical Text Generation - A Friendly Guide
Why This Guide Exists
Before ChatGPT, before GPT-3, before neural networks dominated NLP—computers generated text using elegant statistical methods built on a simple idea:
The next word depends on the words that came before it.
This sounds obvious. But turning that intuition into a working system required clever maths, probability theory, and some creative problem-solving. Understanding these classical methods is not just history—it gives you the foundation to truly understand why modern neural models work the way they do.
This guide builds from scratch, using real examples at every step.
Section 1: What Is a Language Model?
The Core Idea
A language model is a system that assigns a probability to a sequence of words.
In plain English: it answers the question, “How likely is this sentence to appear in natural text?”
| Sentence | Likely probability |
|---|---|
| “The cat sat on the mat.” | High |
| “The mat sat on the cat.” | Lower (grammatical but unusual) |
| “Sat mat the cat on the.” | Very low (grammatically wrong) |
| “Colourless green ideas sleep furiously.” | Low (grammatical but nonsensical) |
A good language model gives high probabilities to natural-sounding sentences and low probabilities to weird or ungrammatical ones.
What Language Models Are Used For
| Application | How the language model helps |
|---|---|
| Autocomplete | “I want to go to the ___” → predicts most likely next word |
| Spell checking | “I went to teh store” — “teh store” has very low probability, so flag it |
| Machine translation | Choose the translated sentence with the highest probability in the target language |
| Speech recognition | Between “recognise speech” and “wreck a nice beach” (same audio), pick the more probable sentence |
| Text generation | Repeatedly predict the next word to produce new text |
Section 2: N-Gram Models — Learning from History
The Simplest Idea: Count What Follows What
Imagine you read a million sentences and track what word tends to follow every other word. After enough reading, you have a pretty good model of language.
This is exactly what N-gram models do.
What Is an N-Gram?
An N-gram is a sequence of N consecutive words.
| N | Name | Example from “The quick brown fox jumps” |
|---|---|---|
| 1 | Unigram | “The”, “quick”, “brown”, “fox”, “jumps” |
| 2 | Bigram | “The quick”, “quick brown”, “brown fox”, “fox jumps” |
| 3 | Trigram | “The quick brown”, “quick brown fox”, “brown fox jumps” |
| 4 | 4-gram | “The quick brown fox”, “quick brown fox jumps” |
Predicting the Next Word with Bigrams
A bigram model assumes the next word depends only on the current word.
Training: Count how often each word follows each other word.
Example corpus:
"I love cats"
"I love dogs"
"I hate rain"
"cats love fish"
Bigram counts: | Previous word | Next word | Count | |—————|———–|——-| | I | love | 2 | | I | hate | 1 | | love | cats | 1 | | love | dogs | 1 | | love | fish | 1 | | cats | love | 1 | | hate | rain | 1 |
Bigram probabilities (divide count by total following word count):
P(love | I) = 2/3 = 0.67
P(hate | I) = 1/3 = 0.33
P(cats | love) = 1/3 = 0.33
P(dogs | love) = 1/3 = 0.33
P(fish | love) = 1/3 = 0.33
Prediction:
- After “I”, the most likely word is “love” (67% chance)
- After “love”, all three options are equal (33% each)
Generating Text with a Bigram Model
import random
from collections import defaultdict
def build_bigram_model(text):
"""Build a bigram language model from text"""
words = text.lower().split()
model = defaultdict(list)
for i in range(len(words) - 1):
model[words[i]].append(words[i + 1])
return model
def generate_text(model, start_word, length=10):
"""Generate text using the bigram model"""
current = start_word
output = [current]
for _ in range(length - 1):
if current not in model:
break
next_word = random.choice(model[current])
output.append(next_word)
current = next_word
return " ".join(output)
# Train on example text
corpus = """
the cat sat on the mat the cat ate the fish
the dog sat on the floor the dog ate the bone
cats love fish dogs love bones
the fish swam in the water
"""
model = build_bigram_model(corpus)
# Generate some text
random.seed(42)
for _ in range(3):
print(generate_text(model, start_word="the", length=8))
Example output:
the cat sat on the floor the dog
the mat the fish swam in the water
the dog ate the bone cats love fish
Notice: the output looks somewhat like natural text but can loop or get stuck!
Trigrams: Using More Context
A trigram model uses the previous two words to predict the next one. This captures more context.
Bigram: P(word | prev_word)
Trigram: P(word | prev_word, prev_prev_word)
Example:
- After “I love”, the next word is probably “cats”, “dogs”, or “pizza”—but not “the” or “and”
- A trigram model knows this; a bigram model only sees “love” and might predict “fish” from other contexts
from collections import defaultdict, Counter
def build_trigram_model(text):
"""Build a trigram language model"""
words = ['<START>'] + text.lower().split() + ['<END>']
model = defaultdict(Counter)
for i in range(len(words) - 2):
context = (words[i], words[i+1])
next_word = words[i+2]
model[context][next_word] += 1
return model
def trigram_predict(model, word1, word2, top_n=3):
"""Predict top N likely next words given two previous words"""
context = (word1, word2)
if context not in model:
return []
total = sum(model[context].values())
predictions = [(w, c/total) for w, c in model[context].most_common(top_n)]
return predictions
# Example
corpus = """
i love cats and i love dogs and i love pizza
cats love fish and dogs love bones
she loves to eat pizza and he loves to eat pasta
"""
model = build_trigram_model(corpus)
print("After 'i love':", trigram_predict(model, "i", "love"))
print("After 'love to':", trigram_predict(model, "love", "to"))
Section 3: The Probability of a Full Sentence
Chaining Probabilities Together
Once you have a bigram model, you can calculate the probability of an entire sentence by multiplying the probabilities of each word given the previous word.
Formula:
P("I love cats") = P(I | <START>) × P(love | I) × P(cats | love) × P(<END> | cats)
Example with numbers:
P(I | <START>) = 0.5 (half our sentences start with "I")
P(love | I) = 0.67 (67% of the time after "I" comes "love")
P(cats | love) = 0.33 (one of three words after "love")
P("I love cats") = 0.5 × 0.67 × 0.33 ≈ 0.11
import math
def sentence_probability(model_probs, sentence):
"""
Calculate log probability of a sentence using bigram model.
Using log to avoid numerical underflow with many multiplications.
"""
words = ['<START>'] + sentence.lower().split() + ['<END>']
log_prob = 0.0
for i in range(len(words) - 1):
bigram = (words[i], words[i+1])
prob = model_probs.get(bigram, 1e-10) # tiny prob for unseen bigrams
log_prob += math.log(prob)
return log_prob
# Example bigram probabilities (hand-crafted for illustration)
probs = {
('<START>', 'i'): 0.5,
('i', 'love'): 0.67,
('love', 'cats'): 0.33,
('cats', '<END>'): 1.0,
('<START>', 'the'): 0.3,
('the', 'dog'): 0.4,
('dog', 'ran'): 0.6,
('ran', '<END>'): 1.0,
}
s1 = sentence_probability(probs, "i love cats")
s2 = sentence_probability(probs, "the dog ran")
print(f"Log P('i love cats') = {s1:.2f}")
print(f"Log P('the dog ran') = {s2:.2f}")
Why log probability? Multiplying many small probabilities (0.5 × 0.3 × 0.1 × …) quickly reaches numbers too small for computers to represent. Taking the log converts multiplication to addition, which is numerically stable.
Section 4: The Sparsity Problem and Smoothing
The Big Problem: Unseen N-Grams
Here is a fundamental challenge. You train your model on a million words. Then someone types a sentence with a word combination that never appeared in your training data.
Training data never contained: "purple elephant sings"
P(sings | purple elephant) = 0/0 = ???
A probability of zero is catastrophic—it means the entire sentence gets probability zero, even if it is perfectly reasonable English.
This is the sparsity problem: real language has infinite possible sentences, but your training data is finite.
Solution 1: Laplace (Add-One) Smoothing
The simplest fix: pretend you saw every possible word combination at least once by adding 1 to every count.
Without smoothing:
Count("purple", "sings") = 0
P(sings | purple) = 0 / total = 0
With Laplace smoothing (add 1 to every count):
Count("purple", "sings") = 0 + 1 = 1
P(sings | purple) = 1 / (total + vocabulary_size)
Now every combination has at least a tiny probability.
def laplace_bigram_probability(word1, word2, bigram_counts, unigram_counts, vocab_size):
"""
Compute P(word2 | word1) with Laplace (add-1) smoothing
"""
count_bigram = bigram_counts.get((word1, word2), 0)
count_unigram = unigram_counts.get(word1, 0)
# Add 1 to numerator, add vocab_size to denominator
return (count_bigram + 1) / (count_unigram + vocab_size)
# Example
bigram_counts = {('I', 'love'): 10, ('I', 'hate'): 3}
unigram_counts = {'I': 13}
vocab_size = 1000
p_love = laplace_bigram_probability('I', 'love', bigram_counts, unigram_counts, vocab_size)
p_never_seen = laplace_bigram_probability('I', 'purple', bigram_counts, unigram_counts, vocab_size)
print(f"P(love | I) = {p_love:.4f}")
print(f"P(purple | I) = {p_never_seen:.6f} ← small but not zero")
Problem with Laplace: It gives too much probability mass to unseen events and takes too much from seen ones. In practice, Laplace smoothing is too aggressive.
Solution 2: Backoff Smoothing
The idea: if you have never seen the trigram “I love cats”, fall back to the bigram “love cats”. If that is also unseen, fall back to the unigram “cats”.
Try: P(cats | I, love) ← trigram — has count? Use it.
Else: P(cats | love) ← bigram — has count? Use it.
Else: P(cats) ← unigram — always has count.
def backoff_probability(word, context, trigram_probs, bigram_probs, unigram_probs,
alpha=0.4):
"""
Stupid backoff: use trigram if available, else bigram, else unigram.
alpha is a discount factor applied when backing off.
"""
if len(context) >= 2:
trigram_key = (context[-2], context[-1], word)
if trigram_key in trigram_probs:
return trigram_probs[trigram_key]
if len(context) >= 1:
bigram_key = (context[-1], word)
if bigram_key in bigram_probs:
return alpha * bigram_probs[bigram_key]
return alpha * alpha * unigram_probs.get(word, 1e-10)
Solution 3: Interpolation Smoothing
Instead of choosing one level, mix all levels together with weights that sum to 1.
P(word | context) = λ₁ × P_trigram + λ₂ × P_bigram + λ₃ × P_unigram
Where λ₁ + λ₂ + λ₃ = 1.
def interpolated_probability(word, context, trigram_probs, bigram_probs, unigram_probs,
l1=0.6, l2=0.3, l3=0.1):
"""
Interpolation smoothing: weighted mix of trigram, bigram, unigram.
l1 + l2 + l3 must equal 1.
"""
p_tri = trigram_probs.get((context[-2], context[-1], word), 0) if len(context) >= 2 else 0
p_bi = bigram_probs.get((context[-1], word), 0) if len(context) >= 1 else 0
p_uni = unigram_probs.get(word, 1e-10)
return l1 * p_tri + l2 * p_bi + l3 * p_uni
Section 5: Evaluating a Language Model — Perplexity
How Do You Know If Your Model Is Any Good?
You could generate some text and see if it sounds natural. But that is subjective. We need a number.
Perplexity is the standard metric for evaluating language models.
What Perplexity Means Intuitively
Perplexity measures how surprised your model is by new text.
Think of it this way: imagine a language model is guessing the next word at each step. Perplexity tells you the average number of equally likely choices the model is considering at each word.
- Low perplexity = model is confident, fewer choices, good model
- High perplexity = model is confused, many choices, bad model
| Perplexity | Interpretation |
|---|---|
| 10 | At each word, the model is effectively choosing from 10 equally likely options — very good |
| 100 | 100 options per word — reasonable |
| 1000 | 1000 options — the model has barely learned anything |
| 1 | Perfect (impossible in practice) |
The Perplexity Formula
Perplexity = 2^(-average log₂ probability per word)
Or equivalently:
Perplexity = (1 / P(test text)) ^ (1/N)
Where N = number of words in the test text
import math
def perplexity(model_probs, test_sentences):
"""
Calculate perplexity of a language model on test sentences.
model_probs: dict of bigram → probability
test_sentences: list of tokenised sentences (lists of words)
"""
total_log_prob = 0.0
total_words = 0
for sentence in test_sentences:
words = ['<START>'] + sentence + ['<END>']
for i in range(len(words) - 1):
bigram = (words[i], words[i+1])
prob = model_probs.get(bigram, 1e-10)
total_log_prob += math.log2(prob)
total_words += 1
avg_log_prob = total_log_prob / total_words
return 2 ** (-avg_log_prob)
# Example: comparing two models on test data
good_model = {
('<START>', 'i'): 0.5, ('i', 'love'): 0.7,
('love', 'cats'): 0.5, ('cats', '<END>'): 1.0,
}
bad_model = {
('<START>', 'i'): 0.1, ('i', 'love'): 0.1,
('love', 'cats'): 0.1, ('cats', '<END>'): 1.0,
}
test = [['i', 'love', 'cats']]
print(f"Good model perplexity: {perplexity(good_model, test):.2f}")
print(f"Bad model perplexity: {perplexity(bad_model, test):.2f}")
# Good model perplexity: ~1.6 (very confident)
# Bad model perplexity: ~10.0 (more confused)
Section 6: Markov Chains — The Statistical Heart
What Is a Markov Chain?
A Markov Chain is a mathematical model where the next state depends only on the current state—not on how you got there.
The N-gram models we built are Markov Chains! A bigram model is a first-order Markov Chain (next word depends on 1 previous word). A trigram model is a second-order Markov Chain (next word depends on 2 previous words).
The Markov Property:
P(word_n | word_1, word_2, ..., word_{n-1}) ≈ P(word_n | word_{n-1})
We approximate that the future depends only on the recent past—not the entire history.
Visualising a Markov Chain
Think of words as states and transitions as arrows with probabilities.
<START> ──0.6──→ "The" ──0.4──→ "cat" ──0.7──→ "sat"
──0.4──→ "A" ──0.3──→ "dog" ──0.3──→ "ran"
──0.3──→ "bird" ──────────→ ...
At each step, you randomly follow an arrow weighted by probability. The result is a generated sentence.
Building a Markov Text Generator
import random
from collections import defaultdict, Counter
import re
def train_markov_model(text, order=2):
"""
Train a Markov chain language model.
order: how many previous words to use as context (1=bigram, 2=trigram)
"""
# Clean and tokenise
words = re.findall(r'\b\w+\b', text.lower())
model = defaultdict(Counter)
for i in range(len(words) - order):
context = tuple(words[i:i+order])
next_word = words[i+order]
model[context][next_word] += 1
return model
def generate_markov_text(model, order, seed_words, length=30):
"""Generate text using the Markov model"""
output = list(seed_words)
context = tuple(seed_words[-order:])
for _ in range(length):
if context not in model:
break
# Weighted random choice
choices = list(model[context].keys())
weights = list(model[context].values())
next_word = random.choices(choices, weights=weights, k=1)[0]
output.append(next_word)
context = tuple(output[-order:])
return ' '.join(output)
# Train on some text
training_text = """
The cat sat on the mat. The cat ate the fish.
The dog ran in the park. The dog chased the cat.
Cats like to eat fish and sleep on mats.
Dogs like to run and play in the park.
The fish swam in the river near the park.
On sunny days, the cat and the dog played together.
"""
model = train_markov_model(training_text, order=2)
print("Generated sentences (order-2 Markov chain):")
print("-" * 50)
random.seed(1)
for i in range(5):
seed = random.choice(list(model.keys()))
text = generate_markov_text(model, order=2, seed_words=list(seed), length=12)
print(f" {i+1}. {text}")
Example output:
Generated sentences (order-2 Markov chain):
--------------------------------------------------
1. the cat sat on sunny days the cat and the dog played
2. the dog chased the cat ate the fish swam in the river
3. cats like to eat fish and sleep on mats dogs like to
4. the park on sunny days the cat and the dog ran in the
5. in the park on sunny days the cat sat on the mat the
Notice: higher-order models produce more coherent text because they remember more context.
Section 7: Limitations of Classical Methods
Classical N-gram models were dominant in NLP for decades. But they have real limitations that motivated the move to neural networks.
1. Fixed Context Window
A trigram only looks back 2 words. It cannot capture long-range dependencies:
"The trophy didn't fit in the suitcase because _____ was too big."
Is the blank “it” (the trophy) or “it” (the suitcase)? Humans use the whole sentence. A trigram model only sees “because ___ was”.
2. No Generalisation
A model trained on “cats love fish” knows nothing about “dogs love fish”—even though they are structurally identical. Every N-gram is treated as independent. Words have no relationships to each other.
3. Vocabulary Explosion
For a vocabulary of 50,000 words, a trigram model needs to store 50,000³ = 125 billion possible combinations. Most are never seen. The model is mostly zeros.
4. Smoothing Is a Hack
All smoothing techniques are approximate fixes to a fundamental problem: the model does not understand language, it just counts.
Why This Matters
These limitations directly motivated word embeddings (words have neighbours and relationships) and neural language models (context is captured by flexible network weights, not fixed windows).
Understanding classical methods makes it clear why the field moved to neural networks—not just that it did.
Section 8: Summary
What You Learned
| Concept | Plain English Summary |
|---|---|
| Language Model | Assigns probability to sentences; higher = more natural |
| N-gram | A sequence of N consecutive words |
| Bigram Model | Predict next word based on current word |
| Trigram Model | Predict next word based on previous 2 words |
| Sparsity Problem | Many N-grams never appear in training data → zero probability |
| Laplace Smoothing | Add 1 to all counts so nothing has zero probability |
| Backoff Smoothing | Fall back to shorter N-gram when longer one unseen |
| Interpolation | Mix all N-gram levels with learnable weights |
| Perplexity | How surprised is the model? Lower = better model |
| Markov Chain | Next state depends only on recent past, not full history |
The Key Insight
Classical text generation taught us that language has statistical regularities. The same words tend to appear together. Some word sequences are far more likely than others. You can learn these patterns from data.
What neural networks added was the ability to generalise those patterns—to understand that “the cat sat” and “a feline rested” are the same idea expressed differently, and to capture context far beyond a fixed window of 2-3 words.
Practice Questions
1. What is the difference between a unigram and a bigram model?
Answer
A unigram model treats each word independently with no context. A bigram model predicts the next word based on the single previous word.2. Why do we use log probabilities instead of raw probabilities for sentences?
Answer
Multiplying many small probabilities produces numbers too tiny for computers to store accurately. Log converts multiplication to addition, avoiding numerical underflow.3. What is the sparsity problem in N-gram models?
Answer
Most possible word combinations never appear in training data, giving them zero probability. This breaks the model whenever it encounters any unseen combination.4. Describe Laplace smoothing in one sentence.
Answer
Add 1 to every N-gram count so that no combination has zero probability, at the cost of slightly redistributing probability from seen to unseen events.5. What does perplexity measure? Is lower or higher better?
Answer
Perplexity measures how surprised the model is by new text—effectively how many equally likely choices it considers at each word. Lower perplexity means a better model.6. What is the Markov property in the context of language models?
Answer
The next word depends only on the recent context (the last N-1 words), not on the entire history of the sentence.7. Why do trigram models generally outperform bigram models?
Answer
Trigrams use more context (2 previous words vs 1), so they make more informed predictions. However, they also suffer more from sparsity.8. Name two limitations of classical N-gram models that neural networks address.