Build a Bigram Character-Level Language Model with PyTorch
Creating a Character-Level Language Model: Bigram Model from Scratch
This guide builds a "Make More" neural network step by step, starting with the simplest possible character-level language model, the bigram model. You'll learn the fundamentals of predicting the next character in a sequence, which forms the foundation for more advanced architectures like RNNs and Transformers.
Understanding the Dataset: names.txt
The dataset contains approximately 32,000 names gathered from a government website. Each name (like "Emma", "Olivia", "Ava") is treated as a sequence of individual characters. For example, "Reese" becomes: r → e → e → s → e.
Each word provides multiple training examples:
- The first character (I in "Isabella") is likely to start a name
- Each character predicts the next one (s follows I, a follows s, etc.)
- The final character tells us the name is likely to end
Building a Bigram Language Model
A bigram model only looks at one previous character to predict the next one. It's simple but a great starting point. To understand how this fits into the broader landscape, check out this introduction to what large language models are and how they work.
Step 1: Extract Bigrams from Words
To capture all useful information, we add special start (.) and end (.) tokens to each word:
import torch
words = open('names.txt', 'r').read().splitlines()
# Create a mapping from characters to integers
chars = sorted(list(set(''.join(words))))
stoi = {s:i+1 for i,s in enumerate(chars)} # Start indexing at 1 for . at position 0
stoi['.'] = 0
itos = {i:s for s,i in stoi.items()}
Step 2: Count Character Pair Frequencies
Create a 27×27 count matrix (26 letters + special token) to tally how often each pair occurs:
N = torch.zeros((27, 27), dtype=torch.int32)
for w in words:
chs = ['.'] + list(w) + ['.']
for ch1, ch2 in zip(chs, chs[1:]):
ix1 = stoi[ch1]
ix2 = stoi[ch2]
N[ix1, ix2] += 1
The resulting matrix shows patterns like:
- Row for
.gives starting character probabilities (e.g., J starts words 175 times) - Column for
.gives ending character probabilities - Internal cells show character transitions (e.g., how often 'a' follows 'b')
Step 3: Convert Counts to Probabilities
Normalize each row so it sums to 1, creating a proper probability distribution using PyTorch broadcasting:
P = (N+1).float() # Add 1 for smoothing (avoids zero probabilities)
P /= P.sum(1, keepdim=True) # Normalize each row
Important: Always use keepdim=True when normalizing rows. Without it, broadcasting silently converts your column sums into row vectors, normalizing columns instead of rows!
Step 4: Sample New Names
Generate names by starting with the . token and iteratively sampling the next character:
g = torch.Generator().manual_seed(2147483647)
for _ in range(20):
out = []
ix = 0 # Start token
while True:
p = P[ix] # Get probability distribution for current character
ix = torch.multinomial(p, num_samples=1, replacement=True, generator=g).item()
if ix == 0: # End token
break
out.append(itos[ix])
print(''.join(out))
Example outputs: "mor", "keanu", "oreilly" – the model learns some name-like patterns but produces mostly nonsense because it only looks one character back. This is a fundamental concept you can explore further in our guide on building and understanding GPT from Shakespeare to ChatGPT.
Evaluating Model Quality with Log-Likelihood
To measure how good our model is, we use the log-likelihood:
log_likelihood = 0.0
n = 0
for w in words:
chs = ['.'] + list(w) + ['.']
for ch1, ch2 in zip(chs, chs[1:]):
ix1 = stoi[ch1]
ix2 = stoi[ch2]
prob = P[ix1, ix2]
logprob = torch.log(prob)
log_likelihood += logprob
n += 1
print(f'Average negative log-likelihood: {-log_likelihood/n:.4f}')
Why log-likelihood?
- Product of many small probabilities becomes tiny and unmanageable
- Log converts multiplication to addition: log(a·b·c) = log a + log b + log c
- Higher probabilities give log values closer to 0 (good), lower probabilities give very negative values (bad)
- Random guessing (uniform distribution over 27 tokens) gives a cross-entropy loss of log(27) ≈ 3.30
- A good model will have a lower (better) loss
If you're new to some of the terminology used here, our article on 20 key AI terms every engineer must know explained simply is a great resource.
Key Takeaways
- Bigram models are the simplest language models, looking at only one previous character
- Counting and normalizing gives us a statistical model with explicit probability distributions
- Sampling uses
torch.multinomialwith a seeded generator for reproducibility - Broadcasting is powerful but dangerous, always check dimensions with
keepdim=True - Log-likelihood provides a single-number quality metric that's numerically stable
For a deeper understanding of how these concepts extend to modern AI systems, you might find our complete guide to LangChain models: language and embedding helpful.
Hi everyone. Hope you're well. And uh next up what I'd like to do is I'd like to build out make more.
Like micrograd before it, make more is a repository that I have on my GitHub webpage. Uh you can look at it. Uh but
just like with micrograd, I'm going to build it out step-by-step and I'm going to spell everything out. So, we're going
to build it out slowly and together. Now, what is make more? Make more, uh as the name suggests, uh
makes more of things that you give it. So, here's an example. names.txt is an example data set to make more.
And when you look at names.txt, you'll find that it's a very large data set of names.
So, here's lots of different types of names. In fact, I believe there are 32,000
names that I've sort of found randomly on a government website. And if you train make more on this data
set, it will learn to make more of things like this. Um and in particular in this case, that will mean more things
that sound name-like, but are actually unique names. And maybe if you have a baby and you're trying to assign name,
maybe you're looking for a cool new sounding unique name, make more might help you.
So, here are some example generations from the neural network once we train it on our data set.
So, here's some example unique names that it will generate. Dontal, uh Irot,
uh Zendy, and so on. And so, all these sort of sound
name-like, uh but they're not, of course, names. So, under the hood, make more is a
character-level language model. So, what that means is that it is treating every single line here as an example.
And within each example, it's treating them all as sequences of individual characters. So, r e e s e is this
example, and that's the sequence of characters, and that's the level on which we are building out make more.
And what it means to to a character-level language model then is that it's just sort of modeling those
sequences of characters and it knows how to predict the next character in the sequence.
Now, we're actually going to implement a large number of character level language models in terms of the neural networks
that are involved in predicting the next character in a sequence. So, very simple bigram and bag of word models,
multi-layer perceptrons, recurrent neural networks, all the way to modern transformers. In fact, the transformer
that we will build will be basically the equivalent transformer to GPT-2 if you have heard of GPT. Uh so, that's kind of
a big deal. It's a modern network and by the end of this series you will actually understand how that works
um on the level of characters. Now, to give you a sense of the extensions here, uh after characters we
will probably spend some time on the word level so that we can generate documents of words, not just little, you
know, segments of characters. Uh but we can generate entire large, much larger documents.
And then we're probably going to go into images and image text uh networks such as DALL-E, Stable Diffusion, and so on.
But for now we have to start uh here, character level language modeling. Let's go.
So, like before we are starting with a completely blank Jupyter notebook page. The first thing is I would like to
basically load up the data set names.txt. So, we're going to open up names.txt for
reading. And we're going to read in everything into a massive string.
And then, because it's a massive string, we'd only like the individual words and put them in a list.
So, let's call splitlines on that string to get all of our words as a Python list of strings.
So, basically we can look at for example the first 10 words and we have that it's a list of Emma,
Olivia, Ava, and so on. And if we look at the top of the page here, that is indeed
what we see. Um So, that's good.
This list actually makes me feel that this is probably sorted by frequency. But okay, so these are the words. Now,
we'd like to actually like learn a little bit more about this data set. Let's look at the total number of words.
We expect this to be roughly 32,000. And then what is the for example shortest word?
So min of len of each word for W in words. So the shortest word will be length two.
And max of len W for W in words. So the longest word will be 15 characters.
So let's now think through our very first language model. As I mentioned, a character-level
language model is predicting the next character in a sequence given already some concrete sequence of characters
before it. Now, what we have to realize here is that every single word here, like
Isabella, is actually quite a few examples packed in to that single word.
Because what is a an existence of a word like Isabella in the data set telling us really? It's saying that
the character I is a very likely character to come first in the sequence of a name.
The character S is likely to come after I. The character A is likely to come after
IS. The character B is very likely to come after ISA. And so on all the way to A
following Isabella. And then there's one more example actually packed in here.
And that is that after there's Isabella, the word is very likely to end.
So that's one more sort of explicit piece of information that we have here. That we have to be careful with.
And so there's a lot packed into a single individual word in terms of the statistical structure of what's likely
to follow in these character sequences. And then of course we don't have just an individual word. We actually have 32,000
of these. And so there's a lot of structure here to model. Now, in the beginning what I'd like to
start with is I would like to start with building a bigram language model. Now, in a bigram language model, we're
always working with just two characters at a time. So, we're only looking at one character
that we are given, and we're trying to predict the next character in the sequence.
So, um what characters are likely to follow R? What characters are likely to follow A? And so on. And we're just
modeling that kind of a little local structure. And we're forgetting the fact that we
may have a lot more information. We're always just looking at the previous character to predict the next one. So,
it's a very simple and weak language model, but I think it's a great place to start.
So, now let's begin by looking at these bigrams in our data set and what they look like. And these bigrams, again, are
just two characters in a row. So, for W words, each W here is an individual word
string. We want to iterate uh for in We want to iterate this word
with consecutive characters. So, two characters at a time, sliding it through the word. Now, a interesting, nice way,
cute way to do this in Python, by the way, is doing something like this. For character one, character two in zip off
W and W at one. one colon print
character one, character two And let's not do all the words. Let's just do the first three words. And I'm
going to show you in a second how this works. But for now, basically, as an example,
let's just do the very first word alone, Emma. You see how we have a Emma, and this
will just print EM, MM, MA. And the reason this works is because W is the string Emma, W at one colon is
the string MMA, and zip takes two iterators, and it pairs them up, and then creates an
iterator over the tuples of their consecutive entries. And if any one of these lists is shorter
than the other, then it will just uh halt and return. So basically, uh that's why we return E
M M M M M M A, but then because this iterator, the second one here, runs out of elements,
zip just ends, and that's why we only get these tuples. So, pretty cute. So, these are the consecutive elements
in the first word. Now, we have to be careful because we actually have more information here than
just these three examples. As I mentioned, we know that E is the is very come first, and we know that A in this
case is coming last. So, one way to do this is basically we're going to create a special array
here of characters. And we're going to hallucinate a special
start token here. I'm going to call it like special start.
So, this is a list of one element plus W and then plus a special end character.
And the reason I'm wrapping the list of W here is because W is a string in a list of W will just have the individual
characters in the list. And then doing this again now, but not iterating over Ws but over the
characters, will give us something like this. So, E is likely So, this is a bigram of
the start character and E, and this is a bigram of the A and the special end character.
And now we can look at, for example, what this looks like for Olivia or Ava.
And indeed, we can actually potentially do this for the entire data set, but we won't print that. That's
going to be too much. But these are the individual character bigrams, and we can print them.
Now, in order to learn the statistics about which characters are likely to follow other characters, the simplest
way in the bigram language models is to simply do it by counting. So, we're basically just going to count
how often any one of these combinations occurs in the training set. In these words. So we're going to need
some kind of a dictionary that's going to maintain some counts for every one of these bigrams. So let's use a dictionary
B. And this will map these bigrams. So bigram is a tuple of character one
character two. And then B at bigram will be B.get of bigram
which is basically the same as B at bigram. But in the case that bigram is not in
the dictionary B, we would like to by default return a zero. Plus one.
So this will basically add up all the bigrams and count how often they occur. Let's get rid of printing.
Or rather let's keep the printing and let's just inspect what B is in this case.
And we see that many bigrams occur just a single time. This one allegedly occurred three times.
So A was an ending character three times. And that's true for all of these words.
All of Emma, Olivia, and Ava end with A. Uh so that's why this occurred three times.
Um now let's do it for all the words. Oops, I should not have printed.
I meant to erase that. Let's kill this. Let's just run.
And now B will have the statistics of the entire data set. So these are the counts across all the
words of the individual bigrams. And we could for example look at some of the most common ones and least common
ones. Um this kind of grows in Python, but the way to do this, the simplest way I like,
is we just use B.items. B.items returns the tuples of
key value. In this case the keys are the character bigrams and the values are the counts.
And so then what we want to do is we want to do um sorted of this.
Uh but by default sort is on the first um on the first item of a tuple, but we
want to sort by the values, which are the second element of a tuple, that is the key value.
So we want to use the key equals lambda uh that takes the key value
and returns the key value at the at one, not at zero, but at one, which is the count. So we want to sort by the count
of these elements. And actually we want it to go backwards. So here what we have is the bigram QNR
occurs only a single time. Uh DZ occurred only a single time. And when we sort this the other way
around, we're going to see the most likely bigrams. So we see that N was very often
an ending character many, many times. And apparently N almost always follows an A. And that's a
very likely combination as well. Um so
this is kind of the individual counts that we achieve over the entire data set.
Now it's actually going to be significantly more convenient for us to keep this information in a
two-dimensional array instead of a Python dictionary. So we're going to store this information
in a 2D array and the rows are going to be the first
character of the bigram and the columns are going to be the second character. And each entry in this two-dimensional
array will tell us how often that first character follows the second character in the data set.
So in particular, the array representation that we're going to use or the library is that of PyTorch.
And PyTorch is a deep learning neural network framework, but part of it is also this torch.tensor, uh which allows
us to create multi-dimensional arrays, and manipulate them very efficiently. So, let's import PyTorch, which you can
do by import torch. And then we can create uh arrays. So, let's create a array of zeros.
And we give it a um size of this array. Let's create a 3x5 array as an example. And
this is a 3x5 array of zeros. And by default, you'll notice a data D type, which is short for data type, is
float 32. So, these are single-precision floating-point numbers. Because we are going to represent
counts, let's actually use D type as torch.int32. So, these are uh
32-bit integers. So, now you see that we have integer data inside this tensor.
Now, tensors allow us to really um manipulate all the individual entries and do it very efficiently.
So, for example, if we want to change this bit, we have to index into the tensor. And in particular, here, this is
the first row, and the um because it's zero-indexed. So, this is row index one, and column index 0 1 2 3.
So, A at 1,3, we can set that to 1. And then A will have a 1 over there. We can of course also do things like
this. So, now A will be 2 over there. Or 3. And also, we can for example say A 0 0
is 5. And then A will have a 5 over here. So, that's how we can index into the
arrays. Now, of course, the array that we are interested in is much much bigger. So, for our purposes, we have 26
letters of the alphabet, and then we have two special characters, S and E. So, uh we want 26 + 2 or 28x28 array.
And let's call it the the N, because it's going to represent sort of the counts.
Let me erase this stuff. So, that's the array that starts at zeros, 28 by 28.
And now let's copy paste this here. But instead of having a dictionary be
which we're going to erase, we now have an N. Now, the problem here is that we have
these characters which are strings, but we have to now um basically index into a um array, and
we have to index using integers. So, we need some kind of a lookup table from characters to integers.
So, let's construct such a character array. And the way we're going to do this is
we're going to take all the words, which is a list of strings. We're going to concatenate all of it
into a massive string. So, this is just simply the entire data set as a single string.
We're going to pass this to the set constructor, which takes this massive string and throws out duplicates because
sets do not allow duplicates. So, set of this will just be the set of all the lowercase characters.
And there should be a total of 26 of them. And now we actually don't want a set, we
want a list. But we don't want a list sorted in some weird arbitrary way, we want it to be
sorted from A to Z. So, a sorted list.
So, those are our characters. Now, what we want is this lookup table as I mentioned. So, let's create a
special S2I, I will call it. Um S is string or character, and this will be an S2I mapping
for IS in enumerate of these characters. So, enumerate basically gives us this
iterator over the integer index and the actual element of the list. And then we are mapping the character to the
integer. So, S2I is a mapping from A to 0, B to 1, etc.
All the way from Z to 25. And that's going to be useful here, but we actually also have to specifically
set that S will be 26. And S 2 I at E will be 27, right? Because Z was 25.
So, those are the lookups. And now we can come here and we can map both character one and character two to their
integers. So, this will be S 2 I at character one. And IX2 will be S 2 I of character two.
And now we should be able to do this line, but using our array. So, N at IX1, IX2, this is the two-dimensional
array indexing I showed you before, and honestly just plus equals one. Because everything starts at zero.
So, this should work and give us a large 28 by 28 array of all these counts. So, if we print N,
this is the array, but of course it looks ugly. So, let's erase this ugly mess and let's try to visualize it a bit
more nicer. So, for that we're going to use a library called Matplotlib.
So, Matplotlib allows us to create figures. So, we can do things like plt.imshow of the counter array.
So, this is the 28 by 28 array. And this is a structure, but even this, I would say, is still pretty ugly. So,
we're going to try to create a much nicer visualization of it, and I wrote a bunch of code for that.
Uh, the first thing we're going to need is we're going to need to invert this array
here, this um dictionary. So, S 2 I is a mapping from S to I. And in I 2 S, we're going to reverse
this dictionary. So, iterate over all the items and just reverse that array. So, I 2 S maps inversely from 0 to A, 1
to B, etc. So, we'll need that. And then here's the code that I came up
with to try to make this a little bit nicer. We create a figure.
We plot N. And then we do and then we visualize a bunch of things later. Let me just run
it so you get a sense of what all this is. Okay.
So, you see here that we have the array spaced out. And every one of these is basically like B follows G zero
times. B follows H 41 times. Um so, A follows J 175 times.
And so, what you can see that I'm doing here is first I show that entire array. And then I iterate over all the
individual little cells here. And I create a character string here. Which is the inverse mapping I to S of
the integer I and the integer J. So, that's These are the bigrams in a character representation.
And then I plot just the bigram text. And then I plot the number of times that this bigram occurs.
Now, the reason that there's a dot item here is because when you index into these arrays, these are torch tensors.
You see that we still get a tensor back. So, the type of this thing, you'd think it would be just an integer 149, but
it's actually torch.tensor. And so, if you do dot item, then it will pop out that individual integer.
So, it'll just be 149. So, that's what's happening there. And these are just some options to make it
look nice. So, what is the structure of this array? Um
We have all these counts, and we see that some of them occur often and some of them do not occur often.
Now, if you scrutinize this carefully, you will notice that we're not actually being very clever.
That's because when you come over here, you'll notice that for example, we have an entire row of completely zeros. And
that's because the end character is never possibly going to be the first character of a bigram because we're
always placing these end tokens all at the end of the bigram. Similarly, we have entire columns of
zeros here because the S character will never possibly be the second element of a bigram because we
always start with S and we end with E and we only have the words in between. So, we have an entire column of zeros,
an entire row of zeros. And in this little 2 by 2 matrix here as well, the only one that can possibly happen is if
S directly follows E. That can be non-zero if we have a word that has no letters. So, in that case
there's no letters in the word, it's an empty word and we just have S follows E. But, the other ones are just not
possible. And so, we're basically wasting space and not only that, but the S and the E
are getting very crowded here. I was using these brackets because there's convention in natural language
processing to use these kinds of brackets to denote special tokens. Uh but, we're going to use something else.
So, let's fix all of this and make it prettier. We're not actually going to have two
special tokens, we're only going to have one special token. So, we're going to have n by n array of
27 by 27 instead. Instead of having two, we will just have one and I will call it a dot.
Okay? Let me swing this over here. Now, one more thing that I would like to
do is I would actually like to make this special character have position zero and I would like to offset all the other
letters off. I find that a little bit more pleasing. Um
so, we need a plus one here so that the first character, which is A, will start
at one. So, S to I will now be A starts at one and dot is zero.
And uh I to S, of course, we're not changing this because I to S just creates a reverse mapping and this will
work fine. So, one is A, two is B, zero is dot. So, we reverse that. Here,
we have a dot and a dot. This should work fine.
Make sure I start at zeros. Count. And then here, we don't go up to 28, we go up to 27.
And this should just work. Okay. So, we see that dot dot never happened.
It's at zero because we don't have empty words. Then this row here now is just very
simply the counts for all the first letters. So, G J starts a word, H starts a word, I
starts a word, etc. And then these are all the ending characters.
And in between, we have the structure of what characters follow each other. So, this is the counts array of our
entire uh data set. So, this array actually has all of the information necessary for us to actually sample from
this bigram character level language model. And
roughly speaking, what we're going to do is we're just going to start following these probabilities and these counts,
and we're going to start sampling from the from the model. So, in the beginning, of course,
we start with the dot, the start token. Dot. So, to sample the first character of a name, we're looking at this row
here. So, we see that we have the counts, and those counts are telling are telling us
how often any one of these characters is to start a word. So, if we take this N,
and we grab the first row, we can do that by using just indexing at zero,
and then using this notation colon for the rest of that row. So, N zero colon
is indexing into the zeroth um row and then it's grabbing all the columns. And so this will give us a
one-dimensional array of the first row. So 0 4 4 10. You know it's 0 4 4 10 1 3 0 6 1 5 4 2
etc. It's just the first row. The shape of this is 27. It's just a row of 27.
And the other way that you can do this also is you just you don't need to actually give this
you just uh grab the zeroth row like this. This is equivalent. Now these are the counts.
And now what we'd like to do is we'd like to basically um sample from this. Since these are the raw counts, we
actually have to convert this to probabilities. So we create a probability vector.
So we'll take n of zero and we'll actually convert this to float first.
Okay, so these integers are converted to float a floating point numbers. And the reason
we're creating floats is because we're about to normalize these counts. So to create a probability distribution
here, we want to divide we basically want to do p p p divide p.sum.
And now we get a vector of smaller numbers and these are now probabilities. So of course because we divided by the
sum, the sum of p now is one. So this is a nice proper probability distribution. It sums to one and this is
giving us the probability for any single character to be the first uh character of a word.
So now we can try to sample from this distribution. To sample from these distributions, we're going to use
torch.multinomial which I've pulled up here. So torch.multinomial returns uh
um samples from the multinomial probability distribution which is a complicated way of saying you give me
probabilities and I will give you integers which are sampled according to the probability distribution.
So this is the signature of the method, and to make everything deterministic, we're going to use a generator object in
PyTorch. Uh so, this makes everything deterministic. So, you when you run this
on your computer, you're going to the exact get the exact same results that I'm getting here on my computer.
So, let me show you how this works. Um Here's the deterministic way of creating
a torch generator object, seeding it with some number that we can agree on.
So, that seeds a generator, gets gives us an object G, and then we can pass that G to a
function that creates um here random numbers. torch.rand creates
random numbers, three of them, and it's using this generator object to as a source of randomness.
Uh so, uh without normalizing it, I can just print.
Uh this is sort of like numbers between zero and one that are random according to this thing. And whenever I run it
again, I'm always going to get the same result because I keep using the same generator
object, which I'm seeding here. And then if I divide to normalize, I'm going to get a nice
probability distribution of just three elements. And then we can use torch.multinomial to
draw samples from it. So, this is what that looks like. torch.multinomial will take
the torch tensor of probability distributions. Then we can ask for a number of samples,
let's say 20. replacement equals true means that when we draw an element, uh we will uh we can
draw it, and then we can put it back into the list of eligible indices to draw again.
And we have to specify replacement as true because by default, uh for for some reason, it's false.
Um and I think, you know, it's just something to be careful with.
Uh and the generator is passed in here. So, we are going to always get deterministic results, the same results.
So, if I run these two, we're going to get a bunch of samples from this distribution.
Now, you'll notice here that the probability for the first element in this tensor is 60%.
So, in these 20 samples, we'd expect 60% of them to be zero. We'd expect 30% of them to be one.
And because the uh element index two has only 10% probability, very few of these samples should be two. And indeed,
we only have a small number of twos. And we can sample as many as we would like.
And the more we sample, the more uh these numbers should um roughly have the distribution here.
So, we should have lots of zeros, half as many um ones, and we should have um
three times as few uh sorry, as few ones, and three times as few uh
twos. So, you see that we have very few twos, we have some ones, and most of them are
zero. So, that's what torch.multinomial is doing. For us here,
we are interested in this row. We've created this um P here,
and now we can sample from it. So, if we use the same seed, and then we sample from this
distribution, let's just get one sample. Then, we see that the sample is, say, 13.
Um so, this will be the index. And let's You see how it's a tensor that wraps 13. We again have to use that item
to pop out that integer. And now, index would be just the number 13.
And of course, the um we can do we can map the I2S of IX to figure out exactly which character we're sampling here.
We're sampling M. So, we're saying that the first character is M in our generation.
And just looking at the row here, M was drawn and you we can see that M actually starts a large number of words.
M started 2,500 words out of 32,000 words. So, almost a bit less than 10% of the words start
with M. So, this is actually fairly likely character to draw. Um
So, that would be the first character of our word and now we can continue to sample more characters because now we
know that M started M is already sampled. So, now to draw the next character, we
will come back here and we will look for the row that starts with M.
So, you see M and we have a row here. So, we see that M.
is 516, MA is this many, MB is this many, etc. So, these are the counts for the
next row and that's the next character that we are going to now generate. So, I think we are ready to actually just
write out the loop because I think you're starting to get a sense of how this is going to go.
The um We always begin at index zero because that's the start token.
And then while true, we're going to grab the row corresponding to index
that we're currently on. So, that's P. So, that's N array at IX. Convert it to float is RP.
Then, we normalize this P to sum to one. Accidentally ran the infinite loop. We normalize P to sum to one.
Then we need this generator object that we're going to initialize up here and we're going to draw a single sample
from this distribution. And then this is going to tell us what index is going to be next.
If the index sampled is zero, then that's now the end token. So, we will break.
Otherwise, we are going to print s2i of ix. i2s of ix.
And uh that's pretty much it. We're just uh this should work. Okay, more.
So, that's the that's the name that we've sampled. We started with M. The next step was O, then R, and then dot.
And this dot we printed here as well. So, let's now do this a few times.
Um So, let's actually create an out list here.
And instead of printing, we're going to append. So, out.append this character. And then here, let's just print it at
the end. So, let's just join up all the outs, and we're just going to print more.
Okay? Now, we're always getting the same result because of the generator. So, if you want to do this a few times,
we can go for I in range 10. We can sample 10 names. And we can just do that 10 times.
And these are the names that we're getting out. Let's do 20.
I'll be honest with you, this doesn't look right. So, I stared at it a few minutes to
convince myself that it actually is right. The reason these samples are so terrible
is that bigram language model is actually like just like really terrible.
We can generate a few more here. And you can see that they're kind of like their name-like a little bit, like
Keanu, O'Reilly, etc. Uh but they're just like totally messed up. Um And I mean, the reason that this is so
bad, like we're generating H as a name. But you have to think through it from the model's eyes. It doesn't know that
this H is the very first H. All it knows is that H was previously, and now how likely is H the last character? Well,
it's somewhat likely, and so it just makes it last character. It doesn't know that there were other things before it
or there were not other things before it. And so, that's why it's generating all these like sun- nonsense names.
Another way to do this is to convince yourself that this is actually doing something reasonable even
though it's so terrible is these little piece here are 27, right? Like 27.
So, how about if we did something like this? Instead of P having any structure
whatsoever, how about if P was just a torch.ones of 27?
of 27. By default, this is a float 32, so this is fine. Divide 27.
So, what I'm doing here is this is the uniform distribution, which will make everything equally likely.
And we can sample from that. So, let's see if that does any better. Okay? So, it's This is what you have
from a model that is completely untrained, where everything is equally likely. So, it's obviously garbage. And
then if we have a trained model, which is trained on just bigrams, this is what we get. So, you can see
that it is more name-like. It is actually working. It's just um bigram is so terrible, and we have to do
better. Now, next I would like to fix an inefficiency that we have going on here. Because what we're doing here is we're
always fetching a row of N from the counts matrix up ahead. And we we're always doing the same
things. We're converting to float and we're dividing and we're doing this every single iteration of this loop and
we just keep re-normalizing these rows over and over again and it's extremely inefficient and wasteful.
So, what I'd like to do is I'd like to actually prepare a matrix capital P that will just have the probabilities in it.
So, in other words, it's going to be the same as the capital N matrix here of counts, but every single row will have
the row of probabilities uh that is normalized to one indicating the probability distribution for the next
character given the character before it. Um as defined by which row we're in. So, basically what we'd like to do is
we'd like to just do it up front here and then we would like to just use that row here.
Uh so, here we would like to just do P equals P of IX instead. Okay.
The other reason I want to do this is not just for efficiency, but also I would like us to practice uh these
N-dimensional tensors and I'd like us to practice uh their manipulation and especially something that's called
broadcasting that we'll go into in a second. We're actually going to have to become
very good at these tensor manipulations because if we're going to build out all the way to transformers, we're going to
be doing some pretty complicated um array operations for efficiency and uh we need to really understand that and be
very good at it. So, intuitively what we want to do is we first want to grab the floating point
copy of N. And I'm mimicking the line here basically.
And then we want to divide all the rows so that they sum to one. So, we'd like to do something like this.
P divide P.sum. But, now we have to be careful because P.sum actually
produces a sum Sorry. P equals N.float.copy. P.sum produces a um
sums up all of the counts of this entire matrix N and gives us a single number of just the
summation of everything. So, that's not the way we want to defi- divide. We want to simultaneously and in parallel divide
all the rows by their respective sums. So, what we have to do now is we have to go into documentation for torch.sum.
And we can scroll down here to a definition that is relevant to us, which is where we don't only provide an input
array that we want to sum, but we also provide the dimension along which we want to sum.
And in particular, we want to sum up uh over rows, right? Now, one more argument that I want you
to pay attention to here is the keepdim is false. If keepdim is true, then the output
tensor is of the same size as input, except of course the dimension along which you summed, which will become just
one. But, if you pass in uh keepdim as false, then this dimension is squeezed out. And
so, torch.sum not only does the sum and collapses dimension to be of size one, but in addition, it does what's called a
squeeze, where it squeeze out it squeezes out that dimension. So,
basically what we want here is we instead want to do p.sum of sum axis. And in particular, notice that p.shape
is 27 by 27. So, when we sum up across axis zero, then we would be taking the zeroth
dimension, and we would be summing across it. So, when keepdim is true,
then this thing will not only give us the counts across um along the columns,
but notice that basically the shape of this is 1 by 27. We just get a row vector.
And the reason we get a row vector here again is because we pass in zero dimension, so this zero dimension
becomes one, and we've done a sum, and we get a row. And so, basically, we've done the sum
this way, vertically, and arrived at just a single 1 by 27
vector of counts. What happens when you take out keepdim is that we just get 27. So, it squeezes
out that dimension and we just get um a one-dimensional vector of size 27. Now, we don't actually want
1 by 27 row vector because that gives us the counts or the sums across um the uh columns.
We actually want to sum the other way along dimension one. And you'll see that the shape of this is
27 by one. So, it's a column vector. It's a 27 by one vector of counts.
Okay? And that's because what's happened here is that we're going horizontally and
this 27 by 27 matrix becomes a 27 by one array. Now, you'll notice by the way that um
the actual numbers of these counts are identical. And that's because this special array of
counts here comes from bigram statistics. And actually, it just so happens by chance or because of the way
this array is constructed that the sums along the columns or along the rows, horizontally or vertically, is
identical. But actually, what we want to do in this case is we want to sum across the uh
rows horizontally. So, what we want here is p.sum of one with keep dim true.
27 by one column vector. And now, what we want to do is we want to divide by that.
Now, we have to be careful here again. Is it possible to take what's a um p.shape you see here is 27
by 27. Is it possible to take a 27 by 27 array and divide it by what is a 27 by one array?
Is that an operation that you can do? And whether or not you can perform this operation is determined by what's called
broadcasting rules. So, if you just search broadcasting semantics in torch, you'll notice that there's a special
definition for uh what's called broadcasting that for whether or not these two arrays can be combined in a
binary operation like division. So the first condition is each tensor has at least one dimension, which is the
case for us. And then when iterating over the dimension sizes starting at the trailing
dimension, the dimension sizes must either be equal, one of them is one, or one of
them does not exist. Okay? So, let's do that. We need to align the two arrays and their shapes,
which is very easy because both of these shapes have two elements, so they're aligned.
Then we iterate over from the from the right and going to the left. Each dimension must be either equal, one
of them is a one, or one of them does not exist. So in this case they're not equal, but
one of them is a one. So, this is fine. And then this dimension, they're both equal. So, this is fine.
So, all the dimensions are fine and therefore the this operation is broadcastable.
So that means that this operation is allowed. And what is it that these arrays do when
you divide 27 by 27 by 27 by one? What it does is that it takes this dimension one and it stretches it out.
It copies it to match 27 here in this case. So in our case, it takes this column
vector which is 27 by one and it copies it 27 times to make
these both be 27 by 27 internally. You can think that way. And so it copies those counts
and then it does an element-wise division, which is what we want because these
counts we want to divide by them on every single one of these columns in this matrix.
So this actually we expect will normalize every single row. And we can check that this is true by
taking the first row for example and taking its sum. We expect this to be one.
Because it's now normalized. And then we expect this now, because if we actually correctly normalize all the
rows, we expect to get the exact same result here. So, let's run this. It's the exact same result.
So, this is correct. So, now I would like to scare you a little bit. Uh you actually have to like I basically
encourage you very strongly to read through broadcasting semantics. And I encourage you to treat this with
respect. And it's not something to play fast and loose with. It's something to really respect, really understand, and
look up maybe some tutorials for broadcasting and practice it, and be careful with it because you can very
quickly run into bugs. Let me show you what I mean. You see how here we have p.sum of one,
keep dims is true. The shape of this is 27 by one. Let me take out this line just so we
have the n, and then we can see the counts. We can see that this is all the counts
across all the rows. And it's a 27 by one column vector,
right? Now, suppose that I tried to do the following,
but I erase keep dims is true here. What does that do? If keep dims is not true, it's false, then remember,
according to documentation, it gets rid of this dimension one. It squeezes it out. So, basically we just get all the
same counts, the same result, except the shape of it is not 27 by one, it is just 27. The one disappears.
But all the counts are the same. So, you think that this divide that would would work.
First of all, can we even write this? And will it Is it even Is it even expected to run? Is it broadcastable?
Let's determine if this result is broadcastable. p.sum at one is shape
is 27. This is 27 by 27. So, 27 by 27 broadcasting into 27.
So, now rules of broadcasting, number one align all the dimensions on the right done now
iteration over all the dimensions starting from the right going to the left
all the dimensions must either be equal one of them must be one or one of them does not exist so here they are all
equal here the dimension does not exist so internally what broadcasting will do is it will create a one here
and then we see that one of them is a one and this will get copied and this will run
this will broadcast okay so you'd expect this to work because we we are
this broadcasting this we can divide this now if I run this you'd expect it to
work but it doesn't you actually get garbage you get a wrong
result because this is actually a bug this keep them equals true makes it work
this is a bug in both cases we are doing the correct counts we are summing up across the rows
but keep them is saving us and making it work so in this case I'd like you to encourage you to
potentially like pause this video at this point and try to think about why this is buggy and why the keep them was
necessary here okay so the reason to do
for this is I'm trying to hint it here when I was sort of giving you a bit of a hint on how this works
this 27 vector internally inside the broadcasting this
becomes a one by 27 and one by 27 is a row vector right and now we are dividing 27 by 27 by one by
27 and torch will replicate this dimension so basically
it will take it will take this row vector and it will copy it
vertically now 27 times so the 27 by 27 lines exactly, and element twice divides.
And so, basically, what's happening here is um
we're actually normalizing the columns instead of normalizing the rows. So, you can check that what's happening
here is that P at zero, which is the first row of P, dot sum is not one, it's seven.
It is the first column, as an example, that sums to one. So,
to summarize, where does the issue come from? The issue comes from the silent adding of a dimension here because in
broadcasting rules, you align on the right and go from right to left, and if dimension doesn't exist, you create it.
So, that's where the problem happens. We still did the counts correctly. We did the counts across the rows, and we got
the the counts on the right here as a column vector. But, because the keepdims was true, this this uh this dimension
was discarded, and now we just have a vector 27. And because of broadcasting the way it
works, this vector of 27 suddenly becomes a row vector. And then this row vector gets replicated
vertically, and then every single point we are dividing by the by the count uh in the opposite direction.
So, uh so, this thing just uh doesn't work. You This needs to be keepdims equals true in
this case. So, then um then we have that P at zero is
normalized. And conversely, the first column, you'd expect to potentially not be normalized.
And this is what makes that work. So, pretty subtle, and uh hopefully this helps to scare you that you should have
a respect for broadcasting, be careful, check your work, uh and uh understand how it works under
the hood, and make sure that it's broadcasting in the direction that you like. Otherwise, you're going to
introduce very subtle bugs, very hard to hard to find bugs, and uh just be careful. One more note on efficiency, we
don't want to be doing this here because uh this creates a completely new tensor that we store into P. We prefer to use
in-place operations if possible. Also, this would be an in-place operation. It has the potential to be
faster. It doesn't create new memory under the hood. And then let's erase this. We don't need it.
And let's also um just do fewer just so I'm not wasting space.
Okay, so we're actually in a pretty good spot now. We trained a bigram language model, and
we trained it really just by counting uh how frequently any pairing occurs and then normalizing so that we get a nice
probability distribution. So really these elements of this array P are really the parameters of our bigram
language model giving us and summarizing the statistics of these bigrams. So we trained the model and then we know
how to sample from the model. We just iteratively uh sample the next character and uh feed it in each time and get the
next character. Now what I'd like to do is I'd like to somehow evaluate the quality of this
model. We'd like to somehow summarize the quality of this model into a single
number. How good is it at predicting uh the training set? And as an example, so in the training
set we can evaluate now the training loss. And this training loss is telling us about uh sort of the quality of this
model in a single number just like we saw in micrograd. So let's try to think through the
quality of the model and how we would evaluate it. Basically what we're going to do is
we're going to copy paste this code that we previously used for counting. Okay?
And let me just print the bigrams first. We're going to use F-strings. And I'm going to print character one
followed by character two. These are the bigrams. And then I don't want to do it for all the words. Let's just do the
first three words. So here we have Emma, Olivia, and Ava bigrams.
Now what we'd like to do is we'd like to basically look at the probability that the model assigns to every one of these
bigrams. So, in other words, we can look at the probability, which is summarized in the
matrix P, of IX1 IX2. And then we can print it here as
probability. And because these probabilities are way too large, let me percent uh
or colon point four F to like truncate it a bit. So, what do we have here, right? We're
looking at the probabilities that the model assigns to every one of these bigrams in the data set.
And so, we can see some of them are 4%, 3%, etc. Just to have a measuring stick in our mind, by the way, um
with 27 possible characters or tokens, and if everything was equally likely, then you'd expect all these
probabilities to be 4% roughly. So, anything above 4% means that we've
learned something useful from these bigram statistics. And you see that roughly some of these
are 4%, but some of them are as high as 40%, 35%, and so on. So, you see that the
model actually assigned a pretty high probability to whatever's in the training set. And so, that that's a good
thing. Um basically, if you have a very good model, you'd expect that these
probabilities should be near one, because that means that uh your model is correctly predicting what's going to
come next, especially in the training set, where you where you trained your model.
So, now we'd like to think about how can we summarize these probabilities into a single number that measures the quality
of this model. Now, when you look at the literature into maximum likelihood estimation and
statistical modeling and so on, you'll see that what's typically used here is something called the likelihood.
And the likelihood is the product of all of these probabilities. And so, the product of all of these
probabilities is the likelihood, and it's really telling us about the probability of the entire data set
assigned uh assigned by the model that we've trained. And that is a measure of quality.
So, the product of these should be as high as possible when you are training the model and when
you have a good model, your your product of these probabilities should be very high.
Um Now, because the product of these probabilities is an unwieldy thing to
work with, uh you can see that all of them are between 0 and 1. So, your product of these probabilities will be a
very tiny number. Um so, for convenience, what people work with usually is not the likelihood, but
they work with what's called the log likelihood. So,
the product of these is likelihood. To get likelihood, we just have to take the log of the probability.
And so, the log of the probability here, I have a log of X from 0 to 1. The log is a you see here monotonic
transformation of the probability, where if you pass in 1, you get 0. So, probability 1 gets you log
probability of 0. And then, as you go lower and lower probability, the log will grow more and
more negative until all the way to negative infinity at 0. So, here we have a log prob, which is
really just a torch. log of probability. Let's print it out to get a sense of what that looks like.
Log prob, also point four if. Okay.
So, as you can see, when we plug in numbers that are very close, some of our higher numbers, we get closer and closer
to 0. And then, if we plug in very bad probabilities, we get more and more
negative number. That's bad. So, and the reason we work with this is for
large extent convenience, right? Because we have mathematically that if you have some product A * B * C of all these
probabilities, right? Or the likelihood is the product of all these probabilities,
then the log of these is just log of A plus log of B plus log of C. If you remember your logs
from your high school or undergrad and so on. So we have that basically
the likelihood is the product of probabilities, the log likelihood is just the sum of the logs of the
individual probabilities. So log likelihood
starts at zero. And then log likelihood here we can just accumulate simply.
And in the end we can print this. Print the log likelihood. F strings.
Maybe you're familiar with this. So log likelihood is -38. Okay.
Now we actually want um So how high can log likelihood get? It
can go to zero. So when all the probabilities are one, log likelihood will be zero. And then when all the
probabilities are lower, this will grow more and more negative. Now we don't actually like this because
what we'd like is a loss function. And a loss function has the semantics that low is good. Because we're trying to
minimize the loss. So we actually need to invert this. And that's what gives us something called the negative log
likelihood. Um negative log likelihood is just negative
of the log likelihood. These are F strings by the way if you'd like to look this up.
Negative log likelihood equals So negative log likelihood now is just the negative of it.
And so the negative log likelihood is a very nice loss function because um the lowest it can get is zero. And the
higher it is, the worse off the predictions are that you're making. And then one more modification to this
that sometimes people do is that for convenience uh they actually like to normalize by they like to make it an
average instead of a sum. And so here let's just keep some counts as well.
So n plus equals one starts at zero. And then here we can have sort of like a normalized log likelihood.
Um if we just normalize it by the count then we will sort of get the average log
likelihood. So this would be usually our loss function here. This this we would this is what we would
use. Uh so our loss function for the training set assigned by the model is 2.4. That's
the quality of this model. And the lower it is, the better off we are. And the higher it is, the worse off
we are. And the job of our, you know, training is to find the parameters that minimize
the negative log likelihood loss. And that would be like a high quality model. Okay, so to summarize I actually
wrote it out here. So our goal is to maximize likelihood which is the product of all the
probabilities assigned by the model. And we want to maximize this likelihood with respect to the model parameters.
And in our case, the model parameters here are defined in the table. These numbers, the probabilities
are uh the model parameters sort of in our bigram language model so far.
Uh but you have to keep in mind that here we are storing everything in a table format, the probabilities. But
what's coming up as a brief preview is that these numbers will not be kept explicitly but these numbers will be
calculated by a neural network. So that's coming up. And we want to change and tune the
parameters of these neural networks. We want to change these parameters to maximize the likelihood, the product of
the probabilities. Now maximizing the likelihood is equivalent to maximizing the log
likelihood because log is a monotonic function. Here's the graph of log.
And basically all it is doing is it's uh just a scaling your um you can look at it as just a scaling of the loss
function. And so the optimization problem here and here are actually equivalent because
this is just a scaling. You can look at it that way. And so these are two identical
optimization problems. Um maximizing the log likelihood is
equivalent to minimizing the negative log likelihood. And then in practice people actually
minimize the average negative log likelihood to get numbers like 2.4. And then this summarizes the quality of
your model. And we'd like to minimize it and make it as small as possible.
And the lowest it can get is zero. And the lower it is the better off your model is because
it's assigning it's assigning high probabilities to your data. Now let's estimate the probability over
the entire training set just to make sure that we get something around 2.4. Let's run this over the entire oops.
Let's take out the print statement as well. Okay, 2.45 over the entire training set.
Now what I'd like to show you is that you can actually evaluate the probability for any word that you want.
Like for example if we just test a single word Andre and bring back the print statement
then you see that Andre is actually kind of like an unlikely word. Like on average
um we take three log probability to represent it. And roughly that's because EJ apparently is very uncommon as an
example. Now think through this um
when I take Andre and I append Q and I test the probability of it Andre Q we actually get um infinity.
And that's because JQ has a 0% probability according to our model. So the log likelihood
so the log of zero will be negative infinity. We get infinite loss. So this is kind of undesirable, right?
Because we plugged in a string that could be like a somewhat reasonable name. But basically what this is saying
is that this model is exactly 0% likely to uh to predict this name and our loss is infinity on this
example. And really what the reason for that is that J
is followed by Q uh zero times. Uh where is Q? JQ is zero and so JQ is 0% likely.
So it's actually kind of gross and people don't like this too much. To fix this, there's a very simple fix that
people like to do to sort of like smooth out your model a little bit that is called model smoothing.
And roughly what's happening is that we will we will add some fake counts. So imagine adding a count of one to
everything. So we add a count of one like this
and then we recalculate the probabilities. And that's model smoothing and you can
add as much as you like. You can add five and that will give you a smoother model.
And the more you add here the more uniform model you're going to have. And the less you add,
um the more more peaked model you're going to have, of course. So one is like a pretty decent count to
add and that will ensure that there will be no zeros in our probability matrix P. And so this will of course change the
generations a little bit. In this case it didn't but it in principle it could. But what that's going to do now is that
nothing will be infinity unlikely. So now our model will predict some other
probability and we see that JQ now has a very small probability. So the model still finds it very surprising that this
was a word or bigram but we don't get negative infinity. So it's kind of like a nice fix that
people like to apply sometimes and it's called model smoothing. Okay, so we've now trained a respectable bigram
character level language model and we saw that we both a sort of trained the model by looking
at the counts of all the bigrams and normalizing the rows to get probability distributions.
We saw that we can also then use those parameters of this model to perform sampling of new words.
So, we sample new names according to those distributions. And we also saw that we can evaluate the quality of this
model. And the quality of this model is summarized in a single number, which is
the negative log likelihood. And the lower this number is, the better the model is
because it is giving high probabilities to the actual next characters in all the bigrams in our training set.
So, that's all well and good. But we've arrived at this model explicitly by doing something that felt sensible. We
were just performing counts, and then we were normalizing those counts. Now, what I would like to do is I would
like to take an alternative approach. We will end up in a very, very similar position, but the approach will look
very different because I would like to cast the problem of bigram character level language modeling into the neural
network framework. And in the neural network framework, we're going to approach things slightly
differently, but again, end up in a very similar spot. I'll go into that later. Now, our neural network is going to be a
still a bigram character level language model. So, it receives a single character as an input.
Then there's neural network with some weights or some parameters W. And it's going to output the probability
distribution over the next character in a sequence. It's going to make guesses as to what is likely to follow this
character that was input to the model. And then in addition to that, we're going to be able to evaluate any setting
of the parameters of the neural net because we have the loss function. The negative log likelihood. So, we're
going to take a look at these probability distributions, and we're going to use the labels
which are basically just the identity of the next character in that bigram, the second character.
So, knowing what second character actually comes next in the bigram allows us to then look at what how high of a
probability the model assigns to that character. And then we of course want the
probability to be very high. And that is another way of saying that the loss is low.
So, we're going to use gradient-based optimization then to tune the parameters of this network because we have the loss
function and we're going to minimize it. So, we're going to tune the weights so that the neural net is correctly
predicting the probabilities for the next character. So, let's get started. The first thing I
want to do is I want to compile the training set of this neural network, right? So, create
the training set of all the bigrams. Okay?
And here I'm going to copy-paste this code
because this code iterates over all the bigrams. So, here we start with the words. We
iterate over all the bigrams. And previously, as you recall, we did the counts. But now we're not going to do
counts. We're just creating a training set. Now, this training set will be made up
of two lists. We have the inputs
and the targets, the the labels. And these bigrams will denote XY. Those are the characters, right?
And so, we're given the first character of the bigram and then we're trying to predict the next one.
Both of these are going to be integers. So, here we'll take X's. data.append is just
X1. Y's.data.append IX2. And then here we actually don't want lists of
integers. We will create uh tensors out of these. So, X's is torch.tensor X's and Y's is torch.tensor of Y's.
And then we don't actually want to take all the words just yet because I want everything to be manageable. Uh so,
let's just do the first word, which is Emma. And then it's clear what these X's and
Y's would be. Here, let me print character one, character two, just so
you see what's going on here. So, the bigrams of these characters is .e, em, mm, ma, a. So, so this single
word, as I mentioned, has 1 2 3 4 5 examples for our neural network. There are five separate examples in
Emma. And those examples are summarized here. When the input to the neural neural
network is integer zero, the desired label is integer five, which corresponds to e.
When the input to the neural network is five, we want its weights to be arranged so that 13 gets a very high probability.
When 13 is put in, we want 13 to have a high probability. When 13 is put in, we also want one to
have a high probability. When one is input, we want zero to have a very high probability. So, there are
five separate input examples to a neural net in this data set.
I wanted to add a tangent of a note of caution to be careful with a lot of the APIs of some of these frameworks.
You saw me silently use torch.Tensor with a lowercase t, and the output looked right.
But, you should be aware that there's actually two ways of constructing a tensor. There's a torch.lowercase
tensor, and there's also a torch.capital tensor class, which you can also construct. Uh so, you can actually call
both. You can also do torch.capital tensor, and you get an x's and y's as well.
So, that's not confusing at all. Um There are threads on what is the
difference between these two. And um unfortunately, the docs are just like
not clear on the difference. And when you look at the the docs of lowercase tensor, constructs tensor with no
autograd history by copying data. It's just like it doesn't it doesn't make sense. So, the actual
difference, as far as I can tell, is explained eventually in this random thread that you can Google.
And really, it comes down to, I believe, that um where is this?
torch.Tensor infers the dtype, the data type, automatically, while torch.Tensor just returns a float tensor.
I would recommend stick to torch.lower case tensor. So, um
indeed, we see that when I construct this with a capital T, the data type here of X's is float 32.
But, torch.lower case tensor you see how it's now X.dtype is now integer.
So, um it's advised that you use lower case T and you can read more about it if you
like in some of these threads. Uh but basically um
I'm pointing out some of these things beca- because I want to caution you and I want you to re- get used to reading a
lot of documentation and reading through a lot of uh Q&A's and threads like this. And um
you know, some of this stuff is unfortunately not easy and not very well documented and you have to be careful
out there. What we want here is integers because that's what makes uh sense. Um and so uh lower case tensor is what we
are using. Okay, now we want to think through how we're going to feed in these examples into a neural network.
Now, it's not quite as straightforward as n- plugging it in because these examples
right now are integers. So, there's like a 0, 5, or 13. It gives us the index of the character and you can't just plug an
integer index into a neural net. These neural nets uh right are sort of made up of these neurons.
And uh these neurons have weights. And as you saw in micrograd, these weights act multiplicatively on the inputs. WX
plus B, there's tanh's and so on. And so, it doesn't really make sense to make an input neuron take on integer values
that you feed in and then multiply on with weights. So, instead, a common way of encoding
integers is what's called one-hot encoding. In one-hot encoding, uh we take an
integer like 13 and we create a vector that is all zeros except for the 13th dimension, which we turn to a one.
And then that vector can feed into a neural net. Now, conveniently,
uh PyTorch actually has something called the one-hot uh uh um
function inside torch.nn.functional. It takes a tensor made up of integers. Um
long is a is a is an integer. Um and it also takes a number of classes,
um which is how large you want your uh tensor uh your vector to be.
So here, let's import is a common way of importing it. And then let's do F.one_hot.
And we feed in uh the integers that we want to encode. So we can actually feed in the entire
array of X's. And we can tell it that num_classes is 27.
So it doesn't have to try to guess it. It may have guessed that it's only 13 and would give us an incorrect result.
So this is the one-hot. Let's call this X_enc for X encoded. And then we see that X_encoded.shape is
5 by 27. And uh we can also visualize it, plt.imshow of
X_enc, to make it a little bit more clear because this is a little messy.
So we see that we've encoded all the five examples uh into vectors. We have five examples, so we have five
rows, and each row here is now an example into a neural net. And we see that the appropriate bit is
turned on as a one, and everything else is zero. So um
here for example, the the zeroth bit is turned on, the fifth bit is turned on, 13th bits are turned on for both of
these examples, and then the first bit here is turned on. So that's how we can encode um integers
into vectors. And then these vectors can feed in to neural nets. One more issue to be
careful with here, by the way, is let's look at the data type of X encoding. We always want to be careful
with data types. What would you expect X encoding's data type to be? When we're plugging numbers
into neural nets, we don't want them to be integers. We want them to be floating point numbers that can take on various
values. But the D type here is actually 64-bit integer. And the reason for that, I suspect, is
that one hot received a 64-bit integer here and it returned to the same data type.
And when you look at the signature of one hot, it doesn't even take a D type, a desired data type of the output
tensor. And so we can't In a lot of functions in torch, we'd be able to do something like
D type equals torch.float32, which is what we want, but one hot does not support that.
So instead, we're going to want to cast this to float like this. So that these
everything is the same. Everything looks the same, but the D type is float 32. And floats can feed
into um neural nets. So now let's construct our first neuron. This neuron will look at these input
vectors. And as you remember from micrograd, these neurons basically perform a very
simple function, wx plus b, where wx is a dot product, right? So we can achieve the same thing here.
Let's first define the weights of this neuron. Basically, where are the initial weights at initialization for this
neuron? Let's initialize them with torch.randn. torch.randn
is um fills a tensor with random numbers drawn from a normal distribution.
And a normal distribution has a probability uh density function like this. And so most of the numbers drawn
from this distribution will be around zero, uh but some of them will be as high as
almost three and so on. And very few numbers will be above three in magnitude.
So we need to take a size as an input here. And I'm going to use size as 27 by 1.
So, 27 by 1, and then let's visualize W. So, W is a column vector of 27 numbers. And uh these weights are then multiplied
by the inputs. So, now to perform this multiplication, we can take X encoding
and we can multiply it with W. This is a matrix multiplication operator in PyTorch.
And the output of this operation is 5 by 1. The reason it's 5 by 1 is the following.
We took X encoding, which is 5 by 27, and we multiplied it by 27 by 1. And
in matrix multiplication, you see that the output will become 5 by 1 because these 27 will multiply and
add. So, basically what we're seeing here, out out of this operation,
is we are seeing the five um activations of this neuron
on these five inputs. And we've evaluated all of them in parallel. We didn't feed in just a single input to
this single neuron. We fed in simultaneously all the five inputs into the same neuron,
and in parallel, PyTorch has evaluated the WX plus B, but here it's just WX. There's no bias.
It has valued W W times X for all of them uh independently. Now, instead of a single neuron though, I would like to
have 27 neurons, and I'll show you in a second why I want 27 neurons. So, instead of having just a one here,
which is indicating this presence of one single neuron, we can use 27.
And then when W is 27 by 27, this will in parallel evaluate all the 27 neurons on all the five inputs.
Giving us a much better, much much bigger result. So, now what we've done is 5 by 27
multiplied 27 by 27. And the output of this is now 5 by 27. So, we can see that the shape of this
is 5 by 27. So, what is every element here telling us, right?
It's telling us for every one of 27 neurons that we created, what is the firing rate of those neurons
on every one of those five examples? So, the element, for example, 3 {comma} 13
is giving us the firing rate of the 13th neuron looking at the third input. And the way this was achieved is by a
dot product between the third input and the 13th column
of this W matrix here. Okay? So, using matrix multiplication, we can very efficiently evaluate
the dot product between lots of input examples in a batch. And lots of neurons, where all of those
neurons have weights in the columns of those Ws. And in matrix multiplication, we're just
doing those dot products and in parallel. Just to show you that this is the case, we can take X and we can
take the third row. And we can take the W and take its 13th
column. And then we can do X and get three. Element-wise multiply with W at 13.
And sum that up. That's WX plus B. Uh well, there's no plus B. It's just WX dot product. And that's
this number. So, you see that this is just being done efficiently by the matrix multiplication
operation for all the input examples and for all the output neurons of this first layer.
Okay, so we fed our 27-dimensional inputs into a first layer of a neural net that has 27 neurons, right? So, we
have 27 inputs and now we have 27 neurons. These neurons perform W * X. They don't have a bias and they don't
have a non-linearity like tanh. We're going to leave them to be a linear layer.
In addition to that, we're not going to have any other layers. This is going to be it. It's just going to be
the dumbest, smallest, simplest neural net, which is just a single linear layer.
And now I'd like to explain what I want those 27 outputs to be. Intuitively, what we're trying to
produce here for every single input example is we're trying to produce some kind of a probability distribution for
the next character in a sequence. And there's 27 of them. But we have to come up with like precise
semantics for exactly how we're going to interpret these 27 numbers that these neurons take on.
Now, intuitively, you see here that these numbers are negative and some of them are positive,
etc. And that's because these are coming out of a neural net layer initialized with
these um uh normal distribution uh parameters.
But what we want is we want something like we had here. Like each row here told us the counts and then we
normalized the counts to get probabilities. And we want something similar to come out of a neural net.
But what we just have right now is just some negative and positive numbers. Now, we want those numbers to somehow
represent the probabilities for the next character. But you see that probabilities, they
they have a special structure. They um they're positive numbers and they sum to one.
And so, that doesn't just come out of a neural net. And then, they can't be counts because
uh these counts are positive and counts are integers. So, counts are also not really a good
thing to output from a neural net. So, instead what the neural net is going to output and how we are going to
interpret the the 27 numbers is that these 27 numbers are giving us log counts
basically. Um so, instead of giving us counts directly like in this table, they're
giving us log counts. And to get the counts, we're going to take the log counts and we're going to
exponentiate them. Now, exponentiation
takes the following form. Um it takes numbers that are negative or they are positive.
It takes the entire real line. And then if you plug in negative numbers, you're going to get e to the x, which is
uh always below one. So, you're getting numbers lower than one.
And if you plug in numbers greater than zero, you're getting numbers greater than one
all the way growing to the infinity. And this here grows to zero. So, basically we're going to take these
numbers here. And
instead of them being positive and negative in all of the place, we're going to interpret them as log counts
and then we're going to element-wise exponentiate these numbers. Exponentiating them now gives us
something like this. And you see that these numbers now because of they went through an
exponent, all the negative numbers turned into numbers below one like 0.338.
And all the positive numbers originally turned into even more positive numbers sort of greater than one.
Um so, like for example, seven um is some positive number over here. Um that is greater than zero.
But, exponentiated outputs here um basically give us something that we can use and interpret as the equivalent
of counts or originally. So, you see these counts here, 1 12 7 51 1 etc. The neural net is kind of now predicting
uh counts. And these counts are positive numbers.
They can never be below zero, so that makes sense. And uh they can now take on various values
depending on the settings of W. So, let me break this down. We're going to interpret these to be the
log counts. Another word for this that is often used is so-called logits.
These are logits, log counts. And these will be sort of the counts. Logits exponentiated.
And this is equivalent to the N matrix, sort of, the N array that we used previously. Remember,
this was the N. This is the the array of counts. And each row here are the counts for the
for the um next character, sort of. So, those are the counts. And now the
probabilities are just the counts um normalized. And so, um
I'm not going to find the same, but basically, I'm not going to scroll all over the place.
We've already done this. We want to counts that sum along the first dimension, and we want to keep dims as
true. We went over this, and this is how we normalize the rows of our counts matrix
to get our probabilities. probs So, now these are the probabilities.
And these are the counts that we have currently. And now when I show the probabilities,
you see that um every row here, of course,
will sum to one because they're normalized. And the shape of this
is 5 by 27. And so really what we've achieved is for every one of our five examples, we now
have a row that came out of a neural net. And because of the transformations here,
we made sure that this output of this neural net now are probabilities or we can interpret to be probabilities.
So, our WX here gave us logits and then we interpret those to be log
counts. We exponentiate to get something that looks like counts.
And then we normalize those counts to get a probability distribution. And all of these are differentiable
operations. So, what we've done now is we are taking inputs.
We have differentiable operations that we can back propagate through and we're getting out probability
distributions. So, um for example, for the zeroth example that fed in,
right, which was um the zeroth example here was a one-hot vector of zero.
And um it basically corresponded to feeding in uh
this example here. So, we're feeding in a dot into a neural net. And the way we fed the dot into a neural net is that we
first got its index. Then we one-hot encoded it. Then it went into the neural net and out
came this distribution of probabilities. And its shape
is 27. There's 27 numbers and we're going to interpret this as the neural net's assignment for how likely
every one of these characters um the 27 characters are to come next. And as we tune the weights W,
we're going to be of course getting different probabilities out for any character that you input.
And so now the question is just can we optimize and find a good W such that the probabilities coming out
are pretty good. And the way we measure pretty good is by the loss function. Okay, so I organized everything into a
single summary so that hopefully it's a bit more clear. So it starts here. We have an input data set.
We have some inputs to the neural net and we have some labels for the correct next character in a sequence. And these
are integers. Here I'm using uh torch generators now so that you see the same numbers that I
see. And I'm generating um
27 neurons weights and each neuron here receives 27 inputs. Then here we're going to plug in all the
input examples x's into a neural net. So here, this is a forward pass. First, we have to encode all of the
inputs into one-hot representations. So we have 27 classes, we pass in these integers and x inc becomes a array that
is 5 by 27. Zeros except for a few ones. We then multiply this in the first layer
of a neural net to get logits. Exponentiate the logits to get fake counts, sort of.
And normalize these counts to get probabilities. So the la- these last two lines by the
way here are called the softmax. Uh which I pulled up here. Softmax is a very often used layer in a
neural net that takes these z's which are logits, exponentiates them,
and uh divides and normalizes. It's a way of taking outputs of a neural net layer and these uh these outputs can be
positive or negative. And it outputs probability distributions. It outputs something that
is always sums to one and are positive numbers, just like probabilities. Um so it's kind of like a normalization
function if you want to think of it that way. And you can put it on top of any other linear layer inside a neural net
and it basically makes a neural net output probabilities. That's very often used and we used it as well here.
So this is the forward pass and that's how we made a neural net output probability.
Now you'll notice that um
all of these this entire forward pass is made up of differentiable
layers. Everything here we can back propagate through. And we saw some of the back propagation in micrograd.
This is just multiplication and addition. All that's happening here is just multiply and then
add. And we know how to back propagate through them. Exponentiation we know how to back
propagate through. And then here we are summing and sum is is easily back propagatable as well.
And division as well. So everything here is differentiable operation and we can back propagate through.
Now we achieve these probabilities which are 5 by 27. For every single example we have a
vector of probabilities that sum to one. And then here I wrote a bunch of stuff uh to sort of like break down uh the
examples. So we have five examples making up Emma, right?
And there are five bigrams inside Emma. So bigram example a bigram example one is that E is the beginning character
right after dot. And the indexes for these are zero and five.
So then we feed in a zero. That's the input to the neural net. We get probabilities from the neural net
that are 27 numbers. And then the label is five because E actually comes after dot.
So that's the label. And then we use this label five to index into the
probability distribution here. So this index five here is 0 1 2 3 4 5. It's this number here.
Which is here. So that's basically the probability assigned by the neural net to the actual
correct character. You see that the network currently thinks that this next character that E
following dot is only 1% likely. Which is of course not very good, right? Because this actually is a training
example and the network thinks that this is currently very very unlikely. But that's just because we didn't get very
lucky in generating a good setting of W. So right now this network thinks this is unlikely and 0.01 is not a good outcome.
So the log likelihood then is very negative. And the negative log likelihood is very
positive. And so four is a very high negative log likelihood and that means we're going to
have a high loss. Because what is the loss? The loss is just the average negative log
likelihood. So the second character is E M. And you see here that also the network
thought that M following E is very unlikely, 1%. Uh the for M following M it thought it
was 2%. And for A following M it actually thought it was 7% likely. So just by
chance this one actually has a pretty good probability and therefore a pretty low negative log likelihood.
And finally here it thought this was 1% likely. So overall our average negative log
likelihood, which is the loss, the total loss that summarizes basically the how well this network currently works at
least on this one word, not on the full data set, just the one word, is 3.76. Which is actually very fairly high loss.
This is not a very good setting of Ws. Now here's what we can do. We're currently getting 3.76.
We can actually come here and we can change our W. We can resample it. So let me just add one to have a different
seed. And then we get a different W. And then we can rerun this.
And with this different seed with this different setting of Ws we now get 3.37. So this is a much better W, right? And
that and it's better because the probability just happened to come out higher for the for the characters that
actually are next. And so you can imagine actually just resampling this, you know, we can try
two. So Okay, this was not very good.
Let's try one more. We can try three. Okay, this was terrible setting because
we have a very high loss. So anyway, I'm going to erase this.
What what I'm doing here, which is just guess and check of randomly assigning parameters and seeing if the network is
good, that is amateur hour. That's not how you optimize a neural net. The way you optimize a neural net is you start
with some random guess, and we're going to commit to this one even though it's not very good.
But now the big deal is we have a loss function. So this loss
is made up only of differentiable operations. And we can minimize the loss by tuning
W's by computing the gradients of the loss with respect to these W matrices. And so then we can tune W to minimize
the loss and find a good setting of W using gradient-based optimization. So let's see how that will work. Now things
are actually going to look almost identical to what we had with micrograd. So here I pulled up the lecture from
micrograd, the notebook. It's from this repository. And when I scroll all the way to the end
where we left off with micrograd, we had something very very similar. We had a number of input examples. In
this case, we had four input examples inside X's. And we had their targets. These are
targets. Just like here, we have our X's now, but we have five of them, and they're now
integers instead of vectors. But we're going to convert our integers to vectors, except our vectors will be
27 large instead of three large. And then here what we did is first we did a forward pass where where ran a
neural net on all of the inputs to get predictions. Our neural net at the time, this NFX,
was a net a multi-layer perceptron. Our neural net is going to look different because our neural net is just
a single layer. Single linear layer followed by a softmax.
So, that's our neural net. And the loss here was the mean squared error. So, we simply subtracted the
prediction from the ground truth and squared it and summed it all up. And that was the loss. And loss was the
single number that summarized the quality of the neural net. And when loss is low, like almost zero, that means the
neural net is um predicting correctly. So, we had a single number that uh that
summarized the uh the performance of the neural net. And everything here was differentiable
and was stored in massive compute graph. And then we iterated over all the parameters. We made sure that the
gradients are set to zero. And we called loss.backward. And loss.backward initiated
backpropagation at the final output node of loss. Right? So, yeah, remember these expressions? We had
loss all the way at the end. We start backpropagation and we went all the way back.
And we made sure that we populated all the parameters.grad. So, that grad started at zero, but
backpropagation filled it in. And then in the update, we iterated over all the parameters and we simply did a
parameter update where every single uh element of our parameters was nudged in the opposite direction of the gradient.
And so, we're going to do the exact same thing here. Uh so, I'm going to pull this up
on the side here so that we have it available. And we're actually going to do the exact same
thing. So, this was the forward pass. So, where we did this.
And probs is our Ypred. So, now we have to evaluate the loss, but we're not using the mean squared
error. we're using the negative log likelihood because we are doing classification, we're not doing
regression, as it's called. So, here we want to calculate loss. Now, the way we calculate it is is just
this average negative log likelihood. Now, this probs here has a shape of 5 by 27.
And so, to get all the we basically want to pluck out the probabilities at the correct indices here.
So, in particular, because the labels are stored here in the array wise, basically what we're after is for the
first example, we're looking at probability of five, right? At index five.
For the second example, at the the second row or row index one, we are interested in the probability
assigned to index 13. At the second example, we also have 13. At the third row, we want one.
And at the last row, which is four, we want zero. So, these are the probabilities we're interested in,
right? And you can see that they're not amazing as we saw above.
So, these are the probabilities we want, but we want like a more efficient way to access these probabilities. Uh not just
listing them out in a tuple like this. So, it turns out that the way to do this in PyTorch, uh one of the ways at least,
is we can basically pass in all of these Sorry about that. All of these um integers in a vectors.
So, the these ones, you see how they're just 0 1 2 3 4,
we can actually create that using MP not MP, sorry, torch.arange of five. 0 1 2 3 4.
So, we can index here with torch.arange of five. And here, we index with wise.
And you see that that gives us exactly these numbers. So, that plugs out the probabilities of
that the neural network assigns to the correct next character. Now, we take those probabilities and we
don't we actually look at the log probability. So, we want to dot log. And then, we want to just average that
up. So, take the mean of all of that. And then, it's the negative average log likelihood that is the loss.
So, the loss here is 3.7 something. And you see that this loss, 3.76, 3.76 is exactly as we've
obtained before, but this is a vectorized form of that expression. So, we get the same loss.
And this same loss we can consider sort of as part of this forward pass. And we've achieved here now loss.
Okay, so we made our way all the way to loss. We defined the forward pass. We forwarded the network and the loss. Now,
we're ready to do backward pass. So, backward pass. We want to first make sure that all the
gradients are reset. So, they're at zero. Now, in PyTorch, you can set the
gradients to be zero, but you can also just set it to none. And setting it to none is more efficient. And PyTorch will
interpret none as like a lack of a gradient and is the same as zeros. So, this is a way to set to zero the
gradient. And now, we do loss.backward. Before we do loss.backward, we need one
more thing. If you remember from micrograd, PyTorch actually requires
that we pass in requires_grad is true. Uh so that we tell PyTorch that we are interested in
calculating gradients for this leaf tensor. By default, this is false. So, let me recalculate with that.
And then, set to none and loss.backward. Now, something magical happened when loss of backward was run.
Because PyTorch, just like micrograd, when we did the forward pass here, it keeps track of all the operations
under the hood. It builds a full computational graph. Just like the graphs we could produce in
micrograd, those graphs exist inside PyTorch. And so, it knows all the dependencies
and all the mathematical operations of everything. And when you then calculate the loss, we
can call a dot backward on it. And dot backward then fills in the gradients of all the intermediates all
the way back to W's, which are the parameters of our neural net. So, now we can do W.grad,
and we see that it has structure. There's stuff inside it. And these gradients, every single
element here, so W.shape is 27 by 27. W.grad's shape is the same, 27 by 27.
And every element of W.grad is telling us the influence of that weight on the loss
function. So, for example, this number all the way here,
if this element, the 0 0 element of W, because the gradient is positive, it's telling us that this has a positive
influence on the loss, slightly nudging W slightly taking W00
and adding a small H to it would increase the loss mildly because this gradient is positive.
Some of these gradients are also negative. So, that's telling us about the gradient
information, and we can use this gradient information to update the weights of this neural network. So,
let's now do the update. It's going to be very similar to what we had in micrograd. We need no uh loop over all
the parameters because we only have one parameter uh tensor, and that is W. So, we simply do W.data plus equals uh, the
We can actually copy this almost exactly. -0.1 * uh, W.grad.
Um, and that would be the update to the tensor.
So, that updates the tensor. And because the tensor is updated, we would expect that now the loss should
decrease. So, here, if I print loss .item,
it was 3.76, right? So, we've updated the W here. So, if I recalculate forward pass,
loss now should be slightly lower. So, 3.76 goes to 3.74.
And then, we can again set to set grad to none and backward update. And now the parameters changed again.
So, if we recalculate the forward pass, we expect a lower loss again, 3.72. Okay, and this is again doing the We're
now doing gradient descent. And when we achieve a low loss, that will mean that the network is assigning
high probabilities to the correct next characters. Okay, so I rearranged everything and I put it all together
from scratch. So, here is where we construct our data set of bigrams.
You see that we are still iterating only on the first word, Emma. I'm going to change that in a second. I
added a number that counts the number of elements in X's, so that we explicitly see the number of examples is five.
Because currently we're just working with Emma, and there's five bigrams there.
And here I added a loop of exactly what we had before. So, we had 10 iterations of gradient descent, of forward pass,
backward pass, and an update. And so, running these two cells, initialization and gradient descent
gives us some improvement on uh the loss function. But now, I want to use all the words.
And there's not five, but 228,000 bigrams now. However, this should require no
modification whatsoever. Everything should just run because all the code we wrote doesn't care if there's five
bigrams or 228,000 bigrams. And with everything, we should just work. So, you see that this will just run.
But now we are optimizing over the entire training set of all the bigrams. And you see now that we are decreasing
very slightly. So, actually, we can probably afford a larger learning rate. We can probably afford even larger
learning rate. Even 50 seems to work on this very, very simple example, right? So, let me
re-initialize, and let's run 100 iterations. See what happens.
Okay. We seem to be coming up to some pretty good losses
here. 2.47. Let me run 100 more. What is the number that we expect, by
the way, in the loss? We expect to get something around what we had originally, actually.
So, all the way back, if you remember, in the beginning of this video, when we optimized uh just by counting,
our loss was roughly 2.47 after we added smoothing. But before smoothing, we had roughly
2.45 uh likelihood. Um sorry, loss.
And so, that's actually roughly the vicinity of what we expect to achieve. But before we achieved it by counting,
and here we are achieving the roughly the same result, but with gradient-based optimization.
So, we come to about 2.4 6, 2.45, etc. And that makes sense because
fundamentally, we're not taking any additional information. We're still just taking in the previous character and
trying to predict the next one. But instead of doing it explicitly by counting and normalizing,
we are doing it with gradient-based learning. And it just so happens that the explicit approach happens to very
well optimize the loss function without any need for gradient-based optimization because the setup for bigram language
models are is is so straightforward and so simple. We can just afford to estimate those probabilities directly
and maintain them in a table. But the gradient-based approach is significantly more flexible.
So we've actually gained a lot because what we can do now is um we can expand this approach and
complexify the neural net. So currently we're just taking a single character and feeding into a neural net
and the neural net is extremely simple. But we're about to iterate on this substantially. We're going to be taking
multiple previous characters and we're going to be feed them in feeding them into increasingly more complex neural
nets. But fundamentally uh the output of the neural net will always just be logits.
And those logits will go through the exact same transformation. We are going to take them through a softmax,
calculate the loss function and the negative log likelihood, and do gradient-based optimization.
And so actually as we complexify the neural nets and work all the way up to transformers,
none of this will really fundamentally change. None of this will fundamentally change. The only thing that will change
is the way we do the forward pass where we take in some previous characters and
calculate the logits for the next character in a sequence. That will become more complex
and uh but we'll use the same machinery to optimize it. And um
it's not obvious how we would have extended this bigram approach into the case where there are many more
characters at the input because eventually these tables would get way too large because there's way too many
combinations of what previous characters uh could be. If you only have one previous character,
we can just keep everything in a table, the counts. But if you have the last 10 characters that are input, we can't
actually keep everything in a table anymore. So this is fundamentally an unscalable approach, and the neural
network approach is significantly more scalable, and it's something that actually we can improve on over time. So
that's where we will be digging next. I wanted to point out two more things. Number one,
I want you to notice that this X inc here, this is made up of one-hot vectors, and
then those one-hot vectors are multiplied by this W matrix. And we think of this as uh multiple
neurons being forwarded in a fully connected manner. But actually what's happening here is
that, for example, if you have a one-hot vector here that has a one at, say, the fifth dimension,
then because of the way the matrix multiplication works, multiplying that one-hot vector with W
actually ends up plucking out the fifth row of W. Logits would become just a fifth row of
W. And that's because of the way the matrix multiplication works.
Um so that's actually what ends up happening.
So but that's actually exactly what happened before. Because remember all the way up here,
we have a bigram. We took the first character, and then that first character indexed into a row of this array here.
And that row gave us the probability distribution for the next character. So the first character was used as a
lookup into a uh matrix here to get the probability
distribution. Well, that's actually exactly what's happening here.
Because we're taking the index, we're encoding it as one-hot, and multiplying it by W.
So logits literally becomes the uh the the appropriate row of W.
And that gets just as before exponentiated to create the counts and then normalized and becomes
probability. So this W here is literally
the same as this array here. But W, remember, is the log counts, not the counts. So it's more precise to say
that W exponentiated W.exp is this array. But this array was filled in by counting
and by basically populating the counts of bigrams, whereas in the gradient-based framework,
we initialize it randomly and then we let the loss guide us to arrive at the exact same array.
So this array exactly here is basically the array W at the end of
optimization, except we arrived at it piece by piece by following the loss. And that's why we also obtain the same
loss function at the end. And the second note is if I come here, remember the smoothing where we added
fake counts to our counts in order to smooth out and make more uniform the distributions of these probabilities.
And that prevented us from assigning zero probability to um
to any one bigram. Now, if I increase the count here, what's happening to the probability?
As I increase the count, probability becomes more and more uniform. Right? Because these counts go only up
to like 900 or whatever. So if I'm adding plus a million to every single number here, you can see how uh the row
and its probability then when we divide is just going to become more and more close to exactly even probability,
uniform distribution. It turns out that the gradient-based framework has an equivalent to
smoothing. In particular, think through these W's here,
which we initialized randomly. We could also think about initializing W's to be zero.
If all the entries of W are zero, then you'll see that logits will become all zero.
And then exponentiating those logits becomes all one. And then the probabilities turn out to
be exactly uniform. So, basically when W's are all equal to each other, or say especially zero,
then the probabilities come out completely uniform. So,
trying to incentivize W to be near zero is basically equivalent to label smoothing. And the more you incentivize
that in the loss function, the more smooth distribution you're going to achieve.
So, this brings us to something that's called regularization, where we can actually augment the loss
function to have a small component that we call a regularization loss. In particular, what we're going to do is
we can take W, and we can for example square all of its entries,
and then we can uh oops. Sorry about that. We can take all the entries of W, and we
can sum them. And because we're squaring, uh there will be no signs anymore. Um
negatives and positives all get squashed to be positive numbers. And then the way this works is you
achieve zero loss if W is exactly or zero. But if W has non-zero numbers, you
accumulate loss. And so, we can actually take this, and we can add it on here.
So, we can do something like loss plus W squared dot sum.
Or let's actually, instead of sum, let's take a mean, cuz otherwise the sum gets too large.
So, mean is like a little bit more manageable. And then we have a regularization loss
here, let's say 0.01 times, or something like that. You can choose the regularization strength.
And then we can just optimize this. And now this optimization actually has two components. Not only is it trying to
make all the probabilities work out, but in addition to that, there's an additional component that simultaneously
tries to make all W's be zero. Because if W's are non-zero, you feel a loss. And so minimizing this, the only way to
do achieve that is for W to be zero. And so you can think of this as adding like a spring force or like a gravity
force that that pushes W to be zero. So W wants to be zero, and the probabilities want to be uniform, but
they also simultaneously want to match up your your probabilities as indicated by the data.
And so the strength of this regularization is exactly controlling the amount of counts
that you add here. Adding a lot more counts here corresponds to
increasing this number. Because the more you increase it, the more this part of the loss function
dominates this part, and the more these these weights will be unable to grow because as they grow,
they accumulate way too much loss. And so if this is strong enough, then we are not able to overcome the
force of this loss, and we will never and basically everything will be uniform predictions.
So I thought that's kind of cool. Okay, and lastly, before we wrap up, I wanted to show you how you would
sample from this neural net model. And I copy-pasted the sampling code from before.
Where remember that we sampled five times. And all we did is we started zero, we
grabbed the current IX row of P. And that was our probability row from which we sampled the next index and
just accumulated that and break when zero. And running this gave us these results.
I still have the P in memory, so this is fine. Now,
this P doesn't come from the row of P. Instead, it comes from this neural net. First, we take IX
and we encode it into a one-hot row of X inc. This X inc multiplies our W,
which really just plucks out the row of W corresponding to IX. Really, that's what's happening.
And that gets our logits, and then we normalize those logits, exponentiate to get counts, and then normalize to get uh
the distribution, and then we can sample from the distribution. So, if I run this,
kind of anticlimactic or climatic, depending how you look at it, but we get the exact same result.
Um and that's because this is in the identical model. Not only does it
achieve the same loss, but um as I mentioned, these are identical models, and this W is the log counts of what
we've estimated before. But, we came to this answer in a very different way and it's got a very different
interpretation. But, fundamentally, this is basically the same model and gives the same samples here. And so,
that's kind of cool. Okay, so we've actually covered a lot of ground. We introduced the bigram character-level
language model. We saw how we can train the model, how we can sample from the model, and how we
can evaluate the quality of the model using the negative log likelihood loss. And then we actually trained the model
in two completely different ways that actually give the same result and the same model.
In the first way, we just counted up the frequency of all the bigrams and normalized.
In the second way, we used the uh negative log likelihood loss as a guide to optimizing the counts matrix
uh or the counts array so that the loss is minimized in the in a gradient-based framework. And we saw that both of them
give the same result. And um that's it.
Now, the second one of these, the gradient base framework, is much more flexible. And right now, our neural
network is super simple. We're taking a single previous character, and we're taking it through a single linear layer
to calculate the logits. This is about to complexify. So, in the follow-up videos, we're going to be
taking more and more of these characters, and we're going to be feeding them into
a neural net. But, this neural net will still output the exact same thing. The neural net
will output logits. And these logits will still be normalized in the exact same way, and
all the loss and everything else in the gradient gradient base framework, everything stays identical.
It's just that this neural net will now complexify all the way to transformers. So, that's going to be pretty awesome,
and I'm looking forward to it. For now, bye.
A bigram model is the simplest language model that predicts the next character based on only the previous one character. In this implementation, each word is processed as a sequence of characters with special start (.) and end (.) tokens, and the model counts how often each character pair (bigram) appears. These counts are converted into probabilities to generate new character sequences, making it a foundational stepping stone for more advanced architectures like RNNs and Transformers.
The dataset 'names.txt' contains approximately 32,000 names. Each word is wrapped with special start (.) and end (.) tokens to capture first-character and last-character patterns. For example, word 'Reese' becomes the sequence '.Reese.' which produces training pairs: (., R), (R, e), (e, e), (e, s), (s, e), (e, .). This extracts all useful information including which characters typically start and end names, and how characters transition within names.
Adding 1 to all counts before normalization is called Laplace smoothing. This prevents zero probabilities for character pairs that never appeared in the training data. Without smoothing, any unseen bigram would receive zero probability, making the model unable to generate novel combinations and potentially causing infinite negative log-likelihood when evaluating on test data. Smoothing ensures the model can assign small but non-zero probability to any possible character pair.
To normalize each row so it sums to 1, use the pattern P /= P.sum(1, keepdim=True). The keepdim=True parameter is critical because it preserves the tensor's 2D shape (keeping it as a column-like structure). Without keepdim=True, P.sum(1) returns a 1D tensor, and broadcasting would silently align it with columns instead of rows, causing you to normalize columns by mistake. Always verify dimensions match your intent when using broadcasting.
Log-likelihood measures how well the model predicts the training data: higher values (closer to zero) indicate better predictions. Because multiplying many small probabilities causes numerical underflow, logarithms convert multiplication into addition. The average negative log-likelihood (cross-entropy loss) provides a normalized metric: random guessing gives about 3.30, while a good bigram model should achieve significantly lower numbers. The loss indicates the average 'surprise' per prediction, with lower values meaning the model is more confident and accurate.
Start with the special start token (index 0 for '.'), then in each iteration look up the probability distribution for the current character's row in the normalized matrix. Use torch.multinomial() to sample the next character index from this distribution, append the corresponding character to the output string, and continue until the special end token (index 0) is sampled. Using a seeded torch.Generator ensures reproducible results across runs. The model typically produces some recognizable name patterns but mostly nonsense due to its limited context window.
The main limitation is that bigram models only consider one previous character when predicting the next, so they cannot capture long-range dependencies, grammatical structure, or semantic meaning. This produces names that have locally plausible character transitions but miss deeper patterns like syllables or roots. Despite this limitation, building a bigram model is valuable because it teaches fundamental concepts: counting/normalization, probability distributions, log-likelihood evaluation, and the importance of start/end tokens, which are essential foundations for understanding more complex architectures like RNNs, LSTMs, and Transformers.
Keep this summary
Save it to LunaNotes and it becomes a real note in your library — editable, searchable, and ready to turn into flashcards or a diagram. Free to start.
Save to LunaNotesOr summarise for another video.
32K+ people have turned 65K+ videos into notes
“Very useful tool. I'm using it for the last couple weeks and I'm loving it so far. Makes learning on YouTube a great experience.”
This summary and transcript were automatically generated using AI with the Free YouTube transcript summary tool by LunaNotes.
Related summaries
How Large Language Models Actually Predict the Next Word
This video provides a clear, step-by-step explanation of how large language models (LLMs) work, from the core concept of next-word prediction to the intricacies of transformers and training at scale. Discover how GPT-3 and similar models are built, how they learn from trillions of text examples, and why their outputs can feel so natural and useful.
Building and Understanding GPT: From Shakespeare to ChatGPT
Explore how GPT models like ChatGPT work under the hood, including the Transformer architecture, training on Tiny Shakespeare data, and innovations like self-attention and multi-head attention. Learn how scaling up these principles leads to powerful language models and the stages involved in creating ChatGPT.
Generative AI Career: Top 10 Basic Keywords Explained Simply
Kickstart your Generative AI career by mastering core concepts. This video covers 10 essential keywords from tokenization to RAG, helping you build a strong foundation before moving to production.
20 Key AI Terms Every Engineer Must Know Explained Simply
This video breaks down 20 essential AI and large language model (LLM) terms, from tokenization and vectors to RAG and reinforcement learning. Designed for engineers, it clarifies confusing jargon and connects concepts like transformers, fine-tuning, and MCP to real-world applications.
Complete Guide to LangChain Models: Language & Embedding Explained
Explore the LangChain model component in depth, covering language and embedding models. Learn how to code with OpenAI, Anthropic, Google Gemini, and open-source models using Hugging Face, plus build a document similarity app.
Most viewed summaries
A Comprehensive Guide to Using Stable Diffusion Forge UI
Explore the Stable Diffusion Forge UI, customizable settings, models, and more to enhance your image generation experience.
Kolonyalismo at Imperyalismo: Ang Kasaysayan ng Pagsakop sa Pilipinas
Tuklasin ang kasaysayan ng kolonyalismo at imperyalismo sa Pilipinas sa pamamagitan ni Ferdinand Magellan.
Mastering Inpainting with Stable Diffusion: Fix Mistakes and Enhance Your Images
Learn to fix mistakes and enhance images with Stable Diffusion's inpainting features effectively.
Pamamaraan at Patakarang Kolonyal ng mga Espanyol sa Pilipinas
Tuklasin ang mga pamamaraan at patakaran ng mga Espanyol sa Pilipinas, at ang epekto nito sa mga Pilipino.
How to Install and Configure Forge: A New Stable Diffusion Web UI
Learn to install and configure the new Forge web UI for Stable Diffusion, with tips on models and settings.
Found this summary useful?
Take it with you. One click puts it in your own LunaNotes library.
Save to LunaNotes