← Zero to Hero notes

Makemore Part 2 - Multilayer Perceptron

Video: Building makemore Part 2: MLP

Background

Based on the paper A Neural Probabilistic Language Model by Bengio et al. in 2003.

Problem

N-gram language models are limited by the curse of dimensionality.

figure

Since the number of permutations of sequences (and thus number of model parameters) scale exponentially with N, it is not practical to scale N even though there is obvious value in considering more context.

At the same time, the high number of sequence permutations makes it is unlikely that sequences in the test set will also be in the training set, which limits the model’s ability to generalize.

Multilayer Perceptron (MLP)

The MLP model solves the curse of dimensionality by projecting high-dimensional word sequences (in discrete space) into a lower-dimensional vector space “embedding”, which captures the semantic relationship between words (in smooth continuous space).

Instead of learning the joint distribution of exact word sequences directly, the MLP model simultaneously learns the embedding and the joint distribution of word sequences expressed by the embedding. With this approach, the number of parameters only scale linearly with V.

Embedding

The sentence “The cat is walking in the bedroom” should help us generalize to make the sentence “A dog was running in a room” because “cat” and “dog” (resp. “is” and “was”, … etc) have similar semantic and grammatical roles.

figure

A hypothetical 3-dimensional embedding of these words might look something like this. Each word is represented by a 3-dimensional vector. Words that are semantically similar would be “close” to each other, and the similarity of two words can be measured by the distance between the two vectors with cosine similarity.

figure

The higher the number of dimensions in the embedding, the deeper the semantic relationship potentially captured.

Network Architecture

There are 3 layers to the neural network: the input layer, the hidden layer, and the output layer.

First, each of the N previous words in the context are transformed into vectors via a learned embedding lookup table C with D number of dimensions. These embedded vectors are concatenated to form the values of the input layer, so the size of the input layer is simply NxD.

Then, the hidden layer is a linear layer with learned weights W_1 and biases B_1 and a tanh activation function. The size of the hidden layer is a hyper-parameter.

Finally, the output layer is also a linear layer with learned weights W_2, biases B_2 and a softmax activation function. The size of the output layer is V (number of words in the vocabulary).

figure

Implementation

Let’s implement a MLP, but at the character level instead of at the word level. We will be using the same list of names we used in our previous bigram model.

Data

We will split the list of words into train (80%), validation (10%) and test (10%). Each set of words will then be made into blocks of 5 characters, where the first 4 characters will be used as the context, and the 5th character will be used as the target.

import torch
import torch.nn.functional as F

# read in all the words
words = open('names.txt', 'r').read().splitlines()

# build the vocabulary of characters and mappings to/from integers
chars = sorted(list(set(''.join(words))))
stoi = {c:i+1 for i,c in enumerate(chars)}
stoi['.'] = 0
itos = {i:c for c,i in stoi.items()}
print(itos)

# build the train/test/split
block_size = 5
def build_dataset(words):
    X, Y = [], []
    for w in words:
        context = [0] * block_size
        for ch in w + '.':
            ix = stoi[ch]
            X.append(context)
            Y.append(ix)
            context = context[1:] + [ix]
    return torch.tensor(X), torch.tensor(Y)

import random
random.seed(42)
random.shuffle(words)
n1 = int(len(words) * 0.8)
n2 = int(len(words) * 0.9)

Xtr, Ytr = build_dataset(words[:n1])
Xdev, Ydev = build_dataset(words[n1:n2])
Xte, Yte = build_dataset(words[n2:])

Training

Below is the code for model initialization and training. Once we understand the background of the MLP model, the actual code to train it is pretty straightforward.

g = torch.Generator().manual_seed(2147483647)

chars = 27 # number of characters in vocabulary
dimensions = 10 # number of dimensions in embedding
neurons = 10 # number of neurons in hidden layer

# initialize parameters with random values
C = torch.randn((chars, dimensions), generator=g)
W1 = torch.randn((dimensions*block_size, neurons), generator=g)
b1 = torch.randn(neurons, generator=g)
W2 = torch.randn((neurons, chars), generator=g)
b2 = torch.randn(chars, generator=g)
params = [C, W1, b1, W2, b2]

# tell PyTorch to track gradients for all parameters
for p in params:
    p.requires_grad_()

stepi = []
losstri = []
training_steps = 200000
for i in range(training_steps):
    # minibatch
    batch_size = 32
    ix = torch.randint(0, Xtr.shape[0], (batch_size,))

    # forward pass on just minibatch
    emb = C[Xtr[ix]]
    h = torch.tanh(emb.view(-1, block_size*dimensions) @ W1 + b1)
    logits = h @ W2 + b2
    loss = F.cross_entropy(logits, Ytr[ix])

    # backward pass
    for p in params:
        p.grad = None
    loss.backward()

    # update parameters
    lr = 0.1 if i < 100000 else 0.01
    for p in params:
        p.data -= lr * p.grad

    # track stats
    stepi.append(i)
    losstri.append(loss.item())

    print(i, loss.item())
figure

Evaluation

Note that the loss we looked at above was only the loss of each mini batch during the training phase. Here is the actual loss on the train, val, and test splits.

for X, Y in [(Xtr, Ytr), (Xdev, Ydev), (Xte, Yte)]:
    emb = C[X] # embedding lookup (32, 3, 2)
    h = torch.tanh(emb.view(-1, block_size*dimensions) @ W1 + b1) # (32, 100)
    logits = h @ W2 + b2 # (32, 27)
    loss = F.cross_entropy(logits, Y) # (32, 100)
    print(loss.item())

# 2.2879016399383545
# 2.2845089435577393
# 2.2882678508758545

Cross Entropy Loss

We are actually using the same loss function (negative log likelihood) as before. But instead of writing it out we are using PyTorch cross_entropy which is the same thing.

Minibatch

Instead of feeding in the entire dataset of training data to the forward pass on every training loop, we only feed in a randomly sampled “minibatch” of training data.

This makes the training loop much faster.

Learning rate decay

Instead of using a single learning rate throughout the training loop, we decay the learning rate in fixed intervals of steps to help the model “fine tune” itself.

Visualizing the character embedding

Here is a plot of the first dimension of the embedding. You can sort of see some structure here: the vowels are grouped together in the top left region, all the consonants are in the middle area, and the letters “v” and “q” are far away in the bottom right.

figure

Sampling from the model

Here is the code to sample from the model. We initialize the context with all . characters so the model is free to generate any name.

# sample fom the model
g = torch.Generator().manual_seed(2147483647)
for _ in range(1000):
    out = []
		# initialize context with all `.` characters
    context = [0] * (block_size)
    while True:
        emb = C[torch.tensor([context])]
        h = torch.tanh(emb.view(1, -1) @ W1 + b1)
        logits = h @ W2 + b2
        probs = F.softmax(logits, dim=1)
        ix = torch.multinomial(probs, 1, generator=g).item()
        context = context[1:] + [ix]
        out.append(ix)
        if ix == 0:
            break

    print(''.join([itos[i] for i in out[:-1]]))