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 Гб. Арганізатары гарантавалі, што абедзве версіі прайграваюць адны і тыя ж малюнкі, гэта значыць поўная версія з'яўляецца залішняй. У любым выпадку, захоўванне XNUMX млн. карцінак у графічных файлах або ў выглядзе масіваў адразу было прызнана нерэнтабельным, і мы вырашылі зліць усе 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-канфігурацыя.

Рэалізацыя 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. Параметрызацыя скрыптоў

Для зручнасці ўвесь код для запуску навучання быў аформлены ў выглядзе адзінага скрыпта, параметрызаванага пры дапамозе docopt наступным чынам:

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)

Пакет 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 з актуальнай версіі керас у 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 рэалізаваць не атрымалася.

За навучаннем сачылі без выкарыстання tensorboard, абмежаваўшыся запісам логаў і захаваннем мадэляў з інфарматыўнымі імёнамі пасля кожнай эпохі:

Калбэкі

# Шаблон имени файла лога
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 у бібліятэцы fast.ai); прыклаўшы некаторыя намаганні, можна партаваць на 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.
  • docopt дазваляе запускаць самадастатковыя скрыпты з параметрамі. Гэта зручна для выкарыстання на выдаленым серверы, у т.л. пад докерам. У RStudio праводзіць шматгадзінныя эксперыменты з навучаннем нейросетей няёмка, ды і сама ўсталёўка IDE на серверы не заўсёды апраўдана.
  • Докер забяспечвае пераноснасць кода і ўзнаўляльнасць вынікаў паміж распрацоўшчыкамі з рознымі версіямі АС і бібліятэк, а таксама прастату запуску на серверах. Запусціць увесь пайплайн для навучання можна ўсяго адной камандай.
  • Google Cloud - бюджэтны спосаб паэксперыментаваць на дарагім жалезе, але трэба ўдумліва выбіраць канфігурацыі.
  • Замяраць хуткасць працы асобных фрагментаў кода вельмі карысна, асабліва пры спалучэнні R і C++, а з пакетам лаўка - яшчэ і вельмі лёгка.

У цэлым гэты досвед быў вельмі карысным, і мы працягваем працаваць над рашэннем некаторых з агучаных праблем.

Крыніца: habr.com

Дадаць каментар