Две недели назад закончился проходивший в офисе Mail.Ru Group хакатон для студентов SmartMailHack. На хакатоне предлагался выбор из трех задач; статья от победителей во второй задаче уже есть на хабре, я же хочу описать решение нашей команды, победившей в первой задаче. Все примеры кода будут на Python & Keras (популярный фреймворк для deep learning).
Описание задачи
Задача заключалась в классификации логотипов различных компаний. Обучающий датасет состоял из 6139 изображений, размеченных на 161 класс (160 разных компаний + метка "other")
Распределение количества обучающих примеров по классам
Основных проблем с данными было две: во-первых, помимо обычных .jpeg и .png файлов, в датасете были и .svg, и .ico, и даже .gif:
Поскольку 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 на кропах в своей реализации, но эту сеть для финального сабмита мы использовать не успели.
Весь наш код с хакатона открыт и доступен в репозитории на гитхабе.
Команда "MADGAN":
- Дмитрий Сенюшкин, физфак МГУ
- Ян Будакян, физфак МГУ
- Карим Эль Хадж Дау, физфак МГУ
- Александр Сидоренко, ФИВТ МФТИ
Автор: Ян Будакян