Данные в этом соревновании предоставляются не в виде готовых картинок, а в виде 340 CSV-файлов (по одному файлу на каждый класс), содержащих JSON-ы с координатами точек. Соединив эти точки линиями, мы получаем итоговое изображение размером 256х256 пикселей. Также для каждой записи приводится метка, была ли картинка корректно распознана используемым на момент сбора датасета классификатором, двухбуквенный код страны проживания автора рисунка, уникальный идентификатор, метка времени и название класса, совпадающее с именем файла. Упрощенная версия исходных данных весит 7.4 Гб в архиве и примерно 20 Гб после распаковки, полные данные после распаковки занимают 240 Гб. Организаторы гарантировали, что обе версии воспроизводят одни и те же рисунки, то есть полная версия является избыточной. В любом случае, хранение 50 млн. картинок в графических файлах или в виде массивов сразу было признано нерентабельным, и мы решили слить все CSV-файлы из архива train_simplified.zip в базу данных с последующей генерацией картинок нужного размера "на лету" для каждого батча. В качестве СУБД была выбрана хорошо себя зарекомендовавшая MonetDB, а именно реализация для R в виде пакета MonetDBLite. Пакет включает в себя embedded-версию сервера базы данных и позволяет поднять сервер непосредственно из R-сессии и там же работать с ним. Создание базы данных и подключение к ней выполняются одной командой: Нам понадобится создать две таблицы: одну для всех данных, другую для служебной информации о загруженных файлах (пригодится, если что-то пойдет не так и процесс придется возобновлять после загрузки нескольких файлов): Самым быстрым способом загрузки данных в БД оказалось прямое копирование CSV-файлов средствами SQL — команда COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','\\n','\"' NULL AS '' BEST EFFORT, где tablename — имя таблицы и path — путь к файлу. Позже был обнаружен еще один способ повысить скорость: достаточно заменить BEST EFFORT на LOCKED BEST EFFORT. При работе с архивом оказалось, что встроенная реализация unzip в R некорректно работает с рядом файлов из архива, поэтому мы использовали системный unzip (с помощью параметра getOption("unzip")).  В случае, если требуется преобразовать таблицу перед записью в БД, достаточно передать в аргумент preprocess функцию, которая будет преобразовывать данные. Код для последовательной загрузки данных в базу: Время загрузки данных может варьироваться в зависимости от скоростных характеристик используемого накопителя. В нашем случае чтение и запись в пределах одного SSD или с флешки (исходный файл) на SSD (БД) занимает менее 10 минут. Еще несколько секунд требуется для создания столбца с целочисленной меткой класса и столбца-индекса (ORDERED INDEX) с номерами строк, по которому будет производиться выборка наблюдений при создании батчей: Для решения задачи формирования батча «на лету» нам необходимо было добиться максимальной скорости извлечения случайных строк из таблицы doodles. Для этого мы использовали 3 трюка. Первый заключался в том, чтобы сократить размерность типа, в котором хранится ID наблюдения. В исходном наборе данных для хранения ID требуется тип bigint, но количество наблюдений позволяет уместить их идентификаторы, равные порядковому номеру, в тип int. Поиск при этом происходит значительно быстрее. Вторым трюком было использование ORDERED INDEX — к этому решению пришли эмпирически, перебрав все доступные варианты. Третий состоял в использовании параметризованных запросов. Суть метода заключается в однократном выполнении команды PREPARE с последующим использованием подготовленного выражения при создании кучи однотипных запросов, но на деле выигрыш по сравнению с простым SELECT оказался в районе статистической погрешности. Процесс заливки данных потребляет не более 450 Мб ОЗУ. То есть описанный подход позволяет ворочать датасеты весом в десятки гигабайт практически на любом бюджетном железе, включая некоторые одноплатники, что довольно круто. Остается выполнить замеры скорости извлечения (случайных) данных и оценить масштабирование при выборке батчей разного размера: Весь процесс подготовки батчей состоит из следующих этапов: В рамках соревнования среди kernel-ов на Python задача решалась преимущественно средствами OpenCV. Один из наиболее простых и очевидных аналогов на R будет выглядеть следующим образом: Рисование выполняется стандартными средствами R с сохранением во временный PNG, хранящийся в ОЗУ (в Linux временные директории R находятся в каталоге /tmp, смонтированном в ОЗУ). Затем этот файл считывается в виде трёхмерного массива с числами в диапазоне от 0 до 1. Это важно, поскольку более общепринятый BMP был бы прочитан в raw-массив с hex-кодами цветов. Протестируем результат: Сам батч будет формироваться следующим образом: Данная реализация нам показалась неоптимальной, поскольку формирование больших батчей занимает неприлично много времени, и мы решили воспользоваться опытом коллег, задействовав мощную библиотеку OpenCV. На тот момент готового пакета для R не было (нет его и сейчас), поэтому была написана минимальная реализация требуемого функционала на C++ с интеграцией в код на R при помощи Rcpp. Для решения задачи использовались следущие пакеты и библиотеки: Стоит отметить, что xtensor оказался просто находкой: кроме того, что он обладает обширным функционалом и высокой производительностью, его разработчики оказались довольно отзывчивыми и оперативно и подробно отвечали на возникающие вопросы. С их помощью удалось реализовать преобразования матриц OpenCV в тензоры xtensor, а также способ объединения 3-х мерных тензоров изображений в 4-х мерный тензор правильной размерности (собственно батч). https://thecoatlessprofessor.com/programming/unofficial-rcpp-api-documentation https://docs.opencv.org/4.0.1/d7/dbd/group__imgproc.html https://xtensor.readthedocs.io/en/latest/ https://xtensor.readthedocs.io/en/latest/file_loading.html#loading-json-data-into-xtensor https://cran.r-project.org/web/packages/RcppThread/vignettes/RcppThread-vignette.pdf Для компиляции файлов, использующих системные файлы и динамическую линковку с установленными в системе библиотеками, мы воспользовались механизмом плагинов, реализованным в пакете Rcpp. Для автоматического нахождения путей и флагов использовали популярную linux-утилиту pkg-config. В результате работы плагина в процессе компиляции будут подставлены следующие значения: Код реализации парсинга JSON и формирования батча для передачи в модель приведён под спойлером. Предварительно добавляем локальную директорию проекта для поиска заголовочных файлов (нужно для ndjson): Этот код следует поместить в файл src/cv_xt.cpp и скомпилировать командой Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); также для работы потребуется nlohmann/json.hpp из репозитория. Код разделён на несколько функций: Для отрисовки разноцветных линий использовалась цветовая модель HSV с последующей конвертацией в RGB. Протестируем результат: Как видим, прирост скорости оказался очень значительным, и догнать код на C++ при помощи параллелизации кода на R не представляется возможным. R имеет заслуженную репутацию языка для обработки данных, помещающихся в ОЗУ, в то время как для Питона более характерна итеративная обработка данных, позволяющая легко и непринужденно реализовывать out-of-core вычисления (вычисления с использованием внешней памяти). Классическим и актуальным для нас в контексте описываемой задачи примером таких вычислений являются глубокие нейронные сети, обучаемые методом градиентного спуска с аппроксимацией градиента на каждом шаге по небольшой порции наблюдений, или мини-батчу. Фреймворки для глубокого обучения, написанные на Python, имеют специальные классы, реализующие итераторы по данным: таблицами, картинкам в папках, бинарным форматам и пр. Можно использовать готовые варианты или же писать свои собственные для специфических задач. В R мы можем воспользоваться всеми возможностями питоновской библиотеки keras с его различными бекендами при помощи одноименного пакета, в свою очередь работающего поверх пакета reticulate. Последний заслуживает отдельной большой статьи; он не только позволяет запускать код на Python из R, но и обеспечивает передачу объектов между R- и Python-сессиями, автомагически выполняя все необходимые преобразования типов. От необходимости хранить все данные в ОЗУ мы избавились за счет использования MonetDBLite, всю "нейросетевую" работу будет выполнять оригинальный код на Python, нам остается лишь написать итератор по данным, поскольку готового для такой ситуации нет ни на R, ни на Python. Требований к нему по сути всего два: он должен возвращать батчи в бесконечном цикле и сохранять свое состояние между итерациями (последнее в R простейшим образом реализуется при помощи замыканий). Ранее требовалось внутри итератора явным образом преобразовывать массивы R в numpy-массивы, но актуальная версия пакета keras делает это сама. Итератор для обучающих и валидационных данных получился следующим: Функция принимает на вход переменную с соединением с БД, номера используемых строк, число классов, размер батча, масштаб (scale = 1 соответствует отрисовке картинок 256х256 пикселей, scale = 0.5 — 128х128 пикселей), индикатор цветности (color = FALSE задает отрисовку в оттенках серого, при использовании color = TRUE каждый штрих рисуется новым цветом) и индикатор препроцессинга для сетей, предобученных на imagenet-е. Последний нужен для того, чтобы отшкалировать значения пикселей с интервала [0, 1] на интервал [-1, 1], который использовался при обучении поставляемых в составе keras моделей. Внешняя функция содержит проверку типов аргументов, таблицу data.table со случайным образом перемешанными номерами строк из samples_index и номерами батчей, счетчик и максимальное число батчей, а также SQL-выражение для выгрузки данных из БД. Дополнительно мы определили внутри быстрый аналог функции keras::to_categorical(). Мы использовали для обучения почти все данные, оставив полпроцента для валидации, поэтому размер эпохи ограничивался параметром steps_per_epoch при вызове keras::fit_generator(), и условие if (i > max_i) срабатывало только для валидационного итератора. Во внутренней функции происходит выборка индексов строк для очередного батча, выгрузка записей из БД с увеличением счетчика батчей, парсинг JSON-ов (функция cpp_process_json_vector(), написанная на C++) и создание массивов, соответствующих картинкам. Затем создаются one-hot векторы с метками классов, массивы со значениями пикселей и с метками объединяются в список, который и является возвращаемым значением. Для ускорения работы использовалось создание индексов в таблицах data.table и модификация по ссылке — без этих "фишек" пакета data.table довольно трудно представить эффективную работу со сколько-нибудь значительными объемами данных в R. Результаты замеров скорости работы на ноутбучном Core i5 выглядят следующим образом: Если имеется достаточный объем ОЗУ, можно серьезно ускорить работу базы данных путем ее переноса в эту самую ОЗУ (для нашей задачи хватает 32 Гб). В линуксе по умолчанию монтируется раздел /dev/shm, занимающий до половины объема ОЗУ. Можно выделить и больше, отредактировав /etc/fstab, чтобы получилась запись вида tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Обязательно перезагружаемся и проверяем результат, выполнив команду df -h. Итератор для тестовых данных выглядит гораздо проще, поскольку тестовый датасет целиком помещается в ОЗУ: Первой из использованных архитектур была mobilenet v1, особенности которой разобраны в этом сообщении. Она присутствует в стандартной поставке keras и, соответственно, доступна в одноименном пакете для R. Но при попытке использовать ее с одноканальными изображениями выяснилось странное: входной тензор должен всегда иметь размерность (batch, height, width, 3), то есть число каналов поменять нельзя. В Python такого ограничения нет, поэтому мы поспешили и написали свою реализацию данной архитектуры, следуя оригинальной статье (без дропаута, который есть в keras-овском варианте): Недостатки такого подхода очевидны. Моделей хочется проверить много, а переписывать каждую архитектуру вручную, наоборот, не хочется. Также мы были лишены возможности использовать веса моделей, предварительно обученных на imagenet-е. Как обычно, помогло изучение документации. Функция get_config() позволяет получить описание модели в пригодном для редактирования виде (base_model_conf$layers — обычный R-овский список), а функция from_config() выполняет обратное преобразование в модельный объект: Теперь не составляет труда написать универсальную функцию для получения любой из поставляемых в составе keras моделей с обученными на imagenet-е весами или без них: При использовании одноканальных изображений предобученные веса не используются. Это можно было бы исправить: при помощи функции get_weights() получить веса модели в виде списка из R-овских массивов, изменить размерность первого элемента этого списка (взяв какой-то один цветовой канал или усреднив все три), а затем загрузить веса обратно в модель функцией set_weights(). Мы этот функционал так и не добавили, поскольку на этом этапе уже было понятно, что продуктивнее работать с цветными картинками. Основную массу экспериментов мы проделали с использованием mobilenet версии 1 и 2, а также resnet34. В этом соревновании хорошо себя показали более современные архитектуры, такие как SE-ResNeXt. К сожалению, в нашем распоряжении готовых реализаций не было, а свои собственные мы не написали (но обязательно напишем). Для удобства весь код для запуска обучения был оформлен в виде единственного скрипта, параметризованного при помощи docopt следующим образом: Пакет docopt представляет собой реализацию http://docopt.org/ для R. С его помощью скрипты запускаются простыми командами вида Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db или ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, если файл train_nn.R является исполняемым (эта команда запустит обучение модели resnet50 на трехцветных изображениях размеров 128х128 пикселей, база данных должна находиться в папке /home/andrey/doodle_db). В список можно добавить скорость обучения, вид оптимизатора и любые другие настраиваемые параметры. В процессе подготовки публикации выяснилось, что архитектуру mobilenet_v2 из актуальной версии keras в R использовать нельзя из-за неучтенных в R-пакете изменений — ждем, пока пофиксят. Этот подход позволил значительно ускорить эксперименты с разными моделями по сравнению с более традиционным запуском скриптов в RStudio (в качестве возможной альтернативы отметим пакет tfruns). Но главное преимущество состоит в возможности легко управлять запуском скриптов в докере или просто на сервере, не устанавливая для этого RStudio.  Мы использовали докер с целью обеспечения переносимости среды для обучения моделей между членами команды и для оперативного развёртывания в облаке. Начать знакомство с этим относительно непривычным для R-программиста инструментом можно с этой серии публикаций или с видеокурса. Докер позволяет как создавать собственные образы «с нуля», так и использовать другие образы в качестве основы для создания собственных. При анализе имеющихся вариантов мы пришли к выводу, что установка драйверов NVIDIA, CUDA+cuDNN и питоновских библиотек — довольно объёмная часть образа, и решили взять за основу официальный образ tensorflow/tensorflow:1.12.0-gpu, добавив туда необходимые R-пакеты. Итоговый докер-файл получился таким: Для удобства используемые пакеты были вынесены в переменные; основная часть написанных скриптов копируется внутрь контейнеров при сборке. Также мы изменили командную оболочку на /bin/bash для удобства использования содержимого /etc/os-release. Это позволило избежать необходимости указывать версию ОС в коде. Дополнительно был написан небольшой баш-скрипт, позволяющий запускать контейнер с различными командами. Например, это могут быть скрипты для обучения нейросетей, ранее помещенные внутрь контейнера, или же командная оболочка для отладки и мониторинга работы контейнера: Если этот баш-скрипт запустить без параметров, внутри контейнера будет вызван скрипт train_nn.R со значениями по умолчанию; если первый позиционный аргумент — это "bash", то контейнер запустится в интерактивном режиме с командной оболочкой. Во всех остальных случаях происходит подстановка значений позиционных аргументов: CMD="Rscript /app/train_nn.R $@". Стоит обратить внимание, что директории с исходными данными и базой данных, а также директория для сохранения обученных моделей монтируются внутрь контейнера из хостовой системы, что позволяет получить доступ к результатам работы скриптов без лишних манипуляций. Одной из особенностей соревнования были весьма шумные данные (см. заглавную картинку, позаимствованную у @Leigh.plt из ODS-слака). Бороться с этим помогают батчи большого размера, и мы после экспериментов на ПК с 1 GPU решили освоить обучение моделей на нескольких GPU в облаке. Использовали GoogleCloud (хорошее руководство по основам работы) из-за большого выбора доступных конфигураций, приемлемых цен и бонусных $300. От жадности был заказан инстанс с 4хV100 с SSD и кучей ОЗУ, и это было большой ошибкой. Деньги такая машина кушает быстро, на экспериментах без отработанного пайплайна можно разориться. С учебными целями лучше брать K80. А вот большой объем ОЗУ пригодился — облачный SSD не впечатлил быстродейcтвием, поэтому базу данных при каждом запуске инстанса переносили на dev/shm. Наибольший интерес представляет фрагмент кода, отвечающий за использование нескольких GPU. Сначала модель создается на CPU с использованием менеджера контекста, совсем как на Питоне: Затем нескомпилированная (это важно) модель копируется на заданное количество доступных GPU, и лишь после этого компилируется: Классический прием с заморозкой всех слоев, кроме последнего, обучением последнего слоя, разморозкой и дообучением модели целиком для нескольких GPU реализовать не удалось. За обучением следили без использования tensorboard, ограничившись записью логов и сохранением моделей с информативными именами после каждой эпохи: Ряд проблем, с которыми мы столкнулись, победить пока не получилось: Что полезного удалось вынести из этого соревнования: В целом этот опыт был очень полезным, и мы продолжаем работать над решением некоторых из озвученных проблем. Биостатистик