Недавно я создал свой первый классификатор изображений, чтобы разделить фотографии дочери на две категории: одни, на которых она улыбается, и другие. Это простая проблема бинарной классификации, но, имея очень мало доступных данных и будучи новичком во всем, например, в материнстве и машинном обучении, мне пришлось столкнуться с несколькими проблемами на пути. Мне очень понравилось работать над этим приложением, и хотелось бы думать, что я кое-что узнал в процессе. Было очень удобно использовать ядро ​​Kaggle, чтобы избавить меня от хлопот по настройке собственной локальной среды и получить доступ к графическому процессору, который был полезен при обучении моей модели. В этой статье я подробно расскажу о том, как создать это приложение, о проблемах, с которыми я столкнулся, и о том, как я их решил. Моя работа здесь адаптирована из этого замечательного поста, который демонстрирует, как классифицировать изображения собак и кошек - еще одну проблему бинарной классификации.

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

Зачем использовать трансферное обучение для этой модели? Вместо того, чтобы обучать модель с нуля для случайной инициализации, мы можем добиться гораздо более быстрого прогресса, если загрузим предварительно обученные веса и модели и перенесем их в нашу собственную модель. Один из способов включения этого метода - избавиться от конечного выходного слоя и создать наш собственный модуль, который выводит желаемую классификацию, в моем случае это класс улыбается или not_smiling. Учитываются параметры всех слоев предварительно обученной модели. заморожены, и вам просто нужно обучить параметры, связанные с вашим собственным конечным выходным слоем. Основная причина, по которой я использую трансферное обучение для своего приложения, заключается в том, что, используя предварительно обученные веса, я все еще могу стремиться создать приложение спуска с небольшим помеченным набором данных . В очень полезном курсе по сверточной нейронной сети Эндрю Нг я узнал, что у вас могут быть такие свойства, как «trainable_weights» и «freeze», которые позволяют соответствующим образом изменять ранние слои. Я использовал trainable = 0 для предварительно обученной базовой модели, чтобы не пересчитывать эти активации каждый раз, когда я беру эпоху или прохожу через обучающий набор. Вы также можете заморозить меньшее количество слоев и обучить последующие слои в дополнение к выходному слою. Таким образом, в зависимости от объема данных у вас нет. замороженных слоев могло быть меньше и нет. слоев, обученных поверх базовой модели, может быть больше. Для получения более подробной информации о трансферном обучении я нашел очень полезными мини-курс Kaggle по глубокому обучению Дэна Беккера и курс Эндрю Нг по сверточной нейронной сети на Coursera.

Я начинаю свое приложение с импорта всех пакетов, которые собираюсь использовать.

import cv2
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
%matplotlib inline
import gc
import os
import random
import seaborn as sns
sns.set()
from keras import backend as K

С самого начала я решил использовать для этой задачи Keras flow_from_directory (). Я создавал свой собственный набор данных и мог легко организовать свой каталог обучающих данных, чтобы он содержал подкаталоги для каждого класса изображений (необходимое условие для flow_from_directory ()). Однако при попытке создать такие же подкаталоги в папке ввода в Kaggle возникла кратковременная сбой. Я попробовал несколько способов создать структуру папок под входной папкой и попытаться загрузить изображения, но безрезультатно (проблемы для новичков!). Но вы можете сделать это очень легко в Kaggle. Чтобы сохранить структуру каталогов в наборе данных Kaggle, просто заархивируйте файлы и загрузите сжатый файл. Вы можете увидеть мою структуру папок в соседнем подкаталоге image-one для каждого класса. Классы в этом случае улыбаются, а не улыбаются. «Улыбка» содержала все фотографии, на которых моя дочь улыбается, а все остальные изображения были помещены в папку «Не улыбается». Поскольку у меня было очень мало данных и я все время пытался добавлять к ним при одновременном кодировании, я решил не создавать отдельную папку проверки, содержащую два класса. Вместо этого я использовал train_test_split и зарезервировал 20% для проверки. Таким образом, я мог просто добавить свои новые изображения в учебные классы и разделить их позже, не беспокоясь о случайном изменении процента разделения.

train_smiling_dir='../input/smile-detection-dataset/smile-detection/train/smiling/'
train_not_smiling_dir='../input/smile-detection-dataset/smile-detection/train/not-smiling/'
test_dir='../input/smile-detection-dataset/smile-detection/test/'

У меня было около 400 фотографий моей дочери, которые я мог использовать для обучения модели. Изначально я столкнулся с проблемой классового дисбаланса, но я смог быстро исправить это, сделав больше фотографий улыбающейся дочери - да, я знаю! Я выбрал легкий путь. Одна из моих ближайших целей в ближайшем будущем - поработать над настраиваемой функцией взвешенных потерь для решения этой проблемы.

image_size = 448

Я немного поигрался с image_size, попробовав 150 224 и, наконец, остановился на 448. Это дало мне лучшие результаты.

def create_img_name_list(path):
    listOfImages=[path+'{}'.format(i) for i in os.listdir(path)]
    return listOfImages

Я создал функцию create_img_name_list, которая принимает путь и возвращает список, содержащий имена файлов в каталоге.

#getting train and test images
train_smiling=create_img_name_list(train_smiling_dir)
train_not_smiling=create_img_name_list(train_not_smiling_dir)
test_imgs=create_img_name_list(test_dir)
train_imgs = train_smiling+train_not_smiling   
random.shuffle(train_imgs)
del train_smiling
del train_not_smiling
gc.collect()

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

#Viewing some images in our train images
import matplotlib.image as mpimg
for key in list(train_imgs)[0:4]:
    img=mpimg.imread(key)
    imgplot=plt.imshow(img)
    plt.show()

Затем я просмотрел некоторые изображения, загруженные в обучающий набор, и заметил, что изображения имеют разные размеры. Это главным образом потому, что в моем стремлении получить больше данных я просмотрел все видео о моей дочери и сделал скриншоты. В некоторых случаях мне даже приходилось вырезать мужа или лицо, чтобы не запутать модель. В результате получались изображения самых разных размеров. Приносим извинения за растачивание акварели! Я не знаю, видно ли это из соседних изображений, но эти 2 изображения (Образцы изображений 1 и 2 из обучающего набора) имеют очень разные размеры - второе больше первого. Не беспокойтесь, здесь в игру вступает моя следующая функция read_and_process_image.

#Resizing images
nrows=image_size
ncolumns=image_size
channels=3

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

#Reading and processing image to an acceptable format for our model
def read_and_process_image(train_images):
    """""
    Returns
    X:array of resized images
    y:array of labels    
    """""
    X=[]
    y=[]
    for img in train_images:
       try:
        #for image in list_of_images:
        X.append(cv2.resize(cv2.imread(img,cv2.IMREAD_COLOR),(nrows,ncolumns),interpolation=cv2.INTER_CUBIC))
        #get the labels
        if 'not-smiling' in img:
            y.append(0)
        elif 'smiling' in img:
            y.append(1)
       except Exception as e:
        print(e)
               
    return X,y

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

Если вы помните, я ранее создавал списки с полным путем к изображениям, с которыми мне нужно было бы работать. Поскольку моя структура каталогов содержала папки «улыбается» и «not_smiling», у меня есть эти тексты в именах изображений, принадлежащих к соответствующие классы. Я использую это, чтобы создать свой список y, присвоив 0 изображениям со словом «не улыбается» в их именах и 1 изображениям со словом «улыбается» в их именах.

X,y=read_and_process_image(train_imgs)

Я вызываю функцию read_and_process_image для создания массивов X и Y. Ниже приведены некоторые изображения после обработки.

#Lets view some of the pics
plt.figure(figsize=(20,10))
columns = 5
for i in range(columns):
    plt.subplot(5 / columns + 1, columns, i + 1)
    plt.imshow(X[i])

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

import seaborn as sns
del train_imgs
gc.collect()
#Convert list to numpy array
X = np.array(X)
y = np.array(y)
sns.countplot(y)
plt.title('Labels for Smiling and Not-Smiling')

Затем я разделил свой набор данных для обучения на подмножества для обучения и проверки. Вы помните, что я не создавал набор данных для проверки до этого момента, потому что я постоянно добавлял изображения в свои папки и не хотел ошибаться относительно того, сколько я добавил для обучения и сколько для проверки. Я использовал train_test_split sklearn, чтобы назначить 20% моего набора данных для проверки. Вот код того же.

#Lets split the data into train and test set
from sklearn.model_selection import train_test_split
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.20, random_state=2)

После создания приведенных выше списков обучения и проверки X и y больше не требуются. Я также назначил на этом этапе batch_size, который потребуется для нашей модели. Что касается batch_size, я поигрался со значениями 8, 16 и 32 и увидел, что 32 дает наилучшие результаты, поэтому остановился на этом. В какой-то момент, когда я назначил только 10% изображений моему подмножеству проверки, я получал ошибку «журналы локальной переменной», на которые ссылались перед назначением. Это произошло потому, что размер моего набора данных проверки был меньше, чем размер пакета, который я затем быстро исправил, изменив процент изображений, назначенных набору проверки.

if K.image_data_format() == 'channels_first':
    X_train = X_train.reshape(X_train.shape[0], channels, nrows, ncolumns)
    #X_test = X_test.reshape(X_test.shape[0], channels, img_rows, img_cols)
    input_shape = (channels, nrows, ncolumns)
else:
    X_train = X_train.reshape(X_train.shape[0], nrows, ncolumns, channels)
    #X_test = X_test.reshape(X_test.shape[0], nrows, ncolumns, channels)
    input_shape = (nrows, ncolumns, channels)

Я напечатал K.image_data_format (), чтобы проверить, какое значение было в моем случае, и он показал каналы_последний. Следовательно, в моем сценарии используется input_shape (nrows, ncolumns, channels). Согласно этой очень полезной ссылке, которая углубляется в предмет - «Каналы последними: данные изображения представлены в трехмерном массиве, где последний канал представляет собой цветовые каналы, например [строки] [столбцы] [каналы]. "

Затем я использовал пакет keras для предварительно обученной модели InceptionResNetV2 с imagenet в качестве весов. Imagenet - это большая база данных изображений, используемая для распознавания изображений. При создании экземпляра модели keras веса загружаются автоматически (ссылка), и модель обучается с использованием набора данных imagenet. Я тоже пробовал модель Resnet50, но получил очень плохие результаты для своего сценария (точность около 40%). Поэтому решил придерживаться модели InceptionResNetV2. Назначенный здесь input_shape должен быть таким же, как input_shape ваших измененных изображений, то есть форма моих изображений после того, как я запустил его через свою функцию read_and_process_image.

from keras.applications import InceptionResNetV2
conv_base = InceptionResNetV2(weights='imagenet', include_top=False, input_shape=input_shape)

Затем я создал последовательную модель, добавил предварительно обученную модель InceptionResNetV2 и настроил верхний уровень.

from keras import layers
from keras import models
model = models.Sequential()
model.add(conv_base)
model.add(layers.Flatten())
model.add(layers.Dense(256, activation='relu'))
model.add(layers.Dense(1, activation='sigmoid'))  
model.summary()

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

После этого я напечатал нет. обучаемых весов до и после я присваиваю trainable = False предварительно обученному слою. Поскольку я использую предварительно обученную сеть, веса для базового слоя уже изучены, поэтому я решил не обучать ее снова.

print('Number of trainable weights before freezing the conv base:', len(model.trainable_weights))
conv_base.trainable = False
print('Number of trainable weights after freezing the conv base:', len(model.trainable_weights))

Нет. весов до замораживания составляет 492 (в основном веса из conv_base), а после замораживания нет. of trainable_weights равно 4.

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

model.compile(loss='binary_crossentropy', optimizer='adam',metrics=['acc'])

Поскольку у меня не было достаточно данных, я знал, что мне нужно использовать увеличение изображения. Увеличение данных - один из методов, часто используемых для повышения производительности в системах компьютерного зрения. Я использовал ImageDataGenerator, который предоставляет основные методы увеличения данных, такие как диапазон поворота и горизонтальный переворот. Я особо не играл с гиперпараметрами при увеличении данных - еще один для списка дел.

#Lets create the augmentation configuration
#This helps prevent overfitting, since we are using a small dataset
from keras.preprocessing.image import ImageDataGenerator
from keras.preprocessing.image import img_to_array, load_img
train_datagen = ImageDataGenerator(rescale=1./255,   #Scale the image between 0 and 1
                                    rotation_range=40,
                                    width_shift_range=0.2,
                                    height_shift_range=0.2,
                                    shear_range=0.2,
                                    zoom_range=0.2,
                                    horizontal_flip=True,
                                    fill_mode='nearest')
val_datagen = ImageDataGenerator(rescale=1./255)  #We do not augment validation data. we only perform rescale
#Create the image generators
train_generator = train_datagen.flow(X_train, y_train,batch_size=batch_size)
val_generator = val_datagen.flow(X_val, y_val, batch_size=batch_size)
#The training part
#Training for 25 epochs with about 9 steps per epoch
history = model.fit_generator(train_generator,
                              steps_per_epoch=ntrain // batch_size,
                              epochs=25,
                              validation_data=val_generator,
                              validation_steps=nval // batch_size,
                              verbose=1)

Я получил точность около 84% за 25 эпох. Учитывая объем предоставленных данных, я очень доволен результатами.

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

#Now lets predict on the images of the test set
X_test, y_test = read_and_process_image(test_imgs)
x = np.array(X_test)
test_datagen = ImageDataGenerator(rescale=1./255)
i = 0
columns = 5
text_labels = []
plt.figure(figsize=(30,20))
for batch in test_datagen.flow(x, batch_size=1):
    pred = model.predict(batch)
    if pred > 0.5:
        text_labels.append('Smile detected!')
    else:
        text_labels.append('No smiles here.')
    plt.subplot(5 / columns + 1, columns, i + 1)
    plt.title(text_labels[i])
    imgplot = plt.imshow(batch[0])
    i += 1
    if i % 10 == 0:
        break
plt.show()

После запуска модели я получаю точность на 9 из 10 изображений. Вчера вечером я также запустил несколько измененных акварельных изображений и вуаля! это тоже сработало. Также неплохо было бы таким образом увеличить свой набор тренировочных данных. Я видел, что изображения, в которых есть несколько объектов в кадре или где моя дочь находится дальше, обычно оказываются неудачными. Со временем я думаю, что собираюсь добавить больше данных и сделать классы более сбалансированными, добавить настраиваемую функцию потерь и продолжить работу над оптимизацией гиперпараметров для точной настройки моей модели. Я также планирую расширить свое приложение и сделать его мультиклассовым классификатором, обнаруживать больше эмоций - вопли, голод, нюхание носа и т. Д. Если больше ничего не получится, сбор этих данных определенно будет забавным! Я хотел бы услышать ваши предложения, если у вас есть какие-либо способы улучшения этой модели для получения лучших результатов.

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

print(os.listdir("../input"))

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

Спасибо, что остались со мной и прочитали мое путешествие по написанию моей первой забавной модели нейронной сети.