Quick Draw Doodle Recognition: kuidas R, C++ ja närvivõrgud kokku sobitada

Quick Draw Doodle Recognition: kuidas R, C++ ja närvivõrgud kokku sobitada

Tere, Habr!

Eelmisel sügisel toimus Kaggle'is joonistatud piltide klassifitseerimise konkurss Quick Draw Doodle Recognition, kus osales R-i meeskond koosseisus Artem Klevtsov, Filipp Upravitelev ja Andrei Ogurtsov. Me ei hakka võistlust detailsemalt kirjeldama, sellest on juba kirjutatud hiljutises postituses.

Medalite kogumist seekord ei õnnestunud, kuid saavutati palju väärtuslikku kogemust, seega sooviksin rääkida kogukonnale mõningatest kõige huvitavamatest ja kasulikematest asjadest, mis on seotud Kaggle'i ja igapäevase tööga. Arutatud teemade hulgas: mitte lihtne elu ilma OpenCV, JSON'i parsimine (neid näiteid käsitletakse C++ koodi integreerimise kaudu R-i skriptidesse või pakettidesse kasutades Rcpp), skriptide parameetriseerimine ja lõpliku lahenduse konteinerimine. Kõik postituses sisalduv kood on käivitatavas vormis saadaval repositoriis.

Sisu:

  1. Efektiivne andmete laadimine CSV-st MonetDB andmebaasi
  2. Partiiside ettevalmistamine
  3. Iteraatorid partii andmete eksportimiseks andmebaasist
  4. Mudeli arhitektuuri valik
  5. Skriptide parameetriseerimine
  6. Skriptide konteinerimine
  7. Google Cloudis mitme GPU kasutamine
  8. Kokkuvõtte asemel

1. Tõhus andmete laadimine CSV-failist MonetDB andmebaasi

Käesolevas võistluses esitatud andmed ei ole valmis piltidena, vaid 340 CSV-faili (iga klassi kohta üks fail), mis sisaldavad JSON-e punktide koordinaatidega. Need punktid ühendades joontega, saame lõpuks pildi suuruses 256x256 pikslit. Iga kirje jaoks tuuakse välja ka silt, kas pilt oli õigesti tuvastatud andmekogumise hetkel kasutatud klassifikaatori poolt, autori elukoha kahe tähega riigikood, unikaalne identifikaator, ajatempli silt ja klassinimi, mis vastab faili nimele. Lihtsustatud versioon algandmetest kaalub 7,4 GB arhiivis ja umbes 20 GB pärast dearkiveerimist, samas kui täielikud andmed dearkiveerimise järel võtavad 240 GB. Korraldajad garanteerisid, et mõlemad versioonid toovad välja samad joonised, mistõttu on täisversioon üleliigne. Igatahes, 50 miljoni pildi säilitamine graafilistes failides või massiivides on kohe tunnistatud ebaotstarbekaks ning otsustasime kõik CSV-failid arhiivist kokku sulatada. train_simplified.zip andmete baasi, mille tulemusena genereeritakse igas mõõdus pildid "otse" iga partii jaoks.

Andmebaasiks valiti hästi tuntud ja usaldusväärne MonetDB, nimelt R-i rakendus paketina MonetDBLite. Pakett sisaldab sisseehitatud andmebaasiserveri versiooni ja võimaldab käivitada serverit otse R-seansist ning selle juures töötada. Andmebaasi loomine ja sellega ühendamine toimub ühe käsuga:

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

Peame looma kaks tabelit: ühe kõigi andmete jaoks ja teise laadimise ajal üles laaditud 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 üleslaadimise meetod osutus CSV-failide otseseks kopeerimiseks SQL-i vahenditega — käsk KOPIY OFFSET 2 TABELLISE PATHIST ALLDEMADEGADEL, KASUTADES PIIRDEID ',','n','"' NULL KUI '' PARIM TÖÖ, kus tabelinimi — tabeli nimi ja failitee — tee faili. Arhiiviga töötamisel leiti, et sisseehitatud rakendus unzip R-is ei toimi õigesti mitmete failidega arhiivist, seetõttu kasutasime süsteemi unzip (parameetri kaudu getOption("unzip")).

Funktsioon andmete salvestamiseks andmebaasi

#' @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 vaja tabelit enne andmebaasi salvestamist töödelda, piisab, kui edastada argumendiks eeltöötlus funktsioon, mis töötleb andmed.

Kood andmete järkjärguliseks laadimiseks andmebaasi:

Andmete salvestamine 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 kasutatud salvestusseadmest. Meie juhul toimub lugemine ja kirjutamine SSD-lt või USB-st (algfail) SSD-le (andmebaasi) alla 10 minuti.

Veel mõned sekundid kuluvad klassi täisarvu märgise ja indeksiveergu loomise (KORRALDATUD INDEKS) rea numbritega, mille alusel valitakse tähelepanekud partii loomisel:

Lisa veergude ja indeksi loomine

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

Ette ülesande lahendamiseks, et luua partii "režiimis", oli meil vaja saavutada maksimaalne kiirus juhuslike ridade väljavõtmiseks tabelist. doodles. Selle saavutamiseks kasutasime kolme nippi. Esimene seisnes vaatluse ID hoidmise tüübi mõõtme vähendamises. Algsetes andmekogudes on ID hoidmiseks vajalik tüüp bigint, kuid vaatlemise arv võimaldab nende identifikaatoreid, mis on järjestusnumbrid, mahutada tüüpi int. Otsing toimub sel juhul märgatavalt kiiremini. Teine nipp oli kasutada KORRALDATUD INDEKS — sellele lahendusele jõudsime empiiriliselt, testides kõiki võimalikke variantes.Kolmas oli parameetriliste päringute kasutamine. Meetodi essents seisneb käsu PREPARE ühekordses täitmisel, liigse taoline päringute loomisel, kuid tegelikus elus saame võrreldes tavalisega kasu. SELECT languste statistilise vea piiresse.

Andmete üleslaadimise protsess kasutab mitte rohkem kui 450 MB RAM-i. See tähendab, et kirjeldatud lähenemine võimaldab töötada kümnete gigabaitide suuruste andmestikega praktiliselt igasugustes odavates seadmetes, sealhulgas mõnedel ühesilmaemulatoritel, mis on üsna muljetavaldav.

On vaja teha juhuslike andmete väljavõtmise kiirusmõõtmised ja hinnata skaleeritavust erineva suurusega parteide valimisel:

Andmebaasi tõhususe test

library(ggplot2)

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

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

# Testi läbiviimine
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("mediaan aeg, s") +
  theme_minimal()

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle Recognition: kuidas R, C++ ja närvivõrgud kokku sobitada

2. Batšite ettevalmistamine

Kogu partii ettevalmistamise protsess koosneb järgmistest etappidest:

  1. Mitme JSON-i parsimine, mis sisaldavad punktide koordinaatidega stringide vektoreid.
  2. Värviliste joonte joonistamine punktide koordinaatide järgi soovitud suurusega pildil (näiteks 256×256 või 128×128).
  3. Saadud piltide teisendamine tensori vormi.

Python'i kernelite võistluse raames lahendati ülesanne peamiselt vahenditega OpenCV. Üks lihtsamaid ja ilmsemaid analooge R-is näeks välja selline:

JSON-i teisendamise rakendamine tensori vormi R-is

r_process_json_str <- function(json, line.width = 3, 
                               color = TRUE, scale = 1) {
  # JSON parse
  coords <- jsonlite::fromJSON(json, simplifyMatrix = FALSE)
  tmp <- tempfile()
  # Remove temporary file after function completes
  on.exit(unlink(tmp))
  png(filename = tmp, width = 256 * scale, height = 256 * scale, pointsize = 1)
  # Empty plot
  plot.new()
  # Plot window dimensions
  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)
}

Joonistamine toimub R-i standardsete tööriistadega, salvestades ajutisse PNG-faili, mis asub RAM-is (Linuxi puhul asuvad ajutised R-i kataloogid) /tmp, mis on monteeritud RAM-i). Seejärel loetakse see fail kolmemõõtmeliseks massiiviks, mille numbrid on vahemikus 0 kuni 1. See on oluline, kuna tavaline BMP loetaks hex-koodide raw-massiiviks.

Katsume tulemust:

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 kokku sobitada

Batch koostatakse järgmiselt:

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

See rakendus tundus meile mitteoptimaalne, kuna suurte partii(de) moodustamine võtab ebanormaalselt kaua aega, ja me otsustasime kasutada kolleegide kogemusi, kaasates võimsat teeki OpenCV. Sellel ajal ei olnud R jaoks valmis paketti (ja ei ole ka praegu), seetõttu kirjutati C++-s minimaalne rakendus vajaliku funktsionaalsuse integreerimiseks R-i koodi. Rcpp.

Ülesande lahendamiseks kasutati järgmisi pakette ja teeke:

  1. OpenCV piltidega töötamiseks ja jooniste tegemiseks. Kasutati eelnevalt installitud süsteemiteeke ja päise faile ning ka dünaamilist linkimist.

  2. xtensor mõõtmete massiivide ja tensoritega töötamiseks. Kasutasime päiseid, mis on kaasatud samanimelisse R-paketti. Raamatukogu võimaldab töötada mõõtmete massiividega, nii row major kui column major järjekorras.

  3. ndjson JSON-i parsimiseks. Seda raamatukogu kasutatakse xtensor automaatsete olemasolu korral projektis.

  4. RcppThread JSON-idest vektori mitme töötluse korraldamiseks. Kasutasime selle paketi poolt pakutavaid päiseid. Rohkem tuntud RcppParallel pakett erineb muu hulgas sisseehitatud katkestusmehhanismiga (interrupt).

Oluline on märkida, et xtensor oli lihtsalt leid: lisaks laiale funktsionaalsusele ja kõrgele jõudlusele olid tema arendajad üsna reageerivad, vastates kiiresti ja põhjalikult tekkivatele küsimustele. Nende abiga õnnestus realiseerida OpenCV-matriiside teisendamine xtensor-tensoriteks, samuti meetod kolme mõõtmeliste piltide tensorite ühendamiseks neljamõõtmeliseks tensoriks õige mõõtmega (praktiliselt partii).

Materjalid Rcpp, xtensor ja RcppThread uurimiseks

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 teekidega dünaamiliseks linkimiseks vajalike failide kompileerimiseks kasutasime pistikprogrammide mehhanismi, mis on rakendatud paketis Rcpp. Tee ja lipu automaatseks leidmiseks kasutasime populaarset Linuxi utiliiti pkg-config.

Rcpp-pistikprogrammi rakendamine OpenCV teegi kasutamiseks

Rcpp::registerPlugin("opencv", function() {
  # Võimalikud paketi nimed
  pkg_config_name <- c("opencv", "opencv4")
  # pkg-config utiliidi binaarfail
  pkg_config_bin <- Sys.which("pkg-config")
  # Kontroll utiliidi olemasolu süsteemis
  checkmate::assert_file_exists(pkg_config_bin, access = "x")
  # Kontroll OpenCV seadete faili olemasolu pkg-config jaoks
  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)
  ))
})

Pistikprogrammi töö tulemusena kompileerimise käigus asendatakse 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 mudelile edastamiseks mõeldud partii loomise kood on esitatud peidetud osa all. Eeltingimusena lisame projekti kohalikku katalooge pealkirjade failide leidmiseks (vajalik ndjson-i jaoks):

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

JSON-i teisendamise C++ kood

// [[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 tuleks salvestada faili src/cv_xt.cpp ja kompileerida käsuga Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); töötamiseks on vajalik ka nlohmann/json.hpp kohast reposiitiumist. Kood on jaotatud mitmeks funktsiooniks:

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

  • parse_json — funktsioon analüüsib JSON-stringi, eraldades punktide koordinaadid ja pakkides need vektorisse;

  • ocv_draw_lines — joonistab saadud punkti vektorist erinevat värvi jooni;

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

  • cpp_process_json_str — funktsiooni ümbritsev wrapper, processmis eksportib tulemuse R-objekti (mitmemõõtmelise massiivi);

  • cpp_process_json_vector — funktsiooni ümbritsev wrapper, cpp_process_json_str, mis võimaldab töödelda stringivektorit mitme lõime režiimis.

Erinevate värvijoonte joonistamiseks kasutati HSV värvimudelit, millele järgnes RGB-ks konverteerimine. 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 kokku sobitada
R ja C++ rakenduste kiirusvõrdlus

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
)
# Benchmarki 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 teostamine
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 kokku sobitada

Kuidas näha, oli kiirusetõus märkimisväärne, ja R keeles koodi paraliseerimisega C++ koodi järele jõuda ei õnnestu.

3. Iteraatorid andmebaasi gruppide väljundiks

R-l on õigustatud maine mäluandmete töötlemise keele osas, samas kui Pythonile on iseloomulik andmete iteratiivne töötlemine, mis võimaldab kenasti ja lihtsalt läbi viia out-of-core arvutusi (arvutused, mis kasutavad välist mälu). Klassikaline ja aktuaalne näide, mis on meie kontekstis asjakohane, on sügavad närvivõrgud, mida õpetatakse gradientide langetamise meetodil, gradientide lähendamisega igal sammul väikese andmeosa või mini-grupi põhjal.

Sügava õppe raamistikud, mis on kirjutatud Pythonis, sisaldavad eriklasse, mis töötlevad andmeid: tabeleid, pilte kaustades, binaarformaatides jne. Saame kasutada valmis variante või kirjutada oma, et täita spetsiifilisi ülesandeid. R-is saame kasutada kõiki Python'i raamatukogu võimalusi. keras oma erinevate tagaplaanidega sama nimetusega paketi abil, mis omakorda töötab paketi peal reticulate. Viimane väärib eraldi suurt artiklit; see mitte ainult ei võimalda Pythonis koodi käitada R-st, vaid tagab ka objektide edastamise R- ja Python-seansside vahel, automaatselt teostades kõik vajalikud tüüpide konversioonid.

Me oleme loobunud vajadusest hoida kõik andmed RAM-is, kasutades MonetDBLite-d; kogu "neuraalvõrgu" töö teeb originaalkood Pythonis, meie ülesanne on vaid kirjutada andmete iteratoor, kuna sellist valmis lahendust ei ole ei R- ega Pythonis. Sellele on põhimõtteliselt vaid kaks nõuet: see peab tagastama partieid lõpmatus tsüklis ja säilitama oma oleku iteratsioonide vahel (viimane R-is on lihtsaim viis saavutada sulgemiste abil). Varasemalt tuli iteratooris selgelt konverteerida R-massiivid numpy-massiivideks, kuid paketi praegune versioon keras teeb seda ise.

Koolitus- ja valideerimisandmete iteratoor on järgmine:

Koolitus- ja valideerimisandmete iteratoor

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 andmebaasiühenduse, kasutatavate ridade numbrid, klasside arvu, partii suuruse, skaala (skaala = 1 vastab piltide joonistamisele suuruses 256x256 pikslit, skaala = 0.5 — 128x128 pikslit), värvuse indikaator (värv = FALSE määrab joonistamise halltoonis, kui kasutatakse värv = TRUE igal joonistusel on uus värv) ja indikaator eeltöötluseks mudelile, mis on eelnevalt koolitatud imagenet-il. Viimane on vajalik, et skaleerida pikslite väärtused vahemikust [0, 1] vahemikku [-1, 1], mida kasutati juba välja antud keras mudelite koolitamisel.

Väline funktsioon sisaldab argumentide tüüpide kontrollimist, tabelit data.table juhuslikult segatud ridade numbritega samples_index ja partii numbritega, loendur ja maksimaalne partii arv, ning SQL-lause andmete väljavõtmiseks andmebaasist. Täiendavalt oleme määratlenud kiirversiooni funktsioonist keras::to_categorical().Koolitamiseks oleme kasutanud peaaegu kogu andmestikku, jättes poole protsendi valideerimiseks, seega oli epohhi suurus piiratud parameetriga steps_per_epoch kui kutsutakse keras::fit_generator(), ja tingimus if (i > max_i) sattus ainult valideerimise iteratsioonile.

Sisemises funktsioonis valitakse järgmise partii rea indeksid, laaditakse andmed andmebaasist, suurendades partii loendurit, parsitakse JSON-e (funktsioon cpp_process_json_vector(), kirjutatud C++ keeles) ja luuakse massiivid, mis vastavad piltidele. Seejärel luuakse one-hot vektoreid klassimärkidega, massiivid piksliväärtustega ja sildid ühendatakse nimekirjaks, mis on tagastatav väärtus. Töö kiirus on parendatud tabelite indeksite loomisega data.table ja viite kaudu modifitseerimisega — ilma nende "nippideta" on üsna raske ette kujutada tõhusat töötlemist märkimisväärsete andmehulkadega R-is. data.table Noote järgi mõõdetud kiirusetulemused lahtiselt Core i5-l näevad välja järgmised:

Iteraatori tulemuslikkuse test

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

# Treeningvaliku indeksid
train_ind <- sample(ind, floor(length(ind) * 0.995))
# Validatsiooni valiku indeksid
val_ind <- ind[-train_ind]
rm(ind)
# Skaleerimise koefitsient
scale <- 0.5

# Mõõtmise teostamine
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
    )
  }
)
# Bänkmärkide 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("mediaan aega, s") +
    theme_minimal()

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle Recognition: kuidas R, C++ ja närvivõrgud kokku sobitada

Kui piisavalt RAM-i on olemas, saab andmebaasi tööd märkimisväärselt kiirendada, viies selle mälu (32 GB on meie ülesande jaoks piisav). Linuxis on vaikimisi ühendatud osa, /dev/shm, mis kasutab kuni poole mälu mahust. Saame määrata ka rohkem, muutes /etc/fstab, et saaksime kirjutise kujul tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Peame kindlasti taaskäivitama ja kontrollima tulemust, käivitades käsu df -h.

Testandmete iteratsioon näeb palju lihtsam välja, kuna testandmed mahutatakse täielikult mällu:

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 valik

Esimene kasutatud arhitektuur oli mobilenet v1, mille omadused on lahti seletatud selles postituses. See on standardsetes tarnetes olemas keras ja seega saadaval samanimelises paketis R jaoks. Kuid ühekordsete piltidega kasutamisel selgus imelik asi: sisenditensor peab alati olema mõõtmetega (batch, kõrgus, laius, 3), st, see number of channels cannot be changed. In Python, there is no such restriction, so we rushed and wrote our implementation of this architecture, following the original article (without dropout, which is 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 ilmne. Soovitakse testida palju mudeleid, kuid iga arhitektuuri käsitsi ümber kirjutamine ei ole soovitav. Samuti ei saanud me kasutada mudeleid, millele on eelnevalt õpetatud Imagenet-is. Nagu alati, aitas dokumentatsiooni uurimine. Funktsioon get_config() võimaldab saada mudeli kirjelduse redigeerimiseks sobivas vormingus (base_model_conf$layers — tavaline R-i loend), samas funktsioon from_config() teeb vastupidise teisenduse mudeli objekti:

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 mudel, mis on varustatud Imagenet-is õpetatud kaaludega või ilma nendeta: keras Funktsioon valmis arhitektuuride laadimiseks

Valmis arhitektuuride laadimise funktsioon

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) {
  # Kontrollige argumendid
  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 ei ole värviline, muudame sisendi mõõtmeid
  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 rakendata eeltreenitud kaalusid. Seda oleks saanud parandada funktsiooni abil get_weights() saamiseks, et saada mudeli kaalusid R-sarnaste massiivide loendina, muutes selle loendi esimese elemendi mõõtmeid (valides ühe värvikanali või keskmistades kõik kolm), seejärel laadides kaalud mudelisse tagasi funktsiooniga set_weights(). Me ei lisanud seda funktsionaalsust, kuna sellel etapil oli juba selge, et efektiivsem on töötada värviliste piltidega.

Peamised eksperimendid viisime läbi mobilenet versioonidega 1 ja 2 ning samuti resnet34-ga. Sellel võistlusel näitasid paremaid tulemusi kaasaegsemad arhitektuurid, nagu SE-ResNeXt. Kahjuks ei olnud meil valmis rakendusi, kuid oma omad me ei kirjutanud (aga kindlasti kirjutame).

5. Skriptide parametriseerimine

Meeldivuse huvides oli kogu treeningu käivitamiseks mõeldud kood vormindatud üheks skriptiks, mida parametriseeriti docopt järgmiselt:

doc <- '
Kasutus:
  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 saadaval olevad mudelid.
  -m --model=          Sünaptilise võrgu mudeli nimi [vaikimisi: mobilenet_v2].
  -b --batch-size=      Partii suurus [vaikimisi: 32].
  -s --scale-factor=   Skaala tegur [vaikimisi: 0.5].
  -c --color                  Kasutada värvilisi jooni [vaikimisi: FALSE].
  -d --db-dir=          Teed andmebaasi kataloogini [vaikimisi: Sys.getenv("db_dir")].
  -r --validate-ratio= Kontrolli proovi suhet [vaikimisi: 0.995].
  -n --n-gpu=         GPU-de arv [vaikimisi: 1].
'
args <- docopt::docopt(doc)

Pakett docopt esindab rakendust http://docopt.org/ R-ile. Selle abil käivitatakse skripte lihtsate käskudega, 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 teostatav (see käsk alustab resnet50 mudeli koolitamist värvilistes piltides, mille suurus on 128x128 pikslit; andmebaas peab olema kaustas /home/andrey/doodle_db). Loendisse saab lisada õppimise kiirus, optimeerija tüüp ja kõik muud kohandatavad parameetrid. Valmistamise protsessis selgus, et arhitektuuri mobilenet_v2 aktuaalses versioonis keras R-is kasutada ei tohi kuna R-paketi muudatusi ei ole arvestatud — ootame, kuni nad parandavad.

See lähenemine võimaldas oluliselt kiirendada katsetusi erinevate mudelitega võrreldes traditsioonilisemate skriptide käivitamisega RStudios (võimaliku alternatiivina märkime paketi tfruns). Kuid peamine eelis seisneb lihtsas skriptide käivitamise haldamises Dockeris või lihtsalt serveris, ilma et oleks vaja RStudio'd installida.

6. Skriptide dockeriseerimine

Kasutasime Dockerit, et tagada mudelite õppe keskkonna ühilduvus meeskonna liikmete vahel ning kiirete paiknemiste tegemise jaoks pilves. Selle R-programmeerija jaoks suhteliselt ebatavalise tööriista tundmaõppimist saab alustada sellest artiklite sarjast või videokursusest..

Docker võimaldab luua nii täiesti uusi pilte kui ka kasutada teisi pilte aluseks oma piltide loomiseks. Olemasolevate variantide analüüsimisel jõudsime järeldusele, et NVIDIA juhtmete, CUDA+cuDNN ja Python'i teekide installimine on üsna mahukas osa pildist, ning otsustasime võtta aluseks ametliku pildi tensorflow/tensorflow:1.12.0-gpu, lisades sinna vajalikud R-paketid.

Lõplik Dockerfile näeb välja selline:

Dockerfile

FROM tensorflow/tensorflow:1.12.0-gpu

MAINTAINER Artem Klevtsov 

SHELL ["/bin/bash", "-c"]

ARG LOCALE="et_EE.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 kasutatavad paketid viidud muutujatesse; peamine osa kirjutatud skripte kopeeritakse konteinerite sisse kogumise ajal. Samuti oleme muutnud käskluste kestaks /bin/bash sisu mugavamaks kasutamiseks /etc/os-release. See võimaldas vältida vajadust näidata OS-i versiooni koodis.

Lisaks on kirjutatud väike bash-skript, mis võimaldab konteinerit käivitada erinevate käsklustega. Näiteks võivad need olla skriptid, mis on seotud närvivõrkude õppimisega, mis on varem konteineri sisse pandud, või käskluskest debugimiseks ja konteineri töö 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 seda bash-skripti käivitada ilma parameetrita, kutsutakse konteineris välja skript train_nn.R vaikeväärtustega; kui esimene positsiooniline argument on «bash», käivitub konteiner interaktiivses režiimis käskluskesega. Kõigil muudel juhtudel toimub positsiooniliste argumentide väärtuste asendamine: CMD="Rscript /app/train_nn.R $@".

Oluline on märkida, et allikafaile ja andmebaasi sisaldavad kataloogid, samuti treenitud mudelite salvestamiseks mõeldud kataloog mountitakse konteinerisse host-süsteemist, võimaldades pääseda skriptide tulemuste juurde ilma täiendavate toiminguteta.

7. Mitme GPU kasutamine Google Cloudis

Üks võistluse eripära oli väga mürarikkad andmed (vt pealkujutist, mis on saadud @Leigh.plt ODS Slackist). Suurte partiidena töötamine aitab sellega toime tulla ja pärast katsetusi 1 GPU-ga arvutis otsustasime õppida mudelite treenimist mitme GPU-ga pilves. Kasutasime GoogleCloudi (hea juhend töö aluspõhimõtete kohta) suure konfiguratsioonide valiku, mõistlike hindade ja $300 boonuse tõttu. Ahne olek käskis tellida 4xV100 SSD-ga instantsi koos suure hulga RAM-iga, ja see oli suur viga. Selline masin kulutab kiiresti raha, eksperimentide käigus ilma välja töötatud torustikuta võib kiiresti bankrotti minna. Õppe eesmärkidega tasub pigem valida K80. Suur RAM-i maht osutus siiski kasulikuks — pilve SSD ei olnud oma kiiruselt eriline, seetõttu kandsime andmebaasi iga instantsi käivitamise korral üle dev/shm.

Kõige huvitavam on koodilõik, mis vastutab mitu GPU kasutamise eest. Esiteks luuakse mudel CPU-l, kasutades konteksti haldurit, just nagu Pythoni puhul:

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 olevaid GPU-sid, 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, kus külmutatakse kõik kihid peale viimase, õpetatakse viimane kiht, seejärel vabastatakse ja täiustatakse mudelit täielikult mitme GPU jaoks, ei õnnestunud ellu viia.

Koolitust jälgiti ilma kasutamata tensorboard, piirdudes logide salvestamise ja mudelite salvestamisega informatiivsete nimedega pärast iga epohhi:

Callback'id

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

Oleme kokku puutunud mitmete probleemidega, millega praegu toime ei tulnud:

  • ühes keras puudub valmis funktsioon optimalse õppe kiirus (kogus, lr_finder fast.ai raamatukogus )); tehes olles on võimalik R-le portida kolmandate osapoolte teostusi, näiteks, seda;
  • nagu eelneva punkti tõttu ei õnnestunud leida õiget õpikoguse kiirus mitme GPU kasutamisel;
  • puuduvad kaasaegsed närvivõrgustruktuurid, eriti need, mis on ette koolitatud imagenet-l;
  • puudub one cycle policy ja diskrimineerivad õppimispeed (cosine annealing meie palve järgi oli rakendatud, aitäh skeydan).

Mis kasulikku saadi sellest võistlusest:

  • Suhteliselt väikese jõudlusega riistvaraga on võimalik valutult töötada mõistlikult (korduvalt suuremate) andmehulkadega. Pakett data.table säästab mälu in-place tabelite muutmise kaudu, vältides nende kopeerimist, ja õigesti kasutades demonstreerib see enamasti kõige kiiremat kiirust kõigi tuntud tööriistade seas skriptikeelte jaoks. Andmete salvestamine andmebaasi võimaldab paljusid kordi üldse mitte mõelda vajadusele suruda kogu andmestik mälu.
  • Aeglased funktsioonid R-is saab asendada kiirete C++ funktsioonidega paketi Rcpp. Kui lisaks kasutada RcppThread või RcppParallel, saame platvormideüleseid mitme eri rakenduse versioone, seega R-tasandi koodi paralleelne töötlemine ei ole vajalik.
  • Paketti Rcpp võib kasutada ilma tõsiste teadmistega C++-st, vajalik minimaalne teave on esitatud siit. Pealkirjafailid mitmete tipptasemel C keele teekide jaoks, näiteks xtensor on saadaval CRAN-is, seega luuakse infrastruktuur projektide teostamiseks, mis integreerivad R-i valmis kõrge jõudlusega C++ koodi. Lisamugavus — C++ süntaksivalgustus ja staatiline koodianalüsaator RStudios.
  • docopt võimaldab käivitada iseseisvaid skripte parameetritega. See on mugav kasutamiseks eemalserveris, sealhulgas Dockeris. RStudios pole pikka aega kestvate närvivõrgu koolituskatsed mugavad, samuti ei ole IDE installimine serveris alati põhjendatud.
  • Docker tagab koodi kaasaskantavuse ja tulemuste reproduktsiooni arendajate vahel, kellel on erinevad operatsioonisüsteemide ja teekide versioonid, samuti muudab serverites käivitamise lihtsaks. Kogu koolituse torujuhtme saab käivitada ainult ühe käsu abil.
  • Google Cloud on eelarvesõbralik viis katsetamiseks kallil riistvaral, kuid konfiguratsioonide valimisel tuleb hoolikalt kaaluda.
  • Koodi fragmentide töötamise kiirusest mõõdmine on väga kasulik, eriti R-i ja C++ kombinatsioonis, ja koos paketiga bench — on samuti väga lihtne.

Kokkuvõttes oli see kogemus väga väärtuslik ning me jätkame mõningate mainitud probleemide lahendamisega.

Allikas: habr.com

Osta usaldusväärne veebihosting DDoS kaitsega, VPS VDS serverid 🔥 Osta usaldusväärne veebihosting DDoS kaitsega, VPS VDS serverid | ProHoster