Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Chapter 11 - Multi-layer Artificial Neural Networks


Prework

Import essentials

Check package version (optional)

Add folder to path in order to load from the check_packages.py script.

Check recommended package versions.

Watermark (optional)

Optional watermark extension is a small IPython notebook plugin that I developed to make the code reproducible.

You can install watermark Jupyter extension via

conda install watermark -c conda-forge  

or

pip install watermark   

For more information, please see: https://github.com/rasbt/watermark.



11.1.Modeling complex functions with artificial neural networks

11.1.1.Single-layer neural network recap



11.1.2.Introducing the multi-layer neural network architecture



11.1.3.Activating a neural network via forward propagation

No example code.




11.2.Classifying handwritten digits

11.2.1.Obtaining and preparing the MNIST dataset

The MNIST dataset is publicly available at http://yann.lecun.com/exdb/mnist/ and consists of the following four parts:

  • Training set images: train-images-idx3-ubyte.gz (9.9 MB, 47 MB unzipped, 60,000 examples)

  • Training set labels: train-labels-idx1-ubyte.gz (29 KB, 60 KB unzipped, 60,000 labels)

  • Test set images: t10k-images-idx3-ubyte.gz (1.6 MB, 7.8 MB, 10,000 examples)

  • Test set labels: t10k-labels-idx1-ubyte.gz (5 KB, 10 KB unzipped, 10,000 labels)

The MNIST dataset was constructed from two datasets of the US National Institute of Standards and Technology (NIST). The training dataset consists of handwritten digits from 250 different people, 50 percent high school students, and 50 percent employees from the Census Bureau. Note that the test dataset contains handwritten digits from different people following the same split.

Use scikit-learn’s new fetch_openml function, which allows us to load the MNIST dataset more conveniently, instead of using numpy.

In scikit-learn, the fetch_openml function downloads the MNIST dataset from OpenML (https://www.openml.org/d/554) as pandas DataFrame and Series objects, which is why we use the .values attribute to obtain the underlying NumPy arrays. (If you are using a scikit-learn version older than 1.0, fetch_openml downloads NumPy arrays directly so you can omit using the .values attribute.)

Result explanation

  • X array consists of 70,000 images, an 784 (28×28) pixels for each image

    • each pixel is represented by a grayscale intensity value

  • y array stores the corresponding 70,000 class labels

Here, fetch_openml already unrolled the 28×28 pixels into one-dimensional row vectors, which represent the rows in our X array (784 per row or image) above. The second array (y) returned by the fetch_openml function contains the corresponding target variable, the class labels (integers 0-9) of the handwritten digits.

Next, let’s normalize the pixels values in MNIST to the range –1 to 1 (originally 0 to 255 for grayscale intensity) via the following code line.

The reason behind normalizing the pixels is that gradient-based optimization is much more stable under these conditions.

To get an idea of how those images in MNIST look, let’s visualize examples of the digits 0-9 after reshaping the 784-pixel vectors from our feature matrix into the original 28×28 image that we can plot via Matplotlib’s imshow function.

Result explanation We should now see a plot of the 2×5 subfigures showing a representative image of each unique digit.

In addition, let’s also plot multiple examples of the same digit to see how different the handwriting for each really is.

Result explanation After executing the code, we should now see the first 25 variants of the digit 7.

Finally, let’s divide the dataset into training, validation, and test subsets. The following code will split the dataset such that 55,000 images are used for training, 5,000 images for validation, and 10,000 images for testing.

11.2.2.Implementing a multi-layer perceptron

We will now implement an MLP from scratch to classify the images in the MNIST dataset. To keep things simple, we will implement an MLP with only one hidden layer.

Let’s look at the following implementation of an MLP, starting with the two helper functions to compute the logistic sigmoid activation and to convert integer class label arrays to one-hot encoded labels.

  • The __init__ constructor instantiates the weight matrices and bias vectors for the hidden and the output layer.

    • These are used in the forward method to make predictions.

  • The forward method takes in one or more training examples and returns the predictions.

    • It returns both the activation values from the hidden layer (a_h) and the output layer (a_out).

    • a_h: to optimize the model parameters; that is, the weight and bias units of the hidden and output layers

    • a_out: represents the class-membership probabilities that we can convert to class labels

  • The backward method, which updates the weight and bias parameters of the neural network.

    • Here for simplicity, we use MSE for the loss function.

Note that this code implementation of the NeuralNetMLP class differs from the familiar scikit-learn API that is centered around the .fit() and .predict() methods. Instead, the main methods of the NeuralNetMLP class are the .forward() and .backward() methods. One of the reasons behind this is that it makes a complex neural network a bit easier to understand in terms of how the information flows through the networks.

After we have implemented the NeuralNetMLP class, we use the following code to instantiate a new NeuralNetMLP object.

The model accepts MNIST images reshaped into 784-dimensional vectors (in the format of X_train, X_valid, or X_test, which we defined previously) for the 10 integer classes (digits 0-9). The hidden layer consists of 50 nodes. Also, as you may be able to tell from looking at the previously defined .forward() method, we use a sigmoid activation function after the first hidden layer and output layer to keep things simple.

The figure shows the summary of the neural network architecture that we instantiated above.



11.2.3.Coding the neural network training loop

In this subsection, we are going to implement the training function that we can use to train the network on mini-batches of the data via backpropagation.

The first function we are going to define is a mini-batch generator, which takes in our dataset and divides it into mini-batches of a desired size for stochastic gradient descent training.

Loss function and performance metric

1) Intuitive way

Next, we have to define our loss function and performance metric that we can use to monitor the training process and evaluate the model. And test the preceding function and compute the initial validation set MSE and accuracy of the model we instantiated in the previous section.

Note that model.forward() returns the hidden and output layer activations. Remember that we have 10 output nodes (one corresponding to each unique class label). Hence, when computing the MSE, we first converted the class labels into one-hot encoded class labels in the mse_loss() function. In practice, it does not make a difference whether we average over the row or the columns of the squared-difference matrix first, so we simply call np.mean() without any axis specification so that it returns a scalar.

The output layer activations, since we used the logistic sigmoid function, are values in the range [0, 1]. For each input, the output layer produces 10 values in the range [0, 1], so we used the np.argmax() function to select the index position of the largest value, which yields the predicted class label. We then compared the true labels with the predicted class labels to compute the accuracy via the accuracy() function we defined.

Result explanation

As we can see from the output, the accuracy is not very high. However, given that we have a balanced dataset with 10 classes, a prediction accuracy of approximately 10 percent is what we would expect for an untrained model producing random predictions.

2) Memory-efficient way

In practice, our computer memory is usually a limiting factor for how much data the model can ingest in one forward pass (due to the large matrix multiplications). Hence, we are defining our MSE and accuracy computation based on our previous mini-batch generator. The following function will compute the MSE and accuracy incrementally by iterating over the dataset one mini-batch at a time to be more memory-efficient.

Result explanation

As we can see from the results, our generator approach produces the same results as the previously defined MSE and accuracy functions, except for a small rounding error in the MSE (0.27 versus 0.28), which is negligible for our purposes.

Training model

Let’s now implement the code to train our model. On a high level, the train() function iterates over multiple epochs, and in each epoch, it used the previously defined minibatch_generator() function to iterate over the whole training set in mini-batches for stochastic gradient descent training. Inside the mini-batch generator for loop, we obtain the outputs from the model, a_h and a_out, via its .forward() method. Then, we compute the loss gradients via the model’s .backward() method. Using the loss gradients, we update the weights by adding the negative gradient multiplied by the learning rate. For example, to update the model weights of the hidden layer, we defined the following line:

model.weight_h -= learning_rate * d_loss__d_w_h

For a single weight, wjw_j, this corresponds to the following partial derivative-based update:

wj:=wj−η∂L∂wjw_j:=w_j-\eta \frac{\partial L}{\partial w_j}

Let’s now execute this function to train our model for 50 epochs.

Result explanation

The reason why we print all this output is that, in NN training, it is really useful to compare training and validation accuracy. This helps us judge whether the network model performs well, given the architecture and hyperparameters. For example, if we observe a low training and validation accuracy, there is likely an issue with the training dataset, or the hyperparameters’ settings are not ideal.

In general, training (deep) NNs is relatively expensive compared with the other models we’ve discussed so far. Thus, we want to stop it early in certain circumstances and start over with different hyperparameter settings. On the other hand, if we find that it increasingly tends to overfit the training data (noticeable by an increasing gap between training and validation dataset performance), we may want to stop the training early, as well.

11.2.4.Evaluating the neural network performance

Let’s look at the performance of the model that we trained in the previous subsection. In train(), we collected the training loss and the training and validation accuracy for each epoch so that we can visualize the results using Matplotlib. Let’s look at the training MSE loss first.

Result explanation

As we can see, the loss decreased substantially during the first 10 epochs and seems to slowly converge in the last 10 epochs. However, the small slope between epoch 40 and epoch 50 indicates that the loss would further decrease with training over additional epochs.

Next, let’s take a look at the training and validation accuracy.

Result explanation

The plot reveals that the gap between training and validation accuracy increases as we train for more epochs. At approximately the 25th epoch, the training and validation accuracy values are almost qual, and then, the network starts to slightly overfit the training data (where the two lines start to separate).

Finally, let’s evaluate the generalization performance of the model by calculating the prediction accuracy on the test dataset.

Result explanation

We can see that the test accuracy is very close to the validation set accuracy corresponding to the last epoch (94.74%). Moreover, the respective training accuracy is only minimally higher at 95.59%, reaffirming that our model only slightly overfits the training data.

To further fine-tune the model, we could change the number of hidden units, the learning rate, etc.

Lastly, let’s take a look at some of the images that our MLP struggles with by extracting and plotting the first 25 misclassified samples from the test set.

Result explanation

We should now see a 5×5 subplot matrix where the first number in the subtitles indicates the plot index, the second number represents the true class label (True), and the third number stands for the predicted class label (Predicted).

As we can see in the result figure, among others, the network finds 7 s7 \mathrm{~s} challenging when they include a horizontal line as in examples 19 and 20. Looking back at an earlier figure in this chapter where we plotted different training examples of the number 7, we can hypothesize that the handwritten digit 7 with a horizontal line is underrepresented in our dataset and is often misclassified.



11.3.Training an artificial neural network

11.3.1.Computing the loss function



11.3.2.Developing your intuition for backpropagation

No code example.

11.3.3.Training neural networks via backpropagation



11.4.Convergence in neural networks




Readers may ignore the next cell.