Quick Draw Doodle Recognition: kā padarīt R, C++ un neironu tīklu draugus

Quick Draw Doodle Recognition: kā padarīt R, C++ un neironu tīklu draugus

Čau Habr!

Pagājušā gada rudenī Kaggle rīkoja ar roku zīmētu attēlu klasifikācijas konkursu Quick Draw Doodle Recognition, kurā cita starpā piedalījās R-zinātnieku komanda: Artjoms Klevcova, Filipa menedžeris и Andrejs Ogurcovs. Mēs neaprakstīsim konkursu sīkāk, tas jau ir izdarīts nesenā publikācija.

Šoreiz neveicās ar medaļu audzēšanu, taču tika iegūta liela vērtīga pieredze, tāpēc vēlos pastāstīt sabiedrībai par vairākām interesantākajām un noderīgākajām lietām Kaglē un ikdienas darbā. Starp apspriestajām tēmām: grūta dzīve bez OpenCV, JSON parsēšana (šajos piemēros apskatīta C++ koda integrācija skriptos vai paketēs R, izmantojot Rcpp), skriptu parametrizēšana un gala risinājuma dokerizācija. Viss ziņojuma kods izpildei piemērotā formā ir pieejams krātuves.

Saturs:

  1. Efektīvi ielādējiet datus no CSV uz MonetDB
  2. Partiju sagatavošana
  3. Iteratori partiju izkraušanai no datu bāzes
  4. Modeļa arhitektūras izvēle
  5. Skripta parametrizācija
  6. Skriptu dokerizācija
  7. Vairāku GPU izmantošana pakalpojumā Google Cloud
  8. Tā vietā, lai noslēgtu

1. Efektīvi ielādējiet datus no CSV datu bāzē MonetDB

Dati šajā konkursā tiek sniegti nevis gatavu attēlu veidā, bet gan 340 CSV failu veidā (katrai klasei viens fails), kas satur JSON ar punktu koordinātām. Savienojot šos punktus ar līnijām, mēs iegūstam galīgo attēlu, kura izmērs ir 256x256 pikseļi. Katram ierakstam ir arī etiķete, kas norāda, vai attēlu pareizi atpazina datu kopas vākšanas laikā izmantotais klasifikators, attēla autora dzīvesvietas valsts divu burtu kods, unikālais identifikators, laika zīmogs. un klases nosaukums, kas atbilst faila nosaukumam. Sākotnējo datu vienkāršotā versija arhīvā sver 7.4 GB un pēc izpakošanas aptuveni 20 GB, pilnie dati pēc izpakošanas aizņem 240 GB. Organizatori nodrošināja, ka abās versijās tika reproducēti vieni un tie paši zīmējumi, kas nozīmē, ka pilna versija bija lieka. Jebkurā gadījumā 50 miljonu attēlu glabāšana grafiskajos failos vai masīvu veidā nekavējoties tika uzskatīta par nerentablu, un mēs nolēmām apvienot visus CSV failus no arhīva. train_simplified.zip datubāzē ar sekojošu vajadzīgā izmēra attēlu ģenerēšanu “lidojumā” katrai partijai.

Par DBVS tika izvēlēta labi pārbaudīta sistēma MonetDB, proti, R kā pakotnes ieviešana MonetDBLite. Paketē ir iekļauta datu bāzes servera iegultā versija, un tā ļauj paņemt serveri tieši no R sesijas un strādāt ar to tur. Datu bāzes izveide un pieslēgšana tai tiek veikta ar vienu komandu:

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

Mums būs jāizveido divas tabulas: viena visiem datiem, otra pakalpojuma informācijai par lejupielādētajiem failiem (noder, ja kaut kas noiet greizi un process ir jāatsāk pēc vairāku failu lejupielādes):

Tabulu veidošana

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"
    )
  )
}

Ātrākais veids, kā ielādēt datus datu bāzē, bija tieši kopēt CSV failus, izmantojot komandu SQL - COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTKur tablename - tabulas nosaukums un path - ceļš uz failu. Strādājot ar arhīvu, tika atklāts, ka iebūvētā ieviešana unzip in R nedarbojas pareizi ar vairākiem failiem no arhīva, tāpēc mēs izmantojām sistēmu unzip (izmantojot parametru getOption("unzip")).

Funkcija rakstīšanai datu bāzē

#' @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))
}

Ja pirms rakstīšanas datu bāzē tabula ir jāpārveido, pietiek ar argumenta ievadīšanu preprocess funkcija, kas pārveidos datus.

Kods datu secīgai ielādei datu bāzē:

Datu ierakstīšana datu bāzē

# Список файлов для записи
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

Datu ielādes laiks var atšķirties atkarībā no izmantotā diskdziņa ātruma īpašībām. Mūsu gadījumā lasīšana un rakstīšana vienā SSD vai no zibatmiņas diska (avota faila) uz SSD (DB) aizņem mazāk nekā 10 minūtes.

Ir vajadzīgas vēl dažas sekundes, lai izveidotu kolonnu ar veselu skaitļu klases apzīmējumu un indeksa kolonnu (ORDERED INDEX) ar rindu numuriem, pēc kuriem tiks ņemti novērojumi, veidojot partijas:

Papildu kolonnu un indeksa izveide

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)"))

Lai atrisinātu partijas izveides problēmu lidojumā, mums bija jāsasniedz maksimālais ātrums nejaušu rindu izvilkšanai no tabulas doodles. Šim nolūkam mēs izmantojām 3 trikus. Pirmais bija samazināt tā veida izmēru, kurā tiek saglabāts novērojuma ID. Sākotnējā datu kopā ID glabāšanai nepieciešamais veids ir bigint, bet novērojumu skaits ļauj ievietot to identifikatorus, kas vienādi ar kārtas numuru int. Šajā gadījumā meklēšana ir daudz ātrāka. Otrs triks bija izmantot ORDERED INDEX — pie šāda lēmuma nonācām empīriski, izpētot visu pieejamo iespējas. Trešais bija parametrizētu vaicājumu izmantošana. Metodes būtība ir vienreiz izpildīt komandu PREPARE ar vēlāku sagatavotas izteiksmes izmantošanu, veidojot tāda paša veida vaicājumu kopumu, taču patiesībā ir priekšrocība salīdzinājumā ar vienkāršu SELECT izrādījās statistiskās kļūdas robežās.

Datu augšupielādes process patērē ne vairāk kā 450 MB RAM. Tas nozīmē, ka aprakstītā pieeja ļauj pārvietot datu kopas, kas sver desmitiem gigabaitu, gandrīz uz jebkura budžeta aparatūras, ieskaitot dažas vienas plates ierīces, kas ir diezgan forši.

Atliek tikai izmērīt (nejauši) datu izguves ātrumu un novērtēt mērogošanu, ņemot dažāda lieluma partijas:

Datu bāzes etalons

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: kā padarīt R, C++ un neironu tīklu draugus

2. Partiju sagatavošana

Viss partijas sagatavošanas process sastāv no šādām darbībām:

  1. Vairāku JSON parsēšana, kas satur virkņu vektorus ar punktu koordinātām.
  2. Krāsainu līniju zīmēšana, pamatojoties uz punktu koordinātām uz vajadzīgā izmēra attēla (piemēram, 256×256 vai 128×128).
  3. Iegūto attēlu pārvēršana tensorā.

Python kodolu konkurences ietvaros problēma tika atrisināta galvenokārt, izmantojot OpenCV. Viens no vienkāršākajiem un acīmredzamākajiem R analogiem izskatītos šādi:

JSON konvertēšana uz Tensoru programmā 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)
}

Zīmēšana tiek veikta, izmantojot standarta R rīkus un saglabāta pagaidu PNG, kas tiek glabāta RAM (operētājsistēmā Linux pagaidu R direktoriji atrodas direktorijā /tmp, uzstādīts RAM). Pēc tam šis fails tiek nolasīts kā trīsdimensiju masīvs ar skaitļiem no 0 līdz 1. Tas ir svarīgi, jo tradicionālāks BMP tiktu nolasīts neapstrādātā masīvā ar heksadecimālo krāsu kodiem.

Pārbaudīsim rezultātu:

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: kā padarīt R, C++ un neironu tīklu draugus

Pati partija tiks veidota šādi:

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

Šī realizācija mums šķita neoptimāla, jo lielu partiju veidošana aizņem nepieklājīgi ilgu laiku, un mēs nolēmām izmantot kolēģu pieredzi, izmantojot jaudīgu bibliotēku. OpenCV. Toreiz nebija gatavas pakotnes priekš R (tagad tādas nav), tāpēc minimāla nepieciešamās funkcionalitātes realizācija tika uzrakstīta C++ ar integrāciju R kodā, izmantojot Rcpp.

Lai atrisinātu problēmu, tika izmantotas šādas pakotnes un bibliotēkas:

  1. OpenCV darbam ar attēliem un līniju zīmēšanai. Izmantotas iepriekš instalētas sistēmas bibliotēkas un galvenes faili, kā arī dinamiskā saite.

  2. xtensor darbam ar daudzdimensiju masīviem un tenzoriem. Mēs izmantojām galvenes failus, kas iekļauti tāda paša nosaukuma R pakotnē. Bibliotēka ļauj strādāt ar daudzdimensiju masīviem gan rindu galvenajā, gan kolonnu galvenajā secībā.

  3. ndjson JSON parsēšanai. Šī bibliotēka tiek izmantota xtensor automātiski, ja tas ir projektā.

  4. RcppPavediens vektora daudzpavedienu apstrādes organizēšanai no JSON. Izmantoti šīs pakotnes nodrošinātie galvenes faili. No populārākiem RcppParallel Paketē, cita starpā, ir iebūvēts cilpas pārtraukšanas mehānisms.

Jāatzīmē, ka xtensor izrādījās Dieva dāvana: papildus tam, ka tam ir plaša funkcionalitāte un augsta veiktspēja, tā izstrādātāji izrādījās diezgan atsaucīgi un ātri un detalizēti atbildēja uz jautājumiem. Ar to palīdzību bija iespējams realizēt OpenCV matricu transformācijas xtensoros, kā arī veids, kā apvienot 3-dimensiju attēla tensorus pareizās dimensijas 4-dimensiju tensorā (pašā partija).

Materiāli Rcpp, xtensor un RcppThread apguvei

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

Lai apkopotu failus, kuros tiek izmantoti sistēmas faili un dinamiska saite ar sistēmā instalētajām bibliotēkām, mēs izmantojām pakotnē ieviesto spraudņa mehānismu Rcpp. Lai automātiski atrastu ceļus un karogus, mēs izmantojām populāru Linux utilītu pkg-config.

Rcpp spraudņa ieviešana OpenCV bibliotēkas lietošanai

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)
  ))
})

Spraudņa darbības rezultātā kompilācijas procesā tiks aizstātas šādas vērtības:

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"

Īstenošanas kods JSON parsēšanai un partijas ģenerēšanai pārsūtīšanai uz modeli ir norādīts zem spoilera. Vispirms pievienojiet vietējo projektu direktoriju, lai meklētu galvenes failus (nepieciešams ndjson):

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

JSON konvertēšana uz tenzoru programmā C++

// [[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;
}

Šis kods ir jāievieto failā src/cv_xt.cpp un kompilējiet ar komandu Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); nepieciešami arī darbam nlohmann/json.hpp no krātuve. Kods ir sadalīts vairākās funkcijās:

  • to_xt — veidnes funkcija attēla matricas pārveidošanai (cv::Mat) uz tensoru xt::xtensor;

  • parse_json — funkcija parsē JSON virkni, izvelk punktu koordinātas, iepakojot tās vektorā;

  • ocv_draw_lines — no iegūtā punktu vektora zīmē daudzkrāsainas līnijas;

  • process — apvieno iepriekš minētās funkcijas un arī pievieno iespēju mērogot iegūto attēlu;

  • cpp_process_json_str - funkciju aptinums process, kas eksportē rezultātu uz R-objektu (daudzdimensiju masīvs);

  • cpp_process_json_vector - funkciju aptinums cpp_process_json_str, kas ļauj apstrādāt virknes vektoru vairāku vītņu režīmā.

Lai zīmētu daudzkrāsainas līnijas, tika izmantots HSV krāsu modelis, kam sekoja pārveidošana uz RGB. Pārbaudīsim rezultātu:

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

Quick Draw Doodle Recognition: kā padarīt R, C++ un neironu tīklu draugus
Īstenošanas ātruma salīdzinājums R un C++

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: kā padarīt R, C++ un neironu tīklu draugus

Kā redzat, ātruma pieaugums izrādījās ļoti ievērojams, un, paralēli R kodu, nav iespējams panākt C++ kodu.

3. Iteratori partiju izkraušanai no datu bāzes

R ir pelnīta reputācija attiecībā uz datu apstrādi, kas iekļaujas RAM, savukārt Python vairāk raksturīga iteratīva datu apstrāde, kas ļauj viegli un dabiski īstenot ārpus kodola aprēķinus (aprēķinus, izmantojot ārējo atmiņu). Klasisks un mums atbilstošs piemērs aprakstītās problēmas kontekstā ir dziļi neironu tīkli, kas apmācīti ar gradienta nolaišanās metodi ar gradienta tuvināšanu katrā solī, izmantojot nelielu novērojumu daļu vai mini-partiju.

Python rakstītajās dziļās mācīšanās ietvaros ir īpašas klases, kas ievieš iteratorus, pamatojoties uz datiem: tabulas, attēli mapēs, bināri formāti utt. Varat izmantot gatavas opcijas vai rakstīt savu konkrētu uzdevumu veikšanai. Programmā R mēs varam izmantot visas Python bibliotēkas iespējas keras ar dažādām aizmugursistēmām, izmantojot tāda paša nosaukuma pakotni, kas savukārt darbojas uz pakotnes tīklveida. Pēdējais ir pelnījis atsevišķu garu rakstu; tas ne tikai ļauj palaist Python kodu no R, bet arī ļauj pārsūtīt objektus starp R un Python sesijām, automātiski veicot visas nepieciešamās tipa konvertācijas.

Mēs atbrīvojāmies no nepieciešamības glabāt visus datus RAM, izmantojot MonetDBLite, visu “neironu tīkla” darbu veiks sākotnējais kods Python, mums tikai jāraksta iterators pār datiem, jo ​​nekas nav gatavs šādai situācijai vai nu R, vai Python. Būtībā tam ir tikai divas prasības: tai ir jāatgriež partijas bezgalīgā cilpā un jāsaglabā savs stāvoklis starp iterācijām (pēdējais R tiek īstenots visvienkāršākajā veidā, izmantojot slēgšanu). Iepriekš iteratora iekšienē R masīvi bija nepārprotami jāpārvērš par masīviem, taču pašreizējā pakotnes versija keras dara pati.

Apmācības un validācijas datu iterators izrādījās šāds:

Iterators apmācībai un datu apstiprināšanai

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)
  }
}

Funkcija kā ievadi ņem mainīgo ar savienojumu ar datu bāzi, izmantoto rindu skaitu, klašu skaitu, partijas lielumu, mērogu (scale = 1 atbilst 256x256 pikseļu attēlu renderēšanai, scale = 0.5 — 128x128 pikseļi), krāsu indikators (color = FALSE norāda renderēšanu pelēktoņos, kad to lieto color = TRUE katrs triepiens tiek uzzīmēts jaunā krāsā) un priekšapstrādes indikators tīkliem, kas iepriekš apmācīti, izmantojot imagenet. Pēdējais ir nepieciešams, lai mērogotu pikseļu vērtības no intervāla [0, 1] līdz intervālam [-1, 1], kas tika izmantots, apmācot piegādāto. keras modeļiem.

Ārējā funkcija satur argumentu tipa pārbaudi, tabulu data.table ar nejauši sajauktiem rindu numuriem no samples_index un partiju numuri, skaitītājs un maksimālais partiju skaits, kā arī SQL izteiksme datu izkraušanai no datu bāzes. Turklāt mēs definējām ātru funkcijas analogu iekšpusē keras::to_categorical(). Mēs izmantojām gandrīz visus datus apmācībai, atstājot pusprocentu apstiprināšanai, tāpēc laikmeta lielumu ierobežoja parametrs steps_per_epoch kad sauc keras::fit_generator(), un nosacījums if (i > max_i) strādāja tikai validācijas iteratoram.

Iekšējā funkcijā tiek izgūti rindu indeksi nākamajai partijai, ieraksti tiek izlādēti no datu bāzes, palielinot partiju skaitītāju, JSON parsēšana (funkcija cpp_process_json_vector(), rakstīts C++) un izveidojot attēliem atbilstošus masīvus. Pēc tam tiek izveidoti one-hot vektori ar klases etiķetēm, masīvi ar pikseļu vērtībām un etiķetēm tiek apvienoti sarakstā, kas ir atgriešanas vērtība. Lai paātrinātu darbu, mēs izmantojām indeksu izveidi tabulās data.table un modifikācijas, izmantojot saiti - bez šīm pakotnes "mikroshēmām" dati.tabula Ir diezgan grūti iedomāties efektīvu darbu ar ievērojamu datu apjomu R.

Core i5 klēpjdatora ātruma mērījumu rezultāti ir šādi:

Iteratora etalons

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: kā padarīt R, C++ un neironu tīklu draugus

Ja jums ir pietiekami daudz RAM, varat nopietni paātrināt datu bāzes darbību, pārsūtot to uz šo pašu RAM (mūsu uzdevumam pietiek ar 32 GB). Operētājsistēmā Linux nodalījums ir uzstādīts pēc noklusējuma /dev/shm, kas aizņem līdz pusei RAM ietilpības. Rediģējot, varat izcelt vairāk /etc/fstablai iegūtu ierakstu patīk tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Noteikti restartējiet un pārbaudiet rezultātu, izpildot komandu df -h.

Testa datu iterators izskatās daudz vienkāršāks, jo testa datu kopa pilnībā iekļaujas RAM:

Iterators testa datiem

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. Modeļa arhitektūras izvēle

Pirmā izmantotā arhitektūra bija mobilais tīkls v1, kuras īpašības ir apskatītas šis ziņa. Tas ir iekļauts standarta komplektācijā keras un attiecīgi ir pieejams iepakojumā ar tādu pašu nosaukumu R. Bet, mēģinot to izmantot ar viena kanāla attēliem, izrādījās dīvaina lieta: ievades tensoram vienmēr jābūt izmēram (batch, height, width, 3), tas ir, kanālu skaitu nevar mainīt. Python nav šāda ierobežojuma, tāpēc mēs steidzāmies un uzrakstījām paši savu šīs arhitektūras ieviešanu, sekojot oriģinālajam rakstam (bez atbiruma, kas ir keras versijā):

Mobilenet v1 arhitektūra

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)
}

Šīs pieejas trūkumi ir acīmredzami. Es vēlos pārbaudīt daudzus modeļus, bet gluži pretēji, es nevēlos pārrakstīt katru arhitektūru manuāli. Mums arī tika liegta iespēja izmantot modeļu atsvarus, kas iepriekš apmācīti imagenet. Kā parasti, palīdzēja dokumentācijas izpēte. Funkcija get_config() ļauj iegūt modeļa aprakstu rediģēšanai piemērotā formā (base_model_conf$layers - parastais R saraksts) un funkcija from_config() veic apgriezto konvertēšanu uz modeļa objektu:

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)

Tagad nav grūti uzrakstīt universālu funkciju, lai iegūtu kādu no piegādātajiem keras modeļi ar vai bez svariem, kas apmācīti, izmantojot imagenet:

Funkcija gatavu arhitektūru ielādei

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)
}

Izmantojot viena kanāla attēlus, netiek izmantoti iepriekš sagatavoti svari. To var labot: izmantojot funkciju get_weights() iegūstiet modeļa svarus R masīvu saraksta veidā, mainiet šī saraksta pirmā elementa izmēru (izņemot vienu krāsu kanālu vai aprēķinot vidējos visus trīs) un pēc tam ielādējiet svarus atpakaļ modelī ar funkciju set_weights(). Mēs nekad nepievienojām šo funkcionalitāti, jo šajā posmā jau bija skaidrs, ka produktīvāk ir strādāt ar krāsainiem attēliem.

Lielāko daļu eksperimentu veicām, izmantojot mobilā tīkla 1. un 2. versiju, kā arī resnet34. Mūsdienīgākas arhitektūras, piemēram, SE-ResNeXt, šajās sacensībās veica labus rezultātus. Diemžēl mūsu rīcībā nebija gatavu implementāciju, un arī paši nerakstījām (bet noteikti rakstīsim).

5. Skriptu parametrizācija

Ērtības labad viss apmācības sākšanas kods tika izveidots kā viens skripts, kas tika parametrizēts, izmantojot docpt šādi:

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)

Paka docpt atspoguļo ieviešanu http://docopt.org/ par R. Ar tās palīdzību tiek palaisti skripti ar vienkāršām komandām, piemēram Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db vai ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, ja fails train_nn.R ir izpildāma (šī komanda sāks modeļa apmācību resnet50 uz trīskrāsu attēliem, kuru izmērs ir 128x128 pikseļi, datu bāzei jāatrodas mapē /home/andrey/doodle_db). Sarakstam varat pievienot mācīšanās ātrumu, optimizētāja veidu un citus pielāgojamus parametrus. Publikācijas sagatavošanas procesā atklājās, ka arhitektūra mobilenet_v2 no pašreizējās versijas keras R lietošanā nevar sakarā ar izmaiņām, kas nav ņemtas vērā R paketē, mēs gaidām, kad viņi to izlabos.

Šī pieeja ļāva ievērojami paātrināt eksperimentus ar dažādiem modeļiem, salīdzinot ar tradicionālāku skriptu palaišanu programmā RStudio (mēs atzīmējam pakotni kā iespējamu alternatīvu tfruns). Bet galvenā priekšrocība ir iespēja viegli pārvaldīt skriptu palaišanu programmā Docker vai vienkārši serverī, šim nolūkam neinstalējot RStudio.

6. Skriptu dokerizācija

Mēs izmantojām Docker, lai nodrošinātu vides pārnesamību apmācības modeļiem starp komandas locekļiem un ātrai izvietošanai mākonī. Ar šo R programmētājam salīdzinoši neparasto rīku var sākt iepazīties ar šis publikāciju sērija vai video kurss.

Docker ļauj gan izveidot savus attēlus no nulles, gan izmantot citus attēlus kā pamatu savu izveidei. Analizējot pieejamās iespējas, nonācām pie secinājuma, ka NVIDIA, CUDA+cuDNN draiveru un Python bibliotēku instalēšana ir diezgan apjomīga attēla daļa, un mēs nolēmām par pamatu ņemt oficiālo attēlu. tensorflow/tensorflow:1.12.0-gpu, pievienojot tur nepieciešamās R pakotnes.

Galīgais docker fails izskatījās šādi:

Dockerfile

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

Ērtības labad izmantotie iepakojumi tika ievietoti mainīgajos; lielākā daļa rakstīto skriptu montāžas laikā tiek kopēti konteineros. Mēs arī mainījām komandu apvalku uz /bin/bash satura lietošanas ērtībai /etc/os-release. Tādējādi tika novērsta nepieciešamība kodā norādīt OS versiju.

Turklāt tika uzrakstīts neliels bash skripts, kas ļauj palaist konteineru ar dažādām komandām. Piemēram, tie varētu būt skripti neironu tīklu apmācībai, kas iepriekš tika ievietoti konteinerā, vai komandu apvalks atkļūdošanai un konteinera darbības uzraudzībai:

Skripts konteinera palaišanai

#!/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}

Ja šis bash skripts tiek palaists bez parametriem, skripts tiks izsaukts konteinerā train_nn.R ar noklusējuma vērtībām; ja pirmais pozicionālais arguments ir "bash", tad konteiners sāksies interaktīvi ar komandas apvalku. Visos citos gadījumos pozicionālo argumentu vērtības tiek aizstātas: CMD="Rscript /app/train_nn.R $@".

Ir vērts atzīmēt, ka direktoriji ar avota datiem un datu bāzi, kā arī direktorijs apmācīto modeļu saglabāšanai ir uzstādīti konteinera iekšpusē no resursdatora sistēmas, kas ļauj piekļūt skriptu rezultātiem bez nevajadzīgām manipulācijām.

7. Vairāku GPU izmantošana pakalpojumā Google Cloud

Viena no konkursa iezīmēm bija ļoti trokšņainie dati (skat. titulbildi, aizgūts no @Leigh.plt no ODS slack). Lielas partijas palīdz ar to cīnīties, un pēc eksperimentiem datorā ar 1 GPU mēs nolēmām apgūt apmācību modeļus vairākos mākonī esošajos GPU. Lietots GoogleCloud (labs ceļvedis pamatiem), pateicoties lielajai pieejamo konfigurāciju izvēlei, saprātīgām cenām un 300 USD bonusam. Mantkārības dēļ pasūtīju 4xV100 eksemplāru ar SSD un tonnu RAM, un tā bija liela kļūda. Šāda iekārta ātri patērē naudu, jūs varat izgāzties, eksperimentējot bez pārbaudīta cauruļvada. Izglītības nolūkos labāk ņemt K80. Taču lielais operatīvās atmiņas apjoms noderēja – mākoņa SSD ar savu veiktspēju nepārsteidza, tāpēc datubāze tika pārsūtīta uz dev/shm.

Vislielāko interesi rada koda fragments, kas ir atbildīgs par vairāku GPU izmantošanu. Pirmkārt, modelis tiek izveidots CPU, izmantojot konteksta pārvaldnieku, tāpat kā Python:

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
  )
})

Pēc tam nekompilētais (tas ir svarīgi) modelis tiek kopēts uz noteiktu skaitu pieejamo GPU un tikai pēc tam tiek kompilēts:

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)
)

Nevarēja ieviest klasisko paņēmienu visu slāņu iesaldēšanai, izņemot pēdējo, pēdējā slāņa apmācību, visa modeļa atsaldēšanu un pārkvalificēšanu vairākiem GPU.

Apmācība tika uzraudzīta bez lietošanas. tensorboard, aprobežojoties ar žurnālu ierakstīšanu un modeļu saglabāšanu ar informatīviem nosaukumiem pēc katra laikmeta:

Atzvani

# Шаблон имени файла лога
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. Secinājuma vietā

Vairākas problēmas, ar kurām esam saskārušies, vēl nav atrisinātas:

  • в keras nav gatavas funkcijas, lai automātiski meklētu optimālo mācīšanās ātrumu (analogs lr_finder bibliotēkā ātri.ai); Ar zināmām pūlēm ir iespējams pārnest trešo pušu implementācijas uz R, piemēram, šis;
  • iepriekšējā punkta rezultātā, izmantojot vairākus GPU, nebija iespējams izvēlēties pareizo treniņu ātrumu;
  • trūkst modernu neironu tīklu arhitektūru, jo īpaši to, kas ir iepriekš apmācītas uz imagenet;
  • neviena cikla politika un diskriminējoši mācību rādītāji (kosinusa atkausēšana tika veikta pēc mūsu pieprasījuma īstenota, Paldies skeydan).

Ko noderīgu uzzināja šajā konkursā:

  • Izmantojot salīdzinoši mazjaudas aparatūru, bez sāpēm varat strādāt ar pienācīgu (daudzkārt lielāku par RAM) datu apjomu. Plastmasas maisiņš dati.tabula ietaupa atmiņu tabulu uz vietas veikto modifikāciju dēļ, kas ļauj izvairīties no to kopēšanas, un, pareizi lietojot, tās iespējas gandrīz vienmēr demonstrē lielāko ātrumu starp visiem mums zināmajiem skriptu valodu rīkiem. Datu saglabāšana datu bāzē ļauj daudzos gadījumos vispār nedomāt par nepieciešamību iespiest visu datu kopu RAM.
  • Lēnās funkcijas R var aizstāt ar ātrajām C++, izmantojot pakotni Rcpp. Ja papildus lietošanai RcppPavediens vai RcppParallel, mēs iegūstam starpplatformu daudzpavedienu implementācijas, tāpēc nav nepieciešams paralēli kodēt R līmenī.
  • Iepakojums Rcpp var lietot bez nopietnām C++ zināšanām, ir iezīmēts nepieciešamais minimums šeit. Galvenes faili vairākām lieliskām C bibliotēkām, piemēram xtensor pieejams CRAN, tas ir, tiek veidota infrastruktūra projektu īstenošanai, kas integrē gatavu augstas veiktspējas C++ kodu R. Papildu ērtības ir sintakses izcelšana un statisks C++ koda analizators programmā RStudio.
  • docpt ļauj palaist autonomus skriptus ar parametriem. Tas ir ērti lietošanai attālā serverī, t.sk. zem doka. Programmā RStudio ir neērti veikt daudzu stundu eksperimentus ar neironu tīklu apmācību, un IDE instalēšana pašā serverī ne vienmēr ir pamatota.
  • Docker nodrošina koda pārnesamību un rezultātu reproducējamību starp izstrādātājiem ar dažādām OS versijām un bibliotēkām, kā arī vieglu izpildi serveros. Jūs varat palaist visu apmācību cauruļvadu tikai ar vienu komandu.
  • Google Cloud ir budžetam draudzīgs veids, kā eksperimentēt ar dārgu aparatūru, taču jums rūpīgi jāizvēlas konfigurācijas.
  • Atsevišķu koda fragmentu ātruma mērīšana ir ļoti noderīga, īpaši kombinējot R un C++, kā arī ar pakotni sols - arī ļoti viegli.

Kopumā šī pieredze bija ļoti vērtīga, un mēs turpinām strādāt, lai atrisinātu dažas no izvirzītajām problēmām.

Avots: www.habr.com

Pievieno komentāru