Глубокое обучение и нейронные сети с Python и Pytorch. Часть III: построение нейронной сети

Предыдущая статья — Глубокое обучение и нейронные сети с Python и Pytorch. Часть II: данные.

Создаем нейронную сеть

В данной статье мы сосредоточимся на реальном создании нейронной сети. В предыдущей части мы создали следующий код для подготовки данных:

import torch
import torchvision
from torchvision import transforms, datasets

train = datasets.MNIST('', train=True, download=True,
                       transform=transforms.Compose([
                           transforms.ToTensor()
                       ]))

test = datasets.MNIST('', train=False, download=True,
                       transform=transforms.Compose([
                           transforms.ToTensor()
                       ]))


trainset = torch.utils.data.DataLoader(train, batch_size=10, shuffle=True)
testset = torch.utils.data.DataLoader(test, batch_size=10, shuffle=False) 

Теперь давайте создадим саму модель нейронной сети. Для начала произведем импорт из библиотеки Pytorch:

import torch.nn as nn
import torch.nn.functional as F

Импорт модуля import torch.nn as nn открывает перед нами более широкие возможности в плане создания нейронной сети. Например, есть возможность выбрать типы слоев в ней: полносвязные, сверточные (для обработки изображений), рекуррентные и так далее. На данный момент мы говорили только о полносвязанных слоях, так что остановимся пока на них.

Импорт модуля import torch.nn.functional as F дает нам доступ к ряду полезных функций, которые иначе нам бы пришлось писать самим. Например, мы очень часто будем использовать в качестве функции активации функцию relu или ступенчатую функцию. Вместо написания кода мы воспользуемся готовым — из данного модуля.

Если вы все же хотите научиться писать такие вещи самостоятельно, следите за нашими публикациями!

Чтобы задать нашу модель, создадим сначала класс. Мы назовем его net и он будет наследоваться от класса nn.Module:

class Net(nn.Module):
    def __init__(self):
        super().__init__()

net = Net()
print(net)
Net()

Здесь нет ничего особенного, но некоторых может немного смутить то, как задан метод __init__. Обычно, создавая класс, мы не используем метод __init__ родительского класса. Ниже мы покажем, как можно запускать метод __init__ родительского класса, поскольку таким образом удобно инстанцировать ряд вещей:

class a:
    <em>'''Will be a parent class'''</em>
    def __init__(self):
        print("initializing a")

class b(a):
    <em>'''Inherits from a, but does not run a's init method '''</em>
    def __init__(self):
        print("initializing b")

class c(a):
    <em>'''Inhereits from a, but DOES run a's init method'''</em>
    def __init__(self):
        super().__init__()
        print("initializing c")
b_ob = b()
initializing b

Заметьте, что при создании экземляра класса b метод __init__ родительского класса не запустился. Но давайте теперь создадим экземпляр класса c:

c_ob = c()
initializing a
initializing c

Методы __init__ обоих классов запустились. Отлично, вернемся опять к нашей нейронной сети. Давайте теперь зададим в ней слои.

class Net(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(28*28, 64)
        self.fc2 = nn.Linear(64, 64)
        self.fc3 = nn.Linear(64, 64)
        self.fc4 = nn.Linear(64, 10)

net = Net()
print(net)

Все что мы сделали, это просто определили значения переменных для нескольких слоев. Мы назвали их fc1fc2… и так далее, но вы можете дать им любые имена. fc расшифровывается как fully connected (полносвязанный). Полносвязанный означает, что каждый нейрон в слое соединен со всеми нейронами из соседних слоев. Ничего особенного здесь не происходит! Напомним вам, что каждое такое соединение имеет вес и, возможно, смещение, поэтому каждое соединение является параметром нейронной сети.

В нашем случае мы определили четыре слоя. Каждый слой класса nn.Linear принимает на вход два параметра: первый отвечает за количество входов, а второй за количество выходов.

Итак, наш первый слой принимает на вход 28 Х 28 значений, потому что наши изображения представляют собой написанные от руки цифры размером 28 Х 28. Стандартная нейронная сеть принимает на вход уже выпрямленный массив, поэтому не 28 Х 28, а 1 Х 784.

А выходов мы задали 64. Это означает, что следующий слой, fc2, должен принять на вход 64 значения (это общее правило, последующий слой всегда должен иметь столько же входов, сколько выходов у предыдущего слоя, иначе будет выдана ошибка). Соответственно, этот слой тоже имеет 64 выхода. И следующий слой, fc3, будет точно таким же.

А вот слой fc4 имеет, естественно, 64 входа, а вот выходов имеет только 10. Почему 10? Потому что наш выходной слой (последний слой) должен иметь 10 нейронов. А почему 10 нейронов? Потому что у нас есть ровно 10 классов (10 рукописных цифр).

Отлично, у нас есть все эти слои, но на самом деле ничто не определяет, как они будут взаимодействовать друг с другом — они просто определены и все.

Итак, наша простейшая нейронная сеть является полносвязанной и также является нейронной сетью прямого распространения. Это значит, что сигналы в ней распространяются от входа к выходу и никак иначе. Мы совсем не обязаны себя так ограничивать, но для нашей модели мы выбрали именно такую сеть.

Итак, давайте зададим метод под названием forward, который и будет определять, каким образом данные будут проходить по сети.

class Net(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(28*28, 64)
        self.fc2 = nn.Linear(64, 64)
        self.fc3 = nn.Linear(64, 64)
        self.fc4 = nn.Linear(64, 10)

    def forward(self, x):
        x = self.fc1(x)
        x = self.fc2(x)
        x = self.fc3(x)
        x = self.fc4(x)
        return x

net = Net()
print(net)
Net(
  (fc1): Linear(in_features=784, out_features=64, bias=True)
  (fc2): Linear(in_features=64, out_features=64, bias=True)
  (fc3): Linear(in_features=64, out_features=64, bias=True)
  (fc4): Linear(in_features=64, out_features=10, bias=True)
) 

Обратите внимание, что x — это параметр нашего метода forward. В нем будут входные данные. Как видите, мы буквально «прогоняем» наши данные через слои нейронной сети. В результате этого она, возможно, и обучится с грехом пополам, но скорей всего просто случится так называемый взрыв данных. Нейронная сеть должна держать под контролем рост данных, но для этого нам нужна функция активации (для каждого слоя), которой пока нет.

Не забывайте, что мы имитируем работу нейронов головного мозга, которые либо активируются, либо нет. Для этого мы используем функцию активации, передав на нее сумму весов всех входящих сигналов. Вначале для этого использовались ступенчатые функции, которые могли принимать только значения 0 или 1. Но затем было установлено, что сигмоиды и некоторые другие функции справляются лучше.

На данный момент наиболее популярна функция relu.

Как правило, функции активации сохраняют наши данные в масштабе от 0 до 1.

И наконец, для выходного слоя мы планируем использовать функцию softmax. Эта функция очень удобна для задач классификации, когда каждый объект может принадлежать только к одному классу. Это означает, что результатом данной функции будет оценка достоверности, в сумме равная 1(сумма значений оценок для каждого класса).

class Net(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(28*28, 64)
        self.fc2 = nn.Linear(64, 64)
        self.fc3 = nn.Linear(64, 64)
        self.fc4 = nn.Linear(64, 10)

    def forward(self, x):
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = F.relu(self.fc3(x))
        x = self.fc4(x)
        return F.log_softmax(x, dim=1)

net = Net()
print(net) 
Net(
  (fc1): Linear(in_features=784, out_features=64, bias=True)
  (fc2): Linear(in_features=64, out_features=64, bias=True)
  (fc3): Linear(in_features=64, out_features=64, bias=True)
  (fc4): Linear(in_features=64, out_features=10, bias=True)
)

На данный момент мы построили нейронную сеть, в которую можем передавать данные и получать на выходе результат. Давайте создадим случайное изображение:

X = torch.randn((28,28))

Это весьма похоже на наши изображения, такой же массив 28 Х 28. Однако, для передачи в нейронную сеть его необходимо выпрямить:

X = X.view(-1,28*28)

Что такое 28*28 понятно, но почему первый параметр -1?

Предполагается, что любой вход и выход нашей нейронной сети является набором групповых функций.

В нашем случае нам нужен размер 1 Х 784 и мы бы могли так и написать. Но как правило используется параметр -1. Почему же? -1 означает «любой размер». То есть данные могут быть 1, 12, 92, 15295... и так далее. Ввести для этого еще одну переменную просто удобно. В нашем случае переменная показывает, сколько «экземпляров» мы будем пропускать.

output = net(X)

А что у нас должно быть на выходе? Это должен быть тензор, который содержит в себе тензор, состоящий из оценок наших десяти возможных классов.

output
tensor([[-2.3037, -2.2145, -2.3538, -2.3707, -2.1101, -2.4241, -2.4055, -2.2921,
         -2.2625, -2.3294]], grad_fn=<LogSoftmaxBackward>)

Отлично, похоже, прямое распространение сигнала работает как надо. Но почему тензор в тензоре? Потому что вход и выход должны быть переменными. Даже если мы просто хотим предсказать по одному входу, это должен быть список входов, а выходом будет список выходов. В действительности не список, а тензор, но, надеемся, вы поняли, что имеется ввиду.

Следующая статья — Глубокое обучение и нейронные сети с Python и Pytorch. Часть IV: обучение нейронной сети.