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

Введение

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

Функция свертки — это композиция двух функций, в которой функция ядра работает с выходом другой функции, называемой входной функцией. В самом простом смысле операции свертки преобразуют входную функцию с помощью функции ядра. Один из примеров функции свертки показан ниже с помощью двумерного изображения, размытого функцией ядра, которая представлена ​​двумерным ядром размером 10x10.

Эти функции свертки изображений широко используются во многих программах обработки изображений. Задумайтесь на минутку: нам даны изображение и функция ядра, и мы получаем новое изображение посредством математической операции с двумя сетками. Если к одному и тому же изображению мы применим другое ядро, мы получим другое выходное изображение. . Точная разница изображена ниже для гауссовского ядра и ядра Собеля:

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

Итак, мы были бы правы, если бы сказали, что два ядра обработали и выделили разные «особенности» одного и того же изображения. Ядро Гаусса размыло детали изображения, а Собел дал только те детали, где есть края исходного изображения. Это свойство ядер, которое используют функции свертки, и выходное изображение часто называют картой признаков исходного входного изображения, поскольку оно часто содержит выделенную конкретную особенность исходного изображения.

Теперь давайте на мгновение переключим внимание на нейронную сеть. В чем именно заключается работа нейронной сети? это значит учиться на данных, которые мы предоставляем, и делать экстраполяции о какой-то невидимой части данных. Что именно нейронная сеть изучает на основе данных, она ищет в данных некоторые полезные функции. Можем ли мы использовать функцию свертки, чтобы помочь сети выделить важные функции и сделать прогнозы на основе этих данных? Конечно, мы можем! Именно это и собирается делать сверточная сеть.

Математические детали сверточной нейронной сети.

Слой свертки

давайте займемся математикой и создадим структуру для нашей нейронной сети. Сначала нам нужно посмотреть, как выглядят наши входные данные, и для этого нам нужно выбрать набор данных, давайте возьмем простой: набор цифровых данных MNIST, набор данных с полутоновыми изображениями рукописных цифр размером 28x28, каждое изображение содержит метку. цифра, которую она представляет, ее курируют люди, и она довольно надежна :)

Уравнение ниже показывает, как выглядит наша функция свертки для изображений MNIST:

Приведенное выше уравнение описывает, как одно изображение будет обрабатываться ядром фиксированного размера.

Слои активации и объединения

После извлечения соответствующей карты объектов из изображения нам нужна какая-то функция активации, которая поможет нашей модели отображать нелинейные функции в данных. Активация — это простая функция, которая выдает на выходе 0 или 1 в зависимости от входного сигнала. Для нашей модели мы будем использовать функцию активации ReLu. Это описывается следующим уравнением и графиком.

Функция ReLu хороша тем, что она быстрее вычисляется и позволяет активировать подмножество нейронов.

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

Пул можно понять с помощью следующей диаграммы изображений.

объединенное изображение более размыто, поскольку каждый пиксель этого изображения содержит максимум окружающих пикселей. Кроме того, размеры изображения также были уменьшены на 1 пиксель в каждом измерении.

Полностью связанные слои

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

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

Вот упрощенная модель слоя прямой связи. X1 и X2 — это скрытые нейроны, которые подключаются к выходному слою. Происходит активация, связанная как с нейронами скрытого слоя, так и с выходными нейронами.

Имея всю эту информацию, приступим к строительству!!

Реализация сверточной нейронной сети с использованием pytorch

Pytorch — это среда машинного обучения, разработанная Meta (ранее Facebook). Это простая и удобная среда для обучения от базовых до готовых к производству моделей машинного обучения.

Помимо этого, нам также понадобится вспомогательная библиотека trochvision, которая поможет нам работать с набором данных mnist digit.

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

import torch
import torchvision 
from torch.utils.data import DataLoader

def load_mnist(root, batch_size, transformation, shuffle = True): 

    train_data = torchvision.datasets.MNIST(root, train = True, download= True, transform= transformation) 

    train_loader = DataLoader(train_data, batch_size, shuffle) 

    test_data = torchvision.datasets.MNIST(root, train = False, download= False, transform= transformation) 

    test_loader = DataLoader(test_data, batch_size, shuffle) 

    return train_loader, test_loader

Поскольку набор обучающих данных представляет собой очень большой (около 60 000) изображений, будет сложно хранить такое количество образцов в памяти, поэтому мы будем загружать данные пакетами и обучать модель на этих небольших пакетах. Здесь API DataLoader от pytorch поможет нам сделать это.

Загрузчик данных берет наш набор обучающих данных и возвращает генератор, который будет выдавать небольшие порции данных. Размер партии определяется нами. Это также позволяет нам применять преобразования к входному изображению. Мы будем применять преобразование для преобразования изображения PIL в тензор Pytorch во время обучения модели. Несколько преобразований также можно составить с помощью функции Torchvision.transfroms.Compose.

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

давайте посмотрим на образец изображения,

import matplotlib.pyplot as plt 

from data_utils import load_mnist 
import torchvision 

# we use the transformation to direcly get the tensor from data loader. 
transformation = torchvision.transforms.Compose([torchvision.transforms.ToTensor()])

_, testloader = load_mnist(root= "./data", batch_size = 1, transformation= transformation) 

data = next(iter(testloader)) 

inputs, labels = data 

sample_image = inputs[0].numpy()[0]

plt.imshow(sample_image) 
plt.show() 

Давайте посмотрим на форму тензора, описывающего изображение.

import matplotlib.pyplot as plt 

from data_utils import load_mnist 
import torchvision 

transformation = torchvision.transforms.Compose([torchvision.transforms.ToTensor()])

_, testloader = load_mnist(root= "./data", batch_size = 1, transformation= transformation) 

data = next(iter(testloader)) 

inputs, labels = data 

print(inpus) 
output: 
torch.Size([1, 1, 28, 28])

Вышеупомянутый тензор имеет форму 1 x 1 x 28 x28. Это означает, что существует одно изображение с одним каналом и высотой и шириной 28 и 28 пикселей.

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

Мы определим класс, в котором будет размещена наша модель и связанные с ней функции обучения и тестирования модели.

import torch.nn as nn 
import torch 


class Model(nn.Module): 
    
    def __init__(self, num_channels, kernel_size, pool_size) -> None:
        super().__init__()

        self.conv1 = nn.Conv2d(1, num_channels, kernel_size = kernel_size, stride = 1, padding = 'same') 
        self.act1 = nn.ReLU() 
        self.pool1 = nn.MaxPool2d(pool_size, stride = 1)

        self.conv2 = nn.Conv2d(num_channels,num_channels, kernel_size = kernel_size, stride = 1, padding = 'same') 
        self.act2 = nn.ReLU() 
        self.pool2 = nn.MaxPool2d(pool_size, stride = 1) 
        
        self.flatten = nn.Flatten() 

        self.fc1 = nn.Linear(num_channels*26*26, 120) 
        self.act_fc1 = nn.ReLU() 
        self.fc2 = nn.Linear(120, 84) 
        self.act_fc2 = nn.ReLU() 
        self.fc3 = nn.Linear(84, 10) 

        self.sm = nn.Softmax(dim = 1) 

    def forward(self, x): 
        x = self.pool1(self.act1(self.conv1(x))) 

        x = self.pool2(self.act2(self.conv2(x)))

        x = self.flatten(x)

        x = self.act_fc1(self.fc1(x)) 
        x = self.act_fc2(self.fc2(x)) 

        x = self.fc3(x) 

        return x
    
    def predict(self, x): 

        output = self.forward(x) 

        return self.sm(output) 
    
    def model_train(self, loss_func, optimizer, train_data_loader): 

        total_training_loss = 0 
        
        for i, data in enumerate(train_data_loader): 

            inputs, labels = data 

            optimizer.zero_grad() 

            outputs = self.forward(inputs) 

            training_loss = loss_func(outputs, labels) 

            training_loss.backward() 

            optimizer.step() 

            total_training_loss += training_loss.item() 

        return total_training_loss

    def model_test(self, test_loader): 

        classified = 0 
        total_inputs = 0 
        for i, data in enumerate(test_loader): 

            inputs, labels = data 

            outputs = self.predict(inputs) 

            _, predictions = torch.max(outputs, dim = 1) 

            classified += sum(map(int, torch.eq(labels, predictions)))

            total_inputs += len(labels) 

        accuracy = float(classified / total_inputs) 

        return accuracy 

Мы определяем архитектуру нашей модели в функции __init__, которая выглядит следующим образом:

def __init__(self, num_channels, kernel_size, pool_size) -> None:
        super().__init__()

        self.conv1 = nn.Conv2d(1, num_channels, kernel_size = kernel_size, stride = 1, padding = 'same') 
        self.act1 = nn.ReLU() 
        self.pool1 = nn.MaxPool2d(pool_size, stride = 1)

        self.conv2 = nn.Conv2d(num_channels,num_channels, kernel_size = kernel_size, stride = 1, padding = 'same') 
        self.act2 = nn.ReLU() 
        self.pool2 = nn.MaxPool2d(pool_size, stride = 1) 
        
        self.flatten = nn.Flatten() 

        self.fc1 = nn.Linear(num_channels*26*26, 120) 
        self.act_fc1 = nn.ReLU() 
        self.fc2 = nn.Linear(120, 84) 
        self.act_fc2 = nn.ReLU() 
        self.fc3 = nn.Linear(84, 10) 

        self.sm = nn.Softmax(dim = 1) 

Наша модель имеет 2 слоя свертки, каждый слой свертки связан со слоем объединения и слоем активации. Затем у нас есть слой сглаживания, который сглаживает выходные данные для слоев прямой связи. В слое прямой связи есть 2 скрытых слоя и один выходной слой с 10 нейронами для 10 возможных классов цифр.

Мы используем дополнительную функцию, называемую функцией softmax. Эта функция преобразует выходные данные последнего слоя в вероятности, что имеет смысл, поскольку наша модель пытается классифицировать изображения по различным ячейкам соответствующих цифр, поэтому наличие вероятностей в качестве выходных данных покажет, насколько достоверна наша модель имеет в определенном изображении принадлежность к определенному «бину» цифр.

Это определяет нашу архитектуру модели, давайте теперь определим прямой поток модели в другой функции:

    def forward(self, x): 
        x = self.pool1(self.act1(self.conv1(x))) 

        x = self.pool2(self.act2(self.conv2(x)))

        x = self.flatten(x)

        x = self.act_fc1(self.fc1(x)) 
        x = self.act_fc2(self.fc2(x)) 

        x = self.fc3(x) 

        return x

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

# defined in the Model class. 

def model_train(self, loss_func, optimizer, train_data_loader): 

        total_training_loss = 0 
        
        for i, data in enumerate(train_data_loader): 

            inputs, labels = data 
            
            # optimizer function is stochastic gradient descent
            optimizer.zero_grad() 

            outputs = self.forward(inputs) 
            
            # loss function is cross entropy loss 
            training_loss = loss_func(outputs, labels) 

            training_loss.backward() 

            optimizer.step() 

            total_training_loss += training_loss.item() 

        return total_training_loss

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

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

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

Мы отслеживаем общие потери обучения за одну эпоху обучения. Мы берем генератор загрузчика данных, получаем пакет входных данных, обнуляем градиенты, чтобы убедиться, что ничего не осталось от предыдущих раундов обучения, передаем входные данные модели. Затем мы вычисляем потери, используя предоставленную нами функцию loss_func (в данном случае мы используем потери Cross_entropy), выполняем обратное распространение для обновления параметров, а затем выполняем шаг оптимизации градиента. Наконец, мы добавляем потери обучения текущей партии к общим потерям обучения и возвращаем общие потери обучения в конце 1 эпохи.

Определив функцию обучения, давайте также напишем тестовую функцию. Эта функция будет перебирать тестовые данные и отслеживать правильно классифицированные изображения. Затем мы будем использовать это число для расчета точности модели на протяжении последовательных итераций обучения. Также давайте назовем каждую итерацию обучения эпохой.

# defined in the Model class 
def model_test(self, test_loader): 

        classified = 0 
        total_inputs = 0 
        for i, data in enumerate(test_loader): 

            inputs, labels = data 

            outputs = self.predict(inputs) 

            _, predictions = torch.max(outputs, dim = 1) 

            classified += sum(map(int, torch.eq(labels, predictions)))

            total_inputs += len(labels) 

        accuracy = float(classified / total_inputs) 

        return accuracy 

Теперь мы готовы написать сценарий поезда, который объединит все элементы.

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

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

Теперь мы подошли к важному шагу: прекратить обучение модели!! Особенность методов градиентного спуска в том, что по мере приближения к глобальному минимуму шаг, который делает алгоритм для оптимизации потерь, становится все меньше и меньше, следовательно, мы не достигаем точно глобального минимума, но очень-очень близки к нему в ходе обучения. . Возникает вопрос: что заставляет нас остановиться, насколько близко это достаточно близко?

Мы будем использовать метод, называемый ранней остановкой. Он включает в себя ведение текущей записи максимальной точности проверки заданной точности модели и остановку обучения, когда модель не может достичь или превысить максимальное число точности после определенного фиксированного числа. итераций. Допустим, модель достигла максимальной точности 98% и в следующие 5 эпох она не сможет достичь или превзойти эти 98%, тогда мы прекратим обучение и сохраним параметры модели, которые дали точность 98%, как лучшие. обученная модель. Полный файл поезда выглядит примерно так:

import torch 
import torch.nn as nn 
import torchvision 
import torch.optim as optim 
from cnn_model import Model 
import os 
import matplotlib.pyplot as plt
import json 
import logging 
from data_utils import load_mnist 


# model_save, plot_save and hyper_parameter_save are helper function to savae: 
# model state
# training plots 
# hypter parameters used for training. 
def model_save(save_dir, model_state): 

    filename = "mnist_trained.pth"
    path = os.path.join(save_dir, filename) 

    torch.save(model_state, path) 

    logging.info("model_saved...") 

def plot_save(save_dir, loss_vec, accuracy_vec): 

    plt.plot(range(len(loss_vec)), loss_vec, color = "blue", marker = 'o', label = "training error") 
    plt.grid() 
    plt.xlabel("no of epochs") 
    plt.ylabel("training error") 
    plt.title("convergence of training error") 
    plt.savefig(os.path.join(save_dir, "model training loss.png"))  
    plt.close() 

    plt.plot(range(len(accuracy_vec)), accuracy_vec, color = "red", marker = 'o', label = "test set accuracy") 
    plt.grid() 
    plt.xlabel("no of epochs")
    plt.ylabel("test accuracy") 
    plt.title("model accuracy convergence") 
    plt.savefig(os.path.join(save_dir, "model_accuracy_score.png")) 
    plt.close() 

    logging.info("loss and accuracy plots saved.")

def hyper_parameter_save(save_dir, hyper_params): 

    hyper_param_file = os.path.join(save_dir, "hyperparameters.json") 


    with open(hyper_param_file, 'w') as file: 
        json.dump(hyper_params, file, indent = 4) 
    
    logging.info("model hyperparamters saved") 

# its a wrapper function to save all the important information from a single training session
def session_save(model, save_dir, loss_vec, accuracy_vec, hyper_parameters): 

    logging.info("making the save directory") 
    try: 
        os.mkdir(save_dir)
    except FileExistsError: 
        logging.warning(f"{save_dir} already exists, saving the files. ")

    model_save(save_dir, model) 

    plot_save(save_dir,loss_vec, accuracy_vec) 

    hyper_parameter_save(save_dir, hyper_parameters) 


####################################################################################################
if __name__ == '__main__': 

    logging.basicConfig(level = logging.INFO)

    logging.info("loading the data and building model...")

    transformation = torchvision.transforms.ToTensor() 

    train_loader, test_loader = load_mnist(root = "./data",
                                          batch_size=64,
                                          transformation= transformation) 

    hyper_parameters = {
        "num_channels": 10,
        "kernel_size": 3,
        "pool_size": 2,
        "learning_rate": 0.01, 
        "momentum": 0.8, 
        "max_epochs": 100, 
        "accuracy_increment_count": 5,
        "max_accuracy": 0.0
    }


    model= Model(num_channels= hyper_parameters["num_channels"],
                kernel_size=hyper_parameters['kernel_size'],
                pool_size=hyper_parameters['pool_size'])


    loss = nn.CrossEntropyLoss() 

    optimizer = optim.SGD(model.parameters(), lr = hyper_parameters["learning_rate"],
                          momentum = hyper_parameters["momentum"]) 


    n_epochs = 0 
    per_epoch_loss = [] 
    per_epoch_accuracy = [0.0] 
    accuracy_count = 0 

    max_accuracy_state = {} 

    logging.info("starting training...") 

    while accuracy_count < hyper_parameters["accuracy_increment_count"] and n_epochs < hyper_parameters["max_epochs"]:

        logging.info(f"starting epoch {n_epochs}")

        #training 
        total_training_loss = model.model_train(loss,
                                          optimizer,
                                          train_loader) 
        
        logging.info(f"epoch {n_epochs} training done.. model loss.. {total_training_loss}")

        per_epoch_loss.append(total_training_loss) 

        # testing 
        accuracy = model.model_test(test_loader) 

        logging.info(f"model testing done.. model accuracy.. {accuracy}")

        if accuracy <= hyper_parameters["max_accuracy"]:
            accuracy_count += 1
        else: 
            accuracy_count = 0 
            hyper_parameters["max_accuracy"] = accuracy 

            max_accuracy_state = model.state_dict() 

        per_epoch_accuracy.append(accuracy) 

        logging.info(f"max accuracy reached: {hyper_parameters['max_accuracy']}")

        n_epochs += 1 
    
    logging.info("training done... saving the model and plots") 

    save_dir = "./trained_model"

    session_save(max_accuracy_state, save_dir, per_epoch_loss, per_epoch_accuracy, hyper_parameters) 

    logging.info("execution complete")

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

Заключение

В этом посте мы узнали об архитектуре сверточной нейронной сети, для чего она используется и как работает. Мы говорим, что такое операция свертки и как она помогает выделить различные особенности входного изображения. Мы построили нашу собственную сеть с помощью pytorch и обучили ее на наборе классификационных данных MNIST. Достигнутая модель точность также была довольно высокой — около 98%.

Рекомендации

Глубокое обучение, Ян Гудфеллоу, Йошуа Бенджио, Арон Курвиль

Вы можете найти базу кода модели в этом репозитории GitHub.