The MNIST Loss Function
We already have our independent variables x
—these are the images themselves. We’ll concatenate them all into a single tensor, and also change them from a list of matrices (a rank-3 tensor) to a list of vectors (a rank-2 tensor). We can do this using view
, which is a PyTorch method that changes the shape of a tensor without changing its contents. -1
is a special parameter to view
that means “make this axis as big as necessary to fit all the data”:
In [ ]:
train_x = torch.cat([stacked_threes, stacked_sevens]).view(-1, 28*28)
We need a label for each image. We’ll use 1
for 3s and 0
for 7s:
In [ ]:
train_y = tensor([1]*len(threes) + [0]*len(sevens)).unsqueeze(1)
train_x.shape,train_y.shape
Out[ ]:
(torch.Size([12396, 784]), torch.Size([12396, 1]))
A Dataset
in PyTorch is required to return a tuple of (x,y)
when indexed. Python provides a zip
function which, when combined with list
, provides a simple way to get this functionality:
In [ ]:
dset = list(zip(train_x,train_y))
x,y = dset[0]
x.shape,y
Out[ ]:
(torch.Size([784]), tensor([1]))
In [ ]:
valid_x = torch.cat([valid_3_tens, valid_7_tens]).view(-1, 28*28)
valid_y = tensor([1]*len(valid_3_tens) + [0]*len(valid_7_tens)).unsqueeze(1)
valid_dset = list(zip(valid_x,valid_y))
Now we need an (initially random) weight for every pixel (this is the initialize step in our seven-step process):
In [ ]:
def init_params(size, std=1.0): return (torch.randn(size)*std).requires_grad_()
In [ ]:
weights = init_params((28*28,1))
The function weights*pixels
won’t be flexible enough—it is always equal to 0 when the pixels are equal to 0 (i.e., its intercept is 0). You might remember from high school math that the formula for a line is y=w*x+b
; we still need the b
. We’ll initialize it to a random number too:
In [ ]:
bias = init_params(1)
In neural networks, the w
in the equation y=w*x+b
is called the weights, and the b
is called the bias. Together, the weights and bias make up the parameters.
jargon: Parameters: The weights and biases of a model. The weights are the
w
in the equationw*x+b
, and the biases are theb
in that equation.
We can now calculate a prediction for one image:
In [ ]:
(train_x[0]*weights.T).sum() + bias
Out[ ]:
tensor([20.2336], grad_fn=<AddBackward0>)
While we could use a Python for
loop to calculate the prediction for each image, that would be very slow. Because Python loops don’t run on the GPU, and because Python is a slow language for loops in general, we need to represent as much of the computation in a model as possible using higher-level functions.
In this case, there’s an extremely convenient mathematical operation that calculates w*x
for every row of a matrix—it’s called matrix multiplication. <> shows what matrix multiplication looks like.
This image shows two matrices, A
and B
, being multiplied together. Each item of the result, which we’ll call AB
, contains each item of its corresponding row of A
multiplied by each item of its corresponding column of B
, added together. For instance, row 1, column 2 (the orange dot with a red border) is calculated as $a_{1,1} * b_{1,2} + a_{1,2} * b_{2,2}$. If you need a refresher on matrix multiplication, we suggest you take a look at the Intro to Matrix Multiplication on Khan Academy, since this is the most important mathematical operation in deep learning.
In Python, matrix multiplication is represented with the @
operator. Let’s try it:
In [ ]:
def linear1(xb): return xb@weights + bias
preds = linear1(train_x)
preds
Out[ ]:
tensor([[20.2336],
[17.0644],
[15.2384],
...,
[18.3804],
[23.8567],
[28.6816]], grad_fn=<AddBackward0>)
The first element is the same as we calculated before, as we’d expect. This equation, [[email protected]](https://nbviewer.jupyter.org/cdn-cgi/l/email-protection) + bias
, is one of the two fundamental equations of any neural network (the other one is the activation function, which we’ll see in a moment).
Let’s check our accuracy. To decide if an output represents a 3 or a 7, we can just check whether it’s greater than 0.5, so our accuracy for each item can be calculated (using broadcasting, so no loops!) with:
In [ ]:
corrects = (preds>0.5).float() == train_y
corrects
Out[ ]:
tensor([[ True],
[ True],
[ True],
...,
[False],
[False],
[False]])
In [ ]:
corrects.float().mean().item()
Out[ ]:
0.4912068545818329
Now let’s see what the change in accuracy is for a small change in one of the weights:
In [ ]:
weights[0] *= 1.0001
In [ ]:
preds = linear1(train_x)
((preds>0.0).float() == train_y).float().mean().item()
Out[ ]:
0.4912068545818329
As we’ve seen, we need gradients in order to improve our model using SGD, and in order to calculate gradients we need some loss function that represents how good our model is. That is because the gradients are a measure of how that loss function changes with small tweaks to the weights.
So, we need to choose a loss function. The obvious approach would be to use accuracy, which is our metric, as our loss function as well. In this case, we would calculate our prediction for each image, collect these values to calculate an overall accuracy, and then calculate the gradients of each weight with respect to that overall accuracy.
Unfortunately, we have a significant technical problem here. The gradient of a function is its slope, or its steepness, which can be defined as rise over run—that is, how much the value of the function goes up or down, divided by how much we changed the input. We can write this in mathematically as: (y_new - y_old) / (x_new - x_old)
. This gives us a good approximation of the gradient when x_new
is very similar to x_old
, meaning that their difference is very small. But accuracy only changes at all when a prediction changes from a 3 to a 7, or vice versa. The problem is that a small change in weights from x_old
to x_new
isn’t likely to cause any prediction to change, so (y_new - y_old)
will almost always be 0. In other words, the gradient is 0 almost everywhere.
A very small change in the value of a weight will often not actually change the accuracy at all. This means it is not useful to use accuracy as a loss function—if we do, most of the time our gradients will actually be 0, and the model will not be able to learn from that number.
S: In mathematical terms, accuracy is a function that is constant almost everywhere (except at the threshold, 0.5), so its derivative is nil almost everywhere (and infinity at the threshold). This then gives gradients that are 0 or infinite, which are useless for updating the model.
Instead, we need a loss function which, when our weights result in slightly better predictions, gives us a slightly better loss. So what does a “slightly better prediction” look like, exactly? Well, in this case, it means that if the correct answer is a 3 the score is a little higher, or if the correct answer is a 7 the score is a little lower.
Let’s write such a function now. What form does it take?
The loss function receives not the images themselves, but the predictions from the model. Let’s make one argument, prds
, of values between 0 and 1, where each value is the prediction that an image is a 3. It is a vector (i.e., a rank-1 tensor), indexed over the images.
The purpose of the loss function is to measure the difference between predicted values and the true values — that is, the targets (aka labels). Let’s make another argument, trgts
, with values of 0 or 1 which tells whether an image actually is a 3 or not. It is also a vector (i.e., another rank-1 tensor), indexed over the images.
So, for instance, suppose we had three images which we knew were a 3, a 7, and a 3. And suppose our model predicted with high confidence (0.9
) that the first was a 3, with slight confidence (0.4
) that the second was a 7, and with fair confidence (0.2
), but incorrectly, that the last was a 7. This would mean our loss function would receive these values as its inputs:
In [ ]:
trgts = tensor([1,0,1])
prds = tensor([0.9, 0.4, 0.2])
Here’s a first try at a loss function that measures the distance between predictions
and targets
:
In [ ]:
def mnist_loss(predictions, targets):
return torch.where(targets==1, 1-predictions, predictions).mean()
We’re using a new function, torch.where(a,b,c)
. This is the same as running the list comprehension [b[i] if a[i] else c[i] for i in range(len(a))]
, except it works on tensors, at C/CUDA speed. In plain English, this function will measure how distant each prediction is from 1 if it should be 1, and how distant it is from 0 if it should be 0, and then it will take the mean of all those distances.
note: Read the Docs: It’s important to learn about PyTorch functions like this, because looping over tensors in Python performs at Python speed, not C/CUDA speed! Try running
help(torch.where)
now to read the docs for this function, or, better still, look it up on the PyTorch documentation site.
Let’s try it on our prds
and trgts
:
In [ ]:
torch.where(trgts==1, 1-prds, prds)
Out[ ]:
tensor([0.1000, 0.4000, 0.8000])
You can see that this function returns a lower number when predictions are more accurate, when accurate predictions are more confident (higher absolute values), and when inaccurate predictions are less confident. In PyTorch, we always assume that a lower value of a loss function is better. Since we need a scalar for the final loss, mnist_loss
takes the mean of the previous tensor:
In [ ]:
mnist_loss(prds,trgts)
Out[ ]:
tensor(0.4333)
For instance, if we change our prediction for the one “false” target from 0.2
to 0.8
the loss will go down, indicating that this is a better prediction:
In [ ]:
mnist_loss(tensor([0.9, 0.4, 0.8]),trgts)
Out[ ]:
tensor(0.2333)
One problem with mnist_loss
as currently defined is that it assumes that predictions are always between 0 and 1. We need to ensure, then, that this is actually the case! As it happens, there is a function that does exactly that—let’s take a look.
Sigmoid
The sigmoid
function always outputs a number between 0 and 1. It’s defined as follows:
In [ ]:
def sigmoid(x): return 1/(1+torch.exp(-x))
Pytorch defines an accelerated version for us, so we don’t really need our own. This is an important function in deep learning, since we often want to ensure values are between 0 and 1. This is what it looks like:
In [ ]:
plot_function(torch.sigmoid, title='Sigmoid', min=-4, max=4)
As you can see, it takes any input value, positive or negative, and smooshes it onto an output value between 0 and 1. It’s also a smooth curve that only goes up, which makes it easier for SGD to find meaningful gradients.
Let’s update mnist_loss
to first apply sigmoid
to the inputs:
In [ ]:
def mnist_loss(predictions, targets):
predictions = predictions.sigmoid()
return torch.where(targets==1, 1-predictions, predictions).mean()
Now we can be confident our loss function will work, even if the predictions are not between 0 and 1. All that is required is that a higher prediction corresponds to higher confidence an image is a 3.
Having defined a loss function, now is a good moment to recapitulate why we did this. After all, we already had a metric, which was overall accuracy. So why did we define a loss?
The key difference is that the metric is to drive human understanding and the loss is to drive automated learning. To drive automated learning, the loss must be a function that has a meaningful derivative. It can’t have big flat sections and large jumps, but instead must be reasonably smooth. This is why we designed a loss function that would respond to small changes in confidence level. This requirement means that sometimes it does not really reflect exactly what we are trying to achieve, but is rather a compromise between our real goal, and a function that can be optimized using its gradient. The loss function is calculated for each item in our dataset, and then at the end of an epoch the loss values are all averaged and the overall mean is reported for the epoch.
Metrics, on the other hand, are the numbers that we really care about. These are the values that are printed at the end of each epoch that tell us how our model is really doing. It is important that we learn to focus on these metrics, rather than the loss, when judging the performance of a model.
SGD and Mini-Batches
Now that we have a loss function that is suitable for driving SGD, we can consider some of the details involved in the next phase of the learning process, which is to change or update the weights based on the gradients. This is called an optimization step.
In order to take an optimization step we need to calculate the loss over one or more data items. How many should we use? We could calculate it for the whole dataset, and take the average, or we could calculate it for a single data item. But neither of these is ideal. Calculating it for the whole dataset would take a very long time. Calculating it for a single item would not use much information, so it would result in a very imprecise and unstable gradient. That is, you’d be going to the trouble of updating the weights, but taking into account only how that would improve the model’s performance on that single item.
So instead we take a compromise between the two: we calculate the average loss for a few data items at a time. This is called a mini-batch. The number of data items in the mini-batch is called the batch size. A larger batch size means that you will get a more accurate and stable estimate of your dataset’s gradients from the loss function, but it will take longer, and you will process fewer mini-batches per epoch. Choosing a good batch size is one of the decisions you need to make as a deep learning practitioner to train your model quickly and accurately. We will talk about how to make this choice throughout this book.
Another good reason for using mini-batches rather than calculating the gradient on individual data items is that, in practice, we nearly always do our training on an accelerator such as a GPU. These accelerators only perform well if they have lots of work to do at a time, so it’s helpful if we can give them lots of data items to work on. Using mini-batches is one of the best ways to do this. However, if you give them too much data to work on at once, they run out of memory—making GPUs happy is also tricky!
As we saw in our discussion of data augmentation in <>, we get better generalization if we can vary things during training. One simple and effective thing we can vary is what data items we put in each mini-batch. Rather than simply enumerating our dataset in order for every epoch, instead what we normally do is randomly shuffle it on every epoch, before we create mini-batches. PyTorch and fastai provide a class that will do the shuffling and mini-batch collation for you, called DataLoader
.
A DataLoader
can take any Python collection and turn it into an iterator over many batches, like so:
In [ ]:
coll = range(15)
dl = DataLoader(coll, batch_size=5, shuffle=True)
list(dl)
Out[ ]:
[tensor([ 3, 12, 8, 10, 2]),
tensor([ 9, 4, 7, 14, 5]),
tensor([ 1, 13, 0, 6, 11])]
For training a model, we don’t just want any Python collection, but a collection containing independent and dependent variables (that is, the inputs and targets of the model). A collection that contains tuples of independent and dependent variables is known in PyTorch as a Dataset
. Here’s an example of an extremely simple Dataset
:
In [ ]:
ds = L(enumerate(string.ascii_lowercase))
ds
Out[ ]:
(#26) [(0, 'a'),(1, 'b'),(2, 'c'),(3, 'd'),(4, 'e'),(5, 'f'),(6, 'g'),(7, 'h'),(8, 'i'),(9, 'j')...]
When we pass a Dataset
to a DataLoader
we will get back many batches which are themselves tuples of tensors representing batches of independent and dependent variables:
In [ ]:
dl = DataLoader(ds, batch_size=6, shuffle=True)
list(dl)
Out[ ]:
[(tensor([17, 18, 10, 22, 8, 14]), ('r', 's', 'k', 'w', 'i', 'o')),
(tensor([20, 15, 9, 13, 21, 12]), ('u', 'p', 'j', 'n', 'v', 'm')),
(tensor([ 7, 25, 6, 5, 11, 23]), ('h', 'z', 'g', 'f', 'l', 'x')),
(tensor([ 1, 3, 0, 24, 19, 16]), ('b', 'd', 'a', 'y', 't', 'q')),
(tensor([2, 4]), ('c', 'e'))]
We are now ready to write our first training loop for a model using SGD!