hi everyone hope you're well and 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 you can look at it 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 as the name
suggests 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 the government website and if you train make more on this data set
it will learn to make more of things like
this 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 a 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 don't tell i wrote
zendy and so on and so all these sort of sound name-like 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 be 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 root models multilevel perceptrons recurring
neural networks all the way to modern transformers in fact the transformer that we will build will be
basically the equivalent transformer to gpt2 if you have heard of gpt 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
on the level of characters now to give you a sense of the extensions here 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 but we can generate entire large much
larger documents
go into images and image text networks such as DALI stable diffusion and so on but for now we
have to start 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 only like the individual
words and put them in the list so let's call split lines 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 length of each word for w in words so the shortest word
will be length
two and max of one 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 an
instance 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 as a bell 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'd 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 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
if 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 in words, each W here is an individual word, a string.
We want to iterate this word with consecutive characters.
So two characters at a time, sliding it through the word.
Now, an interesting, nice way, cute way to do this in Python, by the way,
is doing something like this.
For character1, character2, in, zip, off, W, and W at 1.
One column.
Print, character1, character2.
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, MR.
You see how we have a M up, and this will just print EM, MM, MA.
And the reason this works is because W is the string M up,
W at 1 column 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 halt and return.
So basically, that's why we return EM, MM, MM, MA.
But then, because this iterator's 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 very likely to come first,
but 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, all characters,
and we're going to hallucinate a special start token here.
I'm going to call it like, special start.
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, Emma.
List of w will just have the individual characters in the list.
And then doing this again now, but not iterating over w's, 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 dataset,
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 character1, character2.
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 0, plus 1.
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.
So that's why this occurred three times.
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 dataset.
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.
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.
And 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 sorted of this.
But by default, sort is 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 that takes the key value
and returns the key value at 1, not at 0, but at 1, which is the count.
So we want to sort by the count.
Well, these elements.
And actually, we want it to go backwards.
So here what we have is the bigram QNR occurs only a single time.
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.
So this is kind of the individual counts that we achieve over the entirely.
Now it's actually going to be significantly more convenient for us to keep this information in one
two-dimensional array
So we're going to sort this information in two D array and the rose are going to be the
first character of the Bank and the columns are going to be the second character,
and each entry in this two-dimensional array will tell us.
Um,
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 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 arrays. So let's create an array of zeros
and we give it a 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.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
we're going to use a single precision floating point number. So we're going to use a single
precision floating point number. Let's actually use d type as torch.in32. So these are 32-bit
integers. So now you see that we have integer data inside this tensor. Now tensors allow us to really
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 because it's
zero indexed. So this is row index one and column index zero one two three. So a at one comma three
we can set that to one and then a will have a one over there. We can of course also do things like
this. So now a will be two over there or three and also we can for example say a zero zero is five
and then a will have a five 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 we
want 26 plus 2 or 28 by 28 array and let's call it the capital 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 that into the array. So that's the array that starts at zeros 28 by 28 and now let's copy paste the
this here. But instead of having a dictionary b 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 basically
index into a 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 and 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 sorted list. So those are our characters.
Now what we want is this lookup table as I mentioned. So let's create a special s to i
I will call it. s is string or character and this will be an s to i 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 s to i 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 to 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 1 and character 2 to their integers. So this will be s to i at character 1
and i x 2 will be s to i of character 2. And now we should be able to do this line
but using our array. So n at i x 1 i x 2 this is the two-dimensional array indexing I've shown you before and honestly just plus equals 1.
Because everything starts at 0. 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 this.
We can do things like plti and show of the count array. So this is the 28 by 28 array and this is the 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. The first thing we're going to need is
we're going to need to invert this array here, this dictionary. So s to i is a mapping from s to i and in i to s we're going to reverse the array.
So iterating over all the items and just reverse that array. So i to 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 visualize a bunch of things later.
Let me just run it so you get a sense of what this is.
So we're going to do this.
Okay, so you see here that we have
the array spaced out and every one of these is basically like b follows g 0 times.
b follows h 41 times. So a follows j 175 times. 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 those 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 a torch dot 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?
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 two by two 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.
But we're going to use something else.
So let's fix all 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 set 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.
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 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've reversed 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 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.
This is the counts array of our entire data set.
So this array actually has all 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 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 right here.
So we're looking at this right here.
So we see that we have the counts,
and those counts externally 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 a zero,
and then using this notation, colon,
for the rest of that row.
So N zero colon is indexing into the zero,
and then it's grabbing all the columns.
And so this will give us a one-dimensional array
of the first row.
So zero, four, four, 10.
You know, it's zero, four, four, 10,
one, three, oh, six, one, five, four, two, et cetera.
It's just the first row.
The shape of this is 27.
It's just the row of 27.
And the other way that you can do this also is you just,
you don't actually give this,
you just 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 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,
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 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 character of a word.
So we can do this.
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 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.
So this makes everything deterministic.
So when you run this on your computer,
you're going to get the exact same results
that I'm getting here on my computer.
So let me show you how this works.
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, gives us an object g.
And then we can pass that g to a function,
a function that creates here random numbers.
torch.rand creates random numbers, three of them.
And it's using this generator object
as a source of randomness.
So without normalizing it, I can just print.
This is sort of like numbers between 0 and 1
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, 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, for some reason, it's false.
And I think it's just something to be careful with.
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 0.
We'd expect 30% of them to be 1.
And because the element index 2 has only 10% probability,
very few of these samples should be 2.
And indeed, we only have a small number of 2s.
And we can sample as many as we want.
And the more we sample, the more these numbers
should roughly have the distribution here.
So we should have lots of 0s, half as many 1s.
And we should have three times as few 1s and three times
as few 2s.
So you see that we have very few 2s.
We have some 1s, and most of them are 0s.
So that's what we're going to do.
Thank you.
So that's what Torchlight Multinomial is doing.
For us here, we are interested in this row.
We've created this p here.
And now we can sample from it.
So if we use the same seed, and then we
sample from this distribution, and let's just get one sample,
then we see that the sample is, say, 13.
So this will be the index.
And let's see.
See how it's a tensor that wraps 13?
We again have to use .item to pop out that integer.
And now index would be just the number 13.
And of course, 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 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 was actually a fairly likely character to draw.
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 is already sampled.
So now to draw the next character, we're going to use m.
m is already sampled. So now to draw the next character, we're going to use m.
And we'll 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 dot is 516,
m a is this many, m b 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...
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
Converted to float is our P
Then we normalize the speed to sum to one
I
Accidentally ran the infinite loop we normalize P to sum to one then we need this generator object
Now 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
That's pretty much it we're just 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 is
We printed here as well, so
Let's not do this a few times
So let's actually create an out list here
And instead of printing we're going to append so out dot 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
always getting the same result because of the generator so if we want to do this a few times
we can go for high 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 started 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 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 yanu
riley etc but they're just like totally messed up 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 different
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 some nonsense names another way to do this is to convince yourself that it's 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 torch dot ones
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
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 then 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
renormalizing these rows over and over again and it's extremely inefficient and wasteful
so we're doing this every single iteration of this loop and we just keep renormalizing these rows over
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 that is normalized to one
indicating the probability distribution for the next character given the character before it
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
so here we would like to just do p equals p of i x instead okay the other reason i want to do this
is not just for efficiency but also i would like us to practice these n-dimensional tensors and
i'd like us to practice 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 array operations for efficiency and we're going to have to do some
pretty complicated array operations for efficiency and 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 dot sum
but now we have to be careful because p dot sum actually produces a sum
sorry p equals n dot float copy p dot 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 define 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 of the sum and we can see that the sum is
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 over rows right now one more argument that i want you to pay
attention to here is the keep them is false if keep them 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 keep them 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 dot sum of sum axis and in particular notice
that p dot shape is 27 by 27 so when we sum up across axis 0
then we would be taking the 0th dimension
and we would be summing across it
so when keep dim is true
then this thing
will not only give us the counts
across
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 passed in 0 dimension
so this 0th dimension becomes 1
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 keep dim
is that we just get 27
so it squeezes out
that dimension
and we just get a 1 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
0th
the columns
we actually want to sum the other way
along dimension 1
and you'll see that the shape of this is 27 by 1
so it's a column vector
it's a 27 by 1
vector of counts
and that's because what's happened here is that we're going horizontally
and this 27 by 27 matrix becomes a
27 by 1 array
now you'll notice by the way that
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
rows
horizontally
so what we want here
is p.sum of 1
with keep dim true
27 by 1 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 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 1 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
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 1
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 right
and going to the left
each dimension must be either equal
one of them is a 1
or one of them does not exist
so in this case they're not equal
but one of them is a 1
so this is fine
and then this dimension they're both equal
so this is fine
so all the dimensions
are fine and therefore 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 1? What it does
is that it takes this dimension 1 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 1, and it copies it 27 times
to make these both be 27 by 27 internally. You can think of it 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 1 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. 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 you should do with it. 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 dot sum of 1,
keep them as true. The shape of this is 27 by 1. 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 27 by 1.
vector right now suppose that I tried to do the following but I erase keep them
just true here what does that do if keep them 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 1 it's just 27 the one disappears but all
the counts are the same so you'd think that this divide that would would work
first of all can we even write this and will it even is it even is it even
expected to run is it broadcastable let's determine if this result is
broadcastable p.summit1 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 then
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 broadcast and
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
but it's actually we are this 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 dem was
necessary here okay so the reason to do for this is I'm trying to hint at here
when I was sort of giving you a bit of a hint on how this works this 27 factor is
internally inside the broadcasting this becomes a 1 by 27 and 1 by 27 is a row vector right and
now we are dividing 27 by 27 by 1 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 wise divides and so basically what's happening here is we're actually
normalizing the columns instead of normalizing the rows so you can check that what's happening
here is that P at 0 which is the first row of P dot sum is not 1 it's 7 it is the first column
as an example that sums to 1 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
counts on the right here as a column vector but because the keep dims was true this this 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 at every single point we
are dividing by the by the count in the opposite direction so so this thing just doesn't work
this needs to be keep dims equals true in this case so then then we have that P at 0 is normalized
and conversely the first column you'd expect to potentially not be normalized
and this is what makes it work
so pretty subtle and hopefully this helps to scare you that you should have respect for
broadcasting be careful check your work and 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 find bugs and just be careful one more note on efficiency we don't want
to be doing this here because this creates a completely new tensor that we store into p
we prefer to use in place operations if possible
uh so 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 property distribution
so really these elements of this array p are really the
parameters of our bigram language model giving us in summarizing the statistics of these bigrams
so we train the model and then we know how to sample from the model
we just iteratively uh sample the next character and 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 the quality of the data and we can use that here to kind of write out which is not what we want to use here but like to do keep in front of a table for FARM
summarize the quality of this model into a single number how good is it at predicting the number of Bana
summarize the quality of this model into a single number. How good is it at predicting
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 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. And let me just print these bigrams first. We're
going to use fstrings, 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, 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 of the model, and we can look at the probability of the model,
and we can look at the probability of the model, and we can look at the probability of the model,
which is summarized in the matrix B of Ix1, Ix2. And then we can print it here as probability.
And because these probabilities are way too large, let me percent or colon .4f to 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 dataset. And so we can see some of them are 4%, 3%, etc. Just to have a
measuring stick in our mind, by the way. We have 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's a
good thing. And so we can look at the probability of the model, and we can look at the probability
of the model. Basically, if you have a very good model, you'd expect that these probabilities
should be near one, because that means that your model is correctly predicting what's going to come
next, especially on the training set 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 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 product of these probabilities should
be very high.
Now, because the product of these probabilities is an unwieldy thing to work with, you can see
that all of them are between zero and one. So your product of these probabilities will be a very tiny
number. 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 the likelihood. To get the log
likelihood, we just have to take the log of the probability. And so the log of the probability
here, I have the log of x from zero to one.
The log is a, you see here, monotonic transformation of the probability, where if you pass in one, you
get zero. So probability one gets you log probability of zero. 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
zero.
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.
Let's print it out to get a sense of what that looks like.
Log prob, also, 0.4f.
So as you can see, when we plug in numbers that are very close to some of our higher numbers, we get closer and closer to zero.
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 a large extent, convenience, because we have, mathematically, that if
you have some product A x B x C analyze a function and add some product, you've got a set method.
Yes.
all these probabilities right 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 of the product 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 then the end we can print this
print the log likelihood
f strings maybe you're familiar with this
so log likelihood
is negative 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
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 negative of it and so the negative log likelihood is a negative
likelihood, is a very nice loss function because 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, 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.
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 is what we would use. 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, we want to
maximize the likelihood of all the probabilities assigned by the model. And in our case, the model
parameters here are defined in the table. These numbers, the probabilities are the model parameters
sort of in our diagram language model so far. 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 of all the probabilities
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 just scaling your, 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 scaling, you can look at it that way. And so these are two identical optimization
problems.
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 for 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 or like on average,
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. When I take Andre and I append Q, and I test the
probability of it Andre q, we actually get infinity. And that's because J Q has a 0%
probability according to our model. So the log likelihood, so the log of 0% is 0% which is the
probability of actually dancing. And then what happens when I take Andre, I take Andre q, and I test the
So the log of 0 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 predict this name.
And our loss is infinity on this example.
And really the reason for that is that j is followed by q 0 times.
Where is q?
jq is 0.
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.
And it's called model smoothing.
And roughly what's happening is that we will add some fake counts.
So imagine adding a count of 1 to everything.
So we add a count of 1 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 5 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, the more peaked model you are going to have, of course.
So 1 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 in principle, it could.
But what that's going to do...
What it'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 a 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 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.
And 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 its 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 the second character actually comes next in the bigram allows us to then look at
how high of 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 labels.
And these bigrams will denote x, y.
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 xs.append is just x1.
ys.append is x2.
And then here we actually don't want lists of integers.
We will create tensors out of these.
xs is torch.tensor of xs.
And ys is torch.tensor of ys.
And then we don't actually want to take all the words just yet because I want everything to be manageable.
So let's just do the first word, which is Emma.
And then it's clear what these xs and ys would be.
Here let me print character1, character2, just so you see what's going on here.
So the bigrams of these characters is...
So this single word, as I mentioned, has one, two, three, four, five examples for our neural network.
There are five separate examples in Emma.
And those examples I'll summarize here.
When the input to the neural network is integer 0, the desired label is integer 5, which corresponds to e.
When the input to the neural network is 5, 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 1 to have a high probability.
When 1 is input, we want 0 to have a very high probability.
So there are five separate input examples to a neural net in this dataset.
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.capitalTensor class,
which you can also construct, so you can actually call both.
You can also do torch.capitalTensor, and you get an x as in y as well.
So that's not confusing at all.
There are threads on what is the difference between these two.
And unfortunately, the docs are just not clear on the difference.
And when you look at the docs of lowercase tensor,
constructs tensor with no autograd history by copying data.
It's just like, 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...
Where is this?
Torch.tensor infers the D type, the data type,
automatically, while torch.tensor just returns a float tensor.
I would recommend to stick to torch.lowercase tensor.
So indeed, we see that when I construct this with a capital T,
the data type here of x is float32.
But torch.lowercase tensor,
you see how it's now x.dtype is now integer.
So it's advised that you use lowercase t
and you can read more about it if you like in some of these threads.
But basically, I'm pointing out some of these things
because I want to caution you and I want you to get used to reading a lot of documentation
and reading through a lot of Q&As and threads like this.
And 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 sense.
And so lowercase tensor is what we are using.
OK, 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 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 are sort of made up of these neurons
and these neurons have weights.
And as you saw in microGRAD, these weights act multiplicatively on the inputs.
WX plus B, there's 10 Hs 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, 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 1.
And then that vector can feed into a neural net.
Now, conveniently, PyTorch actually has something called the one-hot function
inside torch and then functional.
It takes a tensor made up of integers.
Long is an integer.
And it also takes a number of classes,
which is how large you want your tensor, your vector to be.
So here, let's import torch.nn.func.
This is a common way of importing it.
And then let's do f.one-hot.
And we feed in the integers that we want to encode.
So we can actually feed in the entire array of Xs.
And we can tell it that numclasses 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 xinc for xencoded.
And then we see that xencoded.shape is 5 by 27.
And we can also visualize it, plt.imshow of xinc,
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 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 here, for example, the zeroth bit is turned on.
The fifth bit is turned on.
Thirteenth 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 integers into vectors.
And then these vectors can feed into neural nets.
One more issue to be careful with here, by the way, is
let's look at the data type of xincoding.
We always want to be careful with data types.
What would you expect xincoding'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 dtype here is actually a 64-bit integer.
And the reason for that, I suspect,
is that one hot received a 64-bit integer here,
and it returned the same data type.
And when you look at the signature of one hot,
it doesn't even take a dtype, 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 dtype 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 dtype is float32.
And floats can feed into 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.
What are the initial weights at initialization for this neuron?
Let's initialize them with torch.random.
torch.random fills a tensor with random numbers
drawn from a normal distribution.
And a normal distribution has a probability density function like this.
And so most of the numbers drawn from this distribution
will be around zero,
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 to be 27 by one.
So 27 by one
and then let's visualize w.
So w is a column vector of 27 numbers.
And 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 five by one.
The reason it's five by one is the following.
We took x encoding
which is five by 27
and we multiplied it by 27 by one.
And in matrix multiplication,
you see that the output will become five by one
because these 27 will multiply and add.
So basically what we're seeing here
out of this operation
is we are seeing the five 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 the 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 is just wx.
There's no bias.
It has value w times x for all of them 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 bigger result.
So now what we've done is five by 27 multiplied 27 by 27.
And the output of this is now five by 27.
So we can see that the shape of this is five 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,
three 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.
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 w's.
And in matrix multiplication,
we're just doing those dot products in parallel.
Just to show you that this is the case,
we can take xank and we can take the third row.
And we can take the w and take its 13th column.
And then we can do xank at three
element-wise multiply with w at 13
and sum that up.
That's wx plus b.
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 times x.
They don't have a bias
and they don't have a nonlinearity 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 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 the neural net layer
initialized with these
normal distribution parameters.
But what we want is
we want something like we had here.
Like each row here told us the counts
and then we normalize the counts
to get probabilities.
And we want something similar
to come out of the 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 have a special structure.
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 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 27 numbers
is that these 27 numbers
are giving us log counts, basically.
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.
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 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 their 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 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.
So like, for example,
seven
is some positive number over here
that is greater than zero.
But exponentiated outputs here
basically give us something that we can use and interpret
as the equivalent of counts originally.
So you see these counts here?
1, 12, 7, 51, 1, etc.
The neural net is kind of now predicting
counts.
And these counts are positive numbers.
They're probably below zero, so that makes sense.
And 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.
In other words 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 array of counts.
And each row here are the counts for the next character, sort of.
So those are the counts.
And now the probabilities are just the counts normalized.
And so 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.sum along the first dimension.
And we want to keep dims as true.
We've went over this.
And this is how we normalize the rows of our counts matrix
to get our probabilities.
Props.
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 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
can be interpreted to be 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 for example, for the zeroth example that fed in,
which was the zeroth example here,
was a one-hot vector of zero.
And it basically corresponded to feeding in 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,
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 torch generators now
so that you see the same numbers
that I see.
And I'm generating 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 xinc becomes an 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 these last two lines, by the way, here
are called the softmax,
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 divides and normalizes.
It's a way of taking outputs of a neural net layer.
And 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.
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
all of these, this entire forward pass
is made up of differentiable layers.
Everything here we can backpropagate through.
And we saw some of the backpropagation in micrograd.
This is just multiplication and addition.
All that's happening here is just multiply and add.
And we know how to backpropagate through them.
Exponentiation, we know how to backpropagate through.
And then here, we are summing.
And sum is easily backpropagatable as well.
And division as well.
So everything here is a differentiable operation.
And we can backpropagate through.
Now, we achieve these probabilities,
which are 5 by 27.
For every single example,
we have a vector of probabilities that sum to 1.
And then here, I wrote a bunch of stuff
to sort of like break down the examples.
So we have 5 examples making up Emma, right?
And there are 5 bigrams inside Emma.
So bigram example 1
is that E is the beginning character
right after dot.
And the indexes for these are 0 and 5.
So then we feed in a 0
that's the input to the neural net.
We get probabilities from the neural net
that are 27 numbers.
And then the label is 5
because E actually comes after dot.
So that's the label.
And then we use this label 5
to index into the probability distribution here.
So this index 5 here
is 0, 1, 2, 3, 4, 5.
It's this number here,
and this number 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 4 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 .
And you see here that also the network thought
that M following E is very unlikely, 1%.
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
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 a 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 it's better because the probabilities
just happen to come out higher
for the characters that actually are next.
And so you can imagine actually just resampling this.
We can try 2.
Okay, this was not very good.
Let's try one more.
We can try 3.
Okay, this was a terrible setting
because we have a very high loss.
So anyway, I'm going to erase this.
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 in neural net.
The way you optimize in 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 Ws
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 that'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 Xs.
And we had their targets, desired targets.
Just like here, we have our Xs 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 we ran a neural net
from all of the inputs to get predictions.
Our neural net at the time, this NFX,
was 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 predicting correctly.
So we had a single number
that summarized the performance of the neural net.
And everything here was differentiable
and was stored in a 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
and we iterated backpropagation
at the final output node of loss.
So 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 .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 element of our parameters
was notched in the opposite direction of the gradient.
And so we're going to do the exact same thing here.
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 we did this.
And props 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 just
this average negative log likelihood.
Now, this props here
has a shape of five by twenty seven.
And so to get all that,
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 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.
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,
not just listing them out in a tuple like this.
So it turns out that the way to do this in PyTorch,
one of the ways, at least, is we can basically pass in all of these
sorry about that, all of these
integers in the vectors.
So these ones, you see how they're just zero, one, two, three, four.
We can actually create that using MP, not MP, sorry, torch.arrange of five.
Zero, one, two, three, four.
So we can index here with torch.arrange of five.
And here we index with wise.
And you see that that gives us exactly these numbers.
So that plucks up the probabilities of that.
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 three point seven something.
And you see that this loss, three point seven six, three point seven six is
exactly as we've obtained before, but this is a vectorized form of that expression.
So we get the same loss and the same loss we can consider sort of as part of this
forward pass and we've achieved here now loss.
OK, so we made our way all the way to loss.
We've defined the forward pass.
We forwarded the network and the loss.
Now we're ready to do the 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
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.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 produced 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 00 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 loop over all the parameters
because we only have one parameter tensor and that is w.
So we simply do w.data plus equals.
We can actually copy this almost exactly negative 0.1 times w.grad.
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,
that item,
it was 3.76, right?
So we've updated the w here.
So if I recalculate forward pass,
the loss now should be slightly lower.
So 3.76 goes to 3.74.
And then we can again 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.
OK, 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.
OK, 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 over the first word, Emma.
I'm going to change that in a second.
I added a number that counts the number of elements in Xs
so that we explicitly see that 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 ten iterations of gradient descent of forward pass, backward pass and update.
And so running these two cells, initialization and gradient descent
gives us some improvement on 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.
And 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 just by counting, our loss was roughly 2.47 after we added smoothing.
But before smoothing, we had roughly 2.45 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.
We're achieving roughly the same result, but with gradient based optimization.
So we come to about 2.46, 2.45, etc.
And that makes sense because fundamentally,
we're not taking in 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 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
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 feeding them into increasingly more complex neural nets.
But fundamentally, 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 logits for the next
character in a sequence that will become more complex.
And we'll use the same machinery to optimize it.
And
it's not obvious how we would have extended this bigram approach into
a space 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 could be.
If you only have one previous character,
we can just keep everything in a table that counts.
But if you have the last 10 characters
that are input, we can't actually keep everything in the 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-ENG 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 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 the fifth row of W.
And that's because of the way the matrix multiplication works.
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 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 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 dot 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
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 the row and its probability then when you 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 initialize 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 a 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, whoops, sorry about that.
We can take all the entries of W and we can sum them.
And because we're squaring, there will be no signs anymore.
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 square dot sum.
Or let's actually instead of sum,
let's take a mean because 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 Ws be zero.
Because if Ws are non-zero, you feel a loss.
And so minimizing this, the only way to 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 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
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 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.
OK, 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 start at 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 xank.
This xank 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 the distribution.
And then we can sample from the distribution.
So if I run this,
it's kind of anticlimactic or climatic, depending how you look at it.
But we get the exact same result.
And that's because this is the identical model.
Not only does it achieve the same loss, but 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.
OK, 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 get 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
negative log likelihood loss as a guide to optimizing the counts matrix
or the counts array so that the loss is minimized in a gradient based framework.
And we saw that both of them give the same result.
And
that's it.
Now, the second one of these, the gradient based 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 based 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.
So for now, bye.