Sign In

Image Classification using Logistic Regression in PyTorch

Part 3 of "PyTorch: Zero to GANs"

This post is the third in a series of tutorials on building deep learning models with PyTorch, an open source neural networks library. Check out the full series:

  1. PyTorch Basics: Tensors & Gradients
  2. Linear Regression & Gradient Descent
  3. Image Classfication using Logistic Regression
  4. Training Deep Neural Networks on a GPU
  5. Coming soon.. (CNNs, RNNs, GANs etc.)

In this tutorial, we'll use our existing knowledge of PyTorch and linear regression to solve a very different kind of problem: image classification. We'll use the famous MNIST Handwritten Digits Database as our training dataset. It consists of 28px by 28px grayscale images of handwritten digits (0 to 9), along with labels for each image indicating which digit it represents. Here are some sample images from the dataset:


System setup

If you want to follow along and run the code as you read, you can clone this notebook, install the required dependencies using conda, and start Jupyter by running the following commands on the terminal:

pip install jovian --upgrade    # Install the jovian library 
jovian clone <notebook_id>      # Download notebook & dependencies
cd 03-logistic-regression       # Enter the created directory 
conda env update                # Install the dependencies
conda activate 03-logistic-regression # Activate virtual env
jupyter notebook                # Start Jupyter

You can find the notebook_id by cliking the Clone button at the top of this page on Jovian. On older versions of conda, you might need to run source activate 03-logistic-regression to activate the environment. For a more detailed explanation of the above steps, check out the System setup section in the first notebook.

Exploring the Data

We begin by importing torch and torchvision. torchvision contains some utilities for working with image data. It also contains helper classes to automatically download and import popular datasets like MNIST.

In [1]:
# Imports
import torch
import torchvision
from torchvision.datasets import MNIST
In [2]:
# Download training dataset
dataset = MNIST(root='data/', download=True)
Downloading to data/MNIST/raw/train-images-idx3-ubyte.gz
Extracting data/MNIST/raw/train-images-idx3-ubyte.gz
Downloading to data/MNIST/raw/train-labels-idx1-ubyte.gz Extracting data/MNIST/raw/train-labels-idx1-ubyte.gz Downloading to data/MNIST/raw/t10k-images-idx3-ubyte.gz
Extracting data/MNIST/raw/t10k-images-idx3-ubyte.gz
Downloading to data/MNIST/raw/t10k-labels-idx1-ubyte.gz Extracting data/MNIST/raw/t10k-labels-idx1-ubyte.gz Processing... Done!

When this statement is executed for the first time, it downloads the data to the data/ directory next to the notebook and creates a PyTorch Dataset. On subsequent executions, the download is skipped as the data is already downloaded. Let's check the size of the dataset.

In [3]:

The dataset has 60,000 images which can be used to train the model. There is also an additonal test set of 10,000 images which can be created by passing train=False to the MNIST class.

In [4]:
test_dataset = MNIST(root='data/', train=False)

Let's look at a sample element from the training dataset.

In [5]:
(<PIL.Image.Image image mode=L size=28x28 at 0x7F8E661EF048>, 5)

It's a pair, consisting of a 28x28 image and a label. The image is an object of the class PIL.Image.Image, which is a part of the Python imaging library Pillow. We can view the image within Jupyter using matplotlib, the de-facto plotting and graphing library for data science in Python.

In [6]:
import matplotlib.pyplot as plt
%matplotlib inline

Along with importing matplotlib, a special statement %matplotlib inline is added to indicate to Jupyter that we want to plot the graphs within the notebook. Without this line, Jupyter will show the image in a popup. Statements starting with % are called IPython magic commands, and are used to configure the behavior of Jupyter itself. You can find a full list of magic commands here: .

Let's look at a couple of images from the dataset.

In [7]:
image, label = dataset[0]
plt.imshow(image, cmap='gray')
print('Label:', label)
Label: 5
Notebook Image
In [8]:
image, label = dataset[10]
plt.imshow(image, cmap='gray')
print('Label:', label)
Label: 3
Notebook Image

It's evident that these images are quite small in size, and recognizing the digits can sometimes be hard even for the human eye. While it's useful to look at these images, there's just one problem here: PyTorch doesn't know how to work with images. We need to convert the images into tensors. We can do this by specifying a transform while creating our dataset.

In [9]:
import torchvision.transforms as transforms

PyTorch datasets allow us to specify one or more transformation functions which are applied to the images as they are loaded. torchvision.transforms contains many such predefined functions, and we'll use the ToTensor transform to convert images into PyTorch tensors.

In [10]:
# MNIST dataset (images and labels)
dataset = MNIST(root='data/', 
In [11]:
img_tensor, label = dataset[0]
print(img_tensor.shape, label)
torch.Size([1, 28, 28]) 5

The image is now converted to a 1x28x28 tensor. The first dimension is used to keep track of the color channels. Since images in the MNIST dataset are grayscale, there's just one channel. Other datasets have images with color, in which case there are 3 channels: red, green and blue (RGB). Let's look at some sample values inside the tensor:

In [12]:
print(torch.max(img_tensor), torch.min(img_tensor))
tensor([[[0.0039, 0.6039, 0.9922, 0.3529, 0.0000], [0.0000, 0.5451, 0.9922, 0.7451, 0.0078], [0.0000, 0.0431, 0.7451, 0.9922, 0.2745], [0.0000, 0.0000, 0.1373, 0.9451, 0.8824], [0.0000, 0.0000, 0.0000, 0.3176, 0.9412]]]) tensor(1.) tensor(0.)

The values range from 0 to 1, with 0 representing black, 1 white and the values in between different shades of grey. We can also plot the tensor as an image using plt.imshow.

In [13]:
# Plot the image by passing in the 28x28 matrix
plt.imshow(img_tensor[0,10:15,10:15], cmap='gray');
Notebook Image

Note that we need to pass just the 28x28 matrix to plt.imshow, without a channel dimension. We also pass a color map (cmap=gray) to indicate that we want to see a grayscale image.

Training and Validation Datasets

While building real world machine learning models, it is quite common to split the dataset into 3 parts:

  1. Training set - used to train the model i.e. compute the loss and adjust the weights of the model using gradient descent.
  2. Validation set - used to evaluate the model while training, adjust hyperparameters (learning rate etc.) and pick the best version of the model.
  3. Test set - used to compare different models, or different types of modeling approaches, and report the final accuracy of the model.

In the MNIST dataset, there are 60,000 training images, and 10,000 test images. The test set is standardized so that different researchers can report the results of their models against the same set of images. Since there's no predefined validation set, we must manually split the 60,000 images into training and validation datasets.

Let's define a function that randomly picks a given fraction of the images for the validation set.

In [14]:
import numpy as np

def split_indices(n, val_pct):
    # Determine size of validation set
    n_val = int(val_pct*n)
    # Create random permutation of 0 to n-1
    idxs = np.random.permutation(n)
    # Pick first n_val indices for validation set
    return idxs[n_val:], idxs[:n_val]

split_indcies randomly shuffles the array indices 0,1,..n-1, and separates out a desired portion from it for the validation set. It's important to shuffle the indices before creating a validation set, because the training images are often ordered by the target labels i.e. images of 0s, followed by images of 1s, followed by images of 2s and so on. If we were to pick a 20% validation set simply by selecting the last 20% of the images, the validation set would only consist of images of 8s and 9s, whereas the training set would contain no images of 8s and 9s. This would make it impossible to train a good model using the training set, which also performs well on the validation set (and on real world data).

In [15]:
train_indices, val_indices = split_indices(len(dataset), val_pct=0.2)
In [16]:
print(len(train_indices), len(val_indices))
print('Sample val indices: ', val_indices[:20])
48000 12000 Sample val indices: [57166 6620 32620 32886 58697 691 39231 40799 49193 17394 56391 13037 10101 16686 45009 519 17812 46943 20138 43140]

We have randomly shuffled the indices, and selected a small portion ( 20% ) to serve as the validation set. We can now create Pytorch data loaders for each of these using a SubsetRandomSampler, which samples elements randomly from a given list of indices, while greating batches of data.

In [17]:
from import SubsetRandomSampler
from import DataLoader
In [18]:

# Training sampler and data loader
train_sampler = SubsetRandomSampler(train_indices)
train_loader = DataLoader(dataset, 

# Validation sampler and data loader
val_sampler = SubsetRandomSampler(val_indices)
val_loader = DataLoader(dataset,


Now that we have prepared our data loaders, we can define our model.

  • A logistic regression model is almost identical to a linear regression model i.e. there are weights and bias matrices, and the output is obtained using simple matrix operations (pred = x @ w.t() + b).

  • Just as we did with linear regression, we can use nn.Linear to create the model instead of defining and initializing the matrices manually.

  • Since nn.Linear expects the each training example to be a vector, each 1x28x28 image tensor needs to be flattened out into a vector of size 784 (28*28), before being passed into the model.

  • The output for each image is vector of size 10, with each element of the vector signifying the probability a particular target label (i.e. 0 to 9). The predicted label for an image is simply the one with the highest probability.

In [19]:
import torch.nn as nn

input_size = 28*28
num_classes = 10

# Logistic regression model
model = nn.Linear(input_size, num_classes)

Of course, this model is a lot larger than our previous model, in terms of the number of parameters. Let's take a look at the weights and biases.

In [20]:
torch.Size([10, 784])
Parameter containing:
tensor([[ 0.0168, -0.0332,  0.0114,  ...,  0.0211, -0.0033,  0.0348],
        [ 0.0008,  0.0039, -0.0327,  ..., -0.0054,  0.0087, -0.0142],
        [ 0.0220, -0.0065,  0.0350,  ..., -0.0095,  0.0061, -0.0044],
        [-0.0085, -0.0072, -0.0329,  ..., -0.0093, -0.0248,  0.0158],
        [ 0.0014, -0.0107, -0.0110,  ...,  0.0212,  0.0287,  0.0235],
        [ 0.0192, -0.0068,  0.0099,  ..., -0.0259,  0.0114, -0.0192]],
In [21]:
Parameter containing:
tensor([ 0.0051,  0.0250, -0.0061, -0.0252,  0.0003, -0.0243, -0.0064,  0.0113,
         0.0119, -0.0329], requires_grad=True)

Although there are a total of 7850 parameters here, conceptually nothing has changed so far. Let's try and generate some outputs using our model. We'll take the first batch of 100 images from our dataset, and pass them into our model.

In [22]:
for images, labels in train_loader:
    outputs = model(images)
tensor([6, 3, 3, 1, 7, 5, 9, 9, 1, 5, 9, 6, 6, 9, 0, 8, 5, 7, 1, 4, 4, 8, 4, 6, 8, 1, 8, 6, 0, 3, 1, 6, 3, 1, 1, 7, 0, 8, 9, 4, 6, 5, 5, 3, 1, 4, 9, 7, 8, 5, 5, 7, 6, 8, 8, 7, 4, 5, 1, 6, 4, 9, 5, 6, 4, 9, 3, 8, 4, 6, 6, 0, 9, 2, 1, 3, 8, 0, 1, 6, 9, 1, 8, 7, 3, 2, 1, 1, 8, 7, 0, 9, 1, 0, 1, 7, 1, 7, 4, 9]) torch.Size([100, 1, 28, 28])
--------------------------------------------------------------------------- RuntimeError Traceback (most recent call last) <ipython-input-22-72eddc737460> in <module> 2 print(labels) 3 print(images.shape) ----> 4 outputs = model(images) 5 break /srv/conda/envs/notebook/lib/python3.7/site-packages/torch/nn/modules/ in __call__(self, *input, **kwargs) 487 result = self._slow_forward(*input, **kwargs) 488 else: --> 489 result = self.forward(*input, **kwargs) 490 for hook in self._forward_hooks.values(): 491 hook_result = hook(self, input, result) /srv/conda/envs/notebook/lib/python3.7/site-packages/torch/nn/modules/ in forward(self, input) 65 @weak_script_method 66 def forward(self, input): ---> 67 return F.linear(input, self.weight, self.bias) 68 69 def extra_repr(self): /srv/conda/envs/notebook/lib/python3.7/site-packages/torch/nn/ in linear(input, weight, bias) 1352 ret = torch.addmm(torch.jit._unwrap_optional(bias), input, weight.t()) 1353 else: -> 1354 output = input.matmul(weight.t()) 1355 if bias is not None: 1356 output += torch.jit._unwrap_optional(bias) RuntimeError: size mismatch, m1: [2800 x 28], m2: [784 x 10] at /pytorch/aten/src/TH/generic/THTensorMath.cpp:940

This leads to an error, because our input data does not have the right shape. Our images are of the shape 1x28x28, but we need them to be vectors of size 784 i.e. we need to flatten them out. We'll use the .reshape method of a tensor, which will allow us to efficiently 'view' each image as a flat vector, without really chaging the underlying data.

To include this additional functionality within our model, we need to define a custom model, by extending the nn.Module class from PyTorch.

In [23]:
class MnistModel(nn.Module):
    def __init__(self):
        self.linear = nn.Linear(input_size, num_classes)
    def forward(self, xb):
        xb = xb.reshape(-1, 784)
        out = self.linear(xb)
        return out
model = MnistModel()

Inside the __init__ constructor method, we instantiate the weights and biases using nn.Linear. And inside the forward method, which is invoked when we pass a batch of inputs to the model, we flatten out the input tensor, and then pass it into self.linear.

xb.reshape(-1, 28*28) indicates to PyTorch that we want a view of the xb tensor with two dimensions, where the length along the 2nd dimension is 28*28 (i.e. 784). One argument to .reshape can be set to -1 (in this case the first dimension), to let PyTorch figure it out automatically based on the shape of the original tensor.

Note that the model no longer has .weight and .bias attributes (as they are now inside the .linear attribute), but it does have a .parameters method which returns a list containing the weights and bias, and can be used by a PyTorch optimizer.

In [24]:
print(model.linear.weight.shape, model.linear.bias.shape)
torch.Size([10, 784]) torch.Size([10])
[Parameter containing:
 tensor([[ 0.0191,  0.0040, -0.0201,  ..., -0.0164,  0.0095, -0.0174],
         [ 0.0093,  0.0209, -0.0279,  ..., -0.0348, -0.0161, -0.0229],
         [-0.0046, -0.0038,  0.0184,  ...,  0.0210, -0.0146,  0.0139],
         [ 0.0214, -0.0167, -0.0267,  ...,  0.0165,  0.0091,  0.0211],
         [-0.0230,  0.0124, -0.0350,  ..., -0.0157,  0.0114,  0.0342],
         [-0.0118, -0.0057,  0.0256,  ...,  0.0004,  0.0015,  0.0162]],
        requires_grad=True), Parameter containing:
 tensor([-0.0134,  0.0238,  0.0345,  0.0296,  0.0120,  0.0299, -0.0086, -0.0075,
         -0.0103, -0.0252], requires_grad=True)]

Our new custom model can be used in the exact same way as before. Let's see if it works.

In [25]:
for images, labels in train_loader:
    outputs = model(images)

print('outputs.shape : ', outputs.shape)
print('Sample outputs :\n', outputs[:2].data)
outputs.shape : torch.Size([100, 10]) Sample outputs : tensor([[ 2.0823e-01, 4.5437e-02, -6.4977e-05, 3.5972e-01, 3.0512e-01, -1.7976e-01, -2.6256e-01, -5.3645e-02, 7.5483e-02, 2.7295e-02], [ 9.4628e-02, -1.1487e-01, 1.8712e-01, -7.1328e-02, -3.4960e-01, 2.0171e-01, 8.8994e-02, -1.2069e-02, -4.1937e-02, 3.1905e-02]])

For each of the 100 input images, we get 10 outputs, one for each class. As discussed earlier, we'd like these outputs to represent probabilities, but for that the elements of each output row must lie between 0 to 1 and add up to 1, which is clearly not the case here.

To convert the output rows into probabilities, we use the softmax function, which has the following formula:


First we replace each element yi in an output row by e^yi, which makes all the elements positive, and then we divide each element by the sum of all elements to ensure that they add up to 1.

While it's easy to implement the softmax function (you should try it!), we'll use the implementation that's provided within PyTorch, because it works well with multidimensional tensors (a list of output rows in our case).

In [26]:
import torch.nn.functional as F

The softmax function is included in the torch.nn.functional package, and requires us to specify a dimension along which the softmax must be applied.

In [27]:
# Apply softmax for each output row
probs = F.softmax(outputs, dim=1)

# Look at sample probabilities
print("Sample probabilities:\n", probs[:2].data)

# Add up the probabilities of an output row
print("Sum: ", torch.sum(probs[0]).item())
Sample probabilities: tensor([[0.1148, 0.0976, 0.0932, 0.1336, 0.1265, 0.0779, 0.0717, 0.0884, 0.1005, 0.0958], [0.1085, 0.0880, 0.1190, 0.0919, 0.0696, 0.1208, 0.1079, 0.0975, 0.0947, 0.1019]]) Sum: 1.0

Finally, we can determine the predicted label for each image by simply choosing the index of the element with the highest probability in each output row. This is done using torch.max, which returns the largest element and the index of the largest element along a particular dimension of a tensor.

In [28]:
max_probs, preds = torch.max(probs, dim=1)
tensor([3, 5, 9, 2, 9, 0, 5, 2, 5, 8, 0, 0, 4, 4, 3, 2, 9, 9, 9, 0, 0, 6, 9, 0, 9, 0, 0, 3, 0, 0, 0, 6, 6, 6, 0, 8, 9, 3, 1, 0, 0, 6, 4, 9, 9, 0, 3, 9, 0, 2, 0, 2, 2, 9, 8, 4, 8, 0, 4, 0, 4, 0, 6, 9, 9, 8, 9, 8, 9, 8, 6, 6, 7, 8, 0, 8, 0, 4, 7, 9, 9, 0, 0, 0, 0, 3, 0, 9, 7, 9, 9, 9, 6, 0, 0, 2, 9, 0, 8, 9]) tensor([0.1336, 0.1208, 0.1218, 0.1199, 0.1358, 0.1300, 0.1309, 0.1331, 0.1261, 0.1313, 0.1537, 0.1166, 0.1206, 0.1281, 0.1289, 0.1458, 0.1876, 0.1255, 0.1373, 0.1248, 0.1447, 0.1170, 0.1384, 0.1368, 0.1331, 0.1240, 0.1551, 0.1149, 0.1333, 0.1197, 0.1193, 0.1507, 0.1320, 0.1451, 0.1195, 0.1265, 0.1292, 0.1144, 0.1286, 0.1293, 0.1554, 0.1161, 0.1273, 0.1269, 0.1197, 0.1229, 0.1207, 0.1149, 0.1222, 0.1179, 0.1407, 0.1282, 0.1130, 0.1354, 0.1314, 0.1340, 0.1486, 0.1510, 0.1188, 0.1498, 0.1220, 0.1360, 0.1313, 0.1414, 0.1483, 0.1314, 0.1281, 0.1382, 0.1430, 0.1188, 0.1474, 0.1498, 0.1178, 0.1271, 0.1397, 0.1433, 0.1154, 0.1339, 0.1253, 0.1263, 0.1299, 0.1413, 0.1392, 0.1317, 0.1321, 0.1250, 0.1351, 0.1244, 0.1348, 0.1224, 0.1320, 0.1303, 0.1283, 0.1293, 0.1207, 0.1384, 0.1397, 0.1814, 0.1189, 0.1381], grad_fn=<MaxBackward0>)

The numbers printed above are the predicted labels for the first batch of training images. Let's compare them with the actual labels.

In [29]:
tensor([0, 2, 3, 4, 3, 4, 4, 1, 2, 3, 5, 1, 0, 0, 6, 7, 0, 3, 5, 1, 6, 9, 8, 0,
        0, 1, 8, 7, 4, 4, 2, 3, 7, 2, 9, 6, 6, 1, 0, 0, 8, 6, 5, 8, 8, 5, 5, 1,
        4, 2, 7, 7, 1, 2, 8, 0, 7, 8, 1, 9, 5, 9, 2, 2, 2, 8, 6, 7, 0, 3, 8, 0,
        2, 4, 9, 8, 9, 5, 4, 7, 6, 7, 9, 0, 6, 4, 7, 6, 2, 4, 4, 7, 0, 9, 7, 1,
        8, 0, 5, 3])

Clearly, the predicted and the actual labels are completely different. Obviously, that's because we have started with randomly initialized weights and biases. We need to train the model i.e. adjust the weights using gradient descent to make better predictions.

Evaluation Metric and Loss Function

Just as with linear regression, we need a way to evaluate how well our model is performing. A natural way to do this would be to find the percentage of labels that were predicted correctly i.e. the accuracy of the predictions.

In [30]:
def accuracy(l1, l2):
    return torch.sum(l1 == l2).item() / len(l1)

The == performs an element-wise comparison of two tensors with the same shape, and returns a tensor of the same shape, containing 0s for unequal elements, and 1s for equal elements. Passing the result to torch.sum returns the number of labels that were predicted correctly. Finally, we divide by the total number of images to get the accuracy.

Let's calculate the accuracy of the current model, on the first batch of data. Obviously, we expect it to be pretty bad.

In [31]:
accuracy(preds, labels)

While the accuracy is a great way for us (humans) to evaluate the model, it can't be used as a loss function for optimizing our model using gradient descent, for the following reasons:

  1. It's not a differentiable function. torch.max and == are both non-continuous and non-differentiable operations, so we can't use the accuracy for computing gradients w.r.t the weights and biases.

  2. It doesn't take into account the actual probabilities predicted by the model, so it can't provide sufficient feedback for incremental improvements.

Due to these reasons, accuracy is a great evaluation metric for classification, but not a good loss function. A commonly used loss function for classification problems is the cross entropy, which has the following formula:


While it looks complicated, it's actually quite simple:

  • For each output row, pick the predicted probability for the correct label. E.g. if the predicted probabilities for an image are [0.1, 0.3, 0.2, ...] and the correct label is 1, we pick the corresponding element 0.3 and ignore the rest.

  • Then, take the logarithm of the picked probability. If the probability is high i.e. close to 1, then its logarithm is a very small negative value, close to 0. And if the probability is low (close to 0), then the logarithm is a very large negative value. We also multiply the result by -1, which results is a large postive value of the loss for poor predictions.

  • Finally, take the average of the cross entropy across all the output rows to get the overall loss for a batch of data.

Unlike accuracy, cross-entropy is a continuous and differentiable function that also provides good feedback for incremental improvements in the model (a slightly higher probability for the correct label leads to a lower loss). This makes it a good choice for the loss function.

As you might expect, PyTorch provides an efficient and tensor-friendly implementation of cross entropy as part of the torch.nn.functional package. Moreover, it also performs softmax internally, so we can directly pass in the outputs of the model without converting them into probabilities.

In [32]:
loss_fn = F.cross_entropy
In [33]:
# Loss for current batch of data
loss = loss_fn(outputs, labels)
tensor(2.3292, grad_fn=<NllLossBackward>)
In [34]:
import jovian
[jovian] Saving notebook..
[jovian] Creating a new notebook on [jovian] Please enter your API key ( from ): API Key:········ [jovian] Uploading notebook.. [jovian] Capturing environment.. [jovian] Committed successfully!

Since the cross entropy is the negative logarithm of the predicted probability of the correct label averaged over all training samples, one way to interpret the resulting number e.g. 2.23 is look at e^-2.23 which is around 0.1 as the predicted probability of the correct label, on average. Lower the loss, better the model.


We are going to use the optim.SGD optimizer to update the weights and biases during training, but with a higher learning rate of 1e-3.

In [35]:
learning_rate = 0.001
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)

Parameters like batch size, learning rate etc. need to picked in advance while training machine learing models, and are called hyperparameters. Picking the right hyperparameters is critical for training an accurate model within a reasonable amount of time, and is an active area of research and experimentation. Feel free to try different learning rates and see how it affects the training process.

Training the model

Now that we have defined the data loaders, model, loss function and optimizer, we are ready to train the model. The training process is almost identical to linear regression. However, we'll augment the fit function we defined earlier to evaluate the model's accuracy and loss using the validation set at the end of every epoch.

We begin by defining a function loss_batch which:

  • calculates the loss for a batch of data
  • optionally perform the gradient descent update step if an optimizer is provided
  • optionally computes a metric (e.g. accuracy) using the predictions and actual targets
In [36]:
def loss_batch(model, loss_func, xb, yb, opt=None, metric=None):
    # Calculate loss
    preds = model(xb)
    loss = loss_func(preds, yb)
    if opt is not None:
        # Compute gradients
        # Update parameters             
        # Reset gradients
    metric_result = None
    if metric is not None:
        # Compute the metric
        metric_result = metric(preds, yb)
    return loss.item(), len(xb), metric_result

The optimizer is an optional argument, to ensure that we can reuse loss_batch for computing the loss on the validation set. We also return the length of the batch as part of the result, as it'll be useful while combining the losses/metrics for the entire dataset.

Next we define an evaluate function, which calculates the overall loss (and a metric, if provided) for the validation set.

In [37]:
def evaluate(model, loss_fn, valid_dl, metric=None):
    with torch.no_grad():
        # Pass each batch through the model
        results = [loss_batch(model, loss_fn, xb, yb, metric=metric)
                   for xb,yb in valid_dl]
        # Separate losses, counts and metrics
        losses, nums, metrics = zip(*results)
        # Total size of the dataset
        total = np.sum(nums)
        # Avg. loss across batches 
        avg_loss = np.sum(np.multiply(losses, nums)) / total
        avg_metric = None
        if metric is not None:
            # Avg. of metric across batches
            avg_metric = np.sum(np.multiply(metrics, nums)) / total
    return avg_loss, total, avg_metric

If it's not immediately clear what this function does, try executing each statement in a separate cell, and look the results. We also need to redefine the accuracy to operate on an entire batch of outputs directly, so that we can use it as a metric in fit.

In [38]:
def accuracy(outputs, labels):
    _, preds = torch.max(outputs, dim=1)
    return torch.sum(preds == labels).item() / len(preds)

Note that we don't need to apply softmax to the outputs, since it doesn't change the relative order of the results. This is because e^x is an increasing function i.e. if y1 > y2, then e^y1 > e^y2 and the same holds true after averaging out the values to get the softmax.

Let's see how the model performs on the validation set with the initial set of weights and biases.

In [39]:
val_loss, total, val_acc = evaluate(model, loss_fn, val_loader, metric=accuracy)
print('Loss: {:.4f}, Accuracy: {:.4f}'.format(val_loss, val_acc))
Loss: 2.3209, Accuracy: 0.0898

The initial accuracy is below 10%, which is what one might expect from a randomly intialized model (since it has a 1 in 10 chance of getting a label right by guessing randomly). Also note that we are using the .format method with the message string to print only the first four digits after the decimal point.

We can now define the fit function quite easily using loss_batch and evaluate.

In [40]:
def fit(epochs, model, loss_fn, opt, train_dl, valid_dl, metric=None):
    for epoch in range(epochs):
        # Training
        for xb,yb in train_dl:
            loss,_,_ = loss_batch(model, loss_fn, xb, yb, opt)

        # Evaluation
        result = evaluate(model, loss_fn, valid_dl, metric)
        val_loss, total, val_metric = result
        # Print progress
        if metric is None:
            print('Epoch [{}/{}], Loss: {:.4f}'
                  .format(epoch+1, epochs, val_loss))
            print('Epoch [{}/{}], Loss: {:.4f}, {}: {:.4f}'
                  .format(epoch+1, epochs, val_loss, metric.__name__, val_metric))

We are now ready to train the model. Let's train for 5 epochs and look at the results.

In [41]:
# Redifine model and optimizer
model = MnistModel()
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)
In [42]:
fit(5, model, F.cross_entropy, optimizer, train_loader, val_loader, accuracy)
--------------------------------------------------------------------------- KeyboardInterrupt Traceback (most recent call last) <ipython-input-42-ebb58e085aca> in <module> ----> 1 fit(5, model, F.cross_entropy, optimizer, train_loader, val_loader, accuracy) <ipython-input-40-ae1b02dd97ce> in fit(epochs, model, loss_fn, opt, train_dl, valid_dl, metric) 2 for epoch in range(epochs): 3 # Training ----> 4 for xb,yb in train_dl: 5 loss,_,_ = loss_batch(model, loss_fn, xb, yb, opt) 6 /srv/conda/envs/notebook/lib/python3.7/site-packages/torch/utils/data/ in __next__(self) 613 if self.num_workers == 0: # same-process loading 614 indices = next(self.sample_iter) # may raise StopIteration --> 615 batch = self.collate_fn([self.dataset[i] for i in indices]) 616 if self.pin_memory: 617 batch = pin_memory_batch(batch) /srv/conda/envs/notebook/lib/python3.7/site-packages/torch/utils/data/ in <listcomp>(.0) 613 if self.num_workers == 0: # same-process loading 614 indices = next(self.sample_iter) # may raise StopIteration --> 615 batch = self.collate_fn([self.dataset[i] for i in indices]) 616 if self.pin_memory: 617 batch = pin_memory_batch(batch) /srv/conda/envs/notebook/lib/python3.7/site-packages/torchvision/datasets/ in __getitem__(self, index) 93 94 if self.transform is not None: ---> 95 img = self.transform(img) 96 97 if self.target_transform is not None: /srv/conda/envs/notebook/lib/python3.7/site-packages/torchvision/transforms/ in __call__(self, pic) 89 Tensor: Converted image. 90 """ ---> 91 return F.to_tensor(pic) 92 93 def __repr__(self): /srv/conda/envs/notebook/lib/python3.7/site-packages/torchvision/transforms/ in to_tensor(pic) 88 # put it from HWC to CHW format 89 # yikes, this transpose takes 80% of the loading time/CPU ---> 90 img = img.transpose(0, 1).transpose(0, 2).contiguous() 91 if isinstance(img, torch.ByteTensor): 92 return img.float().div(255) KeyboardInterrupt:

That's a great result! With just 5 epochs of training, our model has reached an accuracy of over 80% on the validation set. Let's see if we can improve that by training for a few more epochs.

In [44]:
fit(5, model, F.cross_entropy, optimizer, train_loader, val_loader, accuracy)
Epoch [1/5], Loss: 1.0223, accuracy: 0.8179 Epoch [2/5], Loss: 0.9545, accuracy: 0.8230 Epoch [3/5], Loss: 0.8999, accuracy: 0.8285 Epoch [4/5], Loss: 0.8550, accuracy: 0.8331 Epoch [5/5], Loss: 0.8173, accuracy: 0.8370
In [45]:
fit(5, model, F.cross_entropy, optimizer, train_loader, val_loader, accuracy)
Epoch [1/5], Loss: 0.7853, accuracy: 0.8406 Epoch [2/5], Loss: 0.7576, accuracy: 0.8442 Epoch [3/5], Loss: 0.7335, accuracy: 0.8472 Epoch [4/5], Loss: 0.7122, accuracy: 0.8498 Epoch [5/5], Loss: 0.6933, accuracy: 0.8516
In [46]:
fit(5, model, F.cross_entropy, optimizer, train_loader, val_loader, accuracy)
Epoch [1/5], Loss: 0.6764, accuracy: 0.8540 Epoch [2/5], Loss: 0.6612, accuracy: 0.8560 Epoch [3/5], Loss: 0.6474, accuracy: 0.8582 Epoch [4/5], Loss: 0.6348, accuracy: 0.8590 Epoch [5/5], Loss: 0.6232, accuracy: 0.8595

While the accuracy does continue to increase as we train for more epochs, the improvements get smaller with every epoch. This is easier to see using a line graph.

In [47]:
# Replace these values with your results
accuracies = [0.1076, 0.6282, 0.7329, 0.7675, 0.7879, 0.8003,
              0.8095, 0.8163, 0.8223, 0.8273, 0.8311, 
              0.8340, 0.8367, 0.8398, 0.8424, 0.8450,
              0.8465, 0.8484, 0.8498, 0.8514, 0.8530]
plt.plot(accuracies, '-x')
plt.title('Accuracy vs. No. of epochs');
Notebook Image

It's quite clear from the above picture that the model probably won't cross the accuracy threshold of 90% even after training for a very long time. One possible reason for this is that the learning rate might be too high. It's possible that the model's paramaters are "bouncing" around the optimal set of parameters that have the lowest loss. You can try reducing the learning rate and training for a few more epochs to see if it helps.

The more likely reason that the model just isn't powerful enough. If you remember our initial hypothesis, we have assumed that the output (in this case the class probabilities) is a linear function of the input (pixel intensities), obtained by perfoming a matrix multiplication with the weights matrix and adding the bias. This is a fairly weak assumption, as there may not actually exist a linear relationship between the pixel intensities in an image and the digit it represents. While it works reasonably well for a simple dataset like MNIST (getting us to 85% accuracy), we need more sophisticated models that can capture non-linear relationships between image pixels and labels for complex tasks like recognizing everyday objects, animals etc.

Testing with individual images

While we have been tracking the overall accuracy of a model so far, it's also a good idea to look at model's results on some sample images. Let's test out our model with some images from the predefined test dataset of 10000 images. We begin by recreating the test dataset with the ToTensor transform.

In [48]:
!pip install jovian --upgrade
Requirement already up-to-date: jovian in /usr/local/anaconda3/envs/03-logistic-regression/lib/python3.7/site-packages (0.1.88) Requirement already satisfied, skipping upgrade: pyyaml in /usr/local/anaconda3/envs/03-logistic-regression/lib/python3.7/site-packages (from jovian) (5.1.2) Requirement already satisfied, skipping upgrade: uuid in /usr/local/anaconda3/envs/03-logistic-regression/lib/python3.7/site-packages (from jovian) (1.30) Requirement already satisfied, skipping upgrade: requests in /usr/local/anaconda3/envs/03-logistic-regression/lib/python3.7/site-packages (from jovian) (2.21.0) Requirement already satisfied, skipping upgrade: chardet<3.1.0,>=3.0.2 in /usr/local/anaconda3/envs/03-logistic-regression/lib/python3.7/site-packages (from requests->jovian) (3.0.4) Requirement already satisfied, skipping upgrade: idna<2.9,>=2.5 in /usr/local/anaconda3/envs/03-logistic-regression/lib/python3.7/site-packages (from requests->jovian) (2.8) Requirement already satisfied, skipping upgrade: urllib3<1.25,>=1.21.1 in /usr/local/anaconda3/envs/03-logistic-regression/lib/python3.7/site-packages (from requests->jovian) (1.24.1) Requirement already satisfied, skipping upgrade: certifi>=2017.4.17 in /usr/local/anaconda3/envs/03-logistic-regression/lib/python3.7/site-packages (from requests->jovian) (2019.3.9)
In [49]:
import jovian

    'opt': 'SGD',
    'lr': 0.001,
    'batch_size': 100,
    'arch': 'logistic-regression'
[jovian] Hyperparameters logged.
In [50]:
    'val_loss': 1.1057,
    'val_acc': 0.8038
[jovian] Metrics logged.
In [51]:
# Define test dataset
test_dataset = MNIST(root='data/', 

Here's a sample image from the dataset.

In [52]:
img, label = test_dataset[0]
plt.imshow(img[0], cmap='gray')
print('Shape:', img.shape)
print('Label:', label)
Shape: torch.Size([1, 28, 28]) Label: 7
Notebook Image
In [53]:
torch.Size([1, 1, 28, 28])

Let's define a helper function predict_image, which returns the predicted label for a single image tensor.

In [54]:
def predict_image(img, model):
    xb = img.unsqueeze(0)
    yb = model(xb)
    _, preds  = torch.max(yb, dim=1)
    return preds[0].item()

img.unsqueeze simply adds another dimension at the begining of the 1x28x28 tensor, making it a 1x1x28x28 tensor, which the model views as a batch containing a single image.

Let's try it out with a few images.

In [55]:
img, label = test_dataset[0]
plt.imshow(img[0], cmap='gray')
print('Label:', label, ', Predicted:', predict_image(img, model))
Label: 7 , Predicted: 7
Notebook Image
In [56]:
img, label = test_dataset[10]
plt.imshow(img[0], cmap='gray')
print('Label:', label, ', Predicted:', predict_image(img, model))
Label: 0 , Predicted: 0
Notebook Image
In [57]:
img, label = test_dataset[193]
plt.imshow(img[0], cmap='gray')
print('Label:', label, ', Predicted:', predict_image(img, model))
Label: 9 , Predicted: 4
Notebook Image
In [58]:
img, label = test_dataset[1839]
plt.imshow(img[0], cmap='gray')
print('Label:', label, ', Predicted:', predict_image(img, model))
Label: 2 , Predicted: 8
Notebook Image

Identifying where our model performs poorly can help us improve the model, by collecting more training data, increasing/decreasing the complexity of the model, and changing the hypeparameters.

As a final step, let's also look at the overall loss and accuracy of the model on the test set.

In [59]:
test_loader = DataLoader(test_dataset, batch_size=200)

test_loss, total, test_acc = evaluate(model, loss_fn, test_loader, metric=accuracy)
print('Loss: {:.4f}, Accuracy: {:.4f}'.format(test_loss, test_acc))
Loss: 0.5950, Accuracy: 0.8669
In [60]:
    'test_loss': 1.0796,
    'test_acc': 0.8217
[jovian] Metrics logged.

We expect this to be similar to the accuracy/loss on the validation set. If not, we might need a better validation set that has similar data and distribution as the test set (which often comes from real world data).

Saving and loading the model

Since we've trained our model for a long time and achieved a resonable accuracy, it would be a good idea to save the weights and bias matrices to disk, so that we can reuse the model later and avoid retraining from scratch. Here's how you can save the model.

In [61]:, 'mnist-logistic.pth')

The .state_dict method returns an OrderedDict containing all the weights and bias matrices mapped to the right attributes of the model.

In [62]:
              tensor([[ 0.0151,  0.0113,  0.0039,  ...,  0.0012,  0.0264, -0.0314],
                      [ 0.0118, -0.0257, -0.0005,  ...,  0.0249, -0.0222,  0.0248],
                      [-0.0126, -0.0294, -0.0342,  ..., -0.0028,  0.0325, -0.0254],
                      [ 0.0063, -0.0158, -0.0240,  ...,  0.0206, -0.0351,  0.0298],
                      [ 0.0257,  0.0247, -0.0220,  ...,  0.0310,  0.0193, -0.0185],
                      [ 0.0132,  0.0044,  0.0303,  ...,  0.0090,  0.0352,  0.0054]])),
              tensor([-0.0240,  0.0821, -0.0104, -0.0409,  0.0004,  0.0399, -0.0029,  0.0468,
                      -0.1109, -0.0198]))])

To load the model weights, we can instante a new object of the class MnistModel, and use the .load_state_dict method.

In [63]:
model2 = MnistModel()
              tensor([[ 0.0151,  0.0113,  0.0039,  ...,  0.0012,  0.0264, -0.0314],
                      [ 0.0118, -0.0257, -0.0005,  ...,  0.0249, -0.0222,  0.0248],
                      [-0.0126, -0.0294, -0.0342,  ..., -0.0028,  0.0325, -0.0254],
                      [ 0.0063, -0.0158, -0.0240,  ...,  0.0206, -0.0351,  0.0298],
                      [ 0.0257,  0.0247, -0.0220,  ...,  0.0310,  0.0193, -0.0185],
                      [ 0.0132,  0.0044,  0.0303,  ...,  0.0090,  0.0352,  0.0054]])),
              tensor([-0.0240,  0.0821, -0.0104, -0.0409,  0.0004,  0.0399, -0.0029,  0.0468,
                      -0.1109, -0.0198]))])

Just as a sanity check, let's verify that this model has the same loss and accuracy on the test set as before.

In [64]:
test_loss, total, test_acc = evaluate(model, loss_fn, test_loader, metric=accuracy)
print('Loss: {:.4f}, Accuracy: {:.4f}'.format(test_loss, test_acc))
Loss: 0.5950, Accuracy: 0.8669

Commit and upload the notebook

As a final step, we can save and commit our work using the jovian library.

In [65]:
import jovian
In [ ]:
[jovian] Saving notebook..

Jovian uploads the notebook to, captures the Python environment and creates a sharable link for the notebook. You can use this link to share your work and let anyone reproduce it easily with the jovian clone command. Jovian also includes a powerful commenting interface, so you (and others) can discuss & comment on specific parts of your notebook.

Summary and Further Reading

We've created a fairly sophisticated training and evaluation pipeline in this tutorial. Here's a list of the topics we've covered:

  • Working with images in PyTorch (using the MNIST dataset)
  • Splitting a dataset into training, validation and test sets
  • Creating PyTorch models with custom logic by extending the nn.Module class
  • Interpreting model ouputs as probabilities using softmax, and picking predicted labels
  • Picking a good evaluation metric (accuracy) and loss function (cross entropy) for classification problems
  • Setting up a training loop that also evaluates the model using the validation set
  • Testing the model manually on randomly picked examples
  • Saving and loading model checkpoints to avoid retraining from scratch

There's a lot of scope to experiment here, and I encourage you to use the interactive nature of Jupyter to play around with the various parameters. Here are a few ideas:

  • Try making the validation set smaller or larger, and see how it affects the model.
  • Try changing the learning rate and see if you can achieve the same accuracy in fewer epochs.
  • Try changing the batch size. What happens if you use too high a batch size, or too low?
  • Modify the fit function to also track the overall loss and accuracy on the training set, and see how it compares with the validation loss/accuracy. Can you explain why it's lower/higher?
  • Train with a small subset of the data, and see if you can reach a similar level of accuracy.
  • Try building a model for a different dataset, such as the CIFAR10 or CIFAR100 datasets.

Here are some references for further reading:

  • For a more mathematical treatment, see the popular Machine Learning course on Coursera. Most of the images used in this tutorial series have been taken from this course.
  • The training loop defined in this notebook was inspired from FastAI development notebooks which contain a wealth of other useful stuff if you can read and understand the code.
  • For a deep dive into softmax and cross entropy, see this blog post on DeepNotes.

With this we complete our discussion of logistic regression, and we're ready to move on to the next topic: feedforward neural networks!