Хабрахабр

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

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

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

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

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

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


Распределение количества обучающих примеров по классам

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

Гифка из train/adobe

Поскольку 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 ") return image def _prepare_sample(self, image): image = cv2.resize(image, (RESCALE_SIZE, RESCALE_SIZE)) return image

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

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

Модели и transfer learning

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

2 млн. Основная идея transfer learning — взять предобученную на каком-нибудь большом датасете (в нашем случае — ImageNet, 1. Часто таким образом получаются более качественные модели, чем при обучении с нуля, особенно если датасеты (на котором совершался претрейн и целевой) более-менее схожи. изображений, 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) было отсутствие публичного лидерборда — весь тестовый сет был приватным, и выдали его только за два часа до финала хакатона.

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

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

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

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

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

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

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

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

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

Итог

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

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

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


Команда "MADGAN":

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

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть