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

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

Постановка задачи

У меня есть довольно небольшой набор данных изображений собак и набор данных изображений людей, любезно предоставленный Udacity для этой цели. Я хочу создать небольшое приложение, которое принимает изображение в качестве входных данных и возвращает одно из следующих сообщений:

  • если это изображение собаки, верните предсказанную породу
  • если это изображение человека, верните его наиболее напоминающую породу собаки
  • если это не собака и не человек, вернуть сообщение об ошибке

Обнаружение собак и людей

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

Реализация детектора человеческого лица

Я использовал один из предварительно обученных детекторов лиц OpenCV, который можно легко извлечь из .xml, предоставленного одной командой:

import cv2
face_cascade = cv2.CascadeClassifier('haarcascades/haarcascade_frontalface_alt.xml')

Детектор лиц написан в несколько строк кода, так как данные требуют небольшой предварительной обработки.

def face_detector(img_path):
    img = cv2.imread(img_path)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    faces = face_cascade.detectMultiScale(gray)
    return len(faces) > 0

функция выше делает следующее:

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

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

from glob import glob
import numpy as np
# load filenames for human and dog images
human_files = np.array(glob("/data/lfw/*/*"))
dog_files = np.array(glob("/data/dog_images/*/*/*"))

И оцените его с помощью следующего кода:

from tqdm import tqdm
human_files_short = human_files[:100]
dog_files_short = dog_files[:100]

detected_faces_list = []
for x in human_files_short:
    detected_faces_list.append(face_detector(x))
print("detected Faces in human files: " + str(sum(detected_faces_list)))
detected_faces_list = []
for x in dog_files_short:
    detected_faces_list.append(face_detector(x))
print("detected Faces in dog files: "+str(sum(detected_faces_list))

В наборе данных изображений человека было обнаружено 98% человеческих лиц, что является высокой точностью. Количество ложных срабатываний составляет 17% человеческих лиц, обнаруженных в наборе данных изображений собак, что соответствует нашему варианту использования.

Реализация детектора собак

Для обнаружения собак мы будем использовать предварительно обученную сеть VGG16, предоставленную Torchvision.
Сеть предварительно обучена в базе данных ImageNet и может быть создана с помощью этого фрагмента кода:

import torch
import torchvision.models as models
# define VGG16 model
VGG16 = models.vgg16(pretrained=True)

Чтобы сделать прогноз по изображению, нам нужно:

  • преобразовать его в тензор с теми же размерностями, что и входная размерность нашей сети.
  • передать его в предварительно обученную сеть
  • преобразовать выходной тензор в классификатор с наивысшей достоверностью
from PIL import Image
import torchvision.transforms as transforms
import torchvision.datasets as datasets
import os
import numpy as np
def VGG16_predict(img_path):
    '''
    Use pre-trained VGG-16 model to obtain index corresponding to 
    predicted ImageNet class for image at specified path
    
    Args:
        img_path: path to an image
        
    Returns:
        Index corresponding to VGG-16 model's prediction
    '''
    
    ## Load and pre-process an image from the given img_path
    transform = transforms.Compose([transforms.Resize(256), 
                                    transforms.CenterCrop(224), 
                                    transforms.ToTensor(), 
                                    ])
    data = Image.open(img_path)
    data = transform(data)
    data = np.expand_dims(data, 0)
    
    
    data = torch.from_numpy(data)
    ## Return the *index* of the predicted class for that image
    prediction = VGG16(data)
    return torch.max(prediction,1)[1].numpy()[0]  # predicted class index

Глядя на классификационный словарь, мы видим, что изображения собак варьируются от 151 до 268, поэтому мы можем сделать детектор собак, используя наш предиктор VGG16 со следующей функцией:

### returns "True" if a dog is detected in the image stored at img_path
def dog_detector(img_path):
    prediction = VGG16_predict(img_path)
    is_dog = prediction>=151 and prediction<=268
    return  is_dog

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

91% точность обнаружения собак и 0(!) ложных срабатываний, что удивительно.

Создание CNN для классификации изображений с нуля

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

transformations_training = transforms.Compose([transforms.Resize(256), transforms.RandomCrop(224),transforms.RandomRotation(20),transforms.RandomHorizontalFlip(p=0.5) ,  transforms.ToTensor(), 
                                     transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])])
transformations_validation_test = transforms.Compose([transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), 
                                     transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])])
training_set = datasets.ImageFolder(root='/data/dog_images/train' ,
transform = transformations_training)  
validation_set = datasets.ImageFolder(root='/data/dog_images/valid' ,
transform = transformations_validation_test) 
test_set = datasets.ImageFolder(root='/data/dog_images/test' ,
transform = transformations_validation_test) 
train = torch.utils.data.DataLoader(training_set, batch_size = 32, shuffle = True)
test= torch.utils.data.DataLoader(test_set, batch_size = 32, shuffle = False)
valid= torch.utils.data.DataLoader(validation_set, batch_size = 32, shuffle = True)
loaders_scratch = {'train':train, 'test':test, 'valid':valid}

Далее нам нужно построить нашу CNN. Я ориентировался на архитектуру VGG16:

import torch.nn as nn
import torch.nn.functional as F
class Net(nn.Module):
    ### TODO: choose an architecture, and complete the class
    def __init__(self):
        super(Net, self).__init__()
        ## Define layers of a CNN
        self.first_conv_layer_output =64 
        self.second_conv_layer_output = self.first_conv_layer_output*2
        self.last_conv_layer_output = self.second_conv_layer_output*2
        self.conv1 = nn.Conv2d(3, self.first_conv_layer_output, 3, padding = 1)
        self.conv2 = nn.Conv2d(self.first_conv_layer_output, self.second_conv_layer_output, 3, padding = 1)
        self.conv3 = nn.Conv2d(self.second_conv_layer_output, self.last_conv_layer_output, 3, padding = 1)
        
        self.pool = nn.MaxPool2d(2, 2)
        
        self.fc1 = nn.Linear(((224/2/2/2)**2)*self.last_conv_layer_output, 1024)
        self.fc2 = nn.Linear(1024,512)
        self.fc3 = nn.Linear(512, 133)
    
        self.dropout = nn.Dropout(0.25)
    def forward(self, x):
        ## Define forward behavior
        x = self.pool(F.relu(self.conv1(x)))
        x = self.pool(F.relu(self.conv2(x)))
        x = self.pool(F.relu(self.conv3(x)))
        
        x = x.view(-1, ((224/2/2/2)**2)*self.last_conv_layer_output)
        x = self.dropout(F.relu(self.fc1(x)))
        x = self.dropout(F.relu(self.fc2(x)))
        x = self.fc3(x)
        return x
#-#-# You so NOT have to modify the code below this line. #-#-#
# instantiate the CNN
model_scratch = Net()
print(model_scratch)
use_cuda = torch.cuda.is_available()
# move tensors to GPU if CUDA is available
if use_cuda:
    model_scratch.cuda()

Для обучения CNN используется следующая функция:

from PIL import ImageFile
import numpy as np
ImageFile.LOAD_TRUNCATED_IMAGES = True # One image is truncated and better use it than writing "continue" exception handling
def train(n_epochs, loaders, model, optimizer, criterion, use_cuda, save_path):
    """returns trained model"""
    # initialize tracker for minimum validation loss
    valid_loss_min = np.Inf 
    scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=15, gamma=0.5)
    for epoch in range(1, n_epochs+1):
        # initialize variables to monitor training and validation loss
        train_loss = 0.0
        valid_loss = 0.0
        
        ###################
        # train the model #
        ###################
        model.train()
        for batch_idx, (data, target) in enumerate(loaders['train']):
            # move to GPU
            if use_cuda:
                data, target = data.cuda(), target.cuda()
      
            optimizer_scratch.zero_grad()
            estimation = model(data)
            loss = criterion(estimation, target)
            loss.backward()
            optimizer.step()
            
            train_loss = train_loss + ((1 / (batch_idx + 1)) * (loss.data - train_loss))
        ######################    
        # validate the model #
        ######################
        model.eval()
        with torch.no_grad():
            for batch_idx, (data, target) in enumerate(loaders['valid']):
                # move to GPU
                if use_cuda:
                    data, target = data.cuda(), target.cuda()
            ## update the average validation loss
                estimation = model(data)
                loss = criterion(estimation, target)
                valid_loss = valid_loss + ((1 / (batch_idx + 1)) * (loss.data - valid_loss))
            
        # print training/validation statistics 
        print('Epoch: {} \tTraining Loss: {:.6f} \tValidation Loss: {:.6f}'.format(
            epoch, 
            train_loss,
            valid_loss
            ))
        scheduler.step()
        ## TODO: save the model if validation loss has decreased
        if valid_loss < valid_loss_min:
            torch.save(model.state_dict(), save_path)
            #save full model to resume training
            #does not work due to lack of space #sadface
#            torch.save({
#            'epoch': epoch,
#            'model_state_dict': model.state_dict(),
#            'optimizer_state_dict': optimizer.state_dict(),
#            'loss': criterion,
#            }, 'model_scratch_full.pth')
            valid_loss_min = valid_loss
    # return trained model
    return model
# train the model
model_scratch = train(30, loaders_scratch, model_scratch, optimizer_scratch, 
                      criterion_scratch, use_cuda, 'model_scratch.pt')
# load the model that got the best validation accuracy
model_scratch.load_state_dict(torch.load('model_scratch.pt'))

Я кратко расскажу о своей тренировочной функции. Его можно разделить на 3 части:

  1. Инициализация
  2. Обучение
  3. Проверка

Я инициализирую планировщик и параметры потерь, затем начинаю повторять свои эпохи.

valid_loss_min = np.Inf 
    scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=15, gamma=0.5)
    for epoch in range(1, n_epochs+1):
        # initialize variables to monitor training and validation loss
        train_loss = 0.0
        valid_loss = 0.0

Затем я устанавливаю свою модель в режим обучения и перебираю пакет данных:

  1. сбросить градиенты моего оптимизатора в начале новой партии
  2. сделать оценку с моей моделью
  3. вызовите функцию потерь, чтобы получить потерю этой партии
  4. рассчитать средний убыток
model.train()
        for batch_idx, (data, target) in enumerate(loaders['train']):
            # move to GPU
            if use_cuda:
                data, target = data.cuda(), target.cuda()

            optimizer_scratch.zero_grad()
            estimation = model(data)
            loss = criterion(estimation, target)
            loss.backward()
            optimizer.step()
            
            train_loss = train_loss + ((1 / (batch_idx + 1)) * (loss.data - train_loss))

После обучения со всеми партиями я проверяю свою сеть:

  1. установить сеть в режим проверки
  2. отключить корректировку градиентов оптимизатора
  3. делать пакетные оценки
  4. определить потери на партию
  5. рассчитать средний убыток
  6. если модель работает лучше, чем предыдущие итерации, я ее сохраню
model.eval()
        with torch.no_grad():
            for batch_idx, (data, target) in enumerate(loaders['valid']):
                # move to GPU
                if use_cuda:
                    data, target = data.cuda(), target.cuda()
            ## update the average validation loss
                estimation = model(data)
                loss = criterion(estimation, target)
                valid_loss = valid_loss + ((1 / (batch_idx + 1)) * (loss.data - valid_loss))
            
        # print training/validation statistics 
        print('Epoch: {} \tTraining Loss: {:.6f} \tValidation Loss: {:.6f}'.format(
            epoch, 
            train_loss,
            valid_loss
            ))
        scheduler.step()
            torch.save(model.state_dict(), save_path)

            valid_loss_min = valid_loss

Поскольку аппроксимация правильной скорости обучения довольно сложна, я решил использовать планировщик скорости обучения и начать с довольно высокой скорости обучения 0,06 и уменьшить ее вдвое через 15 эпох.

Закомментированная часть

#            torch.save({
#            'epoch': epoch,
#            'model_state_dict': model.state_dict(),
#            'optimizer_state_dict': optimizer.state_dict(),
#            'loss': criterion,
#            }, 'model_scratch_full.pth')

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

Я заканчиваю через 30 эпох и тестирую свою модель.

Точность 19% более чем в 25 раз лучше, чем случайный выбор, но этого недостаточно, чтобы сделать надежный прогноз, давайте попробуем перенести обучение.

Создание CNN с помощью трансферного обучения

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

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

Точность теста 86% — это очень хорошо, учитывая, что некоторые породы настолько похожи друг на друга, что их очень трудно различить даже человеку.

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

### TODO: Write a function that takes a path to an image as input
### and returns the dog breed that is predicted by the model.
# list of class names by index, i.e. a name can be accessed like class_names[0]
class_names = [item[4:].replace("_", " ") for item in loaders_transfer['train'].dataset.classes]
def predict_breed_transfer(img_path):
    #if torch.cuda.is_available():
       # model_transfer= model_transfer.cuda()
    # load the image and return the predicted breed
    #copy paste of vgg16 predict
    transform = transforms.Compose([transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(),transforms.Normalize([0.5, 0.5, 0.5],[0.5, 0.5, 0.5])])
    data = Image.open(img_path)
    data = transform(data)
    data = np.expand_dims(data, 0)
    data = torch.from_numpy(data)
    if use_cuda:
        data = data.cuda()
    model_transfer.eval()
## Return the *index* of the predicted class for that image
    prediction = model_transfer(data)
    prediction = prediction.cpu()
    class_index = torch.max(prediction,1)[1].numpy()[0]  # predicted class index
    return class_names[class_index]

Создайте приложение

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

def run_app(img_path):
    ## handle cases for a human face, dog, and neither
    print("==============================================")
    print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~")
    
    if dog_detector(img_path):
        predicted_dog = predict_breed_transfer(img_path)
        print("you are a " +predicted_dog)
    elif face_detector(img_path):
        predicted_dog = predict_breed_transfer(img_path)
        print("The dog breed that resembles you most is " +predicted_dog)
    else:
        print("Error")
    
    plt.imshow(Image.open(img_path))
    plt.show()
    print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~")
    print("==============================================")

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

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

19% Точность тестирования на нашей недавно построенной CNN превосходит угадывание в 25 раз, что, безусловно, является успехом, если принять во внимание наш небольшой набор данных и менее 1 часа обучения.
Менее 1 часа обучения для точности 86% с использованием трансферного обучения. поразительно, учитывая, что обучение VGG16 с нуля занимает больше недели с большей вычислительной мощностью, чем просто 1080 и больший набор данных.