Foundations of Large Language Models Learning by Doing

by David Louapre

Chapter 0

Natural Language Processing before LLMs

The ideas, papers and exercises that led from n-gram counts to attention.

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:

To explore these questions, we’ll play mostly with two tasks and two datasets:

Datasets
  • 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:

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.

Four NLP tasks
Each task drawn as a model that reads the words of a text and gives probabilities: over the classes (classification), over the words of the vocabulary (language modeling, translation), or over the labels of each word (tagging).

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

p(C∣x1…xL)p(C \mid x_1 \dots x_L)

that a certain sequence of words x1…xLx_1 \dots x_L belongs to a class CC, where here LL 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 p(x1…xL)p(x_1 \dots x_L) of its possible sequences of words. In practice, it is often more convenient to learn the next-word probability

p(xL+1∣x1…xL).p(x_{L+1} \mid x_1 \dots x_L).

From this next-word probability, we can simply recover the full probability by using the chain rule, for instance

p(x1x2x3x4)=p(x1) p(x2∣x1) p(x3∣x1x2) p(x4∣x1x2x3)p(x_1 x_2 x_3 x_4) = p(x_1)\, p(x_2 \mid x_1)\, p(x_3 \mid x_1 x_2)\, p(x_4 \mid x_1 x_2 x_3)

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 y1…yKy_1 \dots y_K conditioned on the original sentence x1…xLx_1 \dots x_L. 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:

p(yi+1∣y1…yi;x1…xL)p(y_{i+1} \mid y_1 \dots y_i; x_1 \dots x_L)

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!

Word alignment in different languages
An English sentence and its translation into French, German or Turkish, cut into morphemes (the smallest parts of a word that carry a meaning, like the -n of seen). French keeps nearly the same order as English, German moves the verbs to the end of their clause, and Turkish nearly reverses the order. The example sentences come from a picture shared on Reddit.

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.

p(ti∣x1…xL)p(t_i \mid x_1 \dots x_L)

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

p(xi∣x1…xi−1)=p(xi)=C(xi)Np(x_{i} \mid x_1 \dots x_{i-1}) = p(x_i) = \frac{C(x_i)}{N}

where C(xi)C(x_i) is the count of word xix_i in the training corpus, and N=∑C(xi)N = \sum C(x_i) 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.

p(xi∣x1…xi−1)=p(xi∣xi−1)p(x_{i} \mid x_1 \dots x_{i-1}) = p(x_i \mid x_{i-1})

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 C(v)C(v) and of every pair of consecutive words C(v,w)C(v,w). We then use it to compute the probability p(w∣v)=C(v,w)/C(v)p(w \mid v) = C(v,w)/C(v) that the word ww follows the word vv. 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
Data: WikiText-2 · Compute time: < 1 min

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.

Starter code
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 V×VV\times V 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 33,2772≈1.133{,}277^2 \approx 1.1 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
What to notice

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 nn consecutive words. To predict the next word, we thus consider the sequence of the n−1n-1 previous words as an input. The Markov chain model of 2.1 is n=2n = 2, the unigram model is n=1n=1 where no context at all is used.

Using n-grams, the transition matrix will be Vn−1×VV^{n-1} \times V, which can quickly become overwhelming (for n=3n=3 and V=30,000V=30{,}000, we have about 3×10133 \times 10^{13} 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 nn.

The next word after a context
The probability of the next word given its n − 1 previous words, from the counts of the training text of WikiText-2.

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 p(xL+1∣x1…xL)p(x_{L+1} \mid x_1 \dots x_L). Any candidate language model will produce a candidate distribution q(xL+1∣x1…xL)q(x_{L+1} \mid x_1 \dots x_L) (in the n-gram Markov model, it depends only on xL−n+2…xLx_{L-n+2} \dots x_L).

How do we measure success? That is, how do we quantify how similar qq is to pp?

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

DKL(p ∥ q)=∑xp(x)log⁡p(x)q(x).D_{KL}(p \,\|\, q) = \sum_x p(x) \log \frac{p(x)}{q(x)}.

I write it with generic distribution pp and qq, but note that this definition also works using conditional probabilities for instance in our case p(xL+1∣x1…xL)p(x_{L+1} \mid x_1 \dots x_L) and q(xL+1∣x1…xL)q(x_{L+1} \mid x_1 \dots x_L).

Finding the distribution qq that best approximates a given pp means minimizing the KL divergence. Now, in our case, as pp 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

DKL(p ∥ q)=∑xp(x)log⁡p(x)−∑xp(x)log⁡q(x)D_{KL}(p \,\|\, q) = \sum_x p(x) \log p(x) - \sum_x p(x) \log q(x)

The first part is (minus) the entropy of pp (which doesn’t depend on qq), while the second part is the so-called cross-entropy.

Since the cross-entropy

H(p,q)=−∑xp(x)log⁡q(x).H(p, q) = -\sum_x p(x) \log q(x).

is the only part that actually depends on qq, we are actually seeking qq that minimizes it.

Finding qq that minimizes the KL-divergence DKL(p ∥ q)D_{KL}(p \,\|\, q) is equivalent to minimizing the cross-entropy H(p,q)H(p,q).

You can try it informally on a very simple example below, by dragging the bars of the orange distribution.

Entropy, cross-entropy and KL divergence
Drag the bars of q, the model, and compare it with p: the cross-entropy is always at least the entropy of p; the difference is the KL divergence, zero only when q = p. These are fake distributions on only 6 words.

Likelihood

Now let’s return to our notations, and remember that we are working with conditional probabilities p(x∣c)p(x \mid c) and q(x∣c)q(x \mid c) of having xx as the next word, knowing the context c=(x1,…,xL)c=(x_1,\dots,x_L).

If qq depends on some parameters θ\theta, we seek to minimize the cross-entropy

−∑xp(x∣c)log⁡qθ(x∣c),-\sum_x p(x \mid c) \log q_{\theta}(x \mid c),

which can be seen as an expectation over distribution probability x∼p(.∣c)x \sim p( . | c)

−Ex∼p(⋅∣c)[log⁡qθ(x∣c)].-\mathbb{E}_{x \sim p( \cdot \mid c)}[\log q_{\theta}(x \mid c)].

Once again, we cannot compute this quantity directly because we do not know pp, but our text corpus is a sample from pp.

At each position ii of a text, the context x<i=x1…xi−1x_{<i} = x_1 \dots x_{i-1} is followed by the word xix_i that was actually written. Most contexts appear only once, so we never see the distribution p(⋅∣x<i)p(\cdot \mid x_{<i}), only one word drawn from it. But the average over the NN positions of the text estimates the cross-entropy, averaged over the contexts as they occur in the language:

H^=−1N∑i=1Nlog⁡qθ(xi∣x<i)\hat{H} = -\frac{1}{N} \sum_{i=1}^{N} \log q_\theta(x_i \mid x_{<i})

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, ∏iqθ(xi∣x<i)\prod_i q_\theta(x_i \mid x_{<i}) is the probability that the model gives to the whole text, its likelihood. So H^\hat{H} 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 θ\theta (for instance the weights of a neural network) that minimize H^\hat{H} on a training text. Each term of the sum is itself a cross-entropy: the one between qθq_\theta and the distribution that puts all its probability on the observed word (a one-hot distribution), which stands in for the unknown pp:

LCE=−∑x1[x=xi]log⁡qθ(x∣x<i)=−log⁡qθ(xi∣x<i)L_{CE} = -\sum_x \mathbf{1}[x = x_i] \log q_\theta(x \mid x_{<i}) = -\log q_\theta(x_i \mid x_{<i})

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 ∇θH(p,qθ)\nabla_\theta H(p, q_\theta). Since the entropy of pp does not depend on θ\theta, this is also ∇θDKL(p ∥ qθ)\nabla_\theta D_{KL}(p \,\|\, q_\theta).

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 C(v,w)/C(v)C(v,w)/C(v) of section 2.1 is exactly the qq 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:

P=exp⁡(−1N∑i=1Nlog⁡q(xi∣x<i))=(∏i=1N1q(xi∣x<i))1/N{\cal P} = \exp\left(-\frac{1}{N} \sum_{i=1}^{N} \log q(x_i \mid x_{<i})\right) = \left(\prod_{i=1}^{N} \frac{1}{q(x_i \mid x_{<i})}\right)^{1/N}

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 VV words has perplexity VV, 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 qq approximates pp, we need another sample from pp 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
Data: WikiText-2 · Compute time: < 1 min

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.

Starter code
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
What to notice

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 Vn−1V^{n-1} while the training text stays the same size.

Exercise 2.3a Count the n-grams for n = 1 to 5
Data: WikiText-2 · Compute time: < 1 min

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.

Starter code
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
What to notice

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:

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
Data: WikiText-2 · Compute time: < 1 min

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].

Starter code
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 λ\lambda: q(w∣a)=λ p2(w∣a)+(1−λ) p1(w)q(w \mid a) = \lambda \, p_2(w \mid a) + (1 - \lambda) \, p_1(w), where p2p_2 and p1p_1 are the probabilities from the bigram and unigram counts. Tune λ\lambda 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()

Output of the cell

What to notice

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.

Bonus

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).

Bonus

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).

Bonus

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?

Bonus

Word order. Compare the perplexity of a few sentences with that of the same sentences, with their words shuffled.

Bonus

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 nn words. Indeed there is no reason to assume that the important information will be in the last nn words, especially if nn 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.

Encoder, representation, head
The pattern of every classifier in this post: an encoder turns a text of any length into a vector of fixed size, and a head turns this vector into the probabilities of the classes. Most of what follows is about the box marked “?”.

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.

WordCountWordCountWordCountWordCount
a1but1great2save1
acting1could1lighting1soundtrack1
and1even1not1this1
boring2flabbergasting1plot1Zendaya1

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 vv is the count CvC_v of the vv-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 wvw_v to each word of the vocabulary, and computes the probability that the review is positive as

p(positive∣x1…xL)=σ(b+∑v=1Vwv Cv),with σ(z)=11+e−z,p(\text{positive} \mid x_1 \dots x_L) = \sigma\Big(b + \sum_{v=1}^{V} w_v \, C_v\Big), \qquad \text{with } \sigma(z) = \frac{1}{1 + e^{-z}},

where bb is a bias, and the sigmoid function σ\sigma 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.

A review as a bag of words
The bag-of-word vector has one entry per word of the vocabulary, in alphabetical order, and only the words of the review have a non-zero entry (the dashed boxes count the zeros between them.) Each entry is colored by the weight of its word in the logistic regression of exercise 3.2 (hover over an entry to see it). Try to shuffle the words of your review: nothing changes.

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
Data: IMDB · Compute time: < 1 min

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.

Starter code
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
What to notice

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 −log⁡-\log of the fraction of documents that contain it,

idf(v)=−log⁡NvN=log⁡NNv,\text{idf}(v) = -\log \frac{N_v}{N} = \log \frac{N}{N_v},

where NN is the number of documents and NvN_v the number of documents that contain the word vv. The entry vv of the vector of a document becomes Cv×idf(v)C_v \times \text{idf}(v), 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
Data: IMDB · Compute time: < 1 min

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.

Starter code
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
What to notice

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.

excellentmovieawesomesoundtracksuperbfilmwonderfulscore
A11110000
B00001111

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
Data: IMDB · Compute time: < 1 min

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.

Starter code
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
What to notice

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.

Bonus

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?

Bonus

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?

Bonus

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?

Bonus

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)?

Bonus

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 D=300D = 300) 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 WW, with one column per word of the vocabulary:

W∈MD×V(R)W \in {\cal M}_{D \times V}(\mathbb{R})

Looking up the embedding of a word is the same as multiplying WW by the one-hot vector of this word: the product selects the column of the word.

The embedding matrix
Pick a word: its one-hot vector has a single 1, at the position of the word, so multiplying W by this vector selects the column of the word, which is its embedding. The values are made up, but chosen so that similar words have similar columns, as we hope a trained matrix does. In practice, D is a few hundred and V tens of thousands.

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:

cos⁡(u,v)=u⋅v∥u∥ ∥v∥\cos(u, v) = \frac{u \cdot v}{\|u\| \, \|v\|}

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:

From L embeddings to one vector: 3 methods
An encoder in two steps: each word becomes its embedding, then an aggregation turns the L embeddings into one vector of fixed size. Switch between the three options. (Values are made up for illustration)

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 eL−n+2,…,eLe_{L-n+2}, \dots, e_L of the last n − 1 words in the matrix WW, concatenate them into a vector cc, then feed cc into a feedforward network with one hidden layer:

q(xL+1=v∣x1…xL)=softmax(Utanh⁡(Ac+b)+d)v,q(x_{L+1} = v \mid x_1 \dots x_L) = \text{softmax}\big(U \tanh(A c + b) + d\big)_v,

where AA and bb are the weights and the biases of the hidden layer, and UU and dd those of the output layer. The output layer gives one score zvz_v per word vv of the vocabulary, and the softmax, softmax(z)v=ezv/∑wezw\text{softmax}(z)_v = e^{z_v} / \sum_{w} e^{z_w}, 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 WW is a parameter of the model, like AA, bb, UU and dd: 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 WW 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.tensor turns 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 activations nn.Tanh and nn.ReLU.
  • Losses. F.cross_entropy takes 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.BCEWithLogitsLoss does 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
Data: WikiText-2 · Compute time: 6 min

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.

Starter code
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 WW 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
What to notice

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 WW by the bag-of-words vector CC of section 3.2, since the column of each word is added as many times as the word appears. So

1L∑i=1Lei=1L WC=Wf,\frac{1}{L} \sum_{i=1}^{L} e_i = \frac{1}{L} \, W C = W f,

where f=C/Lf = C / L is the vector of the frequencies of the words in the review. The average of the embeddings is a bag of words, compressed by WW 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: p(positive)=σ(b+h⋅Wf)p(\text{positive}) = \sigma(b + h \cdot W f), where hh is the vector of the weights of the head. Expanding the dot product gives

h⋅Wf=∑v=1V(h⋅wv) fv,h \cdot W f = \sum_{v=1}^{V} (h \cdot w_v) \, f_v,

where wvw_v is the column of WW for the word vv. This is a logistic regression on the frequencies of the words, where the weight of the word vv is h⋅wvh \cdot w_v. So if WW is learned along with hh, nothing is gained over section 3! The gains can come from two places:

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
Data: IMDB · Compute time: < 1 min

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.

Starter code
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 WW 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 WW. 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
What to notice

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
Data: IMDB · Compute time: < 1 min

Print the 5 nearest neighbors of the six words of exercise 4.2 (“three”, “he”, “city”, “film”, “excellent” and “terrible”) in the embedding matrix WW 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.

Starter code
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 WW 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 WW 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
What to notice

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.

Bonus

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?

Bonus

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?

Bonus

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?

Bonus

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?

Bonus

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.

Bonus

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?

Bonus

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 WW on a task for which data is free and abundant: this is pre-training. Then copy WW 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:

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.

Three ways to learn embeddings by predicting words
Bengio's model predicts the next word from the embeddings of the previous words, concatenated and passed through a hidden layer. The two models of word2vec use the words on both sides, and have no hidden layer: CBOW predicts the middle word from the average of the embeddings around it, skip-gram predicts each word around from the embedding of the middle word. The embeddings and the probabilities are made up.

Let’s write skip-gram. For this, two matrices WW and UU have to be learned. WW is the embedding matrix (dimension D×VD \times V, as in section 4.1) that associates an embedding vector vwv_w, its column, to each word ww. UU is the output projector matrix (dimension V×DV \times D) in the linear layer, which can also be seen as providing a vector ucu_c, its row, for each candidate output word cc.

The probability that the word cc appears around the middle word ww is then a softmax of dot products:

q(c∣w)=euc⋅vw∑c′=1Veuc′⋅vw,q(c \mid w) = \frac{e^{u_c \cdot v_w}}{\sum_{c'=1}^{V} e^{u_{c'} \cdot v_w}},

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 ww?”, it asks a yes-or-no question: “is (w,c)(w, c) a real pair, taken from the text, or a fake one?”. For each real pair, we draw kk random words n1…nkn_1 \dots n_k, which make kk fake pairs (w,ni)(w, n_i), 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:

L=−log⁡σ(uc⋅vw)−∑i=1klog⁡σ(−uni⋅vw).\mathcal{L} = -\log \sigma(u_c \cdot v_w) - \sum_{i=1}^{k} \log \sigma(-u_{n_i} \cdot v_w).

Since σ(−z)=1−σ(z)\sigma(-z) = 1 - \sigma(z), 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 k=5k = 5, 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 ff is dropped with probability 1−t/f1 - \sqrt{t/f}, where tt is a threshold, around 10−510^{-5} in the paper. With t=10−4t = 10^{-4}, about 4% of the occurrences of “the” in WikiText-2 are kept, and all the occurrences of the words of frequency below 10−410^{-4}. 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 log⁡2V\log_2 V 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
Data: WikiText-2 · Compute time: < 1 min

Train a skip-gram model with negative sampling on the training text of WikiText-2. Subsample the frequent words with t=10−4t = 10^{-4}, 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.

Starter code
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 vv that we keep, and one for the context words, the output vectors uu. 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
What to notice

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 jj appears in the window of each word ii. This gives a co-occurrence matrix XX 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:

wi⋅w~j+bi+b~j≈log⁡Xij,w_i \cdot \tilde{w}_j + b_i + \tilde{b}_j \approx \log X_{ij},

where wiw_i and w~j\tilde{w}_j are two vectors of the words, as in skip-gram, and bib_i and b~j\tilde{b}_j are biases. The vectors are fitted by least squares, weighted by a function of the count XijX_{ij}: 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 kk 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 ww and of a context word cc is

vw⋅uc=log⁡p(w,c)p(w) p(c)−log⁡k,v_w \cdot u_c = \log \frac{p(w, c)}{p(w)\, p(c)} - \log k,

where p(w,c)p(w, c) is the probability that cc appears in the window of ww, and p(w)p(w) and p(c)p(c) 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 ww and a context word cc, and write x=vw⋅ucx = v_w \cdot u_c. In the loss of negative sampling, summed over the whole text, the pair (w,c)(w, c) appears as a real pair #(w,c)\#(w, c) times, the number of times cc is in the window of ww. It also appears as a fake pair: each of the #(w)\#(w) real pairs of ww draws kk fake words, and cc is drawn with probability pn(c)p_n(c), so about k #(w) pn(c)k\, \#(w)\, p_n(c) times. The part of the loss that depends on xx is

−#(w,c)log⁡σ(x)−k #(w) pn(c)log⁡σ(−x).-\#(w, c) \log \sigma(x) - k\, \#(w)\, p_n(c) \log \sigma(-x).

Its derivative with respect to xx is −#(w,c) σ(−x)+k #(w) pn(c) σ(x)-\#(w, c)\, \sigma(-x) + k\, \#(w)\, p_n(c)\, \sigma(x), which is zero when ex=σ(x)/σ(−x)=#(w,c)/(k #(w) pn(c))e^x = \sigma(x) / \sigma(-x) = \#(w, c) / \big(k\, \#(w)\, p_n(c)\big). With the frequencies p(w,c)=#(w,c)/Np(w, c) = \#(w, c)/N and p(w)=#(w)/Np(w) = \#(w)/N, where NN is the total number of real pairs, and with fake words drawn from the plain frequencies, pn(c)=p(c)p_n(c) = p(c), this gives the formula above. With the power 3/4 of word2vec, p(c)p(c) 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 vparis−vfrance+vspainv_\text{paris} - v_\text{france} + v_\text{spain}, and look for the word whose vector is the closest: it is “madrid”. In other words, the difference vparis−vfrancev_\text{paris} - v_\text{france} is almost the same as vmadrid−vspainv_\text{madrid} - v_\text{spain}: there is a direction in the space of embeddings that means “the capital of”. In the same way, the word closest to vking−vman+vwomanv_\text{king} - v_\text{man} + v_\text{woman} is “queen”, once we exclude “king” itself (we will come back to this detail in exercise 5.4).

Relations are directions
For each relation, the GloVe vectors of 9 pairs of words, projected on 2 dimensions with a principal component analysis (PCA), and an arrow from the first word of each pair to the second. The arrows are almost parallel: going from a country to its capital, or from a man to a woman, is about the same move everywhere in the space. Hover over a word to highlight its pair. After Figure 2 of Mikolov et al. (2013b).

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 vb−va+vcv_b - v_a + v_c. 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
Data: GloVe, analogy test set · Compute time: < 1 min

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 vb−va+vcv_b - v_a + v_c, 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.

Starter code
# 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 vb−va+vcv_b - v_a + v_c 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%)
What to notice

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 vking−vman+vwomanv_\text{king} - v_\text{man} + v_\text{woman} is still closer to vkingv_\text{king} 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 vwoman−vmanv_\text{woman} - v_\text{man}. 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:

Three ways to get the embedding matrix of a classifier
From scratch, the embedding matrix W starts at random and is learned from the labeled reviews, as in section 4.3. With transfer learning, W is first learned on raw text, for instance by word2vec or GloVe, then copied into the classifier, where it is either kept fixed (frozen) or trained further with the rest of the network (fine-tuned). The values of the matrices are made up.

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
Data: IMDB, GloVe · Compute time: < 1 min

Build the embedding matrix WW 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 WW random (from scratch), with WW copied from GloVe and frozen, and with WW copied from GloVe and fine-tuned. Compare their test accuracies. Then print the 5 nearest neighbors of “good” and “bad” in GloVe, and in WW 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.

Starter code
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 WW 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 WW, 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
What to notice

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
Data: IMDB, GloVe · Compute time: 5 min

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.

Starter code
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

Output of the cell

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

Output of the cell

What to notice

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.

Bonus

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?

Bonus

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?

Bonus

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.

Bonus

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?

Bonus

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.

Bonus

A direction for gender. Compute vwoman−vmanv_\text{woman} - v_\text{man}, vqueen−vkingv_\text{queen} - v_\text{king} and vgirl−vboyv_\text{girl} - v_\text{boy} 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).

Bonus

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.

ModelSectionHow a text becomes a vector of fixed sizeWhat is reused from another taskWikiText-2 perplexityIMDB accuracy
5-gram model, interpolated2keep the last 4 wordsnothing277.7–
Bag of words, TF-IDF3count the wordsnothing–88.4%
Bengio’s language model4.2concatenate the embeddings of the last 3 wordsnothing208.5–
Deep averaging network4.3average the embeddings of the wordsnothing–88.1%
Deep averaging network with GloVe5.5average the embeddings of the wordsthe 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
Data: GloVe · Compute time: < 1 min

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.

Starter code
# 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
What to notice

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.

Bonus

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.

Bonus

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.

Two encoders that read the words in order
Switch between the two encoders. The convolution reads every window of 3 consecutive embeddings and gives 4 features per window; the representation keeps the maximum of each feature over the windows (outlined). The recurrent network (RNN) reads the embeddings one after the other: each step reads the embedding of a word and the state left by the previous step, and the last state (outlined) is the representation. The values are made up.

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 x1…xLx_1 \dots x_L of the words of a text one at a time. It keeps a hidden state hth_t, a vector of size H, and updates it after each word:

ht=tanh⁡(Whht−1+Wxxt+b),h_t = \tanh(W_h h_{t-1} + W_x x_t + b),

starting from h0=0h_0 = 0. The matrices WhW_h, of size H × H, and WxW_x, of size H × D, and the bias bb 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 hth_t depends on all the words x1…xtx_1 \dots x_t, 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 WhW_h 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 hLh_L is the fixed-size representation of the text, and a head reads it, for instance a logistic regression as in section 3.2:

p(positive∣x1…xL)=σ(w⋅hL+c).p(\text{positive} \mid x_1 \dots x_L) = \sigma(w \cdot h_L + c).

The RNN also gives a vector for each word, the state hth_t after this word, which is what tagging needs (section 1.4): a classifier applied to each hth_t gives a label to each word. But hth_t only knows the words up to tt. 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 h1…hLh_1 \dots h_L 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 tt is a product of L−tL - t matrices, one per step:

∂hL∂ht=∂hL∂hL−1⋯∂ht+1∂ht,with∂hs∂hs−1=diag(1−hs2) Wh,\frac{\partial h_L}{\partial h_t} = \frac{\partial h_L}{\partial h_{L-1}} \cdots \frac{\partial h_{t+1}}{\partial h_t}, \qquad \text{with} \quad \frac{\partial h_s}{\partial h_{s-1}} = \text{diag}\big(1 - h_s^2\big)\, W_h,

where the diagonal matrix holds the derivative of the tanh, 1−tanh⁡21 - \tanh^2, for each component of the state. Like the powers of a number, such a product tends to shrink exponentially with the distance L−tL - t 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 ctc_t, which is updated by an addition instead of a product by a matrix:

ct=ft⊙ct−1+it⊙gt,ht=ot⊙tanh⁡(ct),c_t = f_t \odot c_{t-1} + i_t \odot g_t, \qquad h_t = o_t \odot \tanh(c_t),

where ⊙\odot is the product component by component. The vector gt=tanh⁡(Wg[ht−1,xt]+bg)g_t = \tanh(W_g [h_{t-1}, x_t] + b_g) is a candidate update, computed from the previous state and the current word, like the state of the plain RNN. And ftf_t, iti_t and oto_t are three gates: vectors of numbers between 0 and 1, computed in the same way but with a sigmoid, for instance ft=σ(Wf[ht−1,xt]+bf)f_t = \sigma(W_f [h_{t-1}, x_t] + b_f), each with its own weights. The forget gate ftf_t says how much of each component of the cell is kept, the input gate iti_t how much of the candidate is added, and the output gate oto_t how much of the cell goes into the state hth_t, which is what the next step and the head read.

From ct−1c_{t-1} to ctc_t, 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
Data: random embeddings · Compute time: < 1 min

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.

Starter code
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

Output of the cell

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

Output of the cell

What to notice

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 σ(0)=0.5\sigma(0) = 0.5, 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 σ(3)≈0.95\sigma(3) \approx 0.95, 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 WhW_h 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
Data: IMDB · Compute time: 8 min

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.

Starter code
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
What to notice

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 the two reviews
The two hard reviews with the same words, read by the LSTM of exercise 7.2b. Each column of cells is the state after a word, restricted to the 8 units with the largest weights in the head. Each cell shows how much its unit pushes the score of the head toward positive or negative (its value times its weight), with the colors of the figure of section 3.2. The curve is the probability of being positive that the head gives after each word. Move the slider to read the reviews word by word.

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 tt, a head reads the state hth_t and gives the probabilities of the next word, with a softmax over the vocabulary, as in Bengio’s model:

q(xt+1∣x1…xt)=softmax(Uht+d).q(x_{t+1} \mid x_1 \dots x_t) = \text{softmax}(U h_t + d).

The state hth_t 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 UU 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
Data: WikiText-2 · Compute time: 6 min

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).

Starter code
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
What to notice

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.

A character-level LSTM learns to write a play
Samples of 400 characters from a two-layer LSTM (256 units) trained to predict the next character of TinyShakespeare, after more and more batches of training (64 windows of 100 characters each). All the samples use the same random draws, which is why they start in the same way. The validation loss is in bits per character.

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.

Words, characters or subwords?

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 22.0=42^{2.0} = 4 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 2.0×5.5≈112.0 \times 5.5 \approx 11 bits per word: a perplexity of about 211≈2,0002^{11} \approx 2{,}000 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.

Bonus

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?

Bonus

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?

Bonus

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?

Bonus

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?

Bonus

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?

Bonus

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?

Bonus

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.

What is copied from the language model
On the left, an LSTM language model learns to predict the next word on raw text, as in section 7.3. On the right, the classifier of section 7.2. Switch between the two ways of using the language model: copy only its embedding matrix W, as section 5 did with word2vec and GloVe, or copy its embeddings and its LSTM. Either way, the head starts at random, the output layer of the language model is dropped, and the whole classifier is then trained on the labeled reviews. The parts that come from pre-training are in color. The values of the matrices are made up.

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
Data: IMDB · Compute time: 16 min

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.

Starter code
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
What to notice

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:

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 hth_t is a vector of fixed size that depends on the word xtx_t and on the words before it. So it can be seen as a vector for the word xtx_t, 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.

One vector per word, or one vector per occurrence
Word vectors drawn as arrows: the smaller the angle between two arrows, the more similar the two vectors. A static embedding gives “bank” the same vector in both sentences, closer to the words about money, its most frequent meaning. A contextual embedding computes a vector for each occurrence of the word, from its sentence: close to “river” in the first sentence, and close to “money” in the second. Schematic: the directions are made up.

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.

Bonus

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)?

Bonus

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?

Bonus

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?

Bonus

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?

Bonus

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?

Bonus

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 x1…xLx_1 \dots x_L word by word, as the classifier of section 7.2 reads a review. Its last state hLh_L 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 y1…yKy_1 \dots y_K word by word, except that its state starts from hLh_L instead of zero. So each word that it predicts depends on the words that it has already written, and on the source sentence through hLh_L. The decoder learns the probability of section 1.3, with the source sentence replaced by its representation:

p(yN+1∣y1…yN;hL).p(y_{N+1} \mid y_1 \dots y_N; h_L).

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.

An encoder–decoder translates a sentence
The encoder, an RNN, reads the English sentence word by word, as in section 7. Its last state (outlined) is the first state of the decoder, a second RNN with its own weights, which writes the French translation one word at a time. At each step, the decoder reads the word that it wrote just before, starting with a start token, and a head turns its state into the probabilities of the next word. The last state of the encoder is the only link between the two sentences. The values are made up.
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 hLh_L, 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
Data: random digits · Compute time: 3 min

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.

Starter code
# 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.......

Output of the cell

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.......

Output of the cell

What to notice

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,

x1…xL⟶h1…hL,x_1 \dots x_L \longrightarrow h_1 \dots h_L,

and the decoder can read all of them, instead of the last one only:

p(yN+1∣y1…yN;h1…hL).p(y_{N+1} \mid y_1 \dots y_N; h_1 \dots h_L).

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:

cN=∑j=1LαN,j hj.c_N = \sum_{j=1}^{L} \alpha_{N,j}\, h_j.

The weights αN,j\alpha_{N,j} say which words of the source matter to write the next word. To compute them, the decoder compares its own state sNs_N with each state hjh_j of the encoder, which gives a score eN,je_{N,j}, and a softmax turns the scores into weights between 0 and 1 that add up to 1:

αN,j=exp⁡(eN,j)∑k=1Lexp⁡(eN,k).\alpha_{N,j} = \frac{\exp(e_{N,j})}{\sum_{k=1}^{L} \exp(e_{N,k})}.

The simplest score is the dot product, eN,j=sN⋅hje_{N,j} = s_N \cdot h_j, 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.

The decoder attends to the states of the encoder
The encoder–decoder of section 9.1, with attention. To write each word, the decoder reads a weighted average of all the states of the encoder: the thicker the link, the larger the weight. The weights change at each step: to write “film”, the decoder looks at “movie”, and to write “génial”, it goes back to “great”, since French puts the adjective after the noun. Hover over a word of the translation to see its weights. The values and the weights are made up.

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 hjh_j 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
Data: random digits · Compute time: 6 min

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.

Starter code
# 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

Output of the cell

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

Output of the cell

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

Output of the cell

# (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

Output of the cell

What to notice

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.

nn-grams of the candidatein the referenceprecision
1I, loved, this, great, film4 of 50.80
2I loved, loved this, this great, great film3 of 40.75
3I loved this, loved this great, this great film2 of 30.67
4I loved this great, loved this great film1 of 20.50

BLEU is the geometric mean of the four precisions, (0.80×0.75×0.67×0.50)1/4=0.67(0.80 \times 0.75 \times 0.67 \times 0.50)^{1/4} = 0.67, 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:

BLEU=BP⋅(p1 p2 p3 p4)1/4,BP=min⁡(1, e1−r/c),\text{BLEU} = \text{BP} \cdot \left(p_1\, p_2\, p_3\, p_4\right)^{1/4}, \qquad \text{BP} = \min\left(1,\ e^{1 - r/c}\right),

where pnp_n is the precision of the n-grams, cc the number of words of the candidate, and rr 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 ff into English, Brown et al. look for the English sentence ee that maximizes Pr⁡(e)Pr⁡(f∣e)\Pr(e) \Pr(f \mid e): a language model Pr⁡(e)\Pr(e), such as the n-gram models of section 2, and a translation model Pr⁡(f∣e)\Pr(f \mid e), 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.

Bonus

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.

Bonus

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.

Bonus

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?

Bonus

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?

Bonus

The score of Bahdanau et al. Replace the dot product of exercise 9.2 by the small network of Bahdanau et al., eN,j=v⋅tanh⁡(WsN+Uhj)e_{N,j} = v \cdot \tanh(W s_N + U h_j), with WW and UU of size H × H and vv of size H (Bahdanau et al., 2014). Does it learn faster?

Bonus

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 tt, an RNN needs the state after word t−1t - 1, 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.

A layer of self-attention
A layer of self-attention reads the review B of section 7. Each word enters as a vector: its embedding plus an embedding of its position. The layer computes a new vector for every word at the same time, a weighted average of the vectors of all the words. The faint links join every word to every word, and the thick ones show the weights of the word under the mouse. Hover over a word to see its weights. The values and the weights are made up.

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!

The architecture of the transformer: on the left, the encoder, a stack of N layers of multi-head attention and feed-forward networks, reading the input embeddings plus positional encodings; on the right, the decoder, a stack of N layers of masked multi-head attention, multi-head attention over the outputs of the encoder, and feed-forward networks, followed by a linear layer and a softmax that give the output probabilities.
The transformer, an encoder–decoder for translation. The encoder (left) and the decoder (right) are stacks of N = 6 identical layers, and the orange boxes are the layers of attention. Figure from Vaswani et al. (2017).

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:

xCLS x1…xL⟶hCLS h1…hL.x_{\text{CLS}}\, x_1 \dots x_L \longrightarrow h_{\text{CLS}}\, h_1 \dots h_L.

[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.

BERT reads a review
The encoder–head diagram of section 3, one last time. BERT reads the review with the token [CLS] at its start and the token [SEP], which marks the end of a sentence, at its end. Each of its 12 layers computes a new vector for every token from the vectors of all the tokens, as in the figure of section 10.1 (a single layer is drawn). After the last layer, the new vector of [CLS] is the representation of the review, which the head turns into probabilities. Hover over a token to see the weights of its attention. The values and the weights are made up.

To fine-tune BERT for a classification task, add a head that reads hCLSh_{\text{CLS}}, 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, h1…hLh_1 \dots h_L, 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
Data: BERT · Compute time: < 1 min

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.

Starter code
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

Output of the cell

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

Output of the cell

What to notice

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
Data: IMDB · Compute time: 15 min

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.

Starter code
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
What to notice

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:

x1…xL xext⟶h1…hL hext.x_1 \dots x_L \, x_{\text{ext}} \longrightarrow h_1 \dots h_L \, h_{\text{ext}}.

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.

Bonus

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?

Bonus

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.”.

Bonus

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.

Bonus

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?

Bonus

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?

Bonus

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).

Idea

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.

Idea

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)

  1. 1913 Markov. 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
  2. 1948 Shannon. A Mathematical Theory of Communication. Bell System Technical Journal. 2.1
  3. 1951 Shannon. Prediction and Entropy of Printed English. Bell System Technical Journal. 2.2
  4. 1954 Harris. Distributional Structure. Word. 5.1
  5. 1957 Firth. A Synopsis of Linguistic Theory, 1930–1955. Studies in Linguistic Analysis. 5.1
  6. 1972 Sparck Jones. A Statistical Interpretation of Term Specificity and Its Application in Retrieval. Journal of Documentation. 3.3
  7. 1975 Salton, Wong & Yang. A Vector Space Model for Automatic Indexing. Communications of the ACM. 3.2
  8. 1986 Hinton. Learning Distributed Representations of Concepts. Annual Conference of the Cognitive Science Society. 4.1
  9. 1987 Katz. 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
  10. 1990 Deerwester et al. Indexing by Latent Semantic Analysis. Journal of the American Society for Information Science. 4.1
  11. 1990 Elman. Finding Structure in Time. Cognitive Science. 7.1
  12. 1990 Werbos. Backpropagation Through Time: What It Does and How to Do It. Proceedings of the IEEE. 7.2
  13. 1991 Hochreiter. Untersuchungen zu dynamischen neuronalen Netzen. Diploma thesis, Technische Universität München. 7.2
  14. 1993 Brown et al. The Mathematics of Statistical Machine Translation: Parameter Estimation. Computational Linguistics. 9.3
  15. 1994 Bengio, Simard & Frasconi. Learning Long-Term Dependencies with Gradient Descent is Difficult. IEEE Transactions on Neural Networks. 7.2
  16. 1997 Hochreiter & Schmidhuber. Long Short-Term Memory. Neural Computation. 7.2
  17. 1999 Chen & Goodman. An Empirical Study of Smoothing Techniques for Language Modeling. Computer Speech & Language. 2.3
  18. 2002 Pang, Lee & Vaithyanathan. Thumbs up? Sentiment Classification using Machine Learning Techniques. EMNLP. 3.2, 3.4
  19. 2002 Papineni et al. BLEU: a Method for Automatic Evaluation of Machine Translation. ACL. 9.3
  20. 2003 Bengio et al. A Neural Probabilistic Language Model. Journal of Machine Learning Research. 4.2
  21. 2003 Koehn, Och & Marcu. Statistical Phrase-Based Translation. NAACL. 9.3
  22. 2010 Mikolov et al. Recurrent Neural Network Based Language Model. Interspeech. 7.3
  23. 2011 Collobert et al. Natural Language Processing (Almost) from Scratch. Journal of Machine Learning Research. 5.1, 7
  24. 2011 Maas et al. Learning Word Vectors for Sentiment Analysis. ACL. introduction, 3.3
  25. 2012 Wang & Manning. Baselines and Bigrams: Simple, Good Sentiment and Topic Classification. ACL. 3.3, 3.4, 6
  26. 2013 Graves. Generating Sequences With Recurrent Neural Networks. arXiv. 7.3
  27. 2013 Mikolov et al. Efficient Estimation of Word Representations in Vector Space. ICLR 2013, workshop. 5.2, 5.4
  28. 2013 Mikolov et al. Distributed Representations of Words and Phrases and their Compositionality. NeurIPS. 5.2, 5.4
  29. 2013 Mikolov, Yih & Zweig. Linguistic Regularities in Continuous Space Word Representations. NAACL-HLT. 5.4
  30. 2013 Pascanu, Mikolov & Bengio. On the Difficulty of Training Recurrent Neural Networks. ICML. 7.2
  31. 2013 Socher et al. Recursive Deep Models for Semantic Compositionality Over a Sentiment Treebank. EMNLP. 5.5
  32. 2014 Bahdanau, Cho & Bengio. Neural Machine Translation by Jointly Learning to Align and Translate. arXiv (ICLR 2015). 9.2, 9.3
  33. 2014 Cho et al. Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation. EMNLP. 7.2, 9.1
  34. 2014 Cho et al. On the Properties of Neural Machine Translation: Encoder–Decoder Approaches. Workshop on Syntax, Semantics and Structure in Statistical Translation. 9.1
  35. 2014 Kim. Convolutional Neural Networks for Sentence Classification. EMNLP. 5.5, 7, 7.3
  36. 2014 Levy & Goldberg. Neural Word Embedding as Implicit Matrix Factorization. NeurIPS. 5.3, 5.5
  37. 2014 Pennington, Socher & Manning. GloVe: Global Vectors for Word Representation. EMNLP. 5.3
  38. 2014 Sutskever, Vinyals & Le. Sequence to Sequence Learning with Neural Networks. NeurIPS. 9.1
  39. 2014 Zaremba, Sutskever & Vinyals. Recurrent Neural Network Regularization. arXiv. 7.3
  40. 2015 Dai & Le. Semi-supervised Sequence Learning. NeurIPS. 7.2, 8.1
  41. 2015 Iyyer et al. Deep Unordered Composition Rivals Syntactic Methods for Text Classification. ACL. 4.3, 5.5
  42. 2015 Jozefowicz, Zaremba & Sutskever. An Empirical Exploration of Recurrent Network Architectures. ICML. 7.2, 7.3
  43. 2015 Karpathy. The Unreasonable Effectiveness of Recurrent Neural Networks. Blog post. 7.3
  44. 2015 Luong, Pham & Manning. Effective Approaches to Attention-based Neural Machine Translation. EMNLP. 9.2
  45. 2016 Bolukbasi et al. Man is to Computer Programmer as Woman is to Homemaker? Debiasing Word Embeddings. NeurIPS. 5.4
  46. 2016 Gal & Ghahramani. A Theoretically Grounded Application of Dropout in Recurrent Neural Networks. NeurIPS. 7.3
  47. 2016 Merity et al. Pointer Sentinel Mixture Models. arXiv (ICLR 2017). introduction, 7.3
  48. 2016 Sennrich, Haddow & Birch. Neural Machine Translation of Rare Words with Subword Units. ACL. 7.3, 9.3
  49. 2016 Wu et al. Google's Neural Machine Translation System: Bridging the Gap between Human and Machine Translation. arXiv. 9.3
  50. 2017 Bojanowski et al. Enriching Word Vectors with Subword Information. Transactions of the ACL. 7.3
  51. 2017 Joulin et al. Bag of Tricks for Efficient Text Classification. EACL. 4.3
  52. 2017 McCann et al. Learned in Translation: Contextualized Word Vectors. NeurIPS. 8.2
  53. 2017 Peters et al. Semi-supervised Sequence Tagging with Bidirectional Language Models. ACL. 8.2
  54. 2017 Radford, Jozefowicz & Sutskever. Learning to Generate Reviews and Discovering Sentiment. arXiv. 8.1, 8.2
  55. 2017 Vaswani et al. Attention Is All You Need. NeurIPS. 10.1
  56. 2018 Arora et al. Linear Algebraic Structure of Word Senses, with Applications to Polysemy. TACL. 6
  57. 2018 Devlin et al. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. NAACL 2019 (arXiv 2018). 10.2
  58. 2018 Howard & Ruder. Universal Language Model Fine-tuning for Text Classification. ACL. 8.1, 8.2
  59. 2018 Merity, Keskar & Socher. Regularizing and Optimizing LSTM Language Models. ICLR. 8.1
  60. 2018 Peters et al. Deep Contextualized Word Representations. NAACL. 8.2
  61. 2018 Radford et al. Improving Language Understanding by Generative Pre-Training. OpenAI report. 10.3
  62. 2019 Radford et al. Language Models are Unsupervised Multitask Learners. OpenAI report. 10.3
  63. 2019 Sun, Qiu, Xu & Huang. How to Fine-Tune BERT for Text Classification?. CCL 2019. 10.2, 10.3
  64. 2020 Brown et al. Language Models are Few-Shot Learners. NeurIPS. 10.3
  65. 2020 Nissim, van Noord & van der Goot. Fair is Better than Sensational: Man is to Doctor as Woman is to Doctor. Computational Linguistics. 5.4, 5.5
  66. 2022 Ouyang et al. Training Language Models to Follow Instructions with Human Feedback. NeurIPS. 10.3
  67. 2023 Herel & Mikolov. Advancing State of the Art in Language Modeling. arXiv. 2.2, 2.3, 6