makemore: bigram exercises¶
- watch the "makemore: bigram" video on YouTube
- come back and complete these exercises to level up :)
Exercises¶
- Train a trigram language model, i.e. take two characters as an input to predict the 3rd one. Feel free to use either counting or a neural net. Evaluate the loss; Did it improve over a bigram model?
- Split up the dataset randomly into 80% train set, 10% dev set, 10% test set. Train the bigram and trigram models only on the training set. Evaluate them on dev and test splits. What can you see?
- Use the dev set to tune the strength of smoothing (or regularization) for the trigram model - i.e. try many possibilities and see which one works best based on the dev set loss. What patterns can you see in the train and dev set loss as you tune this strength? Take the best setting of the smoothing and evaluate on the test set once and at the end. How good of a loss do you achieve?
- We saw that our 1-hot vectors merely select a row of
W, so producing these vectors explicitly feels wasteful. Can you delete our use ofF.one_hotin favor of simply indexing into rows ofW? - Look up and use
F.cross_entropyinstead. You should achieve the same result. Can you think of why we'd prefer to useF.cross_entropyinstead? - Meta-exercise! Think of a fun/interesting exercise and complete it.
import math, random
import numpy as np
import pandas as pd
import seaborn as sns
import torch
import torch.nn.functional as F
import nltk
from collections import defaultdict
from tqdm import tqdm
from pprint import pprint
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize
%matplotlib inline
words = open('../data/names.txt', 'r').read().splitlines()
words[:10]
['emma', 'olivia', 'ava', 'isabella', 'sophia', 'charlotte', 'mia', 'amelia', 'harper', 'evelyn']
print(f"the shortest word length in the corpus is: {min(len(w) for w in words)}")
print(f"the longest word length in the corpus is: {max(len(w) for w in words)}")
print(f"the length of the corpus document (num of words) is: {len(words)}")
the shortest word length in the corpus is: 2 the longest word length in the corpus is: 15 the length of the corpus document (num of words) is: 32033
t = {}
for w in words:
chs = ['.'] + list(w) + ['.']
for ch1, ch2, ch3 in zip(chs, chs[1:], chs[2:]):
trigram = (ch1, ch2, ch3)
t[trigram] = t.get(trigram, 0) + 1
pprint(f'the 10 most common trigrams: {sorted(t.items(), key = lambda kv: -kv[1])[:10]}')
("the 10 most common trigrams: [(('a', 'h', '.'), 1714), (('n', 'a', '.'), "
"1673), (('a', 'n', '.'), 1509), (('o', 'n', '.'), 1503), (('.', 'm', 'a'), "
"1453), (('.', 'j', 'a'), 1255), (('.', 'k', 'a'), 1254), (('e', 'n', '.'), "
"1217), (('l', 'y', 'n'), 976), (('y', 'n', '.'), 953)]")
N = torch.zeros((27, 27, 27), dtype=torch.int32)
chars = sorted(list(set(''.join(words))))
stoi = {s:i+1 for i,s in enumerate(chars)}
stoi['.'] = 0
itos = {i:s for s,i in stoi.items()}
for w in words:
chs = ['.'] + list(w) + ['.']
for ch1, ch2, ch3 in zip(chs, chs[1:], chs[2:]):
ix1 = stoi[ch1]
ix2 = stoi[ch2]
ix3 = stoi[ch3]
N[ix1, ix2, ix3] +=1
N[0][1]
tensor([ 0, 207, 190, 31, 366, 55, 21, 17, 91, 154, 27, 75, 632, 384,
623, 10, 17, 9, 482, 194, 72, 152, 243, 6, 27, 173, 152],
dtype=torch.int32)
p = N[0][1].float()
p = p / p.sum()
g = torch.Generator().manual_seed(2147483647)
ix = torch.multinomial(p, num_samples=1, replacement=True, generator=g).item()
p.shape, ix, itos[ix]
(torch.Size([27]), 18, 'r')
P = (N+1).float()
P /= P.sum(2, keepdim=True)
g = torch.Generator().manual_seed(2147483647)
for i in range(5):
out = []
ix, jx = 0, 0
while True:
p = P[ix][jx]
next_letter = torch.multinomial(p, num_samples=1, replacement=True, generator=g).item()
ix = jx
jx = next_letter
out.append(itos[next_letter])
if next_letter == 0:
break
print(''.join(out))
quia. yu. quinslyntien. nolliahi. ha.
%%time
log_likelihood = 0.0
n = 0
for w in words:
chs = ['.'] + list(w) + ['.']
for ch1, ch2, ch3 in zip(chs, chs[1:], chs[2:]):
ix1 = stoi[ch1]
ix2 = stoi[ch2]
ix3 = stoi[ch3]
prob = P[ix1, ix2, ix3]
logprob = torch.log(prob)
log_likelihood += logprob
n += 1
print(f'\nlog_likelihood = {log_likelihood:.4f}')
nll = -log_likelihood
print(f'negative log_likelihood = {nll:.4f}\n')
avg_nll = nll/n
print(f'average negative log_likelihood = {avg_nll:.4f}')
log_likelihood = -410414.9688 negative log_likelihood = 410414.9688 average negative log_likelihood = 2.0927 CPU times: user 1.51 s, sys: 19.2 ms, total: 1.53 s Wall time: 1.56 s
def create_trigrams(words_list:list):
xs, ys = [], []
for w in words_list:
chs = ['.'] + list(w) + ['.']
for ch1, ch2, ch3 in zip(chs, chs[1:], chs[2:]):
ix1 = stoi[ch1]
ix2 = stoi[ch2]
ix3 = stoi[ch3]
#print(ch1, ch2, ch3)
xs.append([ix1, ix2])
ys.append(ix3)
xs = torch.tensor(xs)
ys = torch.tensor(ys)
return xs, ys
# Create dataset and initialize the neural net
xnn, ynn = create_trigrams(words)
num = ynn.nelement()
g = torch.Generator().manual_seed(2147483647)
W_nn = torch.randn((27*2, 27), generator=g, requires_grad=True)
num == ynn.shape[0]
True
%%time
n_iter_nn, lr_nn, C_nn = 250, 50, 0.0
losses_nn = []
# gradient descent
for k in range(n_iter_nn):
# forward pass
xenc_nn = F.one_hot(xnn, num_classes=27).float() # input to the network: one-hot encoding
logits_nn = xenc_nn.view(-1, 27*2) @ W_nn # predict log-counts
counts_nn = logits_nn.exp() # counts, equivalent to N
probs_nn = counts_nn / counts_nn.sum(1, keepdim=True) # probabilities for next character
loss_nn = -probs_nn[torch.arange(num), ynn].log().mean() + C_nn*(W_nn**2).mean()
#print(loss_nn.item())
losses_nn.append(loss_nn)
if k > (n_iter_nn - 5):
print(loss_nn.item())
# backward pass
W_nn.grad = None # set to zero the gradient
loss_nn.backward()
# update
W_nn.data += -lr_nn * W_nn.grad
print(f'\naverage negative log_likelihood = {loss_nn.item():.4f}\n')
2.245729923248291 2.24568247795105 2.245635747909546 2.2455894947052 average negative log_likelihood = 2.2456 CPU times: user 48.2 s, sys: 7.36 s, total: 55.6 s Wall time: 25.3 s
def data_split(xs, ys, train_percent:int, val_percent:int, test_percent:int):
'''
split dataset into train, validation and test sets
'''
assert train_percent + val_percent + test_percent == 100
total = len(xs)
train_end, val_end = int(total * train_percent / 100), int(total * (train_percent + val_percent) / 100)
Xtr, Ytr = xs[:train_end], ys[:train_end]
Xval, Yval = xs[train_end:val_end], ys[train_end:val_end]
Xtest, Ytest = xs[val_end:], ys[val_end:]
assert len(Xtr) == len(Ytr)
assert len(Xval) == len(Yval)
assert len(Xtest) == len(Ytest)
return Xtr, Ytr, Xval, Yval, Xtest, Ytest
xnn2, ynn2 = create_trigrams(words)
x_tr, y_tr, x_val, y_val, x_test, y_test = data_split(xnn2, ynn2, 80, 10, 10)
g = torch.Generator().manual_seed(2147483647)
W_nn2 = torch.randn((27*2, 27), generator=g, requires_grad=True)
print(f'{len(x_tr)/len(xnn2)*100:.0f}% training set')
print(f'{len(x_val)/len(xnn2)*100:.0f}% validation set')
print(f'{len(x_test)/len(xnn2)*100:.0f}% test set')
80% training set 10% validation set 10% test set
%%time
losses_tr = []
n_iter_tr, lr_tr, C_tr = 250, 50, 0.0
# gradient descent
for k in range(n_iter_tr):
# forward pass
xenc_tr = F.one_hot(x_tr, num_classes=27).float()
logits_tr = xenc_tr.view(-1, 27*2) @ W_nn2
counts_tr = logits_tr.exp()
probs_tr = counts_tr / counts_tr.sum(1, keepdim=True) + C_tr*(W_nn2**2).mean()
loss_tr = -probs_tr[torch.arange(y_tr.shape[0]), y_tr].log().mean()
#print(loss_tr.item())
losses_tr.append(loss_tr)
if k > (n_iter_tr - 5):
print(loss_tr.item())
# backward pass
W_nn2.grad = None # set to zero the gradient
loss_tr.backward()
# update
W_nn2.data += -lr_tr * W_nn2.grad
#print(f'\naverage negative log_likelihood = {loss_tr.item():.4f}\n')
print(f"\ntrain_loss (final): {losses_tr[-1]:.4f}\n")
plt.plot(losses_tr)
plt.xlabel("epoch")
plt.ylabel("loss")
plt.title("Loss on training data")
plt.grid()
plt.plot()
2.20723819732666 2.2071897983551025 2.207141637802124 2.207094192504883 train_loss (final): 2.2071 CPU times: user 37.7 s, sys: 6.08 s, total: 43.8 s Wall time: 19.6 s
[]
xenc_val = F.one_hot(x_val, num_classes=27).float()
logits_val = xenc_val.view(-1, 27*2) @ W_nn2
counts_val = logits_val.exp()
probs_val = counts_val/ counts_val.sum(1, keepdim=True)
loss_val = -probs_val[torch.arange(y_val.shape[0]), y_val].log().mean()
print(f"validation loss: {loss_val.item():.4f}")
validation loss: 2.4321
xenc_test = F.one_hot(x_test, num_classes=27).float()
logits_test = xenc_test.view(-1, 27*2) @ W_nn2
counts_test = logits_test.exp()
probs_test = counts_test/ counts_test.sum(1, keepdim=True)
loss_test = -probs_test[torch.arange(y_test.shape[0]), y_test].log().mean()
print(f"test loss: {loss_test.item():.4f}")
test loss: 2.4324
For the trigram model, the loss after training the model on the training set was 2.2071. Evaluating the model on the validation and test set yielded losses of 2.4321 and 2.4324 respectively. The test and validation losses are very similar and marginally higher than the traiing loss so that means the model is not overfitting (generalizes well to new unseen data: model robustness).
num_tr, num_val, num_test = y_tr.nelement(), y_val.nelement(), y_test.nelement()
reg_param_list = [0, 0.001, 0.01, 0.02, 0.05, 0.075, 0.1, 0.25, 0.3, 0.4, 0.5]
#reg_param_list = np.geomspace(0.001, 1, 12)
loss_dict = {} # <reg_strength: [final train loss, final val loss]>
n_iter = 200
lr = 50
for C in tqdm(reg_param_list):
# Initialize model & losses
W_reg = torch.randn((27*2, 27), generator=g, requires_grad=True)
#tr_losses, val_losses= [], []
# gradient descent
for k in range(n_iter):
# forward pass
xenc = F.one_hot(x_tr, num_classes=27).float()
logits = xenc.view(-1, 27*2) @ W_reg
counts = logits.exp()
probs = counts / counts.sum(1, keepdim=True)
tr_loss = -probs[torch.arange(num_tr), y_tr].log().mean() + C*(W_reg**2).mean()
#print(loss.item())
#tr_losses.append(tr_loss)
# Zero grad & backward pass
W_reg.grad = None
tr_loss.backward()
# update
W_reg.data += -lr * W_reg.grad
xenc_val = F.one_hot(x_val, num_classes=27).float()
logits = xenc_val.view(-1, 27*2) @ W_reg
counts = logits.exp()
probs = counts / counts.sum(1, keepdim=True)
val_loss = -probs[torch.arange(num_val), y_val].log().mean()
print(f'For regularization parameter = {C:.4f} ---> train loss: {tr_loss.item():.4f},\
validation loss: {val_loss.item():.4f}')
_tr_loss = np.round(tr_loss.item(), decimals=4)
_val_loss = np.round(val_loss.item(), decimals=4)
_C = np.round(C, decimals=4)
loss_dict[_C] = [_tr_loss, _val_loss]
9%|▉ | 1/11 [00:16<02:41, 16.11s/it]
For regularization parameter = 0.0000 ---> train loss: 2.2110, validation loss: 2.4363
18%|█▊ | 2/11 [00:34<02:30, 16.78s/it]
For regularization parameter = 0.0010 ---> train loss: 2.2108, validation loss: 2.4331
27%|██▋ | 3/11 [00:53<02:18, 17.34s/it]
For regularization parameter = 0.0100 ---> train loss: 2.2212, validation loss: 2.4338
36%|███▋ | 4/11 [01:14<02:09, 18.53s/it]
For regularization parameter = 0.0200 ---> train loss: 2.2312, validation loss: 2.4321
45%|████▌ | 5/11 [01:30<01:46, 17.70s/it]
For regularization parameter = 0.0500 ---> train loss: 2.2539, validation loss: 2.4331
55%|█████▍ | 6/11 [01:45<01:24, 16.88s/it]
For regularization parameter = 0.0750 ---> train loss: 2.2698, validation loss: 2.4351
64%|██████▎ | 7/11 [02:04<01:10, 17.53s/it]
For regularization parameter = 0.1000 ---> train loss: 2.2833, validation loss: 2.4390
73%|███████▎ | 8/11 [02:19<00:50, 16.99s/it]
For regularization parameter = 0.2500 ---> train loss: 2.3468, validation loss: 2.4605
82%|████████▏ | 9/11 [02:34<00:32, 16.26s/it]
For regularization parameter = 0.3000 ---> train loss: 2.3635, validation loss: 2.4672
91%|█████████ | 10/11 [02:51<00:16, 16.50s/it]
For regularization parameter = 0.4000 ---> train loss: 2.3929, validation loss: 2.4798
100%|██████████| 11/11 [03:06<00:00, 16.91s/it]
For regularization parameter = 0.5000 ---> train loss: 2.4185, validation loss: 2.4913
loss_dict
{0: [2.211, 2.4363],
0.001: [2.2108, 2.4331],
0.01: [2.2212, 2.4338],
0.02: [2.2312, 2.4321],
0.05: [2.2539, 2.4331],
0.075: [2.2698, 2.4351],
0.1: [2.2833, 2.439],
0.25: [2.3468, 2.4605],
0.3: [2.3635, 2.4672],
0.4: [2.3929, 2.4798],
0.5: [2.4185, 2.4913]}
plt.title(f"Effect of Regularization/Smoothing on Trigram Model")
plt.plot(list(loss_dict.keys()), [train_loss for train_loss, _ in loss_dict.values()], "o--", label="train")
plt.plot(list(loss_dict.keys()), [val_loss for _, val_loss in loss_dict.values()], "o--", label="val")
plt.xlabel("Regularization Strength, C")
plt.ylabel("Loss")
plt.grid()
plt.legend()
plt.plot()
#plt.show()
[]
Increasing the regularization parameter C increases both the validation and training losses, but the training losses grow faster than the validation losses.
Utilising the inputs xnn and weight matrix W_nn from problem 1b (neural network approach for trigram LM). Let's compare the results for one-hot encoding xnn, (xenc_nn) and indexing into rows of W_nn.
logits_idx = W_nn[xnn[:, 0]] + W_nn[xnn[:, 1] + 27]
counts_idx = logits_idx.exp()
probs_idx = counts_idx / counts_idx.sum(1, keepdim=True)
loss_idx = -1 * probs_idx[torch.arange(ynn.shape[0]), ynn].log().mean()
#loss_idx
print(f"validation loss via indexing into rows of W: {loss_idx.item():.4f}")
validation loss via indexing into rows of W: 2.2455
logits_ohe = xenc_nn.view(-1, 27*2) @ W_nn
counts_ohe = logits_ohe.exp()
probs_ohe = counts_ohe / counts_ohe.sum(1, keepdim=True)
loss_ohe = -1 * probs_ohe[torch.arange(ynn.shape[0]), ynn].log().mean()
#loss_ohe
print(f"validation loss via one-hot encoding: {loss_ohe.item():.4f}")
validation loss via one-hot encoding: 2.2455
As we can see from the cells above, we get the same results using either one-hot encoding or indexing into rows of W.
logits = xenc_nn.view(-1, 2*27) @ W_nn
counts = logits.exp()
probs = counts / counts.sum(1, keepdim=True)
loss = -probs[torch.arange(ynn.shape[0]), ynn].log().mean()
print(f"Average Negative Log Likelihood Loss: {loss.item():.4f}")
Average Negative Log Likelihood Loss: 2.2455
loss_ce = F.cross_entropy(logits, ynn)
print(f"Cross Entropy Loss: {loss_ce.item():.4f}")
Cross Entropy Loss: 2.2455
We get the same results using both methods. However, F.cross_entropy is preferable because it combines the softmax step and loss function step together at once. Basically, F.cross_entropy incorporates transforming the neural net outputs to probabilities with calculating the loss in one step (logits -> losses)
- NN outputs to probabilities:
logits-> [exp(x)] ->counts-> [normalize(x)] ->probs - Probabilities to losses:
probs-> [Average negative log likelihood(x)] ->losses
Implementation steps of class:
- Initialize
- Apply a linear transformation to incoming data:
y = xA.T + b, usingtorch.nn.Linear - Initialize weights with normal distribution (mean of 0 and STD of 1)
- Apply a linear transformation to incoming data:
- Forward function
- No activation function
- No activation function
xs, ys = [], []
for w in words:
chs = ['.'] + list(w) + ['.']
for ch1, ch2, ch3 in zip(chs, chs[1:], chs[2:]):
ix1 = stoi[ch1]
ix2 = stoi[ch2]
ix3 = stoi[ch3]
#print(ch1, ch2, ch3)
xs.append([ix1, ix2])
ys.append(ix3)
xs = torch.tensor(xs)
ys = torch.tensor(ys)
class MLP(torch.nn.Module):
def __init__(self):
super().__init__()
self.fc1 = torch.nn.Linear(2*27, 27) # first fully connected layer of MLP
torch.nn.init.normal_(self.fc1.weight, mean=0, std=1)
#torch.nn.init.xavier_uniform(self.fc1.weight)
def forward(self, x):
logits = W_nn[x[:, 0]] + W_nn[x[:, 1] + 27]
return logits
C = 0
model = MLP()
# gradient descent
for k in range(250):
# forward pass
logits = model(xs)
loss = F.cross_entropy(logits, ys)
loss += C * (model.fc1.weight ** 2).mean() # added regularization term
# if k % 10 == 0:
# print(f'After {k} iterations, the loss is {loss.item():.4f}')
# backward pass
model.fc1.zero_grad() # set to zero the gradient (reset gradients of the layer)
loss.backward()
# update
model.fc1.weight.data += -50 * model.fc1.weight.grad
print(f"After {k} iterations, the loss is {loss.item():.4f}")
After 249 iterations, the loss is 2.2455
model.fc1.weight, model.fc1.bias
(Parameter containing:
tensor([[ 1.7855, 0.3460, -1.2854, ..., 0.5336, -0.8839, 0.3031],
[ 0.1225, -1.0614, 1.0801, ..., -0.3535, -0.5087, -1.2049],
[-0.0594, -0.4437, 0.3638, ..., 0.1334, 0.1140, 0.4028],
...,
[-1.1852, 1.0893, -1.8360, ..., 0.0390, 1.0074, 0.5208],
[ 1.0159, -0.2812, 1.5159, ..., -0.1665, 1.3235, -0.6612],
[ 2.1103, 0.6758, -1.7507, ..., 0.7648, 0.6888, -0.0393]],
requires_grad=True),
Parameter containing:
tensor([-0.0118, 0.0219, 0.0048, 0.0952, -0.0755, -0.1241, 0.0293, -0.0502,
-0.1163, 0.0890, 0.1085, -0.1187, -0.0349, 0.0860, -0.1105, -0.0938,
-0.0551, -0.1208, -0.0525, -0.0991, -0.0487, 0.1044, -0.0580, -0.1296,
0.1280, -0.0386, -0.1206], requires_grad=True))
C = 0
model2 = MLP()
optimizer = torch.optim.SGD(model2.parameters(), lr = 50)
# gradient descent
for k in range(250):
# forward pass
logits = model(xs)
loss = F.cross_entropy(logits, ys)
loss += C * (model.fc1.weight.data ** 2).mean() # added regularization term
# if k % 10 == 0:
# print(f'After {k} iterations, the loss is {loss.item():.4f}')
# Backward pass
optimizer.zero_grad()
loss.backward()
# Update the parameters
optimizer.step()
print(f"After {k} iterations, the loss is {loss.item():.4f}")
After 249 iterations, the loss is 2.2455
model2.fc1.weight.shape, model2.fc1.bias.shape
(torch.Size([27, 54]), torch.Size([27]))
model2.fc1.weight, model2.fc1.bias
(Parameter containing:
tensor([[-1.1215, -0.0396, 0.1958, ..., -0.2544, 0.4298, -0.4357],
[ 1.3369, -0.1873, -1.1479, ..., -0.8545, 0.0814, 2.1752],
[ 0.1292, 1.4875, 0.2979, ..., 0.1874, 0.9836, -0.0892],
...,
[-0.6958, 0.1216, -1.8677, ..., 1.2131, 0.4728, -0.7404],
[ 0.8460, 0.1320, 0.7196, ..., -0.0559, -0.4908, -0.4020],
[-0.6621, 1.7270, -1.1435, ..., 1.6317, 0.1695, 0.8134]],
requires_grad=True),
Parameter containing:
tensor([ 0.0715, -0.0081, -0.0726, -0.0865, 0.0413, -0.1155, -0.0556, -0.1025,
-0.0778, 0.0568, 0.1281, -0.0204, -0.0799, 0.0951, -0.0248, 0.0988,
0.1246, -0.0578, -0.0071, 0.0510, 0.0348, -0.1114, 0.0883, -0.0448,
-0.0798, -0.1314, -0.0539], requires_grad=True))
# forward pass
logits = model2(xnn)
print("Shape of logits:", logits.shape)
print("Shape of ynn:", ynn.shape)
loss = F.cross_entropy(logits, ynn)
loss
Shape of logits: torch.Size([196113, 27]) Shape of ynn: torch.Size([196113])
tensor(2.2455, grad_fn=<NllLossBackward0>)