Quick Draw Doodle Recognition: як подружити R, C++ та нейросітки

Quick Draw Doodle Recognition: як подружити R, C++ та нейросітки

Привіт, Хабре!

Восени минулого року на Kaggle проходив конкурс із класифікації намальованих від руки картинок Quick Draw Doodle Recognition, в якому серед інших взяла участь команда R-щиків у складі Артема Кльовцова, Філіпа Управителя и Андрія Огурцова. Докладно описувати змагання не будемо, це вже зроблено у недавньої публікації.

З фармом медалек цього разу не склалося, але було отримано багато цінного досвіду, тому про низку найцікавіших та корисних на Каглі та у повсякденній роботі речей хотілося б розповісти спільноті. Серед розглянутих тем: нелегке життя без OpenCV, парсинг JSON-ів (на цих прикладах розглядається інтеграції коду на С++ в скрипти або пакети на R за допомогою Rcpp), параметризація скриптів та докеризація підсумкового рішення. Весь код з повідомлення у придатному для запуску вигляді доступний репозиторії.

Зміст:

  1. Ефективне завантаження даних із CSV до бази MonetDB
  2. Підготовка батчів
  3. Ітератори для вивантаження батчів із БД
  4. Вибір архітектури моделі
  5. Параметризація скриптів
  6. Докеризація скриптів
  7. Використання декількох GPU у хмарі Google Cloud
  8. Замість висновку

1. Ефективне завантаження даних із CSV до бази MonetDB

Дані в цьому змаганні надаються не у вигляді готових картинок, а у вигляді 340 CSV-файлів (по одному файлу на кожен клас), що містять JSON з координатами точок. Поєднавши ці точки лініями, ми отримуємо підсумкове зображення розміром 256х256 пікселів. Також для кожного запису наводиться мітка, чи була картинка коректно розпізнана класифікатором, що використовується на момент збору датасета, дволітерний код країни проживання автора малюнка, унікальний ідентифікатор, мітка часу і назва класу, що збігається з ім'ям файлу. Спрощена версія вихідних даних важить 7.4 Гб в архіві та приблизно 20 Гб після розпакування, повні дані після розпакування займають 240 Гб. Організатори гарантували, що обидві версії відтворюють ті самі малюнки, тобто повна версія є надмірною. У будь-якому випадку зберігання 50 млн. картинок у графічних файлах або у вигляді масивів відразу було визнано нерентабельним, і ми вирішили злити всі CSV-файли з архіву. train_simplified.zip в базу даних із наступною генерацією картинок потрібного розміру «на льоту» для кожного батча.

Як СУБД була обрана добре себе зарекомендувала MonetDB, А саме реалізація для R у вигляді пакета MonetDBLite. Пакет включає embedded-версію сервера бази даних і дозволяє підняти сервер безпосередньо з R-сесії і там же працювати з ним. Створення бази даних та підключення до неї виконуються однією командою:

con <- DBI::dbConnect(drv = MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))

Нам знадобиться створити дві таблиці: одну для всіх даних, іншу для службової інформації про завантажені файли (нагоді, якщо щось піде не так і процес доведеться відновлювати після завантаження декількох файлів):

Створення таблиць

if (!DBI::dbExistsTable(con, "doodles")) {
  DBI::dbCreateTable(
    con = con,
    name = "doodles",
    fields = c(
      "countrycode" = "char(2)",
      "drawing" = "text",
      "key_id" = "bigint",
      "recognized" = "bool",
      "timestamp" = "timestamp",
      "word" = "text"
    )
  )
}

if (!DBI::dbExistsTable(con, "upload_log")) {
  DBI::dbCreateTable(
    con = con,
    name = "upload_log",
    fields = c(
      "id" = "serial",
      "file_name" = "text UNIQUE",
      "uploaded" = "bool DEFAULT false"
    )
  )
}

Найшвидшим способом завантаження даних у БД виявилося пряме копіювання CSV-файлів засобами SQL - команда COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORT, Де tablename - Ім'я таблиці та path - шлях до файлу. Під час роботи з архівом було виявлено, що вбудована реалізація unzip в R некоректно працює з рядом файлів з архіву, тому ми використовували системний unzip (за допомогою параметра getOption("unzip")).

Функція для запису до бази

#' @title Извлечение и загрузка файлов
#'
#' @description
#' Извлечение CSV-файлов из ZIP-архива и загрузка их в базу данных
#'
#' @param con Объект подключения к базе данных (класс `MonetDBEmbeddedConnection`).
#' @param tablename Название таблицы в базе данных.
#' @oaram zipfile Путь к ZIP-архиву.
#' @oaram filename Имя файла внури ZIP-архива.
#' @param preprocess Функция предобработки, которая будет применена извлечённому файлу.
#'   Должна принимать один аргумент `data` (объект `data.table`).
#'
#' @return `TRUE`.
#'
upload_file <- function(con, tablename, zipfile, filename, preprocess = NULL) {
  # Проверка аргументов
  checkmate::assert_class(con, "MonetDBEmbeddedConnection")
  checkmate::assert_string(tablename)
  checkmate::assert_string(filename)
  checkmate::assert_true(DBI::dbExistsTable(con, tablename))
  checkmate::assert_file_exists(zipfile, access = "r", extension = "zip")
  checkmate::assert_function(preprocess, args = c("data"), null.ok = TRUE)

  # Извлечение файла
  path <- file.path(tempdir(), filename)
  unzip(zipfile, files = filename, exdir = tempdir(), 
        junkpaths = TRUE, unzip = getOption("unzip"))
  on.exit(unlink(file.path(path)))

  # Применяем функция предобработки
  if (!is.null(preprocess)) {
    .data <- data.table::fread(file = path)
    .data <- preprocess(data = .data)
    data.table::fwrite(x = .data, file = path, append = FALSE)
    rm(.data)
  }

  # Запрос к БД на импорт CSV
  sql <- sprintf(
    "COPY OFFSET 2 INTO %s FROM '%s' USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORT",
    tablename, path
  )
  # Выполнение запроса к БД
  DBI::dbExecute(con, sql)

  # Добавление записи об успешной загрузке в служебную таблицу
  DBI::dbExecute(con, sprintf("INSERT INTO upload_log(file_name, uploaded) VALUES('%s', true)",
                              filename))

  return(invisible(TRUE))
}

Якщо потрібно перетворити таблицю перед записом в БД, достатньо передати в аргумент preprocess функцію, яка перетворюватиме дані.

Код для послідовного завантаження даних у базу:

Запис даних у базу

# Список файлов для записи
files <- unzip(zipfile, list = TRUE)$Name

# Список исключений, если часть файлов уже была загружена
to_skip <- DBI::dbGetQuery(con, "SELECT file_name FROM upload_log")[[1L]]
files <- setdiff(files, to_skip)

if (length(files) > 0L) {
  # Запускаем таймер
  tictoc::tic()
  # Прогресс бар
  pb <- txtProgressBar(min = 0L, max = length(files), style = 3)
  for (i in seq_along(files)) {
    upload_file(con = con, tablename = "doodles", 
                zipfile = zipfile, filename = files[i])
    setTxtProgressBar(pb, i)
  }
  close(pb)
  # Останавливаем таймер
  tictoc::toc()
}

# 526.141 sec elapsed - копирование SSD->SSD
# 558.879 sec elapsed - копирование USB->SSD

Час завантаження даних може змінюватись в залежності від швидкісних характеристик накопичувача. У нашому випадку читання та запис у межах одного SSD або з флешки (вихідний файл) на SSD (БД) займає менше 10 хвилин.

Ще кілька секунд потрібно для створення стовпця з цілою міткою класу і стовпця-індексу (ORDERED INDEX) з номерами рядків, за яким проводитиметься вибірка спостережень при створенні батчів:

Створення додаткових стовпців та індексу

message("Generate lables")
invisible(DBI::dbExecute(con, "ALTER TABLE doodles ADD label_int int"))
invisible(DBI::dbExecute(con, "UPDATE doodles SET label_int = dense_rank() OVER (ORDER BY word) - 1"))

message("Generate row numbers")
invisible(DBI::dbExecute(con, "ALTER TABLE doodles ADD id serial"))
invisible(DBI::dbExecute(con, "CREATE ORDERED INDEX doodles_id_ord_idx ON doodles(id)"))

Для вирішення завдання формування батча «на льоту» нам необхідно було досягти максимальної швидкості вилучення випадкових рядків з таблиці doodles. Для цього ми використали 3 трюки. Перший у тому, щоб скоротити розмірність типу, у якому зберігається ID спостереження. У вихідному наборі даних для зберігання ID потрібний тип bigint, але кількість спостережень дозволяє вмістити їх ідентифікатори, рівні порядковому номеру, int. Пошук при цьому відбувається значно швидше. Другим трюком було використання ORDERED INDEX - До цього рішення прийшли емпірично, перебравши всі доступні варіанти. Третій був використання параметризованих запитів. Суть методу полягає в одноразовому виконання команди PREPARE з подальшим використанням підготовленого виразу при створенні купи однотипних запитів, але насправді виграш у порівнянні з простим SELECT опинився у районі статистичної похибки.

Процес заливання даних споживає трохи більше 450 Мб ОЗУ. Тобто описаний підхід дозволяє обертати датасети вагою в десятки гігабайт практично на будь-якому бюджетному залозі, включаючи деякі одноплатники, що досить круто.

Залишається виконати виміри швидкості вилучення (випадкових) даних та оцінити масштабування при вибірці батчів різного розміру:

Бенчмарк бази даних

library(ggplot2)

set.seed(0)
# Подключение к базе данных
con <- DBI::dbConnect(MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))

# Функция для подготовки запроса на стороне сервера
prep_sql <- function(batch_size) {
  sql <- sprintf("PREPARE SELECT id FROM doodles WHERE id IN (%s)",
                 paste(rep("?", batch_size), collapse = ","))
  res <- DBI::dbSendQuery(con, sql)
  return(res)
}

# Функция для извлечения данных
fetch_data <- function(rs, batch_size) {
  ids <- sample(seq_len(n), batch_size)
  res <- DBI::dbFetch(DBI::dbBind(rs, as.list(ids)))
  return(res)
}

# Проведение замера
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    rs <- prep_sql(batch_size)
    bench::mark(
      fetch_data(rs, batch_size),
      min_iterations = 50L
    )
  }
)
# Параметры бенчмарка
cols <- c("batch_size", "min", "median", "max", "itr/sec", "total_time", "n_itr")
res_bench[, cols]

#   batch_size      min   median      max `itr/sec` total_time n_itr
#        <dbl> <bch:tm> <bch:tm> <bch:tm>     <dbl>   <bch:tm> <int>
# 1         16   23.6ms  54.02ms  93.43ms     18.8        2.6s    49
# 2         32     38ms  84.83ms 151.55ms     11.4       4.29s    49
# 3         64   63.3ms 175.54ms 248.94ms     5.85       8.54s    50
# 4        128   83.2ms 341.52ms 496.24ms     3.00      16.69s    50
# 5        256  232.8ms 653.21ms 847.44ms     1.58      31.66s    50
# 6        512  784.6ms    1.41s    1.98s     0.740       1.1m    49
# 7       1024  681.7ms    2.72s    4.06s     0.377      2.16m    49

ggplot(res_bench, aes(x = factor(batch_size), y = median, group = 1)) +
  geom_point() +
  geom_line() +
  ylab("median time, s") +
  theme_minimal()

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle Recognition: як подружити R, C++ та нейросітки

2. Підготовка батчів

Весь процес підготовки батчів складається з наступних етапів:

  1. Парсинг кількох JSONів, що містять вектори рядків з координатами точок.
  2. Відображення кольорових ліній за координатами точок на зображенні потрібного розміру (наприклад, 256×256 або 128×128).
  3. Перетворення отриманих зображень на тензор.

У рамках змагання серед kernel-ів на Python завдання вирішувалося переважно засобами OpenCV. Один з найбільш простих і очевидних аналогів на R виглядатиме так:

Реалізація перетворення JSON на тензор на R

r_process_json_str <- function(json, line.width = 3, 
                               color = TRUE, scale = 1) {
  # Парсинг JSON
  coords <- jsonlite::fromJSON(json, simplifyMatrix = FALSE)
  tmp <- tempfile()
  # Удаляем временный файл по завершению функции
  on.exit(unlink(tmp))
  png(filename = tmp, width = 256 * scale, height = 256 * scale, pointsize = 1)
  # Пустой график
  plot.new()
  # Размер окна графика
  plot.window(xlim = c(256 * scale, 0), ylim = c(256 * scale, 0))
  # Цвета линий
  cols <- if (color) rainbow(length(coords)) else "#000000"
  for (i in seq_along(coords)) {
    lines(x = coords[[i]][[1]] * scale, y = coords[[i]][[2]] * scale, 
          col = cols[i], lwd = line.width)
  }
  dev.off()
  # Преобразование изображения в 3-х мерный массив
  res <- png::readPNG(tmp)
  return(res)
}

r_process_json_vector <- function(x, ...) {
  res <- lapply(x, r_process_json_str, ...)
  # Объединение 3-х мерных массивов картинок в 4-х мерный в тензор
  res <- do.call(abind::abind, c(res, along = 0))
  return(res)
}

Малювання виконується стандартними засобами R зі збереженням у тимчасовий PNG, що зберігається в ОЗП (в Linux часові директорії R знаходяться в каталозі /tmp, що змонтовано в ОЗУ). Потім цей файл зчитується у вигляді тривимірного масиву з числами в діапазоні від 0 до 1. Це важливо, оскільки більш загальноприйнятий BMP був прочитаний в raw-масив з hex-кодами кольорів.

Протестуємо результат:

zip_file <- file.path("data", "train_simplified.zip")
csv_file <- "cat.csv"
unzip(zip_file, files = csv_file, exdir = tempdir(), 
      junkpaths = TRUE, unzip = getOption("unzip"))
tmp_data <- data.table::fread(file.path(tempdir(), csv_file), sep = ",", 
                              select = "drawing", nrows = 10000)
arr <- r_process_json_str(tmp_data[4, drawing])
dim(arr)
# [1] 256 256   3
plot(magick::image_read(arr))

Quick Draw Doodle Recognition: як подружити R, C++ та нейросітки

Сам батч формуватиметься так:

res <- r_process_json_vector(tmp_data[1:4, drawing], scale = 0.5)
str(res)
 # num [1:4, 1:128, 1:128, 1:3] 1 1 1 1 1 1 1 1 1 1 ...
 # - attr(*, "dimnames")=List of 4
 #  ..$ : NULL
 #  ..$ : NULL
 #  ..$ : NULL
 #  ..$ : NULL

Дана реалізація нам здалася неоптимальною, оскільки формування великих батчів займає багато часу, і ми вирішили скористатися досвідом колег, задіявши потужну бібліотеку. OpenCV. На той момент готового пакета для R не було (немає його і зараз), тому було написано мінімальну реалізацію необхідного функціоналу на C++ з інтеграцією в код на R за допомогою Rcpp.

Для вирішення завдання використовувалися такі пакети та бібліотеки:

  1. OpenCV для роботи із зображеннями та малювання ліній. Використовували попередньо встановлені системні бібліотеки та заголовні файли, а також динамічне лінкування.

  2. xtensor для роботи з багатовимірними масивами та тензорами. Використовували файли заголовків, включені в однойменний R-пакет. Бібліотека дозволяє працювати з багатовимірними масивами, причому як у row major, так і в column major порядку.

  3. ndjson для парсингу JSON. Ця бібліотека використовується в xtensor автоматично за її наявності у проекті.

  4. RcppThread для організації багатопотокової обробки вектора з JSON-ів. Використовували файли заголовків, що надаються цим пакетом. Від популярнішого RcppParallel пакет серед іншого відрізняється вбудованим механізмом переривання циклу (interrupt).

Варто зазначити, що xtensor виявився просто знахідкою: крім того, що він має великий функціонал і високу продуктивність, його розробники виявилися досить чуйними і оперативно і докладно відповідали на питання. З їх допомогою вдалося реалізувати перетворення матриць OpenCV в тензори xtensor, а також спосіб об'єднання 3-мірних тензорів зображень в 4-мірний тензор правильної розмірності (власне батч).

Матеріали для вивчення Rcpp, xtensor та RcppThread

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.

Реалізація Rcpp-плагіну для використання бібліотеки OpenCV

Rcpp::registerPlugin("opencv", function() {
  # Возможные названия пакета
  pkg_config_name <- c("opencv", "opencv4")
  # Бинарный файл утилиты pkg-config
  pkg_config_bin <- Sys.which("pkg-config")
  # Проврека наличия утилиты в системе
  checkmate::assert_file_exists(pkg_config_bin, access = "x")
  # Проверка наличия файла настроек OpenCV для pkg-config
  check <- sapply(pkg_config_name, 
                  function(pkg) system(paste(pkg_config_bin, pkg)))
  if (all(check != 0)) {
    stop("OpenCV config for the pkg-config not found", call. = FALSE)
  }

  pkg_config_name <- pkg_config_name[check == 0]
  list(env = list(
    PKG_CXXFLAGS = system(paste(pkg_config_bin, "--cflags", pkg_config_name), 
                          intern = TRUE),
    PKG_LIBS = system(paste(pkg_config_bin, "--libs", pkg_config_name), 
                      intern = TRUE)
  ))
})

В результаті роботи плагіна у процесі компіляції будуть підставлені такі значення:

Rcpp:::.plugins$opencv()$env

# $PKG_CXXFLAGS
# [1] "-I/usr/include/opencv"
#
# $PKG_LIBS
# [1] "-lopencv_shape -lopencv_stitching -lopencv_superres -lopencv_videostab -lopencv_aruco -lopencv_bgsegm -lopencv_bioinspired -lopencv_ccalib -lopencv_datasets -lopencv_dpm -lopencv_face -lopencv_freetype -lopencv_fuzzy -lopencv_hdf -lopencv_line_descriptor -lopencv_optflow -lopencv_video -lopencv_plot -lopencv_reg -lopencv_saliency -lopencv_stereo -lopencv_structured_light -lopencv_phase_unwrapping -lopencv_rgbd -lopencv_viz -lopencv_surface_matching -lopencv_text -lopencv_ximgproc -lopencv_calib3d -lopencv_features2d -lopencv_flann -lopencv_xobjdetect -lopencv_objdetect -lopencv_ml -lopencv_xphoto -lopencv_highgui -lopencv_videoio -lopencv_imgcodecs -lopencv_photo -lopencv_imgproc -lopencv_core"

Код реалізації парсингу JSON та формування батчу для передачі в модель наведено під спойлером. Попередньо додаємо локальну директорію проекту для пошуку заголовних файлів (потрібно для ndjson):

Sys.setenv("PKG_CXXFLAGS" = paste0("-I", normalizePath(file.path("src"))))

Реалізація перетворення JSON на тензор на С++

// [[Rcpp::plugins(cpp14)]]
// [[Rcpp::plugins(opencv)]]
// [[Rcpp::depends(xtensor)]]
// [[Rcpp::depends(RcppThread)]]

#include <xtensor/xjson.hpp>
#include <xtensor/xadapt.hpp>
#include <xtensor/xview.hpp>
#include <xtensor-r/rtensor.hpp>
#include <opencv2/core/core.hpp>
#include <opencv2/highgui/highgui.hpp>
#include <opencv2/imgproc/imgproc.hpp>
#include <Rcpp.h>
#include <RcppThread.h>

// Синонимы для типов
using RcppThread::parallelFor;
using json = nlohmann::json;
using points = xt::xtensor<double,2>;     // Извлечённые из JSON координаты точек
using strokes = std::vector<points>;      // Извлечённые из JSON координаты точек
using xtensor3d = xt::xtensor<double, 3>; // Тензор для хранения матрицы изоображения
using xtensor4d = xt::xtensor<double, 4>; // Тензор для хранения множества изображений
using rtensor3d = xt::rtensor<double, 3>; // Обёртка для экспорта в R
using rtensor4d = xt::rtensor<double, 4>; // Обёртка для экспорта в R

// Статические константы
// Размер изображения в пикселях
const static int SIZE = 256;
// Тип линии
// См. https://en.wikipedia.org/wiki/Pixel_connectivity#2-dimensional
const static int LINE_TYPE = cv::LINE_4;
// Толщина линии в пикселях
const static int LINE_WIDTH = 3;
// Алгоритм ресайза
// https://docs.opencv.org/3.1.0/da/d54/group__imgproc__transform.html#ga5bb5a1fea74ea38e1a5445ca803ff121
const static int RESIZE_TYPE = cv::INTER_LINEAR;

// Шаблон для конвертирования OpenCV-матрицы в тензор
template <typename T, int NCH, typename XT=xt::xtensor<T,3,xt::layout_type::column_major>>
XT to_xt(const cv::Mat_<cv::Vec<T, NCH>>& src) {
  // Размерность целевого тензора
  std::vector<int> shape = {src.rows, src.cols, NCH};
  // Общее количество элементов в массиве
  size_t size = src.total() * NCH;
  // Преобразование cv::Mat в xt::xtensor
  XT res = xt::adapt((T*) src.data, size, xt::no_ownership(), shape);
  return res;
}

// Преобразование JSON в список координат точек
strokes parse_json(const std::string& x) {
  auto j = json::parse(x);
  // Результат парсинга должен быть массивом
  if (!j.is_array()) {
    throw std::runtime_error("'x' must be JSON array.");
  }
  strokes res;
  res.reserve(j.size());
  for (const auto& a: j) {
    // Каждый элемент массива должен быть 2-мерным массивом
    if (!a.is_array() || a.size() != 2) {
      throw std::runtime_error("'x' must include only 2d arrays.");
    }
    // Извлечение вектора точек
    auto p = a.get<points>();
    res.push_back(p);
  }
  return res;
}

// Отрисовка линий
// Цвета HSV
cv::Mat ocv_draw_lines(const strokes& x, bool color = true) {
  // Исходный тип матрицы
  auto stype = color ? CV_8UC3 : CV_8UC1;
  // Итоговый тип матрицы
  auto dtype = color ? CV_32FC3 : CV_32FC1;
  auto bg = color ? cv::Scalar(0, 0, 255) : cv::Scalar(255);
  auto col = color ? cv::Scalar(0, 255, 220) : cv::Scalar(0);
  cv::Mat img = cv::Mat(SIZE, SIZE, stype, bg);
  // Количество линий
  size_t n = x.size();
  for (const auto& s: x) {
    // Количество точек в линии
    size_t n_points = s.shape()[1];
    for (size_t i = 0; i < n_points - 1; ++i) {
      // Точка начала штриха
      cv::Point from(s(0, i), s(1, i));
      // Точка окончания штриха
      cv::Point to(s(0, i + 1), s(1, i + 1));
      // Отрисовка линии
      cv::line(img, from, to, col, LINE_WIDTH, LINE_TYPE);
    }
    if (color) {
      // Меняем цвет линии
      col[0] += 180 / n;
    }
  }
  if (color) {
    // Меняем цветовое представление на RGB
    cv::cvtColor(img, img, cv::COLOR_HSV2RGB);
  }
  // Меняем формат представления на float32 с диапазоном [0, 1]
  img.convertTo(img, dtype, 1 / 255.0);
  return img;
}

// Обработка JSON и получение тензора с данными изображения
xtensor3d process(const std::string& x, double scale = 1.0, bool color = true) {
  auto p = parse_json(x);
  auto img = ocv_draw_lines(p, color);
  if (scale != 1) {
    cv::Mat out;
    cv::resize(img, out, cv::Size(), scale, scale, RESIZE_TYPE);
    cv::swap(img, out);
    out.release();
  }
  xtensor3d arr = color ? to_xt<double,3>(img) : to_xt<double,1>(img);
  return arr;
}

// [[Rcpp::export]]
rtensor3d cpp_process_json_str(const std::string& x, 
                               double scale = 1.0, 
                               bool color = true) {
  xtensor3d res = process(x, scale, color);
  return res;
}

// [[Rcpp::export]]
rtensor4d cpp_process_json_vector(const std::vector<std::string>& x, 
                                  double scale = 1.0, 
                                  bool color = false) {
  size_t n = x.size();
  size_t dim = floor(SIZE * scale);
  size_t channels = color ? 3 : 1;
  xtensor4d res({n, dim, dim, channels});
  parallelFor(0, n, [&x, &res, scale, color](int i) {
    xtensor3d tmp = process(x[i], scale, color);
    auto view = xt::view(res, i, xt::all(), xt::all(), xt::all());
    view = tmp;
  });
  return res;
}

Цей код слід помістити у файл src/cv_xt.cpp та скомпілювати командою Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); також для роботи потрібно nlohmann/json.hpp з репозиторія. Код поділено на кілька функцій:

  • to_xt - Шаблонізована функція для перетворення матриці зображення (cv::Mat) у тензор xt::xtensor;

  • parse_json - Функція парсит JSON-рядок, витягує координати точок, упаковуючи їх у вектор;

  • ocv_draw_lines - З отриманого вектор точок малює різнокольорові лінії;

  • process - поєднує вищеописані функції, а також додає можливість шкалювання отриманого зображення;

  • cpp_process_json_str - обгортка над функцією process, Що експортує результат в R-об'єкт (багатомірний масив);

  • cpp_process_json_vector - обгортка над функцією cpp_process_json_strщо дозволяє обробляти рядковий вектор у багатопотоковому режимі.

Для малювання різнокольорових ліній використовувалася колірна модель HSV з подальшою конвертацією RGB. Протестуємо результат:

arr <- cpp_process_json_str(tmp_data[4, drawing])
dim(arr)
# [1] 256 256   3
plot(magick::image_read(arr))

Quick Draw Doodle Recognition: як подружити R, C++ та нейросітки
Порівняння швидкості роботи реалізацій на R та С++

res_bench <- bench::mark(
  r_process_json_str(tmp_data[4, drawing], scale = 0.5),
  cpp_process_json_str(tmp_data[4, drawing], scale = 0.5),
  check = FALSE,
  min_iterations = 100
)
# Параметры бенчмарка
cols <- c("expression", "min", "median", "max", "itr/sec", "total_time", "n_itr")
res_bench[, cols]

#   expression                min     median       max `itr/sec` total_time  n_itr
#   <chr>                <bch:tm>   <bch:tm>  <bch:tm>     <dbl>   <bch:tm>  <int>
# 1 r_process_json_str     3.49ms     3.55ms    4.47ms      273.      490ms    134
# 2 cpp_process_json_str   1.94ms     2.02ms    5.32ms      489.      497ms    243

library(ggplot2)
# Проведение замера
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    .data <- tmp_data[sample(seq_len(.N), batch_size), drawing]
    bench::mark(
      r_process_json_vector(.data, scale = 0.5),
      cpp_process_json_vector(.data,  scale = 0.5),
      min_iterations = 50,
      check = FALSE
    )
  }
)

res_bench[, cols]

#    expression   batch_size      min   median      max `itr/sec` total_time n_itr
#    <chr>             <dbl> <bch:tm> <bch:tm> <bch:tm>     <dbl>   <bch:tm> <int>
#  1 r                   16   50.61ms  53.34ms  54.82ms    19.1     471.13ms     9
#  2 cpp                 16    4.46ms   5.39ms   7.78ms   192.      474.09ms    91
#  3 r                   32   105.7ms 109.74ms 212.26ms     7.69        6.5s    50
#  4 cpp                 32    7.76ms  10.97ms  15.23ms    95.6     522.78ms    50
#  5 r                   64  211.41ms 226.18ms 332.65ms     3.85      12.99s    50
#  6 cpp                 64   25.09ms  27.34ms  32.04ms    36.0        1.39s    50
#  7 r                  128   534.5ms 627.92ms 659.08ms     1.61      31.03s    50
#  8 cpp                128   56.37ms  58.46ms  66.03ms    16.9        2.95s    50
#  9 r                  256     1.15s    1.18s    1.29s     0.851     58.78s    50
# 10 cpp                256  114.97ms 117.39ms 130.09ms     8.45       5.92s    50
# 11 r                  512     2.09s    2.15s    2.32s     0.463       1.8m    50
# 12 cpp                512  230.81ms  235.6ms 261.99ms     4.18      11.97s    50
# 13 r                 1024        4s    4.22s     4.4s     0.238       3.5m    50
# 14 cpp               1024  410.48ms 431.43ms 462.44ms     2.33      21.45s    50

ggplot(res_bench, aes(x = factor(batch_size), y = median, 
                      group =  expression, color = expression)) +
  geom_point() +
  geom_line() +
  ylab("median time, s") +
  theme_minimal() +
  scale_color_discrete(name = "", labels = c("cpp", "r")) +
  theme(legend.position = "bottom") 

Quick Draw Doodle Recognition: як подружити R, C++ та нейросітки

Як бачимо, приріст швидкості виявився дуже значним, і наздогнати код на C++ за допомогою паралелізації коду на R неможливо.

3. Ітератори для вивантаження батчів із БД

R має заслужену репутацію мови для обробки даних, що містяться в ОЗУ, у той час як для Пітона більш характерна ітеративна обробка даних, що дозволяє легко та невимушено реалізовувати out-of-core обчислення (обчислення з використанням зовнішньої пам'яті). Класичним та актуальним для нас у контексті описуваного завдання прикладом таких обчислень є глибокі нейронні мережі, які навчаються методом градієнтного спуску з апроксимацією градієнта на кожному кроці по невеликій порції спостережень, або міні-батчу.

Фреймворки для глибокого навчання, написані на Python, мають спеціальні класи, що реалізують ітератори за даними: таблицями, картинками в папках, бінарним форматам та ін. Можна використовувати готові варіанти або писати свої власні для специфічних завдань. У R ми можемо скористатися всіма можливостями пітонівської бібліотеки керас з його різними бекендами за допомогою однойменного пакета, що в свою чергу працює поверх пакета сітчастий. Останній заслуговує на окрему велику статтю; він не тільки дозволяє запускати код на Python з R, але й забезпечує передачу об'єктів між R-і Python-сесіями, автомагічно виконуючи всі необхідні перетворення типів.

Від необхідності зберігати всі дані в ОЗУ ми позбулися за рахунок використання MonetDBLite, всю «нейросєтьову» роботу виконуватиме оригінальний код на Python, нам залишається лише написати ітератор за даними, оскільки готового для такої ситуації немає ні на R, ні на Python. Вимог до нього по суті всього два: він повинен повертати батчі в нескінченному циклі і зберігати свій стан між ітераціями (останнє R найпростішим чином реалізується за допомогою замикань). Раніше потрібно всередині ітератора явно перетворювати масиви R в numpy-масиви, але актуальна версія пакета керас робить це сама.

Ітератор для навчальних та валідаційних даних вийшов наступним:

Ітератор для навчальних та валідаційних даних

train_generator <- function(db_connection = con,
                            samples_index,
                            num_classes = 340,
                            batch_size = 32,
                            scale = 1,
                            color = FALSE,
                            imagenet_preproc = FALSE) {
  # Проверка аргументов
  checkmate::assert_class(con, "DBIConnection")
  checkmate::assert_integerish(samples_index)
  checkmate::assert_count(num_classes)
  checkmate::assert_count(batch_size)
  checkmate::assert_number(scale, lower = 0.001, upper = 5)
  checkmate::assert_flag(color)
  checkmate::assert_flag(imagenet_preproc)

  # Перемешиваем, чтобы брать и удалять использованные индексы батчей по порядку
  dt <- data.table::data.table(id = sample(samples_index))
  # Проставляем номера батчей
  dt[, batch := (.I - 1L) %/% batch_size + 1L]
  # Оставляем только полные батчи и индексируем
  dt <- dt[, if (.N == batch_size) .SD, keyby = batch]
  # Устанавливаем счётчик
  i <- 1
  # Количество батчей
  max_i <- dt[, max(batch)]

  # Подготовка выражения для выгрузки
  sql <- sprintf(
    "PREPARE SELECT drawing, label_int FROM doodles WHERE id IN (%s)",
    paste(rep("?", batch_size), collapse = ",")
  )
  res <- DBI::dbSendQuery(con, sql)

  # Аналог keras::to_categorical
  to_categorical <- function(x, num) {
    n <- length(x)
    m <- numeric(n * num)
    m[x * n + seq_len(n)] <- 1
    dim(m) <- c(n, num)
    return(m)
  }

  # Замыкание
  function() {
    # Начинаем новую эпоху
    if (i > max_i) {
      dt[, id := sample(id)]
      data.table::setkey(dt, batch)
      # Сбрасываем счётчик
      i <<- 1
      max_i <<- dt[, max(batch)]
    }

    # ID для выгрузки данных
    batch_ind <- dt[batch == i, id]
    # Выгрузка данных
    batch <- DBI::dbFetch(DBI::dbBind(res, as.list(batch_ind)), n = -1)

    # Увеличиваем счётчик
    i <<- i + 1

    # Парсинг JSON и подготовка массива
    batch_x <- cpp_process_json_vector(batch$drawing, scale = scale, color = color)
    if (imagenet_preproc) {
      # Шкалирование c интервала [0, 1] на интервал [-1, 1]
      batch_x <- (batch_x - 0.5) * 2
    }

    batch_y <- to_categorical(batch$label_int, num_classes)
    result <- list(batch_x, batch_y)
    return(result)
  }
}

Функція приймає на вхід змінну зі з'єднанням з БД, номери рядків, число використовуваних, число класів, розмір батча, масштаб (scale = 1 відповідає малюванню картинок 256х256 пікселів, scale = 0.5 - 128х128 пікселів), індикатор кольоровості (color = FALSE задає малювання у відтінках сірого, при використанні color = TRUE кожен штрих малюється новим кольором) та індикатор препроцессингу для мереж, передбачених на imagenet-і. Останній потрібен для того, щоб відшкалювати значення пікселів з інтервалу [0, 1] на інтервал [-1, 1], який використовувався при навчанні поставлених у складі керас моделей.

Зовнішня функція містить перевірку типів аргументів, таблицю 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 і модифікація за посиланням – без цих «фішок» пакета дані.таблиця досить важко уявити ефективну роботу з скільки-небудь значними обсягами даних R.

Результати вимірювання швидкості роботи на ноутбучному Core i5 виглядають наступним чином:

Бенчмарк ітератора

library(Rcpp)
library(keras)
library(ggplot2)

source("utils/rcpp.R")
source("utils/keras_iterator.R")

con <- DBI::dbConnect(drv = MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))

ind <- seq_len(DBI::dbGetQuery(con, "SELECT count(*) FROM doodles")[[1L]])
num_classes <- DBI::dbGetQuery(con, "SELECT max(label_int) + 1 FROM doodles")[[1L]]

# Индексы для обучающей выборки
train_ind <- sample(ind, floor(length(ind) * 0.995))
# Индексы для проверочной выборки
val_ind <- ind[-train_ind]
rm(ind)
# Коэффициент масштаба
scale <- 0.5

# Проведение замера
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    it1 <- train_generator(
      db_connection = con,
      samples_index = train_ind,
      num_classes = num_classes,
      batch_size = batch_size,
      scale = scale
    )
    bench::mark(
      it1(),
      min_iterations = 50L
    )
  }
)
# Параметры бенчмарка
cols <- c("batch_size", "min", "median", "max", "itr/sec", "total_time", "n_itr")
res_bench[, cols]

#   batch_size      min   median      max `itr/sec` total_time n_itr
#        <dbl> <bch:tm> <bch:tm> <bch:tm>     <dbl>   <bch:tm> <int>
# 1         16     25ms  64.36ms   92.2ms     15.9       3.09s    49
# 2         32   48.4ms 118.13ms 197.24ms     8.17       5.88s    48
# 3         64   69.3ms 117.93ms 181.14ms     8.57       5.83s    50
# 4        128  157.2ms 240.74ms 503.87ms     3.85      12.71s    49
# 5        256  359.3ms 613.52ms 988.73ms     1.54       30.5s    47
# 6        512  884.7ms    1.53s    2.07s     0.674      1.11m    45
# 7       1024     2.7s    3.83s    5.47s     0.261      2.81m    44

ggplot(res_bench, aes(x = factor(batch_size), y = median, group = 1)) +
    geom_point() +
    geom_line() +
    ylab("median time, s") +
    theme_minimal()

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle Recognition: як подружити R, C++ та нейросітки

Якщо є достатній обсяг ОЗП, можна серйозно прискорити роботу бази даних шляхом її перенесення до цієї самої ОЗП (для нашого завдання вистачає 32 Гб). У лінуксі за замовчуванням монтується розділ /dev/shm, що займає до половини обсягу ОЗП Можна виділити і більше, відредагувавши /etc/fstab, щоб вийшов запис виду tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Обов'язково перезавантажуємося та перевіряємо результат, виконавши команду df -h.

Ітератор для тестових даних виглядає набагато простіше, оскільки тестовий датасет повністю міститься в ОЗУ:

Ітератор для тестових даних

test_generator <- function(dt,
                           batch_size = 32,
                           scale = 1,
                           color = FALSE,
                           imagenet_preproc = FALSE) {

  # Проверка аргументов
  checkmate::assert_data_table(dt)
  checkmate::assert_count(batch_size)
  checkmate::assert_number(scale, lower = 0.001, upper = 5)
  checkmate::assert_flag(color)
  checkmate::assert_flag(imagenet_preproc)

  # Проставляем номера батчей
  dt[, batch := (.I - 1L) %/% batch_size + 1L]
  data.table::setkey(dt, batch)
  i <- 1
  max_i <- dt[, max(batch)]

  # Замыкание
  function() {
    batch_x <- cpp_process_json_vector(dt[batch == i, drawing], 
                                       scale = scale, color = color)
    if (imagenet_preproc) {
      # Шкалирование c интервала [0, 1] на интервал [-1, 1]
      batch_x <- (batch_x - 0.5) * 2
    }
    result <- list(batch_x)
    i <<- i + 1
    return(result)
  }
}

4. Вибір архітектури моделі

Першою із використаних архітектур була mobilenet v1, особливості якої розібрані в в цьому повідомленні. Вона присутня у стандартному постачанні керас і, відповідно, доступна в однойменному пакеті для R. Але при спробі використовувати її з одноканальними зображеннями з'ясувалося дивне: вхідний тензор повинен завжди мати розмірність (batch, height, width, 3), Тобто кількість каналів змінити не можна. У Python такого обмеження немає, тому ми поспішили і написали свою реалізацію даної архітектури, дотримуючись оригінальної статті (без дропауту, який є в keras-івському варіанті):

Архітектура mobilenet v1

library(keras)

top_3_categorical_accuracy <- custom_metric(
    name = "top_3_categorical_accuracy",
    metric_fn = function(y_true, y_pred) {
         metric_top_k_categorical_accuracy(y_true, y_pred, k = 3)
    }
)

layer_sep_conv_bn <- function(object, 
                              filters,
                              alpha = 1,
                              depth_multiplier = 1,
                              strides = c(2, 2)) {

  # NB! depth_multiplier !=  resolution multiplier
  # https://github.com/keras-team/keras/issues/10349

  layer_depthwise_conv_2d(
    object = object,
    kernel_size = c(3, 3), 
    strides = strides,
    padding = "same",
    depth_multiplier = depth_multiplier
  ) %>%
  layer_batch_normalization() %>% 
  layer_activation_relu() %>%
  layer_conv_2d(
    filters = filters * alpha,
    kernel_size = c(1, 1), 
    strides = c(1, 1)
  ) %>%
  layer_batch_normalization() %>% 
  layer_activation_relu() 
}

get_mobilenet_v1 <- function(input_shape = c(224, 224, 1),
                             num_classes = 340,
                             alpha = 1,
                             depth_multiplier = 1,
                             optimizer = optimizer_adam(lr = 0.002),
                             loss = "categorical_crossentropy",
                             metrics = c("categorical_crossentropy",
                                         top_3_categorical_accuracy)) {

  inputs <- layer_input(shape = input_shape)

  outputs <- inputs %>%
    layer_conv_2d(filters = 32, kernel_size = c(3, 3), strides = c(2, 2), padding = "same") %>%
    layer_batch_normalization() %>% 
    layer_activation_relu() %>%
    layer_sep_conv_bn(filters = 64, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 128, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 128, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 256, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 256, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 1024, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 1024, strides = c(1, 1)) %>%
    layer_global_average_pooling_2d() %>%
    layer_dense(units = num_classes) %>%
    layer_activation_softmax()

    model <- keras_model(
      inputs = inputs,
      outputs = outputs
    )

    model %>% compile(
      optimizer = optimizer,
      loss = loss,
      metrics = metrics
    )

    return(model)
}

Недоліки такого підходу є очевидними. Модель хочеться перевірити багато, а переписувати кожну архітектуру вручну, навпаки, не хочеться. Також ми були позбавлені можливості використовувати ваги моделей, попередньо навчених на imagenet-і. Як завжди, допомогло вивчення документації. Функція get_config() дозволяє отримати опис моделі в придатному для редагування вигляді (base_model_conf$layers - звичайний R-івський список), а функція from_config() виконує зворотне перетворення на модельний об'єкт:

base_model_conf <- get_config(base_model)
base_model_conf$layers[[1]]$config$batch_input_shape[[4]] <- 1L
base_model <- from_config(base_model_conf)

Тепер не складно написати універсальну функцію для отримання будь-якої з тих, що поставляються у складі керас моделей з навченими на imagenet-е вагами або без них:

Функція для завантаження готових архітектур

get_model <- function(name = "mobilenet_v2",
                      input_shape = NULL,
                      weights = "imagenet",
                      pooling = "avg",
                      num_classes = NULL,
                      optimizer = keras::optimizer_adam(lr = 0.002),
                      loss = "categorical_crossentropy",
                      metrics = NULL,
                      color = TRUE,
                      compile = FALSE) {
  # Проверка аргументов
  checkmate::assert_string(name)
  checkmate::assert_integerish(input_shape, lower = 1, upper = 256, len = 3)
  checkmate::assert_count(num_classes)
  checkmate::assert_flag(color)
  checkmate::assert_flag(compile)

  # Получаем объект из пакета keras
  model_fun <- get0(paste0("application_", name), envir = asNamespace("keras"))
  # Проверка наличия объекта в пакете
  if (is.null(model_fun)) {
    stop("Model ", shQuote(name), " not found.", call. = FALSE)
  }

  base_model <- model_fun(
    input_shape = input_shape,
    include_top = FALSE,
    weights = weights,
    pooling = pooling
  )

  # Если изображение не цветное, меняем размерность входа
  if (!color) {
    base_model_conf <- keras::get_config(base_model)
    base_model_conf$layers[[1]]$config$batch_input_shape[[4]] <- 1L
    base_model <- keras::from_config(base_model_conf)
  }

  predictions <- keras::get_layer(base_model, "global_average_pooling2d_1")$output
  predictions <- keras::layer_dense(predictions, units = num_classes, activation = "softmax")
  model <- keras::keras_model(
    inputs = base_model$input,
    outputs = predictions
  )

  if (compile) {
    keras::compile(
      object = model,
      optimizer = optimizer,
      loss = loss,
      metrics = metrics
    )
  }

  return(model)
}

При використанні одноканальних зображень ваги не використовуються. Це можна було б виправити: за допомогою функції get_weights() отримати ваги моделі у вигляді списку з R-івських масивів, змінити розмірність першого елемента цього списку (взявши якийсь один колірний канал або усереднивши всі три), а потім завантажити ваги назад у модель функцією set_weights(). Ми цей функціонал так і не додали, оскільки на цьому етапі вже було зрозуміло, що продуктивніше працювати з кольоровими картинками.

Основну масу експериментів ми виконали з використанням mobilenet версії 1 і 2, а також resnet34. У цьому змаганні добре себе показали сучасніші архітектури, такі як SE-ResNeXt. На жаль, у нашому розпорядженні готових реалізацій не було, а свої власні ми не написали (але обов'язково напишемо).

5. Параметризація скриптів

Для зручності весь код для запуску навчання був оформлений у вигляді єдиного скрипта, що параметризований за допомогою докпт наступним чином:

doc <- '
Usage:
  train_nn.R --help
  train_nn.R --list-models
  train_nn.R [options]

Options:
  -h --help                   Show this message.
  -l --list-models            List available models.
  -m --model=<model>          Neural network model name [default: mobilenet_v2].
  -b --batch-size=<size>      Batch size [default: 32].
  -s --scale-factor=<ratio>   Scale factor [default: 0.5].
  -c --color                  Use color lines [default: FALSE].
  -d --db-dir=<path>          Path to database directory [default: Sys.getenv("db_dir")].
  -r --validate-ratio=<ratio> Validate sample ratio [default: 0.995].
  -n --n-gpu=<number>         Number of GPUs [default: 1].
'
args <- docopt::docopt(doc)

Пакет докпт є реалізацією 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 з актуальної версії керас в R використовувати не можна через невраховані в R-пакеті змін - чекаємо, поки пофіксують.

Цей підхід дозволив значно прискорити експерименти з різними моделями порівняно з більш традиційним запуском скриптів у RStudio (як можливу альтернативу відзначимо пакет tfruns). Але головна перевага полягає у можливості легко керувати запуском скриптів у докері або просто на сервері, не встановлюючи для цього RStudio.

6. Докеризація скриптів

Ми використовували докер з метою забезпечення переносимості середовища для навчання моделей між членами команди та для оперативного розгортання у хмарі. Почати знайомство з цим відносно незвичним для R-програміста інструментом можна з цій серії публікацій або з відеокурсу.

Докер дозволяє як створювати власні образи «з нуля», так і використовувати інші образи як основу для створення власних. При аналізі наявних варіантів ми дійшли висновку, що встановлення драйверів NVIDIA, CUDA+cuDNN та пітонівських бібліотек — досить об'ємна частина образу, і вирішили взяти за основу офіційний образ tensorflow/tensorflow:1.12.0-gpuдодавши туди необхідні R-пакети.

Підсумковий докер-файл вийшов таким:

Докер-файл

FROM tensorflow/tensorflow:1.12.0-gpu

MAINTAINER Artem Klevtsov <[email protected]>

SHELL ["/bin/bash", "-c"]

ARG LOCALE="en_US.UTF-8"
ARG APT_PKG="libopencv-dev r-base r-base-dev littler"
ARG R_BIN_PKG="futile.logger checkmate data.table rcpp rapidjsonr dbi keras jsonlite curl digest remotes"
ARG R_SRC_PKG="xtensor RcppThread docopt MonetDBLite"
ARG PY_PIP_PKG="keras"
ARG DIRS="/db /app /app/data /app/models /app/logs"

RUN source /etc/os-release && 
    echo "deb https://cloud.r-project.org/bin/linux/ubuntu ${UBUNTU_CODENAME}-cran35/" > /etc/apt/sources.list.d/cran35.list && 
    apt-key adv --keyserver keyserver.ubuntu.com --recv-keys E084DAB9 && 
    add-apt-repository -y ppa:marutter/c2d4u3.5 && 
    add-apt-repository -y ppa:timsc/opencv-3.4 && 
    apt-get update && 
    apt-get install -y locales && 
    locale-gen ${LOCALE} && 
    apt-get install -y --no-install-recommends ${APT_PKG} && 
    ln -s /usr/lib/R/site-library/littler/examples/install.r /usr/local/bin/install.r && 
    ln -s /usr/lib/R/site-library/littler/examples/install2.r /usr/local/bin/install2.r && 
    ln -s /usr/lib/R/site-library/littler/examples/installGithub.r /usr/local/bin/installGithub.r && 
    echo 'options(Ncpus = parallel::detectCores())' >> /etc/R/Rprofile.site && 
    echo 'options(repos = c(CRAN = "https://cloud.r-project.org"))' >> /etc/R/Rprofile.site && 
    apt-get install -y $(printf "r-cran-%s " ${R_BIN_PKG}) && 
    install.r ${R_SRC_PKG} && 
    pip install ${PY_PIP_PKG} && 
    mkdir -p ${DIRS} && 
    chmod 777 ${DIRS} && 
    rm -rf /tmp/downloaded_packages/ /tmp/*.rds && 
    rm -rf /var/lib/apt/lists/*

COPY utils /app/utils
COPY src /app/src
COPY tests /app/tests
COPY bin/*.R /app/

ENV DBDIR="/db"
ENV CUDA_HOME="/usr/local/cuda"
ENV PATH="/app:${PATH}"

WORKDIR /app

VOLUME /db
VOLUME /app

CMD bash

Для зручності пакети, що використовуються, були винесені в змінні; Переважна більшість написаних скриптів копіюється всередину контейнерів під час складання. Також ми змінили командну оболонку на /bin/bash для зручності використання вмісту /etc/os-release. Це дозволило уникнути необхідності вказувати версію ОС у коді.

Додатково було написано невеликий баш-скрипт, що дозволяє запускати контейнер із різними командами. Наприклад, це можуть бути скрипти для навчання нейромереж, раніше поміщені всередину контейнера, або командна оболонка для налагодження та моніторингу роботи контейнера:

Скрипт для запуску контейнера

#!/bin/sh

DBDIR=${PWD}/db
LOGSDIR=${PWD}/logs
MODELDIR=${PWD}/models
DATADIR=${PWD}/data
ARGS="--runtime=nvidia --rm -v ${DBDIR}:/db -v ${LOGSDIR}:/app/logs -v ${MODELDIR}:/app/models -v ${DATADIR}:/app/data"

if [ -z "$1" ]; then
    CMD="Rscript /app/train_nn.R"
elif [ "$1" = "bash" ]; then
    ARGS="${ARGS} -ti"
else
    CMD="Rscript /app/train_nn.R $@"
fi

docker run ${ARGS} doodles-tf ${CMD}

Якщо цей баш-скрипт запустити без параметрів, усередині контейнера буде викликано скрипт train_nn.R із значеннями за умовчанням; якщо перший позиційний аргумент - це bash, то контейнер запуститься в інтерактивному режимі з командною оболонкою. У решті випадків відбувається підстановка значень позиційних аргументів: CMD="Rscript /app/train_nn.R $@".

Варто звернути увагу, що директорії з вихідними даними та базою даних, а також директорія для збереження навчених моделей монтуються всередину контейнера з хостової системи, що дозволяє отримати доступ до результатів роботи скриптів без зайвих маніпуляцій.

7. Використання декількох GPU у хмарі Google Cloud

Однією з особливостей змагання були дуже галасливі дані (див. велику картинку, запозичену у @Leigh.plt з ODS-слаку). Боротися з цим допомагають батчі великого розміру, і ми після експериментів на ПК з 1 GPU вирішили освоїти навчання моделей на кількох GPU у хмарі. Використовували GoogleCloud (гарний посібник з основ роботи) через великий вибір доступних конфігурацій, прийнятних цін і бонусних $300. Від жадібності було замовлено інстанс з 4хV100 з SSD та купою ОЗУ, і це було великою помилкою. Гроші така машина їсть швидко, на експериментах без відпрацьованого пайплайну можна розоритися. З навчальними цілями краще купувати K80. А ось великий обсяг ОЗУ став у нагоді — хмарний SSD не вразив швидкодією, тому базу даних при кожному запуску інстансу переносили на dev/shm.

Найбільший інтерес представляє фрагмент коду, який відповідає за використання кількох GPU. Спочатку модель створюється на CPU з використанням менеджера контексту, як на Пітоні:

with(tensorflow::tf$device("/cpu:0"), {
  model_cpu <- get_model(
    name = model_name,
    input_shape = input_shape,
    weights = weights,
    metrics =(top_3_categorical_accuracy,
    compile = FALSE
  )
})

Потім нескомпільована (це важливо) модель копіюється на задану кількість доступних GPU і лише після цього компілюється:

model <- keras::multi_gpu_model(model_cpu, gpus = n_gpu)
keras::compile(
  object = model,
  optimizer = keras::optimizer_adam(lr = 0.0004),
  loss = "categorical_crossentropy",
  metrics = c(top_3_categorical_accuracy)
)

Класичний прийом із заморожуванням всіх шарів, крім останнього, навчанням останнього шару, розморожуванням та донавчанням моделі цілком для кількох GPU реалізувати не вдалося.

За навчанням стежили без використання тензорна дошка, обмежившись записом логів та збереженням моделей з інформативними іменами після кожної епохи:

Колбеки

# Шаблон имени файла лога
log_file_tmpl <- file.path("logs", sprintf(
  "%s_%d_%dch_%s.csv",
  model_name,
  dim_size,
  channels,
  format(Sys.time(), "%Y%m%d%H%M%OS")
))
# Шаблон имени файла модели
model_file_tmpl <- file.path("models", sprintf(
  "%s_%d_%dch_{epoch:02d}_{val_loss:.2f}.h5",
  model_name,
  dim_size,
  channels
))

callbacks_list <- list(
  keras::callback_csv_logger(
    filename = log_file_tmpl
  ),
  keras::callback_early_stopping(
    monitor = "val_loss",
    min_delta = 1e-4,
    patience = 8,
    verbose = 1,
    mode = "min"
  ),
  keras::callback_reduce_lr_on_plateau(
    monitor = "val_loss",
    factor = 0.5, # уменьшаем lr в 2 раза
    patience = 4,
    verbose = 1,
    min_delta = 1e-4,
    mode = "min"
  ),
  keras::callback_model_checkpoint(
    filepath = model_file_tmpl,
    monitor = "val_loss",
    save_best_only = FALSE,
    save_weights_only = FALSE,
    mode = "min"
  )
)

8. Замість ув'язнення

Низка проблем, з якими ми зіткнулися, перемогти поки не вдалося:

  • в керас немає готової функції для автоматичного пошуку оптимальної швидкості навчання (аналогу lr_finder у бібліотеці швидко.ай); доклавши деякі зусилля, можна портувати R сторонні реалізації, наприклад, цю;
  • як наслідок попереднього пункту, не вдалося підібрати правильну швидкість навчання при використанні декількох GPU;
  • не вистачає сучасних архітектур нейромереж, особливо передбачених на imagenet-е;
  • немає one cycle policy та discriminative learning rates (сosine annealing на наше прохання був реалізований, спасибі skeydan).

Що корисного вдалося винести із цього змагання:

  • На відносно малопотужному залозі можна без болю працювати з пристойними (разово перевищують розмір ОЗУ) обсягами даних. Пакет дані.таблиця економить пам'ять за рахунок in-place модифікації таблиць, що дозволяє уникнути їх копіювання, і при правильному використанні його можливостей майже завжди демонструє найбільшу швидкість серед усіх відомих інструментів для скриптових мов. Збереження даних у БД дозволяє у часто взагалі думати необхідність втискати весь датасет в ОЗУ.
  • Повільні функції R можна замінити швидкими на C++ за допомогою пакета Rcpp. Якщо ще використовувати RcppThread або RcppParallel, отримуємо кросплатформні багатопотокові реалізації, тому код на рівні R паралелити не потрібно.
  • Пакетом Rcpp можна користуватися без серйозних знань C++, необхідний мінімум викладено тут. Заголовні файли для ряду крутих бібліотек типу xtensor доступні на CRAN, тобто формується інфраструктура для реалізації проектів, що інтегрують R готовий високопродуктивний код на C++. Додаткова зручність – підсвічування синтаксису та статичний аналізатор коду на С++ у RStudio.
  • докпт дозволяє запускати самодостатні скрипти із параметрами. Це зручно для використання на віддаленому сервері, у т.ч. під докером. У RStudio проводити багатогодинні експерименти з навчанням нейромереж незручно, та й сама установка IDE на сервері не завжди виправдана.
  • Докер забезпечує переносимість коду та відтворюваність результатів між розробниками з різними версіями ОС та бібліотек, а також простоту запуску на серверах. Запустити весь пайплайн для навчання можна лише однією командою.
  • Google Cloud — бюджетний спосіб експериментувати на дорогому залозі, але потрібно вдумливо вибирати конфігурації.
  • Заміряти швидкість роботи окремих фрагментів коду дуже корисно, особливо при поєднанні R та C++, а з пакетом лава - ще й дуже легко.

Загалом цей досвід був дуже корисним, і ми продовжуємо працювати над вирішенням деяких із озвучених проблем.

Джерело: habr.com

Додати коментар або відгук