Глубокое обучение и нейронные сети с Python и Pytorch. Часть VI: модель сверточной нейронной сети

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

Создание сверточной нейронной сети в Pytorch

Добро пожаловать в шестую часть нашей серии статей про фреймворк Pytorch. В преддверии этого мы рассмотрели, как создать базовую нейронную сеть, а теперь немного усложним задачу и создадим сверточную нейронную сеть или Convnet / CNN.

Наш код на настоящий момент имеет следующий вид:

import os
import cv2
import numpy as np
from tqdm import tqdm


REBUILD_DATA = False # set to true to one once, then back to false unless you want to change something in your training data.

class DogsVSCats():
    IMG_SIZE = 50
    CATS = "PetImages/Cat"
    DOGS = "PetImages/Dog"
    TESTING = "PetImages/Testing"
    LABELS = {CATS: 0, DOGS: 1}
    training_data = []

    catcount = 0
    dogcount = 0

    def make_training_data(self):
        for label in self.LABELS:
            print(label)
            for f in tqdm(os.listdir(label)):
                if "jpg" in f:
                    try:
                        path = os.path.join(label, f)
                        img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
                        img = cv2.resize(img, (self.IMG_SIZE, self.IMG_SIZE))
                        self.training_data.append([np.array(img), np.eye(2)[self.LABELS[label]]])  # do something like print(np.eye(2)[1]), just makes one_hot 
                        #print(np.eye(2)[self.LABELS[label]])

                        if label == self.CATS:
                            self.catcount += 1
                        elif label == self.DOGS:
                            self.dogcount += 1

                    except Exception as e:
                        pass
                        #print(label, f, str(e))

        np.random.shuffle(self.training_data)
        np.save("training_data.npy", self.training_data)
        print('Cats:',dogsvcats.catcount)
        print('Dogs:',dogsvcats.dogcount)

if REBUILD_DATA:
    dogsvcats = DogsVSCats()
    dogsvcats.make_training_data()


training_data = np.load("training_data.npy", allow_pickle=True)
print(len(training_data)) 

Результат:

24946

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

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

Далее, мы опять создадим класс Net, но теперь уже со сверточными слоями:

class Net(nn.Module):
    def __init__(self):
        super().__init__() # просто запускает метод init родительского класса (nn.Module)
        self.conv1 = nn.Conv2d(1, 32, 5) # вход 1 изображение, выход 32 канала, ядро свертки размером 5x5 
        self.conv2 = nn.Conv2d(32, 64, 5) # вход 32, так как выход первого слоя 32. Выход будет 64 канала, ядро свертки размером 5x5
        self.conv3 = nn.Conv2d(64, 128, 5)

Эти слои имеют, в дополнение к количеству входов и выходов, еще один параметр, отвечающий за размер ядра. Это размер окна в пикселях. Число 5 означает, что мы создаем скользящее окно для свертки размером 5×5.

Относительно количества входов и выходов слоев используются те же правила, что и раньше. Вы видите, что первый слой принимает на вход одно изображение и выводит 32 свертки. Затем следующий принимает 32 сверки, а отдает 64. И так далее.

Но теперь мы подходим к новой концепции. Сверточные функции — это просто свертки, возможно, свертки с макспулингом, но они не одномерные. Нам нужно их выпрямить, как будто нам нужно выпрямить изображение перед тем, как пропустить его через обычный слой.

Но как?

Этот вопрос всегда очень раздражает, когда смотришь документацию TensorFlow или Pytorch. Например, вот фрагмент кода сверточной нейронной сети из каталога примеров Pytorch на их github:

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(1, 20, 5, 1)
        self.conv2 = nn.Conv2d(20, 50, 5, 1)
        self.fc1 = nn.Linear(4*4*50, 500)
        self.fc2 = nn.Linear(500, 10

Как мы и говорили, вам нужно выпрямить выход последнего сверточного слоя, прежде чем вы сможете передать его в обычный полносвязанный слой (или то, что pytorch называет линейным слоем). Итак, глядя на этот код, вы видите, что вход для первого полносвязанного слоя: 4 * 4 * 50.

Откуда это взялось? Какие четверки? Что за 50?

Ответов на эти вопросы в документации найти не удалось. Такая же беда была и в TensorFlow до того как там появился метод flatten. Было бы здорово, если бы он также работал в Pytorch!

Вместо этого мы постараемся рассказать, как это сделать самостоятельно. Мы долго думали и искали лучшие решения. Возможно есть что-то и лучше, но мы пока этого не знаем.

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

Каким же образом? Мы можем сначала просто передать тестовые данные, чтобы получить эту форму. Затем мы можем использовать специальный флаг, чтобы определить, совпадает ли эта форма с требуемой после итерации наших данных. Мы могли бы сохранить сам код более чистым, получая это значение каждый раз, но для большей скорости лучше просто один раз выполнить эти вычисления:

class Net(nn.Module):
    def __init__(self):
        super().__init__() # just run the init of parent class (nn.Module)
        self.conv1 = nn.Conv2d(1, 32, 5) # вход 1 изображение, выход 32 канала, ядро свертки размером 5x5
        self.conv2 = nn.Conv2d(32, 64, 5) # вход 32, так как выход первого слоя 32. Выход будет 64 канала, ядро свертки размером 5x5
        self.conv3 = nn.Conv2d(64, 128, 5)

        x = torch.randn(50,50).view(-1,1,50,50)
        self._to_linear = None
        self.convs(x) 

Всякий раз при инициализации данного класса мы создаем случайные данные. Далее мы просто устанавливаем self .__ to_linear в значение none, а затем передаем эти случайные x-данные через self.convs, которого пока еще не существует.

Мы как раз сейчас собираемся сделать self.convs частью нашего метода forward. Смысл данного разделения в том, чтобы была возможность вызывать эти методы самостоятельно.

class Net(nn.Module):
    def __init__(self):
        super().__init__() # just run the init of parent class (nn.Module)
        self.conv1 = nn.Conv2d(1, 32, 5) # вход 1 изображение, выход 32 канала, ядро свертки размером 5x5
        self.conv2 = nn.Conv2d(32, 64, 5) # вход 32, так как выход первого слоя 32. Выход будет 64 канала, ядро свертки размером 5x5
        self.conv3 = nn.Conv2d(64, 128, 5)

        x = torch.randn(50,50).view(-1,1,50,50)
        self._to_linear = None
        self.convs(x)

    def convs(self, x):
        # max pooling over 2x2
        x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))
        x = F.max_pool2d(F.relu(self.conv2(x)), (2, 2))
        x = F.max_pool2d(F.relu(self.conv3(x)), (2, 2))

        if self._to_linear is None:
            self._to_linear = x[0].shape[0]*x[0].shape[1]*x[0].shape[2]
        return x 

С x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2)) проход вперед несколько затрудняется, но не сильно.

Вначале у нас есть функция F.relu(self.conv1(x)). Это то же самое, что и в нашей обычной нейронной сети. Мы просто запускаем выпрямленную линейную диаграмму на сверточных слоях. Затем мы запускаем это через F.max_pool2d с окном 2x2.

Теперь нам нужно рассчитать то, что нам необходимо для выпрямления (self._to_linear). Для этого нужно просто взять размерности тензора и перемножить их. Например, если тензор имеет форму (2,5,3), нам нужно всего лишь произвести умножение 2*5*3 = 30. После вычисления нам необходимо сохранить результат, чтобы в дальнейшем его использовать. В конце метода convs мы просто возвращаем х. Этот метод потом может быть использован и в других слоях.

Нам действительно нужно еще несколько слоев, поэтому давайте добавим их в метод __init__:

self.fc1 = nn.Linear(self._to_linear, 512) #выпрямление.
self.fc2 = nn.Linear(512, 2) # 512 вход, 2 выход так как у нас два класса (собаки и кошки).

Теперь наш класс Net будет иметь следующий вид:

class Net(nn.Module):
    def __init__(self):
        super().__init__() # just run the init of parent class (nn.Module)
        self.conv1 = nn.Conv2d(1, 32, 5) # вход 1 изображение, выход 32 канала, ядро свертки размером 5x5
        self.conv2 = nn.Conv2d(32, 64, 5) # вход 32, так как выход первого слоя 32. Выход будет 64 канала, ядро свертки размером 5x5
        self.conv3 = nn.Conv2d(64, 128, 5)

        x = torch.randn(50,50).view(-1,1,50,50)
        self._to_linear = None
        self.convs(x)

        self.fc1 = nn.Linear(self._to_linear, 512) #выпрямление.
        self.fc2 = nn.Linear(512, 2) # 512 вход, 2 выход так как у нас два класса (собаки и кошки).

    def convs(self, x):
        # max pooling over 2x2
        x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))
        x = F.max_pool2d(F.relu(self.conv2(x)), (2, 2))
        x = F.max_pool2d(F.relu(self.conv3(x)), (2, 2))

        if self._to_linear is None:
            self._to_linear = x[0].shape[0]*x[0].shape[1]*x[0].shape[2]
        return x 

Наконец, мы можем написать наш метод forward, который будет использовать уже существующий метод convs:

    def forward(self, x):
        x = self.convs(x)
        x = x.view(-1, self._to_linear)  # Метод .view используется для выпрямления x 
        x = F.relu(self.fc1(x))
        x = self.fc2(x) # Это выходной слой, поэтому функции активации тут нет.
        return F.softmax(x, dim=1)

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

Итак, вот наша сверточная нейронная сеть. Полный код на данный момент имеет следующий вид:

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

class Net(nn.Module):
    def __init__(self):
        super().__init__() # just run the init of parent class (nn.Module)
        self.conv1 = nn.Conv2d(1, 32, 5) # вход 1 изображение, выход 32 канала, ядро свертки размером 5x5
        self.conv2 = nn.Conv2d(32, 64, 5) # вход 32, так как выход первого слоя 32. Выход будет 64 канала, ядро свертки размером 5x5
        self.conv3 = nn.Conv2d(64, 128, 5)

        x = torch.randn(50,50).view(-1,1,50,50)
        self._to_linear = None
        self.convs(x)

        self.fc1 = nn.Linear(self._to_linear, 512) #выпрямление.
        self.fc2 = nn.Linear(512, 2) # 512 вход, 2 выход так как у нас два класса (собаки и кошки).

    def convs(self, x):
        # max pooling over 2x2
        x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))
        x = F.max_pool2d(F.relu(self.conv2(x)), (2, 2))
        x = F.max_pool2d(F.relu(self.conv3(x)), (2, 2))

        if self._to_linear is None:
            self._to_linear = x[0].shape[0]*x[0].shape[1]*x[0].shape[2]
        return x

    def forward(self, x):
        x = self.convs(x)
        x = x.view(-1, self._to_linear)  # .view is reshape ... this flattens X before 
        x = F.relu(self.fc1(x))
        x = self.fc2(x) # Это наш выходной слой. Функции активации тут нет.
        return F.softmax(x, dim=1)


net = Net()
print(net) 

Результат:

Net(
  (conv1): Conv2d(1, 32, kernel_size=(5, 5), stride=(1, 1))
  (conv2): Conv2d(32, 64, kernel_size=(5, 5), stride=(1, 1))
  (conv3): Conv2d(64, 128, kernel_size=(5, 5), stride=(1, 1))
  (fc1): Linear(in_features=512, out_features=512, bias=True)
  (fc2): Linear(in_features=512, out_features=2, bias=True)
)

Теперь мы готовы обучить нашу модель, поэтому нам нужно создать цикл обучения. Для этого нам понадобится функция потерь и оптимизатор. Снова воспользуемся оптимизатором Adam. На этот раз, поскольку сейчас у нас векторы one_hot («горячее» кодирование категоральных данных), мы будем использовать mse в качестве функции потерь. MSE это функция среднеквадратичной ошибки.

import torch.optim as optim

optimizer = optim.Adam(net.parameters(), lr=0.001)
loss_function = nn.MSELoss() 

Теперь нам нужно проитерировать наши данные, но мы, как и раньше, хотим это делать батчами. Также нам надо разделить наши данные на тренировочные и тестовые.

Наряду с разделением наших данных нам также необходимо преобразовать эти данные в соответствии со стандартами Pytorch, который ожидает от нас данные в виде (-1, IMG_SIZE, IMG_SIZE).

Для начала сделаем следующее:

X = torch.Tensor([i[0] for i in training_data]).view(-1,50,50)
X = X/255.0
y = torch.Tensor([i[1] for i in training_data])

Выше мы разделяем в обучающих данных наборы признаков и их метки. Далее, при помощи метода .view мы преобразуем форму данных в (-1, 50, 50). Здесь числа 50 определяют размер изображения. Теперь нам нужно из этого выделить некоторые данные для тестовой выборки.

Допустим, мы хотим для этого использовать 10% наших данных. Выполним следующий код:

VAL_PCT = 0.1  # для тестирования возьмем 10% наших данных
val_size = int(len(X)*VAL_PCT)
print(val_size)

Результат:

2494

Мы преобразовали это число в тип int (целочисленный тип данных), так как планируем использовать его в качестве индексов при разбиении наших данных.

train_X = X[:-val_size]
train_y = y[:-val_size]

test_X = X[-val_size:]
test_y = y[-val_size:]
print(len(train_X), len(test_X))

Результат:

22452 2494

Теперь мы можем перебрать наши данные для обучения и тестирования. Нам осталось определиться с размером батча. Мы будем использовать батч длиной в 100 элементов, но если у вас возникают ошибки памяти, это число можно уменьшить.

BATCH_SIZE = 100
EPOCHS = 1

for epoch in range(EPOCHS):
    for i in tqdm(range(0, len(train_X), BATCH_SIZE)): # from 0, to the len of x, stepping BATCH_SIZE at a time. [:50] ..for now just to dev
        #print(f"{i}:{i+BATCH_SIZE}")
        batch_X = train_X[i:i+BATCH_SIZE].view(-1, 1, 50, 50)
        batch_y = train_y[i:i+BATCH_SIZE]

        net.zero_grad()

        outputs = net(batch_X)
        loss = loss_function(outputs, batch_y)
        loss.backward()
        optimizer.step()    # Does the update

    print(f"Epoch: {epoch}. Loss: {loss}") 

Результат:

100%|a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^| 225/225 [01:56<00:00,  2.24it/s]
Epoch: 0. Loss: 0.21407592296600342

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

Данный код довольно очевиден, мы просто перебираем нашу обучающую выборку с размером шага в BATCH_SIZE.

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

correct = 0
total = 0
with torch.no_grad():
    for i in tqdm(range(len(test_X))):
        real_class = torch.argmax(test_y[i])
        net_out = net(test_X[i].view(-1, 1, 50, 50))[0]  # returns a list, 
        predicted_class = torch.argmax(net_out)

        if predicted_class == real_class:
            correct += 1
        total += 1
print("Accuracy: ", round(correct/total, 3)) 

Результат:

100%|a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^a-^| 2494/2494 [00:11<00:00, 226.66it/s]
Accuracy:  0.651

Как видите, всего через за одну эпоху мы достигли 65% точности, что довольно неплохо, учитывая конечно то, что наши данные почти идеально сбалансированы (50 на 50 процентов кошек и собак).

При желании можно увеличить число эпох до 3-10. Вы увидите, что точность сильно возрастет. Однако сейчас, когда мы начинаем изучать производительность моделей, время обучения и моменты, когда его надо прекращать, намного целесообразней будет использовать графические процессоры.

В следующей статье мы покажем, как можно перенести данный код на графический процессор. Будет хорошо, если у вас есть доступ к нему, либо локально, либо в облаке (например Linode), так как это даст нам возможность гораздо быстрее производить обучение и тестирование. Но даже если у вас нет доступа к высокопроизводительному графическому процессору, вы все равно можете остаться с нами, просто у вас на это уйдет несколько больше времени. А вот если у вас не хватает терпения хотя бы на час для обучения модели, то, видимо, глубокое обучение — не для вас. Ведь даже на сверхмощных графических процессорах обучение некоторых моделей занимает дни, недели, а иногда и месяцы!

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