SmartMailHack. Решение 1-го места в задаче классификации логотипов

в 19:29, , рубрики: cnn, data mining, deep learning, machine learning, python, классификация изображений, машинное обучение, хакатон

Две недели назад закончился проходивший в офисе Mail.Ru Group хакатон для студентов SmartMailHack. На хакатоне предлагался выбор из трех задач; статья от победителей во второй задаче уже есть на хабре, я же хочу описать решение нашей команды, победившей в первой задаче. Все примеры кода будут на Python & Keras (популярный фреймворк для deep learning).
image

Описание задачи

Задача заключалась в классификации логотипов различных компаний. Обучающий датасет состоял из 6139 изображений, размеченных на 161 класс (160 разных компаний + метка "other")

Примеры изображений с логотипами

SmartMailHack. Решение 1-го места в задаче классификации логотипов - 2
SmartMailHack. Решение 1-го места в задаче классификации логотипов - 3
SmartMailHack. Решение 1-го места в задаче классификации логотипов - 4
SmartMailHack. Решение 1-го места в задаче классификации логотипов - 5

Некоторые представители other

SmartMailHack. Решение 1-го места в задаче классификации логотипов - 6
SmartMailHack. Решение 1-го места в задаче классификации логотипов - 7
SmartMailHack. Решение 1-го места в задаче классификации логотипов - 8

SmartMailHack. Решение 1-го места в задаче классификации логотипов - 9
Распределение количества обучающих примеров по классам

Основных проблем с данными было две: во-первых, помимо обычных .jpeg и .png файлов, в датасете были и .svg, и .ico, и даже .gif:

Гифка из train/adobe

SmartMailHack. Решение 1-го места в задаче классификации логотипов - 10

Поскольку OpenCV читает только jpeg & png, а времени разбираться с другими библиотеками в рамках хакатона не было, мы пошли "в лоб" — с помощью ImageMagick сконвертировали все в jpeg, а от гифок оставили только первый кадр.
Вторая проблема — большой разброс изображений по размерам — была решена строчкой cv2.rescale(): тоже явно не лучший вариант, зато быстрый и рабочий.

def _load_sample(self, sample_path):
    # try to load all files with opencv
    image = cv2.imread(sample_path)
    if image is not None:
        shape = image.shape
        # normal 3-channel image
        if shape[-1] == 3:
            image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
        # grayscale image -> RGB
        if len(shape) == 2 or shape[-1] == 1:
            image = cv2.cvtColor(image, cv2.COLOR_GRAY2RGB)
    else:
        tqdm.write(f"Failed to load {sample_path}")
    return image

 def _prepare_sample(self, image):
    image = cv2.resize(image, (RESCALE_SIZE, RESCALE_SIZE))
    return image

Методы из класса ImageLoader, отвечающие за загрузку и подготовку

Целевая метрика, по которой оценивалось качество модели — F2-мера (отличается от обычной F1-меры коэффициентом перед precision).

Модели и transfer learning

Для классификации изображений последние 6 лет "классическим" инструментом являются глубокие сверточные нейросети. Сразу было понятно, что не имеет смысла экспериментировать с чем-то другим, поэтому оставался один вопрос: обучать какую-либо сверточную архитектуру с нуля, или же воспользоваться transfer learning? Мы выбрали второе, используя зоопарк предобученных на ImageNet моделей из keras.applications.*

Основная идея transfer learning — взять предобученную на каком-нибудь большом датасете (в нашем случае — ImageNet, 1.2 млн. изображений, 1000 классов) нейросеть, заменить "голову" (полносвязный классификатор, идущий после сверточных слоев), а потом дообучить модель уже на целевом датасете. Часто таким образом получаются более качественные модели, чем при обучении с нуля, особенно если датасеты (на котором совершался претрейн и целевой) более-менее схожи.

class PretrainedCLF:

    def __init__(self, clf_name, n_class):
        self.clf_name = clf_name
        self.n_class = n_class
        self.module_ = CLF2MODULE[clf_name]
        self.class_ = CLF2CLASS[clf_name]
        self.backbone = getattr(globals()[self.module_], self.class_)

        i = self._input()
        print(f"Using {self.class_} as backbone")
        backbone = self.backbone(
            include_top=False,
            weights='imagenet',
            pooling='max'
        )
        x = backbone(i)
        out = self._top_classifier(x)
        self.model = Model(i, out)
        for layer in self.model.get_layer(self.clf_name).layers:
            layer.trainable = False

    @staticmethod
    def _input():
        input_ = Input((RESCALE_SIZE, RESCALE_SIZE, 3))
        return input_

    def _top_classifier(self, x):
        x = Dense(512, activation='elu')(x)
        x = Dropout(0.3)(x)
        x = Dense(256, activation='elu')(x)
        x = Dropout(0.2)(x)
        out = Dense(self.n_class, activation='softmax')(x)
        return out

Класс, собирающий классификатор на основе предобученной сети

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

Хакатон

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

Чтобы как-то оценивать качество наших моделей во время хакатона, мы разбили доступный нам датасет на три части в пропорции 70/10/20: train, validation и test соответственно. С самого начала целью было обучить побольше разных сетей, чтобы потом усреднить их предсказания — по опыту Kaggle я знаю, что ансамбли обычно показывают себя лучше, чем одиночные модели.

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

def _augment_sample(self, image):
    # gamma
    if np.random.rand() < 0.5:
        gamma = np.random.choice([0.5, 0.8, 1.2, 1.5])
        image = adjust_gamma(image, gamma)
    # blur
    if np.random.rand() < 0.5:
        image = cv2.GaussianBlur(image, (3, 3), 0)
    return image

Метод, реализующий аугментацию обучающих примеров

Обучались сети на нескольких машинах: я снял на Google Cloud инстанс с Tesla P100, еще один участник команды имел доступ к компьютеру в лаборатории с Titan X на борту, а остальные пользовались Google Colaboratory (бесплатная Tesla K80 от гугла). К моменту выдачи тестовых данных, у нас было около 15 сохраненных моделей (обученные с разными параметрами ResNet-50, Xception, DesneNet-169, InceptionResNet-v2, причем от каждого запуска сохранялось несколько моделей — модель с последней эпохи + модель с лучшей accuracy на валидации) со средним значением F2 на нашем личном 20%-ном тесте в ~0.8. Выглядело это все неплохо, однако время на инференс было ограничено, а сгенерировать и собрать все предсказания в единый сабмит оказалось сложнее, чем мы думали.

Проблемы во время инференса

Тестовый датасет по размеру почти совпадал с обучающим — 6875 файлов, для которых нужно было предсказать метку класса. Мы думали последовательно прогнать картинки через все модели и заняться блендингом (усреднением результатов предсказаний), однако проблемы начались на самом первом шаге — конвертации в jpeg. Если обучающий датасет наш скрипт спокойно сконвертировал, то на тестовом почему-то все сломалось: некоторые файлы после конвертации выходили битыми, из-за чего генерирующий сабмит скрипт падал во время загрузки данных. Пока мы с этим разбирались, успело пройти около 45 минут от изначального часа, к концу которого нужно было предоставить первый сабмит, причем за это время проблему с конвертацией мы так и не решили. Поскольку нужно было отправить хоть что-то, я вставил костыль в загрузку данных, забивающий нулями все несчитывающиеся примеры, в надежде на то, что битых файлов будет не так-то много и они не сильно повлияют на результат:

def _load_sample(self, sample_path):
    # try to load all files with opencv
    image = cv2.imread(sample_path)
    if image is not None:
        shape = image.shape
        # normal 3-channel image
        if shape[-1] == 3:
            image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
        # grayscale image -> RGB
        if len(shape) == 2 or shape[-1] == 1:
            image = cv2.cvtColor(image, cv2.COLOR_GRAY2RGB)
    else:
        image = np.zeros((RESCALE_SIZE, RESCALE_SIZE, 3))
    return image

После этого времени оставалось совсем в обрез, поэтому я быстро получил предсказания из нашей последней обученной модели — InceptionResNet-v2 (которую мы даже не успели проверить на нашем личном тесте) и, не смотря на результат, собрал сабмит и отправил его организаторам, едва успев к уже немного отложенному дедлайну. Увидев через несколько минут, что мы на первом месте, причем с большим отрывом (0.77 против 0.673 у второго места), мы немного расслабились и решили, что уж за оставшиеся 50 минут точно дорешаем технические проблемы и заансамблируем все, что успели обучить.

К этому моменту как раз вроде как решились проблемы с конвертацией (хотя костыль из кода я так и не убрал), и мы начали последовательно загружать сохраненные модели и генерировать отдельные сабмиты. На второй сети стало понятно, что все прогнать мы не успеем точно — с учетом загрузки модели из файла, стадии предсказания и вывода в файл на одну модель уходило около ~5-7 минут (keras о-о-чень долго загружает сохраненные в .h5 модели, как я потом узнал, на stack overflow рекомендуют сохранять отдельно структуру модели и веса, так загрузка будет быстрее), поэтому в финальный сабмит вошли предсказания только двух InceptionResNet-v2, в которые мы поверили после первого сабмита, и трех Xception, имевших лучшее на нашем тесте качество. Где-то за 10 минут до финала хакатона модели отработали и мы получили пять отдельных csv файлов с предсказаниями, которые хотели смешать путем majority voice (для каждого файла выбирается метка, за которую проголосовало большинство моделей). Открываем jupyter, загружаем csv-шки… и я понимаю что где-то в submit.py была ошибка: в файлах сохранились только предсказанные метки, без указания, к какому файлу эта метка относится. Пришлось, надеясь что внутри нашего кода все всегда отсортировано и порядок обработки файлов не меняется от запуска к запуску, найти наш предыдущий сабмит, забрать оттуда колонку с названиями файлов и быстро усреднить новые метки, ровно в 16:05 (организаторы дали дополнительные пять минут) отправив финальный сабмит. Как оказалось потом, этот кодинг в jupyter-ноутбуке на скорость оказался, фактически, не нужен — наш результат улучшился на ~0.04%, до 0.7739. Команда со второго места прибавила целых 4% — до 0.7137, но мы все равно с большим запасом оставались на первом месте.

Итог

Это были интересные выходные, прошедшие в офисе Mail.Ru: параллельно с хакатоном еще были две интересные лекции на тему машинного обучения (и много кофе и печенек, конечно же). А также мы получили ценный опыт, что в условиях хакатона шаги, которые кажутся очевидными, могут доставить множество проблем.

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

Весь наш код с хакатона открыт и доступен в репозитории на гитхабе.

SmartMailHack. Решение 1-го места в задаче классификации логотипов - 11
Команда "MADGAN":

  • Дмитрий Сенюшкин, физфак МГУ
  • Ян Будакян, физфак МГУ
  • Карим Эль Хадж Дау, физфак МГУ
  • Александр Сидоренко, ФИВТ МФТИ

Автор: Ян Будакян

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js