Quick Draw Doodle Recognition: kako se sprijateljiti s R, C++ i neuronskim mrežama

Quick Draw Doodle Recognition: kako se sprijateljiti s R, C++ i neuronskim mrežama

Hej Habr!

Prošle jeseni, Kaggle je bio domaćin natjecanja za klasifikaciju ručno nacrtanih slika, Quick Draw Doodle Recognition, u kojem je, između ostalih, sudjelovao i tim R-znanstvenika: Artem Klevtsova, Philippa Manager и Andrej Ogurtsov. Natjecanje nećemo detaljno opisivati, to je već učinjeno u nedavna objava.

Ovaj put nije išlo s uzgojem medalja, ali stečeno je mnogo dragocjenog iskustva, pa bih želio zajednici ispričati niz najzanimljivijih i najkorisnijih stvari na Kagleu iu svakodnevnom radu. Među temama o kojima se raspravljalo: težak život bez OpenCV, JSON parsiranje (ovi primjeri ispituju integraciju C++ koda u skripte ili pakete u R koristeći Rcpp), parametrizacija skripti i dokerizacija konačnog rješenja. Sav kod iz poruke u obliku pogodnom za izvršenje dostupan je u spremišta.

Sadržaj:

  1. Učinkovito učitajte podatke iz CSV-a u MonetDB
  2. Priprema serija
  3. Iteratori za istovar paketa iz baze podataka
  4. Odabir arhitekture modela
  5. Parametrizacija skripte
  6. Dokerizacija skripti
  7. Korištenje više grafičkih procesora na Google Cloudu
  8. Umjesto zaključka

1. Učinkovito učitajte podatke iz CSV-a u MonetDB bazu podataka

Podaci u ovom natjecanju nisu dati u obliku gotovih slika, već u obliku 340 CSV datoteka (jedna datoteka za svaku klasu) koje sadrže JSON s koordinatama točaka. Spajanjem ovih točaka linijama dobivamo konačnu sliku dimenzija 256x256 piksela. Također za svaki zapis postoji oznaka koja pokazuje je li sliku ispravno prepoznao klasifikator korišten u vrijeme prikupljanja skupa podataka, dvoslovni kod zemlje prebivališta autora slike, jedinstveni identifikator, vremenska oznaka i naziv klase koji odgovara nazivu datoteke. Pojednostavljena verzija originalnih podataka teži 7.4 GB u arhivi i približno 20 GB nakon raspakiranja, puni podaci nakon raspakiranja zauzimaju 240 GB. Organizatori su osigurali da obje verzije reproduciraju iste crteže, što znači da je puna verzija bila suvišna. U svakom slučaju, pohranjivanje 50 milijuna slika u grafičke datoteke ili u obliku nizova odmah je ocijenjeno neisplativim, te smo odlučili spojiti sve CSV datoteke iz arhive train_simplified.zip u bazu podataka s naknadnim generiranjem slika potrebne veličine "u hodu" za svaku seriju.

Kao DBMS odabran je dobro dokazan sustav MonetDB, naime implementacija za R kao paket MonetDBLite. Paket uključuje ugrađenu verziju poslužitelja baze podataka i omogućuje vam preuzimanje poslužitelja izravno iz R sesije i rad s njim tamo. Kreiranje baze podataka i povezivanje s njom vrši se jednom naredbom:

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

Morat ćemo izraditi dvije tablice: jednu za sve podatke, drugu za servisne informacije o preuzetim datotekama (korisno ako nešto pođe po zlu i proces se mora nastaviti nakon preuzimanja nekoliko datoteka):

Izrada tablica

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

Najbrži način učitavanja podataka u bazu bio je izravno kopiranje CSV datoteka pomoću SQL - naredbe COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTGdje tablename - naziv tablice i path - put do datoteke. Tijekom rada s arhivom otkriveno je da ugrađena implementacija unzip u R ne radi ispravno s nizom datoteka iz arhive, pa smo koristili sustav unzip (pomoću parametra getOption("unzip")).

Funkcija za pisanje u bazu podataka

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

Ako trebate transformirati tablicu prije nego što je upišete u bazu podataka, dovoljno je proslijediti argument preprocess funkcija koja će transformirati podatke.

Kod za sekvencijalno učitavanje podataka u bazu podataka:

Upisivanje podataka u bazu podataka

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

Vrijeme učitavanja podataka može varirati ovisno o karakteristikama brzine korištenog pogona. U našem slučaju, čitanje i pisanje unutar jednog SSD-a ili s flash pogona (izvorne datoteke) na SSD (DB) traje manje od 10 minuta.

Potrebno je još nekoliko sekundi za stvaranje stupca s oznakom klase cijelog broja i stupca indeksa (ORDERED INDEX) s brojevima redaka po kojima će se promatranja uzorkovati prilikom kreiranja serija:

Stvaranje dodatnih stupaca i indeksa

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

Da bismo riješili problem kreiranja paketa u hodu, morali smo postići maksimalnu brzinu izdvajanja slučajnih redaka iz tablice doodles. Za to smo koristili 3 trika. Prvi je bio smanjiti dimenzionalnost tipa koji pohranjuje ID opažanja. U izvornom skupu podataka, vrsta potrebna za pohranu ID-a je bigint, ali broj opažanja omogućuje uklapanje njihovih identifikatora, jednakih rednom broju, u tip int. Pretraga je u ovom slučaju mnogo brža. Drugi trik bio je koristiti ORDERED INDEX — do te smo odluke došli empirijski, prošavši sve raspoloživo opcije. Treći je bio korištenje parametriziranih upita. Bit metode je izvršiti naredbu jednom PREPARE uz naknadnu upotrebu pripremljenog izraza pri kreiranju hrpe upita iste vrste, ali zapravo postoji prednost u usporedbi s jednostavnim SELECT pokazalo se da je unutar raspona statističke pogreške.

Proces učitavanja podataka ne troši više od 450 MB RAM-a. Odnosno, opisani pristup omogućuje vam premještanje skupova podataka koji teže desetke gigabajta na gotovo bilo kojem jeftinom hardveru, uključujući neke uređaje s jednom pločom, što je prilično cool.

Sve što preostaje je izmjeriti brzinu dohvaćanja (nasumičnih) podataka i procijeniti skaliranje pri uzorkovanju serija različitih veličina:

Referentna vrijednost baze podataka

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: kako se sprijateljiti s R, C++ i neuronskim mrežama

2. Priprema serija

Cijeli proces pripreme serije sastoji se od sljedećih koraka:

  1. Raščlanjivanje nekoliko JSON-ova koji sadrže vektore nizova s ​​koordinatama točaka.
  2. Crtanje linija u boji na temelju koordinata točaka na slici potrebne veličine (na primjer, 256×256 ili 128×128).
  3. Pretvaranje dobivenih slika u tenzor.

U sklopu natjecanja među Python kernelima, problem je riješen prvenstveno korištenjem OpenCV. Jedan od najjednostavnijih i najočitijih analoga u R-u izgledao bi ovako:

Implementacija pretvorbe JSON u tensor u R

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

Crtanje se izvodi pomoću standardnih R alata i sprema u privremeni PNG pohranjen u RAM-u (na Linuxu se privremeni R direktoriji nalaze u direktoriju /tmp, montiran u RAM). Ta se datoteka zatim čita kao trodimenzionalni niz s brojevima u rasponu od 0 do 1. Ovo je važno jer bi se konvencionalniji BMP čitao u neobrađeni niz s heksadecimalnim kodovima boja.

Testirajmo rezultat:

zip_file <- file.path("data", "train_simplified.zip")
csv_file <- "cat.csv"
unzip(zip_file, files = csv_file, exdir = tempdir(), 
      junkpaths = TRUE, unzip = getOption("unzip"))
tmp_data <- data.table::fread(file.path(tempdir(), csv_file), sep = ",", 
                              select = "drawing", nrows = 10000)
arr <- r_process_json_str(tmp_data[4, drawing])
dim(arr)
# [1] 256 256   3
plot(magick::image_read(arr))

Quick Draw Doodle Recognition: kako se sprijateljiti s R, C++ i neuronskim mrežama

Sama serija će se formirati na sljedeći način:

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

Ova nam se implementacija činila neoptimalnom, jer formiranje velikih serija traje nepristojno dugo, te smo odlučili iskoristiti iskustvo naših kolega korištenjem moćne knjižnice OpenCV. U to vrijeme nije postojao gotov paket za R (sada ga nema), pa je minimalna implementacija potrebne funkcionalnosti napisana u C++ s integracijom u R kod pomoću Rcpp.

Za rješavanje problema korišteni su sljedeći paketi i biblioteke:

  1. OpenCV za rad sa slikama i crtanje linija. Korištene predinstalirane sistemske biblioteke i datoteke zaglavlja, kao i dinamičko povezivanje.

  2. xtenzor za rad s višedimenzionalnim nizovima i tenzorima. Koristili smo datoteke zaglavlja uključene u istoimeni R paket. Knjižnica vam omogućuje rad s višedimenzionalnim nizovima, kako u redovima tako iu stupcima.

  3. ndjson za raščlanjivanje JSON-a. Ova biblioteka se koristi u xtenzor automatski ako je prisutan u projektu.

  4. RcppThread za organiziranje višenitne obrade vektora iz JSON-a. Korištene su datoteke zaglavlja koje pruža ovaj paket. Od popularnijih RcppParalelno Paket, između ostalog, ima ugrađen mehanizam za prekid petlje.

Valja napomenuti da je xtenzor pokazao se božjim darom: osim činjenice da ima opsežnu funkcionalnost i visoke performanse, njegovi programeri pokazali su se prilično osjetljivima i odgovarali su na pitanja brzo i detaljno. Uz njihovu pomoć bilo je moguće implementirati transformacije OpenCV matrica u xtensor tenzore, kao i način kombiniranja 3-dimenzionalnih tenzora slike u 4-dimenzionalni tenzor ispravne dimenzije (samu seriju).

Materijali za učenje Rcpp, xtensor i RcppThread

https://thecoatlessprofessor.com/programming/unofficial-rcpp-api-documentation

https://docs.opencv.org/4.0.1/d7/dbd/group__imgproc.html

https://xtensor.readthedocs.io/en/latest/

https://xtensor.readthedocs.io/en/latest/file_loading.html#loading-json-data-into-xtensor

https://cran.r-project.org/web/packages/RcppThread/vignettes/RcppThread-vignette.pdf

Za kompajliranje datoteka koje koriste sistemske datoteke i dinamičko povezivanje s bibliotekama instaliranim na sustavu koristili smo mehanizam dodatka implementiran u paketu Rcpp. Za automatsko pronalaženje staza i oznaka upotrijebili smo popularni uslužni program za Linux pkg-config.

Implementacija dodatka Rcpp za korištenje biblioteke OpenCV

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

Kao rezultat rada dodatka, sljedeće vrijednosti bit će zamijenjene tijekom procesa kompilacije:

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"

Implementacijski kod za raščlanjivanje JSON-a i generiranje serije za prijenos u model naveden je ispod spojlera. Prvo dodajte direktorij lokalnog projekta za traženje datoteka zaglavlja (potrebno za ndjson):

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

Implementacija pretvorbe JSON u tenzor u C++

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

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

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

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

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

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

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

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

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

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

Ovaj kod treba staviti u datoteku src/cv_xt.cpp i prevesti s naredbom Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); također potrebna za rad nlohmann/json.hpp od spremište. Kod je podijeljen u nekoliko funkcija:

  • to_xt — šablonska funkcija za transformaciju matrice slike (cv::Mat) na tenzor xt::xtensor;

  • parse_json — funkcija analizira JSON niz, izdvaja koordinate točaka, pakirajući ih u vektor;

  • ocv_draw_lines — iz dobivenog vektora točaka crta višebojne linije;

  • process — kombinira gore navedene funkcije i također dodaje mogućnost skaliranja rezultirajuće slike;

  • cpp_process_json_str - omotač preko funkcije process, koji izvozi rezultat u R-objekt (višedimenzionalni niz);

  • cpp_process_json_vector - omotač preko funkcije cpp_process_json_str, koji vam omogućuje obradu vektora niza u višenitnom načinu rada.

Za crtanje raznobojnih linija korišten je HSV model boja, nakon čega je uslijedila konverzija u RGB. Testirajmo rezultat:

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

Quick Draw Doodle Recognition: kako se sprijateljiti s R, C++ i neuronskim mrežama
Usporedba brzine implementacija u R i C++

res_bench <- bench::mark(
  r_process_json_str(tmp_data[4, drawing], scale = 0.5),
  cpp_process_json_str(tmp_data[4, drawing], scale = 0.5),
  check = FALSE,
  min_iterations = 100
)
# Параметры бенчмарка
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: kako se sprijateljiti s R, C++ i neuronskim mrežama

Kao što vidite, pokazalo se da je povećanje brzine vrlo značajno i nije moguće sustići C++ kod paraleliziranjem R koda.

3. Iteratori za istovar paketa iz baze podataka

R ima zasluženu reputaciju za obradu podataka koji stanu u RAM, dok je Python više karakteriziran iterativnom obradom podataka, što vam omogućuje jednostavnu i prirodnu implementaciju proračuna izvan jezgre (izračuni pomoću vanjske memorije). Klasičan i za nas relevantan primjer u kontekstu opisanog problema su duboke neuronske mreže trenirane metodom gradijentnog spuštanja s aproksimacijom gradijenta u svakom koraku pomoću malog dijela promatranja, odnosno mini-serije.

Okviri dubokog učenja napisani u Pythonu imaju posebne klase koje implementiraju iteratore na temelju podataka: tablice, slike u mapama, binarni formati itd. Možete koristiti gotove opcije ili napisati vlastite za određene zadatke. U R-u možemo iskoristiti sve značajke Python biblioteke keras sa svojim različitim pozadinama koristeći istoimeni paket, koji zauzvrat radi na vrhu paketa mrežast. Ovo posljednje zaslužuje poseban dugi članak; ne samo da vam omogućuje pokretanje Python koda iz R-a, već vam također omogućuje prijenos objekata između R i Python sesija, automatski izvodeći sve potrebne pretvorbe tipa.

Riješili smo se potrebe za pohranjivanjem svih podataka u RAM pomoću MonetDBLite-a, sav posao "neuralne mreže" obavljat će izvorni kod u Pythonu, samo moramo napisati iterator preko podataka, jer ništa nije spremno za takvu situaciju u R-u ili Pythonu. U biti postoje samo dva zahtjeva za njega: mora vraćati serije u beskonačnoj petlji i spremati svoje stanje između iteracija (potonje u R implementirano je na najjednostavniji način korištenjem zatvaranja). Prethodno je bilo potrebno eksplicitno pretvoriti R nizove u numpy nizove unutar iteratora, ali trenutna verzija paketa keras radi to sama.

Pokazalo se da je iterator za podatke o obuci i provjeri valjanosti sljedeći:

Iterator za obuku i validaciju podataka

train_generator <- function(db_connection = con,
                            samples_index,
                            num_classes = 340,
                            batch_size = 32,
                            scale = 1,
                            color = FALSE,
                            imagenet_preproc = FALSE) {
  # Проверка аргументов
  checkmate::assert_class(con, "DBIConnection")
  checkmate::assert_integerish(samples_index)
  checkmate::assert_count(num_classes)
  checkmate::assert_count(batch_size)
  checkmate::assert_number(scale, lower = 0.001, upper = 5)
  checkmate::assert_flag(color)
  checkmate::assert_flag(imagenet_preproc)

  # Перемешиваем, чтобы брать и удалять использованные индексы батчей по порядку
  dt <- data.table::data.table(id = sample(samples_index))
  # Проставляем номера батчей
  dt[, batch := (.I - 1L) %/% batch_size + 1L]
  # Оставляем только полные батчи и индексируем
  dt <- dt[, if (.N == batch_size) .SD, keyby = batch]
  # Устанавливаем счётчик
  i <- 1
  # Количество батчей
  max_i <- dt[, max(batch)]

  # Подготовка выражения для выгрузки
  sql <- sprintf(
    "PREPARE SELECT drawing, label_int FROM doodles WHERE id IN (%s)",
    paste(rep("?", batch_size), collapse = ",")
  )
  res <- DBI::dbSendQuery(con, sql)

  # Аналог keras::to_categorical
  to_categorical <- function(x, num) {
    n <- length(x)
    m <- numeric(n * num)
    m[x * n + seq_len(n)] <- 1
    dim(m) <- c(n, num)
    return(m)
  }

  # Замыкание
  function() {
    # Начинаем новую эпоху
    if (i > max_i) {
      dt[, id := sample(id)]
      data.table::setkey(dt, batch)
      # Сбрасываем счётчик
      i <<- 1
      max_i <<- dt[, max(batch)]
    }

    # ID для выгрузки данных
    batch_ind <- dt[batch == i, id]
    # Выгрузка данных
    batch <- DBI::dbFetch(DBI::dbBind(res, as.list(batch_ind)), n = -1)

    # Увеличиваем счётчик
    i <<- i + 1

    # Парсинг JSON и подготовка массива
    batch_x <- cpp_process_json_vector(batch$drawing, scale = scale, color = color)
    if (imagenet_preproc) {
      # Шкалирование c интервала [0, 1] на интервал [-1, 1]
      batch_x <- (batch_x - 0.5) * 2
    }

    batch_y <- to_categorical(batch$label_int, num_classes)
    result <- list(batch_x, batch_y)
    return(result)
  }
}

Funkcija uzima kao ulaz varijablu s vezom na bazu podataka, broj korištenih linija, broj klasa, veličinu serije, razmjer (scale = 1 odgovara renderiranju slika od 256x256 piksela, scale = 0.5 — 128x128 piksela), indikator boja (color = FALSE navodi iscrtavanje u sivim tonovima kada se koristi color = TRUE svaki se potez iscrtava u novoj boji) i indikator pretprocesiranja za mreže koje su unaprijed obučene na imagenetu. Potonji je potreban kako bi se skalirale vrijednosti piksela od intervala [0, 1] do intervala [-1, 1], koji je korišten prilikom obuke isporučenog keras modeli.

Vanjska funkcija sadrži provjeru tipa argumenata, tablicu data.table s nasumično izmiješanim brojevima redaka od samples_index i brojevi serija, brojač i maksimalni broj serija, kao i SQL izraz za istovar podataka iz baze podataka. Osim toga, definirali smo brzi analog funkcije unutar keras::to_categorical(). Koristili smo gotovo sve podatke za obuku, ostavljajući pola posto za provjeru valjanosti, tako da je veličina epohe bila ograničena parametrom steps_per_epoch kad se prozove keras::fit_generator(), i stanje if (i > max_i) radio samo za iterator provjere valjanosti.

U internoj funkciji dohvaćaju se indeksi redaka za sljedeću seriju, zapisi se istovaruju iz baze podataka s povećanjem brojača serije, JSON parsiranje (funkcija cpp_process_json_vector(), napisano u C++) i stvaranje nizova koji odgovaraju slikama. Zatim se kreiraju jednokratni vektori s oznakama klasa, nizovi s vrijednostima piksela i oznakama se kombiniraju u popis, što je povratna vrijednost. Za ubrzanje rada poslužili smo se izradom indeksa u tablicama data.table i izmjena putem linka - bez ovih “čipova” paketa podaci.tabela Prilično je teško zamisliti učinkovit rad s bilo kojom značajnom količinom podataka u R-u.

Rezultati mjerenja brzine na prijenosnom računalu Core i5 su sljedeći:

Referentna vrijednost iteratora

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: kako se sprijateljiti s R, C++ i neuronskim mrežama

Ako imate dovoljnu količinu RAM-a, možete ozbiljno ubrzati rad baze podataka prijenosom u ovaj isti RAM (32 GB je dovoljno za naš zadatak). U Linuxu je particija postavljena prema zadanim postavkama /dev/shm, zauzimajući do polovice kapaciteta RAM-a. Uređivanjem možete istaknuti više /etc/fstabda dobije rekord like tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Obavezno ponovno pokrenite sustav i provjerite rezultat pokretanjem naredbe df -h.

Iterator za testne podatke izgleda puno jednostavnije, budući da testni skup podataka u potpunosti stane u RAM:

Iterator za testne podatke

test_generator <- function(dt,
                           batch_size = 32,
                           scale = 1,
                           color = FALSE,
                           imagenet_preproc = FALSE) {

  # Проверка аргументов
  checkmate::assert_data_table(dt)
  checkmate::assert_count(batch_size)
  checkmate::assert_number(scale, lower = 0.001, upper = 5)
  checkmate::assert_flag(color)
  checkmate::assert_flag(imagenet_preproc)

  # Проставляем номера батчей
  dt[, batch := (.I - 1L) %/% batch_size + 1L]
  data.table::setkey(dt, batch)
  i <- 1
  max_i <- dt[, max(batch)]

  # Замыкание
  function() {
    batch_x <- cpp_process_json_vector(dt[batch == i, drawing], 
                                       scale = scale, color = color)
    if (imagenet_preproc) {
      # Шкалирование c интервала [0, 1] на интервал [-1, 1]
      batch_x <- (batch_x - 0.5) * 2
    }
    result <- list(batch_x)
    i <<- i + 1
    return(result)
  }
}

4. Odabir arhitekture modela

Prva korištena arhitektura bila je mobilenet v1, čije se značajke raspravlja u ovo poruka. Standardno je uključen keras i, u skladu s tim, dostupan je u istoimenom paketu za R. Ali kada se pokušao koristiti s jednokanalnim slikama, ispala je čudna stvar: ulazni tenzor uvijek mora imati dimenziju (batch, height, width, 3), odnosno broj kanala se ne može mijenjati. Ne postoji takvo ograničenje u Pythonu, pa smo požurili i napisali vlastitu implementaciju ove arhitekture, slijedeći izvorni članak (bez ispadanja koje je u keras verziji):

Mobilenet v1 arhitektura

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

Nedostaci ovog pristupa su očiti. Želim testirati puno modela, ali naprotiv, ne želim ručno prepisivati ​​svaku arhitekturu. Također smo bili lišeni mogućnosti da koristimo težine modela unaprijed obučenih na imagenetu. Kao i obično, pomoglo je proučavanje dokumentacije. Funkcija get_config() omogućuje vam da dobijete opis modela u obliku pogodnom za uređivanje (base_model_conf$layers - regularna R lista), i funkcija from_config() izvodi obrnutu konverziju u objekt modela:

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)

Sada nije teško napisati univerzalnu funkciju za dobivanje bilo kojeg od isporučenog keras modeli sa ili bez utega trenirani na imagenetu:

Funkcija za učitavanje gotovih arhitektura

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

Pri korištenju jednokanalnih slika ne koriste se unaprijed uvježbani utezi. Ovo se može popraviti: pomoću funkcije get_weights() dobiti težine modela u obliku popisa R nizova, promijeniti dimenziju prvog elementa ovog popisa (uzimajući jedan kanal boje ili usrednjavajući sva tri), a zatim učitati težine natrag u model pomoću funkcije set_weights(). Nikada nismo dodali ovu funkcionalnost, jer je u ovoj fazi već bilo jasno da je produktivnije raditi sa slikama u boji.

Proveli smo većinu eksperimenata koristeći mobilenet verzije 1 i 2, kao i resnet34. Modernije arhitekture kao što je SE-ResNeXt dobro su se pokazale na ovom natjecanju. Nažalost, nismo imali na raspolaganju gotove implementacije, a nismo napisali vlastitu (ali ćemo je svakako napisati).

5. Parametriranje skripti

Radi praktičnosti, sav kod za početak obuke dizajniran je kao jedna skripta, parametrizirana korištenjem dokpt kako slijedi:

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)

paket dokpt predstavlja implementaciju http://docopt.org/ za R. Uz njegovu pomoć pokreću se skripte jednostavnim naredbama poput Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db ili ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, ako datoteka train_nn.R je izvršna (ova naredba će započeti obuku modela resnet50 na trobojnim slikama dimenzija 128x128 piksela baza se mora nalaziti u mapi /home/andrey/doodle_db). Na popis možete dodati brzinu učenja, vrstu optimizatora i bilo koje druge prilagodljive parametre. U procesu pripreme publikacije pokazalo se da je arhitektura mobilenet_v2 od trenutne verzije keras u R upotrebi ne mogu zbog neuvaženih izmjena u R paketu, čekamo da poprave.

Ovaj je pristup omogućio znatno ubrzanje eksperimenata s različitim modelima u usporedbi s tradicionalnijim pokretanjem skripti u RStudiu (paket navodimo kao moguću alternativu tfruns). Ali glavna prednost je mogućnost jednostavnog upravljanja pokretanjem skripti u Dockeru ili jednostavno na poslužitelju, bez instaliranja RStudio za to.

6. Dokerizacija skripti

Koristili smo Docker kako bismo osigurali prenosivost okruženja za obuku modela između članova tima i za brzu implementaciju u oblaku. Možete se početi upoznavati s ovim alatom, koji je relativno neobičan za R programera, s ovo serija publikacija ili video tečaj.

Docker vam omogućuje stvaranje vlastitih slika od nule i korištenje drugih slika kao temelja za stvaranje vlastitih. Analizirajući dostupne opcije, došli smo do zaključka da je instalacija NVIDIA, CUDA+cuDNN drajvera i Python biblioteka prilično obiman dio slike, te smo odlučili uzeti službenu sliku kao osnovu tensorflow/tensorflow:1.12.0-gpu, dodajući tamo potrebne R pakete.

Konačna docker datoteka izgledala je ovako:

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

Radi praktičnosti, korišteni paketi su stavljeni u varijable; većina napisanih skripti kopira se unutar spremnika tijekom sklapanja. Također smo promijenili naredbenu ljusku u /bin/bash za jednostavno korištenje sadržaja /etc/os-release. Time je izbjegnuta potreba za navođenjem verzije OS-a u kodu.

Dodatno, napisana je mala bash skripta koja vam omogućuje pokretanje spremnika s različitim naredbama. Na primjer, to mogu biti skripte za obuku neuronskih mreža koje su prethodno bile postavljene unutar spremnika ili naredbena ljuska za otklanjanje pogrešaka i praćenje rada spremnika:

Skripta za pokretanje spremnika

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

Ako se ova bash skripta pokrene bez parametara, skripta će biti pozvana unutar spremnika train_nn.R sa zadanim vrijednostima; ako je prvi pozicijski argument "bash", spremnik će se pokrenuti interaktivno s naredbenom ljuskom. U svim ostalim slučajevima, vrijednosti pozicijskih argumenata se zamjenjuju: CMD="Rscript /app/train_nn.R $@".

Vrijedno je napomenuti da su direktoriji s izvornim podacima i bazom podataka, kao i direktorij za spremanje obučenih modela, montirani unutar spremnika iz glavnog sustava, što vam omogućuje pristup rezultatima skripti bez nepotrebnih manipulacija.

7. Korištenje više grafičkih procesora na Google Cloudu

Jedna od značajki natjecanja bili su vrlo bučni podaci (pogledajte naslovnu sliku, posuđenu s @Leigh.plt s ODS slacka). Velike serije pomažu u borbi protiv toga, a nakon eksperimenata na osobnom računalu s 1 GPU-om, odlučili smo svladati modele obuke na nekoliko GPU-ova u oblaku. Koristio GoogleCloud (dobar vodič kroz osnove) zbog velikog izbora dostupnih konfiguracija, razumnih cijena i bonusa od 300 USD. Iz pohlepe sam naručio 4xV100 instancu sa SSD-om i tonom RAM-a, a to je bila velika greška. Takav stroj brzo pojede novac; možete bankrotirati eksperimentirajući bez provjerenog cjevovoda. U obrazovne svrhe, bolje je uzeti K80. No velika količina RAM-a dobro je došla - cloud SSD nije impresionirao svojim performansama, pa je baza podataka prebačena na dev/shm.

Od najvećeg interesa je fragment koda odgovoran za korištenje više GPU-ova. Prvo, model se stvara na CPU-u pomoću upravitelja konteksta, baš kao u Pythonu:

with(tensorflow::tf$device("/cpu:0"), {
  model_cpu <- get_model(
    name = model_name,
    input_shape = input_shape,
    weights = weights,
    metrics =(top_3_categorical_accuracy,
    compile = FALSE
  )
})

Zatim se neprevedeni (ovo je važno) model kopira na određeni broj dostupnih GPU-ova, a tek nakon toga se kompajlira:

model <- keras::multi_gpu_model(model_cpu, gpus = n_gpu)
keras::compile(
  object = model,
  optimizer = keras::optimizer_adam(lr = 0.0004),
  loss = "categorical_crossentropy",
  metrics = c(top_3_categorical_accuracy)
)

Klasična tehnika zamrzavanja svih slojeva osim posljednjeg, uvježbavanje zadnjeg sloja, odmrzavanje i ponovno uvježbavanje cijelog modela za nekoliko GPU-a nije se mogla implementirati.

Trening je praćen bez upotrebe. tenzorska ploča, ograničavajući se na snimanje dnevnika i spremanje modela s informativnim imenima nakon svake epohe:

Povratni pozivi

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

Brojni problemi s kojima smo se susreli još nisu prevladani:

  • в keras ne postoji gotova funkcija za automatsko traženje optimalne brzine učenja (analogno lr_finder u knjižnici brzo.ai); Uz određeni napor, moguće je prenijeti implementacije trećih strana na R, na primjer, ovo;
  • kao posljedica prethodne točke, nije bilo moguće odabrati ispravnu brzinu treninga pri korištenju nekoliko GPU-ova;
  • postoji nedostatak modernih arhitektura neuronskih mreža, posebno onih koje su unaprijed obučene na imagenetu;
  • politika bez jednog ciklusa i diskriminativne stope učenja (kosinusno žarenje je bilo na naš zahtjev implementiran, Hvala skeydan).

Što se korisno naučilo iz ovog natjecanja:

  • Na hardveru relativno male snage možete raditi s pristojnim količinama podataka (višestruko većim od RAM-a) bez muke. Plastična vrećica podaci.tabela štedi memoriju zbog izmjene tablica na licu mjesta, čime se izbjegava njihovo kopiranje, a kada se pravilno koristi, njegove mogućnosti gotovo uvijek pokazuju najveću brzinu među svim nama poznatim alatima za skriptne jezike. Spremanje podataka u bazu podataka omogućuje vam, u mnogim slučajevima, da uopće ne razmišljate o potrebi zbijanja cijelog skupa podataka u RAM.
  • Spore funkcije u R mogu se zamijeniti brzima u C++ pomoću paketa Rcpp. Ako pored upotrebe RcppThread ili RcppParalelno, dobivamo višenitnu implementaciju na više platformi, tako da nema potrebe za paralelizacijom koda na R razini.
  • Paket Rcpp može se koristiti bez ozbiljnog poznavanja C++, potreban minimum je naveden здесь. Datoteke zaglavlja za brojne cool C-biblioteke poput xtenzor dostupan na CRAN-u, odnosno formira se infrastruktura za implementaciju projekata koji integriraju gotov C++ kod visokih performansi u R. Dodatna pogodnost je isticanje sintakse i statički analizator C++ koda u RStudiu.
  • dokpt omogućuje vam pokretanje samostalnih skripti s parametrima. Ovo je zgodno za korištenje na udaljenom poslužitelju, uklj. ispod dokera. U RStudiou je nezgodno provoditi mnogo sati eksperimenata s obukom neuronskih mreža, a instaliranje IDE-a na sam poslužitelj nije uvijek opravdano.
  • Docker osigurava prenosivost koda i ponovljivost rezultata između programera s različitim verzijama OS-a i biblioteka, kao i jednostavnost izvršavanja na poslužiteljima. Možete pokrenuti cijeli cjevovod obuke samo jednom naredbom.
  • Google Cloud jeftin je način eksperimentiranja na skupom hardveru, ali trebate pažljivo odabrati konfiguracije.
  • Mjerenje brzine pojedinačnih fragmenata koda je vrlo korisno, posebno kada se kombiniraju R i C++, te s paketom klupa - također vrlo lako.

Općenito, ovo je iskustvo bilo vrlo korisno i nastavljamo raditi na rješavanju nekih od postavljenih problema.

Izvor: www.habr.com

Dodajte komentar