Quick Draw Doodle Recognition: si të lidhni R, C++ dhe rrjetet nervore

Quick Draw Doodle Recognition: si të lidhni R, C++ dhe rrjetet nervore

Përshëndetje, Habr!

Në vjeshtën e vitit të kaluar, në Kaggle u zhvillua një garë për klasifikimin e vizatimeve të bëra me dorë, Quick Draw Doodle Recognition, në të cilën mori pjesë ekipi i R-sistemeve me përbërje Artem Klevcov, Filipp Upravilev dhe Andrey Ogurtsov. Nuk do ta përshkruajmë garën në detaje, kjo tashmë është bërë në publikimin e fundit.

Me farmën e medaljeve kësaj here nuk pasqyrohej, por u fitua shumë përvojë të vlefshme, ndaj do të dëshironim të ndanim me komunitetin disa nga temat më interesante dhe të dobishme në Kaggle dhe në punën përditshme. Nga temat e shqyrtuara: jeta e vështirë pa OpenCV, analizimi i JSON-ve (në këto shembuj shqyrtohet integrimi i kodit në C++ në skriptet ose paketat në R përmes Rcpp), parametrizimi i skriptëve dhe dockerizimi i zgjidhjes përfundimtare. Të gjithë kodin nga mesazhi në formatin e tij të përdorshëm mund ta gjeni në repozitorit.

Përmbajtja:

  1. Ngarko të dhëna me efikasitet nga CSV në bazën e të dhënave MonetDB
  2. Përgatitja e grupeve të të dhënave
  3. Iteratorët për nxjerrjen e grupeve nga DB
  4. Zgjedhja e arkitekturës së modelit
  5. Parametrizimi i skriptëve
  6. Dockerizimi i skriptëve
  7. Përdorimi i disa GPU-ve në Google Cloud
  8. Në vend të përfundimit

1. Ngarko të dhëna me efikasitet nga CSV në bazën e të dhënave MonetDB

Të dhënat në këtë garë ofrohen jo në formën e imazheve të gatshme, por si 340 skedarë CSV (një për çdo klasë), që përmbajnë JSON me koordinatat e pikave. Duke lidhur këto pika me linea, ne marrim imazhin përfundimtar me përmasa 256x256 piksel. Po ashtu, për çdo regjistrim jepet një etiketë, nëse imazhi është njohur si i saktë nga klasifikuesi në përdorim në momentin e grumbullimit të dataset-it, një kod dytësh për vendin e banuar të autorit të vizatimit, një identifikues unik, një etiketë kohe dhe emri i klasës, që korrespondon me emrin e skedarit. Një version i thjeshtuar i të dhënave zë 7.4 GB në arkiv dhe rreth 20 GB pas shkarkimit, ndërsa të dhënat e plota pas shkarkimit zënë 240 GB. Organizatorët garantuan se të dy versionet prodhojnë të njëjtat vizatime, që do të thotë se versioni i plotë është i tepërt. Në çdo rast, ruajtja e 50 milionëve imazheve në skedarë grafikë ose si tipare menjëherë u njoh si jo fitimprurëse, dhe ne vendosëm që t'i bashkojmë të gjitha skedarët CSV nga arkivi train_simplified.zip në një bazë të dhënash me gjenerimin e imazheve të nevojshme "në flakë" për çdo grup.

Si DBMS u zgjodh një MonetDB, konkretisht implementimi për R në formën e paketës MonetDBLite. Paketa përfshin versionin embedded të serverit të bazës së të dhënave dhe lejon ngritjen e serverit direkt nga sesioni R dhe punën me të aty. Krijimi i bazës së të dhënave dhe lidhja me të kryhen me një komandë:

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

Na nevojitet të krijojmë dy tabela: një për të gjitha të dhënat, një tjetër për informacionin e shërbimit në lidhje me skedarët e ngarkuar (do të jetë e dobishme nëse diçka shkon keq dhe procesi duhet të riniset pas ngarkimit të disa skedarëve):

Krijimi i tabelave

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

Mënyra më e shpejtë për të ngarkuar të dhënat në DB u vërtetua të jetë kopjimi direkt i skedarëve CSV përmes SQL — komanda COPY OFFSET 2 INTO emri_i_tabelës FROM rruga duke përdorur DELIMITERS ',','n','"' NULL AS '' BEST EFFORT, ku emri_tabelës — emri i tabelës dhe path — rruga e skedarit. Gjatë punës me arkiv, u zbulua se implementimi i ndërtuar unzip në R nuk punon saktë me disa skedarë nga arkivi, prandaj ne përdorëm sistemin unzip (me parametrin getOption("unzip")).

Funksioni për të shkruar në bazë

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

Nëse është e nevojshme të keni një transformim të tabelës para se të shkruhet në DB, mjafton të kaloni në argument parapërpunim një funksion, i cili do të transformojë të dhënat.

Kodi për ngarkimin e të dhënave në bazë në mënyrë sekondare:

Shkruaj të dhënat në bazë

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

Koha e ngarkimit të të dhënave mund të variojë në varësi të karakteristikave të shpejtësisë së ruajtësit të përdorur. Në rastin tonë, lexim dhe shkruar brenda një SSD ose nga një USB (skedari burim) në SSD (DB) zërë më pak se 10 minuta.

Për disa sekonda të tjera nevojiten për krijimin e kolonës me etiketë të plotë numerike të klasës dhe kolonës-indeks (ORDERED INDEX) me numrat e rreshtave, sipas të cilit do të bëhet ndarja e vëzhgimeve gjatë krijimit të grupeve:

Krijimi i kolonave shtesë dhe indeksit

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

Për zgjidhjen e problemit të formimit të grupit "në flakë" na duhej të arrinim shpejtësinë maksimale të nxjerrjes së rreshtave të rastit nga tabela doodles. Për këtë ne përdorëm 3 truke. E para ishte të shkurtosh dimensionin e tipit, ku ruhet ID e vëzhgimit. Në grupin e dhënave origjinale, për të ruajtur ID na nevojitet një tip bigint, por numri i vëzhgimeve lejon të përfshihen identifikuesit e tyre, të barabarta me numrin rendor, në një tip int. Kërkimi bëhet dukshëm më shpejt. Truku i dytë ishte përdorimi i ORDERED INDEX — këtë zgjidhje arritëm empiricisht, duke kontrolluar të gjitha opsionet. Triku i tretë ishte përdorimi i kërkesave të parametrizuara. Thelbi i metodës është ekzekutimi i komandës vetëm një herë PREPARE me përdorimin e shprehjes së përgatitur gjatë krijimit të grupeve të kërkesave të ngjashme, por në të vërtetë fitimi krahasuar me thjeshtin SELECT u gjet në rrethin e gabimit statistik.

Procesi i ngarkimit të të dhënave konsumon jo më shumë se 450 MB RAM. Pra, qasja e përshkruar lejon të menaxhohen datasetet me pPesha në dhjetëra gigabajt pothuajse në çdo pajisje buxhetore, duke përfshirë disa karta të vetme, që është mjaft e këndshme.

Mbetej për të kryer matjet e shpejtësisë së nxjerrjes (sfidave) të të dhënave dhe të vlerësohej shkallëzimi gjatë zgjedhjes së grupeve të madhësive të ndryshme:

Benchmark i bazës së të dhënave

library(ggplot2)

set.seed(0)
# Lidhja me bazën e të dhënave
con <- DBI::dbConnect(MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))

# Funksioni për të përgatitur kërkesën në server
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)
}

# Funksioni për nxjerrjen e të dhënave
fetch_data <- function(rs, batch_size) {
  ids <- sample(seq_len(n), batch_size)
  res <- DBI::dbFetch(DBI::dbBind(rs, as.list(ids)))
  return(res)
}

# Kryerja e matjes
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    rs <- prep_sql(batch_size)
    bench::mark(
      fetch_data(rs, batch_size),
      min_iterations = 50L
    )
  }
)
# Parametrat e benchmarkut
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("koha mesatare, s") +
  theme_minimal()

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle Recognition: si të lidhni R, C++ dhe rrjetet nervore

2. Përgatitja e grupeve

I gjithë procesi i përgatitjes së grupeve përbëhet nga etapet e mëposhtme:

  1. Parsing disa JSON-ve, që përmbajnë vektorë string me koordinatat e pikave.
  2. Vizualizimi i linjave të ngjyrave sipas koordinatave të pikave në një imazh të kërkuar (për shembull, 256×256 ose 128×128).
  3. Transformimi i imazheve të marra në tensor.

Brenda garës së kernel-eve në Python, kjo detyrë u zgjidh kryesisht me mjetet OpenCV. Një nga ekuivalentët më të thjeshtë dhe më të dukshëm në R do të dukej si më poshtë:

Implementimi i transformimit të JSON në tensor në R

r_process_json_str <- function(json, line.width = 3, 
                               color = TRUE, scale = 1) {
  # Parsing JSON
  coords <- jsonlite::fromJSON(json, simplifyMatrix = FALSE)
  tmp <- tempfile()
  # Delete temporary file upon 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()
  # Transform image into a 3D array
  res <- png::readPNG(tmp)
  return(res)
}

r_process_json_vector <- function(x, ...) {
  res <- lapply(x, r_process_json_str, ...)
  # Combine 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 with saving to a temporary PNG stored in RAM (on Linux, R temporary directories are located in a directory /tmp, mounted in RAM). Then this file is read as a three-dimensional array with numbers ranging from 0 to 1. This is important since the more common BMP would have been read into 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: si të lidhni R, C++ dhe rrjetet nervore

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 seems suboptimal to us as forming large batches takes an unreasonably long time, and we decided to draw on the experience of colleagues by utilizing a powerful library OpenCV. At that time, there was no ready-made package for R (and there isn't one now), so a minimal implementation of the necessary functionality was written in C++ with integration into the R code using Rcpp.

To solve the task, the following packages and libraries were used:

  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 for working with multidimensional arrays and tensors. Header files included in the eponymous R package were used. The library allows working with multidimensional arrays, both in row-major and column-major order.

  3. ndjson for parsing JSON. This library is used in xtensor automatically when available in the project.

  4. RcppThread for organizing multi-threaded processing of JSON vectors. Header files provided by this package were used. From the more popular RcppParallel package, among other things, features a built-in mechanism for loop interruption (interrupt).

Duhet të theksohet se xtensor u dokazua se si një zgjidhje e shkëlqyer: përveç se ka funksionalitet të gjerë dhe performancë të lartë, zhvilluesit e tij ishin mjaft të përgjegjshëm dhe përgjigjen e tyre ndaj pyetjeve ishte të shpejtë dhe të detajuar. Me ndihmën e tyre, arritëm të realizojmë transformime të matricave OpenCV në tenzorë xtensor, si dhe mënyrën e bashkimit të tenzorëve 3-dimensionale të imazheve në një tenzor 4-dimensionale me dimensionet e sakta (në të vërtetë një grup).

Materialet për studimin e Rcpp, xtensor dhe 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

Për të kompiluara skedarët që përdorin skedarët sistematikë dhe lidhjen dinamike me biblioteka të instaluara në sistem, ne përdorëm mekanizmin e pluginëve të realizuar në paketë Rcpp. Për gjetjen automatike të rrugëve dhe flamujve, përdorëm utilitarin popullor të linux pkg-config.

Realizimi i plugin-it Rcpp për përdorimin e bibliotekës OpenCV

Rcpp::registerPlugin("opencv", function() {
  # Emrat e mundshëm të paketave
  pkg_config_name <- c("opencv", "opencv4")
  # Skedari binar i utilitarit pkg-config
  pkg_config_bin <- Sys.which("pkg-config")
  # Kontrollimi i pranishmërisë së utilitarit në sistem
  checkmate::assert_file_exists(pkg_config_bin, access = "x")
  # Kontrollimi i pranishmërisë së skedarit të konfigurimit OpenCV për pkg-config
  check <- sapply(pkg_config_name, 
                  function(pkg) system(paste(pkg_config_bin, pkg)))
  if (all(check != 0)) {
    stop("Konfigurimi OpenCV për pkg-config nuk u gjet", 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)
  ))
})

Si rezultat i punës së plugin-it, në procesin e kompilimit do të zëvendësohen këto vlera:

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"

Kodi për zbatimin e parsing-ut të JSON dhe formimin e grupit për ta dërguar në model është paraqitur nën spojlerin. Fillimisht, shtojmë direktorinë lokale të projektit për të kërkuar skedarë titujsh (kjo është e nevojshme për ndjson):

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

Realizimi i transformimit nga JSON në tenzor në 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;
}

Ky kod duhet të vendoset në skedarin src/cv_xt.cpp dhe të kompilohet me komandën Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); gjithashtu për funksionimin e tij do të nevojitet nlohmann/json.hpp nga repoja. Kodi është ndarë në disa funksione:

  • to_xt — funksioni i templatuar për transformimin e matricës së imazhit (cv::Mat) në tenzor xt::xtensor;

  • parse_json — funksioni parse JSON-në, nxjerr koordinatat e pikave duke i paketuar në një vektor;

  • ocv_draw_lines — nga vektori i pikave të marrë çizë linja me ngjyra të ndryshme;

  • process — bashkëngjit funksionet e përmendura më sipër, si dhe shton mundësinë për të shkallëzuar imazhin e fituar;

  • cpp_process_json_str — mbështjellës për funksionin process, i cili eksporton rezultatin në një objekt R (masë shumëdimensionale);

  • cpp_process_json_vector — mbështjellës për funksionin cpp_process_json_str, e cila lejon të procesohen vektorët e vargjeve në mënyrë shumë-funksionale.

Për vizatimin e linjave me ngjyra të ndryshme u përdor modeli i ngjyrave HSV me konvertim në RGB. Le të testojmë rezultatin:

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

Quick Draw Doodle Recognition: si të lidhni R, C++ dhe rrjetet nervore
Krahasimi i shpejtësisë së zbatimeve në R dhe 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
)
# Parametrat e benches
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)
# Kryerja e matjes
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
#                  & & &         &
#  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("koha mesatare, s") +
  theme_minimal() +
  scale_color_discrete(name = "", labels = c("cpp", "r")) +
  theme(legend.position = "bottom") 

Quick Draw Doodle Recognition: si të lidhni R, C++ dhe rrjetet nervore

Siç e shohim, rritja e shpejtësisë ishte shumë e konsiderueshme, dhe nuk është e mundur të arrihet kodi në C++ duke përdorur paralelizimin e kodit në R.

3. Iteratorët për eksporte të grupeve nga DB

R ka një reputacion të merituar si një gjuhë për përpunimin e të dhënave që mund të vendosen në RAM, ndërsa Python karakterizohet më shumë nga përpunimi iterativ i të dhënave, i cili lejon realizimin e lehtë dhe pa mundim të llogaritjeve out-of-core (llogaritje që përdorin memorie të jashtme). Një shembull klasik dhe i rëndësishëm për ne në kontekstin e detyrës që po përshkruajmë janë rrjetet neuronale të thella, të cilat janë të trajnuara me metodën e zbritjes gradient, duke aproksimuar gradientin në çdo hap me një sasi të vogël vëzhgimesh, ose mini-batch.

Kornizat për mësimin e thellë, të shkruara në Python, kanë klasa të posaçme që realizojnë iteratorë mbi të dhëna: tabela, imazhe në dosje, formate binarë dhe të tjera. Mund të përdoren variante të gatshme ose të shkruhen ato tona për detyra specifike. Në R ne mund të shfrytëzojmë të gjitha mundësitë e bibliotekës python keras me backend-et e saj duke përdorur paketën me të njëjtin emër, e cila punon mbi paketën reticulate. Ky i fundit meriton një artikull të gjerë të veçantë; ai jo vetëm që lejon ekzekutimin e kodit në Python nga R, por gjithashtu siguron transferimin e objekteve midis sesioneve R dhe Python, duke kryer automatikisht të gjitha konvertimet e nevojshme të tipeve.

Ne e kemi eliminuar nevojën për të ruajtur të gjitha të dhënat në RAM duke përdorur MonetDBLite, gjithë punën "neuronale" do ta kryejë kodi origjinal në Python, ne na mbetet vetëm të shkruajmë një iterator për të dhënat, pasi nuk ka asnjë të gatshëm për këtë situatë as në R dhe as në Python. Kërkesat për të janë në të vërtetë vetëm dy: ai duhet të kthejë batche në një cikël të pafund dhe të ruajë gjendjen e tij midis iteracioneve (kjo në R realizohet në mënyrë të thjeshtë me mjaftueshmëri). Më parë, brenda iteratorit ishte e nevojshme të shndërrohen shpejtësitë R në numpy arrays, por versioni aktual i paketës keras e bën këtë vetë.

Iteratori për të dhënat për trajnim dhe validim doli si në vijim:

Iteratori për të dhënat për trajnim dhe validim

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

Funksioni merr si argument një variabël me lidhjen me DB, numrat e rreshtave të përdorur, numrin e klasave, madhësinë e batch-it, masën (scale = 1 që korrespondon me vizatimin e imazheve 256x256 pikselë, scale = 0.5 — 128x128 pikselë), indikatorin e ngjyrave (color = FALSE cila c akton vizatimin në nuanca gri, duke përdorur color = TRUE çdo goditje vizatohet me një ngjyrë të re) dhe indikatorin e përpunimit për rrjetet e paraformuara në imagenet. Ky i fundit nevojitet për të skaluar vlerat e pikselëve nga intervali [0, 1] në intervalin [-1, 1], i cili është përdorur gjatë trajnimit të modeleve të ofruara me keras modele.

Funksioni i jashtëm përmban një verifikim të tipeve të argumenteve, një tabelë data.table me numrat e rreshtave të marrë në mënyrë të rastësishme nga samples_index dhe numrat e grupeve, numëruesi dhe numri maksimal i grupeve, si dhe shprehja SQL për shkarkimin e të dhënave nga DB. Shtesë, ne kemi përcaktuar brenda një ekuivalenti të shpejtë të funksionit keras::to_categorical(). Kemi përdorur thuajse të gjitha të dhënat për trajnim, duke lënë gjysmë përqind për validim, kështu që madhësia e epokës ishte e kufizuar nga parametri steps_per_epoch kur thërrisni keras::fit_generator(), dhe kushti if (i > max_i) saktë niste vetëm për iteratorin e validimit.

Në funksionin e brendshëm ndodh përzgjedhja e indekseve të rreshtave për grupin tjetër, shkarkimi i regjistrimeve nga DB me rritjen e numëruesit të grupeve, analizimi i JSON-ve (funksioni cpp_process_json_vector(), i shkruar në C++) dhe krijimi i masivave që përparojnë imazhet. Më pas krijohen vektorë one-hot me etiketa klasash, masivët me vlera pikseli dhe etiketat bashkohen në një listë, e cila është vlera e rikthyer. Për të përshpejtuar punën është përdorur krijimi i indekseve në tabela data.table dhe modifikimi në menyrë referencë - pa këto ‘truke’ të paketës data.table është mjaft e vështirë të imagjinosh një funksionim efikas me volume të konsiderueshme të dhënash në R.

Rezultatet e matjeve të shpejtësisë së punës në një laptop Core i5 duken si më poshtë:

Benchmark-i i iteratorit

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

# Indekset për grupin e të dhënave për trajnimin
train_ind <- sample(ind, floor(length(ind) * 0.995))
# Indekset për grupin e verifikimit
val_ind <- ind[-train_ind]
rm(ind)
# Faktorin e shkallës
scale <- 0.5

# Kryerja e matjeve
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
    )
  }
)
# Parametrat e benchmark-ut
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("koha mesatare, s") +
    theme_minimal()

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle Recognition: si të lidhni R, C++ dhe rrjetet nervore

Nëse ka një volum të mjaftueshëm RAM-i, mund të përshpejtohet ndjeshëm funksionimi i bazës së të dhënave duke e transferuar atë në këtë RAM (për detyrën tonë mjafton 32 GB). Në Linux, pjesa mementohet në mënyrë të paracaktuar /dev/shm, duke zënë deri në gjysmën e volumit të RAM-it. Mund të alokosh edhe më shumë, duke redaktuar /etc/fstab, në mënyrë që të rezultojë një shënim si tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Sigurohu që ribashkohu dhe kontrollo rezultatin duke ekzekutuar komandën df -h.

Iteratori për të dhënat testuese duket shumë më i thjeshtë, pasi dataset-i i testit vendoset tërësisht në RAM.

Iteratori për të dhënat testuese

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. Zgjedhja e arkitekturës së modelit

Arkitektura e parë që u përdor ishte mobilenet v1, karakteristikat e së cilës janë analizuar në këtë mesazhin. Ajo është e pranishme në paketën standarde keras dhe, për rrjedhojë, është e disponueshme në paketën me të njëjtin emër për R. Por, kur përpiqeshim ta përdorim atë me imazhe me një kanal, u zbulua diçka e çuditshme: tensor-i hyrës duhet të ketë gjithmonë dimensionin (batch, height, width, 3), dmth numri i kanaleve nuk mund të ndryshohet. Në Python nuk ka një kufizim të tillë, prandaj ne u nxituam dhe shkruam implementimin tonë të kësaj arkitekture, duke e ndjekur artikullin origjinal (pa dropout, që ekziston në versionin e keras):

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

Disavantazhet e këtij qasje janë të dukshme. Dëshirojmë të testojmë shumë modele, por nuk dëshirojmë të rishkruajmë çdo arhitekture manualisht. Gjithashtu, ishim të privuar nga mundësia për të përdorur peshat e modeleve që ishin paraprakisht të trajnuara në imagenet. Si zakonisht, studimi i dokumentacionit ndihmoi. Funksioni get_config() mund të marrësh përshkrimin e modelit në një format të redaktueshëm (base_model_conf$layers — një listë standarde R), dhe funksioni from_config() kthen në një objekt model:

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)

Tani është e lehtë të shkruash një funksion universale për të marrë çfarëdo nga modelet e ofruara si pjesë e keras modeleve me pesha të trajnuara në imagenet ose pa to:

Funksioni për ngarkimin e arkitekturave të gatshme

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) {
  # Kontrollimi i argumenteve
  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)

  # Marrim objektin nga paketa keras
  model_fun <- get0(paste0("application_", name), envir = asNamespace("keras"))
  # Kontrolli i disponueshmërisë së objektit në paketë
  if (is.null(model_fun)) {
    stop("Modeli ", shQuote(name), " nuk u gjet.", call. = FALSE)
  }

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

  # Nëse imazhi nuk është me ngjyra, ndryshojmë dimensionalitetin e inputit
  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)
}

Kur përdoren imazhe njëkanalëshe, pesha të trajnuara nuk përdoren. Kjo mund të rregullohej: duke përdorur funksionin get_weights() për të marrë peshat e modelit në një listë nga arrat R, për të ndryshuar dimensionalitetin e elementit të parë të kësaj liste (duke marrë ndonjë kanal ngjyrash ose me mesataren e të treve), dhe pastaj për të ngarkuar peshat përsëri në model me funksionin set_weights(). Ne nuk e shtuam këtë funksionalitet, pasi në këtë fazë ishte e qartë se ishte më produktive të punosh me imazhe me ngjyra.

Masën kryesore të eksperimenteve e kemi bërë duke përdorur mobilenet version 1 dhe 2, si dhe resnet34. Në këtë garë, arkitekturat më moderne si SE-ResNeXt performuan mirë. Fatkeqësisht, nuk kishim realizime të gatshme në dispozicion, dhe nuk kemi shkruar të tonat (por patjetër do të shkruajmë).

5. Parametrizimi i skripteve

Për lehtësi, i gjithë kodi për nisjen e trajnimit u përgatit si një skript i vetëm, i parametrizuar me docopt si nga:

doc <- '
Përdorimi:
  train_nn.R --help
  train_nn.R --list-models
  train_nn.R [options]

Mundësitë:
  -h --help                   Trego këtë mesazh.
  -l --list-models            Lista e modeleve të disponueshme.
  -m --model=          Emri i modelit të rrjetit nervor [default: mobilenet_v2].
  -b --batch-size=      Madhësia e grupit [default: 32].
  -s --scale-factor=   Faktori i shkallës [default: 0.5].
  -c --color                  Përdor linja me ngjyrë [default: FALSE].
  -d --db-dir=          Rruga drejt dosjes së bazës së dhënash [default: Sys.getenv("db_dir")].
  -r --validate-ratio= Raporti i verifikimit të mostrës [default: 0.995].
  -n --n-gpu=         Numri i GPU-ve [default: 1].
'
args <- docopt::docopt(doc)

Paketa docopt është një implementim http://docopt.org/ për R. Me të, skriptet ekzekutohen me komanda të thjeshta si Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db ose ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, nëse skedari train_nn.R është ekzekutues (kjo komandë do të fillojë trajnimin e modelit resnet50 në imazhe me tre ngjyra me madhësi 128x128 piksel, baza e të dhënave duhet të jetë në dosjen /home/andrey/doodle_db). Në listë mund të shtohet norma e të mësuarit, lloji i optimizuesit dhe çdo parameter tjetër i konfigurueshëm. Në procesin e përgatitjes së publikimit u zbulua se arkitektura mobilenet_v2 nga versioni aktual keras në R përdoret nuk është e mundur për shkak të ndryshimeve që nuk janë marrë parasysh në paketën R — po presim që të korrigjohet.

Ky qasje lejon që eksperimentet me modele të ndryshme të përshpejtohen ndjeshëm krahasuar me ekzekutimin më tradicional të skripteve në RStudio (si një alternativë të mundshme mund të përmendim paketën tfruns). Por avantazhi kryesor qëndron në mundësinë për të menaxhuar lehtësisht ekzekutimin e skripteve në Docker ose thjesht në server, pa instaluar për këtë RStudio.

6. Dockerizimi i skripteve

Ne përdorëm Docker me qëllim të sigurimit të portabilitetit të mjedisit për trajnimin e modeleve midis anëtarëve të ekipit dhe për shpërndarjen e shpejtë në cloud. Të nisim njohjen me këtë vegël relativisht të pazakontë për programuesit R mund të fillojmë me këtë serinë e publikimeve ose me kursin video.

Docker lejon si krijimin e imazheve të veta "nga fillimi", ashtu edhe përdorimin e imazheve të tjera si bazë për të krijuar të veta. Duke analizuar variantet e disponueshme, arritëm në përfundimin se instalimi i drejtuesve NVIDIA, CUDA+cuDNN dhe biblioteka të Python-it është një pjesë mjaft e voluminshme e imazhit, dhe vendosëm të marrim si bazë imazhin zyrtar tensorflow/tensorflow:1.12.0-gpu, duke shtuar aty paketat e nevojshme të R.

Shkalla përfundimtare e Dockerfile-it doli kështu:

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

Për lehtësinë e përdoruesve, paketat e përdorura janë të vendosura në variabla; pjesa kryesore e skripteve të shkruara kopjohet brenda kontejnerëve gjatë ndërtimit. Ne gjithashtu e ndërrojmë shell-in në /bin/bash për lehtësinë e përdorimit të përmbajtjes /etc/os-release. Kjo ndihmon në shmangien e nevojës për të specifikuar versionin e OS në kod.

Përveç kësaj, është shkruar një skript i vogël bash, i cili lejon ekzekutimin e kontejnerit me komanda të ndryshme. Për shembull, këto mund të jenë skripte për trajnim të rrjeteve nervore, të vendosura më parë brenda kontejnerit, ose një shell për debugging dhe monitorimin e funksionimit të kontejnerit:

Skripti për ekzekutimin e kontejnerit

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

Nëse ky skript bash ekzekutohet pa parametra, brenda kontejnerit do të thirret skripti train_nn.R me vlera të paracaktuara; nëse argumenti i parë pozicional është "bash", atëherë kontejneri do të nisë në modin interaktiv me shell-in. Në të gjitha rastet e tjera, ndodhi zëvendësimi i vlerave të argumenteve pozicionale: CMD="Rscript /app/train_nn.R $@".

Duhet të vihet re se drejtoritë me të dhënat e origjinës dhe bazën e të dhënave, si dhe drejtoritë për ruajtjen e modeleve të trajnuara, montohen brenda kontejnerit nga sistemi host, çka lejon qasjen në rezultatet e punës së skripteve pa manovra të panevojshme.

7. Përdorimi i shumë GPU në Google Cloud

Një nga karakteristikat e garës ishin të dhënat mjaft të zhurmshme (shihni imazhin e titullit, e marrë nga @Leigh.plt nga ODS-slack). Për t'u përballur me këtë, ndihmuan grupet e mëdha, dhe pas eksperimenteve në PC me 1 GPU, vendosëm të mësojmë trajnimin e modeleve në disa GPU në re. Përdorëm GoogleCloud (udhëzues i mirë për bazat e punës) për shkak të zgjedhjes së madhe të konfiguracioneve të disponueshme, çmimeve të arsyeshme dhe bonusit prej $300. Për shkak të lakmisë, u porosit një instancë me 4xV100 me SSD dhe shumë RAM, dhe kjo ishte një gabim i madh. Një makinë e tillë konsumon shpejt para, duke eksperimentuar pa një pipeline të punuar mund të shpenzosh shumë. Për qëllime mësimore, është më mirë të marrësh K80. Sidoqoftë, sasia e madhe e RAM ishte e dobishme — SSD-në cloud nuk na impresionoi me shpejtësinë, kështu që na duhej të transferonim bazën e të dhënave në çdo fillim instancës në dev/shm.

Fragmenti i kodit që është më i interesuar tregon për përdorimin e disa GPU-ve. Fillimisht, modeli krijohet në CPU duke përdorur menaxherin e kontekstit, krejt siç bëhet në 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
  )
})

Pastaj, modeli i papërfunduar (këtu është e rëndësishme) kopjohet në numrin e caktuar të GPU-ve të disponueshëm, dhe vetëm atëherë kompilohesh:

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

Nuk arritëm ta realizojmë në mënyrë klasike teknikën e ngrirjes së të gjithë niveleve, përveç atij të fundit, trajnimin e nivelit të fundit, ngrirjen dhe stërvitjen e modelit si një tërësi për disa GPU.

E ndjekur trajnimin pa përdorimin e tensorboard, duke u kufizuar në regjistrimin e logeve dhe ruajtjen e modeleve me emra informativë pas çdo epoke:

Kolbë

# Шаблон имени файла лога
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. Në vend të përfundimit

Një sërë problemesh me të cilat u përballëm nuk arritëm t'i zgjidhim ende:

  • keras nuk ka një funksion të gatshëm për të kërkuar automatikisht shpejtësinë optimale të mësimit (analogun e lr_finder në bibliotekë fast.ai); me disa përpjekje, mund të portosh implementimet e jashtme në R, për shembull, këtë;
  • si pasojë e pikës së mëparshme, nuk arritëm të përcaktonim shpejtësinë e duhur të mësimit në përdorimin e disa GPU-ve;
  • mungojnë arkitekturat moderne të rrjeteve nervorë, veçanërisht ato të trajnuara më parë në imagenet;
  • nuk ka politikën one cycle dhe shkallët diskriminuese të mësimit (kosi annealing sipas kërkesës sonë ishte i realizuar, faleminderit skeydan).

Çfarë dobi të dobishme arritëm të nxjerrim nga kjo garë:

  • Në rrethana të mjaftueshme, mund të punosh pa dhimbje me sasi të konsiderueshme të dhënash (duke e kaluar ndjeshëm madhësinë e RAM). Paketa data.table kurse ruajtjen e memories përmes modifikimit in-place të tabelave, duke shmangur kopjimin e tyre, dhe me përdorimin e duhur të mundësive të tij, tregon pothuajse gjithmonë shpejtësinë më të lartë nga të gjitha mjetet e njohura për gjuhët scripting. Ruajtja e të dhënave në DB në shumë raste lejon që të mos mendoni fare për nevojën për të shtypur të gjithë datasetin në RAM.
  • Funksionet e ngadalta në R mund të zëvendësohen me ato të shpejta në C++ përmes paketës Rcpp. Nëse përveç kësaj përdorim RcppThread ose RcppParallel, marrim realizime të shumëfishta dhe të platformës kryesore, prandaj nuk është e nevojshme të paralelizoni kodin në nivelin R.
  • Paketat Rcpp mund të përdoren pa njohuri të thella në C++, minimumi i nevojshëm është i paraqitur këtu. Skedarët e titullit për disa biblioteka të shkëlqyera në C si xtensor janë të disponueshme në CRAN, që do të thotë se formohet infrastruktura për realizimin e projekteve që integrojnë në R kodin me performancë të lartë në C++. Një lehtësi shtesë — ndihma për sintaksën dhe analisti statik i kodit në C++ në RStudio.
  • docopt lejon ekzekutimin e skripteve autonome me parametra. Kjo është e përshtatshme për përdorim në serverë të largët, përfshirë nën Docker. Të kryesh eksperimente me trajnimin e rrjeteve nervore në RStudio për shumë orë është e papërshtatshme, dhe gjithashtu instalimi i IDE në server nuk është gjithmonë i justifikuar.
  • Docker ofron portabilitetin e kodit dhe riprodhueshmërinë e rezultateve midis zhvilluesve me versione të ndryshme të OS dhe bibliotekave, si dhe thjeshtësinë e ekzekutimit në serverë. Të ekzekutosh të gjithë pipeline-n për trajnimin mund të bëhet me vetëm një komandë.
  • Google Cloud është një mënyrë me kosto të përballueshme për të eksperimentuar në pajisje të shtrenjta, por është e nevojshme të bëhet një zgjedhje e mençur e konfigurimeve.
  • Maturimi i shpejtësisë së punës së segmenteve të veçanta të kodit është shumë e dobishme, sidomos me kombinimin e R-së dhe C++, dhe me paketën bench — është gjithashtu shumë e lehtë.

Në përgjithësi, kjo eksperiencë ka qenë shumë e dobishme dhe ne vazhdojmë të punojmë mbi zgjidhjen e disa prej problemeve të përmendura.

Burimi: habr.com

Bleni hostim të besueshëm për faqe me mbrojtje nga DDoS, serverë VPS VDS 🔥 Bleni hostim të besueshëm për faqe me mbrojtje nga DDoS, serverë VPS VDS | ProHoster