Quick Draw Doodle Recognition: hvordan man bliver venner med R, C++ og neurale netværk

Quick Draw Doodle Recognition: hvordan man bliver venner med R, C++ og neurale netværk

Hej Habr!

Sidste efterår var Kaggle vært for en konkurrence om at klassificere håndtegnede billeder, Quick Draw Doodle Recognition, hvor blandt andet et hold af R-forskere deltog: Artem Klevtsova, Philippa Manager и Andrey Ogurtsov. Vi vil ikke beskrive konkurrencen i detaljer; det er allerede gjort i nylig udgivelse.

Denne gang lykkedes det ikke med medaljedyrkning, men der blev høstet mange værdifulde erfaringer, så jeg vil gerne fortælle samfundet om en række af de mest interessante og brugbare ting på Kagle og i det daglige arbejde. Blandt de diskuterede emner: svært liv uden OpenCV, JSON-parsing (disse eksempler undersøger integrationen af ​​C++-kode i scripts eller pakker i R vha. Rcpp), parametrisering af scripts og dockerisering af den endelige løsning. Al kode fra meddelelsen i en form, der er egnet til udførelse, er tilgængelig i depoter.

Indhold:

  1. Indlæs effektivt data fra CSV til MonetDB
  2. Forberedelse af partier
  3. Iteratorer til udlæsning af batches fra databasen
  4. Valg af modelarkitektur
  5. Script-parameterisering
  6. Dockerisering af scripts
  7. Brug af flere GPU'er på Google Cloud
  8. I stedet for en konklusion

1. Indlæs effektivt data fra CSV til MonetDB-databasen

Dataene i denne konkurrence leveres ikke i form af færdige billeder, men i form af 340 CSV-filer (en fil for hver klasse), der indeholder JSON'er med punktkoordinater. Ved at forbinde disse punkter med linjer får vi et endeligt billede, der måler 256x256 pixels. For hver post er der også en etiket, der angiver, om billedet blev korrekt genkendt af den klassificering, der blev brugt på det tidspunkt, da datasættet blev indsamlet, en kode på to bogstaver for billedforfatterens bopælsland, en unik identifikator, et tidsstempel og et klassenavn, der matcher filnavnet. En forenklet version af de originale data vejer 7.4 GB i arkivet og cirka 20 GB efter udpakning, de fulde data efter udpakning fylder 240 GB. Arrangørerne sikrede, at begge versioner gengav de samme tegninger, hvilket betyder, at den fulde version var overflødig. Under alle omstændigheder blev lagring af 50 millioner billeder i grafiske filer eller i form af arrays umiddelbart betragtet som urentabelt, og vi besluttede at flette alle CSV-filer fra arkivet train_simplified.zip ind i databasen med efterfølgende generering af billeder i den nødvendige størrelse "on the fly" for hver batch.

Et velafprøvet system blev valgt som DBMS MonetDB, nemlig en implementering for R som en pakke MonetDBLite. Pakken indeholder en indlejret version af databaseserveren og giver dig mulighed for at hente serveren direkte fra en R-session og arbejde med den der. Oprettelse af en database og tilslutning til den udføres med én kommando:

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

Vi bliver nødt til at oprette to tabeller: en for alle data, den anden for serviceoplysninger om downloadede filer (nyttigt, hvis noget går galt, og processen skal genoptages efter download af flere filer):

Oprettelse af tabeller

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

Den hurtigste måde at indlæse data i databasen var at kopiere CSV-filer direkte ved hjælp af SQL - kommando COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTHvor tablename - tabelnavn og path - stien til filen. Under arbejdet med arkivet blev det opdaget, at den indbyggede implementering unzip i R fungerer ikke korrekt med en række filer fra arkivet, så vi brugte systemet unzip (ved hjælp af parameteren getOption("unzip")).

Funktion til at skrive til databasen

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

Hvis du skal transformere tabellen, før du skriver den til databasen, er det nok at sende argumentet preprocess funktion, der vil transformere dataene.

Kode til sekventiel indlæsning af data i databasen:

Skrivning af data til databasen

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

Dataindlæsningstiden kan variere afhængigt af hastighedsegenskaberne for det anvendte drev. I vores tilfælde tager læsning og skrivning inden for en SSD eller fra et flashdrev (kildefil) til en SSD (DB) mindre end 10 minutter.

Det tager et par sekunder mere at oprette en kolonne med en heltalsklasseetiket og en indekskolonne (ORDERED INDEX) med linjenumre, som observationer vil blive udtaget efter, når der oprettes batches:

Oprettelse af yderligere kolonner og indeks

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

For at løse problemet med at oprette en batch i farten, var vi nødt til at opnå den maksimale hastighed for at udtrække tilfældige rækker fra tabellen doodles. Til dette brugte vi 3 tricks. Den første var at reducere dimensionaliteten af ​​den type, der gemmer observations-id'et. I det originale datasæt er den type, der kræves for at gemme ID'et bigint, men antallet af observationer gør det muligt at tilpasse deres identifikatorer, lig med ordenstallet, i typen int. Søgningen er meget hurtigere i dette tilfælde. Det andet trick var at bruge ORDERED INDEX — vi kom til denne beslutning empirisk efter at have gennemgået alle tilgængelige optioner. Den tredje var at bruge parametriserede forespørgsler. Essensen af ​​metoden er at udføre kommandoen én gang PREPARE med efterfølgende brug af et forberedt udtryk ved oprettelse af en masse forespørgsler af samme type, men faktisk er der en fordel i forhold til en simpel SELECT viste sig at være inden for rækkevidden af ​​statistiske fejl.

Processen med at uploade data bruger ikke mere end 450 MB RAM. Det vil sige, at den beskrevne tilgang giver dig mulighed for at flytte datasæt, der vejer titusinder af gigabyte på næsten enhver budgethardware, inklusive nogle enkeltbordsenheder, hvilket er ret fedt.

Det eneste, der er tilbage, er at måle hastigheden for at hente (tilfældige) data og evaluere skaleringen ved prøveudtagning af batches af forskellige størrelser:

Database benchmark

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: hvordan man bliver venner med R, C++ og neurale netværk

2. Klargøring af batches

Hele batchforberedelsesprocessen består af følgende trin:

  1. Parsing af flere JSON'er, der indeholder vektorer af strenge med koordinater af punkter.
  2. Tegning af farvede linjer baseret på koordinaterne for punkter på et billede af den nødvendige størrelse (for eksempel 256×256 eller 128×128).
  3. Konvertering af de resulterende billeder til en tensor.

Som en del af konkurrencen blandt Python-kerner blev problemet løst primært ved hjælp af OpenCV. En af de enkleste og mest åbenlyse analoger i R ville se sådan ud:

Implementering af JSON til Tensor-konvertering i 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)
}

Tegning udføres ved hjælp af standard R-værktøjer og gemmes i en midlertidig PNG gemt i RAM (på Linux er midlertidige R-mapper placeret i mappen /tmp, monteret i RAM). Denne fil læses derefter som en tredimensionel matrix med tal fra 0 til 1. Dette er vigtigt, fordi en mere konventionel BMP ville blive læst ind i en rå matrix med hex-farvekoder.

Lad os teste resultatet:

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: hvordan man bliver venner med R, C++ og neurale netværk

Selve batchen vil blive dannet som følger:

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

Denne implementering virkede suboptimal for os, da dannelsen af ​​store partier tager uanstændigt lang tid, og vi besluttede at drage fordel af vores kollegers erfaring ved at bruge et kraftfuldt bibliotek OpenCV. På det tidspunkt var der ingen færdiglavet pakke til R (der er ingen nu), så en minimal implementering af den nødvendige funktionalitet blev skrevet i C++ med integration i R-kode vha. Rcpp.

For at løse problemet blev følgende pakker og biblioteker brugt:

  1. OpenCV til at arbejde med billeder og tegne linjer. Brugte forudinstallerede systembiblioteker og header-filer, samt dynamiske links.

  2. xtensor til arbejde med multidimensionelle arrays og tensorer. Vi brugte header-filer inkluderet i R-pakken af ​​samme navn. Biblioteket giver dig mulighed for at arbejde med flerdimensionelle arrays, både i række-major og kolonne-major rækkefølge.

  3. ndjson til at parse JSON. Dette bibliotek bruges i xtensor automatisk, hvis det er til stede i projektet.

  4. RcppThread til at organisere multi-threaded behandling af en vektor fra JSON. Brugte header-filerne fra denne pakke. Fra mere populær RcppParallel Pakken har blandt andet en indbygget loop interrupt-mekanisme.

Det skal bemærkes, at xtensor viste sig at være en gave: Ud over det faktum, at det har omfattende funktionalitet og høj ydeevne, viste dets udviklere sig at være ret lydhøre og besvarede spørgsmål hurtigt og detaljeret. Med deres hjælp var det muligt at implementere transformationer af OpenCV-matricer til xtensor-tensorer, samt en måde at kombinere 3-dimensionelle billedtensorer til en 4-dimensionel tensor af den korrekte dimension (selve batchen).

Materialer til læring af Rcpp, xtensor og 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

For at kompilere filer, der bruger systemfiler og dynamiske links med biblioteker installeret på systemet, brugte vi plugin-mekanismen implementeret i pakken Rcpp. For automatisk at finde stier og flag brugte vi et populært Linux-værktøj pkg-config.

Implementering af Rcpp-plugin til brug af OpenCV-biblioteket

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

Som et resultat af pluginets drift vil følgende værdier blive erstattet under kompileringsprocessen:

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"

Implementeringskoden til at parse JSON og generere en batch til transmission til modellen er givet under spoileren. Tilføj først en lokal projektmappe for at søge efter header-filer (nødvendig for ndjson):

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

Implementering af JSON til tensor konvertering i 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;
}

Denne kode skal placeres i filen src/cv_xt.cpp og kompiler med kommandoen Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); også påkrævet til arbejde nlohmann/json.hpp af depot. Koden er opdelt i flere funktioner:

  • to_xt — en skabelonfunktion til at transformere en billedmatrix (cv::Mat) til en tensor xt::xtensor;

  • parse_json — funktionen analyserer en JSON-streng, udtrækker koordinaterne for punkter, pakker dem ind i en vektor;

  • ocv_draw_lines — fra den resulterende vektor af punkter, tegner flerfarvede linjer;

  • process — kombinerer ovenstående funktioner og tilføjer også muligheden for at skalere det resulterende billede;

  • cpp_process_json_str - indpakning over funktionen process, som eksporterer resultatet til et R-objekt (multidimensional matrix);

  • cpp_process_json_vector - indpakning over funktionen cpp_process_json_str, som giver dig mulighed for at behandle en strengvektor i multi-threaded mode.

For at tegne flerfarvede linjer blev HSV-farvemodellen brugt, efterfulgt af konvertering til RGB. Lad os teste resultatet:

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

Quick Draw Doodle Recognition: hvordan man bliver venner med R, C++ og neurale netværk
Sammenligning af hastigheden af ​​implementeringer i R og 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: hvordan man bliver venner med R, C++ og neurale netværk

Som du kan se, viste hastighedsstigningen sig at være meget markant, og det er ikke muligt at indhente C++-kode ved at parallelisere R-kode.

3. Iteratorer til udlæsning af batches fra databasen

R har et velfortjent ry for at behandle data, der passer i RAM, mens Python er mere karakteriseret ved iterativ databehandling, så du nemt og naturligt kan implementere out-of-core beregninger (beregninger ved hjælp af ekstern hukommelse). Et klassisk og relevant eksempel for os i forbindelse med det beskrevne problem er dybe neurale netværk trænet af gradient descent-metoden med tilnærmelse af gradienten ved hvert trin ved hjælp af en lille del af observationer, eller mini-batch.

Deep learning frameworks skrevet i Python har specielle klasser, der implementerer iteratorer baseret på data: tabeller, billeder i mapper, binære formater osv. Du kan bruge færdige muligheder eller skrive dine egne til specifikke opgaver. I R kan vi drage fordel af alle funktionerne i Python-biblioteket Keras med sine forskellige backends ved hjælp af pakken af ​​samme navn, som igen fungerer ovenpå pakken retikulere. Sidstnævnte fortjener en særskilt lang artikel; det giver dig ikke kun mulighed for at køre Python-kode fra R, men giver dig også mulighed for at overføre objekter mellem R- og Python-sessioner, hvilket automatisk udfører alle de nødvendige typekonverteringer.

Vi slap af med behovet for at gemme alle data i RAM ved at bruge MonetDBLite, alt det "neurale netværk" arbejde vil blive udført af den originale kode i Python, vi skal bare skrive en iterator over dataene, da der ikke er noget klar til en sådan situation i enten R eller Python. Der er i det væsentlige kun to krav til det: det skal returnere batcher i en endeløs løkke og gemme sin tilstand mellem iterationer (sidstnævnte i R implementeres på den enkleste måde ved hjælp af lukninger). Tidligere var det påkrævet eksplicit at konvertere R-arrays til numpy-arrays inde i iteratoren, men den aktuelle version af pakken Keras gør det selv.

Iteratoren for trænings- og valideringsdata viste sig at være som følger:

Iterator til trænings- og valideringsdata

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

Funktionen tager som input en variabel med en forbindelse til databasen, antallet af anvendte linjer, antallet af klasser, batchstørrelse, skala (scale = 1 svarer til gengivelse af billeder på 256x256 pixels, scale = 0.5 — 128x128 pixels), farveindikator (color = FALSE angiver gengivelse i gråtoner, når den bruges color = TRUE hvert streg er tegnet i en ny farve) og en forbehandlingsindikator for netværk, der er forudtrænet på imagenet. Sidstnævnte er nødvendig for at skalere pixelværdier fra intervallet [0, 1] til intervallet [-1, 1], som blev brugt ved træning af det medfølgende Keras modeller.

Den eksterne funktion indeholder argumenttypekontrol, en tabel data.table med tilfældigt blandede linjenumre fra samples_index og batchnumre, tæller og maksimalt antal batches, samt et SQL-udtryk til udlæsning af data fra databasen. Derudover definerede vi en hurtig analog af funktionen indeni keras::to_categorical(). Vi brugte næsten alle data til træning og efterlod en halv procent til validering, så epokestørrelsen var begrænset af parameteren steps_per_epoch når der ringes op keras::fit_generator(), og tilstanden if (i > max_i) virkede kun for valideringsiteratoren.

I den interne funktion hentes rækkeindekser for næste batch, poster fjernes fra databasen med batchtælleren stigende, JSON parsing (funktion cpp_process_json_vector(), skrevet i C++) og oprette arrays svarende til billeder. Derefter oprettes one-hot vektorer med klasseetiketter, arrays med pixelværdier og labels kombineres til en liste, som er returværdien. For at fremskynde arbejdet brugte vi oprettelsen af ​​indekser i tabeller data.table og ændring via linket - uden disse pakke "chips" data.tabel Det er ret svært at forestille sig at arbejde effektivt med nogen betydelig mængde data i R.

Resultaterne af hastighedsmålinger på en Core i5 bærbar er som følger:

Iterator benchmark

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: hvordan man bliver venner med R, C++ og neurale netværk

Hvis du har en tilstrækkelig mængde RAM, kan du for alvor fremskynde driften af ​​databasen ved at overføre den til den samme RAM (32 GB er nok til vores opgave). I Linux er partitionen monteret som standard /dev/shm, der optager op til halvdelen af ​​RAM-kapaciteten. Du kan fremhæve mere ved at redigere /etc/fstabat få en plade som tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Sørg for at genstarte og kontrollere resultatet ved at køre kommandoen df -h.

Iteratoren for testdata ser meget enklere ud, da testdatasættet passer helt ind i RAM:

Iterator til testdata

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. Valg af modelarkitektur

Den første anvendte arkitektur var mobilenet v1, hvis funktioner er diskuteret i dette besked. Det medfølger som standard Keras og er følgelig tilgængelig i pakken af ​​samme navn for R. Men når man forsøger at bruge den med enkeltkanalsbilleder, viste en mærkelig ting sig: inputtensoren skal altid have dimensionen (batch, height, width, 3), det vil sige, at antallet af kanaler ikke kan ændres. Der er ingen sådan begrænsning i Python, så vi skyndte os og skrev vores egen implementering af denne arkitektur, efter den originale artikel (uden frafaldet, der er i keras-versionen):

Mobilenet v1 arkitektur

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

Ulemperne ved denne fremgangsmåde er indlysende. Jeg vil teste mange modeller, men tværtimod vil jeg ikke omskrive hver arkitektur manuelt. Vi blev også frataget muligheden for at bruge vægten af ​​modeller, der var forudtrænede på imagenet. Som sædvanlig hjalp det at studere dokumentationen. Fungere get_config() giver dig mulighed for at få en beskrivelse af modellen i en form, der er egnet til redigering (base_model_conf$layers - en almindelig R-liste), og funktionen from_config() udfører den omvendte konvertering til et modelobjekt:

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)

Nu er det ikke svært at skrive en universel funktion for at få noget af det medfølgende Keras modeller med eller uden vægt trænet på imagenet:

Funktion til indlæsning af færdige arkitekturer

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

Ved brug af enkeltkanalsbilleder bruges der ingen fortrænede vægte. Dette kunne løses: ved hjælp af funktionen get_weights() få modelvægtene i form af en liste over R-arrays, ændre dimensionen af ​​det første element i denne liste (ved at tage en farvekanal eller lægge et gennemsnit af alle tre), og derefter indlæse vægtene tilbage i modellen med funktionen set_weights(). Vi har aldrig tilføjet denne funktionalitet, for på dette tidspunkt var det allerede klart, at det var mere produktivt at arbejde med farvebilleder.

Vi udførte de fleste eksperimenter med mobilenet version 1 og 2 samt resnet34. Mere moderne arkitekturer som SE-ResNeXt klarede sig godt i denne konkurrence. Desværre havde vi ikke færdige implementeringer til vores rådighed, og vi skrev ikke vores egne (men vi skriver helt sikkert).

5. Parametrisering af scripts

For nemheds skyld blev al kode til start af træning designet som et enkelt script, parametriseret vha docpt som følger:

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)

pakke docpt repræsenterer implementeringen http://docopt.org/ for R. Med dens hjælp lanceres scripts med simple kommandoer som f.eks Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db eller ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, hvis fil train_nn.R er eksekverbar (denne kommando begynder at træne modellen resnet50 på trefarvede billeder, der måler 128x128 pixels, skal databasen ligge i mappen /home/andrey/doodle_db). Du kan tilføje indlæringshastighed, optimeringstype og alle andre tilpasselige parametre til listen. I forbindelse med udarbejdelsen af ​​publikationen viste det sig, at arkitekturen mobilenet_v2 fra den aktuelle version Keras i R brug kan ikke grundet ændringer, der ikke er taget højde for i R-pakken, venter vi på, at de ordner det.

Denne tilgang gjorde det muligt betydeligt at fremskynde eksperimenter med forskellige modeller sammenlignet med den mere traditionelle lancering af scripts i RStudio (vi bemærker pakken som et muligt alternativ tfruns). Men den største fordel er muligheden for nemt at administrere lanceringen af ​​scripts i Docker eller blot på serveren uden at installere RStudio til dette.

6. Dockerisering af scripts

Vi brugte Docker til at sikre overførsel af miljøet til træningsmodeller mellem teammedlemmer og til hurtig implementering i skyen. Du kan begynde at stifte bekendtskab med dette værktøj, som er relativt usædvanligt for en R-programmør, med dette række af publikationer eller video kursus.

Docker giver dig mulighed for både at skabe dine egne billeder fra bunden og bruge andre billeder som grundlag for at skabe dine egne. Da vi analyserede de tilgængelige muligheder, kom vi til den konklusion, at installation af NVIDIA, CUDA+cuDNN-drivere og Python-biblioteker er en ret omfangsrig del af billedet, og vi besluttede at tage det officielle billede som grundlag. tensorflow/tensorflow:1.12.0-gpu, tilføjer de nødvendige R-pakker der.

Den endelige docker-fil så således ud:

Dockerfil

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

For nemheds skyld blev de anvendte pakker sat i variabler; Hovedparten af ​​de skrevne scripts kopieres inde i beholderne under samlingen. Vi ændrede også kommandoskallen til /bin/bash for at lette brugen af ​​indholdet /etc/os-release. Dette undgik behovet for at specificere OS-versionen i koden.

Derudover blev der skrevet et lille bash-script, der giver dig mulighed for at starte en container med forskellige kommandoer. For eksempel kan disse være scripts til træning af neurale netværk, der tidligere var placeret inde i containeren, eller en kommandoskal til fejlretning og overvågning af containerens drift:

Script til at starte containeren

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

Hvis dette bash-script køres uden parametre, vil scriptet blive kaldt inde i containeren train_nn.R med standardværdier; hvis det første positionelle argument er "bash", så starter containeren interaktivt med en kommandoskal. I alle andre tilfælde erstattes værdierne af positionelle argumenter: CMD="Rscript /app/train_nn.R $@".

Det er værd at bemærke, at mapperne med kildedata og database samt mappen til lagring af trænede modeller er monteret inde i containeren fra værtssystemet, hvilket giver dig adgang til resultaterne af scripts uden unødvendige manipulationer.

7. Brug af flere GPU'er på Google Cloud

Et af kendetegnene ved konkurrencen var de meget støjende data (se titelbilledet, lånt fra @Leigh.plt fra ODS slack). Store batches hjælper med at bekæmpe dette, og efter eksperimenter på en pc med 1 GPU besluttede vi at mestre træningsmodeller på flere GPU'er i skyen. Brugte GoogleCloud (god guide til det grundlæggende) på grund af det store udvalg af tilgængelige konfigurationer, rimelige priser og $300 bonus. Af grådighed bestilte jeg en 4xV100-instans med en SSD og et væld af RAM, og det var en stor fejl. Sådan en maskine spiser hurtigt penge; du kan gå i stykker med at eksperimentere uden en dokumenteret pipeline. Til uddannelsesformål er det bedre at tage K80. Men den store mængde RAM kom godt med - cloud SSD'en imponerede ikke med dens ydeevne, så databasen blev overført til dev/shm.

Af størst interesse er kodefragmentet, der er ansvarligt for at bruge flere GPU'er. Først oprettes modellen på CPU'en ved hjælp af en konteksthåndtering, ligesom i 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
  )
})

Derefter kopieres den ukompilerede (dette er vigtigt) model til et givet antal tilgængelige GPU'er, og først derefter kompileres den:

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

Den klassiske teknik med at fryse alle lag undtagen det sidste, træne det sidste lag, frigøre og genoptræne hele modellen til flere GPU'er kunne ikke implementeres.

Træningen blev overvåget uden brug. tensorboard, begrænser os til at registrere logfiler og gemme modeller med informative navne efter hver epoke:

Tilbagekald

# Шаблон имени файла лога
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. I stedet for en konklusion

En række problemer, som vi er stødt på, er endnu ikke blevet overvundet:

  • в Keras der er ingen færdiglavet funktion til automatisk at søge efter den optimale indlæringshastighed (analog lr_finder i biblioteket hurtigt.ai); Med en vis indsats er det muligt at portere tredjepartsimplementeringer til R, f.eks. dette;
  • som en konsekvens af det foregående punkt, var det ikke muligt at vælge den korrekte træningshastighed ved brug af flere GPU'er;
  • der er mangel på moderne neurale netværksarkitekturer, især dem, der er forudtrænede på imagenet;
  • ingen cykluspolitik og diskriminerende læringshastigheder (cosinusudglødning var på vores anmodning implementeret, tak skeydan).

Hvilke nyttige ting blev lært af denne konkurrence:

  • På relativt lavt strømforbrug hardware kan du arbejde med anstændige (mange gange størrelsen af ​​RAM) datamængder uden smerter. Plastikpose data.tabel sparer hukommelse på grund af in-place modifikation af tabeller, som undgår at kopiere dem, og når de bruges korrekt, viser dens muligheder næsten altid den højeste hastighed blandt alle værktøjer, vi kender til scriptsprog. At gemme data i en database giver dig i mange tilfælde mulighed for slet ikke at tænke på behovet for at presse hele datasættet ind i RAM.
  • Langsomme funktioner i R kan erstattes med hurtige i C++ ved hjælp af pakken Rcpp. Hvis ud over brug RcppThread eller RcppParallel, får vi multi-threadede implementeringer på tværs af platforme, så der er ingen grund til at parallelisere koden på R-niveau.
  • Pakke Rcpp kan bruges uden seriøst kendskab til C++, er det påkrævede minimum skitseret her. Header-filer til en række seje C-biblioteker som f.eks xtensor tilgængelig på CRAN, det vil sige, at der dannes en infrastruktur til implementering af projekter, der integrerer færdiglavet højtydende C++-kode i R. Yderligere bekvemmelighed er syntaksfremhævning og en statisk C++ kodeanalysator i RStudio.
  • docpt giver dig mulighed for at køre selvstændige scripts med parametre. Dette er praktisk til brug på en fjernserver, inkl. under docker. I RStudio er det ubelejligt at udføre mange timers eksperimenter med træning af neurale netværk, og installation af IDE på selve serveren er ikke altid berettiget.
  • Docker sikrer kodeportabilitet og reproducerbarhed af resultater mellem udviklere med forskellige versioner af OS og biblioteker, samt nem eksekvering på servere. Du kan starte hele træningspipeline med kun én kommando.
  • Google Cloud er en budgetvenlig måde at eksperimentere med dyr hardware på, men du skal vælge konfigurationer med omhu.
  • At måle hastigheden af ​​individuelle kodefragmenter er meget nyttigt, især når du kombinerer R og C++ og med pakken bænk - også meget nemt.

Samlet set var denne oplevelse meget givende, og vi fortsætter med at arbejde på at løse nogle af de rejste problemer.

Kilde: www.habr.com

Tilføj en kommentar