Quick Draw Doodle Recognition: kuidas R, C++ ja närvivõrgud sõbraks saada

Quick Draw Doodle Recognition: kuidas R, C++ ja närvivõrgud sõbraks saada

Tere, Habr!

Eelmisel sügisel toimus Kaggle'is joonistatud piltide klassifitseerimise konkurss Quick Draw Doodle Recognition, kus osales R-skriptide meeskond koosseisus Artem Klevtsov, Filipp Upravilev ja Andrei Ogurtzov. Konkursist ei hakka pikalt rääkima, seda on juba käsitletud hiljutises publikatsioonis.

Medalite farmiga seekord hästi ei läinud, kuid saadi palju väärtuslikku kogemust, seetõttu tahaksime rääkida teatud huvitavamatest ja kasulikest asjadest Kaggle'is ning igapäevases töös. Arutletud teemade seas: elu ilma OpenCV, JSON-e analüüs (näiteks tutvustatakse, kuidas C++ kood integreerub R skriptide või paketidega läbi Rcpp), skriptide parametreerimine ja lahenduste dockeriseerimine. Kõik sõnumis olev kood on käivitatavas vormis saadaval hoidlad.

Sisukord:

  1. Efektiivne andmete laadimine CSV-st MonetDB andmebaasi
  2. Partii ettevalmistamine
  3. Iteraatorid partii andmete laadimiseks andmebaasist
  4. Mudeli arhitektuuri valik
  5. Skripti parametreerimine
  6. Skripti dockeriseerimine
  7. Mitme GPU kasutamine Google Cloudis
  8. Lõpetuseks

1. Efektiivne andmete laadimine CSV-st MonetDB andmebaasi

Selles konkurssosaladuses esitatud andmed ei ole valmis piltide kujul, vaid 340 CSV-failina (iga klassi kohta üks fail), mis sisaldavad JSON-e punktide koordinaatidega. Need punktid ühendades saame lõpppildi, mille suurus on 256x256 pikslit. Iga kirje jaoks on lisatud silt, kas pilt oli korrektne klassifikaatori poolt, kahekohaline kood autori elukohariigist, ainulaadne identifikaator, ajatemple ja klassi nimi, mis vastab faili nimele. Lihtsustatud versioon algandmetest kaalub arhiivis 7,4 GB ja avamisel umbes 20 GB, täielikud andmed pärast avamist võtavad 240 GB. Korraldajad garanteerisid, et mõlemad versioonid esitlevad samu joonistusi, st täielik versioon on üleliigne. Sellegipoolest, 50 miljoni pildi hoidmine graafikafailides või massiivides korraga tunnistati ebaotstarbekaks, ning me otsustasime kõik CSV-failid arhiivist train_simplified.zip koondada andmebaasi, genereerides vajaliku suurusega pilte „režiimis” iga partii jaoks.

Kasutusena valiti hästi tõestatud andmebaasisüsteem MonetDB, nimelt R-i paketina toote rakendus MonetDBLite. Pakett sisaldab andmebaasi serveri embedded-versiooni ja võimaldab serverit käivitada otse R-seansist ning seal töötada. Andmebaasi loomine ja sellele ühendamine toimub ühe käsklusega:

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

Peame looma kaks tabelit: ühe kõigi andmete jaoks ja teise üleslaaditud failide teenuseteabe jaoks (kasulik, kui midagi läheb valesti ja protsessi tuleb jätkata pärast mitme faili üleslaadimist):

Tabelite loomine

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

Andmete kiireim laadimisviis andmebaasi osutus CSV-failide otsekoheseks kopeerimiseks SQL-id kasutades — käsk COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORT, kus tablename — tabeli nimi ja path — faili tee. Arhiiviga töötades leiti, et R-i sisseehitatud rakendus unzip ei tööta õigesti mitmete arhiivifailidega, seetõttu kasutasime süsteemset unzip (kasutades parameetrit getOption("unzip")).

Andmebaasi kirjutamise funktsioon

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

Kui on vajalik tabeli töötlemine enne andmebaasi kirjutamist, piisab, kui edastada argumendiks eelprotsess funktsioon, mis andmeid töötleb.

Kood andmete järjestikuseks laadimiseks andmebaasi:

Andmete kirjutamine andmebaasi

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

Andmete laadimise aeg võib varieeruda sõltuvalt kasutatava salvestusmedia kiirusest. Meie puhul võtab SSD-lt lugemine ja kirjutamine (algfail) SSD-le (andmebaasi) vähem kui 10 minutit.

Veidi rohkem aega kulub klassitüübi täisarvulise märgistuse ja indeksiveeru loomise jaoks (ORDERED INDEX) ridade numbritega, mille järgi valitakse vaatlused partiide loomisel:

Lisaveergude ja indeksi loomine

message("Generate labels")
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)"))

Küsimuse lahendamiseks "realtime" partii loomisel oli meil vaja saavutada maksimaalne juhuslike ridade väljavõtmise kiirus tabelist doodles. Selleks kasutasime kolme trikki. Esimene seisnes selles, et vähendame salvestatava vaatamise ID tüübi mõõtmeid. Algandmestikus vajatakse ID hoidmiseks tüüpi bigint, kuid vaatamiste arv võimaldab nende identifikaatoreid, mis vastavad järjestikusele numbrile, mahu vadada tüüpi int. Otsing toimub seeläbi oluliselt kiiremini. Teine trikk oli kasutada ORDERED INDEX , millele said empiiriliselt kõik saadaval olnud variandid. Kolmas seisnes parametriseeritud päringute kasutamises. Meetodi tuum seisneb käsu PREPARE ühekordses täitmises, millele järgneb ettevalmistatud väljendi kasutamine sarnaste päringute grupi loomisel, kuid tegelikult oli kasu võrreldes lihtsa SELECT statistilise vea piirkonnas.

Andmete laadimisprotsess tarbib mitte rohkem kui 450 MB RAM-i. See tähendab, et kirjeldatud lähenemine võimaldab suudelda kümnete gigabaitide suuruseid andmekogumeid praktiliselt igasugusel eelarvearvutil, sealhulgas mõnel üheplaadisel masinal, mis on üsna muljetavaldav.

Jääb üle mõõta andmete (juhuslik) väljavõtmise kiirus ja hinnata skaleerimist erineva suurusega partii valimisel:

Andmebaasi benchmark

library(ggplot2)

set.seed(0)
# Andmebaasi ühendamine
con <- DBI::dbConnect(MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))

# Funktsioon serveripoolse päringu ettevalmistamiseks
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)
}

# Funktsioon andmete väljavõtmiseks
fetch_data <- function(rs, batch_size) {
  ids <- sample(seq_len(n), batch_size)
  res <- DBI::dbFetch(DBI::dbBind(rs, as.list(ids)))
  return(res)
}

# Aja mõõtmine
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    rs <- prep_sql(batch_size)
    bench::mark(
      fetch_data(rs, batch_size),
      min_iterations = 50L
    )
  }
)
# Bänkimise parameetrid
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("mediaaeg, s") +
  theme_minimal()

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle Recognition: kuidas R, C++ ja närvivõrgud sõbraks saada

2. Batch-ide ettevalmistamine

Kogu batch-ide ettevalmistamise protsess koosneb järgmistest etappidest:

  1. Mitu JSON-i analüüsimine, mis sisaldavad stringide vektoreid punktide koordinaatidega.
  2. Värviliste joonte joonistamine punktide koordinaatide põhjal soovitud suurusega pildil (näiteks 256×256 või 128×128).
  3. Saadud piltide ümberkujundamine tensori formati.

Konkursi raames kernel-ite seas Pythonis lahendati ülesanne peamiselt vahendite abil OpenCV. Üks lihtsamaid ja ilmsemaid analooge R-is näeks välja järgmiselt:

JSON-i ümberkujundamise rakendamine tensori formaadis R-is

r_process_json_str <- function(json, line.width = 3, 
                               color = TRUE, scale = 1) {
  # JSON parsing
  coords <- jsonlite::fromJSON(json, simplifyMatrix = FALSE)
  tmp <- tempfile()
  # Delete temporary file after function execution
  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 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 done using standard R tools, saving to a temporary PNG stored in RAM (in Linux, temporary directories for R 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, as the more common BMP would be 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: kuidas R, C++ ja närvivõrgud sõbraks saada

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 since generating large batches takes an inordinately long time, and we decided to leverage the experience of colleagues by utilizing a powerful library OpenCV. At that time, there was no ready-made package for R (there isn't one now either), so a minimal implementation of the required functionality was written in C++ with integration into the R code via Rcpp.

The following packages and libraries were used for the task:

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

  2. xtensor multidimensionaalsete massiivide ja tenzoritega töötamiseks. Kasutati R-paketi sama nimega päiseid. Raamatukogu võimaldab töötada multidimensionaalsete massiividega, nii read-põhises kui ka veerg-põhises järjekorras.

  3. ndjson JSON-i analüüsimiseks. Seda raamatukogu kasutatakse xtensor projekti olemasolu korral automaatselt.

  4. RcppThread JSON-idest vektori mitme nüansilise töötlemise korraldamiseks. Kasutati selle paketi poolt pakutud päiseid. Kuigi see on vähem populaarne, RcppParallel pakett erineb muu hulgas sisseehitatud tsükli katkestamise mehhanismist (interrupt).

Oluline on märkida, et xtensor oli tõeline leid: lisaks laiale funktsionaalsusele ja kõrgele töötlusvõimele, olid selle arendajad üsna abivalmid ning vastasid kiiresti ja põhjalikult tekkinud küsimustele. Nende abiga õnnestus realiseerida OpenCV maatriksite konversioonid xtensoriteks, ning samuti mooduste 3D-piltide tenzorite ühendamine 4D tenzoriks õige mõõtmega (st batch).

Materjalid Rcpp, xtensor ja RcppThread õppimiseks

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

Süsteemifailide ja süsteemis installitud raamatukogudega dünaamiliseks linkimiseks kasutades failide kompileerimiseks kasutasime pluginate mehhanismi, mis on rakendatud paketi Rcpp. Automaatsete teede ja lipukeste leidmiseks kasutasime populaarset linux-utiliiti pkg-config.

Rcpp plugina rakendamine OpenCV raamatukogu kasutamiseks

Rcpp::registerPlugin("opencv", function() {
  # Võimalikud paketi nimed
  pkg_config_name <- c("opencv", "opencv4")
  # Tööriista pkg-config binaarfail
  pkg_config_bin <- Sys.which("pkg-config")
  # Utiliidi olemasolu kontrollimine süsteemis
  checkmate::assert_file_exists(pkg_config_bin, access = "x")
  # OpenCV seadistuste faili olemasolu kontroll pkg-configile
  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)
  ))
})

Plugina töö tulemuseks kompileerimise käigus sisestatakse järgmised väärtused:

Rcpp:::.plugins$opencv()$env

# $PKG_CXXFLAGS
# [1] "-I/usr/include/opencv"
#
# $PKG_LIBS
# [1] "-lopencv_shape -lopencv_stitching -lopencv_superres -lopencv_videostab -lopencv_aruco -lopencv_bgsegm -lopencv_bioinspired -lopencv_ccalib -lopencv_datasets -lopencv_dpm -lopencv_face -lopencv_freetype -lopencv_fuzzy -lopencv_hdf -lopencv_line_descriptor -lopencv_optflow -lopencv_video -lopencv_plot -lopencv_reg -lopencv_saliency -lopencv_stereo -lopencv_structured_light -lopencv_phase_unwrapping -lopencv_rgbd -lopencv_viz -lopencv_surface_matching -lopencv_text -lopencv_ximgproc -lopencv_calib3d -lopencv_features2d -lopencv_flann -lopencv_xobjdetect -lopencv_objdetect -lopencv_ml -lopencv_xphoto -lopencv_highgui -lopencv_videoio -lopencv_imgcodecs -lopencv_photo -lopencv_imgproc -lopencv_core"

JSON-i parsimise ja mudeli edastamiseks partii loomise kood on esitatud peidetud osas. Esiteks lisame projekti kohaliku katalooge pealkirjade failide leidmiseks (vajalik ndjson jaoks):

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

JSONi muutmise teostamine C++-s

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

See kood tuleb paigutada faili src/cv_xt.cpp ja kompileerida käsuga Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); samuti on töökorras vajalik nlohmann/json.hpp API-s repositsioon. Kood on jagatud mitmeks funktsiooniks:

  • to_xt — mallifunktsioon pildimatriisi muutmiseks (cv::Mat) tensoriks xt::xtensor;

  • parse_json — funktsioon, mis parsib JSON-i stringi, kraabib punktide koordinaadid ja pakib need vektorisse;

  • ocv_draw_lines — joonistab saadud punkti veerust eri värvi jooned;

  • process — ühendab eelnevalt kirjeldatud funktsioonid ning lisab võimaluse saadud pilti skaleerida;

  • cpp_process_json_str — funktsiooni ümberpakendamine, processmis ekspordib tulemuse R-objektina (mitmemõõtmeline massiiv);

  • cpp_process_json_vector — funktsiooni ümberpakendamine, cpp_process_json_str— mis võimaldab töödelda stringivektorit mitme protsessi režiimis.

Erinevate värvijoontide joonistamiseks kasutati HSV värvimudelit, millele järgnes konversioon RGB-ks. Testime tulemust:

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

Quick Draw Doodle Recognition: kuidas R, C++ ja närvivõrgud sõbraks saada
R-i ja C++-i elluviimiste töökiirus võrreldes

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
)
# Benchmarcki parameetrid
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)
# Mõõtmise tegemine
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("mediaan aeg, s") +
  theme_minimal() +
  scale_color_discrete(name = "", labels = c("cpp", "r")) +
  theme(legend.position = "bottom") 

Quick Draw Doodle Recognition: kuidas R, C++ ja närvivõrgud sõbraks saada

Nagu näeme, oli kiirusetõus väga märkimisväärne, ja R keeles koodi paralleelimine ei võimalda C++ koodi saavutada.

3. Iteraatorid andmebaasist partii väljavõtmiseks

R on omandanud maine andmetöötluse keeleks, mis sobib hästi andmete hoidmiseks RAM-is, samas kui Pythoni jaoks on iseloomulikum andmete iteratiivne töötlemine, mis võimaldab hõlpsasti ja mugavalt rakendada out-of-core arvutusi (arvutusi välist mälu kasutades). Klasikaline ja meie konteksti jaoks aktuaalne näide sellistest arvutustest on sügavad närvivõrgud, mida koolitatakse gradientide langemise meetodi abil, lähenedes gradientide ligikaudsetele väärtustele iga sammu juures väikese andmeosa ehk mini-batch'i baasil.

Pythonis kirjutatud sügava õppe raamistikud sisaldavad spetsiaalseid klasse, mis rakendavad andmete iteraatoreid: tabelite, kaustades olevaid pilte, binaarvormate jne. Saame kasutada valmis lahendusi või kirjutada oma, spetsiifiliste ülesannete jaoks. R-is saame kasutada kõiki Pythoni teegi võimalusi. keras selle erinevate tagaplaanide abil sama nime saanud paketi kaudu, mis omakorda töötab paketi reticulate. Viimane väärib eraldi suurt artiklit; see mitte ainult ei võimalda käivitada Pythoni koodi R-ist, vaid ka tagab objektide edastamise R- ja Python-seansside vahel, tehes automaatselt kõik vajalikud tüüpide konversioonid.

Oleme vabastanud end vajadusest hoida kõiki andmeid RAM-is, kasutades MonetDBLite'i; kogu "neuraalvõrgu" töö teeb originaalkood Pythonis, meil jääb vaid kirjutada andmete iterator, kuna selliste olukordade jaoks ei ole ühtegi valmis lahendust ei R-is ega Pythonis. Nõudeid sellele on tegelikult ainult kaks: see peab tagastama parte igavese tsükli jooksul ja säilitama oma seisundi iteratsioonide vahel (viimane R-is rakendatakse kõige lihtsamal viisil sulgemiste abil). Varasemalt tuli iteratoris selgelt teisendada R massiivid numpy-massiivideks, kuid paketi praegune versioon keras teeb seda ise.

Treening- ja valideerimisandmete iterator sai selline:

Treening- ja valideerimisandmete iterator

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

Funktsioon võtab sisendiks muutuja andmebaasiühenduse, kasutatavate ridade numbrid, klasside arvu, parte suuruse, skaala (scale = 1 vastab 256x256 pikslise pildi joonistamisele, scale = 0.5 — 128x128 pikslise pildi joonistamine), värvuse indikaator (color = FALSE määrab must-valge joonistamise, juhul kui kasutatakse värv = TÕENE iga joon tõmmatakse uue värviga) ja ettevalmistuse indikaator võrgu jaoks, mis on eelnevalt treenitud imagenet-is. Viimane on vajalik, et skaalata pikslite väärtused vahemikust [0, 1] vahemikku [-1, 1], mida kasutati koosvarutud mudelite koolitamiseks. keras mudelid.

Väline funktsioon sisaldab argumenditüüpide kontrolli, tabelit data.table juhuslikult segatud ridade numbritega, mis pärinevad samples_index ja partii numbritega, loenduriga ja maksimaalse partii arvuga, samuti SQL-lausena andmete allalaadimiseks andmebaasist. Lisaks määrasime sisemises funktsioonis kiire analooge funktsioonile keras::to_categorical(). Kasutasime koolitamiseks peaaegu kõiki andmeid, jättes pool protsenti valideerimiseks, seega oli epohhi suurus piiratud parameetriga steps_per_epoch käskluse kutsel keras::fit_generator(), ja tingimus if (i > max_i) kehtis ainult valideerimise iteratoori jaoks.

Sisemises funktsioonis valitakse ridade indeksid järgmise partii jaoks, andmete allalaadimine andmebaasist koos partii loenduri suurenemisega, JSON-ide parsimine (funktsioon cpp_process_json_vector(), kirjutatud C++-s) ja massiivide loomine, mis vastavad piltidele. Seejärel luuakse one-hot vektorid klassimärkide, pikslite väärtuste massiivid ja märgid liidetakse loendisse, mis on tagastatav väärtus. Töö kiirusetõstmiseks kasutati indeksite loomist tabelites data.table ja viite kaudu modifitseerimist - ilma nende "nippideta" on pakendi efektiivne kasutamine R-is väga keeruline. data.table Käideldavuse kiirusmõõtmise tulemused sülearvutis Core i5 näevad välja järgmised:

Iteraatori benchmark

Бенчмарк итератора

library(Rcpp)
library(keras)
library(ggplot2)

source("utils/rcpp.R")
source("utils/keras_iterator.R")

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

ind <- seq_len(DBI::dbGetQuery(con, "SELECT count(*) FROM doodles")[[1L]])
num_classes <- DBI::dbGetQuery(con, "SELECT max(label_int) + 1 FROM doodles")[[1L]]

# Indeksid treeningkomplektile
train_ind <- sample(ind, floor(length(ind) * 0.995))
# Indeksid valideerimiskomplektile
val_ind <- ind[-train_ind]
rm(ind)
# Skaala koefitsient
scale <- 0.5

# Maatlemisprotsess
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
    )
  }
)
# Benchmarkerija parameetrid
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: kuidas R, C++ ja närvivõrgud sõbraks saada

Kui RAM-i on piisavalt, saab andmebaasi tõhusust oluliselt parandada, viies selle RAM-i (meie ülesande jaoks piisab 32 GB). Linuxis monteeritakse vaikimisi /dev/shm, mis hõivab kuni poole RAM-i mahust. Saame eraldada ka rohkem, muutes /etc/fstab, et saada järgnev kirje tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Peame kindlasti taaskäivitama ja tulemuse kontrollimiseks käivitama käsu df -h.

Testandmete iteratsioon on palju lihtsam, kuna testkomplekt mahub täielikult RAM-i:

Testandmete iteratsioon

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. Mudeli arhitektuuri valimine

Esimene kasutatud arhitektuur oli mobilenet v1, mille omadused on arutatud selles teates. See on saadaval standardse tarnimisega keras ning vastavalt ka ühesuguses R-paketis. Kuid ühekanalsete piltide kasutamisel tekkis kummaline probleem: sisendi tensori mõõtmed peavad alati olema (batch, height, width, 3), see, the number of channels cannot be changed. In Python, there is no such limitation, so we hastily created our own implementation of this architecture, following the original article (without the dropout present in the Keras version):

Mobilenet v1 arhitektuur

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

Selle lähenemise puudused on ilmsed. Soov on katsetada palju mudeleid, kuid iga arhitektuuri käsitsi ümberkirjutamine pole soovitav. Samuti olime ilma võimalusest kasutada imaginet-il eelõppinud mudelite kaalusid. Nagu alati, aitas dokumentatsiooni uurimine. Funktsioon get_config() võimaldab saada mudeli kirjeldust redigeerimiseks sobivas vormis (base_model_conf$layers — tavaline R-klasside loetelu), ja funktsioon from_config() teeb tagasiülekande mudelobjekti:

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)

Nüüd on lihtne kirjutada universaalset funktsiooni, et saada ükskõik milline pakkumistest, keras mudelitest, millel on treenitud kaalu täiendavad või ilma nendeta:

Funktsioon valmis arhitektuuride laadimiseks

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) {
  # Argumentide kontroll
  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)

  # Saame objekti keras paketist
  model_fun <- get0(paste0("application_", name), envir = asNamespace("keras"))
  # Kontrollime objekti olemasolu paketis
  if (is.null(model_fun)) {
    stop("Mudelit ", shQuote(name), " ei leitud.", call. = FALSE)
  }

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

  # Kui pilt pole värviline, muudame sisendi dimensiooni
  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)
}

Ühekanaliste piltide kasutamisel ei kasutata eelõpitud kaalusid. Selle saaks parandada: funktsiooni get_weights() kaudu, et saada mudeli kaalu R-massiivide nimekirjana, muuta esimese elemendi mõõtmeid (valides mõne värvikanali või keskmistades kõik kolm) ja seejärel laadida kaalu tagasi mudelisse funktsiooni set_weights(). Me ei lisanud seda funktsionaalsust, kuna sellel etapil oli juba selge, et kvaliteetsete värviliste piltidega on tõhusam töötada.

Enamik katsetusi viisime läbi mobilenet versioonide 1 ja 2 ning resnet34 abil. Selles võistluses näitasid end hästi kaasaegsemad arhitektuurid, nagu SE-ResNeXt. Kahjuks ei olnud meil valmis teostusi, kuid me ei ole oma versioone koostanud (kuid kindlasti teeme seda).

5. Skriptide parametriseerimine

Kasutamise mugavuse huvides oli kogu kood, mis käivitab koolituse, vormistatud ühtseks skriptiks, mis on parametriseeritud docopt järgmiselt:

doc <- '
Kasutamine:
  train_nn.R --help
  train_nn.R --list-models
  train_nn.R [valikud]

Valikud:
  -h --help                   Näita seda sõnumit.
  -l --list-models            Loetle saadaolevad mudelid.
  -m --model=          Neuraalvõrgu mudeli nimi [vaikimisi: mobilenet_v2].
  -b --batch-size=    Partii suurus [vaikimisi: 32].
  -s --scale-factor=    Skaala tegur [vaikimisi: 0.5].
  -c --color                  Kasuta värvilisi jooni [vaikimisi: FALSE].
  -d --db-dir=          Andmebaasi katalooge tee [vaikimisi: Sys.getenv("db_dir")].
  -r --validate-ratio=  Valideeri näidise suhe [vaikimisi: 0.995].
  -n --n-gpu=         GPU-de arv [vaikimisi: 1].
'
args <- docopt::docopt(doc)

Pakett docopt on rakendus http://docopt.org/ R jaoks. Selle abil käivituvad skriptid lihtsate käskudega, mis näevad välja nagu Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db või ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, kui fail train_nn.R on käivitatav (see käsk käivitab mudeli koolituse resnet50 kolme värvilise pildi suurusega 128x128 pikslit, andmebaas peab asuma kaustas /home/andrey/doodle_db). Loendisse saab lisada õppimise kiirus, optimeerija tüüp ja kõik muud konfigureeritavad parameetrid. Avaldamise ettevalmistamisel selgus, et arhitektuuri mobilenet_v2 aktuaalsest versioonist keras R-is kasutamine sisestada URL-i (see ei ole kunagi töötanud, kuid sisestusala lubas URL-i määrata, mis segas kasutajaid). R-paketi muutustega, mis pole arvesse võetud — ootame parandusi.

See lähenemine võimaldas oluliselt kiirendada katsetusi erinevate mudelitega võrreldes traditsioonilise skriptide käivitamisega RStudio-s (võimaliku alternatiivina mainime paketti tfruns). Kuid peamine eelis on võimalus hõlpsasti hallata skriptide käivitamist Dockeris või lihtsalt serveris, ilma et oleks vaja RStudio-d paigaldada.

6. Skriptide dockeriseerimine

Kasutasime Dockeri eesmärgiga tagada keskkonna ülekantavuse mudelite koolitamiseks tiimi liikmete vahel ja kiireks juurutamiseks pilves. Selle suhteliselt harjumatu tööriista tundmaõppimist R-programmeerijana saab alustada selle artiklite sarjast või videokursusest.

Docker võimaldab luua nii oma pilte "nullist" kui kasutada teisi pilte alusena oma loomisel. Analüüsides olemasolevaid variante, jõudsime järeldusele, et NVIDIA draiverite, CUDA+cuDNN ja Python'i teekide installimine on üsna mahukas osa pildist, ja otsustasime võtta aluseks ametliku pildi tensorflow/tensorflow:1.12.0-gpu, lisades sinna vajalikud R-paketid.

Lõplik Docker'i fail näeb välja järgmine:

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

Mugavuse huvides on kasutatud pakettide loetelu eraldi muutujatesse. Suur osa kirjutatud skripte kopeeritakse konteinerite sisse ehitamise ajal. Samuti muutisime käsurea tõlkija /bin/bash mugavuse tõstmiseks sisu /etc/os-release. See vältis vajadust määrata koodi sees operatsioonisüsteemi versioon.

Lisaks on kirjutatud väike bash-skript, mis võimaldab konteinerit käivitada erinevate käskudega. Näiteks võivad need olla skriptid närvivõrkude treenimiseks, mis on eelnevalt konteinerisse paigutatud, või käsurea tõlkija konteineri tõrkeotsimiseks ja jälgimiseks:

Konteineri käivitamise skript

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

Kui käivitada see bash-skript ilma parameetriteta, kutsutakse konteineris esile skript train_nn.R vaikeväärtustega; kui esimene positsiooniline argument on "bash", käivitub konteiner interaktiivses režiimis käsurea tõlgiga. Kõigil muudel juhtudel asetatakse positsiooniliste argumentide väärtused: CMD="Rscript /app/train_nn.R $@".

Tasub tähele panna, et sisendandmete ja andmebaasi kataloogid ning treenitud mudelite salvestamise kataloog montaažitakse konteinerisse hostisüsteemist, mis võimaldab juurdepääsu skriptide töö tulemustele ilma liigsete toiminguteta.

7. Mitme GPU kasutamine Google Cloudis

Üks võistluse eripära oli väga mürarikkad andmed (vt pealkildiga pilti, mis on saadud @Leigh.plt ODS Slackist). Suurte partii suurustega aitab sellega võidelda ning pärast katsetusi 1 GPU-ga arvutil otsustasime õppida mudelite treenimist mitmel GPU-l pilves. Kasutasime GoogleCloudi (hea juhend töö põhialuste kohta) tänu laiale valikule saadaval olevatest konfiguratsioonidest, mõistlike hindade ja 300 dollari boonuse tõttu. Ahneuse tõttu tellisime 4xV100 SSD ja suure hulga RAM-iga instantsi, mis osutus suureks veaks. See masin sööb raha kiiresti, katsetades ilma väljatöötatud torustikuta võib pankrotisse minna. Õppe eesmärkidel on parem võtta K80. Suur RAM- maht osutus siiski kasulikuks - pilves SSD ei olnud kiirete omadustega muljetavaldav, seetõttu kanti andmebaas iga instantsi käivitamisel üle dev/shm.

Suurimat huvi pakub koodifragment, mis vastutab mitme GPU kasutamise eest. Esiteks luuakse mudel CPU-l konteksti halduri abil, nagu Pythonis:

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

Seejärel kopeeritakse kompilatsioonita (see on oluline) mudel määratud arvu saadaval GPU-dele, ja alles seejärel kompileeritakse:

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

Klassikalist lähenemist, mille puhul külmutatakse kõik kihid peale viimase, treenitakse viimast kihti, seejärel sulatatakse mudel täielikult üles ja vajadusel treenitakse uuesti mitmel GPU-l, ei õnnestunud realiseerida.

Koolituse jälgimist tehti ilma tensorboard, piirdudes logide salvestamise ja informatiivsete nimedega mudelite hoidmisega pärast iga epohhi:

Kutsead

# Шаблон имени файла лога
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. Kokkuvõtte asemel

Rida probleeme, millega me silmitsi seisame, pole veel lahendatud:

  • ja keras puudub valmis funktsioon, mis automaatselt otsib optimaalset õppimiskiirus (analoog lr_finder raamatukogus fast.ai); natuke vaeva nähes on võimalik kolmanda osapoole rakendusi R-ile portida, näiteks seda;
  • eelmise punkti tulemusena ei õnnestunud õiget õppimiskiirus valida, kui kasutatakse mitu GPU-d;
  • puuduvad kaasaegsed närvivõrkude arhitektuurid, eriti neid, mis on eelnevalt treenitud imagenet-il;
  • puuduvad one cycle policy ja diskrimineerivad õppimiskiirus (cosine annealing meie palumise järgi oli rakendatud, aitäh skeydan).

Mida kasulikku õnnestus sellest võistlusest välja tuua:

  • Suhteliselt madala jõudlusega riistvaraga on võimalik töötada mõistlike (korduvalt suuremate kui RAM-i suurus) andmemahtudega. Pakett data.table kasutab mälu säästmise jaoks in-place muutmist tabelites, mis võimaldab vältida nende kopeerimist, ja sobiva kasutuse korral näitab see peaaegu alati kõige suuremat kiiruset kõigi tuntud skriptikeelte tööriistade seas. Andmete salvestamine andmebaasi võimaldab paljude juhtumite korral üldse mitte mõelda vajadusele suruda kogu andmestik RAM-i.
  • Aeglased funktsioonid R-is saab asendada kiirete C++-ga paketi abil Rcpp. Kui lisaks kasutada RcppThread või RcppParallel, saame ristplatvormilised mitme haru rakendused, seega ei ole R-tasandi koodi paralleelne töötlemine vajalik.
  • Paketti Rcpp võib kasutada ilma tõsise C++ teadmiseta, vajalik miinimum on esitatud siin. Mitmete suurepäraste C-keele raamatukogude päised, nagu xtensor , on saadaval CRAN-is, seega luuakse infrastruktuur projektide rakendamiseks, mis integreerivad R-i valmis kõrge jõudlusega C++ koodi. Täiendav mugavus — C++ süntaksihõlma ja staatiline koodi analüsaator RStudios.
  • docopt võimaldab iseseisvaid skripte käivitada parameetritega. See on mugav kasutamiseks kaugserveril, sealhulgas Dockeris. RStudios on keeruline läbida pikaajalisi eksperimente närvivõrkude koolitamisega, ja ka IDE installimine serverisse ei ole alati põhjendatud.
  • Docker tagab koodi ülekandmise ja tulemuste korduvuse erinevate operatsioonisüsteemide ja teekide versioonide vahel, samuti lihtsustab see käivitamist serverites. Kogu koolitusprotsessi saab alustada ühe käsuga.
  • Google Cloud on eelarvesõbralik viis eksperimentimiseks kallite seadmetega, kuid konfiguratsioone tuleb hoolikalt valida.
  • Erinevate koodilõikude töökiirusel mõõtmine on väga kasulik, eriti R ja C++ kombinatsioonis, ning koos paketiga bench on see veelgi lihtsam.

Kokkuvõttes oli see kogemus väga kasulik ja me jätkame töötamist mõnede esitatud probleemide lahendamise kallal.

Allikas: habr.com

Osta usaldusväärne hostimine veebilehtede jaoks DDoS-i kaitsega, VPS VDS serverid 🔥 Osta usaldusväärne hostimine veebilehtede jaoks DDoS-i kaitsega, VPS VDS serverid | ProHoster