This post is a written version of the first lecture I gave for the “Foundations of Large Language Models” course that I teach at the MVA master in Paris.
The philosophy I want to convey in this series is that you learn by doing, and this is even more true in the age of AI agents that are able to code for you. This post contains many practical exercises, most of them doable in less than 30 minutes, and involving at most a few dozen lines of code. If you really want to master the fundamentals of LLMs, I strongly advise you to do these exercises…without the help of a coding agent!
Every exercise contains the problem statement and a starter piece of code to load the data. Hints and solutions are folded so you don’t see them right away, and a checkpoint shows you the expected results.
Have fun learning by doing! (and feel free to reach out to me if you notice any error)
This first post is about classical Natural Language Processing (NLP), before the LLM era.
I know what you’re thinking. Studying old-school NLP might feel like a waste of time, as these techniques have largely been superseded by LLMs.
Still, I believe dedicating a few hours to it is worth your time. First of all, you’ll learn important concepts that are still relevant today. You’ll better understand the genealogy of ideas, which is important for developing your research taste. In particular, you’ll see more clearly how and why transformers were introduced and rapidly adopted. Finally, this post references a few dozen seminal papers that you must have seen once in your life if you want to be serious about the topic.
In this first chapter, we will follow two important questions that have shaped the development of the field of NLP:
- How can we build a machine learning model that takes as input a piece of text, given that it has no fixed length?
- How can we transfer what a model learned on one task to another, building on the fact that on every NLP task, language is essentially the same?
To explore these questions, we’ll play mostly with two tasks and two datasets:
- the IMDB movie reviews for sentiment classification
- WikiText-2 for language modeling.
- IMDB (Maas et al., 2011): a set of 50k movie reviews labeled either positive or negative, 25k for training and 25k for testing. Get it on the Hugging Face Hub at stanfordnlp/imdb. Note that it also contains a split made of 50k unlabeled reviews.
- WikiText-2 (Merity et al., 2016): about 2 million training words from Wikipedia articles, with a vocabulary of about 33,000 words. Use the tokenized version (
wikitext-2-v1): punctuation is already separated by spaces, and rare words are already replaced by an<unk>token. Get it on the Hugging Face Hub at Salesforce/wikitext.
Other useful datasets are:
- TinyShakespeare: about 1 million characters of Shakespeare’s plays, from Andrej Karpathy’s char-rnn project. Used only as a side demo for character-level generation. Get it on the Hugging Face Hub at jchwenger/tiny_shakespeare.
- text8: the first 100 MB of a cleaned English Wikipedia dump (originally from Matt Mahoney), a standard corpus for training word2vec. Get it on the Hugging Face Hub at afmck/text8.
1. The typical tasks of NLP
Now that we live in a world of LLMs, it’s easy to think that autoregressive language modeling is all there is to NLP. But before 2019 and the realization that GPT-2 could generate coherent text and hence do other tasks without being specifically trained for them, language modeling was only one among the many tasks of classical NLP.
For decades, text classification, tagging and translation were the most important challenges that were tackled using the tools of machine learning.
Each of these four tasks can be formally represented as learning certain probability distributions conditioned on an input piece of text seen as a sequence of words. This is illustrated below.
Let’s consider these different tasks.
1.1 Classification
This is the simplest task to state in NLP: you receive a piece of text and you have to classify it among a predefined list of categories. It can be spam detection, sentiment analysis (Is this movie review positive or not?), or classification among topics (Is this news article about sports, politics, technology…?).
For that, a classifier has to learn the conditional probability
that a certain sequence of words belongs to a class , where here is not fixed a priori, and depends on the sequence.
As shown in the introductory example at the top of this post (“Wow, what a masterpiece. […] A perfect film if you love naps.”), sentiment classification can be subtle and involve detecting things like sarcasm.
1.2 Language modeling
In principle, modeling a language means learning the probability distribution of its possible sequences of words. In practice, it is often more convenient to learn the next-word probability
From this next-word probability, we can simply recover the full probability by using the chain rule, for instance
From today’s perspective, the main use of such a probability distribution is to generate text using autoregression: draw a word from the next-word distribution, append it to the text, and repeat. This is the principle behind LLM chatbots and agents.
But knowing the language probability distribution can have other uses, for instance to score the probability of sentences among a list of candidates. Imagine an Automatic Speech Recognition (ASR) model producing two candidate transcriptions that both reasonably fit the audio sample: it can be followed by a post-processing stage using a language model, to score these candidates and help choose the right one. For instance on any reasonable language model, the probability of the sentence “General relativity was formulated by Alfred Einstein” would certainly rank lower than that of “General relativity was formulated by Albert Einstein”.
Although I present them separately, it is important to realize that next-word prediction is a form of classification task! Instead of assigning probabilities to a finite set of classes, you assign probabilities to a finite set of words among the pre-defined vocabulary. The main practical difference is the size: generally a few classes in NLP classification tasks, while the vocabulary size in language modeling is often tens of thousands.
1.3 Translation
Translation from one language to another has long been one of the most important tasks in NLP, but also a hard one. Formally, we want to learn the probability of a candidate translation conditioned on the original sentence . Again, in practice, we seek to estimate the next-word probability of the translated sequence, conditioned on the original sentence and what has been written so far:
Note that learning this probability distribution is far from obvious since the two sentences a priori have different lengths, and the translation is generally not a word-for-word correspondence.
On top of that, the word order can be totally different, see a nice example below showing the same sentence in 4 languages…you should try the English vs Turkish comparison!
1.4 Tagging
Finally, another typical NLP task is tagging. Its goal is to assign a class per word (or group of words). It can be grammar mistake detection, or tagging the grammatical category of each word in a sentence (noun, verb, …), a task that is known as part-of-speech tagging.
It can also be Named Entity Recognition (NER), for instance tagging all the names of people (or all places) in a text.
Tagging can be seen as “a classifier applied to each word”, but each taking into account the surrounding context.
I will not cover much of tagging here.
2. Counting words: n-grams as the simplest language models
Since language models will be our main interest later in the series, it is worth starting with a historical example representing the simplest way of generating language in an autoregressive way: the Markov model.
2.1 Markov model
If you really want the simplest (and most inefficient) language model, you can simply draw words at random in the vocabulary, following their natural frequency in the language. This is called the unigram model, for which
where is the count of word in the training corpus, and is the corpus size. This is a very crude model! And we can at least improve it by considering bigrams, i.e. the Markov chain model.
Simple Markov chain
In the simplest bigram Markov chain model, the probability of a word in a sequence only depends on the word that comes just before it.
This assumption is of course incorrect for actual human languages, but it allows for very simple modeling using only a transition matrix.
Using a training set, we simply count the occurrences of every word and of every pair of consecutive words . We then use it to compute the probability that the word follows the word . These probabilities define a transition matrix that can then be used for autoregressive text generation using a Markov Chain model.
Let’s try that on the following exercise.
Exercise 2.1 Build a Markov chain model
Using the WikiText-2 data, count the occurrences of consecutive words and use them to generate a sequence of 50 words with a Markov chain model.
from datasets import load_dataset
wikitext = load_dataset("Salesforce/wikitext", "wikitext-2-v1")
print(wikitext["train"][4]["text"][:70]) # 70 chars from line 4
The game began development in 2010 , carrying over a large portion of
Hint
Look at a sample of WikiText-2. The text is already pre-processed, you just need to split it on spaces to get words. Keep every word as it is, including the = signs of the section titles (lines like = = History = =), @-@, which stands for a hyphen (role @-@ playing), and @,@ and @.@, which stand for the comma and the decimal point inside numbers (14 @,@ 000, 5 @.@ 5).
Note that the dataset replaces the rare words, those with a count below 3, with the <unk> word. So every word of the validation and test sets is also in the training text, and none of them gets a probability of 0 for being unknown.
Hint
The vocabulary is too large to actually efficiently store the count matrix, which would be very sparse anyway. Use a nested dictionary {'the' : {'dog' : 3, ...}, ...} to count the occurrence of each pair of words.
Hint
random.choices(words, weights=counts) draws a word with a probability proportional to its count, normalization is automatic.
Checkpoint what you should get
The training split has 2,051,910 words. The vocabulary has 33,277 words, including <unk> and =. There are 618,405 distinct pairs, out of billion possible pairs.
With another seed, your sample will differ but should look similar: each pair of consecutive words is plausible, but the sentence as a whole is not.
text: 2051910 words vocabulary: 33277 words distinct pairs: 618405 The institute . " and long , Mortimer Wheeler secured by worry over the Song military officer notable example of willows along the Richmond Chamber of the opening sequence stars except those produced , In November 23 January 1908 Distinguished Service before dissipating as a more able to bring the
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
wikitext = load_dataset("Salesforce/wikitext", "wikitext-2-v1")
print(wikitext["train"][4]["text"][:70]) # 70 chars from line 4
The game began development in 2010 , carrying over a large portion of
# (1) Build the train sequence by splitting every line on spaces
train_sequence = []
for line in wikitext["train"]["text"]:
train_sequence.extend(line.split())
print(f"text: {len(train_sequence)} words")
print(f"vocabulary: {len(set(train_sequence))} words")
# (2) Use a nested dict for counting bigrams
bigram_counts = {}
for a, b in zip(train_sequence, train_sequence[1:]):
if a not in bigram_counts:
bigram_counts[a] = {}
if b not in bigram_counts[a]:
bigram_counts[a][b] = 0
bigram_counts[a][b] += 1
print(f"distinct pairs: {sum(len(f) for f in bigram_counts.values())}")
# (3) Generate a sequence using random.choices with the counts as weights
first_word = "The"
length = 50
import random
random.seed(0)
sample_words = [first_word]
while len(sample_words) < length:
followers = bigram_counts[sample_words[-1]]
options, weights = list(followers), list(followers.values())
next_word = random.choices(options, weights)[0]
sample_words.append(next_word)
print("\n" + " ".join(sample_words))
text: 2051910 words vocabulary: 33277 words distinct pairs: 618405 The institute . " and long , Mortimer Wheeler secured by worry over the Song military officer notable example of willows along the Richmond Chamber of the opening sequence stars except those produced , In November 23 January 1908 Distinguished Service before dissipating as a more able to bring the
As you can see, the produced text is nonsense, but the word order is roughly grammatical.
n-gram models
We can also extend the model by using n-grams, that is, chunks of consecutive words. To predict the next word, we thus consider the sequence of the previous words as an input. The Markov chain model of 2.1 is , the unigram model is where no context at all is used.
Using n-grams, the transition matrix will be , which can quickly become overwhelming (for and , we have about entries). In practice, once again we store only the n-grams that occur, in a dictionary of counts, but not the full table.
Below you can see examples for WikiText-2 at various .
2.2 Comparing probability distributions
In language modeling, our goal is to approximate the “true” probability distribution of next word occurrence. Let’s call it . Any candidate language model will produce a candidate distribution (in the n-gram Markov model, it depends only on ).
How do we measure success? That is, how do we quantify how similar is to ?
KL Divergence and cross-entropy loss
To assess whether a certain probability distribution is a good approximation to another one, the tool of choice is the Kullback–Leibler divergence
I write it with generic distribution and , but note that this definition also works using conditional probabilities for instance in our case and .
Finding the distribution that best approximates a given means minimizing the KL divergence. Now, in our case, as a priori is unknown, exactly computing this quantity might seem impossible. But to minimize the KL divergence, we can first notice that its log part can be separated into
The first part is (minus) the entropy of (which doesn’t depend on ), while the second part is the so-called cross-entropy.
Since the cross-entropy
is the only part that actually depends on , we are actually seeking that minimizes it.
Finding that minimizes the KL-divergence is equivalent to minimizing the cross-entropy .
You can try it informally on a very simple example below, by dragging the bars of the orange distribution.
Likelihood
Now let’s return to our notations, and remember that we are working with conditional probabilities and of having as the next word, knowing the context .
If depends on some parameters , we seek to minimize the cross-entropy
which can be seen as an expectation over distribution probability
Once again, we cannot compute this quantity directly because we do not know , but our text corpus is a sample from .
At each position of a text, the context is followed by the word that was actually written. Most contexts appear only once, so we never see the distribution , only one word drawn from it. But the average over the positions of the text estimates the cross-entropy, averaged over the contexts as they occur in the language:
This average has a simple meaning. A sum of logs is the log of a product, and by the chain rule of section 1.2, is the probability that the model gives to the whole text, its likelihood. So is the negative log-likelihood of the text divided by its number of words, and minimizing the cross-entropy amounts to maximizing the likelihood of the corpus.
To train a model, we choose the parameters (for instance the weights of a neural network) that minimize on a training text. Each term of the sum is itself a cross-entropy: the one between and the distribution that puts all its probability on the observed word (a one-hot distribution), which stands in for the unknown :
This is the cross-entropy loss used to train language models, LLMs included. Such a single term is a noisy estimate, but on average over the words of the text, its gradient is . Since the entropy of does not depend on , this is also .
Maximizing the likelihood = Minimizing the cross-entropy loss = Minimizing the KL divergence.
For the n-gram models, we don’t even need gradients: the ratio of counts of section 2.1 is exactly the that maximizes the likelihood of the training text.
Perplexity
Now a cross-entropy is not easy to read and interpret, so we usually report its exponential, the perplexity of the model on a text:
It is the inverse of the geometric average of the probabilities that the model gives to the words of the text. A model that is uniform over words has perplexity , so perplexity can be interpreted the number of words the model hesitates between.
Optional Validation and test sets: why we need two held-out texts
Now, on which text should we measure it? Not on the training text: the model was fitted to these very words, so its perplexity there is too optimistic. A model that memorized the training text would give it a very high probability, and still predict new text badly. To estimate how well approximates , we need another sample from that the model has never seen: a held-out set.
WikiText-2 comes with two held-out sets. The validation set is used for every choice we make about the model, such as the value of n (or the weights of the interpolation we’ll introduce in section 2.3): we try several values and keep the one with the lowest validation perplexity. But a choice made this way is fitted to the validation set a little, so the validation perplexity of the chosen model is slightly optimistic too. The test set is used only once, at the end, to measure the perplexity we report.
In the exercises, we measure the perplexity on the validation set, and the test perplexity only to compare the models in section 6.
Exercise 2.2 Measure the perplexity of the unigram and bigram models
Using the counts of exercise 2.1, compute the perplexity of the unigram model and of the bigram model on the validation set of WikiText-2. Do you notice a problem? Then compute the perplexity of the unigram model on the test set.
The starter code repeats steps (1) and (2) of the solution of exercise 2.1, which build the training sequence and the bigram counts. It also splits the validation and test sets into words in the same way.
from datasets import load_dataset
wikitext = load_dataset("Salesforce/wikitext", "wikitext-2-v1")
# (1) Build the train sequence by splitting every line on spaces
# [Copied from the solution of 2.1, step (1)]
train_sequence = []
for line in wikitext["train"]["text"]:
train_sequence.extend(line.split())
print(f"text: {len(train_sequence)} words")
print(f"vocabulary: {len(set(train_sequence))} words")
# (2) Use a nested dict for counting bigrams
# [Copied from the solution of 2.1, step (2)]
bigram_counts = {}
for a, b in zip(train_sequence, train_sequence[1:]):
if a not in bigram_counts:
bigram_counts[a] = {}
if b not in bigram_counts[a]:
bigram_counts[a][b] = 0
bigram_counts[a][b] += 1
print(f"distinct pairs: {sum(len(f) for f in bigram_counts.values())}")
# ----------------------
# New in this exercise
# ----------------------
# The validation and test sequences, built in the same way
val_sequence = []
for line in wikitext["validation"]["text"]:
val_sequence.extend(line.split())
test_sequence = []
for line in wikitext["test"]["text"]:
test_sequence.extend(line.split())
print(f"validation: {len(val_sequence)} words")
print(f"test: {len(test_sequence)} words")
text: 2051910 words vocabulary: 33277 words distinct pairs: 618405 validation: 213886 words test: 241211 words
Hint
Add up the log-probabilities instead of multiplying the probabilities: the product of 200,000 probabilities is too small to be stored as a floating-point number.
Hint
For the bigram model, look for the pairs of the validation set that have a count of zero in training.
Checkpoint what you should get
Every word of the validation set appears in the training text, so the unigram model gives each of them a non-zero probability, and its perplexity is 996.5. But 46,338 pairs of the validation set, 21.7% of them, never appear in training: each of them gets the probability 0, so the bigram perplexity is infinite.
On the test set, the unigram perplexity is 941.7. It is lower than on the validation set. This is not a bug: the test articles of WikiText-2 happen to be easier to predict. Published models show the same gap: a Kneser-Ney 5-gram gets 219.3 on the test set and 233.9 on the validation set (Herel & Mikolov, 2023).
unigram perplexity, validation set: 996.5 validation pairs never seen in training: 46338 out of 213885, or 21.7% bigram perplexity, validation set: inf unigram perplexity, test set: 941.7
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
wikitext = load_dataset("Salesforce/wikitext", "wikitext-2-v1")
# (1) Build the train sequence by splitting every line on spaces
# [Copied from the solution of 2.1, step (1)]
train_sequence = []
for line in wikitext["train"]["text"]:
train_sequence.extend(line.split())
print(f"text: {len(train_sequence)} words")
print(f"vocabulary: {len(set(train_sequence))} words")
# (2) Use a nested dict for counting bigrams
# [Copied from the solution of 2.1, step (2)]
bigram_counts = {}
for a, b in zip(train_sequence, train_sequence[1:]):
if a not in bigram_counts:
bigram_counts[a] = {}
if b not in bigram_counts[a]:
bigram_counts[a][b] = 0
bigram_counts[a][b] += 1
print(f"distinct pairs: {sum(len(f) for f in bigram_counts.values())}")
# ----------------------
# New in this exercise
# ----------------------
# The validation and test sequences, built in the same way
val_sequence = []
for line in wikitext["validation"]["text"]:
val_sequence.extend(line.split())
test_sequence = []
for line in wikitext["test"]["text"]:
test_sequence.extend(line.split())
print(f"validation: {len(val_sequence)} words")
print(f"test: {len(test_sequence)} words")
text: 2051910 words vocabulary: 33277 words distinct pairs: 618405 validation: 213886 words test: 241211 words
# (1) Count the words of the train sequence
unigram_counts = {}
for word in train_sequence:
if word not in unigram_counts:
unigram_counts[word] = 0
unigram_counts[word] += 1
# (2) Perplexity of the unigram model: the exponential of minus
# the average log-probability of the validation words
import math
def perplexity(log_probs):
return math.exp(-sum(log_probs) / len(log_probs))
n_train = len(train_sequence)
log_probs = []
for word in val_sequence:
log_probs.append(math.log(unigram_counts[word] / n_train))
print(f"unigram perplexity, validation set: "
f"{perplexity(log_probs):.1f}")
# (3) Perplexity of the bigram model. A pair never seen in
# training has the probability 0, and log(0) is minus infinity.
follower_totals = {}
for a in bigram_counts:
follower_totals[a] = sum(bigram_counts[a].values())
log_probs = []
unseen_pairs = 0
for a, b in zip(val_sequence, val_sequence[1:]):
count = bigram_counts[a].get(b, 0)
if count == 0:
unseen_pairs += 1
log_probs.append(-math.inf)
else:
log_probs.append(math.log(count / follower_totals[a]))
n_pairs = len(log_probs)
print(f"\nvalidation pairs never seen in training: {unseen_pairs}")
print(f"out of {n_pairs}, or {100 * unseen_pairs / n_pairs:.1f}%")
print(f"bigram perplexity, validation set: {perplexity(log_probs)}")
# (4) Perplexity of the unigram model on the test set
log_probs = []
for word in test_sequence:
log_probs.append(math.log(unigram_counts[word] / n_train))
test_perplexity = perplexity(log_probs)
print(f"\nunigram perplexity, test set: {test_perplexity:.1f}")
unigram perplexity, validation set: 996.5 validation pairs never seen in training: 46338 out of 213885, or 21.7% bigram perplexity, validation set: inf unigram perplexity, test set: 941.7
Just by knowing how frequent each word is, the unigram model hesitates between about 1,000 words, instead of 33,277 for a uniform guess. The bigram model should do much better, since it also knows the previous word, but one validation pair out of five never appears in training. A single zero probability is enough to make the perplexity infinite!
2.3 Curse of dimensionality
Zero counts arrive fast with words taken from a training corpus. With a vocabulary of 33,000 words, there are about 1.1 billion possible word pairs, but 2 million training words show at most 2 million of them (in exercise 2.1 we had about 618,000 distinct pairs, so 0.06%). Unseen pairs already appear in held-out text at n = 2 or 3. This is a manifestation of the curse of dimensionality: the number of possible contexts grows exponentially as while the training text stays the same size.
Exercise 2.3a Count the n-grams for n = 1 to 5
Extend the counts of exercise 2.1 to n = 1 to 5, with nested dictionaries keyed by the tuple of the n − 1 previous words. Generate a text with each n. Then measure, for each n, the fraction of the n-grams of the validation set never seen in training.
The starter code builds the training sequence, with step (1) of the solution of exercise 2.1, and the validation and test sequences, as in the starter of exercise 2.2.
from datasets import load_dataset
wikitext = load_dataset("Salesforce/wikitext", "wikitext-2-v1")
# (1) Build the train sequence by splitting every line on spaces
# [Copied from the solution of 2.1, step (1)]
train_sequence = []
for line in wikitext["train"]["text"]:
train_sequence.extend(line.split())
print(f"text: {len(train_sequence)} words")
print(f"vocabulary: {len(set(train_sequence))} words")
# The validation and test sequences, built in the same way
# [Copied from the starter of 2.2]
val_sequence = []
for line in wikitext["validation"]["text"]:
val_sequence.extend(line.split())
test_sequence = []
for line in wikitext["test"]["text"]:
test_sequence.extend(line.split())
print(f"validation: {len(val_sequence)} words")
print(f"test: {len(test_sequence)} words")
text: 2051910 words vocabulary: 33277 words validation: 213886 words test: 241211 words
Hint
For n = 1, the context is the empty tuple (). The counts for n = 1 to 5 take about 1.5 GB of memory.
Hint
When you generate a text, the first words have fewer than n − 1 words before them: use the counts of a smaller n for them.
Checkpoint what you should get
The fraction of the n-grams of the validation set never seen in training grows from 0% for n = 1 (every word is in the vocabulary, thanks to <unk>) to 21.7% for n = 2 and 95.2% for n = 5. Your samples will differ, unless you use the same seed and the same first word.
n = 1: The island Several was a three with Coast and at 500 Stefani five game expressed 15 is oxidation Abd Beginning digital and continuing F closed down . that built weekly n = 2: The institute . " and long , Mortimer Wheeler secured by worry over the Song military officer notable example of willows along the Richmond Chamber of the opening sequence stars n = 3: The institute is part of the river also served as the negative critical reviews , but the studio floor were often built in 1858 on the south of Romani marked n = 4: The institute is part of the first stone of the original route of Highway 36 was first assumed by the Department of Highways were accused of cowardice by fellow officers n = 5: The institute is part of Florida Atlantic 's Department of Ocean Engineering which was founded in 1870 . By 20 April 1914 , when the residents passed Act to <unk> n = 1: 0.0% never seen n = 2: 21.7% never seen n = 3: 60.8% never seen n = 4: 85.8% never seen n = 5: 95.2% never seen
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
wikitext = load_dataset("Salesforce/wikitext", "wikitext-2-v1")
# (1) Build the train sequence by splitting every line on spaces
# [Copied from the solution of 2.1, step (1)]
train_sequence = []
for line in wikitext["train"]["text"]:
train_sequence.extend(line.split())
print(f"text: {len(train_sequence)} words")
print(f"vocabulary: {len(set(train_sequence))} words")
# The validation and test sequences, built in the same way
# [Copied from the starter of 2.2]
val_sequence = []
for line in wikitext["validation"]["text"]:
val_sequence.extend(line.split())
test_sequence = []
for line in wikitext["test"]["text"]:
test_sequence.extend(line.split())
print(f"validation: {len(val_sequence)} words")
print(f"test: {len(test_sequence)} words")
text: 2051910 words vocabulary: 33277 words validation: 213886 words test: 241211 words
# (1) For each n, count the n-grams in a nested dict:
# counts[n][context][word] is the number of times `word` follows
# `context`, the tuple of the n - 1 words before it.
# For n = 1, the context is the empty tuple ().
counts = {}
for n in range(1, 6):
counts[n] = {}
for i in range(n - 1, len(train_sequence)):
context = tuple(train_sequence[i - n + 1:i])
word = train_sequence[i]
if context not in counts[n]:
counts[n][context] = {}
if word not in counts[n][context]:
counts[n][context][word] = 0
counts[n][context][word] += 1
# (2) Generate 30 words with each n, starting with "The"
import random
first_word = "The"
length = 30
for n in range(1, 6):
random.seed(0)
sample_words = [first_word]
while len(sample_words) < length:
# The last n - 1 words, or fewer for the first words
if n == 1:
context = ()
else:
context = tuple(sample_words[-(n - 1):])
followers = counts[len(context) + 1][context]
options, weights = list(followers), list(followers.values())
next_word = random.choices(options, weights)[0]
sample_words.append(next_word)
print(f"n = {n}: " + " ".join(sample_words) + "\n")
# (3) For each n, the fraction of the n-grams of the validation
# sequence never seen in training
for n in range(1, 6):
unseen = 0
total = 0
for i in range(n - 1, len(val_sequence)):
context = tuple(val_sequence[i - n + 1:i])
word = val_sequence[i]
if context not in counts[n] or word not in counts[n][context]:
unseen += 1
total += 1
print(f"n = {n}: {100 * unseen / total:.1f}% never seen")
n = 1: The island Several was a three with Coast and at 500 Stefani five game expressed 15 is oxidation Abd Beginning digital and continuing F closed down . that built weekly n = 2: The institute . " and long , Mortimer Wheeler secured by worry over the Song military officer notable example of willows along the Richmond Chamber of the opening sequence stars n = 3: The institute is part of the river also served as the negative critical reviews , but the studio floor were often built in 1858 on the south of Romani marked n = 4: The institute is part of the first stone of the original route of Highway 36 was first assumed by the Department of Highways were accused of cowardice by fellow officers n = 5: The institute is part of Florida Atlantic 's Department of Ocean Engineering which was founded in 1870 . By 20 April 1914 , when the residents passed Act to <unk> n = 1: 0.0% never seen n = 2: 21.7% never seen n = 3: 60.8% never seen n = 4: 85.8% never seen n = 5: 95.2% never seen
As n grows, the samples become more and more readable, and at n = 5 they almost look like real sentences. But don’t be fooled: 95% of the 5-grams of the validation set never appear in training, so the 5-gram model rarely has a choice. It copies long pieces of the training text word for word (12 to 30 words in our samples), and jumps from one to another when they share 4 words. The 5-gram model writes well because it memorizes the training text.
There are several options to avoid the infinite perplexity due to zero probabilities associated with the curse of dimensionality:
- Smoothing: giving unseen sequences a small non-zero probability. The simplest is add-one smoothing (add 1 to every count), but it works badly with words: it gives too much probability to the billions of unseen pairs.
- Interpolation works better: it mixes the n-gram estimate with the (n−1)-gram estimate, using a weight tuned on validation data. The estimate is applied step-by-step (recursively) down to the unigram, and since the unigram is non-zero for every word in the vocabulary, the mix is never zero.
- More sophisticated techniques have been devised since, see for instance the Kneser-Ney below, that has long been state-of-the-art.
Optional Backoff and Kneser-Ney: the best smoothing of the counting era
Many variants of smoothing followed, such as backoff (Katz, 1987), which falls back on the (n−1)-gram estimate only when the n-gram was never seen. The best of them, according to the large comparison of Chen and Goodman (Chen & Goodman, 1999), is Kneser-Ney smoothing. Its trick is in the shorter contexts: a word gets a probability according to the number of different words it follows, rather than according to its count (think of “Francisco”, frequent but almost always after “San”). It remained for years the baseline that neural language models had to beat.
Smoothing deals with unseen sequences of known words. Unseen words are another problem, which WikiText-2 hides by replacing rare words with <unk>. This is our first encounter with the vocabulary problem: it comes back in section 3.2, but tokenizers (the topic of the next post) solve it.
Exercise 2.3b★ Smooth the n-gram models
With the counts of exercise 2.3a, compute the perplexity of the n-gram models on the validation set, for n = 1 to 5, with add-one smoothing and with interpolation. Tune the weights of the interpolation on the validation set, and plot the perplexity against n. Then compute the test perplexity of the interpolated 5-gram model.
The starter code builds the three sequences, as in exercises 2.1 and 2.2, then the counts for n = 1 to 5 with step (1) of the solution of exercise 2.3a: counts[n][context][word].
from datasets import load_dataset
wikitext = load_dataset("Salesforce/wikitext", "wikitext-2-v1")
# (1) Build the train sequence by splitting every line on spaces
# [Copied from the solution of 2.1, step (1)]
train_sequence = []
for line in wikitext["train"]["text"]:
train_sequence.extend(line.split())
print(f"text: {len(train_sequence)} words")
print(f"vocabulary: {len(set(train_sequence))} words")
# The validation and test sequences, built in the same way
# [Copied from the starter of 2.2]
val_sequence = []
for line in wikitext["validation"]["text"]:
val_sequence.extend(line.split())
test_sequence = []
for line in wikitext["test"]["text"]:
test_sequence.extend(line.split())
print(f"validation: {len(val_sequence)} words")
print(f"test: {len(test_sequence)} words")
# (1) For each n, count the n-grams in a nested dict:
# counts[n][context][word] is the number of times `word` follows
# `context`, the tuple of the n - 1 words before it.
# For n = 1, the context is the empty tuple ().
# [Copied from the solution of 2.3a, step (1)]
counts = {}
for n in range(1, 6):
counts[n] = {}
for i in range(n - 1, len(train_sequence)):
context = tuple(train_sequence[i - n + 1:i])
word = train_sequence[i]
if context not in counts[n]:
counts[n][context] = {}
if word not in counts[n][context]:
counts[n][context][word] = 0
counts[n][context][word] += 1
text: 2051910 words vocabulary: 33277 words validation: 213886 words test: 241211 words
Hint
Work with log-probabilities, as in exercise 2.2. Many n-grams of the validation set are not in the counts: look them up with .get, which returns a default value for a missing key. With add-one smoothing, the count of every possible n-gram grows by 1, so the count of each context grows by V, the size of the vocabulary.
Hint
For interpolation, start with two orders and a single weight : , where and are the probabilities from the bigram and unigram counts. Tune by a grid search on the validation set. Then add the orders one at a time, each with its own weight. When a context was never seen in training, skip its order.
Hint
To try many weights quickly, compute once, for each word of the validation set and each n, the count of the n-gram that ends with this word and the count of its context. The perplexity for given weights is then a few operations on arrays.
Checkpoint what you should get
With add-one smoothing, the validation perplexity grows with n, from 999.1 for n = 1 to 31,024.1 for n = 5. With interpolation, it goes down from 996.5 for n = 1 to 296.2 for n = 5, and the interpolated 5-gram model has a test perplexity of 277.7. The charts below show all the values.
n = 1: add-one perplexity 999.1 n = 2: add-one perplexity 2207.8 n = 3: add-one perplexity 13327.0 n = 4: add-one perplexity 26020.8 n = 5: add-one perplexity 31024.1 n = 2: weight 0.68, interpolated perplexity 345.5 n = 3: weight 0.23, interpolated perplexity 303.7 n = 4: weight 0.11, interpolated perplexity 297.3 n = 5: weight 0.09, interpolated perplexity 296.2 test perplexity, n = 5: 277.7
Solution 3 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
wikitext = load_dataset("Salesforce/wikitext", "wikitext-2-v1")
# (1) Build the train sequence by splitting every line on spaces
# [Copied from the solution of 2.1, step (1)]
train_sequence = []
for line in wikitext["train"]["text"]:
train_sequence.extend(line.split())
print(f"text: {len(train_sequence)} words")
print(f"vocabulary: {len(set(train_sequence))} words")
# The validation and test sequences, built in the same way
# [Copied from the starter of 2.2]
val_sequence = []
for line in wikitext["validation"]["text"]:
val_sequence.extend(line.split())
test_sequence = []
for line in wikitext["test"]["text"]:
test_sequence.extend(line.split())
print(f"validation: {len(val_sequence)} words")
print(f"test: {len(test_sequence)} words")
# (1) For each n, count the n-grams in a nested dict:
# counts[n][context][word] is the number of times `word` follows
# `context`, the tuple of the n - 1 words before it.
# For n = 1, the context is the empty tuple ().
# [Copied from the solution of 2.3a, step (1)]
counts = {}
for n in range(1, 6):
counts[n] = {}
for i in range(n - 1, len(train_sequence)):
context = tuple(train_sequence[i - n + 1:i])
word = train_sequence[i]
if context not in counts[n]:
counts[n][context] = {}
if word not in counts[n][context]:
counts[n][context][word] = 0
counts[n][context][word] += 1
text: 2051910 words vocabulary: 33277 words validation: 213886 words test: 241211 words
# (1) For each word of a sequence and each n, look up two counts
# in training: the n-gram that ends with this word, and its
# context. Start at the 5th word, so that every word has 4 words
# before it. The counts are stored in numpy arrays.
import numpy as np
context_totals = {}
for n in range(1, 6):
context_totals[n] = {}
for context in counts[n]:
context_totals[n][context] = sum(counts[n][context].values())
def lookup_counts(sequence):
ngram_count = {}
context_count = {}
for n in range(1, 6):
ngram_list = []
context_list = []
for i in range(4, len(sequence)):
context = tuple(sequence[i - n + 1:i])
followers = counts[n].get(context, {})
ngram_list.append(followers.get(sequence[i], 0))
context_list.append(context_totals[n].get(context, 0))
ngram_count[n] = np.array(ngram_list)
context_count[n] = np.array(context_list)
return ngram_count, context_count
val_ngram, val_context = lookup_counts(val_sequence)
def perplexity(probs):
return np.exp(-np.mean(np.log(probs)))
# (2) Add-one smoothing: add 1 to the count of every possible
# n-gram, so the count of each context grows by V, the size of
# the vocabulary
V = len(set(train_sequence))
add_one = []
for n in range(1, 6):
probs = (val_ngram[n] + 1) / (val_context[n] + V)
add_one.append(perplexity(probs))
print(f"n = {n}: add-one perplexity {add_one[-1]:.1f}")
# (3) Interpolation: q_n = w * p_n + (1 - w) * q_(n-1), where p_n
# is the probability from the n-gram counts. When the context was
# never seen in training, q_n = q_(n-1).
def interpolate(ngram_count, context_count, weights):
q = ngram_count[1] / context_count[1] # n = 1
for n in range(2, len(weights) + 2):
w = weights[n - 2] # weights[0] is the weight of n = 2
seen = context_count[n] > 0
p = np.zeros(len(q))
p[seen] = ngram_count[n][seen] / context_count[n][seen]
q = np.where(seen, w * p + (1 - w) * q, q)
return q
# Tune the weight of each n in turn, by a grid search
weights = []
interpolation = [perplexity(interpolate(val_ngram, val_context, []))]
print()
for n in range(2, 6):
best_w, best_perplexity = 0, np.inf
for k in range(1, 100):
w = k / 100
q = interpolate(val_ngram, val_context, weights + [w])
if perplexity(q) < best_perplexity:
best_w, best_perplexity = w, perplexity(q)
weights.append(best_w)
interpolation.append(best_perplexity)
print(f"n = {n}: weight {best_w}, "
f"interpolated perplexity {best_perplexity:.1f}")
# (4) Test perplexity of the interpolated 5-gram model
test_ngram, test_context = lookup_counts(test_sequence)
q = interpolate(test_ngram, test_context, weights)
test_perplexity = perplexity(q)
print(f"\ntest perplexity, n = 5: {test_perplexity:.1f}")
n = 1: add-one perplexity 999.1 n = 2: add-one perplexity 2207.8 n = 3: add-one perplexity 13327.0 n = 4: add-one perplexity 26020.8 n = 5: add-one perplexity 31024.1 n = 2: weight 0.68, interpolated perplexity 345.5 n = 3: weight 0.23, interpolated perplexity 303.7 n = 4: weight 0.11, interpolated perplexity 297.3 n = 5: weight 0.09, interpolated perplexity 296.2 test perplexity, n = 5: 277.7
import matplotlib.pyplot as plt
orders = [1, 2, 3, 4, 5]
plt.figure(figsize=(5, 3.5))
plt.plot(orders, add_one, "o-", label="add-one")
plt.plot(orders, interpolation, "o-", label="interpolation")
plt.yscale("log")
plt.xticks(orders)
plt.xlabel("n")
plt.ylabel("validation perplexity")
plt.legend()
plt.show()

Add-one smoothing is a disaster: with n = 5, its perplexity is 31,024, barely better than a uniform guess over the 33,277 words! Interpolation does much better, but look at the chart: almost all the gain comes from n = 2, and going from n = 2 to n = 5 only takes the perplexity from 346 to 296. The tuned weights say the same thing: 0.68 for the bigrams, but only 0.09 for the 5-grams. Longer contexts should help a lot, yet with counts we can hardly use them.
If you want to dig deeper, below are a few bonus exercises.
Kneser-Ney smoothing. Implement it, and compare it with interpolation. How close does it get to the neural models of the next sections? For reference, a Kneser-Ney 5-gram reaches a test perplexity of 219 on WikiText-2 (Herel & Mikolov, 2023).
Characters instead of words. Train character n-gram models on TinyShakespeare (jchwenger/tiny_shakespeare, loaded with sample_by="document"), for n = 1 to 10. At which n do unseen n-grams appear? At which n do the samples become readable? Their perplexity cannot be compared with that of the word models, because the unit is not the same (see the box in section 7.3).
Albert or Alfred? Score “General relativity was formulated by Albert Einstein” and “General relativity was formulated by Alfred Einstein” with your models (the example of section 1.2). Which n is needed to prefer Albert?
Word order. Compare the perplexity of a few sentences with that of the same sentences, with their words shuffled.
Memory. How many distinct n-grams does the model store, for each n?
3. Classification with bag of words
Let’s now leave language modeling for a while, and turn to the other task of this post: classifying movie reviews as positive or negative.
3.1 The pattern: encoder → fixed-size representation → head
In a classification task, such as the sentiment analysis of movie reviews, the input text has a variable length. And we can’t rely on the trick we used for the Markov language model, which is to keep only the last words. Indeed there is no reason to assume that the important information will be in the last words, especially if is less than 3 or 4!
Since all classical machine learning methods (logistic regression, feedforward networks, tree-based methods…) require an input of fixed size, we need a way to go from a variable-length piece of text to a fixed-size representation of the content of this text.
In general, a classification model in NLP can be split into two steps: the encoder, which produces a fixed-size representation of the text (sometimes called a latent representation), and the classification head, which takes this representation and produces the probabilities of the classes.
This is illustrated below.
Note that the n-gram models of section 2 already follow this pattern, for language modeling: the encoder keeps the last n − 1 words of the text, a representation of fixed size, and the head is the table of counts, which gives the probabilities of the next word.
So what are our options to build a fixed-size representation of a review? Let’s start with the simplest one.
3.2 Bag of words
Consider this review:
Great acting, great lighting, and a flabbergasting soundtrack, but not even Zendaya could save this boring, boring plot.
We can simply count the occurrences of each word.
| Word | Count | Word | Count | Word | Count | Word | Count |
|---|---|---|---|---|---|---|---|
| a | 1 | but | 1 | great | 2 | save | 1 |
| acting | 1 | could | 1 | lighting | 1 | soundtrack | 1 |
| and | 1 | even | 1 | not | 1 | this | 1 |
| boring | 2 | flabbergasting | 1 | plot | 1 | Zendaya | 1 |
We can’t use this table directly as a representation, because its size (and the words in it) changes from one review to the next. The solution is to choose a vocabulary of V words beforehand, for instance the V most frequent words of the training reviews. Each review then becomes a vector of size V, whose entry number is the count of the -th word of the vocabulary in the review.
Almost all the entries of this vector are zeros: the median IMDB review has 178 words, while the vocabulary has thousands of them. It is a sparse vector. You can also see it as the sum of the one-hot vectors of the words of the review, which has the same size V whatever the length of the review. We have our fixed-size representation, and we can feed it to any classical classifier.
This representation is called a bag of words: it is as if we had cut the review into words and thrown them into a bag. We know how many times each word appears, but we have lost the order in which they appear (keep that in mind, it will come back).
Of course, some words will not be in the vocabulary, like “Zendaya” in our example. They are simply ignored. With V = 10,000, “flabbergasting” is most likely ignored too: it appears in only 2 of the 25,000 training reviews of IMDB.
For the head, the simplest choice is a logistic regression. It gives a weight to each word of the vocabulary, and computes the probability that the review is positive as
where is a bias, and the sigmoid function turns the score into a probability between 0 and 1. In other words, each occurrence of a word pushes the score up or down by the weight of this word, and the model says “positive” when the total is above zero. The weights are learned on labeled reviews, by minimizing the cross-entropy loss of section 2.2 (with two classes, instead of a whole vocabulary of next words).
You can play with such a model below: it is the one you will train in exercise 3.2. Edit the review and watch its count vector change. To show the limits of this approach, try to write a very negative review that gets classified as positive, for instance using sarcasm.
Let’s train such a model on IMDB.
Optional Don't forget overfitting
Remember that with 10,000 weights for 20,000 reviews, the model can overfit: it can find weights that fit the training reviews perfectly, noise included, and generalize badly. This is why logistic regression is usually regularized: a penalty proportional to the sum of the squared weights is added to the loss, to keep the weights small. In scikit-learn, the parameter C is the inverse of the strength of this penalty: the smaller C, the smaller the weights. As we did for the weights of the interpolation in section 2.3, we choose C on a validation set, here 5,000 reviews taken out of the training set.
Exercise 3.2 Classify the IMDB reviews with a bag of words
Represent each review by its counts over the 10,000 most frequent words of the training reviews, and train a logistic regression on these vectors. Keep 5,000 training reviews aside as a validation set, and use it to choose the parameter C of the regularization. Then measure the test accuracy, and print the 10 words with the largest positive weights and the 10 words with the largest negative weights.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
example = imdb["train"][0]
print(f"label {example['label']} (0 = negative, 1 = positive)")
print(example["text"][:70])
label 0 (0 = negative, 1 = positive) I rented I AM CURIOUS-YELLOW from my video store because of all the co
Hint
The reviews contain HTML line breaks, <br />: replace them with spaces. To make the validation set, use the method train_test_split(test_size=5000, seed=0) of the training split. Don’t just take the last 5,000 reviews: the training reviews are sorted by label!
Hint
CountVectorizer(max_features=10000) from scikit-learn builds the vocabulary of the most frequent words of the texts you give to its method fit_transform, and returns their count vectors. Its method transform then counts the words of any other text. By default, it skips the words of a single letter, like “a”: pass token_pattern=r"\b\w+\b" to keep them. The vectors are stored as sparse matrices, which keep only the non-zero entries.
Hint
Use LogisticRegression(C=C, max_iter=1000) from scikit-learn, and try C = 0.01, 0.03, 0.1, 0.3, 1, 3 and 10. Once a model is trained, model.coef_[0] holds its weights, in the order of the words of vectorizer.get_feature_names_out().
Checkpoint what you should get
The best value is C = 0.1, with a validation accuracy of 88.9%, and the test accuracy is 87.6%. The most negative words include “worst”, “waste” and “awful”, the most positive ones “perfect”, “excellent” and “superb”. If you cut the reviews into words differently, your numbers and your lists can differ a little.
train: 20000 reviews validation: 5000 reviews test: 25000 reviews C = 0.01: validation accuracy 0.883 C = 0.03: validation accuracy 0.887 C = 0.1: validation accuracy 0.889 C = 0.3: validation accuracy 0.886 C = 1: validation accuracy 0.875 C = 3: validation accuracy 0.870 C = 10: validation accuracy 0.862 test accuracy, C = 0.1: 0.876 negative: worst waste disappointment awful poorly boring lacks fails avoid mess positive: 7 8 perfect excellent refreshing wonderfully superb amazing favorite rare
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
example = imdb["train"][0]
print(f"label {example['label']} (0 = negative, 1 = positive)")
print(example["text"][:70])
label 0 (0 = negative, 1 = positive) I rented I AM CURIOUS-YELLOW from my video store because of all the co
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# (2) Count the words of each review, over the 10,000 most
# frequent words of the training reviews. The pattern makes every
# run of letters or digits a word, even a single letter like "a".
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_features=10000,
token_pattern=r"\b\w+\b")
X_train = vectorizer.fit_transform(train_texts)
X_val = vectorizer.transform(val_texts)
X_test = vectorizer.transform(test_texts)
# (3) Train a logistic regression for several values of C, the
# inverse of the strength of the regularization, and keep the best
# model on the validation set
from sklearn.linear_model import LogisticRegression
print()
best_accuracy = 0
for C in [0.01, 0.03, 0.1, 0.3, 1, 3, 10]:
model = LogisticRegression(C=C, max_iter=1000)
model.fit(X_train, train_labels)
accuracy = model.score(X_val, val_labels)
print(f"C = {C}: validation accuracy {accuracy:.3f}")
if accuracy > best_accuracy:
best_accuracy, best_C, best_model = accuracy, C, model
# (4) Measure the test accuracy of the best model
test_accuracy = best_model.score(X_test, test_labels)
print(f"\ntest accuracy, C = {best_C}: {test_accuracy:.3f}")
# (5) Print the words with the largest weights, toward negative
# and toward positive. np.argsort sorts the weights in increasing
# order and returns their positions.
import numpy as np
words = vectorizer.get_feature_names_out()
weights = best_model.coef_[0] # one weight per word
order = np.argsort(weights)
most_negative = [words[i] for i in order[:10]]
most_positive = [words[i] for i in order[-10:]]
most_positive.reverse()
print("\nnegative:", " ".join(most_negative))
print("positive:", " ".join(most_positive))
train: 20000 reviews validation: 5000 reviews test: 25000 reviews C = 0.01: validation accuracy 0.883 C = 0.03: validation accuracy 0.887 C = 0.1: validation accuracy 0.889 C = 0.3: validation accuracy 0.886 C = 1: validation accuracy 0.875 C = 3: validation accuracy 0.870 C = 10: validation accuracy 0.862 test accuracy, C = 0.1: 0.876 negative: worst waste disappointment awful poorly boring lacks fails avoid mess positive: 7 8 perfect excellent refreshing wonderfully superb amazing favorite rare
Without knowing anything about the order of the words, a bag of words gets 87.6% of the test reviews right! And its weights are easy to read: the model has built its own lists of positive and negative words from the data. But look at the two most positive words: “7” and “8”. They are not sentiments, they are grades: many reviewers end with “7/10” or “8/10”, and the model has found this shortcut on its own. The model does not understand the reviews: it counts the words that tend to come with each label.
Learning the words from the data works much better than choosing them by hand, and this was one of the first lessons of sentiment classification by machine learning.
3.3 TF-IDF weighting
In the previous example, words like “a” and “and” are certainly not very informative, because they are very common: “the” appears in 99% of the training reviews of IMDB, “a” and “and” in 97% of them. On the contrary, “boring” appears in only 6% of the reviews, and certainly carries useful information.
A solution to improve the bag-of-words representation is to down-weight the words that appear in most documents, and up-weight the rare ones. This is what the inverse document frequency does: multiply the count of each word by of the fraction of documents that contain it,
where is the number of documents and the number of documents that contain the word . The entry of the vector of a document becomes , hence the name TF-IDF, for “term frequency times inverse document frequency”. With a base-2 logarithm, “the” gets a weight of 0.01, almost nothing, “boring” a weight of 4, and “flabbergasting”, in 2 reviews out of 25,000, would get 13.6.
Another simple variant keeps only the presence of each word: 1 if the word is in the review, 0 if not. This is what worked best for Pang et al. (see the margin note of section 3.2). Let’s compare these weightings on IMDB.
Exercise 3.3 Weight the words of the bag of words
Redo exercise 3.2 with two other weightings of the words: their presence, and TF-IDF. For each weighting, choose C on the validation set, then compare the test accuracies of the three weightings.
The starter code repeats step (1) of the solution of exercise 3.2, so the training, validation and test reviews are the same as in exercise 3.2.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
train: 20000 reviews validation: 5000 reviews test: 25000 reviews
Hint
CountVectorizer(binary=True) gives the presence of the words, and TfidfVectorizer gives TF-IDF. Both take the same arguments as in exercise 3.2.
Hint
TfidfVectorizer differs from the formula above in two ways. It uses the natural logarithm and adds 1 to the idf, so that a word present in every review is not removed completely. And it divides each vector by its length, so that all the reviews get vectors of length 1, whatever their number of words.
Hint
The best C is not the same for the three weightings: their vectors have values of very different sizes.
Checkpoint what you should get
The test accuracy is 87.6% with the counts (C = 0.1), 88.2% with the presence (C = 0.03), and 88.4% with TF-IDF (C = 3).
counts: C = 0.1, test accuracy 0.876 presence: C = 0.03, test accuracy 0.882 TF-IDF: C = 3, test accuracy 0.884
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
train: 20000 reviews validation: 5000 reviews test: 25000 reviews
# (1) The three weightings, over the 10,000 most frequent words
# of the training reviews
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfVectorizer
V = 10000
pattern = r"\b\w+\b" # every word, even a single letter
vectorizers = {
"counts": CountVectorizer(max_features=V, token_pattern=pattern),
"presence": CountVectorizer(max_features=V, token_pattern=pattern,
binary=True),
"TF-IDF": TfidfVectorizer(max_features=V, token_pattern=pattern),
}
# (2) For each weighting, choose C on the validation set as in
# exercise 3.2, then measure the test accuracy of the best model
from sklearn.linear_model import LogisticRegression
test_accuracies = {}
for name in vectorizers:
vectorizer = vectorizers[name]
X_train = vectorizer.fit_transform(train_texts)
X_val = vectorizer.transform(val_texts)
X_test = vectorizer.transform(test_texts)
best_accuracy = 0
for C in [0.01, 0.03, 0.1, 0.3, 1, 3, 10]:
model = LogisticRegression(C=C, max_iter=1000)
model.fit(X_train, train_labels)
accuracy = model.score(X_val, val_labels)
if accuracy > best_accuracy:
best_accuracy, best_C, best_model = accuracy, C, model
test_accuracies[name] = best_model.score(X_test, test_labels)
print(f"{name}: C = {best_C}, "
f"test accuracy {test_accuracies[name]:.3f}")
counts: C = 0.1, test accuracy 0.876 presence: C = 0.03, test accuracy 0.882 TF-IDF: C = 3, test accuracy 0.884
TF-IDF wins, but by less than one point. Why so little? Because the logistic regression learns its own weight for each word. Multiplying the count of a word by a fixed number, like its idf, can be compensated by dividing the weight of the word by the same number: the model can learn the same thing with or without idf, and only the regularization (and the normalization of the vectors) makes a small difference. TF-IDF matters much more when no weight is learned, for instance to measure the similarity between two documents, which was the problem of Sparck Jones and Salton. Whatever the weighting, our bag of words stays below 89%.
These numbers are typical of the bag of words on IMDB: the paper that introduced the dataset reports 87.8% with the presence of the words (Maas et al., 2011). A strong bag-of-words baseline by Wang and Manning reaches 91.2% (Wang & Manning, 2012). It adds the pairs of consecutive words to the vocabulary, so that “not good” becomes a word of its own, and mixes a Naive Bayes classifier with an SVM. Pairs of words bring back a little of the word order, and this order is precisely what the bag of words is missing.
3.4 The limit
Consider these two reviews:
A: Excellent movie, awesome soundtrack.
B: Superb film, wonderful score.
They say essentially the same thing, but with completely different words. So they get completely orthogonal representations in the bag of words: no entry is non-zero in both vectors.
| excellent | movie | awesome | soundtrack | superb | film | wonderful | score | |
|---|---|---|---|---|---|---|---|---|
| A | 1 | 1 | 1 | 1 | 0 | 0 | 0 | 0 |
| B | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 1 |
For the bag of words, “superb” is as different from “excellent” as it is from “boring”. The representation does not help the head: the head must learn the weight of each word separately, from the labeled reviews that contain this word. For a frequent word like “excellent”, found in 1,780 training reviews, this works fine. But a rare word like “mesmerizing” (62 reviews) gets a weight learned from a handful of examples, and a word outside the vocabulary, like “flabbergasting”, gets no weight at all.
The second limit is in the name: a bag of words forgets the order of the words. These two reviews have exactly the same words, hence exactly the same vector, so the model must give them the same probability:
A: I expected it to be terrible, but it was great.
B: I expected it to be great, but it was terrible.
The same goes for negation. In “not bad at all”, the model sees “not”, “bad”, “at” and “all” separately, and “bad” has a strong negative weight. And sarcasm is even worse, since it uses positive words to say something negative.
To follow these limits through the post, I wrote a small set of hard reviews. They are short, and their sentiment is obvious to any human reader. They are grouped by kind of difficulty: negation, contrast, sarcasm, word order, synonyms and rare words. We will run every IMDB model of the post on them, starting with our bag of words.
Exercise 3.4 Test the bag of words on the hard reviews
Run the model of exercise 3.2 on the 13 hard reviews of the starter code. For each review, print its label and the probability of being positive that the model gives it. Which reviews does the model get wrong, and why?
The starter code trains this model again, with steps (1) and (2) of the solution of exercise 3.2 and the value of C chosen there, 0.1. Then it lists the hard reviews.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# (2) Count the words of each review, over the 10,000 most
# frequent words of the training reviews. The pattern makes every
# run of letters or digits a word, even a single letter like "a".
# [Copied from the solution of 3.2, step (2)]
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_features=10000,
token_pattern=r"\b\w+\b")
X_train = vectorizer.fit_transform(train_texts)
X_val = vectorizer.transform(val_texts)
X_test = vectorizer.transform(test_texts)
# ----------------------
# New in this exercise
# ----------------------
# The logistic regression of exercise 3.2, with C = 0.1, the
# value chosen on the validation set
from sklearn.linear_model import LogisticRegression
model = LogisticRegression(C=0.1, max_iter=1000)
model.fit(X_train, train_labels)
# The hard reviews: (review, label), with 1 for positive
hard_reviews = [
# Negation
("Not bad at all, actually.", 1),
("Nothing about this movie is good.", 0),
("I wouldn't call it a masterpiece, but I didn't regret "
"a minute.", 1),
# Contrast
("Great acting, great lighting and a flabbergasting soundtrack, "
"but not even Zendaya could save this boring, boring plot.", 0),
("The first hour is slow and the dialogue is clumsy, but the "
"ending makes it all worth it.", 1),
("Everyone told me it would be the film of the year: great "
"cast, amazing trailer, brilliant director. What a "
"disappointment.", 0),
# Sarcasm
("Wow, what a masterpiece. Two hours of pure brilliance, and I "
"only checked my watch twenty times. The acting was so "
"convincing that I truly believed the actors wanted to be "
"somewhere else. A perfect film if you love naps.", 0),
("Brilliant. Another sequel nobody asked for, with the same "
"jokes as the first one.", 0),
# The same words in a different order
("I expected it to be terrible, but it was great.", 1),
("I expected it to be great, but it was terrible.", 0),
# Synonyms, no word in common
("Excellent movie, awesome soundtrack.", 1),
("Superb film, wonderful score.", 1),
# Rare words
("A flabbergasting, mesmerizing tour de force.", 1),
]
print(f"{len(hard_reviews)} hard reviews")
train: 20000 reviews validation: 5000 reviews test: 25000 reviews 13 hard reviews
Hint
model.predict_proba returns one row per review, with the probabilities of the two classes: negative in column 0, positive in column 1.
Hint
To understand a prediction, look at the weights of the words of the review: paste it into the figure “A review as a bag of words” of section 3.2, which shows the same model.
Checkpoint what you should get
The model gets 7 of the 13 reviews right. The two reviews with the same words get exactly the same probability, 0.495.
label p(positive) right review positive 0.335 NO Not bad at all, actually. negative 0.423 yes Nothing about this movie is good. positive 0.470 NO I wouldn't call it a masterpiece, b… negative 0.109 yes Great acting, great lighting and a … positive 0.513 yes The first hour is slow and the dial… negative 0.685 NO Everyone told me it would be the fi… negative 0.761 NO Wow, what a masterpiece. Two hours … negative 0.598 NO Brilliant. Another sequel nobody as… positive 0.495 NO I expected it to be terrible, but i… negative 0.495 yes I expected it to be great, but it w… positive 0.779 yes Excellent movie, awesome soundtrack. positive 0.810 yes Superb film, wonderful score. positive 0.521 yes A flabbergasting, mesmerizing tour … 7 right out of 13
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# (2) Count the words of each review, over the 10,000 most
# frequent words of the training reviews. The pattern makes every
# run of letters or digits a word, even a single letter like "a".
# [Copied from the solution of 3.2, step (2)]
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_features=10000,
token_pattern=r"\b\w+\b")
X_train = vectorizer.fit_transform(train_texts)
X_val = vectorizer.transform(val_texts)
X_test = vectorizer.transform(test_texts)
# ----------------------
# New in this exercise
# ----------------------
# The logistic regression of exercise 3.2, with C = 0.1, the
# value chosen on the validation set
from sklearn.linear_model import LogisticRegression
model = LogisticRegression(C=0.1, max_iter=1000)
model.fit(X_train, train_labels)
# The hard reviews: (review, label), with 1 for positive
hard_reviews = [
# Negation
("Not bad at all, actually.", 1),
("Nothing about this movie is good.", 0),
("I wouldn't call it a masterpiece, but I didn't regret "
"a minute.", 1),
# Contrast
("Great acting, great lighting and a flabbergasting soundtrack, "
"but not even Zendaya could save this boring, boring plot.", 0),
("The first hour is slow and the dialogue is clumsy, but the "
"ending makes it all worth it.", 1),
("Everyone told me it would be the film of the year: great "
"cast, amazing trailer, brilliant director. What a "
"disappointment.", 0),
# Sarcasm
("Wow, what a masterpiece. Two hours of pure brilliance, and I "
"only checked my watch twenty times. The acting was so "
"convincing that I truly believed the actors wanted to be "
"somewhere else. A perfect film if you love naps.", 0),
("Brilliant. Another sequel nobody asked for, with the same "
"jokes as the first one.", 0),
# The same words in a different order
("I expected it to be terrible, but it was great.", 1),
("I expected it to be great, but it was terrible.", 0),
# Synonyms, no word in common
("Excellent movie, awesome soundtrack.", 1),
("Superb film, wonderful score.", 1),
# Rare words
("A flabbergasting, mesmerizing tour de force.", 1),
]
print(f"{len(hard_reviews)} hard reviews")
train: 20000 reviews validation: 5000 reviews test: 25000 reviews 13 hard reviews
# (1) Count the words of the hard reviews over the vocabulary of
# the model, and compute their probability of being positive
hard_texts = [review for review, label in hard_reviews]
X_hard = vectorizer.transform(hard_texts)
probabilities = model.predict_proba(X_hard)
p_positive = probabilities[:, 1] # column 0: negative, 1: positive
# (2) Print a table: the model is right when it gives more than
# 0.5 to the label of the review
names = ["negative", "positive"]
print("label p(positive) right review")
right_count = 0
for (review, label), p in zip(hard_reviews, p_positive):
right = (p > 0.5) == (label == 1)
if right:
right_count += 1
mark = "yes" if right else "NO"
short = review if len(review) <= 36 else review[:35] + "…"
print(f"{names[label]:9} {p:.3f} {mark:5} {short}")
print(f"\n{right_count} right out of {len(hard_reviews)}")
label p(positive) right review positive 0.335 NO Not bad at all, actually. negative 0.423 yes Nothing about this movie is good. positive 0.470 NO I wouldn't call it a masterpiece, b… negative 0.109 yes Great acting, great lighting and a … positive 0.513 yes The first hour is slow and the dial… negative 0.685 NO Everyone told me it would be the fi… negative 0.761 NO Wow, what a masterpiece. Two hours … negative 0.598 NO Brilliant. Another sequel nobody as… positive 0.495 NO I expected it to be terrible, but i… negative 0.495 yes I expected it to be great, but it w… positive 0.779 yes Excellent movie, awesome soundtrack. positive 0.810 yes Superb film, wonderful score. positive 0.521 yes A flabbergasting, mesmerizing tour … 7 right out of 13
The bag of words fails where we expected it to. Sarcasm fools it: “perfect” and “masterpiece” make the review about naps 76% positive, and “Brilliant” makes the sequel review 60% positive. Negation too: “Not bad at all, actually” is only 34% positive, because of “bad”. The two reviews with the same words get the same probability, so one of them is necessarily wrong. On the other hand, the two synonymous reviews are both right: each of their words appears in hundreds of labeled reviews, so the model has learned its weight. A bag of words knows which words are positive, but not what the review says.
How can we do better? For the first limit, we need a representation where similar words get similar vectors, so that what the model learns about “excellent” also helps with “superb”, and even with words it has rarely seen. This is the topic of the next section. For the word order, we will have to wait until section 7.
If you want to dig deeper, below are a few bonus exercises.
A small neural network. Replace the logistic regression with a perceptron with one hidden layer (MLPClassifier in scikit-learn). Does it beat the logistic regression? Does it get more hard reviews right?
Naive Bayes. Train a Naive Bayes classifier on the counts (MultinomialNB in scikit-learn), one of the three classifiers of Pang et al. (Pang et al., 2002). How does it compare with the logistic regression?
Pairs of words. Add the pairs of consecutive words to the vocabulary (ngram_range=(1, 2)), as in Wang and Manning (Wang & Manning, 2012). How much does the accuracy improve? Which hard reviews does it fix?
The size of the vocabulary. How does the test accuracy change with V, from 1,000 words to the whole vocabulary of the training reviews (about 75,000 words)?
Shortcuts. Remove the digits from the reviews before counting the words. How much accuracy does the model lose without the grades “7/10” or “8/10”?
4. Dense word embeddings
At the end of section 3, we saw the first limit of the bag of words: each word has its own dimension, so for the model, “superb” is as different from “excellent” as it is from “boring”. Let’s fix this.
4.1 The embedding matrix
The idea is to represent each word by a dense vector of real numbers, in a space whose dimension D is much smaller than the size V of the vocabulary: a few hundred dimensions (for instance ) for tens of thousands of words. This vector is called the embedding of the word.
The embeddings of all the words are stored in an embedding matrix , with one column per word of the vocabulary:
Looking up the embedding of a word is the same as multiplying by the one-hot vector of this word: the product selects the column of the word.
The benefit of dense embeddings over the sparse vectors of the bag of words is that we can compare them, and measure how similar two words are. The usual measure is the cosine similarity, the cosine of the angle between the two vectors, which does not depend on their lengths:
It is 1 when the two vectors point in the same direction, 0 when they are orthogonal, and −1 when they point in opposite directions. Note that the one-hot vectors of two different words are always orthogonal: in the bag of words, the similarity between two different words is always 0.
Let’s assume for now that we have a good embedding matrix (we will see how to get one, and as you might guess, we will learn it). We can hope that similar words get similar vectors: a high similarity between “excellent” and “superb”, but a low one between “ball” and “crocodile”. This is what makes a good embedding: the similarity between the vectors reflects the similarity between the meanings of the words. A model that has learned something about “excellent” then knows something about “superb” too.
Optional Latent Semantic Analysis: another way to get dense vectors
There are other ways to get dense vectors than learning them with a neural network. For example, take the matrix of the counts of section 3, with one row per word and one column per document, and compress it with a truncated singular value decomposition (SVD): each word gets a dense vector, and the words that appear in the same documents get similar vectors. This is Latent Semantic Analysis.
Now, simply using word embeddings does not solve our problem of variable input size. We replace the L words of a text with L vectors of size D: this is still not a representation of fixed size! So we need a way to aggregate these L vectors into a single vector, whose size does not depend on L. Here are three simple options:
- the average of the L vectors, of size D;
- a weighted average, to give more importance to some words, also of size D;
- the concatenation of the vectors of the last n − 1 words only, of size (n − 1) × D, as the n-gram models of section 2 did with the words themselves.
The concatenation keeps the order of the last words, but forgets everything before them. This suits language modeling, where the next word depends mostly on the last ones, so this is where we start.
4.2 Concatenation → Bengio’s neural language model
Let’s go back to language modeling for a moment. With the n-gram models of section 2, we assumed that the probability of the next word depends only on the n − 1 previous words. We can keep this assumption, but replace the table of counts with a neural network, whose input is the concatenation of the embeddings of the n − 1 previous words: a vector of fixed size (n − 1) × D.
This is the idea of the influential paper of Bengio et al. from 2003, which can be considered as the first neural language model. Its architecture is simple: look up the embeddings of the last n − 1 words in the matrix , concatenate them into a vector , then feed into a feedforward network with one hidden layer:
where and are the weights and the biases of the hidden layer, and and those of the output layer. The output layer gives one score per word of the vocabulary, and the softmax, , turns these V scores into probabilities, positive and summing to 1, as the sigmoid of section 3.2 did for two classes.
The embedding matrix is a parameter of the model, like , , and : the embeddings and the weights of the network are learned jointly, by minimizing the cross-entropy loss of section 2.2 on the training text. At each step, the gradient of the loss flows back through the network to the columns of of the words of the context, and moves them.
I’ll assume here that you know how to train a neural network with PyTorch. If not, the course of Andrej Karpathy is a great place to start, and its third lecture builds a model of this kind, on characters.
Optional PyTorch: what we use from it in this post
From here on, the exercises train neural networks with PyTorch. They use only a small part of it, and mostly the same part:
- Tensors.
torch.tensorturns lists of word indices into tensors..to(device)moves them to the GPU when there is one, and the code runs on the CPU otherwise. - Layers. A model is a stack of layers in
nn.Sequential:nn.Embedding, which holds the embedding matrix of section 4.1;nn.Flatten, which concatenates the embeddings of the context;nn.Linear; and the activationsnn.Tanhandnn.ReLU. - Losses.
F.cross_entropytakes the V scores of the output layer and the index of the right word, applies the softmax, and computes the cross-entropy of section 2.2.nn.BCEWithLogitsLossdoes the same for two classes, with the sigmoid of section 3.2.
The training loop is written by hand, and it is the same in every exercise:
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
for epoch in range(n_epochs):
order = torch.randperm(len(targets)) # shuffle the examples
for start in range(0, len(order), batch_size):
batch = order[start:start + batch_size]
scores = model(inputs[batch])
loss = F.cross_entropy(scores, targets[batch])
optimizer.zero_grad()
loss.backward() # compute the gradients
optimizer.step() # update the parameters
To measure a model on the validation set, put the computation inside with torch.no_grad():, which skips the gradients. To keep the model of the best epoch, save a copy of it with copy.deepcopy. We don’t use Dataset or DataLoader: the data of each exercise fits in memory as a few tensors, and we draw the batches ourselves.
Later sections add a few pieces: requires_grad_(False), to keep pre-trained embeddings fixed during the training (section 5); the recurrent layers nn.LSTM and nn.GRU, and gradient clipping with nn.utils.clip_grad_norm_ (section 7); and models written as a subclass of nn.Module, with their own forward method, when they are not a simple stack of layers, like the LSTM of section 7 and the encoder–decoder of section 9.
Why would this work better than counts? Remember the curse of dimensionality of section 2.3: most contexts of 3 or 4 words never appear in the training text, and for the counts, an unseen context is a dead end. The neural model is not stuck: if it has seen “the cat is walking”, and if “dog” has an embedding close to the one of “cat”, then it already knows something about “the dog is walking”. And similar words do get similar embeddings, because they appear in similar contexts and must lead to similar predictions. The model generalizes from the contexts it has seen to the contexts it has never seen.
Let’s train it on WikiText-2, and compare it with the n-gram models of section 2. The model has many more parameters than the logistic regression of section 3, and it overfits after a few epochs, so we choose the number of epochs on the validation set.
Exercise 4.2★ Train Bengio's neural language model
Train Bengio’s neural language model on WikiText-2: look up the embeddings of the 3 words before each word (D = 100), concatenate them, then a hidden layer of 200 units (tanh) and a softmax over the vocabulary. Train for 4 epochs, measure the validation perplexity after each epoch, and compute the test perplexity of the best model. Compare with the smoothed n-gram models of exercise 2.3b. Then print the 5 nearest neighbors of “three”, “he”, “city”, “film”, “excellent” and “terrible” in the learned embedding matrix, according to the cosine similarity.
The starter code builds the three sequences, as in exercises 2.1 and 2.2. Then it gives each word of the vocabulary an index, and turns each sequence into pairs: the indices of 3 consecutive words, and the index of the word that follows them.
from datasets import load_dataset
wikitext = load_dataset("Salesforce/wikitext", "wikitext-2-v1")
# (1) Build the train sequence by splitting every line on spaces
# [Copied from the solution of 2.1, step (1)]
train_sequence = []
for line in wikitext["train"]["text"]:
train_sequence.extend(line.split())
print(f"text: {len(train_sequence)} words")
print(f"vocabulary: {len(set(train_sequence))} words")
# The validation and test sequences, built in the same way
# [Copied from the starter of 2.2]
val_sequence = []
for line in wikitext["validation"]["text"]:
val_sequence.extend(line.split())
test_sequence = []
for line in wikitext["test"]["text"]:
test_sequence.extend(line.split())
print(f"validation: {len(val_sequence)} words")
print(f"test: {len(test_sequence)} words")
# ----------------------
# New in this exercise
# ----------------------
# Give each word of the vocabulary an index
vocabulary = sorted(set(train_sequence))
word_index = {word: i for i, word in enumerate(vocabulary)}
# Turn a sequence into (context, next word) pairs of indices:
# row i of contexts holds the 3 words before the word targets[i]
import torch
context_size = 3
def make_pairs(sequence):
indices = [word_index[word] for word in sequence]
contexts = [indices[i - context_size:i]
for i in range(context_size, len(indices))]
targets = indices[context_size:]
return torch.tensor(contexts), torch.tensor(targets)
train_contexts, train_targets = make_pairs(train_sequence)
val_contexts, val_targets = make_pairs(val_sequence)
test_contexts, test_targets = make_pairs(test_sequence)
print(f"train: {len(train_targets)} pairs")
context = [vocabulary[i] for i in train_contexts[30].tolist()]
print(f"example: {context} -> {vocabulary[train_targets[30]]}")
text: 2051910 words vocabulary: 33277 words validation: 213886 words test: 241211 words train: 2051907 pairs example: ['Valkyria', 'Chronicles', 'III'] -> outside
Hint
nn.Embedding(V, D) holds the embedding matrix: given a tensor of word indices of shape (batch, 3), it returns their embeddings, of shape (batch, 3, D). nn.Flatten() then concatenates the 3 embeddings of each row. Start from small embeddings, with nn.init.normal_(embedding.weight, std=0.1): with the default standard deviation of 1, the rare words would barely move from their random starting point in 4 epochs.
Hint
The output layer gives V scores. F.cross_entropy(scores, targets) applies the softmax and computes the cross-entropy loss in one go. The perplexity is the exponential of the average loss over the validation pairs.
Hint
Train with torch.optim.Adam (learning rate 0.001) on batches of 512 pairs: an epoch is 4,000 batches. On a laptop CPU, an epoch takes about a minute and a half, and a GPU is much faster. Keep a copy of the model (copy.deepcopy) at the epoch with the best validation perplexity.
Hint
nn.Embedding stores transposed: row i of its weight is the embedding of word i. Divide each row by its length: the cosine similarities between a word and all the others are then a single product of a matrix by a vector.
Checkpoint what you should get
The validation perplexity goes down from 260.1 after the first epoch to 223.9 after the third, then goes back up to 228.5: the model starts to overfit. The test perplexity of the model of epoch 3 is 208.5. The nearest neighbors of “three” are other numbers, and those of “film” include “book”, “novel” and “album”. On a GPU, your numbers and your neighbors can differ a little.
epoch 1: validation perplexity 260.1 epoch 2: validation perplexity 226.2 epoch 3: validation perplexity 223.9 epoch 4: validation perplexity 228.5 test perplexity, epoch 3: 208.5 three: two, four, five, eight, six he: she, He, Torres, McCall, Colby city: town, park, community, sightings, formations film: book, novel, album, What, bird excellent: actual, reduce, ultimate, ideological, immense terrible: my, ought, interesting, wrong, physical
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
wikitext = load_dataset("Salesforce/wikitext", "wikitext-2-v1")
# (1) Build the train sequence by splitting every line on spaces
# [Copied from the solution of 2.1, step (1)]
train_sequence = []
for line in wikitext["train"]["text"]:
train_sequence.extend(line.split())
print(f"text: {len(train_sequence)} words")
print(f"vocabulary: {len(set(train_sequence))} words")
# The validation and test sequences, built in the same way
# [Copied from the starter of 2.2]
val_sequence = []
for line in wikitext["validation"]["text"]:
val_sequence.extend(line.split())
test_sequence = []
for line in wikitext["test"]["text"]:
test_sequence.extend(line.split())
print(f"validation: {len(val_sequence)} words")
print(f"test: {len(test_sequence)} words")
# ----------------------
# New in this exercise
# ----------------------
# Give each word of the vocabulary an index
vocabulary = sorted(set(train_sequence))
word_index = {word: i for i, word in enumerate(vocabulary)}
# Turn a sequence into (context, next word) pairs of indices:
# row i of contexts holds the 3 words before the word targets[i]
import torch
context_size = 3
def make_pairs(sequence):
indices = [word_index[word] for word in sequence]
contexts = [indices[i - context_size:i]
for i in range(context_size, len(indices))]
targets = indices[context_size:]
return torch.tensor(contexts), torch.tensor(targets)
train_contexts, train_targets = make_pairs(train_sequence)
val_contexts, val_targets = make_pairs(val_sequence)
test_contexts, test_targets = make_pairs(test_sequence)
print(f"train: {len(train_targets)} pairs")
context = [vocabulary[i] for i in train_contexts[30].tolist()]
print(f"example: {context} -> {vocabulary[train_targets[30]]}")
text: 2051910 words vocabulary: 33277 words validation: 213886 words test: 241211 words train: 2051907 pairs example: ['Valkyria', 'Chronicles', 'III'] -> outside
# (1) Build the model: look up the embeddings of the 3 words of
# the context, concatenate them, then a hidden layer (tanh) and
# one score per word of the vocabulary. Start from small
# embeddings: with the default initialization, of size 1, the
# rare words would keep their random vectors.
from torch import nn
V = len(vocabulary)
D = 100 # size of the embeddings
H = 200 # size of the hidden layer
torch.manual_seed(0)
model = nn.Sequential(
nn.Embedding(V, D), # (batch, 3) -> (batch, 3, D)
nn.Flatten(), # concatenation: (batch, 3 * D)
nn.Linear(context_size * D, H),
nn.Tanh(),
nn.Linear(H, V), # one score per word
)
nn.init.normal_(model[0].weight, std=0.1)
# Run on the GPU if there is one
device = "cuda" if torch.cuda.is_available() else "cpu"
model = model.to(device)
# (2) The perplexity of a model on a set of pairs: the exponential
# of the average cross-entropy loss. F.cross_entropy applies the
# softmax to the scores, then computes the loss of section 2.2.
import math
import torch.nn.functional as F
def perplexity(model, contexts, targets):
total_loss = 0
with torch.no_grad():
for start in range(0, len(targets), 10000):
scores = model(contexts[start:start + 10000].to(device))
batch_targets = targets[start:start + 10000].to(device)
total_loss += F.cross_entropy(scores, batch_targets,
reduction="sum").item()
return math.exp(total_loss / len(targets))
# (3) Train with Adam on batches of 512 pairs. After each epoch,
# measure the validation perplexity, and keep a copy of the best
# model.
import copy
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
batch_size = 512
best_perplexity = math.inf
for epoch in range(4):
order = torch.randperm(len(train_targets)) # shuffled pairs
for start in range(0, len(order), batch_size):
batch = order[start:start + batch_size]
scores = model(train_contexts[batch].to(device))
batch_targets = train_targets[batch].to(device)
loss = F.cross_entropy(scores, batch_targets)
optimizer.zero_grad()
loss.backward()
optimizer.step()
val_perplexity = perplexity(model, val_contexts, val_targets)
print(f"epoch {epoch + 1}: validation perplexity "
f"{val_perplexity:.1f}")
if val_perplexity < best_perplexity:
best_perplexity, best_epoch = val_perplexity, epoch + 1
best_model = copy.deepcopy(model)
# (4) Test perplexity of the best model
test_perplexity = perplexity(best_model, test_contexts, test_targets)
print(f"\ntest perplexity, epoch {best_epoch}: {test_perplexity:.1f}")
# (5) The nearest neighbors of a few words. nn.Embedding stores W
# transposed: row i is the embedding of word i. Divide each row by
# its length, so that a dot product is a cosine similarity.
embeddings = best_model[0].weight.detach().cpu()
embeddings = embeddings / embeddings.norm(dim=1, keepdim=True)
print()
for word in ["three", "he", "city", "film", "excellent", "terrible"]:
similarities = embeddings @ embeddings[word_index[word]]
ranking = similarities.argsort(descending=True).tolist()
nearest = [vocabulary[i] for i in ranking[1:6]] # [0]: the word
print(f"{word}: {', '.join(nearest)}")
epoch 1: validation perplexity 260.1 epoch 2: validation perplexity 226.2 epoch 3: validation perplexity 223.9 epoch 4: validation perplexity 228.5 test perplexity, epoch 3: 208.5 three: two, four, five, eight, six he: she, He, Torres, McCall, Colby city: town, park, community, sightings, formations film: book, novel, album, What, bird excellent: actual, reduce, ultimate, ideological, immense terrible: my, ought, interesting, wrong, physical
With 3 words of context, the neural model reaches a test perplexity of 208.5, against 277.7 for the interpolated 5-gram model of section 2.3, which sees one more word. It even beats the Kneser-Ney 5-gram (219.3), the best of the counting models! And look at the embeddings: the neighbors of “three” are other numbers, “he” is closest to “she”, and “film” to “book”, “novel” and “album”. Nobody told the model that these words are alike: it found out because they appear in similar contexts. But the neighbors of “excellent” and “terrible” are noise. These words are rare in Wikipedia: “excellent” appears 28 times in the training text and “terrible” 24 times, against 1,253 times for “film”. A language model gives close embeddings to the words that play the same role, as long as it sees them often enough.
Optional The cost of the softmax: where the training time goes
Notice how long the training takes, compared with the counts of section 2. Almost all of this time goes into the output layer, which computes a score for each of the 33,277 words of the vocabulary, at every position of the text: 200 × 33,277 multiplications per word, against 300 × 200 for the hidden layer. This cost of the softmax over the whole vocabulary will come back in section 5.2.
4.3 Ways to aggregate the word vectors of a text
Concatenation works for language modeling, because we can assume that only the last n − 1 words matter. But as we said in section 3.1, it does not work at all for classification: the sentiment of a review can be anywhere in it. How can we aggregate the L embeddings of a review?
The simplest option is to average them. And there is a neat way to write this average. Adding up the embeddings of the words of a review is the same as multiplying by the bag-of-words vector of section 3.2, since the column of each word is added as many times as the word appears. So
where is the vector of the frequencies of the words in the review. The average of the embeddings is a bag of words, compressed by from V to D dimensions. Like the bag of words, it forgets the order of the words. And it gives the same importance to all the words, “the” included: to avoid diluting the average with uninformative words, we can weight it with the IDF of section 3.3.
What do we gain with this compression? Consider the simplest head, a logistic regression on the average: , where is the vector of the weights of the head. Expanding the dot product gives
where is the column of for the word . This is a logistic regression on the frequencies of the words, where the weight of the word is . So if is learned along with , nothing is gained over section 3! The gains can come from two places:
- add hidden layers after the average, so that the head is not linear anymore;
- start from an embedding matrix that is already good, and keep it fixed. Then the weight of a word depends on its embedding, so similar words get similar weights, even the words that never appear in the labeled reviews. This is the topic of section 5.
The first option is the deep averaging network (DAN) (Iyyer et al., 2015): average the embeddings, pass the average through a few hidden layers, then classify. In 2015, it rivaled the models that followed the syntax of the sentences, while training in a few minutes on a laptop. Let’s train one on IMDB.
Exercise 4.3 Train a deep averaging network on IMDB
Train a deep averaging network on IMDB, with the vocabulary of exercise 3.2: the average of the embeddings of the words of the review (D = 100), then two hidden layers of 100 units and one output, all trained together from scratch. Choose the number of epochs on the validation set, measure the test accuracy, and compare it with the bag of words of section 3. Then run the model on the hard reviews of exercise 3.4.
The starter code repeats steps (1) and (2) of the solution of exercise 3.2, which split the reviews and count their words, and lists the hard reviews of exercise 3.4.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# (2) Count the words of each review, over the 10,000 most
# frequent words of the training reviews. The pattern makes every
# run of letters or digits a word, even a single letter like "a".
# [Copied from the solution of 3.2, step (2)]
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_features=10000,
token_pattern=r"\b\w+\b")
X_train = vectorizer.fit_transform(train_texts)
X_val = vectorizer.transform(val_texts)
X_test = vectorizer.transform(test_texts)
# The hard reviews: (review, label), with 1 for positive
# [Copied from the starter of 3.4]
hard_reviews = [
# Negation
("Not bad at all, actually.", 1),
("Nothing about this movie is good.", 0),
("I wouldn't call it a masterpiece, but I didn't regret "
"a minute.", 1),
# Contrast
("Great acting, great lighting and a flabbergasting soundtrack, "
"but not even Zendaya could save this boring, boring plot.", 0),
("The first hour is slow and the dialogue is clumsy, but the "
"ending makes it all worth it.", 1),
("Everyone told me it would be the film of the year: great "
"cast, amazing trailer, brilliant director. What a "
"disappointment.", 0),
# Sarcasm
("Wow, what a masterpiece. Two hours of pure brilliance, and I "
"only checked my watch twenty times. The acting was so "
"convincing that I truly believed the actors wanted to be "
"somewhere else. A perfect film if you love naps.", 0),
("Brilliant. Another sequel nobody asked for, with the same "
"jokes as the first one.", 0),
# The same words in a different order
("I expected it to be terrible, but it was great.", 1),
("I expected it to be great, but it was terrible.", 0),
# Synonyms, no word in common
("Excellent movie, awesome soundtrack.", 1),
("Superb film, wonderful score.", 1),
# Rare words
("A flabbergasting, mesmerizing tour de force.", 1),
]
print(f"{len(hard_reviews)} hard reviews")
train: 20000 reviews validation: 5000 reviews test: 25000 reviews 13 hard reviews
Hint
By the formula above, the average of the embeddings is times the frequency vector of the review. So the first layer of the network can be nn.Linear(V, D, bias=False), applied to the count vectors of exercise 3.2 divided by their sum: its weight, of size D × V, is . Turn the sparse count vectors into tensors a batch at a time, with torch.tensor(X[batch].toarray()): all of them at once would take several gigabytes.
Hint
After the average, use nn.Linear and nn.ReLU layers, and end with a single output, the score of the review. nn.BCEWithLogitsLoss() applies the sigmoid to the score and computes the cross-entropy loss for two classes.
Hint
Train with Adam (learning rate 0.001) on batches of 64 reviews: an epoch takes about a second. The model overfits after a couple of epochs: keep a copy of the model (copy.deepcopy) at the epoch with the best validation accuracy.
Checkpoint what you should get
The best validation accuracy, 89.4%, is at epoch 2, and the test accuracy is 88.1%. The model gets 6 of the 13 hard reviews right, with probabilities very close to 0 or 1. The two reviews with the same words get exactly the same probability, 0.043.
epoch 1: validation accuracy 0.890 epoch 2: validation accuracy 0.894 epoch 3: validation accuracy 0.883 epoch 4: validation accuracy 0.882 epoch 5: validation accuracy 0.879 test accuracy, epoch 2: 0.881 label p(positive) right review positive 0.000 NO Not bad at all, actually. negative 0.003 yes Nothing about this movie is good. positive 0.015 NO I wouldn't call it a masterpiece, b… negative 0.000 yes Great acting, great lighting and a … positive 0.063 NO The first hour is slow and the dial… negative 0.994 NO Everyone told me it would be the fi… negative 0.990 NO Wow, what a masterpiece. Two hours … negative 0.870 NO Brilliant. Another sequel nobody as… positive 0.043 NO I expected it to be terrible, but i… negative 0.043 yes I expected it to be great, but it w… positive 1.000 yes Excellent movie, awesome soundtrack. positive 1.000 yes Superb film, wonderful score. positive 0.999 yes A flabbergasting, mesmerizing tour … 6 right out of 13
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# (2) Count the words of each review, over the 10,000 most
# frequent words of the training reviews. The pattern makes every
# run of letters or digits a word, even a single letter like "a".
# [Copied from the solution of 3.2, step (2)]
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_features=10000,
token_pattern=r"\b\w+\b")
X_train = vectorizer.fit_transform(train_texts)
X_val = vectorizer.transform(val_texts)
X_test = vectorizer.transform(test_texts)
# The hard reviews: (review, label), with 1 for positive
# [Copied from the starter of 3.4]
hard_reviews = [
# Negation
("Not bad at all, actually.", 1),
("Nothing about this movie is good.", 0),
("I wouldn't call it a masterpiece, but I didn't regret "
"a minute.", 1),
# Contrast
("Great acting, great lighting and a flabbergasting soundtrack, "
"but not even Zendaya could save this boring, boring plot.", 0),
("The first hour is slow and the dialogue is clumsy, but the "
"ending makes it all worth it.", 1),
("Everyone told me it would be the film of the year: great "
"cast, amazing trailer, brilliant director. What a "
"disappointment.", 0),
# Sarcasm
("Wow, what a masterpiece. Two hours of pure brilliance, and I "
"only checked my watch twenty times. The acting was so "
"convincing that I truly believed the actors wanted to be "
"somewhere else. A perfect film if you love naps.", 0),
("Brilliant. Another sequel nobody asked for, with the same "
"jokes as the first one.", 0),
# The same words in a different order
("I expected it to be terrible, but it was great.", 1),
("I expected it to be great, but it was terrible.", 0),
# Synonyms, no word in common
("Excellent movie, awesome soundtrack.", 1),
("Superb film, wonderful score.", 1),
# Rare words
("A flabbergasting, mesmerizing tour de force.", 1),
]
print(f"{len(hard_reviews)} hard reviews")
train: 20000 reviews validation: 5000 reviews test: 25000 reviews 13 hard reviews
# (1) Turn count vectors into frequency vectors: the counts
# divided by the number of words of the review. X.toarray() makes
# a numpy array from rows of a sparse matrix: do it a few rows at a
# time, since all the rows at once would take gigabytes.
import torch
def frequencies(X):
counts = torch.tensor(X.toarray(), dtype=torch.float32)
lengths = counts.sum(dim=1, keepdim=True)
return counts / lengths
# (2) Build the network. Its first layer, without bias, multiplies
# the frequency vector by the matrix W of size D x V: this gives
# the average of the embeddings of the words of the review. Then
# two hidden layers, and one output score.
from torch import nn
V = X_train.shape[1] # size of the vocabulary
D = 100 # size of the embeddings
H = 100 # size of the hidden layers
torch.manual_seed(0)
model = nn.Sequential(
nn.Linear(V, D, bias=False), # average of the embeddings
nn.Linear(D, H),
nn.ReLU(),
nn.Linear(H, H),
nn.ReLU(),
nn.Linear(H, 1), # p(positive) = sigmoid(score)
)
# (3) Train with Adam, on batches of 64 reviews, with the
# cross-entropy loss for two classes. After each epoch, measure
# the validation accuracy, and keep a copy of the best model.
import copy
def predict(model, X):
# p(positive) for each review of X, 1,000 reviews at a time
probabilities = []
with torch.no_grad():
for start in range(0, X.shape[0], 1000):
scores = model(frequencies(X[start:start + 1000]))
probabilities.append(torch.sigmoid(scores[:, 0]))
return torch.cat(probabilities)
def accuracy(model, X, labels):
right = (predict(model, X) > 0.5) == torch.tensor(labels)
return right.float().mean().item()
y_train = torch.tensor(train_labels, dtype=torch.float32)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_function = nn.BCEWithLogitsLoss() # sigmoid + cross-entropy
batch_size = 64
best_accuracy = 0
for epoch in range(5):
order = torch.randperm(len(y_train)).tolist() # shuffled
for start in range(0, len(order), batch_size):
batch = order[start:start + batch_size]
scores = model(frequencies(X_train[batch]))[:, 0]
loss = loss_function(scores, y_train[batch])
optimizer.zero_grad()
loss.backward()
optimizer.step()
val_accuracy = accuracy(model, X_val, val_labels)
print(f"epoch {epoch + 1}: "
f"validation accuracy {val_accuracy:.3f}")
if val_accuracy > best_accuracy:
best_accuracy, best_epoch = val_accuracy, epoch + 1
best_model = copy.deepcopy(model)
# (4) Measure the test accuracy of the best model
test_accuracy = accuracy(best_model, X_test, test_labels)
print(f"\ntest accuracy, epoch {best_epoch}: {test_accuracy:.3f}")
# (5) Run the best model on the hard reviews, as in exercise 3.4
hard_texts = [review for review, label in hard_reviews]
p_positive = predict(best_model, vectorizer.transform(hard_texts))
names = ["negative", "positive"]
print("\nlabel p(positive) right review")
right_count = 0
for (review, label), p in zip(hard_reviews, p_positive.tolist()):
right = (p > 0.5) == (label == 1)
if right:
right_count += 1
mark = "yes" if right else "NO"
short = review if len(review) <= 36 else review[:35] + "…"
print(f"{names[label]:9} {p:.3f} {mark:5} {short}")
print(f"\n{right_count} right out of {len(hard_reviews)}")
epoch 1: validation accuracy 0.890 epoch 2: validation accuracy 0.894 epoch 3: validation accuracy 0.883 epoch 4: validation accuracy 0.882 epoch 5: validation accuracy 0.879 test accuracy, epoch 2: 0.881 label p(positive) right review positive 0.000 NO Not bad at all, actually. negative 0.003 yes Nothing about this movie is good. positive 0.015 NO I wouldn't call it a masterpiece, b… negative 0.000 yes Great acting, great lighting and a … positive 0.063 NO The first hour is slow and the dial… negative 0.994 NO Everyone told me it would be the fi… negative 0.990 NO Wow, what a masterpiece. Two hours … negative 0.870 NO Brilliant. Another sequel nobody as… positive 0.043 NO I expected it to be terrible, but i… negative 0.043 yes I expected it to be great, but it w… positive 1.000 yes Excellent movie, awesome soundtrack. positive 1.000 yes Superb film, wonderful score. positive 0.999 yes A flabbergasting, mesmerizing tour … 6 right out of 13
88.1%: the deep averaging network does no better than the bag of words (87.6% with the counts, 88.4% with TF-IDF). Iyyer et al. found the same on IMDB with random embeddings at the start: 88.8% for the DAN, and 88.9% without the hidden layers. With 20,000 labeled reviews, the logistic regression of section 3 already had enough data to learn a weight for each word, and the hidden layers have little to add. On the hard reviews, the model makes the same kinds of mistakes as the bag of words, since it also ignores the order of the words. But it is much more sure of itself: “Not bad at all, actually.” gets a probability of 0.000 instead of 0.335. In this review of 5 words, “bad” makes up a fifth of the average, while a word of a typical IMDB review, with its 178 words, weighs about 0.6%. Averaging embeddings learned from scratch is a bag of words in disguise.
4.4 Embeddings are embeddings
We have now learned two embedding matrices, for two tasks. Yet the words are the same in both: “superb” means the same in a Wikipedia article and in a movie review, so we could expect its embedding to be similar whatever the task. Let’s compare the two matrices.
Exercise 4.4 Compare the embeddings learned on the two tasks
Print the 5 nearest neighbors of the six words of exercise 4.2 (“three”, “he”, “city”, “film”, “excellent” and “terrible”) in the embedding matrix of the deep averaging network of exercise 4.3, according to the cosine similarity. Compare them with their neighbors in the embeddings of the language model of exercise 4.2.
The starter code trains the network of exercise 4.3 again, with steps (1) and (2) of the solution of exercise 3.2 and steps (1) to (3) of the solution of exercise 4.3: the network of the best epoch is best_model. It does not train the language model of exercise 4.2: take its neighbors from your results of exercise 4.2, or from the output of its solution.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# (2) Count the words of each review, over the 10,000 most
# frequent words of the training reviews. The pattern makes every
# run of letters or digits a word, even a single letter like "a".
# [Copied from the solution of 3.2, step (2)]
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_features=10000,
token_pattern=r"\b\w+\b")
X_train = vectorizer.fit_transform(train_texts)
X_val = vectorizer.transform(val_texts)
X_test = vectorizer.transform(test_texts)
# (1) Turn count vectors into frequency vectors: the counts
# divided by the number of words of the review. X.toarray() makes
# a numpy array from rows of a sparse matrix: do it a few rows at a
# time, since all the rows at once would take gigabytes.
# [Copied from the solution of 4.3, step (1)]
import torch
def frequencies(X):
counts = torch.tensor(X.toarray(), dtype=torch.float32)
lengths = counts.sum(dim=1, keepdim=True)
return counts / lengths
# (2) Build the network. Its first layer, without bias, multiplies
# the frequency vector by the matrix W of size D x V: this gives
# the average of the embeddings of the words of the review. Then
# two hidden layers, and one output score.
# [Copied from the solution of 4.3, step (2)]
from torch import nn
V = X_train.shape[1] # size of the vocabulary
D = 100 # size of the embeddings
H = 100 # size of the hidden layers
torch.manual_seed(0)
model = nn.Sequential(
nn.Linear(V, D, bias=False), # average of the embeddings
nn.Linear(D, H),
nn.ReLU(),
nn.Linear(H, H),
nn.ReLU(),
nn.Linear(H, 1), # p(positive) = sigmoid(score)
)
# (3) Train with Adam, on batches of 64 reviews, with the
# cross-entropy loss for two classes. After each epoch, measure
# the validation accuracy, and keep a copy of the best model.
# [Copied from the solution of 4.3, step (3)]
import copy
def predict(model, X):
# p(positive) for each review of X, 1,000 reviews at a time
probabilities = []
with torch.no_grad():
for start in range(0, X.shape[0], 1000):
scores = model(frequencies(X[start:start + 1000]))
probabilities.append(torch.sigmoid(scores[:, 0]))
return torch.cat(probabilities)
def accuracy(model, X, labels):
right = (predict(model, X) > 0.5) == torch.tensor(labels)
return right.float().mean().item()
y_train = torch.tensor(train_labels, dtype=torch.float32)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_function = nn.BCEWithLogitsLoss() # sigmoid + cross-entropy
batch_size = 64
best_accuracy = 0
for epoch in range(5):
order = torch.randperm(len(y_train)).tolist() # shuffled
for start in range(0, len(order), batch_size):
batch = order[start:start + batch_size]
scores = model(frequencies(X_train[batch]))[:, 0]
loss = loss_function(scores, y_train[batch])
optimizer.zero_grad()
loss.backward()
optimizer.step()
val_accuracy = accuracy(model, X_val, val_labels)
print(f"epoch {epoch + 1}: "
f"validation accuracy {val_accuracy:.3f}")
if val_accuracy > best_accuracy:
best_accuracy, best_epoch = val_accuracy, epoch + 1
best_model = copy.deepcopy(model)
train: 20000 reviews validation: 5000 reviews test: 25000 reviews epoch 1: validation accuracy 0.890 epoch 2: validation accuracy 0.894 epoch 3: validation accuracy 0.883 epoch 4: validation accuracy 0.882 epoch 5: validation accuracy 0.879
Hint
The first layer of the network of exercise 4.3 stores as it is, with one column per word: best_model[0].weight has size D × V. vectorizer.vocabulary_ gives the index of a word, and vectorizer.get_feature_names_out() the word at each index.
Hint
Divide each column of by its length: the cosine similarities between a word and all the others are then a single product of a vector by a matrix, as in exercise 4.2.
Checkpoint what you should get
The neighbors of “excellent” are words of praise (“wonderfully”, “perfectly”, “refreshing”, and the grade “7”), and those of “terrible” words of blame (“poorly”, “worst”, “awful”). The neighbors of “three”, “he”, “city” and “film” look random.
three: rising, resources, relies, avoid, appears he: two, imaginative, life, out, many city: passing, romantic, filled, epic, carry film: own, reward, northam, 10, hudson excellent: 7, wonderfully, perfectly, refreshing, rare terrible: poorly, worst, awful, annoying, waste
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# (2) Count the words of each review, over the 10,000 most
# frequent words of the training reviews. The pattern makes every
# run of letters or digits a word, even a single letter like "a".
# [Copied from the solution of 3.2, step (2)]
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_features=10000,
token_pattern=r"\b\w+\b")
X_train = vectorizer.fit_transform(train_texts)
X_val = vectorizer.transform(val_texts)
X_test = vectorizer.transform(test_texts)
# (1) Turn count vectors into frequency vectors: the counts
# divided by the number of words of the review. X.toarray() makes
# a numpy array from rows of a sparse matrix: do it a few rows at a
# time, since all the rows at once would take gigabytes.
# [Copied from the solution of 4.3, step (1)]
import torch
def frequencies(X):
counts = torch.tensor(X.toarray(), dtype=torch.float32)
lengths = counts.sum(dim=1, keepdim=True)
return counts / lengths
# (2) Build the network. Its first layer, without bias, multiplies
# the frequency vector by the matrix W of size D x V: this gives
# the average of the embeddings of the words of the review. Then
# two hidden layers, and one output score.
# [Copied from the solution of 4.3, step (2)]
from torch import nn
V = X_train.shape[1] # size of the vocabulary
D = 100 # size of the embeddings
H = 100 # size of the hidden layers
torch.manual_seed(0)
model = nn.Sequential(
nn.Linear(V, D, bias=False), # average of the embeddings
nn.Linear(D, H),
nn.ReLU(),
nn.Linear(H, H),
nn.ReLU(),
nn.Linear(H, 1), # p(positive) = sigmoid(score)
)
# (3) Train with Adam, on batches of 64 reviews, with the
# cross-entropy loss for two classes. After each epoch, measure
# the validation accuracy, and keep a copy of the best model.
# [Copied from the solution of 4.3, step (3)]
import copy
def predict(model, X):
# p(positive) for each review of X, 1,000 reviews at a time
probabilities = []
with torch.no_grad():
for start in range(0, X.shape[0], 1000):
scores = model(frequencies(X[start:start + 1000]))
probabilities.append(torch.sigmoid(scores[:, 0]))
return torch.cat(probabilities)
def accuracy(model, X, labels):
right = (predict(model, X) > 0.5) == torch.tensor(labels)
return right.float().mean().item()
y_train = torch.tensor(train_labels, dtype=torch.float32)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_function = nn.BCEWithLogitsLoss() # sigmoid + cross-entropy
batch_size = 64
best_accuracy = 0
for epoch in range(5):
order = torch.randperm(len(y_train)).tolist() # shuffled
for start in range(0, len(order), batch_size):
batch = order[start:start + batch_size]
scores = model(frequencies(X_train[batch]))[:, 0]
loss = loss_function(scores, y_train[batch])
optimizer.zero_grad()
loss.backward()
optimizer.step()
val_accuracy = accuracy(model, X_val, val_labels)
print(f"epoch {epoch + 1}: "
f"validation accuracy {val_accuracy:.3f}")
if val_accuracy > best_accuracy:
best_accuracy, best_epoch = val_accuracy, epoch + 1
best_model = copy.deepcopy(model)
train: 20000 reviews validation: 5000 reviews test: 25000 reviews epoch 1: validation accuracy 0.890 epoch 2: validation accuracy 0.894 epoch 3: validation accuracy 0.883 epoch 4: validation accuracy 0.882 epoch 5: validation accuracy 0.879
# (1) The nearest neighbors of the words of exercise 4.2. The
# first layer of the network stores W as it is: column i is the
# embedding of word i. Divide each column by its length, so that a
# dot product is a cosine similarity.
words = vectorizer.get_feature_names_out() # in the order of W
W = best_model[0].weight.detach() # size D x V
W = W / W.norm(dim=0, keepdim=True)
for word in ["three", "he", "city", "film", "excellent", "terrible"]:
i = vectorizer.vocabulary_[word] # index of the word
similarities = W[:, i] @ W # one similarity per word
ranking = similarities.argsort(descending=True).tolist()
nearest = [words[j] for j in ranking[1:6]] # [0]: the word
print(f"{word}: {', '.join(nearest)}")
three: rising, resources, relies, avoid, appears he: two, imaginative, life, out, many city: passing, romantic, filled, epic, carry film: own, reward, northam, 10, hudson excellent: 7, wonderfully, perfectly, refreshing, rare terrible: poorly, worst, awful, annoying, waste
The two matrices are the mirror image of each other. The averaging network only learned what the sentiment needs: whether a word is positive or negative. It knows that “excellent” is like “wonderfully” and “terrible” like “awful”, which the language model did not learn. But “three”, “he”, “city” and “film” say nothing about the sentiment, so the training had no consistent reason to move their embeddings, and their neighbors look random. The language model did the opposite: it organized the frequent words of Wikipedia, and left the rare words of praise and blame in the noise. An embedding learned for a task only captures what this task needs, from the data of this task.
This is the limit of learning the embeddings along with the task. A labeled dataset is small, and it only teaches what the task needs: 20,000 reviews are enough to learn which words are positive, but not that “film” is like “book”. Yet the meanings of the words do not depend on the task. Learning embeddings that reflect the structure of the language again for every task, from a few labeled examples, looks like a waste.
What if we could learn the embeddings once and for all, on a task where data is abundant, and then reuse them for many tasks? Exercise 4.2 gives a hint of where to find such a task: predicting a word from its context only needs raw text, which is free, and it taught our language model that “three” is like “four” and “film” like “book”. This is the idea of pre-training and transfer learning, and the topic of the next section.
If you want to dig deeper, below are a few bonus exercises.
The size of the context. Train the neural language model of exercise 4.2 with 1, 2 and 5 words of context, and plot its validation perplexity against n, next to the curve of the interpolated n-gram models of exercise 2.3b. Does the neural model make better use of long contexts?
Unseen contexts. Measure the perplexity of the neural language model and of the interpolated 4-gram model only on the words of the validation set whose 3 previous words never appear together in the training text. Which model suffers more?
Mixing the two models. As Bengio et al. did, average the probabilities given by the neural language model and by the interpolated 5-gram model, with a weight of 0.5 each. How much does the perplexity improve? Why do the two models help each other?
Generation. Generate a text with the neural language model, as you did with the n-gram models in exercise 2.3a. Does it copy the training text, like the 5-gram model?
No hidden layers. Remove the hidden layers of the deep averaging network of exercise 4.3. By section 4.3, the model is then a logistic regression on the frequencies of the words. Check that it does as well as the bag of words of section 3.
A weighted average. Weight the average of exercise 4.3 by the IDF of the words (section 3.3). Does the accuracy change? And the predictions on the hard reviews?
Negation in a bag of words. For six adjectives, three positive and three negative, compare the probabilities that the bag of words of exercise 3.2 gives to “The movie was X.” and to “The movie was not X.”. Check that “not” moves the score of every sentence by the same amount. Does the deep averaging network of exercise 4.3 do better?
5. Pre-training embeddings: word2vec and GloVe
At the end of section 4, we saw that an embedding learned for a task only captures what this task needs, from the data of this task. And exercise 4.2 gave us a hint of a solution: trained only to predict the next word of Wikipedia articles, Bengio’s language model had found on its own that “three” is like “four” and “film” like “book”.
5.1 Splitting the training
The idea is simple: split the training in two. First, learn the embedding matrix on a task for which data is free and abundant: this is pre-training. Then copy into the model of the task we care about, called the downstream task, and train this model on its own labeled data. Raw text is cheap: there are billions of words of Wikipedia and news articles online, and nobody needs to annotate them. Labels are expensive: someone has to read each review and say whether it is positive.
So let’s forget about our downstream classification task for a moment, and focus on the embeddings. The problem is that to learn embeddings, we need something to optimize! We need a task, and an objective that can be computed from raw text only.
The answer comes from linguistics, and it is older than computers: words that appear in similar contexts tend to have similar meanings. This is the distributional hypothesis. In 1954, Zellig Harris wrote that “difference of meaning correlates with difference of distribution” (Harris, 1954), and John Firth summarized it in 1957 with a famous sentence: “You shall know a word by the company it keeps!” (Firth, 1957).
So a model trained to predict a word from its context, or the context from the word, has every reason to give similar vectors to words with similar meanings. This is what happened in exercise 4.2. But Bengio’s model was slow: almost all of its time went into the softmax over the whole vocabulary (see “The cost of the softmax” in section 4.2). In 2013, word2vec kept the idea, and removed everything that was expensive.
5.2 Word2vec
Word2vec is the name of two models proposed by Tomas Mikolov and his colleagues at Google (Mikolov et al., 2013). Both look at a window of words around a middle word:
- CBOW (for continuous bag of words) predicts the middle word from the average of the embeddings of the words around it;
- skip-gram does the opposite: it predicts each word around from the embedding of the middle word.
Neither model has a hidden layer. CBOW is Bengio’s model without its hidden layer, with context on both sides of the word, and an average instead of a concatenation. Skip-gram reverses the direction of the prediction. This is what makes them fast: from the embeddings to the scores of the words, there is a single product of a matrix by a vector.
Let’s write skip-gram. For this, two matrices and have to be learned. is the embedding matrix (dimension , as in section 4.1) that associates an embedding vector , its column, to each word . is the output projector matrix (dimension ) in the linear layer, which can also be seen as providing a vector , its row, for each candidate output word .
The probability that the word appears around the middle word is then a softmax of dot products:
and the model is trained, as usual, by minimizing the cross-entropy over all the pairs (middle word, word around it) of the training text.
But the denominator is still a sum over the whole vocabulary: the cost of the softmax has not gone away. The second paper of word2vec (Mikolov, Sutskever, et al., 2013) removes it with a trick called negative sampling. Instead of asking the model “which of the V words appears around ?”, it asks a yes-or-no question: “is a real pair, taken from the text, or a fake one?”. For each real pair, we draw random words , which make fake pairs , and we train a logistic regression (section 3.2) to tell the real pair from the fake ones, with the dot product as its score:
Since , the first term is the cross-entropy loss of section 3.2 for a pair labeled 1 (real), and each of the others for a pair labeled 0 (fake). With , each pair costs 6 dot products, instead of one for each of the 33,277 words of WikiText-2. The fake words are drawn from the counts of the words raised to the power 3/4, which gives the rare words a little more chance than their frequency alone.
One last trick: subsampling. Frequent words like “the” appear in the window of almost every word, and tell us little about it. So before building the pairs, each occurrence of a word of frequency is dropped with probability , where is a threshold, around in the paper. With , about 4% of the occurrences of “the” in WikiText-2 are kept, and all the occurrences of the words of frequency below . This is the intuition of the IDF of section 3.3 again: a word that is everywhere carries little information.
Optional The hierarchical softmax: the other way around the softmax
The first paper of word2vec avoided the softmax in another way. The words of the vocabulary are the leaves of a binary tree, built so that frequent words are close to the root (a Huffman tree). To give a probability to a word, the model follows the path from the root to its leaf, and at each node, a logistic regression gives the probability of going left or right. The probability of the word is the product of the probabilities of these choices. A path has about nodes: 15 logistic regressions for 33,277 words, instead of 33,277 scores. Negative sampling, simpler, replaced it in the second paper.
Let’s train skip-gram with negative sampling on WikiText-2, the text on which we trained Bengio’s model in exercise 4.2, and compare the two.
Exercise 5.2 Train word2vec on WikiText-2
Train a skip-gram model with negative sampling on the training text of WikiText-2. Subsample the frequent words with , then pair each remaining word with the 2 words before it and the 2 words after it. For each real pair, draw 5 fake context words from the counts of the words raised to the power 3/4. Train embeddings of size D = 100 for 3 epochs, then print the 5 nearest neighbors of the six words of exercise 4.2 (“three”, “he”, “city”, “film”, “excellent” and “terrible”). Compare the neighbors and the training time with those of Bengio’s model in exercise 4.2.
The starter code builds the training sequence, with step (1) of the solution of exercise 2.1, and gives each word of the vocabulary an index, as in the starter of exercise 4.2.
from datasets import load_dataset
wikitext = load_dataset("Salesforce/wikitext", "wikitext-2-v1")
# (1) Build the train sequence by splitting every line on spaces
# [Copied from the solution of 2.1, step (1)]
train_sequence = []
for line in wikitext["train"]["text"]:
train_sequence.extend(line.split())
print(f"text: {len(train_sequence)} words")
print(f"vocabulary: {len(set(train_sequence))} words")
# Give each word of the vocabulary an index
# [Copied from the starter of 4.2]
vocabulary = sorted(set(train_sequence))
word_index = {word: i for i, word in enumerate(vocabulary)}
text: 2051910 words vocabulary: 33277 words
Hint
Count the words with a dictionary, as in section 2. Then turn the subsampled sequence of word indices into a tensor sequence: sequence[:-1] and sequence[1:] pair each word with the next one, and sequence[:-2] and sequence[2:] with the word two positions later. Use each of these pairs in both directions.
Hint
Use two nn.Embedding(V, D): one for the middle words, whose rows are the embeddings that we keep, and one for the context words, the output vectors . Start both from small values, with nn.init.normal_(..., std=0.1). For a batch of pairs, the scores are (center * context).sum(dim=1), where center and context have size (batch, D).
Hint
torch.multinomial(noise, n, replacement=True) draws n word indices, with probabilities proportional to noise, here the counts raised to the power 3/4. For each batch, put the score of each real pair and the scores of its 5 fake pairs side by side, in a tensor of size (batch, 6), with the labels 1, 0, 0, 0, 0, 0. nn.BCEWithLogitsLoss() then computes the loss of negative sampling.
Hint
Train with Adam (learning rate 0.003) on batches of 4,096 pairs. For the neighbors, reuse the code of exercise 4.2 on the matrix of the middle words.
Checkpoint what you should get
Subsampling keeps 997,693 of the 2,051,910 words, which gives 3,990,766 pairs. The training takes about 15 seconds on a laptop CPU. The neighbors of “three” are other numbers, and those of “film” include “cinema” and “movie”. The neighbors of “excellent” and “terrible” look random. Your neighbors can differ, especially those of the rare words.
after subsampling: 997693 words pairs: 3990766 epoch 1: loss 0.411, 5 s since the start epoch 2: loss 0.363, 10 s since the start epoch 3: loss 0.343, 15 s since the start three: five, two, thirty, four, seven he: him, she, Howson, bullpen, hurting city: capital, Varanasi, Stepen, road, Ramallah film: cinema, Coriolanus, movie, Magadheera, Telugu excellent: knock, evoke, misery, hint, fun terrible: happy, endearingly, Sometimes, hearted, bearer
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
wikitext = load_dataset("Salesforce/wikitext", "wikitext-2-v1")
# (1) Build the train sequence by splitting every line on spaces
# [Copied from the solution of 2.1, step (1)]
train_sequence = []
for line in wikitext["train"]["text"]:
train_sequence.extend(line.split())
print(f"text: {len(train_sequence)} words")
print(f"vocabulary: {len(set(train_sequence))} words")
# Give each word of the vocabulary an index
# [Copied from the starter of 4.2]
vocabulary = sorted(set(train_sequence))
word_index = {word: i for i, word in enumerate(vocabulary)}
text: 2051910 words vocabulary: 33277 words
# (1) Count the words, then subsample the frequent ones: keep each
# occurrence of a word of frequency f with probability sqrt(t / f),
# so that "the" loses most of its occurrences, and a rare word none
import math
import random
counts = {}
for word in train_sequence:
if word not in counts:
counts[word] = 0
counts[word] += 1
t = 1e-4
random.seed(0)
kept = [] # the indices of the words that are kept, in order
for word in train_sequence:
frequency = counts[word] / len(train_sequence)
if random.random() < math.sqrt(t / frequency):
kept.append(word_index[word])
print(f"after subsampling: {len(kept)} words")
# (2) Build the (center, context) pairs: each word of the subsampled
# sequence with each of the 2 words before it and after it.
# sequence[:-offset] and sequence[offset:] pair each word with the
# word that comes offset positions later.
import torch
window = 2
sequence = torch.tensor(kept)
centers, contexts = [], []
for offset in range(1, window + 1):
centers += [sequence[:-offset], sequence[offset:]]
contexts += [sequence[offset:], sequence[:-offset]]
centers = torch.cat(centers)
contexts = torch.cat(contexts)
print(f"pairs: {len(centers)}")
# (3) The model: two embedding matrices, one for the words at the
# center (the embeddings we keep), one for the words of the context.
# The score of a pair is the dot product of their two vectors.
from torch import nn
V = len(vocabulary)
D = 100 # size of the embeddings
torch.manual_seed(0)
center_embeddings = nn.Embedding(V, D)
context_embeddings = nn.Embedding(V, D)
nn.init.normal_(center_embeddings.weight, std=0.1)
nn.init.normal_(context_embeddings.weight, std=0.1)
# (4) Train with Adam on batches of 4,096 real pairs. For each real
# pair, draw k = 5 fake context words from the counts raised to the
# power 3/4, with torch.multinomial. The loss is the cross-entropy
# loss for two classes: 1 for the real pairs, 0 for the fake ones.
import time
noise = torch.tensor([counts[word] for word in vocabulary],
dtype=torch.float32) ** 0.75
k = 5
parameters = (list(center_embeddings.parameters())
+ list(context_embeddings.parameters()))
optimizer = torch.optim.Adam(parameters, lr=3e-3)
loss_function = nn.BCEWithLogitsLoss() # sigmoid + cross-entropy
batch_size = 4096
start_time = time.time()
for epoch in range(3):
order = torch.randperm(len(centers)) # shuffled pairs
for start in range(0, len(order), batch_size):
batch = order[start:start + batch_size]
B = len(batch)
center = center_embeddings(centers[batch]) # (B, D)
context = context_embeddings(contexts[batch]) # (B, D)
fake_words = torch.multinomial(noise, B * k, replacement=True)
fake = context_embeddings(fake_words).view(B, k, D)
real_scores = (center * context).sum(dim=1) # (B,)
# center[:, None] has size (B, 1, D): it multiplies each of
# the k fake contexts of its pair
fake_scores = (center[:, None] * fake).sum(dim=2) # (B, k)
scores = torch.cat([real_scores[:, None], fake_scores], dim=1)
labels = torch.zeros(B, 1 + k)
labels[:, 0] = 1 # the real pair is in column 0
loss = loss_function(scores, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
elapsed = time.time() - start_time
print(f"epoch {epoch + 1}: loss {loss.item():.3f}, "
f"{elapsed:.0f} s since the start")
# (5) The nearest neighbors of the words of exercise 4.2, in the
# matrix of the center words. Divide each row by its length, so
# that a dot product is a cosine similarity.
embeddings = center_embeddings.weight.detach()
embeddings = embeddings / embeddings.norm(dim=1, keepdim=True)
print()
for word in ["three", "he", "city", "film", "excellent", "terrible"]:
similarities = embeddings @ embeddings[word_index[word]]
ranking = similarities.argsort(descending=True).tolist()
nearest = [vocabulary[i] for i in ranking[1:6]] # [0]: the word
print(f"{word}: {', '.join(nearest)}")
after subsampling: 997693 words pairs: 3990766 epoch 1: loss 0.411, 5 s since the start epoch 2: loss 0.363, 10 s since the start epoch 3: loss 0.343, 15 s since the start three: five, two, thirty, four, seven he: him, she, Howson, bullpen, hurting city: capital, Varanasi, Stepen, road, Ramallah film: cinema, Coriolanus, movie, Magadheera, Telugu excellent: knock, evoke, misery, hint, fun terrible: happy, endearingly, Sometimes, hearted, bearer
Word2vec trains in 15 seconds, where Bengio’s model needed almost 6 minutes on the same text and the same computer, with fewer pairs per epoch: per pair, word2vec is about 30 times faster. The neighbors are of the same kind: numbers with numbers, “he” with “him” and “she”. But they are not quite the same: “film” is now closest to “cinema” and “movie”, words of the same topic, while Bengio’s model put it next to “book”, “novel” and “album”, words that play the same role in a sentence. As for the rare words, they are noise again: “excellent” appears only 28 times in the training text. 2 million words are not enough, and this is where the speed of word2vec matters. Word2vec is cheap enough to learn from billions of words.
5.3 GloVe
Word2vec learns by predicting, one window at a time. A year later, Jeffrey Pennington, Richard Socher and Christopher Manning went back to counting (Pennington et al., 2014). Their model, GloVe (for global vectors), first counts, over the whole training text, how many times each word appears in the window of each word . This gives a co-occurrence matrix of size V × V, like the counts of pairs of section 2.1, but with a window on both sides of the word. Then GloVe looks for vectors whose dot products give the logarithm of these counts:
where and are two vectors of the words, as in skip-gram, and and are biases. The vectors are fitted by least squares, weighted by a function of the count : the rare pairs, whose counts are noisy, weigh less, and the weight stops growing beyond 100 occurrences, so that the very frequent pairs do not dominate. Like the Latent Semantic Analysis of section 4.1, GloVe compresses a big table of counts into dense vectors of a few hundred dimensions.
So which is better, predicting or counting? In 2014, Omer Levy and Yoav Goldberg showed that the two are much closer than they look (Levy & Goldberg, 2014). Consider skip-gram with negative sampling, with fake pairs for each real pair. If the embeddings are large enough, its loss is minimal when the dot product of the vectors of a word and of a context word is
where is the probability that appears in the window of , and and are the frequencies of the two words. The logarithm is the pointwise mutual information (PMI) of the two words: it compares how often they appear together with how often they would if they were independent. It is high for “ice” and “cold”, and close to zero for “ice” and “the”. So word2vec, without ever building the table, factorizes a table of co-occurrence statistics, just like GloVe and LSA. Predicting the context and counting the contexts end up computing nearly the same thing.
Optional Where the PMI comes from: the optimum of negative sampling in three lines
Take a word and a context word , and write . In the loss of negative sampling, summed over the whole text, the pair appears as a real pair times, the number of times is in the window of . It also appears as a fake pair: each of the real pairs of draws fake words, and is drawn with probability , so about times. The part of the loss that depends on is
Its derivative with respect to is , which is zero when . With the frequencies and , where is the total number of real pairs, and with fake words drawn from the plain frequencies, , this gives the formula above. With the power 3/4 of word2vec, is replaced by its smoothed version. The argument assumes that each dot product can take any value, which requires large enough embeddings.
The Stanford team released GloVe vectors trained on several corpora. In the rest of this section, we use the vectors trained on 6 billion words of Wikipedia and news articles: 400,000 words in lower case, in 300 dimensions. This is 3,000 times more text than WikiText-2.
5.4 What embeddings capture
The first thing embeddings capture is similarity: the nearest neighbors of exercises 4.2 and 5.2. But they also hold a more surprising structure. Take the vector , and look for the word whose vector is the closest: it is “madrid”. In other words, the difference is almost the same as : there is a direction in the space of embeddings that means “the capital of”. In the same way, the word closest to is “queen”, once we exclude “king” itself (we will come back to this detail in exercise 5.4).
This gives a way to answer analogies, “a is to b as c is to ?”: look for the word whose vector is the closest to . Mikolov et al. turned it into a test of the quality of embeddings (Mikolov, Chen, et al., 2013): 19,544 analogies in 14 categories, about meaning (capitals, currencies, family) and about grammar (plurals, tenses, comparatives). Let’s try it on GloVe. As in some experiments of the first paper of word2vec, we only keep the 30,000 most frequent words, both for the questions and for the answers.
Exercise 5.4 Solve analogies with GloVe
Answer the analogies “man is to king as woman is to ?”, “france is to paris as spain is to ?”, “big is to bigger as small is to ?”, “walk is to walked as swim is to ?” and “man is to doctor as woman is to ?”. For each one, look for the word whose GloVe vector is the closest to , first excluding the three words of the question from the answers, then allowing them. Then measure the accuracy on the analogy test set of Mikolov et al. in both ways, separately for the questions about meaning and about grammar.
# The GloVe vectors of Pennington et al., trained on 6 billion
# words of Wikipedia and news (Gigaword), in the copy of
# sentence-transformers on the Hub: 400,000 words in lower case,
# sorted from the most frequent to the least frequent, with 300
# dimensions. Row 0 of the file is a padding token: drop it.
import json
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
repo = "sentence-transformers/average_word_embeddings_glove.6B.300d"
weights_path = hf_hub_download(
repo, "0_WordEmbeddings/model.safetensors")
words_path = hf_hub_download(
repo, "0_WordEmbeddings/whitespacetokenizer_config.json")
glove_vectors = load_file(weights_path)["emb_layer.weight"][1:]
glove_words = json.load(open(words_path))["vocab"][1:]
# Keep the 30,000 most frequent words
words = glove_words[:30000]
vectors = glove_vectors[:30000]
word_index = {word: i for i, word in enumerate(words)}
# The analogy test set of Mikolov et al.: each row reads "Word1 is to
# Word2 as Word3 is to Word4", and Subject is its category
from datasets import load_dataset
analogies = load_dataset("almogtavor/google-analogy-dataset")["train"]
print(f"GloVe: {len(words)} words, vectors of size {vectors.shape[1]}")
print(f"test set: {len(analogies)} analogies")
print(f"example: {analogies[0]}")
GloVe: 30000 words, vectors of size 300
test set: 19544 analogies
example: {'Word1': 'Athens', 'Word2': 'Greece', 'Word3': 'Baghdad', 'Word4': 'Iraq', 'Subject': 'capital-common-countries'}Hint
Divide each vector by its length first, so that the dot products give the cosine similarities. The target does not need to be divided by its length: dividing it by a positive number does not change the ranking of the words.
Hint
vectors @ target gives the dot products of the target with the 30,000 vectors at once. To exclude a word, set its score to -math.inf before taking the argmax.
Hint
The words of the test set are capitalized (“Athens”, “Greece”), while GloVe only has lower-case words: put them in lower case. Skip the questions with a word outside the 30,000. The categories about grammar are those whose Subject starts with “gram”.
Checkpoint what you should get
With the question words excluded, the answers are “queen”, “madrid”, “larger” (a mistake), “swam” and “physician”, and the accuracy is 79.2% on meaning and 70.6% on grammar. With the question words allowed, three of the five answers are a word of the question, starting with “king”, and the accuracy falls to 24.8% on meaning and 20.7% on grammar. 14,877 of the 19,544 questions have their four words among the 30,000 most frequent words.
question excluded allowed man is to king as woman is to queen king france is to paris as spain is to madrid madrid big is to bigger as small is to larger larger walk is to walked as swim is to swam swim man is to doctor as woman is to physician doctor question words excluded: meaning: 4754 right out of 6002 (79.2%) grammar: 6262 right out of 8875 (70.6%) question words allowed: meaning: 1491 right out of 6002 (24.8%) grammar: 1834 right out of 8875 (20.7%)
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
# The GloVe vectors of Pennington et al., trained on 6 billion
# words of Wikipedia and news (Gigaword), in the copy of
# sentence-transformers on the Hub: 400,000 words in lower case,
# sorted from the most frequent to the least frequent, with 300
# dimensions. Row 0 of the file is a padding token: drop it.
import json
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
repo = "sentence-transformers/average_word_embeddings_glove.6B.300d"
weights_path = hf_hub_download(
repo, "0_WordEmbeddings/model.safetensors")
words_path = hf_hub_download(
repo, "0_WordEmbeddings/whitespacetokenizer_config.json")
glove_vectors = load_file(weights_path)["emb_layer.weight"][1:]
glove_words = json.load(open(words_path))["vocab"][1:]
# Keep the 30,000 most frequent words
words = glove_words[:30000]
vectors = glove_vectors[:30000]
word_index = {word: i for i, word in enumerate(words)}
# The analogy test set of Mikolov et al.: each row reads "Word1 is to
# Word2 as Word3 is to Word4", and Subject is its category
from datasets import load_dataset
analogies = load_dataset("almogtavor/google-analogy-dataset")["train"]
print(f"GloVe: {len(words)} words, vectors of size {vectors.shape[1]}")
print(f"test set: {len(analogies)} analogies")
print(f"example: {analogies[0]}")
GloVe: 30000 words, vectors of size 300
test set: 19544 analogies
example: {'Word1': 'Athens', 'Word2': 'Greece', 'Word3': 'Baghdad', 'Word4': 'Iraq', 'Subject': 'capital-common-countries'}# (1) The answer to "a is to b as c is to ?": the word whose vector
# is closest to b - a + c. Divide each vector by its length, so that
# the dot products with the target rank the words by cosine
# similarity. With exclude=True, the three words of the question are
# not allowed as answers.
import math
vectors = vectors / vectors.norm(dim=1, keepdim=True)
def answer(a, b, c, exclude):
target = (vectors[word_index[b]] - vectors[word_index[a]]
+ vectors[word_index[c]])
similarities = vectors @ target # one score per word
if exclude:
for word in [a, b, c]:
similarities[word_index[word]] = -math.inf
return words[similarities.argmax()]
questions = [("man", "king", "woman"), ("france", "paris", "spain"),
("big", "bigger", "small"), ("walk", "walked", "swim"),
("man", "doctor", "woman")]
print(f"{'question':34} excluded allowed")
for a, b, c in questions:
question = f"{a} is to {b} as {c} is to"
print(f"{question:34} {answer(a, b, c, True):10} "
f"{answer(a, b, c, False)}")
# (2) The accuracy on the test set, for the questions whose four
# words are among the 30,000 words. The categories whose name starts
# with "gram" are about grammar (plurals, tenses...), the others
# about meaning (capitals, currencies, family...).
for exclude in [True, False]:
right = {"meaning": 0, "grammar": 0}
total = {"meaning": 0, "grammar": 0}
for row in analogies:
a, b, c, d = [row[f"Word{i}"].lower() for i in range(1, 5)]
if not all(word in word_index for word in [a, b, c, d]):
continue
kind = "grammar" if row["Subject"].startswith("gram") \
else "meaning"
total[kind] += 1
if answer(a, b, c, exclude) == d:
right[kind] += 1
print(f"\nquestion words {'excluded' if exclude else 'allowed'}:")
for kind in ["meaning", "grammar"]:
print(f" {kind}: {right[kind]} right out of {total[kind]} "
f"({right[kind] / total[kind]:.1%})")
question excluded allowed man is to king as woman is to queen king france is to paris as spain is to madrid madrid big is to bigger as small is to larger larger walk is to walked as swim is to swam swim man is to doctor as woman is to physician doctor question words excluded: meaning: 4754 right out of 6002 (79.2%) grammar: 6262 right out of 8875 (70.6%) question words allowed: meaning: 1491 right out of 6002 (24.8%) grammar: 1834 right out of 8875 (20.7%)
GloVe answers about three analogies out of four, without having been trained for it: it was only fitted to the counts of pairs of words. The questions about meaning are mostly about geography, since capitals and American states make up 85% of them in the test set. Now look at the second column. When the words of the question are allowed, the best answer is most often one of them: “man is to king as woman is to king”. The vector is still closer to than to any other vector, because the difference between “man” and “woman” is small compared with everything that makes “king” different from other words. On the whole test set, the accuracy falls from 74% to 22%. “Queen” is not the word closest to king − man + woman: it is the closest one once “king” is excluded.
This is the first caveat of analogies: an analogy search must exclude the words of the question, otherwise it returns one of them most of the time. The rule is in the first paper of word2vec, and in most evaluations since. But it also forces an answer that is not a word of the question, even when the best answer is one of them. In the exercise, “man is to doctor as woman is to ?” gives “physician” with the rule, and “doctor” without it. Keep this in mind when you read that embeddings answer “man is to computer programmer as woman is to homemaker” (Bolukbasi et al., 2016).
The second caveat: the meaning is in the directions, not in the coordinates. It is tempting to look for the coordinate of the embeddings that encodes gender, or the one that encodes royalty. But the losses of word2vec and GloVe only depend on dot products of vectors, and dot products do not change if all the vectors are rotated in the same way. A rotated embedding is just as good as the original, and each of its coordinates is completely different. So a single coordinate has no meaning in itself: only directions do, like . Hinton’s network of section 4.1 was different: each of its units went through its own non-linearity, so a rotation would change the outputs of the network. There, the axes are special, and a unit can have a meaning of its own.
5.5 Transfer learning
Let’s go back to our movie reviews. We now have embeddings learned on 6 billion words, and the deep averaging network of section 4.3 starts with an embedding matrix: we can copy the GloVe vectors into it. This is transfer learning: what was learned on one task, here fitting the counts of pairs of words, is reused for another one, classifying reviews. There are two ways to do it:
- freeze the embeddings: keep fixed, and only train the hidden layers and the head on the labeled reviews;
- fine-tune the embeddings: start from the GloVe vectors, but let the training on the reviews change them, like the other parameters.
Frozen embeddings keep everything they learned from the 6 billion words, including what they know about words that appear in few labeled reviews, or in none. As we saw in section 4.3, the weight of a word in the head then depends on its embedding, so similar words get similar weights. Fine-tuned embeddings can also adapt to the task, but the words that appear in the labeled reviews move, while the others stay where they were. Let’s compare the two with the training from scratch of section 4.3.
Exercise 5.5a Start the averaging network from GloVe
Build the embedding matrix of the deep averaging network of exercise 4.3 from the GloVe vectors of the 10,000 words of the vocabulary (D = 300). Train the network three times: with random (from scratch), with copied from GloVe and frozen, and with copied from GloVe and fine-tuned. Compare their test accuracies. Then print the 5 nearest neighbors of “good” and “bad” in GloVe, and in after fine-tuning.
The starter code repeats steps (1) and (2) of the solution of exercise 3.2, which split the reviews and count their words, and loads all the GloVe vectors, as in the starter of exercise 5.4. The network is not in the starter: start from your code of exercise 4.3.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# (2) Count the words of each review, over the 10,000 most
# frequent words of the training reviews. The pattern makes every
# run of letters or digits a word, even a single letter like "a".
# [Copied from the solution of 3.2, step (2)]
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_features=10000,
token_pattern=r"\b\w+\b")
X_train = vectorizer.fit_transform(train_texts)
X_val = vectorizer.transform(val_texts)
X_test = vectorizer.transform(test_texts)
# The GloVe vectors of Pennington et al., trained on 6 billion
# words of Wikipedia and news (Gigaword), in the copy of
# sentence-transformers on the Hub: 400,000 words in lower case,
# sorted from the most frequent to the least frequent, with 300
# dimensions. Row 0 of the file is a padding token: drop it.
# [Copied from the starter of 5.4]
import json
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
repo = "sentence-transformers/average_word_embeddings_glove.6B.300d"
weights_path = hf_hub_download(
repo, "0_WordEmbeddings/model.safetensors")
words_path = hf_hub_download(
repo, "0_WordEmbeddings/whitespacetokenizer_config.json")
glove_vectors = load_file(weights_path)["emb_layer.weight"][1:]
glove_words = json.load(open(words_path))["vocab"][1:]
train: 20000 reviews validation: 5000 reviews test: 25000 reviews
Hint
vectorizer.get_feature_names_out() gives the words of the vocabulary, in the order of the count vectors. Build as a tensor of size 300 × 10,000, with the GloVe vector of word j in column j, and copy it into the first layer of the network with model[0].weight.data = W_glove.clone(). A few words, like “hadn”, have no GloVe vector: leave their column at zero.
Hint
To freeze , call model[0].weight.requires_grad_(False): PyTorch then does not compute its gradient. Give the optimizer only the parameters that are trained: [p for p in model.parameters() if p.requires_grad].
Hint
Exercise 5.5b trains the same network on subsets of the reviews, down to 100 reviews. To prepare for it, write a function train(model, X, labels) that draws each batch of 64 reviews at random from X (with torch.randint), for 1,500 batches, and keeps the model with the best validation accuracy, measured every 100 batches.
Checkpoint what you should get
The test accuracy is 87.6% from scratch, 83.3% with GloVe frozen, and 88.1% with GloVe fine-tuned. In GloVe, the nearest neighbor of “bad” is “good”. After fine-tuning, the neighbors of “bad” are “worse”, “unfortunately”, “awful”, “nothing” and “worst”.
12 words without a GloVe vector: ['feinstone', 'gypo', 'hackenstein', 'hadn', 'imho'] from scratch test accuracy 0.876 frozen test accuracy 0.833 fine-tuned test accuracy 0.881 good, GloVe: better, really, always, you, well good, fine-tuned: really, always, you, better, think bad, GloVe: good, things, worse, thing, because bad, fine-tuned: worse, unfortunately, awful, nothing, worst
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# (2) Count the words of each review, over the 10,000 most
# frequent words of the training reviews. The pattern makes every
# run of letters or digits a word, even a single letter like "a".
# [Copied from the solution of 3.2, step (2)]
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_features=10000,
token_pattern=r"\b\w+\b")
X_train = vectorizer.fit_transform(train_texts)
X_val = vectorizer.transform(val_texts)
X_test = vectorizer.transform(test_texts)
# The GloVe vectors of Pennington et al., trained on 6 billion
# words of Wikipedia and news (Gigaword), in the copy of
# sentence-transformers on the Hub: 400,000 words in lower case,
# sorted from the most frequent to the least frequent, with 300
# dimensions. Row 0 of the file is a padding token: drop it.
# [Copied from the starter of 5.4]
import json
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
repo = "sentence-transformers/average_word_embeddings_glove.6B.300d"
weights_path = hf_hub_download(
repo, "0_WordEmbeddings/model.safetensors")
words_path = hf_hub_download(
repo, "0_WordEmbeddings/whitespacetokenizer_config.json")
glove_vectors = load_file(weights_path)["emb_layer.weight"][1:]
glove_words = json.load(open(words_path))["vocab"][1:]
train: 20000 reviews validation: 5000 reviews test: 25000 reviews
# (1) The pre-trained embedding matrix: column j is the GloVe vector
# of word j of the vocabulary, in the order of the count vectors.
# The few words that GloVe does not know keep a column of zeros.
import torch
glove_index = {word: i for i, word in enumerate(glove_words)}
vocabulary = vectorizer.get_feature_names_out()
D = glove_vectors.shape[1] # 300
W_glove = torch.zeros(D, len(vocabulary))
missing = []
for j, word in enumerate(vocabulary):
if word in glove_index:
W_glove[:, j] = glove_vectors[glove_index[word]]
else:
missing.append(word)
print(f"{len(missing)} words without a GloVe vector: {missing[:5]}")
# (2) The deep averaging network of exercise 4.3, with D = 300. Its
# first layer is W: random (from scratch), or the GloVe vectors,
# kept fixed (frozen) or trained with the rest (fine-tuned).
from torch import nn
def frequencies(X):
counts = torch.tensor(X.toarray(), dtype=torch.float32)
lengths = counts.sum(dim=1, keepdim=True)
return counts / lengths
def make_model(embeddings):
torch.manual_seed(0)
model = nn.Sequential(
nn.Linear(len(vocabulary), D, bias=False), # average
nn.Linear(D, 100),
nn.ReLU(),
nn.Linear(100, 100),
nn.ReLU(),
nn.Linear(100, 1), # p(positive) = sigmoid(score)
)
if embeddings != "from scratch":
model[0].weight.data = W_glove.clone()
if embeddings == "frozen":
model[0].weight.requires_grad_(False) # no gradient
return model
# (3) A function that trains a model for 1,500 batches of 64
# reviews, drawn at random from the training reviews. Every 100
# batches, it measures the validation accuracy, and keeps a copy of
# the best model.
import copy
def predict(model, X):
# p(positive) for each review of X, 1,000 reviews at a time
probabilities = []
with torch.no_grad():
for start in range(0, X.shape[0], 1000):
scores = model(frequencies(X[start:start + 1000]))
probabilities.append(torch.sigmoid(scores[:, 0]))
return torch.cat(probabilities)
def accuracy(model, X, labels):
right = (predict(model, X) > 0.5) == torch.tensor(labels)
return right.float().mean().item()
def train(model, X, labels, n_batches=1500):
y = torch.tensor(labels, dtype=torch.float32)
trained = [p for p in model.parameters() if p.requires_grad]
optimizer = torch.optim.Adam(trained, lr=1e-3)
loss_function = nn.BCEWithLogitsLoss()
best_accuracy = 0
for step in range(1, n_batches + 1):
batch = torch.randint(len(y), (64,)).tolist()
scores = model(frequencies(X[batch]))[:, 0]
loss = loss_function(scores, y[batch])
optimizer.zero_grad()
loss.backward()
optimizer.step()
if step % 100 == 0:
val_accuracy = accuracy(model, X_val, val_labels)
if val_accuracy > best_accuracy:
best_accuracy = val_accuracy
best_model = copy.deepcopy(model)
return best_model
# (4) Train the three versions, and measure their test accuracy
models = {}
for embeddings in ["from scratch", "frozen", "fine-tuned"]:
models[embeddings] = train(make_model(embeddings),
X_train, train_labels)
test_accuracy = accuracy(models[embeddings], X_test, test_labels)
print(f"{embeddings:12} test accuracy {test_accuracy:.3f}")
# (5) The nearest neighbors of "good" and "bad" among the words of
# the vocabulary, in GloVe and in W after fine-tuning
def neighbors(W, word, n=5):
W = W / W.norm(dim=0, keepdim=True).clamp(min=1e-8)
similarities = W[:, vectorizer.vocabulary_[word]] @ W
ranking = similarities.argsort(descending=True).tolist()
return [vocabulary[j] for j in ranking[1:n + 1]] # [0]: word
W_tuned = models["fine-tuned"][0].weight.detach()
print()
for word in ["good", "bad"]:
print(f"{word}, GloVe: {', '.join(neighbors(W_glove, word))}")
print(f"{word}, fine-tuned: {', '.join(neighbors(W_tuned, word))}")
12 words without a GloVe vector: ['feinstone', 'gypo', 'hackenstein', 'hadn', 'imho'] from scratch test accuracy 0.876 frozen test accuracy 0.833 fine-tuned test accuracy 0.881 good, GloVe: better, really, always, you, well good, fine-tuned: really, always, you, better, think bad, GloVe: good, things, worse, thing, because bad, fine-tuned: worse, unfortunately, awful, nothing, worst
With 20,000 labeled reviews, pre-training barely helps: fine-tuned GloVe gains half a point over the training from scratch, and frozen GloVe loses more than 4 points. Iyyer et al. found the same on IMDB, as we will see below. Why are the frozen embeddings so poor? Look at the neighbors of “bad” in GloVe: the first one is “good”. The two words appear in the same contexts (“the film was ___”, “a ___ idea”), so for a model of the counts of pairs of words, they are almost the same word. The difference of sentiment is in the vectors, but it is a small part of them. Fine-tuning makes it a big part: after training on the reviews, “bad” is close to “worse”, “awful” and “worst”, as Kim also observed with word2vec vectors (Kim, 2014). Pre-trained embeddings know the language, not the task.
In their paper, Iyyer et al. ran the same comparison (Iyyer et al., 2015). Their variants NBOW-RAND and DAN-RAND start from random embeddings of 300 dimensions instead of GloVe. The effect depends a lot on the dataset. On short sentences, pre-training helps clearly: on binary SST, the Stanford Sentiment Treebank (Socher et al., 2013), made of short sentences from movie reviews, the DAN gets 86.3% with GloVe and 83.2% with random embeddings; on the sentences of Rotten Tomatoes, 80.3% and 77.3%. On IMDB, the gain is tiny: 89.4% and 88.8%. With long reviews and 25,000 labels, there is enough data to learn the embeddings of the useful words from scratch.
This suggests that pre-training should matter most when labels are few. Let’s measure it.
Exercise 5.5b★ Train with fewer labels
Train the three versions of exercise 5.5a (from scratch, GloVe frozen, GloVe fine-tuned) on 100, 300, 1,000, 3,000, 10,000 and 20,000 training reviews drawn at random, and plot their test accuracy against the number of labeled reviews.
The starter code repeats steps (1) to (3) of the solution of exercise 5.5a, after the code of exercises 3.2 and 5.4 that they need. It gives the GloVe matrix W_glove, the function make_model, which builds one of the three versions, and the function train, which trains a model on the reviews that you give it. What is left is the loop over the numbers of reviews, and the chart.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# (2) Count the words of each review, over the 10,000 most
# frequent words of the training reviews. The pattern makes every
# run of letters or digits a word, even a single letter like "a".
# [Copied from the solution of 3.2, step (2)]
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_features=10000,
token_pattern=r"\b\w+\b")
X_train = vectorizer.fit_transform(train_texts)
X_val = vectorizer.transform(val_texts)
X_test = vectorizer.transform(test_texts)
# The GloVe vectors of Pennington et al., trained on 6 billion
# words of Wikipedia and news (Gigaword), in the copy of
# sentence-transformers on the Hub: 400,000 words in lower case,
# sorted from the most frequent to the least frequent, with 300
# dimensions. Row 0 of the file is a padding token: drop it.
# [Copied from the starter of 5.4]
import json
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
repo = "sentence-transformers/average_word_embeddings_glove.6B.300d"
weights_path = hf_hub_download(
repo, "0_WordEmbeddings/model.safetensors")
words_path = hf_hub_download(
repo, "0_WordEmbeddings/whitespacetokenizer_config.json")
glove_vectors = load_file(weights_path)["emb_layer.weight"][1:]
glove_words = json.load(open(words_path))["vocab"][1:]
# (1) The pre-trained embedding matrix: column j is the GloVe vector
# of word j of the vocabulary, in the order of the count vectors.
# The few words that GloVe does not know keep a column of zeros.
# [Copied from the solution of 5.5a, step (1)]
import torch
glove_index = {word: i for i, word in enumerate(glove_words)}
vocabulary = vectorizer.get_feature_names_out()
D = glove_vectors.shape[1] # 300
W_glove = torch.zeros(D, len(vocabulary))
missing = []
for j, word in enumerate(vocabulary):
if word in glove_index:
W_glove[:, j] = glove_vectors[glove_index[word]]
else:
missing.append(word)
print(f"{len(missing)} words without a GloVe vector: {missing[:5]}")
# (2) The deep averaging network of exercise 4.3, with D = 300. Its
# first layer is W: random (from scratch), or the GloVe vectors,
# kept fixed (frozen) or trained with the rest (fine-tuned).
# [Copied from the solution of 5.5a, step (2)]
from torch import nn
def frequencies(X):
counts = torch.tensor(X.toarray(), dtype=torch.float32)
lengths = counts.sum(dim=1, keepdim=True)
return counts / lengths
def make_model(embeddings):
torch.manual_seed(0)
model = nn.Sequential(
nn.Linear(len(vocabulary), D, bias=False), # average
nn.Linear(D, 100),
nn.ReLU(),
nn.Linear(100, 100),
nn.ReLU(),
nn.Linear(100, 1), # p(positive) = sigmoid(score)
)
if embeddings != "from scratch":
model[0].weight.data = W_glove.clone()
if embeddings == "frozen":
model[0].weight.requires_grad_(False) # no gradient
return model
# (3) A function that trains a model for 1,500 batches of 64
# reviews, drawn at random from the training reviews. Every 100
# batches, it measures the validation accuracy, and keeps a copy of
# the best model.
# [Copied from the solution of 5.5a, step (3)]
import copy
def predict(model, X):
# p(positive) for each review of X, 1,000 reviews at a time
probabilities = []
with torch.no_grad():
for start in range(0, X.shape[0], 1000):
scores = model(frequencies(X[start:start + 1000]))
probabilities.append(torch.sigmoid(scores[:, 0]))
return torch.cat(probabilities)
def accuracy(model, X, labels):
right = (predict(model, X) > 0.5) == torch.tensor(labels)
return right.float().mean().item()
def train(model, X, labels, n_batches=1500):
y = torch.tensor(labels, dtype=torch.float32)
trained = [p for p in model.parameters() if p.requires_grad]
optimizer = torch.optim.Adam(trained, lr=1e-3)
loss_function = nn.BCEWithLogitsLoss()
best_accuracy = 0
for step in range(1, n_batches + 1):
batch = torch.randint(len(y), (64,)).tolist()
scores = model(frequencies(X[batch]))[:, 0]
loss = loss_function(scores, y[batch])
optimizer.zero_grad()
loss.backward()
optimizer.step()
if step % 100 == 0:
val_accuracy = accuracy(model, X_val, val_labels)
if val_accuracy > best_accuracy:
best_accuracy = val_accuracy
best_model = copy.deepcopy(model)
return best_model
train: 20000 reviews validation: 5000 reviews test: 25000 reviews 12 words without a GloVe vector: ['feinstone', 'gypo', 'hackenstein', 'hadn', 'imho']
Hint
random.sample(range(len(train_labels)), n) draws n distinct indices, and X_train[chosen] selects the rows of the sparse matrix of the counts.
Hint
With 100 reviews, the accuracy changes a lot from one draw to the next: average it over 3 draws for the sizes up to 1,000.
Hint
We keep the validation set of 5,000 reviews to choose the best model, whatever the number of training reviews. This is a little optimistic: someone who has only 100 labeled reviews does not have 5,000 more to validate on.
Checkpoint what you should get
With 100 labeled reviews, the test accuracy is 69.2% from scratch, 75.2% with GloVe frozen, and 73.4% with GloVe fine-tuned. Frozen GloVe is the best version with 100 reviews, and fine-tuned GloVe from 300 reviews on. With 20,000 reviews, the training from scratch and fine-tuned GloVe both reach 87.5%, and frozen GloVe stays at 83.3%. These last numbers differ a little from those of exercise 5.5a, because the batches are not drawn in the same order.
100 labels, from scratch test accuracy 0.692 100 labels, frozen test accuracy 0.752 100 labels, fine-tuned test accuracy 0.734 300 labels, from scratch test accuracy 0.779 300 labels, frozen test accuracy 0.787 300 labels, fine-tuned test accuracy 0.793 1000 labels, from scratch test accuracy 0.825 1000 labels, frozen test accuracy 0.810 1000 labels, fine-tuned test accuracy 0.832 3000 labels, from scratch test accuracy 0.850 3000 labels, frozen test accuracy 0.825 3000 labels, fine-tuned test accuracy 0.854 10000 labels, from scratch test accuracy 0.866 10000 labels, frozen test accuracy 0.829 10000 labels, fine-tuned test accuracy 0.874 20000 labels, from scratch test accuracy 0.875 20000 labels, frozen test accuracy 0.833 20000 labels, fine-tuned test accuracy 0.875

Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# (2) Count the words of each review, over the 10,000 most
# frequent words of the training reviews. The pattern makes every
# run of letters or digits a word, even a single letter like "a".
# [Copied from the solution of 3.2, step (2)]
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_features=10000,
token_pattern=r"\b\w+\b")
X_train = vectorizer.fit_transform(train_texts)
X_val = vectorizer.transform(val_texts)
X_test = vectorizer.transform(test_texts)
# The GloVe vectors of Pennington et al., trained on 6 billion
# words of Wikipedia and news (Gigaword), in the copy of
# sentence-transformers on the Hub: 400,000 words in lower case,
# sorted from the most frequent to the least frequent, with 300
# dimensions. Row 0 of the file is a padding token: drop it.
# [Copied from the starter of 5.4]
import json
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
repo = "sentence-transformers/average_word_embeddings_glove.6B.300d"
weights_path = hf_hub_download(
repo, "0_WordEmbeddings/model.safetensors")
words_path = hf_hub_download(
repo, "0_WordEmbeddings/whitespacetokenizer_config.json")
glove_vectors = load_file(weights_path)["emb_layer.weight"][1:]
glove_words = json.load(open(words_path))["vocab"][1:]
# (1) The pre-trained embedding matrix: column j is the GloVe vector
# of word j of the vocabulary, in the order of the count vectors.
# The few words that GloVe does not know keep a column of zeros.
# [Copied from the solution of 5.5a, step (1)]
import torch
glove_index = {word: i for i, word in enumerate(glove_words)}
vocabulary = vectorizer.get_feature_names_out()
D = glove_vectors.shape[1] # 300
W_glove = torch.zeros(D, len(vocabulary))
missing = []
for j, word in enumerate(vocabulary):
if word in glove_index:
W_glove[:, j] = glove_vectors[glove_index[word]]
else:
missing.append(word)
print(f"{len(missing)} words without a GloVe vector: {missing[:5]}")
# (2) The deep averaging network of exercise 4.3, with D = 300. Its
# first layer is W: random (from scratch), or the GloVe vectors,
# kept fixed (frozen) or trained with the rest (fine-tuned).
# [Copied from the solution of 5.5a, step (2)]
from torch import nn
def frequencies(X):
counts = torch.tensor(X.toarray(), dtype=torch.float32)
lengths = counts.sum(dim=1, keepdim=True)
return counts / lengths
def make_model(embeddings):
torch.manual_seed(0)
model = nn.Sequential(
nn.Linear(len(vocabulary), D, bias=False), # average
nn.Linear(D, 100),
nn.ReLU(),
nn.Linear(100, 100),
nn.ReLU(),
nn.Linear(100, 1), # p(positive) = sigmoid(score)
)
if embeddings != "from scratch":
model[0].weight.data = W_glove.clone()
if embeddings == "frozen":
model[0].weight.requires_grad_(False) # no gradient
return model
# (3) A function that trains a model for 1,500 batches of 64
# reviews, drawn at random from the training reviews. Every 100
# batches, it measures the validation accuracy, and keeps a copy of
# the best model.
# [Copied from the solution of 5.5a, step (3)]
import copy
def predict(model, X):
# p(positive) for each review of X, 1,000 reviews at a time
probabilities = []
with torch.no_grad():
for start in range(0, X.shape[0], 1000):
scores = model(frequencies(X[start:start + 1000]))
probabilities.append(torch.sigmoid(scores[:, 0]))
return torch.cat(probabilities)
def accuracy(model, X, labels):
right = (predict(model, X) > 0.5) == torch.tensor(labels)
return right.float().mean().item()
def train(model, X, labels, n_batches=1500):
y = torch.tensor(labels, dtype=torch.float32)
trained = [p for p in model.parameters() if p.requires_grad]
optimizer = torch.optim.Adam(trained, lr=1e-3)
loss_function = nn.BCEWithLogitsLoss()
best_accuracy = 0
for step in range(1, n_batches + 1):
batch = torch.randint(len(y), (64,)).tolist()
scores = model(frequencies(X[batch]))[:, 0]
loss = loss_function(scores, y[batch])
optimizer.zero_grad()
loss.backward()
optimizer.step()
if step % 100 == 0:
val_accuracy = accuracy(model, X_val, val_labels)
if val_accuracy > best_accuracy:
best_accuracy = val_accuracy
best_model = copy.deepcopy(model)
return best_model
train: 20000 reviews validation: 5000 reviews test: 25000 reviews 12 words without a GloVe vector: ['feinstone', 'gypo', 'hackenstein', 'hadn', 'imho']
# (1) For each number of labels, train the three versions on random
# subsets of the training reviews, drawn with random.sample: 3
# subsets for the small sizes, whose accuracy varies a lot from one
# subset to the next, and 1 for the others. Average the test
# accuracies over the subsets.
import random
sizes = [100, 300, 1000, 3000, 10000, 20000]
versions = ["from scratch", "frozen", "fine-tuned"]
results = {version: [] for version in versions}
for n in sizes:
n_subsets = 3 if n <= 1000 else 1
for version in versions:
total = 0
for seed in range(n_subsets):
random.seed(seed)
chosen = random.sample(range(len(train_labels)), n)
labels = [train_labels[i] for i in chosen]
model = train(make_model(version), X_train[chosen], labels)
total += accuracy(model, X_test, test_labels)
results[version].append(total / n_subsets)
print(f"{n:6} labels, {version:12} "
f"test accuracy {results[version][-1]:.3f}")
# (2) Plot the test accuracy against the number of labels
import matplotlib.pyplot as plt
plt.figure(figsize=(5, 3.5))
for version in versions:
plt.plot(sizes, results[version], "o-", label=version)
plt.xscale("log")
plt.xlabel("number of labeled reviews")
plt.ylabel("test accuracy")
plt.legend()
plt.show()
100 labels, from scratch test accuracy 0.692 100 labels, frozen test accuracy 0.752 100 labels, fine-tuned test accuracy 0.734 300 labels, from scratch test accuracy 0.779 300 labels, frozen test accuracy 0.787 300 labels, fine-tuned test accuracy 0.793 1000 labels, from scratch test accuracy 0.825 1000 labels, frozen test accuracy 0.810 1000 labels, fine-tuned test accuracy 0.832 3000 labels, from scratch test accuracy 0.850 3000 labels, frozen test accuracy 0.825 3000 labels, fine-tuned test accuracy 0.854 10000 labels, from scratch test accuracy 0.866 10000 labels, frozen test accuracy 0.829 10000 labels, fine-tuned test accuracy 0.874 20000 labels, from scratch test accuracy 0.875 20000 labels, frozen test accuracy 0.833 20000 labels, fine-tuned test accuracy 0.875

With 100 labeled reviews, the GloVe vectors are worth 6 points: 75.2% frozen, against 69.2% from scratch. Only 3,686 of the 10,000 words of the vocabulary appear in 100 random reviews. From scratch, the 6,314 other words keep their random embeddings, and the model knows nothing about them when it meets them in a test review. With frozen GloVe vectors, every word comes with what was learned from 6 billion words, and gets a weight close to the weights of similar words, even if it appears in no labeled review. As the number of labels grows, the gap closes: from 1,000 reviews on, the frozen embeddings are the worst version, and with 20,000 reviews, fine-tuning GloVe and training from scratch give the same accuracy. Pre-training matters most when labels are few.
This is a first answer to the second question of this post: what is learned on a task with abundant data can be transferred to another task. But only the first layer of the network is transferred. The hidden layers and the head are still learned from the labeled reviews alone. The average still forgets the order of the words: whatever the embeddings, the two hard reviews with the same words get exactly the same probability. And each word has a single vector, whatever the sentence it is in. The next section takes stock of these limits.
If you want to dig deeper, below are a few bonus exercises.
CBOW. Replace skip-gram with CBOW in exercise 5.2: average the embeddings of the 4 words around each word, and give a score to this average with the real middle word and with 5 fake ones. Do the neighbors change?
The size of the window. Train the skip-gram model of exercise 5.2 with 1 word and with 10 words on each side. How do the neighbors change?
More text. Train the skip-gram model of exercise 5.2 on text8 (afmck/text8), the first 100 MB of a cleaned English Wikipedia: 17 million words, 8 times WikiText-2. Compare the neighbors, and evaluate both models on the analogy test set of exercise 5.4.
Counting instead of predicting. Build the co-occurrence matrix of WikiText-2 with a window of 2 words, replace its entries by their PMI, set the negative ones to zero, and compress it to 100 dimensions with a truncated SVD (torch.svd_lowrank), as Levy and Goldberg did (Levy & Goldberg, 2014). Are the neighbors as good as those of exercise 5.2?
A rotation. Multiply all the GloVe vectors by a random orthogonal matrix (the Q of torch.linalg.qr of a random matrix). Check that the neighbors and the analogies of exercise 5.4 do not change, while all the coordinates do.
A direction for gender. Compute , and in GloVe. How similar are these three directions? Project a few words of professions on their average: which ones are on the side of “woman”? Read Nissim et al. first (Nissim et al., 2020).
Text of the same kind. Train the skip-gram model of exercise 5.2 on the 50,000 unlabeled reviews of IMDB (the unsupervised split), and use these embeddings in exercise 5.5a instead of GloVe. Does text of the same kind beat 3,000 times more text of another kind?
6. Interlude: where we stand
We have now trained models of four kinds on our two tasks: counts of n-grams, Bengio’s neural language model, bags of words and deep averaging networks. Before we turn to networks that read the words in order, let’s pause and see where we stand. The table below shows the best model of each kind, how it turns a text into a vector of fixed size, what it reuses from another task, and its score on the test set.
| Model | Section | How a text becomes a vector of fixed size | What is reused from another task | WikiText-2 perplexity | IMDB accuracy |
|---|---|---|---|---|---|
| 5-gram model, interpolated | 2 | keep the last 4 words | nothing | 277.7 | – |
| Bag of words, TF-IDF | 3 | count the words | nothing | – | 88.4% |
| Bengio’s language model | 4.2 | concatenate the embeddings of the last 3 words | nothing | 208.5 | – |
| Deep averaging network | 4.3 | average the embeddings of the words | nothing | – | 88.1% |
| Deep averaging network with GloVe | 5.5 | average the embeddings of the words | the embedding matrix, learned on 6 billion words | – | 88.1% |
In language modeling, each new model did better. From the unigram model (941.7) to Bengio’s model, the perplexity went down from about 1,000 to about 200, and Bengio’s model beats the best published smoothing of the counts, Kneser-Ney, at 219.3 (Herel & Mikolov, 2023). But all these models make the assumption of section 2.1: the next word depends only on the last few words. Our language models see at most four words of the past.
In classification, nothing moved. Dense embeddings, averages, and embeddings pre-trained on 6 billion words: none of them beat the bag of words of section 3. All our classifiers are between 87.6% and 88.4%, except the deep averaging network with frozen GloVe vectors, at 83.3%. The largest gain we met came from elsewhere: when Wang and Manning add the pairs of consecutive words to a bag of words, they gain 2 to 3 points, up to 91.2% for their best classifier (Wang & Manning, 2012). As we saw in section 3.3, a pair like “not good” brings back a little of the word order. On IMDB, the pairs of words gained more than the embeddings.
The hard reviews of section 3.4 tell the same story. All our classifiers get the synonyms and the rare words right, but none of them gets more than 4 of the 10 reviews of negation, contrast, sarcasm and word order. And each of them gives the same probability to the two reviews with the same words, so one of the two is always wrong. Whether they count the words or average their embeddings, our classifiers do not see the order of the words.
There is one more limit, which the hard reviews do not show. In “Superb film, wonderful score.”, the score is the music of the film. But in all our models, a word has a single weight or a single vector, whatever the sentence it appears in: the score of a film and the score of a football match get the same embedding. When a word has several meanings, which one does its vector hold? Let’s look at GloVe.
Exercise 6 Find the meanings of ambiguous words in GloVe
Print the 8 nearest neighbors of “bank”, “mouse”, “plot” and “score” among the 30,000 most frequent words of GloVe. Which meaning of each word do they show? Then find the rank of “river” among the neighbors of “bank”, and the rank of “soundtrack” among the neighbors of “score”.
The starter code loads the GloVe vectors of the 30,000 most frequent words, as in the starter of exercise 5.4.
# The GloVe vectors of Pennington et al., trained on 6 billion
# words of Wikipedia and news (Gigaword), in the copy of
# sentence-transformers on the Hub: 400,000 words in lower case,
# sorted from the most frequent to the least frequent, with 300
# dimensions. Row 0 of the file is a padding token: drop it.
# [Copied from the starter of 5.4]
import json
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
repo = "sentence-transformers/average_word_embeddings_glove.6B.300d"
weights_path = hf_hub_download(
repo, "0_WordEmbeddings/model.safetensors")
words_path = hf_hub_download(
repo, "0_WordEmbeddings/whitespacetokenizer_config.json")
glove_vectors = load_file(weights_path)["emb_layer.weight"][1:]
glove_words = json.load(open(words_path))["vocab"][1:]
# Keep the 30,000 most frequent words
# [Copied from the starter of 5.4]
words = glove_words[:30000]
vectors = glove_vectors[:30000]
word_index = {word: i for i, word in enumerate(words)}
print(f"GloVe: {len(words)} words, vectors of size {vectors.shape[1]}")
GloVe: 30000 words, vectors of size 300
Hint
Start as in exercise 5.4: divide each vector by its length, so that the product of the matrix of the vectors by the vector of a word gives the cosine similarities of this word with the 30,000 words at once. argsort(descending=True) sorts the words from the closest to the farthest, and the first one is the word itself.
Hint
The rank of a word among the neighbors of another one is one plus the number of words that are closer. (similarities > similarities[j]).sum() counts them, and the word itself is one of them.
Checkpoint what you should get
The neighbors of “bank” are all about finance: “banks”, “banking”, “central”, “credit”… Those of “mouse” mix the animal (“mice”, “rat”, “rabbit”) and the computer (“keyboard”). Those of “plot” are about conspiracies (“foiled”, “assassinate”), and those of “score” about games (“scoring”, “winning”, “goal”). “river” is only the 544th neighbor of “bank”, and “soundtrack” the 146th neighbor of “score”, out of 29,999 words.
bank: banks, banking, central, credit, bankers, financial, investment, lending mouse: mice, rat, keyboard, rabbit, rodent, monkey, rats, cat plot: plots, plotting, foiled, plotted, assassinate, conspiracy, conspirators, assassination score: scoring, scored, scores, winning, game, goal, win, play "river" is neighbor number 544 of "bank", out of 29999 "soundtrack" is neighbor number 146 of "score", out of 29999
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
# The GloVe vectors of Pennington et al., trained on 6 billion
# words of Wikipedia and news (Gigaword), in the copy of
# sentence-transformers on the Hub: 400,000 words in lower case,
# sorted from the most frequent to the least frequent, with 300
# dimensions. Row 0 of the file is a padding token: drop it.
# [Copied from the starter of 5.4]
import json
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
repo = "sentence-transformers/average_word_embeddings_glove.6B.300d"
weights_path = hf_hub_download(
repo, "0_WordEmbeddings/model.safetensors")
words_path = hf_hub_download(
repo, "0_WordEmbeddings/whitespacetokenizer_config.json")
glove_vectors = load_file(weights_path)["emb_layer.weight"][1:]
glove_words = json.load(open(words_path))["vocab"][1:]
# Keep the 30,000 most frequent words
# [Copied from the starter of 5.4]
words = glove_words[:30000]
vectors = glove_vectors[:30000]
word_index = {word: i for i, word in enumerate(words)}
print(f"GloVe: {len(words)} words, vectors of size {vectors.shape[1]}")
GloVe: 30000 words, vectors of size 300
# (1) The 8 nearest neighbors of each word, by cosine similarity.
# Divide each vector by its length, so that the dot products with
# the vector of a word are its cosine similarities with all the
# 30,000 words.
vectors = vectors / vectors.norm(dim=1, keepdim=True)
def similarities_to(word):
return vectors @ vectors[word_index[word]] # one per word
for word in ["bank", "mouse", "plot", "score"]:
ranking = similarities_to(word).argsort(descending=True).tolist()
neighbors = [words[i] for i in ranking[1:9]] # [0] is the word
print(f"{word}: {', '.join(neighbors)}")
# (2) The rank of a word among the neighbors of another one: one
# plus the number of words that are closer, the word itself excluded
print()
for word, other in [("bank", "river"), ("score", "soundtrack")]:
similarities = similarities_to(word)
closer = similarities > similarities[word_index[other]]
n_closer = closer.sum().item() - 1 # without the word itself
print(f'"{other}" is neighbor number {n_closer + 1} of "{word}", '
f"out of {len(words) - 1}")
bank: banks, banking, central, credit, bankers, financial, investment, lending mouse: mice, rat, keyboard, rabbit, rodent, monkey, rats, cat plot: plots, plotting, foiled, plotted, assassinate, conspiracy, conspirators, assassination score: scoring, scored, scores, winning, game, goal, win, play "river" is neighbor number 544 of "bank", out of 29999 "soundtrack" is neighbor number 146 of "score", out of 29999
A single vector must hold all the meanings of a word, and the meaning that is the most frequent in the training text wins. GloVe was trained on Wikipedia and news articles, where a bank holds money, a plot is a conspiracy, and a score is the result of a match. The meanings that matter in movie reviews, the story of a film and its music, are not the frequent ones. They are not lost: “river” is closer to “bank” than 98% of the words, and “soundtrack” is closer to “score” than 99.5% of them. But they are mixed into the same vector as the other meanings. When two meanings are both frequent, as for “mouse”, the neighbors come from both. A static embedding mixes all the meanings of a word, and the most frequent one dominates.
Many words have several meanings, and this is called polysemy. To tell the bank of a river from the bank that holds money, a model must look at the other words of the sentence. In section 8.2, we will meet the first embeddings that depend on the sentence of the word, and in section 10.2, we will compare the vectors of “bank” in sentences about rivers and about money.
Let’s go back to the two questions of the introduction.
How can we build a model that takes as input a piece of text of any length? So far we have two kinds of answers. Either we keep only the last few words, in order: this suits language modeling, but forgets everything before them. Or we keep all the words but forget their order: this suits classification, but fails on the hard reviews. None of our models reads the whole text in order, as we do. This is what the recurrent networks of the next section do.
How can we transfer what a model learned on one task to another? So far, only the embedding matrix has been transferred. It helps when labels are scarce, with 6 points of accuracy for 100 labeled reviews (exercise 5.5b), but not with all the labels. What the classifier does with the embeddings, which is where the hard reviews fail, is still learned from the labeled reviews alone, and each word keeps a single vector whatever its sentence. Pre-training the rest of the encoder is the topic of section 8.
If you want to dig deeper, below are two bonus exercises.
The meanings of the movie reviews. Train the skip-gram model of exercise 5.2 on the 50,000 unlabeled reviews of IMDB (the unsupervised split), and compare the neighbors of “plot”, “score” and “cast” with those of GloVe.
Fool the models. Write a short review whose sentiment is obvious to a human reader, without negation, contrast or sarcasm, that the bag of words and the deep averaging networks get wrong. What did you use to fool them?
7. Recurrent networks: reading in order
At the end of section 6, we saw that none of our models reads a text in order. The n-gram models and Bengio’s model read the last few words in order, and forget everything before them. The bags of words and the averaging networks read all the words, but forget their order. Consider again these two hard reviews:
A: I expected it to be terrible, but it was great.
B: I expected it to be great, but it was terrible.
They have exactly the same words, so every classifier of the post so far gives them exactly the same probability, and gets one of them wrong. To tell them apart, the encoder must aggregate the embeddings of the words in a way that depends on their order. Before transformers, there were two main ways to do this.
The first one is the convolution. Take the network of Bengio’s model (section 4.2), which reads the concatenated embeddings of a window of a few consecutive words. Instead of applying it only to the last words of the text, slide it along the text, and apply it to every window. Each window gives a small vector of features, and one of these features could for instance react to “was terrible”, wherever it appears in the review. Then keep, for each feature, its largest value over all the windows of the text. This is called max pooling, and it gives a vector of fixed size, whatever the length of the text. Collobert et al. used it to tag the words of a sentence (Collobert et al., 2011), and Kim to classify sentences.
The second one is the recurrent network. It reads the words one after the other, as we do, and updates a vector, its state, after each word. The state after the last word is the representation of the text.
A convolution sees the order of the words inside each window, but not beyond. With windows of 3 words, “was terrible” and “was great” give different features, which is enough for our two reviews. But a window cannot relate two words that are farther apart than its size, unless several layers of convolutions are stacked. A recurrent network has no window: in principle, its last state depends on all the words of the text, and on their order. In the years before transformers, recurrent networks were the standard encoder of NLP, and they are the topic of this section.
7.1 The RNN
A recurrent neural network (RNN) reads the embeddings of the words of a text one at a time. It keeps a hidden state , a vector of size H, and updates it after each word:
starting from . The matrices , of size H × H, and , of size H × D, and the bias are the same at every step. This is why an RNN can read texts of any length with a fixed number of parameters, like the convolution, whose window has the same weights at every position.
The state depends on all the words , and on their order: reading “terrible” then “great” does not give the same state as reading “great” then “terrible”, since each word is added to a state that has already been transformed by and the tanh. So the state is a summary of everything read so far, of fixed size H, updated at each word. For classification, the last state is the fixed-size representation of the text, and a head reads it, for instance a logistic regression as in section 3.2:
The RNN also gives a vector for each word, the state after this word, which is what tagging needs (section 1.4): a classifier applied to each gives a label to each word. But only knows the words up to . For tagging, a second RNN often reads the text backwards, and the two states of each word are concatenated: this is a bidirectional RNN. Keep this sequence of states in mind: in section 9.2, attention will read all of them, instead of the last one only.
7.2 The training problem
An RNN is trained like the other networks of the post, by minimizing a loss with gradient descent. To compute the gradient, unroll the network: write its L steps one after the other, as in the figure at the start of this section. The unrolled RNN is a deep feedforward network with one layer per word, where all the layers share the same weights, and the gradient is computed by backpropagation through these L layers. This is called backpropagation through time (Werbos, 1990).
This is where the problem lies. For the loss to teach the network something about the first words of a review, the gradient must travel back from the last state to the first ones, through all the steps in between. By the chain rule, the gradient of the last state with respect to the state after word is a product of matrices, one per step:
where the diagonal matrix holds the derivative of the tanh, , for each component of the state. Like the powers of a number, such a product tends to shrink exponentially with the distance if its matrices shrink the vectors they multiply, and to grow exponentially if they stretch them. These are the vanishing and the exploding gradients. The derivative of the tanh is at most 1, and close to 0 when a unit is saturated, so the gradient tends to vanish. Then the first words of a long review receive almost no gradient, and the network cannot learn to use them, even though they are in its input.
Bengio, Simard and Frasconi showed that this is not bad luck (Bengio et al., 1994). A network that stores a piece of information robustly, so that the noise of the following inputs does not erase it, is precisely a network whose gradients with respect to the past vanish exponentially. In their words: “These results expose a trade-off between efficient learning by gradient descent and latching on information for long periods.”
When the gradient explodes, a single update can throw the weights far away, and the loss suddenly jumps. The fix is simple, and it is called gradient clipping (Pascanu et al., 2013): before each update, if the norm of the gradient is larger than a threshold, divide the gradient so that its norm equals the threshold. The update keeps its direction, but its size is limited. In PyTorch, it is one line, nn.utils.clip_grad_norm_(model.parameters(), 1.0), between loss.backward() and optimizer.step().
Vanishing gradients need a change of architecture. The LSTM (for long short-term memory) adds a second state, the cell , which is updated by an addition instead of a product by a matrix:
where is the product component by component. The vector is a candidate update, computed from the previous state and the current word, like the state of the plain RNN. And , and are three gates: vectors of numbers between 0 and 1, computed in the same way but with a sigmoid, for instance , each with its own weights. The forget gate says how much of each component of the cell is kept, the input gate how much of the candidate is added, and the output gate how much of the cell goes into the state , which is what the next step and the head read.
From to , the gradient is only multiplied by the forget gate, component by component: no matrix, and no derivative of the tanh. When the forget gate is close to 1, the gradient goes back through many steps almost unchanged, and the network learns, word by word, what to keep and what to forget.
The GRU (for gated recurrent unit) (Cho et al., 2014) is a simpler variant, with two gates and no separate cell. In PyTorch, nn.RNN, nn.LSTM and nn.GRU take the same arguments, so you can switch from one to the other in one line. Let’s measure the gradient that reaches each word in these networks.
Exercise 7.2a Measure the gradient that reaches each word
Feed a sequence of 100 random embeddings to a plain RNN and to an LSTM, both untrained, with D = H = 100. For each network, compute the norm of the gradient of its last hidden state with respect to each embedding, and plot it against the distance from the end of the sequence, on a log scale. Then set the bias of the forget gate of the LSTM to 3, and plot again.
import torch
# A sequence of L random embeddings of size D, as a batch of one
# sequence: PyTorch's recurrent layers with batch_first=True expect
# a tensor of shape (batch, L, D)
L, D = 100, 100
torch.manual_seed(0)
embeddings = torch.randn(1, L, D)
print(f"embeddings: {tuple(embeddings.shape)}")
embeddings: (1, 100, 100)
Hint
With batch_first=True, nn.RNN(D, H) and nn.LSTM(D, H) take a tensor of shape (batch, L, D), and return the states after every word, of shape (batch, L, H), with the last state. The LSTM also returns its last cell.
Hint
Call requires_grad_() on a copy of the embeddings, so that PyTorch computes the gradient with respect to them. backward() needs a single number: use the sum of the components of the last state. The gradient is then in the .grad of the embeddings, one row per word. Compute the norms of the rows in float64 (.double()): in float32, the squares of the tiniest components round to 0.
Hint
Each gate of a PyTorch LSTM has two biases, which are added: one in bias_ih_l0, one in bias_hh_l0. Each of these vectors holds the biases of the four gates one after the other (input, forget, cell, output), H values each, so the forget gate is the slice [H:2 * H]. Change them inside with torch.no_grad():.
Checkpoint what you should get
At 10 words from the end, the norm of the gradient is 1.3e-3 for the RNN and 5.5e-3 for the LSTM. At 50 words, it is 1.6e-17 and 1.9e-11, and at the first embedding, 99 words from the end, 6.3e-34 and 3.9e-22. With the bias of its forget gate at 3, the LSTM still gives 0.25 to the first embedding. The chart below shows the three curves.
RNN distance 10: 1.3e-03 50: 1.6e-17 99: 6.3e-34 LSTM distance 10: 5.5e-03 50: 1.9e-11 99: 3.9e-22 LSTM, forget bias 3 distance 10: 5.0e-01 50: 2.5e-01 99: 2.5e-01

Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
import torch
# A sequence of L random embeddings of size D, as a batch of one
# sequence: PyTorch's recurrent layers with batch_first=True expect
# a tensor of shape (batch, L, D)
L, D = 100, 100
torch.manual_seed(0)
embeddings = torch.randn(1, L, D)
print(f"embeddings: {tuple(embeddings.shape)}")
embeddings: (1, 100, 100)
# (1) A plain RNN and an LSTM with hidden states of size H, untrained,
# with the default initialization of PyTorch
from torch import nn
H = 100
torch.manual_seed(0)
rnn = nn.RNN(D, H, batch_first=True)
lstm = nn.LSTM(D, H, batch_first=True)
# (2) The norm of the gradient of the last state with respect to each
# embedding. backward() needs a single number: take the sum of the
# components of the last state.
def gradient_norms(model, embeddings):
x = embeddings.clone().requires_grad_() # track the gradient
states, _ = model(x) # (1, L, H): the state after each word
states[0, -1].sum().backward()
# One norm per word, computed in float64: in float32, the
# squares of the tiny components would round to 0
return x.grad[0].double().norm(dim=1)
# (3) A copy of the LSTM with the bias of its forget gate set to 3.
# PyTorch adds two biases, bias_ih_l0 and bias_hh_l0, each holding
# the biases of the 4 gates one after the other: input, forget,
# cell, output, H values each.
import copy
open_lstm = copy.deepcopy(lstm)
with torch.no_grad():
open_lstm.bias_ih_l0[H:2 * H] = 3.0 # the forget gate
open_lstm.bias_hh_l0[H:2 * H] = 0.0
# (4) Plot the norms against the distance from the end of the
# sequence: 0 for the last embedding, L - 1 for the first one
import matplotlib.pyplot as plt
models = {"RNN": rnn, "LSTM": lstm,
"LSTM, forget bias 3": open_lstm}
distances = list(range(L))
plt.figure(figsize=(5, 3.5))
for name, model in models.items():
norms = gradient_norms(model, embeddings).flip(0).tolist()
plt.plot(distances, norms, label=name)
print(f"{name:20} distance 10: {norms[10]:.1e} "
f"50: {norms[50]:.1e} 99: {norms[99]:.1e}")
plt.yscale("log")
plt.xlabel("distance from the end of the sequence")
plt.ylabel("norm of the gradient")
plt.legend()
plt.show()
RNN distance 10: 1.3e-03 50: 1.6e-17 99: 6.3e-34 LSTM distance 10: 5.5e-03 50: 1.9e-11 99: 3.9e-22 LSTM, forget bias 3 distance 10: 5.0e-01 50: 2.5e-01 99: 2.5e-01

On a log scale, the curves of the RNN and of the LSTM are almost straight lines: at each step back, the gradient is divided by about 2.2 for the RNN, and by about 1.6 for the LSTM. After 99 steps, the first word gets almost nothing. So the LSTM is not a magic fix. With the default initialization of PyTorch, the biases of its forget gate are close to 0, so the gate starts around , and, as Jozefowicz et al. put it, “this introduces a vanishing gradient with a factor of 0.5 per timestep” (Jozefowicz et al., 2015). With a bias of 3, the forget gate starts around , and the gradient stays between 0.25 and 0.5 from the 10th word back to the first one. This is what the LSTM adds: a path where the gradient is only multiplied by the forget gates, which the network can learn to open. The plain RNN has no such path: at every step, its gradient goes through and the derivative of the tanh. Jozefowicz et al. recommend “adding a bias of 1 to the forget gate of every LSTM in every application”. In an LSTM, the forget gates decide how far back the gradient goes.
Let’s now train an LSTM on the reviews of IMDB. Two details are new compared with the averaging networks. First, the LSTM reads the words in order, so each review becomes the list of the indices of its words, instead of a vector of counts. Second, the reviews of a batch have different lengths, but a tensor must be rectangular: we pad the short reviews with zeros, and pack the batch with the length of each review, so that the LSTM stops at the last word of each review, and the last state is not computed on the padding. To keep the training short, we only keep the last 400 words of the 14% of reviews that are longer.
Exercise 7.2b Classify the IMDB reviews with an LSTM
Train an LSTM classifier on IMDB, with the vocabulary of exercise 3.2: the LSTM reads the embeddings of the words of the review (D = 100, H = 128), and a logistic head reads its last state. Choose the number of epochs on the validation set, and measure the test accuracy. Then measure the accuracy on the test reviews with their words shuffled, and run the model on the hard reviews of exercise 3.4.
The starter code repeats step (1) of the solution of exercise 3.2 and lists the hard reviews of exercise 3.4. Then it turns each review into the list of the indices of its words, in order, with the vocabulary of exercise 3.2.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# The hard reviews: (review, label), with 1 for positive
# [Copied from the starter of 3.4]
hard_reviews = [
# Negation
("Not bad at all, actually.", 1),
("Nothing about this movie is good.", 0),
("I wouldn't call it a masterpiece, but I didn't regret "
"a minute.", 1),
# Contrast
("Great acting, great lighting and a flabbergasting soundtrack, "
"but not even Zendaya could save this boring, boring plot.", 0),
("The first hour is slow and the dialogue is clumsy, but the "
"ending makes it all worth it.", 1),
("Everyone told me it would be the film of the year: great "
"cast, amazing trailer, brilliant director. What a "
"disappointment.", 0),
# Sarcasm
("Wow, what a masterpiece. Two hours of pure brilliance, and I "
"only checked my watch twenty times. The acting was so "
"convincing that I truly believed the actors wanted to be "
"somewhere else. A perfect film if you love naps.", 0),
("Brilliant. Another sequel nobody asked for, with the same "
"jokes as the first one.", 0),
# The same words in a different order
("I expected it to be terrible, but it was great.", 1),
("I expected it to be great, but it was terrible.", 0),
# Synonyms, no word in common
("Excellent movie, awesome soundtrack.", 1),
("Superb film, wonderful score.", 1),
# Rare words
("A flabbergasting, mesmerizing tour de force.", 1),
]
print(f"{len(hard_reviews)} hard reviews")
# ----------------------
# New in this exercise
# ----------------------
# The vocabulary of exercise 3.2: the 10,000 most frequent words
# of the training reviews. build_analyzer() returns the function
# that CountVectorizer uses to cut a text into words.
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_features=10000,
token_pattern=r"\b\w+\b")
vectorizer.fit(train_texts)
split_words = vectorizer.build_analyzer()
word_index = {word: int(i) # word -> index, from 0 to V - 1
for word, i in vectorizer.vocabulary_.items()}
V = len(word_index)
# Turn a review into the list of the indices of its words, in
# order. The words outside the vocabulary get the index V. Keep
# only the last 400 words of the long reviews.
max_length = 400
def to_indices(text):
indices = [word_index.get(word, V) for word in split_words(text)]
return indices[-max_length:]
train_sequences = [to_indices(text) for text in train_texts]
val_sequences = [to_indices(text) for text in val_texts]
test_sequences = [to_indices(text) for text in test_texts]
example = hard_reviews[9][0]
print(f"\n{split_words(example)}")
print(f"{to_indices(example)}")
train: 20000 reviews validation: 5000 reviews test: 25000 reviews 13 hard reviews ['i', 'expected', 'it', 'to', 'be', 'great', 'but', 'it', 'was', 'terrible'] [4469, 3253, 4804, 9084, 903, 4000, 1344, 4804, 9679, 8934]
Hint
Write the model as a subclass of nn.Module, with nn.Embedding(V + 1, D) (the index V is for the words outside the vocabulary), nn.LSTM(D, H, batch_first=True) and nn.Linear(H, 1).
Hint
nn.utils.rnn.pad_sequence(tensors, batch_first=True) pads a list of tensors of different lengths with zeros. Pass the embeddings of the padded batch to nn.utils.rnn.pack_padded_sequence(x, lengths, batch_first=True, enforce_sorted=False), and give the result to the LSTM. It returns the states and (h, c), where h[0] holds the state after the last word of each review.
Hint
Train with Adam (learning rate 0.001) on batches of 64 reviews, with nn.BCEWithLogitsLoss() as in exercise 4.3, and clip the gradient at 1. On a laptop CPU, an epoch takes a little more than a minute.
Hint
For the shuffle test, shuffle a copy of the list of indices of each test review with random.shuffle, and measure the accuracy on these lists.
Checkpoint what you should get
The validation accuracy rises at every epoch, from 72.6% after the first one to 87.2% after the sixth, and the test accuracy is 87.2%. With the words of the test reviews shuffled, it falls to 82.6%. The model gets 8 of the 13 hard reviews right, including the two reviews with the same words: 0.633 for the positive one, and 0.112 for the negative one. Your numbers can differ by a point or so, especially on a GPU.
epoch 1: validation accuracy 0.726 epoch 2: validation accuracy 0.819 epoch 3: validation accuracy 0.845 epoch 4: validation accuracy 0.852 epoch 5: validation accuracy 0.865 epoch 6: validation accuracy 0.872 test accuracy, epoch 6: 0.872 test accuracy, words shuffled: 0.826 label p(positive) right review positive 0.371 NO Not bad at all, actually. negative 0.192 yes Nothing about this movie is good. positive 0.271 NO I wouldn't call it a masterpiece, b… negative 0.011 yes Great acting, great lighting and a … positive 0.990 yes The first hour is slow and the dial… negative 0.930 NO Everyone told me it would be the fi… negative 0.933 NO Wow, what a masterpiece. Two hours … negative 0.732 NO Brilliant. Another sequel nobody as… positive 0.633 yes I expected it to be terrible, but i… negative 0.112 yes I expected it to be great, but it w… positive 0.976 yes Excellent movie, awesome soundtrack. positive 0.963 yes Superb film, wonderful score. positive 0.698 yes A flabbergasting, mesmerizing tour … 8 right out of 13
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# The hard reviews: (review, label), with 1 for positive
# [Copied from the starter of 3.4]
hard_reviews = [
# Negation
("Not bad at all, actually.", 1),
("Nothing about this movie is good.", 0),
("I wouldn't call it a masterpiece, but I didn't regret "
"a minute.", 1),
# Contrast
("Great acting, great lighting and a flabbergasting soundtrack, "
"but not even Zendaya could save this boring, boring plot.", 0),
("The first hour is slow and the dialogue is clumsy, but the "
"ending makes it all worth it.", 1),
("Everyone told me it would be the film of the year: great "
"cast, amazing trailer, brilliant director. What a "
"disappointment.", 0),
# Sarcasm
("Wow, what a masterpiece. Two hours of pure brilliance, and I "
"only checked my watch twenty times. The acting was so "
"convincing that I truly believed the actors wanted to be "
"somewhere else. A perfect film if you love naps.", 0),
("Brilliant. Another sequel nobody asked for, with the same "
"jokes as the first one.", 0),
# The same words in a different order
("I expected it to be terrible, but it was great.", 1),
("I expected it to be great, but it was terrible.", 0),
# Synonyms, no word in common
("Excellent movie, awesome soundtrack.", 1),
("Superb film, wonderful score.", 1),
# Rare words
("A flabbergasting, mesmerizing tour de force.", 1),
]
print(f"{len(hard_reviews)} hard reviews")
# ----------------------
# New in this exercise
# ----------------------
# The vocabulary of exercise 3.2: the 10,000 most frequent words
# of the training reviews. build_analyzer() returns the function
# that CountVectorizer uses to cut a text into words.
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_features=10000,
token_pattern=r"\b\w+\b")
vectorizer.fit(train_texts)
split_words = vectorizer.build_analyzer()
word_index = {word: int(i) # word -> index, from 0 to V - 1
for word, i in vectorizer.vocabulary_.items()}
V = len(word_index)
# Turn a review into the list of the indices of its words, in
# order. The words outside the vocabulary get the index V. Keep
# only the last 400 words of the long reviews.
max_length = 400
def to_indices(text):
indices = [word_index.get(word, V) for word in split_words(text)]
return indices[-max_length:]
train_sequences = [to_indices(text) for text in train_texts]
val_sequences = [to_indices(text) for text in val_texts]
test_sequences = [to_indices(text) for text in test_texts]
example = hard_reviews[9][0]
print(f"\n{split_words(example)}")
print(f"{to_indices(example)}")
train: 20000 reviews validation: 5000 reviews test: 25000 reviews 13 hard reviews ['i', 'expected', 'it', 'to', 'be', 'great', 'but', 'it', 'was', 'terrible'] [4469, 3253, 4804, 9084, 903, 4000, 1344, 4804, 9679, 8934]
# (1) The model: the embeddings of the words (one more for the
# unknown words), an LSTM that reads them in order, and a head
# that reads its last state. The reviews of a batch have different
# lengths: pad them with zeros to the same length, and pack them
# with their lengths, so that the LSTM stops at the last word of
# each review and never reads the padding.
import torch
from torch import nn
device = "cuda" if torch.cuda.is_available() else "cpu"
class LSTMClassifier(nn.Module):
def __init__(self, V, D, H):
super().__init__()
self.embedding = nn.Embedding(V + 1, D) # + unknown
self.lstm = nn.LSTM(D, H, batch_first=True)
self.head = nn.Linear(H, 1) # p(positive) = sigmoid
def forward(self, sequences):
lengths = torch.tensor([len(s) for s in sequences])
padded = nn.utils.rnn.pad_sequence(
[torch.tensor(s) for s in sequences], batch_first=True)
x = self.embedding(padded.to(device)) # (batch, L, D)
packed = nn.utils.rnn.pack_padded_sequence(
x, lengths, batch_first=True, enforce_sorted=False)
_, (h, c) = self.lstm(packed) # h: (1, batch, H)
return self.head(h[0])[:, 0] # the score of each review
torch.manual_seed(0)
model = LSTMClassifier(V, D=100, H=128).to(device)
# (2) Train with Adam on batches of 64 reviews, with gradient
# clipping. After each epoch, measure the validation accuracy, and
# keep a copy of the best model.
import copy
import random
def predict(model, sequences):
# p(positive) for each sequence, 500 sequences at a time
probabilities = []
with torch.no_grad():
for start in range(0, len(sequences), 500):
scores = model(sequences[start:start + 500])
probabilities.append(torch.sigmoid(scores).cpu())
return torch.cat(probabilities)
def accuracy(model, sequences, labels):
right = (predict(model, sequences) > 0.5) == torch.tensor(labels)
return right.float().mean().item()
y_train = torch.tensor(train_labels, dtype=torch.float32)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_function = nn.BCEWithLogitsLoss() # sigmoid + cross-entropy
batch_size = 64
best_accuracy = 0
for epoch in range(6):
order = torch.randperm(len(train_sequences)).tolist()
for start in range(0, len(order), batch_size):
batch = order[start:start + batch_size]
scores = model([train_sequences[i] for i in batch])
loss = loss_function(scores, y_train[batch].to(device))
optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
val_accuracy = accuracy(model, val_sequences, val_labels)
print(f"epoch {epoch + 1}: "
f"validation accuracy {val_accuracy:.3f}")
if val_accuracy > best_accuracy:
best_accuracy, best_epoch = val_accuracy, epoch + 1
best_model = copy.deepcopy(model)
# (3) The test accuracy of the best model, then the shuffle test:
# the same test reviews, with the words of each one in a random order
test_accuracy = accuracy(best_model, test_sequences, test_labels)
print(f"\ntest accuracy, epoch {best_epoch}: {test_accuracy:.3f}")
random.seed(0)
shuffled_sequences = []
for sequence in test_sequences:
shuffled = list(sequence)
random.shuffle(shuffled)
shuffled_sequences.append(shuffled)
shuffled_accuracy = accuracy(best_model, shuffled_sequences,
test_labels)
print(f"test accuracy, words shuffled: {shuffled_accuracy:.3f}")
# (4) Run the best model on the hard reviews, as in exercise 3.4
hard_sequences = [to_indices(text) for text, label in hard_reviews]
p_positive = predict(best_model, hard_sequences)
names = ["negative", "positive"]
print("\nlabel p(positive) right review")
right_count = 0
for (review, label), p in zip(hard_reviews, p_positive.tolist()):
right = (p > 0.5) == (label == 1)
if right:
right_count += 1
mark = "yes" if right else "NO"
short = review if len(review) <= 36 else review[:35] + "…"
print(f"{names[label]:9} {p:.3f} {mark:5} {short}")
print(f"\n{right_count} right out of {len(hard_reviews)}")
epoch 1: validation accuracy 0.726 epoch 2: validation accuracy 0.819 epoch 3: validation accuracy 0.845 epoch 4: validation accuracy 0.852 epoch 5: validation accuracy 0.865 epoch 6: validation accuracy 0.872 test accuracy, epoch 6: 0.872 test accuracy, words shuffled: 0.826 label p(positive) right review positive 0.371 NO Not bad at all, actually. negative 0.192 yes Nothing about this movie is good. positive 0.271 NO I wouldn't call it a masterpiece, b… negative 0.011 yes Great acting, great lighting and a … positive 0.990 yes The first hour is slow and the dial… negative 0.930 NO Everyone told me it would be the fi… negative 0.933 NO Wow, what a masterpiece. Two hours … negative 0.732 NO Brilliant. Another sequel nobody as… positive 0.633 yes I expected it to be terrible, but i… negative 0.112 yes I expected it to be great, but it w… positive 0.976 yes Excellent movie, awesome soundtrack. positive 0.963 yes Superb film, wonderful score. positive 0.698 yes A flabbergasting, mesmerizing tour … 8 right out of 13
For the first time in the post, a classifier gets the two reviews with the same words right: 0.63 for “I expected it to be terrible, but it was great.”, and 0.11 for the other one. And shuffling the words of the test reviews now costs 4.6 points of accuracy: the model uses the order of the words. Yet its test accuracy, 87.2%, is no better than the bag of words of section 3 (87.6% with counts, 88.4% with TF-IDF). Dai and Le found the same: 86.5% for an LSTM trained from scratch on IMDB (Dai & Le, 2015). Reading in order is harder to learn than counting. The model must learn the embeddings, what to keep in its state and when to forget it, from 20,000 labeled reviews only, and it needed 6 epochs where the averaging network of exercise 4.3 needed 2. The hard reviews show where the order helps: the contrast “The first hour is slow and the dialogue is clumsy, but the ending makes it all worth it.” is now right (0.99). But “Not bad at all, actually.” is still negative, and the sarcastic reviews still fool the model. An LSTM can use the order of the words, but from 20,000 labeled reviews, it learns little more than a bag of words.
Let’s look at how the LSTM tells the two reviews apart. The figure below shows it reading them, word by word. The first five words are the same in both reviews, and so are the states: the network cannot know what comes next. Then “terrible” lowers the probability of being positive in review A, and “great” raises it in review B. The next three words, “but it was”, are again the same in both reviews, yet the states stay different, since each state carries what was read before it. And the last word moves the prediction to the other side: up with “great” in review A, down with “terrible” in review B.
The LSTM reads in order, but on IMDB it does not beat the bag of words. Dai and Le found the same, and they also found the remedy: pre-train the LSTM on raw text first, as section 5 pre-trained the embeddings, then fine-tune it on the labeled reviews. With this, their LSTM reached 92.4% on IMDB. This is the topic of section 8. To pre-train an LSTM on raw text, we need a task that raw text can supervise: predicting the next word, as in section 2.
7.3 RNNs as language models and generators
Let’s go back to language modeling. An RNN is also a language model: at each position , a head reads the state and gives the probabilities of the next word, with a softmax over the vocabulary, as in Bengio’s model:
The state depends on all the words since the start of the text, so the context is no longer limited to the last n − 1 words. Mikolov et al. built such a model in 2010 (Mikolov et al., 2010), and with a mix of several RNNs, they reduced the perplexity of a Kneser-Ney 5-gram model on news articles by almost half. In their words, the results were “breaking myth that language modeling is just about counting n-grams”.
To train it, we would like to unroll the RNN over the whole training text and backpropagate through it. But WikiText-2 is a single text of 2 million words, and the gradient would have to go through 2 million steps. The usual solution is truncated backpropagation through time. Cut the text into chunks of 35 words, and read them in order. The state is carried from one chunk to the next, so that the prediction of each word still depends on all the text before it. But the gradient stops at the start of the chunk: the network learns from at most 35 words of context, even if it uses more. To use the parallelism of matrix products, we also cut the text into 32 long streams, and a batch reads the 32 streams side by side, one chunk of each.
Like Bengio’s model in exercise 4.2, this model has many parameters (most of them in the output layer, one row of per word of the vocabulary) and it overfits after a few epochs, so we keep the model of the epoch with the best validation perplexity.
Exercise 7.3★ Train an LSTM language model on WikiText-2
Train an LSTM language model on WikiText-2 (D = H = 256), with truncated backpropagation through time: read the training text in chunks of 35 words, carry the state from one chunk to the next, but stop the gradient at the start of each chunk. Train for 3 epochs, measure the validation perplexity after each epoch, and compute the test perplexity of the best model. Compare with the n-gram models of exercise 2.3b and with Bengio’s model of exercise 4.2. Then generate a text of 50 words.
The starter code builds the three sequences and the index of the words, as in exercises 2.1, 2.2 and 4.2. Then it cuts each sequence into streams that a batch reads side by side (see the first hint).
from datasets import load_dataset
wikitext = load_dataset("Salesforce/wikitext", "wikitext-2-v1")
# (1) Build the train sequence by splitting every line on spaces
# [Copied from the solution of 2.1, step (1)]
train_sequence = []
for line in wikitext["train"]["text"]:
train_sequence.extend(line.split())
print(f"text: {len(train_sequence)} words")
print(f"vocabulary: {len(set(train_sequence))} words")
# The validation and test sequences, built in the same way
# [Copied from the starter of 2.2]
val_sequence = []
for line in wikitext["validation"]["text"]:
val_sequence.extend(line.split())
test_sequence = []
for line in wikitext["test"]["text"]:
test_sequence.extend(line.split())
print(f"validation: {len(val_sequence)} words")
print(f"test: {len(test_sequence)} words")
# Give each word of the vocabulary an index
# [Copied from the starter of 4.2]
vocabulary = sorted(set(train_sequence))
word_index = {word: i for i, word in enumerate(vocabulary)}
# ----------------------
# New in this exercise
# ----------------------
# Cut a sequence into n streams of indices: row i holds the i-th
# piece of the text. A batch reads the n streams side by side, so
# that each stream continues from one batch to the next.
import torch
def make_streams(sequence, n):
indices = torch.tensor([word_index[word] for word in sequence])
length = len(indices) // n # drop the last few words
return indices[:n * length].view(n, length)
train_streams = make_streams(train_sequence, 32)
val_streams = make_streams(val_sequence, 10)
test_streams = make_streams(test_sequence, 10)
print(f"\ntrain: {tuple(train_streams.shape)} (streams, words)")
first = [vocabulary[i] for i in train_streams[1, :8].tolist()]
print(f"start of stream 1: {' '.join(first)}")
text: 2051910 words vocabulary: 33277 words validation: 213886 words test: 241211 words train: (32, 64122) (streams, words) start of stream 1: . Antonio Imbert <unk> is one of the
Hint
The starter cuts each set into streams: train_streams has 32 rows, and row i is the i-th piece of the text. For a chunk starting at position start, the input is train_streams[:, start:start + 35], and the targets are the same columns shifted by one, train_streams[:, start + 1:start + 36].
Hint
Write the model as a subclass of nn.Module, as in exercise 7.2b, with nn.Embedding(V, D), nn.LSTM(D, H, batch_first=True) and nn.Linear(H, V), and start from small embeddings, as in exercise 4.2. The LSTM takes the state (h, c) left by the previous chunk, or None for the first chunk, and returns the states at every position, then the new state (h, c). F.cross_entropy wants the scores as a matrix with one row per position: reshape them with scores.reshape(-1, V), and the targets with y.reshape(-1).
Hint
Before each chunk, replace the state by (h.detach(), c.detach()): the values are kept, but the gradient will not flow into the previous chunks. Without it, backward() would go back through the whole text read so far.
Hint
Train with Adam (learning rate 0.002) and clip the gradient at 1. An epoch is 1,832 chunks, and it takes about two and a half minutes on a laptop CPU. To generate the text, feed one word at a time, and keep the state from one word to the next.
Checkpoint what you should get
The validation perplexity is 204.9 after the first epoch, 168.4 after the second, and 169.2 after the third: the model starts to overfit. The test perplexity of the model of epoch 2 is 155.8. Your sample will differ, but it should read like pieces of Wikipedia sentences put end to end. On a GPU, your numbers can differ a little.
epoch 1: validation perplexity 204.9 epoch 2: validation perplexity 168.4 epoch 3: validation perplexity 169.2 test perplexity, epoch 2: 155.8 The Ancients glazing to stop this thing . The episode follows recording essentially the John Jervois list of Labour Justice in 1758 for this early weeks . Critical reception of the United States , the USA Medicine Treaty of Away Games ( Bass in December 2006 ) <unk> ( the
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
wikitext = load_dataset("Salesforce/wikitext", "wikitext-2-v1")
# (1) Build the train sequence by splitting every line on spaces
# [Copied from the solution of 2.1, step (1)]
train_sequence = []
for line in wikitext["train"]["text"]:
train_sequence.extend(line.split())
print(f"text: {len(train_sequence)} words")
print(f"vocabulary: {len(set(train_sequence))} words")
# The validation and test sequences, built in the same way
# [Copied from the starter of 2.2]
val_sequence = []
for line in wikitext["validation"]["text"]:
val_sequence.extend(line.split())
test_sequence = []
for line in wikitext["test"]["text"]:
test_sequence.extend(line.split())
print(f"validation: {len(val_sequence)} words")
print(f"test: {len(test_sequence)} words")
# Give each word of the vocabulary an index
# [Copied from the starter of 4.2]
vocabulary = sorted(set(train_sequence))
word_index = {word: i for i, word in enumerate(vocabulary)}
# ----------------------
# New in this exercise
# ----------------------
# Cut a sequence into n streams of indices: row i holds the i-th
# piece of the text. A batch reads the n streams side by side, so
# that each stream continues from one batch to the next.
import torch
def make_streams(sequence, n):
indices = torch.tensor([word_index[word] for word in sequence])
length = len(indices) // n # drop the last few words
return indices[:n * length].view(n, length)
train_streams = make_streams(train_sequence, 32)
val_streams = make_streams(val_sequence, 10)
test_streams = make_streams(test_sequence, 10)
print(f"\ntrain: {tuple(train_streams.shape)} (streams, words)")
first = [vocabulary[i] for i in train_streams[1, :8].tolist()]
print(f"start of stream 1: {' '.join(first)}")
text: 2051910 words vocabulary: 33277 words validation: 213886 words test: 241211 words train: (32, 64122) (streams, words) start of stream 1: . Antonio Imbert <unk> is one of the
# (1) The model: the embeddings, an LSTM, and one score per word of
# the vocabulary at each position. forward() takes the state left
# by the previous chunk, and returns the new one. Start from small
# embeddings, as in exercise 4.2.
from torch import nn
import torch.nn.functional as F
class LSTMLanguageModel(nn.Module):
def __init__(self, V, D, H):
super().__init__()
self.embedding = nn.Embedding(V, D)
self.lstm = nn.LSTM(D, H, batch_first=True)
self.output = nn.Linear(H, V)
def forward(self, x, state=None):
e = self.embedding(x) # (batch, steps, D)
h, state = self.lstm(e, state) # (batch, steps, H)
return self.output(h), state # (batch, steps, V)
device = "cuda" if torch.cuda.is_available() else "cpu"
V = len(vocabulary)
torch.manual_seed(0)
model = LSTMLanguageModel(V, D=256, H=256).to(device)
nn.init.normal_(model.embedding.weight, std=0.1)
# (2) The chunks of 35 words: x holds the words of the chunk, y the
# words that follow them, one position later
steps = 35
def chunks(streams):
pairs = []
for start in range(0, streams.shape[1] - 1, steps):
y = streams[:, start + 1:start + 1 + steps]
x = streams[:, start:start + y.shape[1]]
pairs.append((x.to(device), y.to(device)))
return pairs
# (3) The perplexity on a set of streams, read chunk after chunk
# with the state carried over
import math
def perplexity(model, streams):
total_loss, count, state = 0, 0, None
with torch.no_grad():
for x, y in chunks(streams):
scores, state = model(x, state)
total_loss += F.cross_entropy(scores.reshape(-1, V),
y.reshape(-1),
reduction="sum").item()
count += y.numel()
return math.exp(total_loss / count)
# (4) Train with Adam and gradient clipping. The state is carried
# from one chunk to the next, but detach() cuts its link to the
# previous chunks, so that the gradient stops at the chunk.
import copy
optimizer = torch.optim.Adam(model.parameters(), lr=2e-3)
best_perplexity = math.inf
for epoch in range(3):
state = None
for x, y in chunks(train_streams):
if state is not None:
state = (state[0].detach(), state[1].detach())
scores, state = model(x, state)
loss = F.cross_entropy(scores.reshape(-1, V), y.reshape(-1))
optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
val_perplexity = perplexity(model, val_streams)
print(f"epoch {epoch + 1}: validation perplexity "
f"{val_perplexity:.1f}")
if val_perplexity < best_perplexity:
best_perplexity, best_epoch = val_perplexity, epoch + 1
best_model = copy.deepcopy(model)
# (5) Test perplexity of the best model
test_perplexity = perplexity(best_model, test_streams)
print(f"\ntest perplexity, epoch {best_epoch}: {test_perplexity:.1f}")
# (6) Generate 50 words, one at a time: the state carries all the
# words generated so far
first_word = "The"
length = 50
torch.manual_seed(0)
sample_words = [first_word]
x = torch.tensor([[word_index[first_word]]]).to(device)
state = None
with torch.no_grad():
for _ in range(length - 1):
scores, state = best_model(x, state)
probabilities = torch.softmax(scores[0, -1], dim=0)
next_index = torch.multinomial(probabilities, 1).item()
sample_words.append(vocabulary[next_index])
x = torch.tensor([[next_index]]).to(device)
print(f"\n{' '.join(sample_words)}")
epoch 1: validation perplexity 204.9 epoch 2: validation perplexity 168.4 epoch 3: validation perplexity 169.2 test perplexity, epoch 2: 155.8 The Ancients glazing to stop this thing . The episode follows recording essentially the John Jervois list of Labour Justice in 1758 for this early weeks . Critical reception of the United States , the USA Medicine Treaty of Away Games ( Bass in December 2006 ) <unk> ( the
After a single epoch, the LSTM already beats every language model of sections 2 and 4: 204.9 on the validation set, against 223.9 for Bengio’s model at its best epoch. Its test perplexity, 155.8, is 25% lower than the 208.5 of Bengio’s model, and 29% lower than the 219.3 of the Kneser-Ney 5-gram, for about the same training time as Bengio’s model. The difference is the context: Bengio’s model sees the last 3 words, while the state of the LSTM carries everything since the start of its stream. Look at the sample: it opens a parenthesis, ”( Bass in December 2006 )”, and closes it four words later. By then, the opening parenthesis is out of the window of the last 3 words, so Bengio’s model could only guess, while the LSTM can keep it in its state. But the sample still makes no sense beyond a few words: the state remembers some things, such as an open parenthesis, but not what the text is about. With a context that is no longer limited to a window, the LSTM predicts the next word much better than the models of sections 2 and 4.
Our LSTM is small, and it was trained for only 3 epochs. The best LSTMs of 2016 on WikiText-2 have two layers of 650 units, and they are regularized with dropout: during the training, components of the vectors of the network are set to zero at random, so that the network cannot rely too much on any of them. Zaremba et al. applied dropout to the embeddings and to the states that go from one layer to the next, but not to the state that goes from one word to the next: new random zeros at every word would also erase what the network remembers (Zaremba et al., 2014). Gal and Ghahramani then showed that this state can be dropped too, if the same components are set to zero at every word of a sequence (Gal & Ghahramani, 2016). With this variational dropout, a two-layer LSTM reaches a test perplexity of 96.3 on WikiText-2, and 80.8 with a mechanism that can also copy a word from the recent text (Merity et al., 2016). We will meet an even better LSTM in section 8.
Like the n-gram models, an RNN language model can generate text: draw the next word from its probabilities, feed it back as the next input, and repeat. RNNs made this popular at the level of characters. Instead of words, the model reads and predicts one character at a time, with a vocabulary of a few dozen symbols. In a famous blog post, Andrej Karpathy trained such LSTMs on the works of Shakespeare, on Wikipedia, on LaTeX files and on the source code of Linux (Karpathy, 2015). As he notes about Shakespeare, “all the RNN knows are characters, so in particular it samples both speaker’s names and the contents”. Below are the samples of a small character-level LSTM trained on TinyShakespeare, at several steps of its training.
At first, the characters are drawn at random. After 100 batches, the model has learned the spaces, the line breaks and a few short words. After 300 batches, it writes in the format of a play: a name in capitals followed by a colon, then the lines of this character, like “GOLUTE:”, a name that Shakespeare never wrote. After 1,000 batches, the names are real ones, like JULIET or FRIAR LAURENCE, and most words are English words, in sentences that make no sense.
So far, our units have been words. A word-level model has a fixed vocabulary: in WikiText-2, the words that appear at least 3 times in the training text, every other word being replaced by <unk>. A word outside the vocabulary, like “Zendaya” or “flabbergasting” in section 3, is invisible, or at best an <unk>, which says nothing about it. And each word gets its own embedding, learned separately, so the model must learn “film” and “films” twice.
Characters solve this problem: TinyShakespeare has only 65 different characters, and any word can be written with them, even a word that the model has never seen. But a text has about 5 times more characters than words, so the RNN must carry information over 5 times more steps, which is exactly what section 7.2 says is hard. And the model must learn to spell before it can learn anything about meaning.
Note that perplexities measured on different units cannot be compared directly. The probability that a model gives to a whole text does not depend on the unit, but the perplexity divides its logarithm by the number of units: words, characters, or anything else. For instance, the character-level LSTM of the figure above ends with 2.0 bits per character on the validation text of TinyShakespeare: a perplexity of per character, which looks much better than the perplexities of our word-level models. But this text has 5.5 characters per word, spaces and punctuation included, so the same probability is bits per word: a perplexity of about per word. To compare a character-level model with a word-level model, divide the log-probability of the same text by the same number, for instance its number of words.
Subwords are the compromise used by today’s language models: frequent words are units of their own, and rare words are cut into frequent pieces. Byte-pair encoding (Sennrich et al., 2016) builds such a vocabulary from the training text: start from the characters, and merge the most frequent pair of consecutive symbols into a new symbol, again and again, until the vocabulary has the size you want. With subwords, no word is unknown, and the texts are only a little longer than with words. These are the tokenizers, the topic of the next post.
If you want to dig deeper, below are a few bonus exercises.
A plain RNN. Replace the LSTM of exercise 7.2b with nn.RNN. Does it learn? How far is its accuracy from the accuracy of the LSTM?
The GRU. Replace the LSTM with nn.GRU in exercises 7.2b and 7.3. Which one learns faster, and which one gets the best result?
Open the forget gates. Before training the LSTM of exercise 7.2b, set the biases of its forget gate to 1, as Jozefowicz et al. recommend (Jozefowicz et al., 2015). Does the validation accuracy rise faster?
Without clipping. Train the language model of exercise 7.3 without gradient clipping, with a learning rate of 0.01. Plot the loss of each batch: do you see spikes?
The gradient in a trained model. Redo exercise 7.2a with the trained LSTM of exercise 7.2b, on the embeddings of a real review of 400 words. Which words get the largest gradients?
Convolutions. Train the convolutional network of Kim (Kim, 2014) on IMDB: windows of 3, 4 and 5 words, 100 features for each size, a max over the positions, and a logistic head. nn.Conv1d applies the same small network to every window of the text. How does it compare with the LSTM? How much does its accuracy change when the words of the test reviews are shuffled?
Characters. Train a character-level LSTM on TinyShakespeare (jchwenger/tiny_shakespeare, loaded with sample_by="document"), and look at its samples during the training, as in the figure of section 7.3. Compare them with the samples of the character n-gram models of the bonus of section 2.3.
8. Pre-training the whole encoder
At the end of section 7, the LSTM could read the words in order, but on IMDB it did no better than a bag of words. It had to learn everything from 20,000 labeled reviews: the embeddings of the words, what to keep in its state, and when to forget it. Section 5 gave us part of a solution: learn the embeddings on raw text first. But only the first layer of the network was pre-trained, and everything above it still started from scratch.
Exercise 7.3 suggests going further. A language model is an LSTM trained on raw text, and its embeddings and its LSTM are exactly the encoder of the classifier of exercise 7.2b. So why not pre-train the whole encoder as a language model, then copy it into the classifier and fine-tune it on the labeled reviews? Only the head would start from scratch. The output layer of the language model, which gives the probabilities of the next word, is simply dropped.
This is the second question of the introduction again: what can be transferred from one task to another? Section 5 transferred the first layer of the network. This section transfers the whole encoder. And in section 10, we will reach models for which nothing at all is trained for the task.
8.1 Pre-train the encoder, then fine-tune it
Andrew Dai and Quoc Le, at Google, tested this idea on text classification in 2015 (Dai & Le, 2015). They pre-trained an LSTM of 1,024 units as a language model, used its embeddings and its LSTM to initialize the classifier, and fine-tuned the whole network on the labeled reviews. On IMDB, the accuracy went from 86.5% for the same LSTM trained from scratch to 92.4%, 4 points above our bags of words of section 3. Initializing only the embeddings, with word2vec vectors learned on the reviews, gave 90.0%. They also tried a second pre-training task, the sequence autoencoder: read the whole review, then write it again, word by word, starting from the last state. It did a little better, 92.8%. And the pre-trained LSTMs were also easier to train. From scratch, they write, “if we were to increase the number of hidden units or to increase the number of backprop steps, the training breaks down very quickly”.
Let’s try it with our own LSTM. The 50,000 unlabeled reviews of IMDB (the unsupervised split of the dataset) are made for this: more than 10 million words of the same kind as the labeled reviews, five times WikiText-2. We pre-train the encoder of exercise 7.2b on them as a language model, then look at what it transfers, in two ways. First, we freeze it, and train a logistic regression on its last state. If the language model has learned to track the sentiment of a review, because it helps to predict the next words, the logistic regression will find it there. Then we fine-tune the classifier of exercise 7.2b from the pre-trained encoder, on 1,000 labeled reviews: as exercise 5.5b showed, this is where pre-training should matter most.
Exercise 8.1★ Pre-train the LSTM as a language model
Pre-train the encoder of exercise 7.2b (its embeddings and its LSTM, D = 100, H = 128) as a language model on the 50,000 unlabeled reviews of IMDB, for one epoch, with the vocabulary of exercise 3.2. Measure its perplexity on the validation reviews, and generate the end of a review that starts with “this movie”. Then measure what it knows about sentiment: freeze it, train a logistic regression on its last state, and compare with the same LSTM before training. Last, fine-tune the classifier of exercise 7.2b on 1,000 labeled reviews, starting from scratch, from the embeddings of the language model, and from its embeddings and its LSTM, and average each test accuracy over 3 random draws of the 1,000 reviews.
The starter code repeats step (1) of the solution of exercise 3.2, then the vocabulary and the class LSTMClassifier of exercise 7.2b. It also turns the 50,000 unlabeled reviews into lists of word indices.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# The vocabulary of exercise 3.2: the 10,000 most frequent words
# of the training reviews. build_analyzer() returns the function
# that CountVectorizer uses to cut a text into words.
# [Copied from the starter of 7.2b]
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_features=10000,
token_pattern=r"\b\w+\b")
vectorizer.fit(train_texts)
split_words = vectorizer.build_analyzer()
word_index = {word: int(i) # word -> index, from 0 to V - 1
for word, i in vectorizer.vocabulary_.items()}
V = len(word_index)
# Turn a review into the list of the indices of its words, in
# order. The words outside the vocabulary get the index V. Keep
# only the last 400 words of the long reviews.
# [Copied from the starter of 7.2b]
max_length = 400
def to_indices(text):
indices = [word_index.get(word, V) for word in split_words(text)]
return indices[-max_length:]
train_sequences = [to_indices(text) for text in train_texts]
val_sequences = [to_indices(text) for text in val_texts]
test_sequences = [to_indices(text) for text in test_texts]
# (1) The model: the embeddings of the words (one more for the
# unknown words), an LSTM that reads them in order, and a head
# that reads its last state. The reviews of a batch have different
# lengths: pad them with zeros to the same length, and pack them
# with their lengths, so that the LSTM stops at the last word of
# each review and never reads the padding.
# [Copied from the solution of 7.2b, step (1)]
import torch
from torch import nn
device = "cuda" if torch.cuda.is_available() else "cpu"
class LSTMClassifier(nn.Module):
def __init__(self, V, D, H):
super().__init__()
self.embedding = nn.Embedding(V + 1, D) # + unknown
self.lstm = nn.LSTM(D, H, batch_first=True)
self.head = nn.Linear(H, 1) # p(positive) = sigmoid
def forward(self, sequences):
lengths = torch.tensor([len(s) for s in sequences])
padded = nn.utils.rnn.pad_sequence(
[torch.tensor(s) for s in sequences], batch_first=True)
x = self.embedding(padded.to(device)) # (batch, L, D)
packed = nn.utils.rnn.pack_padded_sequence(
x, lengths, batch_first=True, enforce_sorted=False)
_, (h, c) = self.lstm(packed) # h: (1, batch, H)
return self.head(h[0])[:, 0] # the score of each review
torch.manual_seed(0)
model = LSTMClassifier(V, D=100, H=128).to(device)
# ----------------------
# New in this exercise
# ----------------------
# The 50,000 unlabeled reviews of IMDB, turned into lists of word
# indices in the same way
unlabeled_texts = clean(imdb["unsupervised"]["text"])
unlabeled_sequences = [to_indices(text) for text in unlabeled_texts]
n_words = sum(len(sequence) for sequence in unlabeled_sequences)
print(f"\nunlabeled: {len(unlabeled_sequences)} reviews, "
f"{n_words} words")
print(split_words(unlabeled_texts[0])[:10])
train: 20000 reviews validation: 5000 reviews test: 25000 reviews unlabeled: 50000 reviews, 10565838 words ['this', 'is', 'just', 'a', 'precious', 'little', 'diamond', 'the', 'play', 'the']
Hint
Write the language model as in exercise 7.3, with nn.Embedding(V + 1, D), nn.LSTM(D, H, batch_first=True) and nn.Linear(H, V + 1): the unknown words (index V) are one more word to predict. Name its embeddings and its LSTM as in the classifier of exercise 7.2b, embedding and lstm, so that you can copy them into the classifier later.
Hint
Each review is a training sequence: the inputs are its words but the last one, and the targets are its words but the first one. pad_sequence pads the short reviews of a batch at the end. The LSTM reads from left to right, so the padding does not change the states of the real words. Pad the targets with −100: F.cross_entropy ignores these positions. The scores of a batch have size (batch, L, V + 1): to compute the perplexity, take 50 reviews at a time, since the scores of 500 reviews would take 8 GB.
Hint
Train with Adam (learning rate 0.002), and clip the gradient at 1, as in exercise 7.3. An epoch is 782 batches of 64 reviews, about four minutes on a laptop CPU.
Hint
For the logistic regression, compute the state after the last word of each review: run the LSTM on a padded batch, and take, for each review, the state at the position of its last word, lengths - 1. LogisticRegression of scikit-learn takes this matrix of 20,000 rows and 128 columns, as it took the counts of the words in exercise 3.2.
Hint
To fine-tune, create a new LSTMClassifier, and copy the parts of the language model into it: model.embedding.load_state_dict(language_model.embedding.state_dict()), and the same with lstm. Then train it as in exercise 5.5b: 400 batches of 32 reviews drawn at random among the 1,000 (random.choices), and keep the model with the best validation accuracy, measured every 50 batches. With 1,000 reviews, the accuracy depends on the reviews drawn: average it over 3 draws.
Checkpoint what you should get
After one epoch, the validation perplexity is 212.1, and the sample has the words of a review (“believable”, “gore”, “sci fi story”) in sentences that make no sense. The logistic regression on the last state gets 57.7% of the test reviews right, against 54.8% for the same LSTM before training. Fine-tuned on 1,000 labeled reviews, the classifier gets 65.3% from scratch, 70.2% from the embeddings of the language model, and 68.2% from its embeddings and its LSTM. The whole solution takes about 16 minutes on a laptop CPU, most of it for the nine fine-tunings. Your numbers can differ by a point or so.
validation perplexity: 212.1 this movie isn t believable i m leaving the apocalypse wretched gore moments this is while i have to add here sci construction and allows married and make a happy riding you say i heard of sort of sci fi story it
LSTM before training: test accuracy 0.548 LSTM after training: test accuracy 0.577 fine-tuned on 1,000 labeled reviews: from scratch test accuracy 0.653 embeddings test accuracy 0.702 embeddings and LSTM test accuracy 0.682
Solution 3 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# The vocabulary of exercise 3.2: the 10,000 most frequent words
# of the training reviews. build_analyzer() returns the function
# that CountVectorizer uses to cut a text into words.
# [Copied from the starter of 7.2b]
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_features=10000,
token_pattern=r"\b\w+\b")
vectorizer.fit(train_texts)
split_words = vectorizer.build_analyzer()
word_index = {word: int(i) # word -> index, from 0 to V - 1
for word, i in vectorizer.vocabulary_.items()}
V = len(word_index)
# Turn a review into the list of the indices of its words, in
# order. The words outside the vocabulary get the index V. Keep
# only the last 400 words of the long reviews.
# [Copied from the starter of 7.2b]
max_length = 400
def to_indices(text):
indices = [word_index.get(word, V) for word in split_words(text)]
return indices[-max_length:]
train_sequences = [to_indices(text) for text in train_texts]
val_sequences = [to_indices(text) for text in val_texts]
test_sequences = [to_indices(text) for text in test_texts]
# (1) The model: the embeddings of the words (one more for the
# unknown words), an LSTM that reads them in order, and a head
# that reads its last state. The reviews of a batch have different
# lengths: pad them with zeros to the same length, and pack them
# with their lengths, so that the LSTM stops at the last word of
# each review and never reads the padding.
# [Copied from the solution of 7.2b, step (1)]
import torch
from torch import nn
device = "cuda" if torch.cuda.is_available() else "cpu"
class LSTMClassifier(nn.Module):
def __init__(self, V, D, H):
super().__init__()
self.embedding = nn.Embedding(V + 1, D) # + unknown
self.lstm = nn.LSTM(D, H, batch_first=True)
self.head = nn.Linear(H, 1) # p(positive) = sigmoid
def forward(self, sequences):
lengths = torch.tensor([len(s) for s in sequences])
padded = nn.utils.rnn.pad_sequence(
[torch.tensor(s) for s in sequences], batch_first=True)
x = self.embedding(padded.to(device)) # (batch, L, D)
packed = nn.utils.rnn.pack_padded_sequence(
x, lengths, batch_first=True, enforce_sorted=False)
_, (h, c) = self.lstm(packed) # h: (1, batch, H)
return self.head(h[0])[:, 0] # the score of each review
torch.manual_seed(0)
model = LSTMClassifier(V, D=100, H=128).to(device)
# ----------------------
# New in this exercise
# ----------------------
# The 50,000 unlabeled reviews of IMDB, turned into lists of word
# indices in the same way
unlabeled_texts = clean(imdb["unsupervised"]["text"])
unlabeled_sequences = [to_indices(text) for text in unlabeled_texts]
n_words = sum(len(sequence) for sequence in unlabeled_sequences)
print(f"\nunlabeled: {len(unlabeled_sequences)} reviews, "
f"{n_words} words")
print(split_words(unlabeled_texts[0])[:10])
train: 20000 reviews validation: 5000 reviews test: 25000 reviews unlabeled: 50000 reviews, 10565838 words ['this', 'is', 'just', 'a', 'precious', 'little', 'diamond', 'the', 'play', 'the']
# (1) The language model: the embeddings and the LSTM of the
# classifier of exercise 7.2b, and an output layer that gives a
# score to each word of the vocabulary at each position, as in
# exercise 7.3. The unknown words (index V) are one more word that
# it can predict. Start from small embeddings, as in exercise 4.2.
import torch.nn.functional as F
class LSTMLanguageModel(nn.Module):
def __init__(self, V, D, H):
super().__init__()
self.embedding = nn.Embedding(V + 1, D) # + unknown
self.lstm = nn.LSTM(D, H, batch_first=True)
self.output = nn.Linear(H, V + 1)
def forward(self, x, state=None):
e = self.embedding(x) # (batch, steps, D)
h, state = self.lstm(e, state) # (batch, steps, H)
return self.output(h), state # (batch, steps, V + 1)
torch.manual_seed(0)
language_model = LSTMLanguageModel(V, D=100, H=128).to(device)
nn.init.normal_(language_model.embedding.weight, std=0.1)
# (2) A batch of reviews: the inputs x are the words of each review
# but the last one, the targets y are the words that follow them.
# pad_sequence pads the short reviews at the end: the LSTM reads
# from left to right, so the padding does not change the states of
# the real words. The targets are padded with -100, which
# F.cross_entropy ignores, so the padding does not count in the loss.
from torch.nn.utils.rnn import pad_sequence
def make_batch(sequences):
x = pad_sequence([torch.tensor(s[:-1]) for s in sequences],
batch_first=True)
y = pad_sequence([torch.tensor(s[1:]) for s in sequences],
batch_first=True, padding_value=-100)
return x.to(device), y.to(device)
# (3) The perplexity on a list of reviews, 50 reviews at a time
import math
def perplexity(model, sequences):
total_loss, count = 0, 0
with torch.no_grad():
for start in range(0, len(sequences), 50):
x, y = make_batch(sequences[start:start + 50])
scores, _ = model(x)
total_loss += F.cross_entropy(scores.reshape(-1, V + 1),
y.reshape(-1),
reduction="sum").item()
count += (y != -100).sum().item()
return math.exp(total_loss / count)
# (4) Train for one epoch on the unlabeled reviews, in batches of
# 64 reviews, with Adam and gradient clipping as in exercise 7.3
optimizer = torch.optim.Adam(language_model.parameters(), lr=2e-3)
order = torch.randperm(len(unlabeled_sequences)).tolist()
for start in range(0, len(order), 64):
batch = [unlabeled_sequences[i] for i in order[start:start + 64]]
x, y = make_batch(batch)
scores, _ = language_model(x)
loss = F.cross_entropy(scores.reshape(-1, V + 1), y.reshape(-1))
optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(language_model.parameters(), 1.0)
optimizer.step()
val_perplexity = perplexity(language_model, val_sequences)
print(f"validation perplexity: {val_perplexity:.1f}")
# (5) Generate the end of a review that starts with "this movie",
# one word at a time, as in exercise 7.3
vocabulary = list(vectorizer.get_feature_names_out()) + ["<unk>"]
first_words = ["this", "movie"]
length = 40
torch.manual_seed(0)
sample_words = list(first_words)
x = torch.tensor([[word_index[w] for w in first_words]]).to(device)
state = None
with torch.no_grad():
for _ in range(length):
scores, state = language_model(x, state)
probabilities = torch.softmax(scores[0, -1], dim=0)
next_index = torch.multinomial(probabilities, 1).item()
sample_words.append(vocabulary[next_index])
x = torch.tensor([[next_index]]).to(device)
print(f"\n{' '.join(sample_words)}")
validation perplexity: 212.1 this movie isn t believable i m leaving the apocalypse wretched gore moments this is while i have to add here sci construction and allows married and make a happy riding you say i heard of sort of sci fi story it
# (1) What does the language model know about sentiment? Freeze it,
# and train a logistic regression, as in exercise 3.2, on its state
# after the last word of each review. Do the same with the same
# network before its training, for comparison.
from sklearn.linear_model import LogisticRegression
def last_states(model, sequences):
# The state after the last word of each review, 500 at a time
states = []
with torch.no_grad():
for start in range(0, len(sequences), 500):
batch = sequences[start:start + 500]
lengths = torch.tensor([len(s) for s in batch])
padded = pad_sequence([torch.tensor(s) for s in batch],
batch_first=True)
e = model.embedding(padded.to(device))
h, _ = model.lstm(e) # (batch, L, H): after each word
last = h[torch.arange(len(batch)), lengths - 1]
states.append(last.cpu())
return torch.cat(states)
torch.manual_seed(0)
untrained_model = LSTMLanguageModel(V, D=100, H=128).to(device)
nn.init.normal_(untrained_model.embedding.weight, std=0.1)
for name, lm in [("before training", untrained_model),
("after training", language_model)]:
probe = LogisticRegression(max_iter=1000)
probe.fit(last_states(lm, train_sequences), train_labels)
probe_accuracy = probe.score(last_states(lm, test_sequences),
test_labels)
print(f"LSTM {name}: test accuracy {probe_accuracy:.3f}")
# (2) Fine-tune the classifier of exercise 7.2b on 1,000 labeled
# reviews, with three starts: random (from scratch), the
# embeddings of the language model, or its embeddings and its LSTM.
# The head is new in all three. As in exercise 5.5b, draw the
# batches at random from the labeled reviews, measure the accuracy
# on 1,000 validation reviews every 50 batches, and keep the best
# model.
import copy
import random
def predict(model, sequences):
# p(positive) for each sequence, 500 sequences at a time
probabilities = []
with torch.no_grad():
for start in range(0, len(sequences), 500):
scores = model(sequences[start:start + 500])
probabilities.append(torch.sigmoid(scores).cpu())
return torch.cat(probabilities)
def accuracy(model, sequences, labels):
right = (predict(model, sequences) > 0.5) == torch.tensor(labels)
return right.float().mean().item()
def fine_tune(start, chosen, n_batches=400):
torch.manual_seed(0)
model = LSTMClassifier(V, D=100, H=128).to(device)
lm = language_model
if start != "from scratch":
model.embedding.load_state_dict(lm.embedding.state_dict())
if start == "embeddings and LSTM":
model.lstm.load_state_dict(lm.lstm.state_dict())
y = torch.tensor(train_labels, dtype=torch.float32)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_function = nn.BCEWithLogitsLoss()
random.seed(0) # the same batches for the three starts
best_accuracy = 0
for step in range(1, n_batches + 1):
batch = random.choices(chosen, k=32)
scores = model([train_sequences[i] for i in batch])
loss = loss_function(scores, y[batch].to(device))
optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
if step % 50 == 0:
val_accuracy = accuracy(model, val_sequences[:1000],
val_labels[:1000])
if val_accuracy > best_accuracy:
best_accuracy = val_accuracy
best_model = copy.deepcopy(model)
return best_model
# (3) Fine-tune each start on 3 random sets of 1,000 labeled
# reviews, and average the test accuracies
starts = ["from scratch", "embeddings", "embeddings and LSTM"]
results = {start: [] for start in starts}
for seed in range(3):
random.seed(seed)
chosen = random.sample(range(len(train_labels)), 1000)
for start in starts:
model = fine_tune(start, chosen)
results[start].append(accuracy(model, test_sequences,
test_labels))
print("\nfine-tuned on 1,000 labeled reviews:")
for start in starts:
mean_accuracy = sum(results[start]) / len(results[start])
print(f" {start:20} test accuracy {mean_accuracy:.3f}")
LSTM before training: test accuracy 0.548 LSTM after training: test accuracy 0.577 fine-tuned on 1,000 labeled reviews: from scratch test accuracy 0.653 embeddings test accuracy 0.702 embeddings and LSTM test accuracy 0.682
The language model has learned something about reviews: its sample has their vocabulary, and its perplexity on the validation reviews is 212, against 10,001 for a uniform choice among the words of its vocabulary. But look at what its state knows about sentiment. The logistic regression on the last state gets 57.7% of the test reviews right: 3 points more than the same LSTM before training, and 30 points less than the bag of words of section 3. The state holds what the training asked for, what is needed to predict the next word, and for that, the last few words matter most. As Dai and Le put it, “language modeling is a short-term objective, so that the hidden state only captures the ability to predict the next few words” (Dai & Le, 2015). The fine-tuning tells the same story. With 1,000 labeled reviews, the embeddings of the language model are worth 5 points over the training from scratch. But adding its LSTM loses 2 of these points: for the classifier, the LSTM of the language model is a worse start than a random LSTM. Note also how far the three versions are from the 82.5% of the averaging network of exercise 5.5b, trained on the same number of labels: an LSTM needs many more labels to learn to read. A small language model transfers its embeddings, not its LSTM.
So is pre-training the encoder useless? No, but our language model is tiny. Dai and Le trained an LSTM of 1,024 units for “roughly 500K steps with a batch size of 128”, while ours has 128 units and saw 782 batches of 64 reviews. In 2017, Alec Radford, Rafal Jozefowicz and Ilya Sutskever, at OpenAI, showed what happens at a much larger scale (Radford et al., 2017). They trained an LSTM of 4,096 units to predict the next character of 82 million Amazon reviews, then trained a logistic regression on its last state, as in exercise 8.1. On IMDB, it reached 92.9%. While looking at the weights of the logistic regression, they found that most of the work was done by a single unit of the state: this unit alone, with a threshold, gives 92.3%. Nobody had told the network what sentiment is. It had learned to track it, character after character, probably because knowing whether a review is positive helps to predict its next characters. In their words: “It is possible that sentiment as a conditioning feature has strong predictive capability for language modelling. This is likely since sentiment is such an important component of a review.”
In 2018, Jeremy Howard and Sebastian Ruder turned the idea into a recipe that works on any classification task, ULMFiT (for universal language model fine-tuning) (Howard & Ruder, 2018). They start from the best LSTM language model of the time, the AWD-LSTM of Merity et al. (Merity et al., 2018): three layers of 1,150 units, regularized with several kinds of dropout, including dropout on the weights from one state to the next. It reaches a test perplexity of 65.8 on WikiText-2, against 155.8 for our LSTM of exercise 7.3. The recipe has three stages:
- pre-train the language model on general text: 103 million words of Wikipedia (WikiText-103, the big brother of WikiText-2);
- fine-tune it as a language model on the text of the task, here the reviews of IMDB, so that it adapts to the words and the style of reviews;
- add a head, and fine-tune the classifier on the labeled reviews.
The last stage needs care. A pre-trained language model fine-tuned too aggressively with a new head can lose what it learned during the pre-training, which is called catastrophic forgetting. So they unfreeze the network gradually, from the top: first the head alone, then the last layer of the LSTM, and so on down to the embeddings. Each layer also gets its own learning rate, smaller for the lower layers. With two classifiers, one that reads the reviews from left to right and one from right to left, ULMFiT reaches 95.4% on IMDB, and 94.7% with a single one.
In three years, from Dai and Le to ULMFiT, pre-training the encoder as a language model took the accuracy on IMDB from 92.4% to 95.4%. Before this section, the best result we had met was 91.2%, for NBSVM with the pairs of words (section 6).
8.2 Contextual embeddings
There is another way to use a pre-trained language model. Look again at the states of its LSTM. After each word, the state is a vector of fixed size that depends on the word and on the words before it. So it can be seen as a vector for the word , like an embedding, but one that changes with the sentence. Such vectors are called contextual embeddings.
They answer the last limit of section 6. In a static embedding like GloVe, “bank” has a single vector, whatever its sentence, and this vector is dominated by its most frequent meaning, the bank that holds money (exercise 6). In a contextual embedding, “bank” gets a new vector each time it appears, computed from the words around it.
The best known contextual embeddings of this period are those of ELMo (for embeddings from language models), by Matthew Peters and his colleagues at the Allen Institute for AI and the University of Washington (Peters et al., 2018). ELMo is a pair of LSTM language models, trained on about 800 million words of news. One reads the sentence from left to right and predicts the next word, the other reads it from right to left and predicts the previous word, so that the vector of a word depends on the whole sentence. Each has two layers of 4,096 units. They read the characters of the words, so that no word is unknown. The ELMo vector of a word is a weighted sum of the states of all the layers at this word, with weights learned for each task. The language models stay frozen: at the input of the model of each task, the ELMo vector of each word is concatenated with its usual static embedding. Added in this way to strong models of six tasks, from question answering to the sentiment of sentences, ELMo set a new state of the art on all six.
ELMo was not the first to do this. A few months earlier, Peters et al. had already given a tagger the last states of two pre-trained language models, one for each direction (Peters et al., 2017), and McCann et al. had taken the states of the encoder of a translation model, which they called CoVe (McCann et al., 2017). ELMo used all the layers of the language model instead of the last one, and showed that its vectors help on a wide range of tasks. As its authors write, “each token is assigned a representation that is a function of the entire input sentence.”
ELMo and ULMFiT are the two ways of using a pre-trained language model that transformers inherited. ELMo freezes the language model, and uses its states as features for another network trained for the task. ULMFiT fine-tunes the language model itself, and adds only a head. BERT, in section 10.2, is used in both ways, and we will compare the vectors that it gives to “bank” in sentences about rivers and about money.
If you want to dig deeper, below are a few bonus exercises.
A sentiment neuron? In the language model of exercise 8.1, look for the unit of the last state whose value alone best separates the positive reviews from the negative ones, with a threshold at its median. How far is it from the 92.3% of the sentiment neuron of Radford et al. (Radford et al., 2017)?
A bigger language model. Pre-train language models with H = 256 and H = 512, or for more epochs, and run the logistic regression of exercise 8.1 on their last states. Does the state learn more about sentiment as the perplexity goes down?
All the states. The head of ULMFiT reads the last state, but also the average and the maximum of all the states of the review (Howard & Ruder, 2018). Run the logistic regression of exercise 8.1 on the average of the states instead of the last one, for the language model and for the LSTM before training. What changes?
Gradual unfreezing. Fine-tune the pre-trained encoder of exercise 8.1 as ULMFiT does: first train the head alone, with the encoder frozen, then unfreeze the LSTM, then the embeddings, with a smaller learning rate for the lower layers. Does the LSTM of the language model help now?
All the labels. Fine-tune the three starts of exercise 8.1 on the 20,000 labeled reviews, for 6 epochs, as in exercise 7.2b. Does pre-training still matter with many labels?
Contextual vectors. With the language model of exercise 8.1, compute the state after the word “score” in ten sentences, five about the music of a film and five about a game, and the matrix of their cosine similarities. Do the two meanings separate? Keep in mind that this language model only reads the words before “score”.
9. From sequence to sequence: translation and the birth of attention
Let’s go back to the third task of section 1, translation. All the models of the post so far give a single probability distribution for a text: over the classes of a review, or over the next word. A translation is a whole sentence, which generally does not have the length of the source sentence, and whose words can come in another order, as the figure of section 1.3 shows. Until 2014, the best translation systems were statistical machine translation systems, built from many parts: tables that translate short pieces of sentences, learned from millions of pairs of sentences, and an n-gram language model that checks that the result reads like French or English (we come back to them in section 9.3). Then, in a few months, a handful of papers showed that two recurrent networks could do the whole job, and one of them introduced attention, the mechanism at the heart of transformers.
9.1 Encoder–decoder (seq2seq)
The idea is to use two RNNs. The first one, the encoder, reads the source sentence word by word, as the classifier of section 7.2 reads a review. Its last state is the fixed-size representation of the sentence. The second one, the decoder, is a language model, like the one of section 7.3, that writes the translation word by word, except that its state starts from instead of zero. So each word that it predicts depends on the words that it has already written, and on the source sentence through . The decoder learns the probability of section 1.3, with the source sentence replaced by its representation:
To start, the decoder reads a special start token, and it writes words until it produces a special end token. During the training, it reads the true previous words of the translation, as a language model reads its training text, and the loss is the cross-entropy of the next true word. This is called teacher forcing. At test time, there is no true translation to read: the decoder reads the words that it has written itself. The simplest way to choose them is to take, at each step, the word with the highest probability, which is called greedy decoding.
Optional Beam search: choosing the whole translation, not one word at a time
Greedy decoding chooses each word without looking ahead. But the most probable translation as a whole can start with a word that is not the most probable first word: a less probable word now can lead to much more probable words later. Finding the most probable translation exactly is impossible, since the number of possible sentences grows exponentially with their length. Beam search is a compromise: it keeps the B most probable beginnings of translations. At each step, it extends each of them with every word of the vocabulary, and keeps the B extensions with the highest probability, until they end with the end token. With B = 1, it is greedy decoding. Sutskever et al. used B = 12 (Sutskever et al., 2014): with their ensemble of 5 models, the BLEU score (section 9.3) goes from 33.0 with greedy decoding to 34.5 with B = 2, and 34.8 with B = 12.
Kyunghyun Cho and his colleagues in Montreal introduced this encoder–decoder in June 2014, in the paper where they also introduced the GRU of section 7.2 (Cho et al., 2014). But they only used it to score pairs of short phrases inside a statistical system. In September, Ilya Sutskever, Oriol Vinyals and Quoc Le, at Google, translated whole sentences from English to French with a very similar model, developed in parallel. Their encoder and their decoder were LSTMs with four layers of 1,000 units, trained for about ten days on 12 million pairs of sentences. An ensemble of five of them beat the statistical system of reference, with a BLEU score of 34.8 against 33.3 (BLEU, a score of translation between 0 and 100, is explained in section 9.3). In their words, “it is the first time that a pure neural translation system outperforms a phrase-based SMT baseline on a large scale MT task by a sizeable margin”. The title of their paper gave the architecture its usual name: sequence to sequence, or seq2seq.
Look at the figure again: the whole source sentence must go through , a vector of fixed size, whether the sentence has 5 words or 50. Cho et al. measured the consequence in a second paper: the translations of their encoder–decoder were good for short sentences, but their quality “degrades rapidly as the length of the sentence and the number of unknown words increase”. Their hypothesis: “the fixed-length vector representation does not have enough capacity to encode a long sentence with complicated structure and meaning”. The last state of the encoder is a bottleneck: everything the decoder knows about the source sentence must fit into it.
Optional A strange trick: reversing the source sentence improves translation
Sutskever et al. also found a strange trick: reverse the order of the words of the source sentence. To translate “I loved this great movie”, the encoder reads “movie great this loved I”. With this trick, the test perplexity of their model went from 5.8 to 4.7, and its BLEU score from 25.9 to 30.6. Their explanation: in the normal order, each word of the source is separated from its translation by about the length of the sentence. Reversing the source does not change the average distance between the words and their translations, but “the first few words in the source language are now very close to the first few words in the target language, so the problem’s minimal time lag is greatly reduced”. The network can start by learning these short dependencies, through a gradient that goes back a few steps only, which is easy, as section 7.2 showed. The trick is a symptom of the bottleneck: how well a piece of the source sentence reaches the decoder depends on the number of steps that separate it from the word that needs it. With this trick and their much larger networks, Sutskever et al. saw almost no drop of quality on long sentences, unlike Cho et al. A larger vector holds more, but its size is still fixed.
To see the bottleneck in action without a translation dataset, let’s use a toy task: reverse strings of digits. The “translation” of 3 9 1 4 is 4 1 9 3. We can generate as many pairs as we want, of any length, and we know the right answer, so we can measure exactly how the accuracy depends on the length. The order is reversed, as Turkish nearly reverses the order of English in the figure of section 1.3. And the reversal is the easy order of Sutskever et al.: the first digit to write is the last one read.
Exercise 9.1★ Reverse strings of digits with an encoder–decoder
Train a GRU encoder–decoder to reverse strings of digits. The encoder reads the digits, its last state is the first state of the decoder, and the decoder writes the digits in reverse order, one at a time (D = 32, H = 128). Train it on strings of 5 to 30 digits. Then, for each length, measure the fraction of strings that it reverses without a single mistake, and the fraction of digits that it gets right, and plot both against the length.
# The digits 0 to 9 are their own tokens. The decoder reads a start
# token, 10, before it writes the first digit.
START = 10
# A batch of n random strings of digits of the same length, and
# their "translations": the same digits in reverse order
import torch
def make_batch(n, length):
sources = torch.randint(0, 10, (n, length))
targets = sources.flip(1) # reverse the order of the columns
return sources, targets
torch.manual_seed(0)
sources, targets = make_batch(3, 8)
for source, target in zip(sources.tolist(), targets.tolist()):
source_text = "".join(str(d) for d in source)
target_text = "".join(str(d) for d in target)
print(f"{source_text} -> {target_text}")
49303973 -> 37930394 73166986 -> 68966137 68436914 -> 41963486
Hint
Write the model as a subclass of nn.Module with two embeddings, nn.Embedding(10, D) for the source and nn.Embedding(11, D) for the decoder, which also reads the start token; two GRUs, nn.GRU(D, H, batch_first=True); and nn.Linear(H, 10) for the scores of the next digit. A GRU returns the states after every step, and its last state, of shape (1, batch, H). Give the last state of the encoder as the second argument of the decoder, so that the decoder starts from it.
Hint
With teacher forcing, the decoder reads the start token followed by the target digits, except the last one: torch.cat([starts, targets[:, :-1]], dim=1), where starts is a column of start tokens. At each position, its scores are compared with the target digit, with F.cross_entropy on all the positions at once, as in exercise 7.3. Draw a new length for each batch with random.randint(5, 30), so that all the strings of a batch have the same length, and there is no padding.
Hint
Train with Adam (learning rate 0.001) on 20,000 batches of 64 strings, and clip the gradient at 1. It takes about two and a half minutes on a laptop CPU.
Hint
To decode greedily, start from a column of start tokens. At each step, give the model the source and the digits written so far, take the digit with the highest score at the last position (argmax), and append it. After L steps, correct = outputs == targets is a matrix of booleans, and correct.all(dim=1) tells which strings have no mistake.
Checkpoint what you should get
After 20,000 batches, the model reverses all the strings of 10 digits without a mistake, 91.7% of the strings of 15 digits, 51.8% of those of 20 digits, and none of those of 30 digits. The fraction of digits right falls more slowly, from 100% for 10 digits to 53.8% for 30 digits. The chart below shows both curves. The solution takes about three minutes on a laptop CPU. Your numbers can differ by a few points.
batches 1 to 5000: loss 0.709 batches 5001 to 10000: loss 0.293 batches 10001 to 15000: loss 0.182 batches 15001 to 20000: loss 0.119 10 digits: 100.0% of the strings, 100.0% of the digits 15 digits: 91.7% of the strings, 98.7% of the digits 20 digits: 51.8% of the strings, 91.3% of the digits 25 digits: 5.6% of the strings, 73.3% of the digits 30 digits: 0.0% of the strings, 53.8% of the digits target: 6525378564826761271358100 output: ......58........72.......

Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
# The digits 0 to 9 are their own tokens. The decoder reads a start
# token, 10, before it writes the first digit.
START = 10
# A batch of n random strings of digits of the same length, and
# their "translations": the same digits in reverse order
import torch
def make_batch(n, length):
sources = torch.randint(0, 10, (n, length))
targets = sources.flip(1) # reverse the order of the columns
return sources, targets
torch.manual_seed(0)
sources, targets = make_batch(3, 8)
for source, target in zip(sources.tolist(), targets.tolist()):
source_text = "".join(str(d) for d in source)
target_text = "".join(str(d) for d in target)
print(f"{source_text} -> {target_text}")
49303973 -> 37930394 73166986 -> 68966137 68436914 -> 41963486
# (1) The model. An encoder GRU reads the source digits, and its
# last state is the first state of a decoder GRU. The decoder reads
# the start token and the digits written so far, and gives the
# scores of the next digit at each position.
from torch import nn
import torch.nn.functional as F
class EncoderDecoder(nn.Module):
def __init__(self, D, H):
super().__init__()
self.source_embedding = nn.Embedding(10, D)
self.encoder = nn.GRU(D, H, batch_first=True)
self.target_embedding = nn.Embedding(11, D) # with START
self.decoder = nn.GRU(D, H, batch_first=True)
self.output = nn.Linear(H, 10)
def forward(self, sources, decoder_inputs):
e = self.source_embedding(sources) # (batch, L, D)
_, h = self.encoder(e) # last state: (1, batch, H)
d = self.target_embedding(decoder_inputs) # (batch, N, D)
states, _ = self.decoder(d, h) # (batch, N, H)
return self.output(states) # (batch, N, 10)
# (2) Train with teacher forcing: the decoder reads the true target
# digits, shifted by one position, after the start token. Each
# batch has its own length, between 5 and 30, so there is no
# padding. A function, since exercise 9.2 trains another model.
import random
def train(model, steps=20_000, batch_size=64):
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
total_loss = 0
for step in range(steps):
length = random.randint(5, 30)
sources, targets = make_batch(batch_size, length)
starts = torch.full((batch_size, 1), START)
decoder_inputs = torch.cat([starts, targets[:, :-1]], dim=1)
scores = model(sources, decoder_inputs)
loss = F.cross_entropy(scores.reshape(-1, 10),
targets.reshape(-1))
optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
total_loss += loss.item()
if (step + 1) % 5000 == 0: # the average of 5,000 batches
print(f"batches {step - 4998} to {step + 1}: "
f"loss {total_loss / 5000:.3f}")
total_loss = 0
torch.manual_seed(0)
random.seed(0)
model = EncoderDecoder(D=32, H=128)
train(model)
# (3) Greedy decoding: start from the start token, and write the
# digit with the highest score, one at a time. The model reads
# again all the digits written so far at each step: wasteful, but
# simple, and fast enough for 30 digits.
def translate(model, sources):
decoder_inputs = torch.full((len(sources), 1), START)
with torch.no_grad():
for _ in range(sources.shape[1]):
scores = model(sources, decoder_inputs) # (batch, N, 10)
next_digits = scores[:, -1].argmax(dim=1)
decoder_inputs = torch.cat(
[decoder_inputs, next_digits[:, None]], dim=1)
return decoder_inputs[:, 1:] # drop the start token
# (4) For each length, the fraction of strings without a mistake,
# and the fraction of digits right, on 1,000 new strings
lengths = list(range(5, 31))
def accuracy_by_length(model):
string_accuracy, digit_accuracy = [], []
for length in lengths:
sources, targets = make_batch(1000, length)
correct = translate(model, sources) == targets # (1000, L)
whole = correct.all(dim=1) # one value per string
string_accuracy.append(whole.float().mean().item())
digit_accuracy.append(correct.float().mean().item())
return string_accuracy, digit_accuracy
torch.manual_seed(1)
string_accuracy, digit_accuracy = accuracy_by_length(model)
print()
for length in [10, 15, 20, 25, 30]:
i = lengths.index(length)
print(f"{length} digits: "
f"{string_accuracy[i]:.1%} of the strings, "
f"{digit_accuracy[i]:.1%} of the digits")
# (5) One string of 25 digits, its reversal, and the output of the
# model, with a dot for each right digit
sources, targets = make_batch(1, 25)
output = translate(model, sources)
target_text = "".join(str(d) for d in targets[0].tolist())
output_text = "".join(str(o) if o != t else "."
for o, t in zip(output[0].tolist(),
targets[0].tolist()))
print(f"\ntarget: {target_text}\noutput: {output_text}")
# (6) Plot both accuracies against the length
import matplotlib.pyplot as plt
plt.figure(figsize=(5, 3.5))
plt.plot(lengths, string_accuracy, label="whole string right")
plt.plot(lengths, digit_accuracy, label="digits right")
plt.ylim(0, 1.05)
plt.xlabel("number of digits")
plt.ylabel("accuracy")
plt.legend()
plt.show()
batches 1 to 5000: loss 0.709 batches 5001 to 10000: loss 0.293 batches 10001 to 15000: loss 0.182 batches 15001 to 20000: loss 0.119 10 digits: 100.0% of the strings, 100.0% of the digits 15 digits: 91.7% of the strings, 98.7% of the digits 20 digits: 51.8% of the strings, 91.3% of the digits 25 digits: 5.6% of the strings, 73.3% of the digits 30 digits: 0.0% of the strings, 53.8% of the digits target: 6525378564826761271358100 output: ......58........72.......

The model has learned the task perfectly for short strings: it reverses all the strings of 10 digits without a mistake. Then the accuracy collapses: half of the strings of 20 digits have a mistake, and almost all those of 25 digits. Yet a string of 25 digits is no harder to reverse than a string of 10: the model only has to repeat what it read. The difference is that the last state of the encoder, 128 numbers, must hold all the digits, and the more digits there are, the less precisely each of them is kept. Look at the example of 25 digits. The first digits written, which are the last ones read, are right, as in the reversed sentences of Sutskever et al. The mistakes come later, and they are not random: twice, the model swaps two neighboring digits, writing “58” instead of “85”. The state remembers which digits are there, but not exactly where. With more training, the curves move to the right: after 10,000 batches instead of 20,000, only 28% of the strings of 20 digits were right. But they still fall with the length. The last state of the encoder holds a fixed amount of information, so the longer the source, the more of it is lost.
9.2 Attention
In September 2014, a few days before the paper of Sutskever et al., Dzmitry Bahdanau, Kyunghyun Cho and Yoshua Bengio proposed to remove the bottleneck (Bahdanau et al., 2014). Their idea is to keep all the states of the encoder. As we saw in section 7.1, the encoder gives one state per word of the source,
and the decoder can read all of them, instead of the last one only:
But the decoder needs an input of fixed size, while the number of states grows with the source sentence. The solution is a weighted average of the states, the context, recomputed for each word that the decoder writes:
The weights say which words of the source matter to write the next word. To compute them, the decoder compares its own state with each state of the encoder, which gives a score , and a softmax turns the scores into weights between 0 and 1 that add up to 1:
The simplest score is the dot product, , which is large for the states of the encoder that point in the same direction as the state of the decoder. The head then reads both the state of the decoder and the context, to give the probabilities of the next word. Bahdanau et al. computed the score with a small neural network, from the previous state of the decoder, and also gave the context to the RNN of the decoder. A year later, Luong, Pham and Manning compared several variants, and found that the simpler dot product works well (Luong et al., 2015): this is the version of the exercise below.
Nobody tells the network which words of the source go with which words of the translation. The weights are computed by the network, and trained by the loss of the translation, through backpropagation, like everything else. This mechanism was called attention: to write each word, the decoder decides which words of the source to look at. With attention, there is no more single box between the encoder and the decoder: each word of the translation can read the states of all the words of the source, wherever they are. Hover over the words of the translation in the figure below to see the weights of each step.
In their paper, Bahdanau et al. compared two models with 1,000 units, trained on the same pairs of sentences: an encoder–decoder (RNNencdec) and a model with attention (RNNsearch). Trained on sentences of up to 50 words, the encoder–decoder reached a BLEU score of 17.8, and the model with attention 26.8, and 28.5 when trained longer. The difference comes from the long sentences: “the performance of RNNencdec dramatically drops as the length of the sentences increases”, while the model with attention “shows no performance deterioration even with sentences of length 50 or more”. Their encoder was a bidirectional RNN (section 7.1), so that the state of each word also holds the words that follow it: in their words, “the annotation contains the summaries of both the preceding words and the following words”.
The weights of the attention can also be read. For a pair of sentences, they form a matrix, with one row per word of the translation and one column per word of the source: it is the alignment of the figure of section 1.3, learned without a single example of alignment. Translating “The agreement on the European Economic Area was signed in August 1992.” into “L’accord sur la zone économique européenne a été signé en août 1992.”, the attention follows the words in order, except for the adjectives, which French puts after the noun: the model “was able to correctly align [zone] with [Area], jumping over the two words ([European] and [Economic]), and then looked one word back at a time to complete the whole phrase [zone économique européenne]”.
Let’s add attention to the model of exercise 9.1. For the reversal, the right alignment is known: to write the k-th digit, the decoder copies the k-th digit from the end of the source, so this is where it should look.
Exercise 9.2★ Add attention to the encoder–decoder
Add attention to the encoder–decoder of exercise 9.1. At each position, compute the dot products of the decoder state with all the states of the encoder, turn them into weights with a softmax, and give the output layer the weighted average of the encoder states (the context) next to the decoder state. Train it as in exercise 9.1, and plot its accuracies against the length, on the same chart as the model without attention. Then draw the weights of the attention for a string of 20 digits, as a matrix with one row per digit written and one column per digit read. Which digit of the source does the decoder look at to write each digit?
The starter code repeats the starter and steps (1) to (4) of the solution of exercise 9.1, so it trains the model without attention again, in about three minutes on a laptop CPU. It gives the class EncoderDecoder, and the functions train, translate and accuracy_by_length, which you can reuse for the model with attention.
# The digits 0 to 9 are their own tokens. The decoder reads a start
# token, 10, before it writes the first digit.
# [Copied from the starter of 9.1]
START = 10
# A batch of n random strings of digits of the same length, and
# their "translations": the same digits in reverse order
# [Copied from the starter of 9.1]
import torch
def make_batch(n, length):
sources = torch.randint(0, 10, (n, length))
targets = sources.flip(1) # reverse the order of the columns
return sources, targets
torch.manual_seed(0)
sources, targets = make_batch(3, 8)
for source, target in zip(sources.tolist(), targets.tolist()):
source_text = "".join(str(d) for d in source)
target_text = "".join(str(d) for d in target)
print(f"{source_text} -> {target_text}")
# (1) The model. An encoder GRU reads the source digits, and its
# last state is the first state of a decoder GRU. The decoder reads
# the start token and the digits written so far, and gives the
# scores of the next digit at each position.
# [Copied from the solution of 9.1, step (1)]
from torch import nn
import torch.nn.functional as F
class EncoderDecoder(nn.Module):
def __init__(self, D, H):
super().__init__()
self.source_embedding = nn.Embedding(10, D)
self.encoder = nn.GRU(D, H, batch_first=True)
self.target_embedding = nn.Embedding(11, D) # with START
self.decoder = nn.GRU(D, H, batch_first=True)
self.output = nn.Linear(H, 10)
def forward(self, sources, decoder_inputs):
e = self.source_embedding(sources) # (batch, L, D)
_, h = self.encoder(e) # last state: (1, batch, H)
d = self.target_embedding(decoder_inputs) # (batch, N, D)
states, _ = self.decoder(d, h) # (batch, N, H)
return self.output(states) # (batch, N, 10)
# (2) Train with teacher forcing: the decoder reads the true target
# digits, shifted by one position, after the start token. Each
# batch has its own length, between 5 and 30, so there is no
# padding. A function, since exercise 9.2 trains another model.
# [Copied from the solution of 9.1, step (2)]
import random
def train(model, steps=20_000, batch_size=64):
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
total_loss = 0
for step in range(steps):
length = random.randint(5, 30)
sources, targets = make_batch(batch_size, length)
starts = torch.full((batch_size, 1), START)
decoder_inputs = torch.cat([starts, targets[:, :-1]], dim=1)
scores = model(sources, decoder_inputs)
loss = F.cross_entropy(scores.reshape(-1, 10),
targets.reshape(-1))
optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
total_loss += loss.item()
if (step + 1) % 5000 == 0: # the average of 5,000 batches
print(f"batches {step - 4998} to {step + 1}: "
f"loss {total_loss / 5000:.3f}")
total_loss = 0
torch.manual_seed(0)
random.seed(0)
model = EncoderDecoder(D=32, H=128)
train(model)
# (3) Greedy decoding: start from the start token, and write the
# digit with the highest score, one at a time. The model reads
# again all the digits written so far at each step: wasteful, but
# simple, and fast enough for 30 digits.
# [Copied from the solution of 9.1, step (3)]
def translate(model, sources):
decoder_inputs = torch.full((len(sources), 1), START)
with torch.no_grad():
for _ in range(sources.shape[1]):
scores = model(sources, decoder_inputs) # (batch, N, 10)
next_digits = scores[:, -1].argmax(dim=1)
decoder_inputs = torch.cat(
[decoder_inputs, next_digits[:, None]], dim=1)
return decoder_inputs[:, 1:] # drop the start token
# (4) For each length, the fraction of strings without a mistake,
# and the fraction of digits right, on 1,000 new strings
# [Copied from the solution of 9.1, step (4)]
lengths = list(range(5, 31))
def accuracy_by_length(model):
string_accuracy, digit_accuracy = [], []
for length in lengths:
sources, targets = make_batch(1000, length)
correct = translate(model, sources) == targets # (1000, L)
whole = correct.all(dim=1) # one value per string
string_accuracy.append(whole.float().mean().item())
digit_accuracy.append(correct.float().mean().item())
return string_accuracy, digit_accuracy
torch.manual_seed(1)
string_accuracy, digit_accuracy = accuracy_by_length(model)
print()
for length in [10, 15, 20, 25, 30]:
i = lengths.index(length)
print(f"{length} digits: "
f"{string_accuracy[i]:.1%} of the strings, "
f"{digit_accuracy[i]:.1%} of the digits")
49303973 -> 37930394 73166986 -> 68966137 68436914 -> 41963486 batches 1 to 5000: loss 0.709 batches 5001 to 10000: loss 0.293 batches 10001 to 15000: loss 0.182 batches 15001 to 20000: loss 0.119 10 digits: 100.0% of the strings, 100.0% of the digits 15 digits: 91.7% of the strings, 98.7% of the digits 20 digits: 51.8% of the strings, 91.3% of the digits 25 digits: 5.6% of the strings, 73.3% of the digits 30 digits: 0.0% of the strings, 53.8% of the digits
Hint
The encoder GRU returns its states after every digit, of shape (batch, L, H), and the decoder GRU its states, of shape (batch, N, H). states @ encoder_states.transpose(1, 2) computes all the dot products at once, a tensor of shape (batch, N, L): one row of L scores for each position of the decoder. torch.softmax(scores, dim=2) turns each row into weights that add up to 1, and weights @ encoder_states gives the contexts, of shape (batch, N, H).
Hint
Write the new model as a subclass of EncoderDecoder: in __init__, replace the output layer by nn.Linear(2 * H, 10), and in forward, give it torch.cat([states, contexts], dim=2). The functions train, translate and accuracy_by_length of exercise 9.1 then work unchanged.
Hint
To draw the weights, keep them in an attribute of the model in forward (self.weights = weights). After translate, the last call to the model has read the start token and all the digits written but the last one, so its weights have one row for each digit written. plt.imshow(weights) draws the matrix, and a cross on the digit to copy in each row, with plt.scatter, shows whether the largest weight is on it.
Checkpoint what you should get
With attention, the model reverses 99.9% of the strings of 10 digits, 93.8% of those of 20 digits, and 82.1% of those of 30 digits, with 95.3% of their digits right. On the chart, the dashed curves of the model without attention fall far below. In the matrix of the weights, each row has one dark cell, and these cells form an anti-diagonal. But the crosses show that, except in the first row, the dark cell is one column to the right of the digit to copy: 19 of the 20 rows peak on the digit just after it. The solution takes about six minutes on a laptop CPU, since its starter trains the model of exercise 9.1 again. Your numbers can differ by a few points.
batches 1 to 5000: loss 0.113 batches 5001 to 10000: loss 0.011 batches 10001 to 15000: loss 0.007 batches 15001 to 20000: loss 0.004 10 digits: 99.9% of the strings, 100.0% of the digits 15 digits: 99.2% of the strings, 99.7% of the digits 20 digits: 93.8% of the strings, 98.2% of the digits 25 digits: 79.2% of the strings, 93.8% of the digits 30 digits: 82.1% of the strings, 95.3% of the digits

rows that peak on the digit to copy: 1 of 20 rows that peak on the digit just after it: 19 of 20

Solution 3 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
# The digits 0 to 9 are their own tokens. The decoder reads a start
# token, 10, before it writes the first digit.
# [Copied from the starter of 9.1]
START = 10
# A batch of n random strings of digits of the same length, and
# their "translations": the same digits in reverse order
# [Copied from the starter of 9.1]
import torch
def make_batch(n, length):
sources = torch.randint(0, 10, (n, length))
targets = sources.flip(1) # reverse the order of the columns
return sources, targets
torch.manual_seed(0)
sources, targets = make_batch(3, 8)
for source, target in zip(sources.tolist(), targets.tolist()):
source_text = "".join(str(d) for d in source)
target_text = "".join(str(d) for d in target)
print(f"{source_text} -> {target_text}")
# (1) The model. An encoder GRU reads the source digits, and its
# last state is the first state of a decoder GRU. The decoder reads
# the start token and the digits written so far, and gives the
# scores of the next digit at each position.
# [Copied from the solution of 9.1, step (1)]
from torch import nn
import torch.nn.functional as F
class EncoderDecoder(nn.Module):
def __init__(self, D, H):
super().__init__()
self.source_embedding = nn.Embedding(10, D)
self.encoder = nn.GRU(D, H, batch_first=True)
self.target_embedding = nn.Embedding(11, D) # with START
self.decoder = nn.GRU(D, H, batch_first=True)
self.output = nn.Linear(H, 10)
def forward(self, sources, decoder_inputs):
e = self.source_embedding(sources) # (batch, L, D)
_, h = self.encoder(e) # last state: (1, batch, H)
d = self.target_embedding(decoder_inputs) # (batch, N, D)
states, _ = self.decoder(d, h) # (batch, N, H)
return self.output(states) # (batch, N, 10)
# (2) Train with teacher forcing: the decoder reads the true target
# digits, shifted by one position, after the start token. Each
# batch has its own length, between 5 and 30, so there is no
# padding. A function, since exercise 9.2 trains another model.
# [Copied from the solution of 9.1, step (2)]
import random
def train(model, steps=20_000, batch_size=64):
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
total_loss = 0
for step in range(steps):
length = random.randint(5, 30)
sources, targets = make_batch(batch_size, length)
starts = torch.full((batch_size, 1), START)
decoder_inputs = torch.cat([starts, targets[:, :-1]], dim=1)
scores = model(sources, decoder_inputs)
loss = F.cross_entropy(scores.reshape(-1, 10),
targets.reshape(-1))
optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
total_loss += loss.item()
if (step + 1) % 5000 == 0: # the average of 5,000 batches
print(f"batches {step - 4998} to {step + 1}: "
f"loss {total_loss / 5000:.3f}")
total_loss = 0
torch.manual_seed(0)
random.seed(0)
model = EncoderDecoder(D=32, H=128)
train(model)
# (3) Greedy decoding: start from the start token, and write the
# digit with the highest score, one at a time. The model reads
# again all the digits written so far at each step: wasteful, but
# simple, and fast enough for 30 digits.
# [Copied from the solution of 9.1, step (3)]
def translate(model, sources):
decoder_inputs = torch.full((len(sources), 1), START)
with torch.no_grad():
for _ in range(sources.shape[1]):
scores = model(sources, decoder_inputs) # (batch, N, 10)
next_digits = scores[:, -1].argmax(dim=1)
decoder_inputs = torch.cat(
[decoder_inputs, next_digits[:, None]], dim=1)
return decoder_inputs[:, 1:] # drop the start token
# (4) For each length, the fraction of strings without a mistake,
# and the fraction of digits right, on 1,000 new strings
# [Copied from the solution of 9.1, step (4)]
lengths = list(range(5, 31))
def accuracy_by_length(model):
string_accuracy, digit_accuracy = [], []
for length in lengths:
sources, targets = make_batch(1000, length)
correct = translate(model, sources) == targets # (1000, L)
whole = correct.all(dim=1) # one value per string
string_accuracy.append(whole.float().mean().item())
digit_accuracy.append(correct.float().mean().item())
return string_accuracy, digit_accuracy
torch.manual_seed(1)
string_accuracy, digit_accuracy = accuracy_by_length(model)
print()
for length in [10, 15, 20, 25, 30]:
i = lengths.index(length)
print(f"{length} digits: "
f"{string_accuracy[i]:.1%} of the strings, "
f"{digit_accuracy[i]:.1%} of the digits")
49303973 -> 37930394 73166986 -> 68966137 68436914 -> 41963486 batches 1 to 5000: loss 0.709 batches 5001 to 10000: loss 0.293 batches 10001 to 15000: loss 0.182 batches 15001 to 20000: loss 0.119 10 digits: 100.0% of the strings, 100.0% of the digits 15 digits: 91.7% of the strings, 98.7% of the digits 20 digits: 51.8% of the strings, 91.3% of the digits 25 digits: 5.6% of the strings, 73.3% of the digits 30 digits: 0.0% of the strings, 53.8% of the digits
# (1) The model of exercise 9.1 with attention. Its output layer
# reads the state of the decoder and a context: the average of all
# the states of the encoder, weighted by a softmax of their dot
# products with the state of the decoder. Only the output layer and
# forward() change.
class AttentionEncoderDecoder(EncoderDecoder):
def __init__(self, D, H):
super().__init__(D, H)
self.output = nn.Linear(2 * H, 10) # state and context
def forward(self, sources, decoder_inputs):
e = self.source_embedding(sources) # (batch, L, D)
encoder_states, h = self.encoder(e) # (batch, L, H)
d = self.target_embedding(decoder_inputs) # (batch, N, D)
states, _ = self.decoder(d, h) # (batch, N, H)
# Each decoder state against each encoder state: (batch, N, L)
scores = states @ encoder_states.transpose(1, 2)
weights = torch.softmax(scores, dim=2) # each row sums to 1
contexts = weights @ encoder_states # (batch, N, H)
self.weights = weights # kept to draw them in step (4)
both = torch.cat([states, contexts], dim=2) # (batch, N, 2H)
return self.output(both) # (batch, N, 10)
# (2) Train it exactly as the model without attention
torch.manual_seed(0)
random.seed(0)
attention_model = AttentionEncoderDecoder(D=32, H=128)
train(attention_model)
# (3) Its accuracies, on the same chart as the model without
# attention (dashed)
torch.manual_seed(1)
accuracies = accuracy_by_length(attention_model)
attention_string_accuracy, attention_digit_accuracy = accuracies
print()
for length in [10, 15, 20, 25, 30]:
i = lengths.index(length)
print(f"{length} digits: "
f"{attention_string_accuracy[i]:.1%} of the strings, "
f"{attention_digit_accuracy[i]:.1%} of the digits")
import matplotlib.pyplot as plt
plt.figure(figsize=(5, 3.5))
plt.plot(lengths, attention_string_accuracy, color="C0",
label="whole string, attention")
plt.plot(lengths, attention_digit_accuracy, color="C1",
label="digits, attention")
plt.plot(lengths, string_accuracy, color="C0", linestyle="--",
label="whole string, no attention")
plt.plot(lengths, digit_accuracy, color="C1", linestyle="--",
label="digits, no attention")
plt.ylim(0, 1.05)
plt.xlabel("number of digits")
plt.ylabel("accuracy")
plt.legend()
plt.show()
batches 1 to 5000: loss 0.113 batches 5001 to 10000: loss 0.011 batches 10001 to 15000: loss 0.007 batches 15001 to 20000: loss 0.004 10 digits: 99.9% of the strings, 100.0% of the digits 15 digits: 99.2% of the strings, 99.7% of the digits 20 digits: 93.8% of the strings, 98.2% of the digits 25 digits: 79.2% of the strings, 93.8% of the digits 30 digits: 82.1% of the strings, 95.3% of the digits

# (4) The weights of the attention for a string of 20 digits. The
# last call to the model in translate() reads the 19 digits written
# before the last one, so it keeps the weights of all 20 positions:
# one row per digit written, one column per digit read.
torch.manual_seed(2)
sources, targets = make_batch(1, 20)
output = translate(attention_model, sources)
weights = attention_model.weights[0] # (20, 20)
# The k-th digit written copies the k-th digit from the end of the
# source: in row k, the digit to copy is in column 19 - k
to_copy = torch.arange(19, -1, -1)
peaks = weights.argmax(dim=1) # the column of the largest weight
print(f"rows that peak on the digit to copy: "
f"{(peaks == to_copy).sum().item()} of 20")
print(f"rows that peak on the digit just after it: "
f"{(peaks == to_copy + 1).sum().item()} of 20")
# Draw the matrix, with a cross on the digit to copy in each row
plt.figure(figsize=(4.5, 4.5))
plt.imshow(weights, cmap="Blues")
plt.scatter(to_copy, range(20), marker="x", color="C1", s=15)
plt.xticks(range(20), sources[0].tolist())
plt.yticks(range(20), output[0].tolist())
plt.xlabel("digit read by the encoder")
plt.ylabel("digit written by the decoder")
plt.show()
rows that peak on the digit to copy: 1 of 20 rows that peak on the digit just after it: 19 of 20

With the same number of units and the same training, attention changes everything: for strings of 30 digits, the model gets 82% of the strings right instead of none, and 95% of the digits instead of 54%. It no longer has to squeeze the source into one vector: to write each digit, it reads the states of the encoder directly. The matrix of the weights shows how. Each row has a single dark cell, so to write each digit, the decoder looks at a single place of the source, and these places form the anti-diagonal of a reversal, an alignment that nobody gave to the model. Look closely, though: the decoder does not look at the state of the encoder after the digit that it copies, but at the next one, after the following digit of the source. A GRU remembers the digits before the last one, so this state holds the right digit too. And it is probably easier to find, since it has just read the same digit as the decoder, the digit written at the previous step. The weights of the attention point at states, not at words, and the state of an RNN holds more than its own word. With attention, each digit of the output reads the source directly, and the accuracy depends much less on the length.
In these models, attention helps the RNNs: the encoder still reads the source one word after the other, and the decoder still writes the translation one word after the other. Three years later, transformers kept attention and dropped the RNNs: this is the topic of section 10.
9.3 Measuring translation
How good is a translation? For our digits, there is a single right answer, so we could count the mistakes. A sentence has many good translations, and the best judges are humans who read them. But, as Kishore Papineni and his colleagues at IBM wrote in 2002, “Human evaluations can take months to finish and involve human labor that can not be reused”. They proposed an automatic score, BLEU (for bilingual evaluation understudy), based on one idea: “The closer a machine translation is to a professional human translation, the better it is” (Papineni et al., 2002). BLEU compares the candidate translation with one or several translations written by humans, the references, and counts the n-grams of the candidate that also appear in a reference, for n from 1 to 4.
Let’s translate the French sentence of the figure back to English. With the reference “I loved this great movie”, the candidate “I loved this great film” gets these precisions: the fraction of its n-grams that appear in the reference.
| n | n-grams of the candidate | in the reference | precision |
|---|---|---|---|
| 1 | I, loved, this, great, film | 4 of 5 | 0.80 |
| 2 | I loved, loved this, this great, great film | 3 of 4 | 0.75 |
| 3 | I loved this, loved this great, this great film | 2 of 3 | 0.67 |
| 4 | I loved this great, loved this great film | 1 of 2 | 0.50 |
BLEU is the geometric mean of the four precisions, , usually written as a percentage: 66.9. Two corrections make the score harder to cheat. First, an n-gram of the candidate is counted at most as many times as it appears in a reference: otherwise, “the the the the the the the” would have a precision of 7/7 against “The cat is on the mat.”, instead of 2/7. Second, a short candidate like “I loved” has perfect precisions, so BLEU multiplies the mean by a brevity penalty, smaller than 1 when the candidate is shorter than the reference:
where is the precision of the n-grams, the number of words of the candidate, and the number of words of the reference.
Our example also shows the limit of BLEU: it counts words, not meanings. “film” is a perfect translation of “film”, yet it costs the candidate a third of its score, because the reference says “movie”. This is why test sets often have several references for each sentence, and why BLEU is computed on a whole test set: the n-grams of all the sentences are counted together before the precisions are computed. On a single sentence, the authors warn, “scores on individual sentences will often vary from human judgments”. On a whole test set, the BLEU scores of five translators, two humans and three machine translation systems, followed the ratings of human judges closely, with a correlation of 0.99. BLEU became the standard score of translation for the next twenty years.
The phrase-based statistical systems were the reference of the first neural translation papers. They come from a line of work started at IBM at the end of the 1980s. To translate a French sentence into English, Brown et al. look for the English sentence that maximizes : a language model , such as the n-gram models of section 2, and a translation model , learned from pairs of sentences through the alignment of their words. Koehn, Och and Marcu later replaced the words by phrases, which are simply sequences of consecutive words, with no grammatical meaning, extracted from the alignments of the words with their probabilities (Koehn et al., 2003). Surprisingly, “limiting the length to a maximum of only three words per phrase already achieves top performance”, and keeping only the phrases that make sense grammatically made the translations worse. A phrase-based system combines a table of millions of phrases, an n-gram language model, a model of the reordering of the phrases and a few other scores, with weights tuned to maximize BLEU. The open-source toolkit Moses made these systems the standard.
On the task of the first neural papers, the translation of English into French of WMT’14 (a yearly competition of machine translation), Moses gets a BLEU score of 33.3, and the best system of the competition, also phrase-based, 37.0 when scored in the same way. The ensemble of Sutskever et al. reached 34.8 on its own, and 36.5 when it was used to re-rank the 1,000 best translations of Moses. The model of Bahdanau et al. scored 28.5 on all the sentences, but 36.2 against 35.6 for Moses on the sentences without unknown words: with a vocabulary of 30,000 words, rare words were the weak point of neural translation, until subwords (the box of section 7.3) (Sennrich et al., 2016). Two years later, Google’s neural system had 8 layers of LSTM in its encoder and in its decoder, attention and subwords, and an ensemble of 8 such models reached 41.2. In September 2016, it replaced Google’s phrase-based system for the translation of Chinese into English.
If you want to dig deeper, below are a few bonus exercises.
Copy instead of reverse. Train the encoder–decoder of exercise 9.1 to copy the strings of digits, in the same order. Is it easier or harder than the reversal? Explain the difference with the trick of Sutskever et al.
Longer strings. Test the models of exercises 9.1 and 9.2 on strings of 31 to 50 digits, longer than all their training strings. Does attention still help? Look at the weights of the attention after the 30th digit.
Sort the digits. Train the model of exercise 9.2 to sort the digits instead of reversing them. What do the weights of the attention look like?
Add two numbers. Train the model of exercise 9.2 to add two numbers written as strings of digits, like “4817+305” → “5122”. Writing the digits of the sum from the last one, as we do by hand, may help. Where does the attention look?
The score of Bahdanau et al. Replace the dot product of exercise 9.2 by the small network of Bahdanau et al., , with and of size H × H and of size H (Bahdanau et al., 2014). Does it learn faster?
A real translation. Train the encoder–decoder with attention to translate English into French, on the pairs of sentences of sentence-transformers/parallel-sentences-tatoeba (config en-fr, 273,270 short pairs, a median of 7 English words), and measure its BLEU score with sacrebleu. Other options: bentrevett/multi30k (English and German only), or Helsinki-NLP/opus_books (config en-fr, longer sentences from books).
10. Epilogue: how transformers inherit the story
At the end of section 9, attention was helping two RNNs: the encoder still read the source one word after the other, and the decoder still wrote the translation one word after the other. But in June 2017, a team at Google removed the RNNs, and kept only attention. Their architecture, the transformer, is the basis of all the large language models of today.
Although we will not study it in detail here, I want to highlight something: before 2019 (and GPT-2), transformers were not meant to be used to create LLMs and chatbots as we know them today, but just as a means to solve the classical NLP tasks of classification and translation! Let’s see how this story started.
10.1 Transformers instead of RNNs
In RNNs, recurrence has two costs. The first is gradient flow. As we saw in exercise 7.2a, between two distant words the information and the gradient go through every step in between, and even in an LSTM, the gradient that reaches a word shrinks with its distance to the end of the text. The second cost is the time it takes. To compute the state after word , an RNN needs the state after word , so the L states of a text are computed one after the other.
A GPU is fast because it does thousands of operations at the same time, but within a given piece of text, an RNN cannot give it many operations to do in parallel. In the words of Vaswani et al., “This inherently sequential nature precludes parallelization within training examples, which becomes critical at longer sequence lengths” (Vaswani et al., 2017).
The idea of the transformer is to replace the recurrence by attention inside the text itself. In section 9.2, the decoder compared its state with the states of all the words of the source. In self-attention, every word of a text does the same with all the words of the same text, itself included.
Each word gives a score to every word of the text, a dot product as in section 9.2 (computed after learned linear transformations of the vectors, which we skip here). A softmax turns the scores into weights, and the new vector of the word is the weighted average of the vectors of all the words. This removes the two costs of recurrence. Any two words are linked in a single step, whatever the distance between them, so the information and the gradient no longer go through all the words in between. And the new vectors of all the words are computed at the same time, with a few products of matrices, which is what a GPU does best. A transformer stacks several such layers: each one computes new vectors from the vectors of the layer below.
A weighted average does not depend on the order of the words, so self-attention alone would read a text as a bag of words, like the averages of section 4.3. This is why the input vector of each word is its embedding plus a vector that encodes its position in the text.
The transformer of Vaswani et al. is an encoder–decoder, like the models of section 9, applied to a translation task. You probably know the picture of its architecture from the paper, shown below, and it might look overcomplicated compared to what we do today. Indeed we tend to forget it today, as “transformer model” is almost synonymous with “autoregressive LLM”, but the task for which it was invented was translation!
Its encoder, on the left of the figure, is a stack of layers of self-attention that read the source sentence. Its decoder, on the right, attends to the vectors of the encoder, as the decoder of section 9.2 did, and it also uses self-attention on the translation written so far. During the training, the decoder computes its predictions for all the positions of the translation at once, so its self-attention needs a mask (the “Masked Multi-Head Attention” of the figure): each position may attend only to itself and to the positions before it, and not to the words that it must predict. In the words of the paper, the mask is there “to prevent positions from attending to subsequent positions”.
10.2 BERT
The transformer was made for translation, but if we focus only on its encoder part, it does exactly what we were looking for in section 8: it is a network that reads a whole text and gives each word a vector that depends on all the other words (and that trains much faster than an LSTM). In October 2018, Jacob Devlin and his colleagues at Google pre-trained a transformer encoder on raw text, and fine-tuned it for each task, as ULMFiT did with an LSTM (Devlin et al., 2019). They called it BERT, for bidirectional encoder representations from transformers. Once again, they were not trying to create an autoregressive LLM able to generate text; they were simply looking for a better encoder to then perform the usual downstream NLP tasks.
But how do we pre-train the encoder? In other words, which task can train an encoder on raw text? A language model predicts the next word from the words before it, so it ignores the words after. This is a waste for an encoder, whose job is to read the whole text: in their words, “The major limitation is that standard language models are unidirectional”. ELMo got around it with two language models, one for each direction (section 8.2), but each of them reads only one side of the word. And a transformer encoder cannot be trained as a language model at all: every word attends to every word, so the word to predict would be in its input.
BERT hides words instead. In each text, 15% of the tokens are chosen at random and replaced by a special token, [MASK], and BERT must predict them from all the other tokens, on both sides. (To avoid a gap with the fine-tuning, where nothing is masked, a chosen token is replaced by [MASK] 80% of the time, by a random token 10% of the time, and left as it is 10% of the time.) This masked language model was “inspired by the Cloze task”, a test of readability from 1953, where readers fill in the missing words of a text. It is also the task of CBOW in section 5.2, predicting a word from the words around it, except that the context is now the whole text, and the model a deep transformer instead of an average of embeddings.
BERT was pre-trained on 3.3 billion words: BooksCorpus, 800 million words of unpublished books, and the English Wikipedia, 2,500 million words. Its vocabulary has 30,000 WordPieces, subwords like those of the box of section 7.3, so no word is unknown. It comes in two sizes. BERT base has 12 layers, vectors of 768 and 12 heads, 110 million parameters. BERT large has 24 layers, vectors of 1,024 and 16 heads, 340 million parameters. Each pre-training took 4 days, on Google’s own processors, the TPUs.
Now with a simple encoder, we associate a latent vector to every word in the text. If we want to do a classification task, this is not what we need, we need a fixed length representation for the whole text. How does BERT turn a text of any length into a vector of fixed size? With a trick that brings back the box of section 3: a special token, [CLS] (for classification), is added at the start of every text, and BERT computes a new vector for it, as for every other token:
[CLS] is not a word of the text, so its vector has nothing of its own to represent: through the attention of its 12 layers, it collects what it needs from all the words. A second pre-training task trains it: BERT reads two sentences, and must tell from the vector of [CLS] whether the second one follows the first one in the text, or was drawn at random. In the words of the paper, “The final hidden state corresponding to this token is used as the aggregate sequence representation for classification tasks.” The vector of [CLS] is the fixed-size representation, and the head of a classifier reads it.
To fine-tune BERT for a classification task, add a head that reads , and train the whole network on the labeled examples, for 2 to 4 epochs, with a small learning rate: the authors recommend values between 2e-5 and 5e-5. This is the recipe of ULMFiT, without its gradual unfreezing. BERT set a new state of the art on eleven tasks. On GLUE, a benchmark of nine tasks of sentence understanding, the score went from 72.8, for GPT (section 10.3), to 80.5 for BERT large. BERT can also be used as ELMo was: frozen, with its vectors given as features to another network. On a task of named entity recognition, the concatenation of the vectors of its last four layers was “only 0.3 F1 behind fine-tuning the entire model”.
The other vectors, , are contextual embeddings, like those of ELMo in section 8.2, except that each of them depends on all the words of the text, on both sides, at every layer. Let’s look at “bank” again.
Exercise 10.2a Compare the vectors of “bank” in BERT
Take the ten sentences of the starter: in the first five, “bank” is the side of a river, and in the last five, it holds money. In GloVe, “bank” has the same vector in all of them. Compute the vector of “bank” in each sentence at the input of BERT and after its last layer, and the cosine similarities between the ten vectors, at the input and after the last layer. Draw the two matrices of similarities. Then compute the average similarity between two sentences with the same meaning, and between two sentences with different meanings.
from transformers import AutoTokenizer, AutoModel
# BERT base, trained on lower-case text: 12 layers, vectors of 768
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
model = AutoModel.from_pretrained("bert-base-uncased")
# Ten sentences with "bank": the side of a river in the first five,
# the bank that holds money in the last five. In some of them, only
# the words after "bank" tell its meaning.
sentences = [
"We had a picnic on the bank of the river.",
"The boat drifted slowly toward the muddy bank.",
"Fishermen sat along the bank, waiting for a bite.",
"After the flood, the river overflowed its bank.",
"The bank was steep and covered with reeds.",
"I opened an account at the bank.",
"The bank raised its interest rates again.",
"She went to the bank to deposit her salary.",
"The robbers broke into the bank at night.",
"The bank refused to give him a loan.",
]
meanings = ["river"] * 5 + ["money"] * 5
# The tokenizer cuts a sentence into tokens, and adds [CLS] at the
# start and [SEP] at the end
encoding = tokenizer(sentences[0])
print(tokenizer.convert_ids_to_tokens(encoding["input_ids"]))
['[CLS]', 'we', 'had', 'a', 'picnic', 'on', 'the', 'bank', 'of', 'the', 'river', '.', '[SEP]']
Hint
model(**encoding, output_hidden_states=True) returns the vectors of every layer in hidden_states: a tuple of 13 tensors of shape (1, number of tokens, 768). The first one is the input of BERT, and the last one the output of its last layer. tokens.index("bank") gives the position of “bank” among the tokens of the sentence.
Hint
As in exercise 6, divide each vector by its length: the product of the matrix of the ten vectors with its transpose then gives all the cosine similarities at once. plt.imshow draws a matrix, with one color per value.
Checkpoint what you should get
At the input of BERT, all the vectors of “bank” are almost the same: the average similarity is 0.95 for two sentences with the same meaning, and 0.96 for two sentences with different meanings. After the last layer, it is 0.77 for the same meaning, and 0.49 for different meanings: the matrix shows two blocks. The solution takes about fifteen seconds.
input of BERT: same meaning 0.95, different meanings 0.96 last layer: same meaning 0.77, different meanings 0.49

Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from transformers import AutoTokenizer, AutoModel
# BERT base, trained on lower-case text: 12 layers, vectors of 768
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
model = AutoModel.from_pretrained("bert-base-uncased")
# Ten sentences with "bank": the side of a river in the first five,
# the bank that holds money in the last five. In some of them, only
# the words after "bank" tell its meaning.
sentences = [
"We had a picnic on the bank of the river.",
"The boat drifted slowly toward the muddy bank.",
"Fishermen sat along the bank, waiting for a bite.",
"After the flood, the river overflowed its bank.",
"The bank was steep and covered with reeds.",
"I opened an account at the bank.",
"The bank raised its interest rates again.",
"She went to the bank to deposit her salary.",
"The robbers broke into the bank at night.",
"The bank refused to give him a loan.",
]
meanings = ["river"] * 5 + ["money"] * 5
# The tokenizer cuts a sentence into tokens, and adds [CLS] at the
# start and [SEP] at the end
encoding = tokenizer(sentences[0])
print(tokenizer.convert_ids_to_tokens(encoding["input_ids"]))
['[CLS]', 'we', 'had', 'a', 'picnic', 'on', 'the', 'bank', 'of', 'the', 'river', '.', '[SEP]']
# (1) The vector of "bank" in each sentence: at the input of BERT
# (hidden_states[0], the embedding of the token plus the embedding
# of its position) and after its last layer (hidden_states[12])
import torch
input_vectors, output_vectors = [], []
for sentence in sentences:
encoding = tokenizer(sentence, return_tensors="pt")
tokens = tokenizer.convert_ids_to_tokens(encoding["input_ids"][0])
position = tokens.index("bank")
with torch.no_grad():
outputs = model(**encoding, output_hidden_states=True)
input_vectors.append(outputs.hidden_states[0][0, position])
output_vectors.append(outputs.hidden_states[12][0, position])
# (2) The cosine similarities between the ten vectors, drawn as
# matrices: one row and one column per sentence
import matplotlib.pyplot as plt
def cosine_similarities(vectors):
V = torch.stack(vectors)
V = V / V.norm(dim=1, keepdim=True)
return V @ V.T # (10, 10)
similarities = {
"input of BERT": cosine_similarities(input_vectors),
"last layer": cosine_similarities(output_vectors),
}
fig, axes = plt.subplots(1, 2, figsize=(8, 3.8), layout="constrained")
for ax, (name, S) in zip(axes, similarities.items()):
image = ax.imshow(S, cmap="Blues", vmin=0, vmax=1)
ax.set_title(name)
ax.set_xticks(range(10), meanings, rotation=90)
ax.set_yticks(range(10), meanings)
fig.colorbar(image, ax=axes, label="cosine similarity")
plt.show()
# (3) The average similarity between two different sentences with
# the same meaning, and with different meanings
for name, S in similarities.items():
same, different = [], []
for i in range(10):
for j in range(i + 1, 10):
if meanings[i] == meanings[j]:
same.append(S[i, j].item())
else:
different.append(S[i, j].item())
print(f"{name}: same meaning {sum(same) / len(same):.2f}, "
f"different meanings {sum(different) / len(different):.2f}")
input of BERT: same meaning 0.95, different meanings 0.96 last layer: same meaning 0.77, different meanings 0.49

At the input of BERT, the vector of “bank” is the embedding of the token, plus the embedding of its position: it is a static vector, as in GloVe, and the small differences between sentences only come from the position of the word. After 12 layers of attention, the vectors have moved apart according to the meaning of the word: two banks of rivers are much more similar to each other than to a bank that holds money. This is what GloVe could not do in exercise 6, where “bank” had a single vector, close to finance. Look also at the sentences that start with “The bank”, such as “The bank was steep and covered with reeds.” and “The bank refused to give him a loan.”. Only the words after “bank” tell its meaning, and BERT puts them in the right block. The language model of exercise 8.1, which reads from left to right, could not do this. In BERT, the vector of a word depends on all the words of its text, so the same word gets different vectors for different meanings.
Now let’s fine-tune BERT on IMDB. With the 20,000 labeled reviews, this takes more than an hour on a laptop CPU. But exercises 5.5b and 8.1 showed that pre-training matters most when labels are few. So let’s use the 1,000 labeled reviews of exercise 8.1: from them, the LSTMs of exercise 8.1 reached 65% to 70%, and the deep averaging network of exercise 5.5b reached 82.5%.
Exercise 10.2b Fine-tune BERT on 1,000 reviews
Add a head to BERT that reads the new vector of the token [CLS], and fine-tune the whole network on the 1,000 labeled reviews of the first of the three draws of exercise 8.1, for 3 epochs, with the first 256 tokens of each review. Measure its test accuracy, and compare it with the classifiers of exercises 5.5b and 8.1, trained on the same number of labels. Then run it on the hard reviews of exercise 3.4.
The starter code repeats step (1) of the solution of exercise 3.2, lists the hard reviews of exercise 3.4, and draws the same 1,000 reviews as exercise 8.1: chosen holds their positions in train_texts. It also loads the tokenizer of BERT.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# The hard reviews: (review, label), with 1 for positive
# [Copied from the starter of 3.4]
hard_reviews = [
# Negation
("Not bad at all, actually.", 1),
("Nothing about this movie is good.", 0),
("I wouldn't call it a masterpiece, but I didn't regret "
"a minute.", 1),
# Contrast
("Great acting, great lighting and a flabbergasting soundtrack, "
"but not even Zendaya could save this boring, boring plot.", 0),
("The first hour is slow and the dialogue is clumsy, but the "
"ending makes it all worth it.", 1),
("Everyone told me it would be the film of the year: great "
"cast, amazing trailer, brilliant director. What a "
"disappointment.", 0),
# Sarcasm
("Wow, what a masterpiece. Two hours of pure brilliance, and I "
"only checked my watch twenty times. The acting was so "
"convincing that I truly believed the actors wanted to be "
"somewhere else. A perfect film if you love naps.", 0),
("Brilliant. Another sequel nobody asked for, with the same "
"jokes as the first one.", 0),
# The same words in a different order
("I expected it to be terrible, but it was great.", 1),
("I expected it to be great, but it was terrible.", 0),
# Synonyms, no word in common
("Excellent movie, awesome soundtrack.", 1),
("Superb film, wonderful score.", 1),
# Rare words
("A flabbergasting, mesmerizing tour de force.", 1),
]
print(f"{len(hard_reviews)} hard reviews")
# ----------------------
# New in this exercise
# ----------------------
# The 1,000 labeled reviews of the first draw of exercise 8.1
import random
random.seed(0)
chosen = random.sample(range(len(train_labels)), 1000)
# The tokenizer of BERT cuts the words that it does not know into
# known pieces: ## marks a piece that continues a word
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
print(f"\n{tokenizer.tokenize(hard_reviews[12][0])}")
train: 20000 reviews validation: 5000 reviews test: 25000 reviews 13 hard reviews ['a', 'fl', '##ab', '##berg', '##ast', '##ing', ',', 'me', '##sm', '##eri', '##zing', 'tour', 'de', 'force', '.']
Hint
Load BERT with AutoModel.from_pretrained("bert-base-uncased"). The tokenizer prepares a batch of reviews for it: tokenizer(texts, truncation=True, max_length=256, padding=True, return_tensors="pt"). On this batch, BERT returns last_hidden_state, the vectors of its last layer, of shape (batch, tokens, 768). The vector of [CLS] is at position 0.
Hint
Train with torch.optim.AdamW, a variant of Adam, with the learning rate of 2e-5 recommended by the authors of BERT, in batches of 16 reviews. A learning rate like the 1e-3 of our other exercises would change the pre-trained weights too much.
Hint
from_pretrained returns a model whose dropout is turned off, as for predictions. Call model.train() before the training, to turn it on, and model.eval() after. On a laptop CPU, the three epochs take about five minutes, and the predictions on the 25,000 test reviews about ten.
Checkpoint what you should get
After 3 epochs on the 1,000 reviews, BERT gets 89.2% of the test reviews right. It gets 10 of the 13 hard reviews right: it misses “Not bad at all, actually.” and the two sarcastic reviews. The solution takes about 15 minutes on a laptop CPU, two thirds of them for the predictions on the 25,000 test reviews. Your numbers can differ by a point or so.
test accuracy: 0.892 label p(positive) right review positive 0.297 NO Not bad at all, actually. negative 0.036 yes Nothing about this movie is good. positive 0.910 yes I wouldn't call it a masterpiece, b… negative 0.027 yes Great acting, great lighting and a … positive 0.987 yes The first hour is slow and the dial… negative 0.041 yes Everyone told me it would be the fi… negative 0.984 NO Wow, what a masterpiece. Two hours … negative 0.952 NO Brilliant. Another sequel nobody as… positive 0.981 yes I expected it to be terrible, but i… negative 0.029 yes I expected it to be great, but it w… positive 0.992 yes Excellent movie, awesome soundtrack. positive 0.991 yes Superb film, wonderful score. positive 0.896 yes A flabbergasting, mesmerizing tour … 10 right out of 13
Solution 2 cells
Run the cells in this order, in a fresh notebook: they include the imports, the data loading and the random seeds.
from datasets import load_dataset
imdb = load_dataset("stanfordnlp/imdb")
# (1) Hold out 5,000 training reviews as a validation set, and
# replace the HTML line breaks with spaces
# [Copied from the solution of 3.2, step (1)]
def clean(texts):
return [text.replace("<br />", " ") for text in texts]
split = imdb["train"].train_test_split(test_size=5000, seed=0)
train_texts = clean(split["train"]["text"])
val_texts = clean(split["test"]["text"])
test_texts = clean(imdb["test"]["text"])
train_labels = split["train"]["label"]
val_labels = split["test"]["label"]
test_labels = imdb["test"]["label"]
print(f"train: {len(train_texts)} reviews")
print(f"validation: {len(val_texts)} reviews")
print(f"test: {len(test_texts)} reviews")
# The hard reviews: (review, label), with 1 for positive
# [Copied from the starter of 3.4]
hard_reviews = [
# Negation
("Not bad at all, actually.", 1),
("Nothing about this movie is good.", 0),
("I wouldn't call it a masterpiece, but I didn't regret "
"a minute.", 1),
# Contrast
("Great acting, great lighting and a flabbergasting soundtrack, "
"but not even Zendaya could save this boring, boring plot.", 0),
("The first hour is slow and the dialogue is clumsy, but the "
"ending makes it all worth it.", 1),
("Everyone told me it would be the film of the year: great "
"cast, amazing trailer, brilliant director. What a "
"disappointment.", 0),
# Sarcasm
("Wow, what a masterpiece. Two hours of pure brilliance, and I "
"only checked my watch twenty times. The acting was so "
"convincing that I truly believed the actors wanted to be "
"somewhere else. A perfect film if you love naps.", 0),
("Brilliant. Another sequel nobody asked for, with the same "
"jokes as the first one.", 0),
# The same words in a different order
("I expected it to be terrible, but it was great.", 1),
("I expected it to be great, but it was terrible.", 0),
# Synonyms, no word in common
("Excellent movie, awesome soundtrack.", 1),
("Superb film, wonderful score.", 1),
# Rare words
("A flabbergasting, mesmerizing tour de force.", 1),
]
print(f"{len(hard_reviews)} hard reviews")
# ----------------------
# New in this exercise
# ----------------------
# The 1,000 labeled reviews of the first draw of exercise 8.1
import random
random.seed(0)
chosen = random.sample(range(len(train_labels)), 1000)
# The tokenizer of BERT cuts the words that it does not know into
# known pieces: ## marks a piece that continues a word
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
print(f"\n{tokenizer.tokenize(hard_reviews[12][0])}")
train: 20000 reviews validation: 5000 reviews test: 25000 reviews 13 hard reviews ['a', 'fl', '##ab', '##berg', '##ast', '##ing', ',', 'me', '##sm', '##eri', '##zing', 'tour', 'de', 'force', '.']
# (1) The classifier: BERT, and a head that reads the new vector of
# the token [CLS], at position 0. The tokenizer pads the reviews of
# a batch to the same length, and keeps the first 256 tokens of the
# long ones. Its attention mask tells BERT to ignore the padding.
import torch
from torch import nn
from transformers import AutoModel
device = "cuda" if torch.cuda.is_available() else "cpu"
class BERTClassifier(nn.Module):
def __init__(self):
super().__init__()
self.bert = AutoModel.from_pretrained("bert-base-uncased")
self.head = nn.Linear(768, 1) # p(positive) = sigmoid
def forward(self, texts):
batch = tokenizer(texts, truncation=True, max_length=256,
padding=True, return_tensors="pt")
states = self.bert(**batch.to(device)).last_hidden_state
return self.head(states[:, 0])[:, 0] # (batch,)
torch.manual_seed(0)
model = BERTClassifier().to(device)
# (2) Fine-tune the whole network on the 1,000 reviews, for 3
# epochs, in batches of 16, with a small learning rate. train()
# turns on the dropout of BERT, which eval() turns off.
y_train = torch.tensor(train_labels, dtype=torch.float32)
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5)
loss_function = nn.BCEWithLogitsLoss() # sigmoid + cross-entropy
model.train()
random.seed(0)
for epoch in range(3):
order = list(chosen)
random.shuffle(order)
for start in range(0, len(order), 16):
batch = order[start:start + 16]
scores = model([train_texts[i] for i in batch])
loss = loss_function(scores, y_train[batch].to(device))
optimizer.zero_grad()
loss.backward()
optimizer.step()
model.eval()
# (3) The test accuracy, 64 reviews at a time
def predict(texts):
probabilities = []
with torch.no_grad():
for start in range(0, len(texts), 64):
scores = model(texts[start:start + 64])
probabilities.append(torch.sigmoid(scores).cpu())
return torch.cat(probabilities)
correct = (predict(test_texts) > 0.5) == torch.tensor(test_labels)
test_accuracy = correct.float().mean().item()
print(f"test accuracy: {test_accuracy:.3f}")
# (4) The hard reviews, as in exercise 3.4
p_positive = predict([review for review, label in hard_reviews])
names = ["negative", "positive"]
print("\nlabel p(positive) right review")
right_count = 0
for (review, label), p in zip(hard_reviews, p_positive.tolist()):
right = (p > 0.5) == (label == 1)
if right:
right_count += 1
mark = "yes" if right else "NO"
short = review if len(review) <= 36 else review[:35] + "…"
print(f"{names[label]:9} {p:.3f} {mark:5} {short}")
print(f"\n{right_count} right out of {len(hard_reviews)}")
test accuracy: 0.892 label p(positive) right review positive 0.297 NO Not bad at all, actually. negative 0.036 yes Nothing about this movie is good. positive 0.910 yes I wouldn't call it a masterpiece, b… negative 0.027 yes Great acting, great lighting and a … positive 0.987 yes The first hour is slow and the dial… negative 0.041 yes Everyone told me it would be the fi… negative 0.984 NO Wow, what a masterpiece. Two hours … negative 0.952 NO Brilliant. Another sequel nobody as… positive 0.981 yes I expected it to be terrible, but i… negative 0.029 yes I expected it to be great, but it w… positive 0.992 yes Excellent movie, awesome soundtrack. positive 0.991 yes Superb film, wonderful score. positive 0.896 yes A flabbergasting, mesmerizing tour … 10 right out of 13
With 1,000 labeled reviews, BERT gets 89.2% of the test reviews right: 24 points more than the LSTM of exercise 8.1 trained from scratch on the same reviews, 19 points more than the best pre-trained LSTM of that exercise, and 6 to 8 points more than the deep averaging networks of exercise 5.5b. It even beats all our classifiers trained on the 20,000 labeled reviews, the best of which got 88.4%. Yet the recipe is the one of exercise 8.1: pre-train the encoder on raw text, add a head, and fine-tune everything. What changed is the scale of the pre-training: 3.3 billion words and 110 million parameters, against 10 million words and about a million parameters for our LSTM. The hard reviews show what BERT brings. It reads the negations of “Nothing about this movie is good.” and “I wouldn’t call it a masterpiece, but I didn’t regret a minute.”, the three contrasts, and the two reviews with the same words. It still misses “Not bad at all, actually.”, and the two sarcastic reviews, which it calls positive with a probability above 0.95: a thousand movie reviews do not teach that “I only checked my watch twenty times” is not praise. A large pre-trained encoder transfers so much about language that a thousand labeled reviews are enough.
With all the labeled reviews, and up to 510 tokens of each review, BERT base reaches 94.6% on IMDB. It reaches 95.6% when its pre-training is first continued on the reviews of IMDB, as a masked language model, as ULMFiT did in its second stage (Sun et al., 2019).
10.3 GPT-1 to GPT-3, then InstructGPT
A few months before BERT, in June 2018, Alec Radford and his colleagues at OpenAI had taken the other half of the transformer, the decoder (Radford et al., 2018). A decoder is a language model: thanks to the mask of section 10.1, each token attends only to the tokens before it, so it can be trained on raw text to predict the next token, like the LSTM of section 7.3, but with all the positions of a text computed at once. Their model, now called GPT-1 (GPT for generative pre-training), has 12 layers, vectors of 768 and 12 heads, and reads up to 512 tokens. It was trained on BooksCorpus, more than 7,000 unpublished books, chosen because “it contains long stretches of contiguous text, which allows the generative model to learn to condition on long-range information”.
Then GPT-1 is fine-tuned for each task, with a head, as ULMFiT and BERT. But in a decoder, the vector of a token depends only on the tokens before it, so the first token cannot summarize the text, as [CLS] does in BERT. The only token that has read the whole text is the last one. So GPT-1 adds a special token at the end of the text, the extract token, and the head reads its vector:
Fine-tuned in this way, GPT-1 improved the state of the art on 9 of the 12 tasks of the paper. The authors also noticed something else: without any fine-tuning, the language model alone could do some tasks. To find the sentiment of a sentence, they add the word “very” at its end, and compare the probabilities that the model gives to the next words “positive” and “negative”. These tricks work better and better as the pre-training goes on (their Figure 2).
GPT-2, in February 2019, pushed this idea to its end (Radford et al., 2019). The architecture is the same, only larger: up to 48 layers and 1.5 billion parameters, reading 1,024 tokens. It was trained on WebText, 40 GB of text from 8 million web pages: the pages linked from Reddit by posts with at least 3 karma, a sign that some readers found them interesting. And GPT-2 is not fine-tuned at all. The claim of the paper is in its abstract: “language models begin to learn these tasks without any explicit supervision”. A task can be written as text, for instance a translation as “(translate to french, english text, french text)”, and a language model trained on enough text has met such sequences, and learns to continue them, because this predicts their next words. To summarize an article, GPT-2 reads it followed by “TL;DR:”, the abbreviation that people write on the web before a summary. To translate, it reads a few pairs written as “english sentence = french sentence”, then an English sentence followed by ”=”. These results were modest, for instance 11.5 BLEU from French to English. But on language modeling, they were spectacular.
Without a single update of its weights on WikiText-2, GPT-2 beat every model trained on it: its smallest version, which has the size of GPT-1, has a test perplexity of 29.41, against 39.14 for the previous best, and the largest one 18.34.
You probably know the rest of the story. GPT-3, in May 2020, is GPT-2 made a hundred times larger (Brown et al., 2020): 175 billion parameters, 96 layers, vectors of 12,288, a context of 2,048 tokens, trained on 300 billion tokens from the web, books and Wikipedia. Its paper gave the idea of GPT-2 a name, in-context learning: the task is described in the text given to the model, the prompt, with a few examples, and the model continues the text.
This is the end point of our second question. In sections 2 to 4, nothing was transferred from one task to another. Section 5 transferred the embeddings, section 8 the whole encoder, and BERT and GPT-1 transfer everything but the head. With GPT-3, even the head is gone. The answer to a task is the next words of the text, so the output layer of the language model, which section 8 dropped, is the head of every task, and nothing is trained for the task anymore. The task is written in the prompt, and the pre-trained language model does all the work.
One step was still missing. A language model continues texts as they are written on the web, which is not the same as doing what the person who writes the prompt asks for. As the authors of InstructGPT put it, the task of “predicting the next token on a webpage from the internet […] is different from the objective “follow the user’s instructions helpfully and safely”” (Ouyang et al., 2022). In March 2022, they trained GPT-3 to follow instructions, in three steps: fine-tuning on answers written by people, training a reward model on the preferences of people between several answers, and optimizing the language model against this reward model with reinforcement learning. The result was striking: “outputs from the 1.3B parameter InstructGPT model are preferred to outputs from the 175B GPT-3, despite having 100x fewer parameters”. This stage, called post-training, turns a language model into an assistant. It is the topic of another part of the course.
If you want to dig deeper, below are a few bonus exercises.
The layers of BERT. Redo exercise 10.2a for each of the 13 vectors of hidden_states, and plot the average similarity for the same meaning and for different meanings against the layer. At which layer do the two meanings separate?
Fill the blanks. Load the pre-training head of BERT with AutoModelForMaskedLM.from_pretrained("bert-base-uncased"), and print its 5 most likely tokens for the [MASK] of a few sentences, such as “The movie was [MASK].” or “I went to the [MASK] to deposit my salary.”.
BERT as features. Freeze BERT, compute the vector of [CLS] of each review, and train a logistic regression on these vectors, as in exercise 8.1, with 1,000 and with 20,000 labeled reviews. How far is it from fine-tuning? Try also the average of the vectors of all the tokens.
All the labels. Fine-tune BERT on the 20,000 labeled reviews for 2 epochs, on a GPU (the free GPU of Google Colab is enough), and compare with the 94.6% of Sun et al. (Sun et al., 2019). They kept the first 128 and the last 382 tokens of the long reviews: does it matter?
Sentiment without training. Use the trick of GPT-1 with the smallest GPT-2: add ” very” at the end of a review, and compare the probabilities of the next tokens ” positive” and ” negative”. How many hard reviews does it get right, and what accuracy on 1,000 test reviews?
GPT-2 on WikiText-2. Measure the perplexity of the four GPT-2 models (gpt2, gpt2-medium, gpt2-large and gpt2-xl) on the test set of WikiText-2, and plot it against the number of parameters. Join the test words of exercise 2.2 with spaces, and compute the perplexity per word: the exponential of minus the sum of the log-probabilities of the tokens, divided by the number of words, not of tokens, so that it can be compared with the models of the post. GPT-2 reads at most 1,024 tokens: move a window of 1,024 tokens along the text by steps of 512, and score only the tokens that the previous window did not score. Compare with Table 3 of the paper (Radford et al., 2019).
A detector of AI-written text. Fine-tune BERT to tell reviews written by people from reviews written by a language model, like the detectors of AI-written text.
A cheap JEV-clone. JEV has been very popular in the past few weeks, as a “general purpose classifier”. Many open-source models tried to reproduce its results, for instance starting from a BERT model. You could try to create your own!
References (chronological)
- 1913 An Example of Statistical Investigation of the Text Eugene Onegin Concerning the Connection of Samples in Chains. Bulletin of the Imperial Academy of Sciences of St. Petersburg. English translation: Science in Context, 2006. 2
- 1948 A Mathematical Theory of Communication. Bell System Technical Journal. 2.1
- 1951 Prediction and Entropy of Printed English. Bell System Technical Journal. 2.2
- 1954 Distributional Structure. Word. 5.1
- 1957 A Synopsis of Linguistic Theory, 1930–1955. Studies in Linguistic Analysis. 5.1
- 1972 A Statistical Interpretation of Term Specificity and Its Application in Retrieval. Journal of Documentation. 3.3
- 1975 A Vector Space Model for Automatic Indexing. Communications of the ACM. 3.2
- 1986 Learning Distributed Representations of Concepts. Annual Conference of the Cognitive Science Society. 4.1
- 1987 Estimation of Probabilities from Sparse Data for the Language Model Component of a Speech Recognizer. IEEE Transactions on Acoustics, Speech, and Signal Processing. 2.3
- 1990 Indexing by Latent Semantic Analysis. Journal of the American Society for Information Science. 4.1
- 1990 Finding Structure in Time. Cognitive Science. 7.1
- 1990 Backpropagation Through Time: What It Does and How to Do It. Proceedings of the IEEE. 7.2
- 1991 Untersuchungen zu dynamischen neuronalen Netzen. Diploma thesis, Technische Universität München. 7.2
- 1993 The Mathematics of Statistical Machine Translation: Parameter Estimation. Computational Linguistics. 9.3
- 1994 Learning Long-Term Dependencies with Gradient Descent is Difficult. IEEE Transactions on Neural Networks. 7.2
- 1997 Long Short-Term Memory. Neural Computation. 7.2
- 1999 An Empirical Study of Smoothing Techniques for Language Modeling. Computer Speech & Language. 2.3
- 2002 Thumbs up? Sentiment Classification using Machine Learning Techniques. EMNLP. 3.2, 3.4
- 2002 BLEU: a Method for Automatic Evaluation of Machine Translation. ACL. 9.3
- 2003 A Neural Probabilistic Language Model. Journal of Machine Learning Research. 4.2
- 2003 Statistical Phrase-Based Translation. NAACL. 9.3
- 2010 Recurrent Neural Network Based Language Model. Interspeech. 7.3
- 2011 Natural Language Processing (Almost) from Scratch. Journal of Machine Learning Research. 5.1, 7
- 2011 Learning Word Vectors for Sentiment Analysis. ACL. introduction, 3.3
- 2012 Baselines and Bigrams: Simple, Good Sentiment and Topic Classification. ACL. 3.3, 3.4, 6
- 2013 Generating Sequences With Recurrent Neural Networks. arXiv. 7.3
- 2013 Efficient Estimation of Word Representations in Vector Space. ICLR 2013, workshop. 5.2, 5.4
- 2013 Distributed Representations of Words and Phrases and their Compositionality. NeurIPS. 5.2, 5.4
- 2013 Linguistic Regularities in Continuous Space Word Representations. NAACL-HLT. 5.4
- 2013 On the Difficulty of Training Recurrent Neural Networks. ICML. 7.2
- 2013 Recursive Deep Models for Semantic Compositionality Over a Sentiment Treebank. EMNLP. 5.5
- 2014 Neural Machine Translation by Jointly Learning to Align and Translate. arXiv (ICLR 2015). 9.2, 9.3
- 2014 Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation. EMNLP. 7.2, 9.1
- 2014 On the Properties of Neural Machine Translation: Encoder–Decoder Approaches. Workshop on Syntax, Semantics and Structure in Statistical Translation. 9.1
- 2014 Convolutional Neural Networks for Sentence Classification. EMNLP. 5.5, 7, 7.3
- 2014 Neural Word Embedding as Implicit Matrix Factorization. NeurIPS. 5.3, 5.5
- 2014 GloVe: Global Vectors for Word Representation. EMNLP. 5.3
- 2014 Sequence to Sequence Learning with Neural Networks. NeurIPS. 9.1
- 2014 Recurrent Neural Network Regularization. arXiv. 7.3
- 2015 Semi-supervised Sequence Learning. NeurIPS. 7.2, 8.1
- 2015 Deep Unordered Composition Rivals Syntactic Methods for Text Classification. ACL. 4.3, 5.5
- 2015 An Empirical Exploration of Recurrent Network Architectures. ICML. 7.2, 7.3
- 2015 The Unreasonable Effectiveness of Recurrent Neural Networks. Blog post. 7.3
- 2015 Effective Approaches to Attention-based Neural Machine Translation. EMNLP. 9.2
- 2016 Man is to Computer Programmer as Woman is to Homemaker? Debiasing Word Embeddings. NeurIPS. 5.4
- 2016 A Theoretically Grounded Application of Dropout in Recurrent Neural Networks. NeurIPS. 7.3
- 2016 Pointer Sentinel Mixture Models. arXiv (ICLR 2017). introduction, 7.3
- 2016 Neural Machine Translation of Rare Words with Subword Units. ACL. 7.3, 9.3
- 2016 Google's Neural Machine Translation System: Bridging the Gap between Human and Machine Translation. arXiv. 9.3
- 2017 Enriching Word Vectors with Subword Information. Transactions of the ACL. 7.3
- 2017 Bag of Tricks for Efficient Text Classification. EACL. 4.3
- 2017 Learned in Translation: Contextualized Word Vectors. NeurIPS. 8.2
- 2017 Semi-supervised Sequence Tagging with Bidirectional Language Models. ACL. 8.2
- 2017 Learning to Generate Reviews and Discovering Sentiment. arXiv. 8.1, 8.2
- 2017 Attention Is All You Need. NeurIPS. 10.1
- 2018 Linear Algebraic Structure of Word Senses, with Applications to Polysemy. TACL. 6
- 2018 BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. NAACL 2019 (arXiv 2018). 10.2
- 2018 Universal Language Model Fine-tuning for Text Classification. ACL. 8.1, 8.2
- 2018 Regularizing and Optimizing LSTM Language Models. ICLR. 8.1
- 2018 Deep Contextualized Word Representations. NAACL. 8.2
- 2018 Improving Language Understanding by Generative Pre-Training. OpenAI report. 10.3
- 2019 Language Models are Unsupervised Multitask Learners. OpenAI report. 10.3
- 2019 How to Fine-Tune BERT for Text Classification?. CCL 2019. 10.2, 10.3
- 2020 Language Models are Few-Shot Learners. NeurIPS. 10.3
- 2020 Fair is Better than Sensational: Man is to Doctor as Woman is to Doctor. Computational Linguistics. 5.4, 5.5
- 2022 Training Language Models to Follow Instructions with Human Feedback. NeurIPS. 10.3
- 2023 Advancing State of the Art in Language Modeling. arXiv. 2.2, 2.3, 6