Як побудувати нейронну мережу з нуля за допомогою PyTorch

У цій статті ми підемо під капот нейронних мереж, щоб дізнатися, як побудувати її з нуля.

Одне, що мене найбільше хвилює в процесі глибокого навчання - це базікання коду, щоб побудувати щось з нуля. Однак це непросте завдання, і навчити когось іншого робити це ще складніше.

Я працював на курсі Fast.ai, і цей блог натхненний моїм досвідом.

Без будь-яких подальших затримок розпочнемо нашу чудову подорож демістифікації нейронних мереж.

Як працює нейронна мережа?

Почнемо з розуміння роботи нейронних мереж на високому рівні.

Нейронна мережа приймає набір даних і видає прогноз. Це так просто.

Дозвольте навести приклад.

Скажімо, один із ваших друзів (який не є великим любителем футболу) вказує на стару картину відомого футболіста - скажімо, Ліонеля Мессі - і запитує вас про нього.

Ви зможете ідентифікувати футболіста за секунду. Причина в тому, що ви бачили його фотографії тисячу разів раніше. Тож ви можете його ідентифікувати, навіть якщо фотографія старе або зроблено при слабкому світлі.

Але що станеться, якщо я покажу вам фотографію відомого бейсболіста (а ви ще ніколи не бачили жодної гри в бейсбол)? Ви не зможете розпізнати цього гравця. У такому випадку, навіть якщо картинка буде чіткою та яскравою, ви не будете знати, хто це.

Це той самий принцип, який використовується для нейронних мереж. Якщо наша мета - створити нейронну мережу для розпізнавання котів і собак, ми просто показуємо нейронній мережі купу фотографій собак і котів.

Більш конкретно, ми показуємо нейромережеві фотографії собак, а потім повідомляємо, що це собаки. А потім покажіть йому фотографії котів і визначте їх як котів.

Після того, як ми навчимо нашу нейронну мережу із зображеннями котів і собак, вона може легко класифікувати, чи містить це зображення кішку чи собаку. Коротше кажучи, він може впізнати кота від собаки.

Але якщо ви покажете нашій нейронній мережі зображення коня чи орла, він ніколи не визначить його як коня чи орла. Це тому, що він ніколи раніше не бачив зображення коня чи орла, тому що ми ніколи не показували йому цих тварин.

Якщо ви хочете поліпшити здатність нейронної мережі, то все, що вам потрібно зробити, це показати їй фотографії всіх тварин, яких ви хочете класифікувати за допомогою нейронної мережі. На сьогоднішній день все, що йому відомо, - це коти і собаки і нічого іншого.

Набір даних, який ми використовуємо для тренувань, сильно залежить від проблеми на наших руках. Якщо ви хочете класифікувати, чи має твіт позитивні чи негативні настрої, то, ймовірно, вам потрібен набір даних, що містить багато твітів із відповідною міткою, як позитивний, так і негативний.

Тепер, коли у вас є огляд високого рівня наборів даних і того, як нейронна мережа навчається на цих даних, давайте глибше заглибимося в те, як працюють нейронні мережі.

Розуміння нейронних мереж

Ми будуватимемо нейронну мережу для класифікації цифр три та сім із зображення.

Але перед тим, як побудувати свою нейронну мережу, нам потрібно глибше зрозуміти, як вони працюють.

Кожне зображення, яке ми передаємо в нашу нейронну мережу, - це лише купа цифр. Тобто, кожен із наших зображень має розмір 28 × 28, що означає, що він має 28 рядків і 28 стовпців, як матриця.

Ми розглядаємо кожну з цифр як цілісне зображення, але для нейронної мережі це лише група чисел в діапазоні від 0 до 255.

Ось піксельне зображення цифри п’ять:

Як ви можете бачити вище, у нас є 28 рядків і 28 стовпців (індекс починається з 0 і закінчується на 27), як матриця. Нейронні мережі бачать лише ці матриці 28 × 28.

Щоб показати деякі деталі, я щойно показав відтінок разом із значеннями пікселів. Якщо придивитися до зображення ближче, ви зможете побачити, що значення пікселів, близькі до 255, темніші, тоді як значення ближче до 0 світліше за відтінком.

У PyTorch ми не використовуємо термін матриця. Натомість ми використовуємо термін тензор. Кожне число в PyTorch представляється як тензор. Отже, відтепер ми будемо використовувати термін тензор замість матриці.

Візуалізація нейронної мережі

Нейронна мережа може мати будь-яку кількість нейронів і шарів.

Ось як виглядає нейронна мережа:

Не плутайте грецькі літери на малюнку. Я розберу це для вас:

Візьмімо випадок прогнозування виживання пацієнта чи ні на основі набору даних, що містить ім’я пацієнта, температуру, кров’яний тиск, стан серця, місячну зарплату та вік.

У нашому наборі даних лише температура, артеріальний тиск, стан серця та вік мають суттєве значення для прогнозування виживання пацієнта чи ні. Тож ми присвоїмо цим значенням вищу вагу, щоб показати вищу важливість.

Але такі особливості, як ім’я пацієнта та щомісячна заробітна плата, майже не впливають на рівень виживання пацієнта. Тому ми призначаємо менші значення ваги цим характеристикам, щоб показати меншу важливість.

На наведеному малюнку x1, x2, x3 ... xn - це функції у нашому наборі даних, які можуть бути значеннями пікселів у випадку даних зображень або таких функцій, як артеріальний тиск або стан серця, як у наведеному вище прикладі.

Значення ознак множаться на відповідні значення ваги, що називаються w1j, w2j, w3j ... wnj. Помножені значення підсумовуються разом і передаються наступному шару.

Оптимальні значення ваги вивчаються під час тренування нейронної мережі. Значення ваги постійно оновлюються таким чином, щоб максимізувати кількість правильних прогнозів.

У нашому випадку функція активації - це не що інше, як сигмоїдна функція. Будь-яке значення, яке ми передаємо сигмоїду, перетворюється на значення від 0 до 1. Ми просто ставимо функцію сигмоїда поверх нашого передбачення нейронної мережі, щоб отримати значення від 0 до 1.

You will understand the importance of the sigmoid layer once we start building our neural network model.

There are a lot of other activation functions that are even simpler to learn than sigmoid.

This is the equation for a sigmoid function:

The circular-shaped nodes in the diagram are called neurons. At each layer of the neural network, the weights are multiplied with the input data.

We can increase the depth of the neural network by increasing the number of layers. We can improve the capacity of a layer by increasing the number of neurons in that layer.

Understanding our data set

The first thing we need in order to train our neural network is the data set.

Since the goal of our neural network is to classify whether an image contains the number three or seven, we need to train our neural network with images of threes and sevens. So, let's build our data set.

Luckily, we don't have to create the data set from scratch. Our data set is already present in PyTorch. All we have to do is just download it and do some basic operations on it.

We need to download a data set called MNIST(Modified National Institute of Standards and Technology) from the torchvision library of PyTorch.

Now let's dig deeper into our data set.

What is the MNIST data set?

The MNIST data set contains handwritten digits from zero to nine with their corresponding labels as shown below:

So, what we do is simply feed the neural network the images of the digits and their corresponding labels which tell the neural network that this is a three or seven.

How to prepare our data set

The downloaded MNIST data set has images and their corresponding labels.

We just write the code to index out only the images with a label of three or seven. Thus, we get a data set of threes and sevens.

First, let's import all the necessary libraries.

import torch from torchvision import datasets import matplotlib.pyplot as plt

We import the PyTorch library for building our neural network and the torchvision library for downloading the MNIST data set, as discussed before. The Matplotlib library is used for displaying images from our data set.

Now, let's prepare our data set.

mnist = datasets.MNIST('./data', download=True) threes = mnist.data[(mnist.targets == 3)]/255.0 sevens = mnist.data[(mnist.targets == 7)]/255.0 len(threes), len(sevens)

As we learned above, everything in PyTorch is represented as tensors. So our data set is also in the form of tensors.

We download the data set in the first line. We index out only the images whose target value is equal to 3 or 7 and normalize them by dividing with 255 and store them separately.

We can check whether our indexing was done properly by running the code in the last line which gives the number of images in the threes and sevens tensor.

Now let's check whether we've prepared our data set correctly.

def show_image(img): plt.imshow(img) plt.xticks([]) plt.yticks([]) plt.show() show_image(threes[3]) show_image(sevens[8])

Using the Matplotlib library, we create a function to display the images.

Let's do a quick sanity check by printing the shape of our tensors.

print(threes.shape, sevens.shape)

If everything went right, you will get the size of threes and sevens as ([6131, 28, 28]) and ([6265, 28, 28]) respectively. This means that we have 6131 28×28 sized images for threes and 6265 28×28 sized images for sevens.

We've created two tensors with images of threes and sevens. Now we need to combine them into a single data set to feed into our neural network.

combined_data = torch.cat([threes, sevens]) combined_data.shape

We will concatenate the two tensors using PyTorch and check the shape of the combined data set.

Now we will flatten the images in the data set.

flat_imgs = combined_data.view((-1, 28*28)) flat_imgs.shape

We will flatten the images in such a way that each of the 28×28 sized images becomes a single row with 784 columns (28×28=784). Thus the shape gets converted to ([12396, 784]).

We need to create labels corresponding to the images in the combined data set.

target = torch.tensor([1]*len(threes)+[2]*len(sevens)) target.shape

We assign the label 1 for images containing a three, and the label 0 for images containing a seven.

How to train your Neural Network

To train your neural network, follow these steps.

Step 1: Building the model

Below you can see the simplest equation that shows how neural networks work:

                                y = Wx + b

Here, the term 'y' refers to our prediction, that is, three or seven. 'W' refers to our weight values, 'x' refers to our input image, and 'b' is the bias (which, along with weights, help in making predictions).

In short, we multiply each pixel value with the weight values and add them to the bias value.

The weights and bias value decide the importance of each pixel value while making predictions.  

We are classifying three and seven, so we have only two classes to predict.

So, we can predict 1 if the image is three and 0 if the image is seven. The prediction we get from that step may be any real number, but we need to make our model (neural network) predict a value between 0 and 1.

This allows us to create a threshold of 0.5. That is, if the predicted value is less than 0.5 then it is a seven. Otherwise it is a three.

We use a sigmoid function to get a value between 0 and 1.

We will create a function for sigmoid using the same equation shown earlier. Then we pass in the values from the neural network into the sigmoid.

We will create a single layer neural network.

We cannot create a lot of loops to multiply each weight value with each pixel in the image, as it is very expensive. So we can use a magic trick to do the whole multiplication in one go by using matrix multiplication.

def sigmoid(x): return 1/(1+torch.exp(-x)) def simple_nn(data, weights, bias): return sigmoid(([email protected]) + bias)

Step 2: Defining the loss

Now, we need a loss function to calculate by how much our predicted value is different from that of the ground truth.

For example, if the predicted value is 0.3 but the ground truth is 1, then our loss is very high. So our model will try to reduce this loss by updating the weights and bias so that our predictions become close to the ground truth.

We will be using mean squared error to check the loss value. Mean squared error finds the mean of the square of the difference between the predicted value and the ground truth.

def error(pred, target): return ((pred-target)**2).mean()

Step 3: Initialize the weight values

We just randomly initialize the weights and bias. Later, we will see how these values are updated to get the best predictions.

w = torch.randn((flat_imgs.shape[1], 1), requires_grad=True) b = torch.randn((1, 1), requires_grad=True)

The shape of the weight values should be in the following form:

(Number of neurons in the previous layer, number of neurons in the next layer)

We use a method called gradient descent to update our weights and bias to make the maximum number of correct predictions.

Our goal is to optimize or decrease our loss, so the best method is to calculate gradients.

We need to take the derivative of each and every weight and bias with respect to the loss function. Then we have to subtract this value from our weights and bias.

In this way, our weights and bias values are updated in such a way that our model makes a good prediction.

Updating a parameter for optimizing a function is not a new thing – you can optimize any arbitrary function using gradients.

We've set a special parameter (called requires_grad) to true to calculate the gradient of weights and bias.

Step 4: Update the weights

If our prediction does not come close to the ground truth, that means that we've made an incorrect prediction. This means that our weights are not correct. So we need to update our weights until we get good predictions.

For this purpose, we put all of the above steps inside a for loop and allow it to iterate any number of times we wish.

At each iteration, the loss is calculated and the weights and biases are updated to get a better prediction on the next iteration.

Thus our model becomes better after each iteration by finding the optimal weight value suitable for our task in hand.

Each task requires a different set of weight values, so we can't expect our neural network trained for classifying animals to perform well on musical instrument classification.

This is how our model training looks like:

for i in range(2000): pred = simple_nn(flat_imgs, w, b) loss = error(pred, target.unsqueeze(1)) loss.backward() w.data -= 0.001*w.grad.data b.data -= 0.001*b.grad.data w.grad.zero_() b.grad.zero_() print("Loss: ", loss.item())

We will calculate the predictions and store it in the 'pred' variable by calling the function that we've created earlier. Then we calculate the mean squared error loss.

Then, we will calculate all the gradients for our weights and bias and update the value using those gradients.

We've multiplied the gradients by 0.001, and this is called learning rate. This value decides the rate at which our model will learn, if it is too low, then the model will learn slowly, or in other words, the loss will be reduced slowly.

If the learning rate is too high, our model will not be stable, jumping between a wide range of loss values. This means it will fail to converge.

We do the above steps for 2000 times, and each time our model tries to reduce the loss by updating the weights and bias values.

We should zero out the gradients at the end of each loop or epoch so that there is no accumulation of unwanted gradients in the memory which will affect our model's learning.

Since our model is very small, it doesn't take much time to train for 2000 epochs or iterations. After 2000 epochs, our neural netwok has given a loss value of 0.6805 which is not bad from such a small model.

Conclusion

There is a huge space for improvement in the model that we've just created.

This is just a simple model, and you can experiment on it by increasing the number of layers, number of neurons in each layer, or increasing the number of epochs.

In short, machine learning is a whole lot of magic using math. Always learn the foundational concepts – they may be boring, but eventually you will understand that those boring math concepts created these cutting edge technologies like deepfakes.

You can get the complete code on GitHub or play with the code in Google colab.