Quick Draw Doodle Recognition: kako povezati R, C++ i neuronske mreže

Quick Draw Doodle Recognition: kako povezati R, C++ i neuronske mreže

Hello, Habr!

Prejšnjo jesen je na Kaggle potekalo tekmovanje v klasifikaciji ročno narisanih slik Quick Draw Doodle Recognition, v katerem je med drugim sodelovala ekipa R-jevcev v sestavi Artem Klevcov, Filipp Upravitev in Andrej Ogurcov. Podrobneje ne bomo opisovali tekmovanja, to je že bilo storjeno v nedavni publikaciji.

Tokrat nam ni uspelo priti do medalj, prejeli pa smo veliko dragocenih izkušenj, zato bi radi delili nekaj najbolj zanimivih in uporabnih tem na Kaggleu in v vsakodnevnem delu s skupnostjo. Med obravnavanimi temami: težko življenje brez OpenCV, parsenje JSON-ov (v teh primerih obravnavamo integracije kode v C++ v skripte ali pakete v R preko Rcpp), parametrizacija skript in dockerizacija končne rešitve. Vsa koda iz sporočila je na voljo v uporabni obliki za izvajanje v repozitoriji.

Vsebina:

  1. Učinkovito nalaganje podatkov iz CSV v bazo MonetDB
  2. Priprava serij
  3. Iteratorji za nalaganje serij iz BDP
  4. Izbira arhitekture modela
  5. Parametrizacija skript
  6. Dockerizacija skript
  7. Uporaba več GPU-jev v oblaku Google Cloud
  8. Namesto zaključka

1. Učinkovito nalaganje podatkov iz CSV v bazo MonetDB

Podatki v tem tekmovanju niso dostopni kot že pripravljene slike, temveč v obliki 340 CSV-datotek (ena datoteka za vsak razred), ki vsebujejo JSON-e s koordinatami točk. Ko te točke povežemo s črtami, dobimo končno sliko velikosti 256x256 slikovnih pik. Prav tako za vsak vnos navaja oznako, ali je bila slika pravilno prepoznana s strani klasifikatorja, ki je bil uporabljen ob zbiranju podatkov, dvobrko države bivanja avtorja risbe, edinstven identificator, oznako časa in ime razreda, ki se ujema z imenom datoteke. Poenostavljena različica izvornih podatkov tehta 7,4 GB v arhivu in približno 20 GB po razpakiranju, celotni podatki po razpakiranju zasedejo 240 GB. Organizatorji so zagotovili, da obe različici reproducirata iste risbe, kar pomeni, da je polna različica odvečna. V vsakem primeru je bilo shranjevanje 50 milijonov slik v grafičnih datotekah ali v obliki matrik takoj prepoznano kot nerentabilno, in odločili smo se združiti vse CSV-datoteke iz arhiva train_simplified.zip v bazo podatkov s kasnejšo generacijo slik potrebne velikosti «na let» za vsak serijo.

Kot SODB je bila izbrana dobro uveljavljena MonetDB, in particolare l'implementazione per R sotto forma di pacchetto MonetDBLite. Il pacchetto include una versione embedded del server di database e consente di avviare il server direttamente dalla sessione R e lavorare con esso. La creazione del database e la connessione vengono eseguite con un solo comando:

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

Dovremo creare due tabelle: una per tutti i dati e un'altra per le informazioni di servizio sui file caricati (utile se qualcosa va storto e il processo deve essere ripreso dopo il caricamento di alcuni file):

Создание таблиц

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

Il modo più veloce per caricare i dati nel database è stato il copia diretta dei file CSV utilizzando SQL — il comando COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORT, gde v definiciji pogleda. — il nome della tabella e path — il percorso del file. Lavorando con l'archivio, è stato scoperto che l'implementazione integrata unzip in R non funziona correttamente con alcuni file dell'archivio, quindi abbiamo utilizzato il sistema unzip (utilizzando il parametro getOption("unzip")).

La funzione per scrivere nel database

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

Nel caso in cui sia necessario trasformare la tabella prima di scriverla nel database, è sufficiente passare come argomento preprocess una funzione che trasformerà i dati.

Codice per il caricamento sequenziale dei dati nel database:

Scrivere i dati nel database

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

Il tempo di caricamento dei dati può variare a seconda delle caratteristiche di velocità del supporto utilizzato. Nel nostro caso, la lettura e la scrittura all'interno di un SSD o da una chiavetta USB (file sorgente) a SSD (DB) richiede meno di 10 minuti.

Altri pochi secondi sono necessari per creare una colonna con l'etichetta della classe intera e una colonna indice (ORDERED INDEX) con i numeri di riga, che sarà utilizzata per la selezione delle osservazioni durante la creazione dei batch:

Creazione di colonne aggiuntive e indice

message("Generare etichette")
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("Generare numeri di riga")
invisible(DBI::dbExecute(con, "ALTER TABLE doodles ADD id serial"))
invisible(DBI::dbExecute(con, "CREATE ORDERED INDEX doodles_id_ord_idx ON doodles(id)"))

Za reševanje naloge oblikovanja serij "v živo" smo morali doseči maksimalno hitrost pridobivanja naključnih vrstic iz tabele. doodles. Za to smo uporabili 3 trike. Prvi je bil zmanjšati dimenzionalnost tipa, v katerem se shranjuje ID opazovanja. V izvorni podatkovni zbirki je za shranjevanje ID potreben tip bigint, vendar število opazovanj omogoča, da se njihovi identifikatorji, ki so enaki zaporedni številki, spravijo v tip int. Iskanje se pri tem precej izboljša. Drugi trik je bila uporaba ORDERED INDEX — do tega rešenja smo prišli empirično, tako da smo preizkusili vse razpoložljive možnosti. Tretji je bil uporaba parametrizeanih poizvedb. Osnovna ideja metode je enkratno izvajanje ukaza PREPARE s kasnejšo uporabo pripravljenega izraza pri ustvarjanju serije enotnih poizvedb, vendar se je v praksi dobiček v primerjavi s preprostim SELECT izkazal za statistično napako.

Proces nalaganja podatkov porabi največ 450 MB RAM-a. To pomeni, da omogoča opisani pristop obvladovati podatkovne nize s težo več deset gigabajtov praktično na katerikoli proračunski strojni opremi, vključno z nekaterimi enoslojnimi računalniki, kar je precej kul.

Ostaja še izvesti meritve hitrosti pridobivanja (naključnih) podatkov in oceniti širitev pri izbiri serij različne velikosti:

Benchmark baze podatkov

library(ggplot2)

set.seed(0)
# Povezovanje na bazo podatkov
con <- DBI::dbConnect(MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))

# Funkcija za pripravo poizvedbe na strani strežnika
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)
}

# Funkcija za izčrpavanje podatkov
fetch_data <- function(rs, batch_size) {
  ids <- sample(seq_len(n), batch_size)
  res <- DBI::dbFetch(DBI::dbBind(rs, as.list(ids)))
  return(res)
}

# Izvedba meritev
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    rs <- prep_sql(batch_size)
    bench::mark(
      fetch_data(rs, batch_size),
      min_iterations = 50L
    )
  }
)
# Parametri benchmarka
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
#                    
# 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: kako povezati R, C++ i neuronske mreže

2. Priprava serij

Celoten proces priprave serij obsega naslednje faze:

  1. Parsanje več JSON-ov, ki vsebujejo vektorje nizov s koordinatami točk.
  2. Narisi barvne črte po koordinatah točk na sliki potrebne velikosti (na primer, 256×256 ali 128×128).
  3. Pretvorba pridobljenih slik v tenzor.

V okviru tekmovanja med kernel-i v Pythonu je bila naloga reševana predvsem z orodji OpenCV. Eden najbolj preprostih in očitnih analogov v R bi izgledal takole:

Izvedba pretvorbe JSON v tenzor v R

r_process_json_str <- function(json, line.width = 3, 
                               color = TRUE, scale = 1) {
  # Parsing JSON
  coords <- jsonlite::fromJSON(json, simplifyMatrix = FALSE)
  tmp <- tempfile()
  # Deleting the temporary file after function completion
  on.exit(unlink(tmp))
  png(filename = tmp, width = 256 * scale, height = 256 * scale, pointsize = 1)
  # Empty plot
  plot.new()
  # Plot window size
  plot.window(xlim = c(256 * scale, 0), ylim = c(256 * scale, 0))
  # Line colors
  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()
  # Convert image to a 3D array
  res <- png::readPNG(tmp)
  return(res)
}

r_process_json_vector <- function(x, ...) {
  res <- lapply(x, r_process_json_str, ...)
  # Merging 3D image arrays into a 4D tensor
  res <- do.call(abind::abind, c(res, along = 0))
  return(res)
}

Drawing is performed using standard R tools, saving to a temporary PNG stored in RAM (in Linux, R's temporary directories are located in a directory /tmp, mounted in RAM). Then this file is read as a 3D array with values ranging from 0 to 1. This is important, as a more commonly used BMP would be read as a raw array with hex color codes.

Let's test the result:

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: kako povezati R, C++ i neuronske mreže

The batch will be formed as follows:

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

This implementation seemed suboptimal to us, as forming large batches takes an unacceptably long time, and we decided to draw on the experience of our colleagues, using a powerful library OpenCV. At that time, there was no ready-to-use package for R (and there isn't one now), so a minimal implementation of the required functionality was written in C++ with integration into the R code using Rcpp.

The following packages and libraries were used to solve the task:

  1. OpenCV for working with images and drawing lines. We used pre-installed system libraries and header files, as well as dynamic linking.

  2. xtensor za delo z večdimenzionalnimi poli in tenzorji. Uporabili smo glave, vključene v istoimenski R-paket. Knjižnica omogoča delo z večdimenzionalnimi poli tako v vrstnem redu vrstic (row major) kot v vrstnem redu stolpcev (column major).

  3. ndjson za razčlenjevanje JSON-a. Ta knjižnica se uporablja v xtensor samodejno, če je prisotna v projektu.

  4. RcppThread za organizacijo večnitnega obdelovanja vektorja iz JSON-ov. Uporabili smo glave, ki jih ponuja ta paket. Od bolj priljubljenega RcppParallel paket med drugim odlikuje vgrajeni mehanizem za prekinitev zanke (interrupt).

Je třeba poznamenat, že xtensor se je izkazal kot pravi zaklad: poleg tega, da ima široko funkcionalnost in visoko učinkovitost, so se njegovi razvijalci izkazali za zelo odzivne in hitro ter podrobno odgovarjali na vprašanja. Z njihovo pomočjo je bilo mogoče izvesti pretvorbe matric OpenCV v tenzorje xtensor ter način združevanja 3-dimenzionalnih tenzorjev slik v 4-dimenzionalni tenzor pravilne velikosti (dejansko paket).

Gradiva za študij Rcpp, xtensor in 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

Za kompilacijo datotek, ki uporabljajo sistemske datoteke in dinamično povezovanje z nameščenimi knjižnicami v sistemu, smo uporabili mehanizem vtičnikov, realiziran v paketu Rcpp. Za samodejno iskanje poti in zastavic smo uporabili priljubljeno linux orodje pkg-config.

Izvedba Rcpp vtičnika za uporabo knjižnice OpenCV

Rcpp::registerPlugin("opencv", function() {
  # Možna imena paketa
  pkg_config_name <- c("opencv", "opencv4")
  # Izvršna datoteka orodja pkg-config
  pkg_config_bin <- Sys.which("pkg-config")
  # Preverjanje prisotnosti orodja v sistemu
  checkmate::assert_file_exists(pkg_config_bin, access = "x")
  # Preverjanje prisotnosti konfiguracijske datoteke OpenCV za 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)
  ))
})

Kot rezultat dela vtičnika bodo v procesu kompilacije vnešene naslednje vrednosti:

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"

Кód для реализации парсинга JSON и формирования батча для передачи в модель приведён под спойлером. Предварительно добавляем локальную директорию проекта для поиска заголовочных файлов (это необходимо для ndjson):

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

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

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

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

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

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

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

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

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

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

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

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

Этот код следует поместить в файл src/cv_xt.cpp и скомпилировать командой Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); также для работы потребуется nlohmann/json.hpp iz In this branch, the frontend code has been modified to redirect users to Auth0 for authentication and use the JWT token in requests to other services. The last part is implemented like this (. Код разделён на несколько функций:

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

  • parse_json — функция парсит JSON-строку, извлекает координаты точек, упаковывая их в вектор;

  • ocv_draw_lines — из полученного вектора точек отрисовывает разноцветные линии;

  • proces — объединяет вышеописанные функции, а также добавляет возможность шкалирования полученного изображения;

  • cpp_process_json_str — обёртка над функцией proces, которая экспортирует результат в R-объект (многомерный массив);

  • cpp_process_json_vector — обёртка над функцией cpp_process_json_str, которая позволяет обрабатывать строковый вектор в многопоточном режиме.

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

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

Quick Draw Doodle Recognition: kako povezati R, C++ i neuronske mreže
Сравнение скорости работы реализаций на R и С++

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

#   expression                min     median       max `itr/sec` total_time  n_itr
#                                  
# 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
#                  <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: kako povezati R, C++ i neuronske mreže

Kot vidimo, izboljšanje hitrosti je bilo zelo pomembno, in ne moremo dohiteti kode C++ s paralelizacijo kode R.

3. Iteratorji za izvoz paketov iz Baze podatkov

R ima zasluženu reputaciju jezika za obradu podataka koji se nalaze u RAM-u, dok Python karakteriše iterativna obrada podataka, koja omogućava lako i bez napora sprovođenje out-of-core izračunavanja (izračunavanja koristeći spoljašnju memoriju). Klasičan i relevantan primer takvih izračunavanja, u kontekstu opisane zadatke, su duboke neuronske mreže, obučene metodom gradijentnog spuštanja sa aproksimacijom gradijenta na svakom koraku koristeći mali deo opažanja ili mini-batch.

Okviri za duboko učenje napisani na Python-u imaju posebne klase koje implementiraju iteratore po podacima: tabelama, slikama u fasciklama, binarnim formatima itd. Možete koristiti gotove opcije ili pisati sopstvene za specifične zadatke. U R-u možemo iskoristiti sve mogućnosti Pythonske biblioteke keras sa njegovim raznim backend-ovima pomoću istoimenog paketa, koji opet radi preko paketa reticulatePoslednji zaslužuje posebno veliko poglavlje; ne samo da omogućava pokretanje koda na Python-u iz R-a, već i prenosi objekte između R- i Python sesija, automatski izvršavajući sve potrebne promene tipova.

Oslobodili smo se potrebe da sve podatke čuvamo u RAM-u koristeći MonetDBLite, sav 'neuronski' rad će obavljati originalni kod na Python-u, a nama preostaje samo da napišemo iterator po podacima, pošto ne postoji gotov za ovu situaciju ni u R-u ni u Python-u. Zahtevi za njega su u suštini samo dva: treba da vraća batch-ove u beskonačnoj petlji i da čuva svoje stanje između iteracija (to se u R-u jednostavno ostvaruje pomoću zatvaranja). Ranije je bilo potrebno unutar iteratora eksplicitno pretvarati R-ove nizove u numpy nizove, ali aktuelna verzija paketa keras to radi sama.

Iterator za obučavajuće i validacione podatke ispao je sledeći:

Iterator za obučavajuće i validacione podatke

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 prima na ulazu varijablu sa vezom na Bazu podataka, brojeve koji se koriste redovima, broj klasa, veličinu batch-a, skalu (scale = 1 odgovara crtanju slika 256x256 piksela, scale = 0.5 — 128x128 piksela), indikator koloracije (color = FALSE postavlja crtanje u nijansama sive, prilikom korišćenja barva = TRUE vsaka poteza se nariše z novo barvo) in indikator predobdelave za omrežja, predhodno usposobljena na imagenet. Ta je potrebna za preoblikovanje vrednosti pikslov z intervala [0, 1] na interval [-1, 1], ki se je uporabljal med usposabljanjem dobavljenih modelov. keras modelov.

Zunanja funkcija vsebuje preverjanje tipov argumentov, tabelo data.table z naključno premešanimi številkami vrstic iz samples_index in številke serij, števec in največje število serij ter SQL izraz za izvoz podatkov iz baze podatkov. Dodatno smo določili znotraj hitrih analogov funkcije keras::to_categorical(). Za usposabljanje smo uporabili skoraj vse podatke in pustili pol odstotka za validacijo, zato je bila velikost epohe omejena s parametrom steps_per_epoch pri klicu keras::fit_generator(), in pogoj if (i > max_i) je veljal le za validacijski iterator.

V notranji funkciji se izvede izbira indeksov vrstic za naslednjo serijo, izvoz zapisov iz baze podatkov z povečanjem števca serij, analiziranje JSON-ov (funkcija cpp_process_json_vector(), napisana v C++) in ustvarjanje tabel, ki ustrezajo slikam. Nato se ustvarijo one-hot vektorji z oznakami razredov, tabelami z vrednostmi pikslov in oznakami se združijo v seznam, ki je vrnjena vrednost. Za pospešitev delovanja je bilo uporabljeno ustvarjanje indeksov v tabelah data.table in modificiranje po referenci - brez teh 'trikov' paketa data.table je precej težko predstavljati učinkovito delo z večjimi količinami podatkov v R.

Rezultati meritev hitrosti delovanja na prenosniku Core i5 so videti takole:

Benchmark iteratorja

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
#                    
# 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: kako povezati R, C++ i neuronske mreže

Če je dovolj RAM-a, je mogoče znatno pospešiti delovanje baze podatkov s prenosom vanj (za našo nalogo zadostuje 32 GB). V Linuxu se privzeto montira particija /dev/shm, ki zavzema do polovice prostornine RAM-a. Lahko pa dodelite tudi več, tako da uredite /etc/fstab, tako da dobite zapis v obliki tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Obvezno se znova zaženite in preverite rezultat z ukazom df -h.

Iterator za testne podatke izgleda veliko preprosto, saj se celoten testni nabor podatkov shrani v RAM:

Iterator za testne podatke

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. Izbira arhitekture modela

Prva iz uporabljenih arhitektur je bila mobilenet v1, za katero so značilnosti opisane v ovom sporočilu. Na voljo je v standardni dostavi, keras in torej dostopna v istoimenskem paketu za R. Vendar se je ob poskusu uporabe z enokanálnimi slikami pojavila čudna težava: vhodni tenzor mora vedno imeti dimenzije (batch, height, width, 3), to je pomeni, da število kanalov ni mogoče spremeniti. V Pythonu ni tovrstnih omejitev, zato smo se hitro lotili in napisali svojo implementacijo te arhitekture, ki sledi izvirnemu članku (brez dropout-a, ki je v različici keras):

Arhitektura mobilenet v1

library(keras)

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

layer_sep_conv_bn %
  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 %
    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 % compile(
      optimizer = optimizer,
      loss = loss,
      metrics = metrics
    )

    return(model)
}

Pomanjkljivosti takšnega pristopa so očitne. Veliko modelov bi želeli preveriti, prepisovanje vsake arhitekture ročno pa ni privlačno. Prav tako smo bili prikrajšani za priložnost uporabe teže modelov, predhodno usposobljenih na imagenetu. Kot običajno je pomagalo študiranje dokumentacije. Funkcija get_config() omogoča pridobitev opisa modela v obliki, primerni za ureditev (base_model_conf$layers — обычный R-овский список), а функция from_config() выполняет обратное преобразование в модельный объект:

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

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

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

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

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

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

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

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

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

  return(model)
}

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

Večino poskusov smo izvedli z uporabo mobilenet različic 1 in 2 ter resnet34. V tem tekmovanju so se dobro izkazale sodobnejše arhitekture, kot je SE-ResNeXt. Na žalost nimamo pripravljenih implementacij, naše pa še nismo napisali (ampak jih bomo zagotovo).

5. Parametrizacija skriptov

Za lažjo uporabo je bil celoten koda za zagon usposabljanja oblikovana kot en sam skript, parametriziran z uporabo docopt takole:

doc <- '
Uporaba:
  train_nn.R --help
  train_nn.R --list-models
  train_nn.R [možnosti]

Možnosti:
  -h --help                   Prikaže to sporočilo.
  -l --list-models            Seznam razpoložljivih modelov.
  -m --model=          Ime modela nevralne mreže [privzeto: mobilenet_v2].
  -b --batch-size=      Velikost paketa [privzeto: 32].
  -s --scale-factor=   Faktor skale [privzeto: 0.5].
  -c --color                  Uporabi barvne črte [privzeto: FALSE].
  -d --db-dir=          Pot do imenika baze podatkov [privzeto: Sys.getenv("db_dir")].
  -r --validate-ratio= Razmer vzorca za validacijo [privzeto: 0.995].
  -n --n-gpu=         Število GPU-jev [privzeto: 1].
'
args <- docopt::docopt(doc)

Package docopt predstavlja izvedbo http://docopt.org/ za R. S pomočjo tega se skripti zaženejo s preprostimi ukazi, kot je Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db ili ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, če je datoteka train_nn.R izvedljiva (ta ukaz bo zagnal učenje modela resnet50 na barvnih slikah velikosti 128x128 pikslov, baza podatkov mora biti v mapi /home/andrey/doodle_db). Na seznam lahko dodate hitrost učenja, tip optimizatorja in druge prilagodljive parametre. Med pripravo objave se je izkazalo, da arhitekturo mobilenet_v2 iz aktualne različice keras v R uporabljati možno zaradi neupoštevanih sprememb v R-paketih — čakamo, da to popravijo.

Ta pristop je omogočil znatno pospešitev eksperimentov z različnimi modeli v primerjavi s tradicionalnejšim zagonjem skriptov v RStudiju (kot možno alternativno omenjamo paket tfruns). Glavna prednost pa je možnost enostavnega upravljanja z zagonom skriptov v Dockerju ali preprosto na strežniku, brez namestitve RStudia.

6. Dockerizacija skriptov

Uporabljali smo Docker z namenom zagotavljanja prenosljivosti okolja za usposabljanje modelov med člani ekipe in za hitro uvajanje v oblak. Začne se spoznavanje s tem razmeroma nenavadnim orodjem za R-programerje z tole serije objav ali z videokurza.

Docker omogoča tako ustvarjanje lastnih slik "od nule" kot tudi uporabo drugih slik kot osnovo za ustvarjanje lastnih. Po analizi razpoložljivih možnosti smo ugotovili, da je namestitev gonilnikov NVIDIA, CUDA+cuDNN in Pythonovih knjižnic precej obsežen del slike, zato smo se odločili, da vzamemo za osnovo uradno sliko tensorflow/tensorflow:1.12.0-gpu, dodali pa smo tudi potrebne R-paket.

Končni Docker datoteka je izgledala tako:

Dockerfile

FROM tensorflow/tensorflow:1.12.0-gpu

MAINTAINER Artem Klevtsov 

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

Za enostavnost so bile uporabljene pakete shranjene v spremenljivke; večina napisanih skriptov se med gradnjo kopiira v kontejnerje. Prav tako smo spremenili ukazno lupino na /bin/bash za lažjo uporabo vsebine /etc/os-release. To je omogočilo, da ni bilo treba v kodi navesti različice operacijskega sistema.

Dodatno je bil napisan majhen bash skript, ki omogoča zagon kontejnerja z različnimi ukazi. Na primer, to so lahko skripti za učenje nevronetov, ki so bili prej vključeni v kontejner, ali ukazna lupina za odpravljanje napak in spremljanje delovanja kontejnerja:

Skript za zagon kontejnerja

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

Če ta bash skript zaženete brez parametrov, se v znotraj kontejnerja izvede skript train_nn.R s privzetimi vrednostmi; če je prvi pozicijski argument "bash", se bo kontejner zagnal v interaktivnem načinu s ukazno lupino. V vseh drugih primerih pride do nadomestitve vrednosti pozicijskih argumentov: CMD="Rscript /app/train_nn.R $@".

Opozoriti je treba, da se imeniki z izvorni podatki in bazo podatkov, prav tako imenik za shranjevanje naučenih modelov, montirajo v kontejner iz gostiteljske sistem, kar omogoča dostop do rezultatov delovanja skriptov brez dodatnih manipulacij.

7. Uporaba več GPU-jev v oblaku Google Cloud

Ena od lastnosti tekmovanja so bili zelo hrupni podatki (glejte naslovno sliko, ki jo je prispeval @Leigh.plt iz ODS Slacka). S tem se lahko borite s pomočjo velikih serij, in po eksperimentih na osebnem računalniku z enim GPU-jem smo se odločili za usposabljanje modelov na več GPU-jih v oblaku. Uporabili smo GoogleCloud (dobro vodilo za osnove dela) zaradi velike izbire razpoložljivih konfiguracij, sprejemljivih cen in bonusnih $300. Iz pohlepa je bil naročen primerek z 4xV100 s SSD in nekaj RAM-a, kar se je izkazalo za veliko napako. Takšna naprava hitro poje denar; pri eksperimentih brez uspelih delovnih tokov se lahko na hitro bankrotira. Za učne namene je bolje vzeti K80. Velika količina RAM-a pa je prišla prav — oblačni SSD me ni impresioniral s hitrostjo, zato smo bazo podatkov pri vsakem zagonu primerka premestili na dev/shm.

Največjo zanimivost predstavlja del kode, ki je odgovoren za uporabo več GPU-jev. Model se najprej ustvari na CPU-ju z uporabo upravljalnika konteksta, prav tako kot v Pythonu:

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

Nato se nekompajliran (to je pomembno) model kopira na določeno število razpoložljivih GPU-jev, in šele po tem se kompilira:

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

Klasične tehnike z zamrzovanjem vseh plasti, razen zadnje, usposabljanjem zadnje plasti, odmrzovanjem in nadaljnjim usposabljanjem modela v celoti za več GPU-jev ni bilo mogoče uresničiti.

Nadzorovali smo usposabljanje brez uporabe tensorboard, omejuje se na beleženje logov in shranjevanje modelov z informativnimi imeni po vsaki epohi:

Povratni klici

# Шаблон имени файла лога
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. Namesto sklepa

Vrsta težav, s katerimi smo se soočili, še nismo uspeli premagati:

  • u keras ni pripravne funkcije za samodejno iskanje optimalne hitrosti učenja (analoga lr_finder v knjižnici fast.ai); z določenim trudom je mogoče prenesti na R zunanje implementacije, na primer, to;
  • kot posledica prejšnje točke, nismo uspeli najti prave hitrosti učenja pri uporabi več GPU-jev;
  • primanjkuje sodobnih arhitektur nevronskih mrež, zlasti tistih, ki so bile vnaprej usposobljene na imagenet-u;
  • manjka one cycle policy in diskriminativne hitrosti učenja (cosine annealing na našo prošnjo je bil je realiziran, hvala skeydan).

Kaj koristnega smo uspeli pridobiti iz tega tekmovanja:

  • Na relativno nenapredni strojni opremi je mogoče brez težav delati s precejšnjimi (večstokrat večjimi od velikosti RAM-a) količinami podatkov. Paket data.table varčuje s pomnilnikom z in-place modificiranjem tabel, kar omogoča izogibanje njihovemu kopiranju, in pri pravilni uporabi njegovih možnosti skoraj vedno pokaže največjo hitrost med vsemi znanimi orodji za skriptne jezike. Shranjevanje podatkov v BDP omogoča v mnogih primerih sploh ne razmišljati o potrebi po vsem podatkovnem naboru v RAM.
  • Počasne funkcije v R je mogoče zamenjati z hitrimi v C++ z uporabo paketa Rcpp. Če poleg tega uporabimo RcppThread ili RcppParallel, dobimo večplatformske večnitedeljske implementacije, zato ni potrebno paralelizirati kode na ravni R.
  • Paket Rcpp se lahko uporablja brez resnega znanja C++, potrebne osnove so opisane ovde. Header datoteke za nekatere napredne C knjižnice, kot so xtensor so na voljo na CRAN, kar pomeni, da se oblikuje infrastruktura za izvajanje projektov, ki integrirajo v R pripravljeno visoko zmogljivo kodo v C++. Dodatna prednost je poudarjanje sintakse in statični analizator kode v C++ v RStudiu.
  • docopt omogoča zagon samostojnih skriptov s parametri. To je priročno za uporabo na oddaljenem strežniku, vključno z Dockerjem. V RStudiu je neprijetno izvajati dolgotrajne eksperimente z usposabljanjem nevronskih mrež, prav tako pa namestitev IDE na strežniku ni vedno upravičena.
  • Docker omogoča prenosljivost kode in ponovljivost rezultatov med razvijalci z razdifferentimi različicami operacijskih sistemov in knjižnic, hkrati pa poenostavlja zagon na strežnikih. Celoten pipeline za učenje lahko zagnate z le eno ukazno vrstico.
  • Google Cloud je cenovno ugoden način za eksperimentiranje na dragi strojni opremi, vendar je treba premišljeno izbrati konfiguracije.
  • Merjenje hitrosti delovanja posameznih delov kode je zelo koristno, zlasti v kombinaciji R in C++, z paketom bench je to še posebej enostavno.

Na splošno je bilo to izkušnja zelo uporabna in še naprej delamo na reševanju nekaterih od izpostavljenih težav.

Izvor: habr.com

Kupite zanesljivo gostovanje za spletne strani z zaščito pred DDoS, VPS VDS strežniki 🔥 Kupite zanesljivo gostovanje za spletne strani z zaščito pred DDoS, VPS VDS strežniki | ProHoster