Quick Draw Doodle Recognition: kuidas sõbruneda R, C++ ja närvivõrkudega

Quick Draw Doodle Recognition: kuidas sõbruneda R, C++ ja närvivõrkudega

Tere Habr!

Eelmisel sügisel korraldas Kaggle käsitsi joonistatud piltide klassifitseerimise võistluse Quick Draw Doodle Recognition, millest võttis teiste seas osa ka R-teadlaste meeskond: Artem Klevtsova, Philippa juhataja и Andrei Ogurtsov. Võistlust me täpsemalt ei kirjelda, see on juba tehtud hiljutine väljaanne.

Seekord medalipõllundusega ei õnnestunud, küll aga saadi palju väärtuslikku kogemust, nii et räägin kogukonnale mitmest huvitavamast ja kasulikumast Kagles ja igapäevatöös. Arutlusel olnud teemade hulgas: raske elu ilma OpenCV, JSON-i sõelumine (need näited uurivad C++ koodi integreerimist skriptidesse või pakettidesse R-is kasutades Rcpp), skriptide parameetrite muutmine ja lõpplahenduse dokkimine. Kogu sõnumi kood täitmiseks sobival kujul on saadaval hoidlad.

Sisukord:

  1. Laadige andmeid tõhusalt CSV-st MonetDB-sse
  2. Partiide ettevalmistamine
  3. Iteraatorid partiide andmebaasist mahalaadimiseks
  4. Mudeli arhitektuuri valimine
  5. Skripti parameetrid
  6. Skriptide dokkimine
  7. Mitme GPU kasutamine Google Cloudis
  8. Selle asemel, et järeldus

1. Laadige andmed tõhusalt CSV-st MonetDB andmebaasi

Selle võistluse andmed ei esitata mitte valmiskujutiste kujul, vaid 340 CSV-failina (üks fail iga klassi kohta), mis sisaldavad punktikoordinaatidega JSON-e. Ühendades need punktid joontega, saame lõpliku pildi mõõtmetega 256x256 pikslit. Samuti on iga kirje juures silt, mis näitab, kas andmestiku kogumise ajal kasutatud klassifikaator tundis pildi õigesti ära, pildi autori elukohariigi kahetäheline kood, kordumatu identifikaator, ajatempel ja klassi nimi, mis ühtib failinimega. Algandmete lihtsustatud versioon kaalub arhiivis 7.4 GB ja pärast lahtipakkimist umbes 20 GB, täielikud andmed pärast lahtipakkimist võtavad 240 GB. Korraldajad tagasid, et mõlemad versioonid reprodutseerisid samu jooniseid, mis tähendab, et täisversioon oli üleliigne. Igal juhul peeti 50 miljoni pildi salvestamist graafilistesse failidesse või massiividesse kohe kahjumlikuks ja otsustasime arhiivist kõik CSV-failid liita. train_simplified.zip andmebaasi koos järgneva vajaliku suurusega piltide genereerimisega iga partii kohta "lennult".

DBMS-iks valiti end hästi tõestanud süsteem MonetDB, nimelt R-i teostus paketina MonetDBLite. Pakett sisaldab andmebaasiserveri manustatud versiooni ja võimaldab teil serveri otse R-seansist üles võtta ja seal sellega töötada. Andmebaasi loomine ja sellega ühenduse loomine toimub ühe käsuga:

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

Peame looma kaks tabelit: üks kõigi andmete jaoks, teine ​​allalaaditud failide teenuseteabe jaoks (kasulik, kui midagi läheb valesti ja protsessi tuleb pärast mitme faili allalaadimist jätkata):

Tabelite koostamine

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

Kiireim viis andmete laadimiseks andmebaasi oli CSV-failide otse kopeerimine, kasutades käsku SQL - COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTKus tablename - tabeli nimi ja path - faili tee. Arhiiviga töötades avastati, et sisseehitatud teostus unzip in R ei tööta paljude arhiivi failidega õigesti, seetõttu kasutasime süsteemi unzip (kasutades parameetrit getOption("unzip")).

Funktsioon andmebaasi kirjutamiseks

#' @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 peate tabelit enne andmebaasi kirjutamist teisendama, piisab argumendi sisestamisest preprocess funktsioon, mis muudab andmed.

Kood andmete järjestikuseks andmebaasi laadimiseks:

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 olenevalt kasutatava draivi kiirusomadustest erineda. Meie puhul võtab lugemine ja kirjutamine ühes SSD-s või mälupulgalt (lähtefailist) SSD-le (DB) vähem kui 10 minutit.

Täisarvuklassi sildi ja indeksi veeruga veeru loomiseks kulub veel mõni sekund (ORDERED INDEX) koos ridade numbritega, mille järgi kogumite loomisel vaatlustest valimi võetakse:

Täiendavate 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)"))

Partii loomise probleemi lahendamiseks pidime saavutama tabelist juhuslike ridade ekstraheerimise maksimaalse kiiruse doodles. Selleks kasutasime 3 trikki. Esimene oli vaatlus ID-d salvestava tüübi mõõtmete vähendamine. Algses andmekogumis on ID salvestamiseks vajalik tüüp bigint, kuid vaatluste arv võimaldab sobitada nende järjenumbriga võrdsed identifikaatorid tüüpi int. Otsing on sel juhul palju kiirem. Teine nipp oli kasutada ORDERED INDEX — jõudsime selle otsuseni empiiriliselt, olles läbinud kõik kättesaadavad valikud. Kolmas oli parameetritega päringute kasutamine. Meetodi põhiolemus on käsu üks kord täitmine PREPARE koos ettevalmistatud avaldise hilisema kasutamisega sama tüüpi päringute hunniku loomisel, kuid tegelikult on sellel eelis võrreldes lihtsa päringutega SELECT osutus statistilise vea piiresse.

Andmete üleslaadimise protsess ei tarbi rohkem kui 450 MB RAM-i. See tähendab, et kirjeldatud lähenemine võimaldab teil kümneid gigabaite kaaluvaid andmekogumeid teisaldada peaaegu igal eelarveriistvaral, sealhulgas mõnel ühe plaadiga seadmel, mis on päris lahe.

Jääb üle vaid mõõta (juhuslike) andmete otsimise kiirust ja hinnata erineva suurusega partiide proovide võtmisel skaleerimist:

Andmebaasi etalon

library(ggplot2)

set.seed(0)
# Подключение к базе данных
con <- DBI::dbConnect(MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))

# Функция для подготовки запроса на стороне сервера
prep_sql <- function(batch_size) {
  sql <- sprintf("PREPARE SELECT id FROM doodles WHERE id IN (%s)",
                 paste(rep("?", batch_size), collapse = ","))
  res <- DBI::dbSendQuery(con, sql)
  return(res)
}

# Функция для извлечения данных
fetch_data <- function(rs, batch_size) {
  ids <- sample(seq_len(n), batch_size)
  res <- DBI::dbFetch(DBI::dbBind(rs, as.list(ids)))
  return(res)
}

# Проведение замера
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    rs <- prep_sql(batch_size)
    bench::mark(
      fetch_data(rs, batch_size),
      min_iterations = 50L
    )
  }
)
# Параметры бенчмарка
cols <- c("batch_size", "min", "median", "max", "itr/sec", "total_time", "n_itr")
res_bench[, cols]

#   batch_size      min   median      max `itr/sec` total_time n_itr
#        <dbl> <bch:tm> <bch:tm> <bch:tm>     <dbl>   <bch:tm> <int>
# 1         16   23.6ms  54.02ms  93.43ms     18.8        2.6s    49
# 2         32     38ms  84.83ms 151.55ms     11.4       4.29s    49
# 3         64   63.3ms 175.54ms 248.94ms     5.85       8.54s    50
# 4        128   83.2ms 341.52ms 496.24ms     3.00      16.69s    50
# 5        256  232.8ms 653.21ms 847.44ms     1.58      31.66s    50
# 6        512  784.6ms    1.41s    1.98s     0.740       1.1m    49
# 7       1024  681.7ms    2.72s    4.06s     0.377      2.16m    49

ggplot(res_bench, aes(x = factor(batch_size), y = median, group = 1)) +
  geom_point() +
  geom_line() +
  ylab("median time, s") +
  theme_minimal()

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle Recognition: kuidas sõbruneda R, C++ ja närvivõrkudega

2. Partiide ettevalmistamine

Kogu partii ettevalmistamise protsess koosneb järgmistest etappidest:

  1. Mitme JSON-i sõelumine, mis sisaldavad punktide koordinaatidega stringide vektoreid.
  2. Värviliste joonte joonistamine punktide koordinaatide alusel vajaliku suurusega pildile (näiteks 256×256 või 128×128).
  3. Saadud piltide teisendamine tensoriks.

Pythoni tuumade vahelise konkurentsi raames lahendati probleem peamiselt kasutades OpenCV. Üks lihtsamaid ja ilmsemaid R-i analooge näeks välja selline:

JSON-i tensoriks teisendamise rakendamine R-is

r_process_json_str <- function(json, line.width = 3, 
                               color = TRUE, scale = 1) {
  # Парсинг JSON
  coords <- jsonlite::fromJSON(json, simplifyMatrix = FALSE)
  tmp <- tempfile()
  # Удаляем временный файл по завершению функции
  on.exit(unlink(tmp))
  png(filename = tmp, width = 256 * scale, height = 256 * scale, pointsize = 1)
  # Пустой график
  plot.new()
  # Размер окна графика
  plot.window(xlim = c(256 * scale, 0), ylim = c(256 * scale, 0))
  # Цвета линий
  cols <- if (color) rainbow(length(coords)) else "#000000"
  for (i in seq_along(coords)) {
    lines(x = coords[[i]][[1]] * scale, y = coords[[i]][[2]] * scale, 
          col = cols[i], lwd = line.width)
  }
  dev.off()
  # Преобразование изображения в 3-х мерный массив
  res <- png::readPNG(tmp)
  return(res)
}

r_process_json_vector <- function(x, ...) {
  res <- lapply(x, r_process_json_str, ...)
  # Объединение 3-х мерных массивов картинок в 4-х мерный в тензор
  res <- do.call(abind::abind, c(res, along = 0))
  return(res)
}

Joonistamine toimub standardsete R-tööriistade abil ja salvestatakse RAM-i salvestatud ajutisse PNG-vormingusse (Linuxis asuvad ajutised R-kataloogid kataloogis /tmp, paigaldatud RAM-i). Seejärel loetakse seda faili kolmemõõtmelise massiivina, mille numbrid jäävad vahemikku 0 kuni 1. See on oluline, sest tavapärasem BMP loetakse kuueteistkümnendvärvikoodidega töötlemata massiiviks.

Testime 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 sõbruneda R, C++ ja närvivõrkudega

Partii ise moodustatakse 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 teostus tundus meile ebaoptimaalne, kuna suurte partiide moodustamine võtab sündsusetult kaua aega ja otsustasime oma kolleegide kogemusi ära kasutada võimsa raamatukogu abil. OpenCV. Sel ajal polnud R-i jaoks valmispaketti (praegu pole), nii et minimaalne vajalike funktsionaalsuste realiseerimine kirjutati C++ keeles koos R-koodi integreerimisega, kasutades Rcpp.

Probleemi lahendamiseks kasutati järgmisi pakette ja teeke:

  1. OpenCV piltidega töötamiseks ja joonte joonistamiseks. Kasutatud eelinstallitud süsteemiteeke ja päisefaile, samuti dünaamilist linkimist.

  2. xtensor töötamiseks mitmemõõtmeliste massiivide ja tensoritega. Kasutasime samanimelises R-paketis sisalduvaid päisefaile. Teek võimaldab töötada mitmemõõtmeliste massiividega nii rea põhi- kui ka veeru põhijärjekorras.

  3. ndjson JSON-i sõelumiseks. Seda raamatukogu kasutatakse xtensor automaatselt, kui see on projektis olemas.

  4. RcppThread JSON-i vektori mitme lõimega töötlemise korraldamiseks. Kasutati selle paketi päisefaile. Populaarsematest RcppParallel Paketis on muuhulgas sisseehitatud loop-katkestusmehhanism.

Tuleb märkida, et xtensor osutus õnneks: lisaks sellele, et sellel on laialdane funktsionaalsus ja suur jõudlus, osutusid selle arendajad üsna vastutulelikeks ning vastasid küsimustele kiiresti ja üksikasjalikult. Nende abiga oli võimalik teostada OpenCV maatriksite teisendusi xtensortensorites, aga ka viis ühendada 3-mõõtmelised pilditensorid õige mõõtmega 4-mõõtmeliseks tensoriks (partii ise).

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üsteemifaile ja dünaamilist linkimist süsteemi installitud teekidega kasutavate failide koostamiseks kasutasime paketis juurutatud pistikprogrammi mehhanismi Rcpp. Teede ja lippude automaatseks leidmiseks kasutasime populaarset Linuxi utiliiti pkg-config.

Rcpp plugina juurutamine OpenCV teegi kasutamiseks

Rcpp::registerPlugin("opencv", function() {
  # Возможные названия пакета
  pkg_config_name <- c("opencv", "opencv4")
  # Бинарный файл утилиты pkg-config
  pkg_config_bin <- Sys.which("pkg-config")
  # Проврека наличия утилиты в системе
  checkmate::assert_file_exists(pkg_config_bin, access = "x")
  # Проверка наличия файла настроек OpenCV для pkg-config
  check <- sapply(pkg_config_name, 
                  function(pkg) system(paste(pkg_config_bin, pkg)))
  if (all(check != 0)) {
    stop("OpenCV config for the pkg-config not found", call. = FALSE)
  }

  pkg_config_name <- pkg_config_name[check == 0]
  list(env = list(
    PKG_CXXFLAGS = system(paste(pkg_config_bin, "--cflags", pkg_config_name), 
                          intern = TRUE),
    PKG_LIBS = system(paste(pkg_config_bin, "--libs", pkg_config_name), 
                      intern = TRUE)
  ))
})

Pistikprogrammi töö tulemusena asendatakse kompileerimise käigus 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 partii genereerimise rakenduskood on toodud spoileri all. Esmalt lisage päisefailide otsimiseks kohalik projektikataloog (vajalik ndjsoni jaoks):

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

JSON-i tensoriks teisendamine 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 tuleks faili panna src/cv_xt.cpp ja kompileerige käsuga Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); vajalik ka tööks nlohmann/json.hpp kohta hoidla. Kood on jagatud mitmeks funktsiooniks:

  • to_xt — mallifunktsioon kujutise maatriksi teisendamiseks (cv::Mat) tensoriks xt::xtensor;

  • parse_json — funktsioon parsib JSON-stringi, eraldab punktide koordinaadid, pakkides need vektorisse;

  • ocv_draw_lines — saadud punktivektorist joonistab mitmevärvilisi jooni;

  • process — kombineerib ülaltoodud funktsioone ja lisab ka võimaluse saadavat pilti skaleerida;

  • cpp_process_json_str - funktsiooni ümbris process, mis ekspordib tulemuse R-objekti (mitmemõõtmeline massiiv);

  • cpp_process_json_vector - funktsiooni ümbris cpp_process_json_str, mis võimaldab töödelda stringi vektorit mitme lõimega režiimis.

Mitmevärviliste joonte joonistamiseks kasutati HSV värvimudelit, millele järgnes teisendamine RGB-le. 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 sõbruneda R, C++ ja närvivõrkudega
Rakenduste kiiruse võrdlus R ja C++ keeles

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

#   expression                min     median       max `itr/sec` total_time  n_itr
#   <chr>                <bch:tm>   <bch:tm>  <bch:tm>     <dbl>   <bch:tm>  <int>
# 1 r_process_json_str     3.49ms     3.55ms    4.47ms      273.      490ms    134
# 2 cpp_process_json_str   1.94ms     2.02ms    5.32ms      489.      497ms    243

library(ggplot2)
# Проведение замера
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    .data <- tmp_data[sample(seq_len(.N), batch_size), drawing]
    bench::mark(
      r_process_json_vector(.data, scale = 0.5),
      cpp_process_json_vector(.data,  scale = 0.5),
      min_iterations = 50,
      check = FALSE
    )
  }
)

res_bench[, cols]

#    expression   batch_size      min   median      max `itr/sec` total_time n_itr
#    <chr>             <dbl> <bch:tm> <bch:tm> <bch:tm>     <dbl>   <bch:tm> <int>
#  1 r                   16   50.61ms  53.34ms  54.82ms    19.1     471.13ms     9
#  2 cpp                 16    4.46ms   5.39ms   7.78ms   192.      474.09ms    91
#  3 r                   32   105.7ms 109.74ms 212.26ms     7.69        6.5s    50
#  4 cpp                 32    7.76ms  10.97ms  15.23ms    95.6     522.78ms    50
#  5 r                   64  211.41ms 226.18ms 332.65ms     3.85      12.99s    50
#  6 cpp                 64   25.09ms  27.34ms  32.04ms    36.0        1.39s    50
#  7 r                  128   534.5ms 627.92ms 659.08ms     1.61      31.03s    50
#  8 cpp                128   56.37ms  58.46ms  66.03ms    16.9        2.95s    50
#  9 r                  256     1.15s    1.18s    1.29s     0.851     58.78s    50
# 10 cpp                256  114.97ms 117.39ms 130.09ms     8.45       5.92s    50
# 11 r                  512     2.09s    2.15s    2.32s     0.463       1.8m    50
# 12 cpp                512  230.81ms  235.6ms 261.99ms     4.18      11.97s    50
# 13 r                 1024        4s    4.22s     4.4s     0.238       3.5m    50
# 14 cpp               1024  410.48ms 431.43ms 462.44ms     2.33      21.45s    50

ggplot(res_bench, aes(x = factor(batch_size), y = median, 
                      group =  expression, color = expression)) +
  geom_point() +
  geom_line() +
  ylab("median time, s") +
  theme_minimal() +
  scale_color_discrete(name = "", labels = c("cpp", "r")) +
  theme(legend.position = "bottom") 

Quick Draw Doodle Recognition: kuidas sõbruneda R, C++ ja närvivõrkudega

Nagu näha, osutus kiiruse kasv väga oluliseks ning R-koodi paralleeliseerimisega pole võimalik C++ koodile järele jõuda.

3. Iteraatorid partiide andmebaasist mahalaadimiseks

R-l on RAM-i mahtuvate andmete töötlemisel väljateenitud maine, samas kui Pythonile on iseloomulik pigem iteratiivne andmetöötlus, mis võimaldab lihtsalt ja loomulikult rakendada tuumaväliseid arvutusi (arvutused välismälu abil). Klassikaline ja meie jaoks asjakohane näide kirjeldatud probleemi kontekstis on sügavad närvivõrgud, mida treenitakse gradiendi laskumise meetodil ja gradiendi lähendamine igal etapil, kasutades väikest osa vaatlustest või minipartii.

Pythonis kirjutatud süvaõpperaamistikel on spetsiaalsed klassid, mis rakendavad andmete põhjal iteraatoreid: tabelid, pildid kaustades, binaarvormingud jne. Konkreetsete ülesannete jaoks saab kasutada valmisvalikuid või kirjutada ise. R-is saame kasutada kõiki Pythoni teegi funktsioone keras oma erinevate taustaprogrammidega, kasutades samanimelist paketti, mis omakorda töötab paketi peal võrgutama. Viimane väärib eraldi pikka artiklit; see mitte ainult ei võimalda teil käivitada Pythoni koodi R-st, vaid võimaldab teil ka objekte R ja Pythoni seansside vahel üle kanda, teostades automaatselt kõik vajalikud tüübikonversioonid.

Vabanesime vajadusest salvestada kõik andmed RAM-i, kasutades MonetDBLite'i, kogu "närvivõrgu" töö teeb Pythonis algne kood, peame lihtsalt andmete kohale kirjutama iteraatori, kuna midagi pole valmis sellise olukorra jaoks kas R-is või Pythonis. Sellele on sisuliselt ainult kaks nõuet: see peab tagastama partiid lõputu tsüklina ja salvestama oma oleku iteratsioonide vahel (viimane R-s on kõige lihtsamal viisil rakendatud sulgemiste abil). Varem tuli iteraatori sees R-massiivid selgesõnaliselt teisendada numbilisteks massiivideks, kuid paketi praegune versioon keras teeb seda ise.

Koolitus- ja valideerimisandmete iteraator osutus järgmiseks:

Iteraator koolituse ja andmete valideerimiseks

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, millel on ühendus andmebaasiga, kasutatud ridade arv, klasside arv, partii suurus, skaala (scale = 1 vastab 256x256 piksliga piltide renderdamisele, scale = 0.5 — 128x128 pikslit, värviindikaator (color = FALSE määrab kasutamisel halltoonis renderduse color = TRUE iga tõmme joonistatakse uue värviga) ja eeltöötluse indikaator võrkude jaoks, mis on eelnevalt koolitatud imagenetis. Viimast on vaja piksliväärtuste skaleerimiseks intervallist [0, 1] intervallile [-1, 1], mida kasutati kaasasolevate seadmete treenimisel. keras mudelid.

Väline funktsioon sisaldab argumendi tüübi kontrollimist, tabelit data.table juhuslikult segatud reanumbritega alates samples_index ja partiide numbrid, loendur ja maksimaalne partiide arv, samuti SQL-avaldis andmete andmebaasist mahalaadimiseks. Lisaks määratlesime sees oleva funktsiooni kiire analoogi keras::to_categorical(). Kasutasime treenimiseks peaaegu kõiki andmeid, jättes valideerimiseks pool protsenti, nii et ajastu suurust piiras parameeter steps_per_epoch kui kutsutakse keras::fit_generator()ja tingimus if (i > max_i) töötas ainult valideerimisiteraatori jaoks.

Sisefunktsioonis hangitakse reaindeksid järgmise partii jaoks, kirjed laaditakse andmebaasist maha partiiloenduri suurenemisega, JSON-i sõelumine (funktsioon cpp_process_json_vector(), kirjutatud C++) ja luua piltidele vastavad massiivid. Seejärel luuakse klassi siltidega ühekuumad vektorid, piksliväärtuste ja siltidega massiivid ühendatakse loendiks, mis on tagastatav väärtus. Töö kiirendamiseks kasutasime indeksite loomist tabelites data.table ja muutmine lingi kaudu - ilma nende paketi "kiipideta" andmed.tabel On üsna raske ette kujutada tõhusat töötamist mis tahes olulise andmehulgaga R-s.

Core i5 sülearvuti kiiruse mõõtmise tulemused on järgmised:

Iteraatori etalon

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

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

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

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

# Индексы для обучающей выборки
train_ind <- sample(ind, floor(length(ind) * 0.995))
# Индексы для проверочной выборки
val_ind <- ind[-train_ind]
rm(ind)
# Коэффициент масштаба
scale <- 0.5

# Проведение замера
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    it1 <- train_generator(
      db_connection = con,
      samples_index = train_ind,
      num_classes = num_classes,
      batch_size = batch_size,
      scale = scale
    )
    bench::mark(
      it1(),
      min_iterations = 50L
    )
  }
)
# Параметры бенчмарка
cols <- c("batch_size", "min", "median", "max", "itr/sec", "total_time", "n_itr")
res_bench[, cols]

#   batch_size      min   median      max `itr/sec` total_time n_itr
#        <dbl> <bch:tm> <bch:tm> <bch:tm>     <dbl>   <bch:tm> <int>
# 1         16     25ms  64.36ms   92.2ms     15.9       3.09s    49
# 2         32   48.4ms 118.13ms 197.24ms     8.17       5.88s    48
# 3         64   69.3ms 117.93ms 181.14ms     8.57       5.83s    50
# 4        128  157.2ms 240.74ms 503.87ms     3.85      12.71s    49
# 5        256  359.3ms 613.52ms 988.73ms     1.54       30.5s    47
# 6        512  884.7ms    1.53s    2.07s     0.674      1.11m    45
# 7       1024     2.7s    3.83s    5.47s     0.261      2.81m    44

ggplot(res_bench, aes(x = factor(batch_size), y = median, group = 1)) +
    geom_point() +
    geom_line() +
    ylab("median time, s") +
    theme_minimal()

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle Recognition: kuidas sõbruneda R, C++ ja närvivõrkudega

Kui teil on piisavalt RAM-i, saate andmebaasi tööd tõsiselt kiirendada, teisaldades selle samale RAM-ile (meie ülesande jaoks piisab 32 GB-st). Linuxis on partitsioon vaikimisi ühendatud /dev/shm, hõivates kuni poole RAM-i mahust. Redigeerides saate rohkem esile tõsta /etc/fstabet saada selline rekord nagu tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Kindlasti taaskäivitage ja kontrollige tulemust, käivitades käsu df -h.

Testiandmete iteraator näeb välja palju lihtsam, kuna testandmestik mahub täielikult RAM-i:

Iteraator katseandmete jaoks

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 mobiilivõrk v1, mille omadusi käsitletakse artiklis see sõnum. See on standardvarustuses keras ja vastavalt sellele on saadaval R-i samanimelises pakendis. Kuid proovides seda ühe kanaliga piltidega kasutada, selgus kummaline asi: sisendtensoril peab alati olema mõõde (batch, height, width, 3)st kanalite arvu ei saa muuta. Pythonis sellist piirangut pole, nii et kiirustasime ja kirjutasime selle arhitektuuri oma teostuse, järgides algset artiklit (ilma kerase versioonis sisalduva väljalangemiseta):

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 <- function(object, 
                              filters,
                              alpha = 1,
                              depth_multiplier = 1,
                              strides = c(2, 2)) {

  # NB! depth_multiplier !=  resolution multiplier
  # https://github.com/keras-team/keras/issues/10349

  layer_depthwise_conv_2d(
    object = object,
    kernel_size = c(3, 3), 
    strides = strides,
    padding = "same",
    depth_multiplier = depth_multiplier
  ) %>%
  layer_batch_normalization() %>% 
  layer_activation_relu() %>%
  layer_conv_2d(
    filters = filters * alpha,
    kernel_size = c(1, 1), 
    strides = c(1, 1)
  ) %>%
  layer_batch_normalization() %>% 
  layer_activation_relu() 
}

get_mobilenet_v1 <- function(input_shape = c(224, 224, 1),
                             num_classes = 340,
                             alpha = 1,
                             depth_multiplier = 1,
                             optimizer = optimizer_adam(lr = 0.002),
                             loss = "categorical_crossentropy",
                             metrics = c("categorical_crossentropy",
                                         top_3_categorical_accuracy)) {

  inputs <- layer_input(shape = input_shape)

  outputs <- inputs %>%
    layer_conv_2d(filters = 32, kernel_size = c(3, 3), strides = c(2, 2), padding = "same") %>%
    layer_batch_normalization() %>% 
    layer_activation_relu() %>%
    layer_sep_conv_bn(filters = 64, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 128, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 128, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 256, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 256, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 1024, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 1024, strides = c(1, 1)) %>%
    layer_global_average_pooling_2d() %>%
    layer_dense(units = num_classes) %>%
    layer_activation_softmax()

    model <- keras_model(
      inputs = inputs,
      outputs = outputs
    )

    model %>% compile(
      optimizer = optimizer,
      loss = loss,
      metrics = metrics
    )

    return(model)
}

Selle lähenemisviisi puudused on ilmsed. Ma tahan testida paljusid mudeleid, kuid vastupidi, ma ei taha iga arhitektuuri käsitsi ümber kirjutada. Samuti võeti meilt ära võimalus kasutada imagenetis eelkoolitatud modellide raskusi. Nagu ikka, aitas dokumentatsiooni uurimine. Funktsioon get_config() võimaldab saada mudeli kirjeldust redigeerimiseks sobival kujul (base_model_conf$layers - tavaline R-loend) ja funktsioon from_config() teostab pöördkonversiooni mudeliobjektiks:

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 pole keeruline kirjutada universaalset funktsiooni, et saada mis tahes kaasasolevat keras mudelid raskustega või ilma, mis on treenitud imagenetis:

Funktsioon valmisarhitektuuride 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) {
  # Проверка аргументов
  checkmate::assert_string(name)
  checkmate::assert_integerish(input_shape, lower = 1, upper = 256, len = 3)
  checkmate::assert_count(num_classes)
  checkmate::assert_flag(color)
  checkmate::assert_flag(compile)

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

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

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

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

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

  return(model)
}

Ühe kanaliga kujutiste kasutamisel ei kasutata eelnevalt treenitud raskusi. Seda saab parandada: kasutades funktsiooni get_weights() hankige mudeli kaalud R-massiivide loendina, muutke selle loendi esimese elemendi dimensiooni (võtes ühe värvikanali või keskmistades kõik kolm) ja laadige kaalud funktsiooniga tagasi mudelisse set_weights(). Me ei lisanud seda funktsiooni kunagi, sest selles etapis oli juba selge, et värvipiltidega on produktiivsem töötada.

Enamiku katsete viisime läbi mobiilivõrgu versioonide 1 ja 2 ning resnet34 abil. Moodsamad arhitektuurid, nagu SE-ResNeXt, esinesid sellel võistlusel hästi. Kahjuks ei olnud meie käsutuses valmis teostusi ja me ei kirjutanud oma (aga kindlasti kirjutame).

5. Skriptide parameetrite määramine

Mugavuse huvides kujundati kogu treeningu alustamise kood ühe skriptina, mille parameetrid on parameetrid docpt järgmiselt:

doc <- '
Usage:
  train_nn.R --help
  train_nn.R --list-models
  train_nn.R [options]

Options:
  -h --help                   Show this message.
  -l --list-models            List available models.
  -m --model=<model>          Neural network model name [default: mobilenet_v2].
  -b --batch-size=<size>      Batch size [default: 32].
  -s --scale-factor=<ratio>   Scale factor [default: 0.5].
  -c --color                  Use color lines [default: FALSE].
  -d --db-dir=<path>          Path to database directory [default: Sys.getenv("db_dir")].
  -r --validate-ratio=<ratio> Validate sample ratio [default: 0.995].
  -n --n-gpu=<number>         Number of GPUs [default: 1].
'
args <- docopt::docopt(doc)

Pakk docpt esindab rakendamist http://docopt.org/ jaoks R. Tema abiga käivitatakse skriptid 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 käivitatav (käsk alustab mudeli treenimist resnet50 kolmevärvilistel piltidel mõõtmetega 128x128 pikslit peab andmebaas asuma kaustas /home/andrey/doodle_db). Loendisse saate lisada õppimiskiirust, optimeerija tüüpi ja muid kohandatavaid parameetreid. Väljaande koostamise käigus selgus, et arhitektuur mobilenet_v2 praegusest versioonist keras R kasutuses ei tohi R-paketis arvestamata muudatuste tõttu ootame neid parandama.

See lähenemine võimaldas oluliselt kiirendada katseid erinevate mudelitega võrreldes traditsioonilisema skriptide käivitamisega RStudios (võimaliku alternatiivina märgime paketti tfruns). Kuid peamine eelis on võimalus hõlpsalt hallata skriptide käivitamist Dockeris või lihtsalt serveris, ilma RStudiot installimata.

6. Skriptide dokkimine

Kasutasime Dockerit keskkonna kaasaskantavuse tagamiseks meeskonnaliikmete vahelise koolitusmudelite jaoks ja kiireks pilves juurutamiseks. Selle R-programmeerija jaoks suhteliselt ebatavalise tööriistaga saate tutvumist alustada see väljaannete seeria või videokursus.

Docker võimaldab teil nii nullist ise pilte luua kui ka teisi pilte enda loomise aluseks võtta. Olemasolevaid valikuid analüüsides jõudsime järeldusele, et NVIDIA, CUDA+cuDNN draiverite ja Pythoni 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 dockeri fail nägi välja selline:

dockerfile

FROM tensorflow/tensorflow:1.12.0-gpu

MAINTAINER Artem Klevtsov <[email protected]>

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

ARG LOCALE="en_US.UTF-8"
ARG APT_PKG="libopencv-dev r-base r-base-dev littler"
ARG R_BIN_PKG="futile.logger checkmate data.table rcpp rapidjsonr dbi keras jsonlite curl digest remotes"
ARG R_SRC_PKG="xtensor RcppThread docopt MonetDBLite"
ARG PY_PIP_PKG="keras"
ARG DIRS="/db /app /app/data /app/models /app/logs"

RUN source /etc/os-release && 
    echo "deb https://cloud.r-project.org/bin/linux/ubuntu ${UBUNTU_CODENAME}-cran35/" > /etc/apt/sources.list.d/cran35.list && 
    apt-key adv --keyserver keyserver.ubuntu.com --recv-keys E084DAB9 && 
    add-apt-repository -y ppa:marutter/c2d4u3.5 && 
    add-apt-repository -y ppa:timsc/opencv-3.4 && 
    apt-get update && 
    apt-get install -y locales && 
    locale-gen ${LOCALE} && 
    apt-get install -y --no-install-recommends ${APT_PKG} && 
    ln -s /usr/lib/R/site-library/littler/examples/install.r /usr/local/bin/install.r && 
    ln -s /usr/lib/R/site-library/littler/examples/install2.r /usr/local/bin/install2.r && 
    ln -s /usr/lib/R/site-library/littler/examples/installGithub.r /usr/local/bin/installGithub.r && 
    echo 'options(Ncpus = parallel::detectCores())' >> /etc/R/Rprofile.site && 
    echo 'options(repos = c(CRAN = "https://cloud.r-project.org"))' >> /etc/R/Rprofile.site && 
    apt-get install -y $(printf "r-cran-%s " ${R_BIN_PKG}) && 
    install.r ${R_SRC_PKG} && 
    pip install ${PY_PIP_PKG} && 
    mkdir -p ${DIRS} && 
    chmod 777 ${DIRS} && 
    rm -rf /tmp/downloaded_packages/ /tmp/*.rds && 
    rm -rf /var/lib/apt/lists/*

COPY utils /app/utils
COPY src /app/src
COPY tests /app/tests
COPY bin/*.R /app/

ENV DBDIR="/db"
ENV CUDA_HOME="/usr/local/cuda"
ENV PATH="/app:${PATH}"

WORKDIR /app

VOLUME /db
VOLUME /app

CMD bash

Mugavuse huvides pandi kasutatud paketid muutujateks; suurem osa kirjutatud skriptidest kopeeritakse monteerimise ajal konteineritesse. Muutsime ka käsukestaks /bin/bash sisu kasutamise hõlbustamiseks /etc/os-release. Nii välditi vajadust koodis OS-i versiooni täpsustada.

Lisaks kirjutati väike bash-skript, mis võimaldab käivitada konteineri erinevate käskudega. Näiteks võivad need olla skriptid närvivõrkude koolitamiseks, mis olid varem konteinerisse paigutatud, või käsukestad silumiseks ja konteineri toimimise jälgimiseks:

Skript konteineri käivitamiseks

#!/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äivitatakse ilma parameetriteta, kutsutakse skript konteinerisse train_nn.R vaikeväärtustega; kui esimene positsiooniargument on "bash", algab konteiner interaktiivselt käsukestaga. Kõigil muudel juhtudel asendatakse positsiooniargumentide väärtused: CMD="Rscript /app/train_nn.R $@".

Väärib märkimist, et lähteandmete ja andmebaasiga kataloogid, samuti koolitatud mudelite salvestamise kataloog paigaldatakse hostsüsteemist konteinerisse, mis võimaldab teil pääseda juurde skriptide tulemustele ilma tarbetute manipulatsioonideta.

7. Mitme GPU kasutamine Google Cloudis

Võistluse üheks tunnuseks olid väga mürarikkad andmed (vt tiitelpilti, laenatud @Leigh.plt lehelt ODS slack). Suured partiid aitavad selle vastu võidelda ja pärast 1 GPU-ga arvutiga tehtud katseid otsustasime õppida pilves mitmel GPU-l treeningmudeleid. Kasutatud GoogleCloudi (hea juhend põhitõdede kohta) saadaolevate konfiguratsioonide suure valiku, mõistlike hindade ja 300-dollarilise boonuse tõttu. Ahnusest tellisin 4xV100 eksemplari koos SSD ja tonni RAM-iga ja see oli suur viga. Selline masin sööb raha kiiresti ära, ilma tõestatud torujuhtmeta võite katsetada. Hariduslikel eesmärkidel on parem võtta K80. Kuid suur hulk RAM-i tuli kasuks - pilve-SSD ei avaldanud oma jõudlusega muljet, nii et andmebaas viidi üle dev/shm.

Suurimat huvi pakub koodifragment, mis vastutab mitme GPU kasutamise eest. Esiteks luuakse mudel protsessoris kontekstihalduri abil, täpselt 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 kompileerimata (see on oluline) mudel teatud arvule saadaolevatele GPU-dele ja alles pärast seda 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 tehnikat kõigi kihtide külmutamiseks, välja arvatud viimane, viimase kihi treenimiseks, kogu mudeli külmutamise vabastamiseks ja ümberõpetamiseks mitme GPU jaoks ei saanud rakendada.

Treeningut jälgiti kasutamata. tensorbordi, piirdudes logide salvestamise ja informatiivsete nimedega mudelite salvestamisega iga ajastu järel:

Tagasihelistamine

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

Mitmeid probleeme, millega oleme kokku puutunud, pole veel lahendatud:

  • в keras optimaalse õppimiskiiruse automaatseks otsimiseks pole valmis funktsiooni (analoog lr_finder raamatukogu kiire.ai); Teatud jõupingutustega on võimalik R-i teisaldada kolmandate osapoolte rakendusi, näiteks see;
  • eelmise punkti tulemusena ei olnud mitme GPU kasutamisel võimalik valida õiget treeningkiirust;
  • puuduvad kaasaegsed närvivõrgu arhitektuurid, eriti need, mis on eelkoolitatud imagenetis;
  • mitte ühe tsükli poliitika ja diskrimineerivad õppemäärad (koosinuslõõmutamine toimus meie palvel rakendatud, aitäh skeydan).

Mida kasulikku sellelt konkursilt õppisite:

  • Suhteliselt väikese võimsusega riistvaraga saate ilma valuta töötada korralike (mitu korda suuremate RAM-i) andmemahtudega. Kilekott andmed.tabel säästab mälu tänu tabelite kohapealsele muutmisele, mis väldib nende kopeerimist ja õige kasutamise korral näitavad selle võimalused peaaegu alati suurimat kiirust kõigi meile teadaolevate skriptikeelte tööriistade seas. Andmete andmebaasi salvestamine võimaldab teil paljudel juhtudel üldse mitte mõelda vajadusele suruda kogu andmestik RAM-i.
  • R-i aeglased funktsioonid saab paketti kasutades asendada kiirete funktsioonidega C++-s Rcpp. Kui lisaks kasutada RcppThread või RcppParallel, saame platvormideülesed mitme lõimega teostused, nii et pole vaja koodi R-tasemel paralleelstada.
  • pakett Rcpp saab kasutada ilma tõsiste C++ teadmisteta, nõutav miinimum on välja toodud siin. Päisefailid paljude lahedate C-teekide jaoks, näiteks xtensor saadaval CRAN-is, st moodustatakse infrastruktuur projektide elluviimiseks, mis integreerivad R-sse valmis suure jõudlusega C++ koodi. Täiendav mugavus on RStudio süntaksi esiletõstmine ja staatiline C++ koodianalüsaator.
  • docpt võimaldab käivitada parameetritega iseseisvaid skripte. Seda on mugav kasutada kaugserveris, sh. doki all. RStudios on ebamugav paljude tundide pikkuseid katseid läbi viia närvivõrkude treenimisega ning IDE installimine serverisse endasse ei ole alati õigustatud.
  • Docker tagab koodi teisaldatavuse ja tulemuste reprodutseeritavuse erinevate OS-i versioonide ja teekide arendajate vahel, samuti täitmise lihtsuse serverites. Saate käivitada kogu koolituskonveieri vaid ühe käsuga.
  • Google Cloud on eelarvesõbralik viis kalli riistvaraga katsetamiseks, kuid konfiguratsioonid tuleb hoolikalt valida.
  • Üksikute koodifragmentide kiiruse mõõtmine on väga kasulik, eriti R ja C++ kombineerimisel ning paketiga pink - ka väga lihtne.

Üldiselt oli see kogemus väga rahuldust pakkuv ja jätkame tööd mõne tõstatatud probleemi lahendamise nimel.

Allikas: www.habr.com

Lisa kommentaar